@zerotal/core 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +79 -0
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/package.json +72 -0
- package/src/application/Application.ts +1671 -0
- package/src/application/BootDoctor.ts +108 -0
- package/src/application/DevErrorPage.ts +567 -0
- package/src/application/ExceptionHandler.ts +183 -0
- package/src/application/currentApp.ts +73 -0
- package/src/assets/assets.ts +79 -0
- package/src/assets/index.ts +16 -0
- package/src/auth/AuthenticatedUser.ts +18 -0
- package/src/build/PackageLinter.ts +146 -0
- package/src/build/PackageScaffold.ts +127 -0
- package/src/build/codemod.ts +64 -0
- package/src/build/index.ts +12 -0
- package/src/command/Command.ts +254 -0
- package/src/command/CommandRunner.ts +593 -0
- package/src/command/OutputWriter.ts +61 -0
- package/src/command/builtin/CompileCommand.ts +46 -0
- package/src/command/builtin/CssBuildCommand.ts +71 -0
- package/src/command/builtin/KeyGenerateCommand.ts +58 -0
- package/src/command/builtin/LintPackagesCommand.ts +72 -0
- package/src/command/builtin/MakeCommandCommand.ts +85 -0
- package/src/command/builtin/MakeControllerCommand.ts +95 -0
- package/src/command/builtin/MakeEventCommand.ts +85 -0
- package/src/command/builtin/MakeJobCommand.ts +53 -0
- package/src/command/builtin/MakeListenerCommand.ts +35 -0
- package/src/command/builtin/MakeMiddlewareCommand.ts +63 -0
- package/src/command/builtin/MakeNotificationCommand.ts +48 -0
- package/src/command/builtin/MakeObserverCommand.ts +78 -0
- package/src/command/builtin/MakePackageCommand.ts +45 -0
- package/src/command/builtin/MakePolicyCommand.ts +66 -0
- package/src/command/builtin/MakeProviderCommand.ts +75 -0
- package/src/command/builtin/MakeRequestCommand.ts +47 -0
- package/src/command/builtin/MakeResourceCommand.ts +61 -0
- package/src/command/builtin/MakeTestCommand.ts +120 -0
- package/src/command/builtin/ReloadCommand.ts +52 -0
- package/src/command/builtin/ReplCommand.ts +174 -0
- package/src/command/builtin/RouteListCommand.ts +188 -0
- package/src/command/builtin/ServeCommand.ts +321 -0
- package/src/command/builtin/StartCommand.ts +3 -0
- package/src/command/builtin/StatusCommand.ts +71 -0
- package/src/command/builtin/TestCommand.ts +172 -0
- package/src/command/builtin/WorkerCommand.ts +27 -0
- package/src/command/builtin/index.ts +53 -0
- package/src/command/scaffold/worker.ts.txt +12 -0
- package/src/command/scaffold/zerotal.ts.txt +26 -0
- package/src/command/startZerotal.ts +55 -0
- package/src/config/AppConfig.ts +253 -0
- package/src/config/ConfigLoader.ts +117 -0
- package/src/config/ConfigManager.ts +169 -0
- package/src/config/index.ts +46 -0
- package/src/config/registry.ts +59 -0
- package/src/config/validation.ts +117 -0
- package/src/container/Container.ts +606 -0
- package/src/container/ContextualBindingBuilder.ts +57 -0
- package/src/container/ScopedResolver.ts +117 -0
- package/src/container/index.ts +32 -0
- package/src/container/inject.ts +55 -0
- package/src/container/types.ts +71 -0
- package/src/context/RequestContext.ts +91 -0
- package/src/contracts/auth.ts +24 -0
- package/src/contracts/index.ts +23 -0
- package/src/contracts/session.ts +70 -0
- package/src/contracts/transaction.ts +26 -0
- package/src/conventions/ConventionLoader.ts +128 -0
- package/src/conventions/builtinConcerns.ts +131 -0
- package/src/crypt/Crypt.ts +141 -0
- package/src/crypt/URLSigner.ts +96 -0
- package/src/datetime/Carbon.ts +1396 -0
- package/src/datetime/CarbonInterval.ts +421 -0
- package/src/datetime/clock.ts +28 -0
- package/src/datetime/index.ts +23 -0
- package/src/datetime/temporal-shim.ts +1 -0
- package/src/dev/BuildOutput.ts +131 -0
- package/src/dev/CssPlugins.ts +184 -0
- package/src/dev/DevBuildHook.ts +74 -0
- package/src/dev/DevOrchestrator.ts +213 -0
- package/src/dev/DevReloadMiddleware.ts +101 -0
- package/src/dev/DevReloadServer.ts +85 -0
- package/src/dev/DevWsServer.ts +45 -0
- package/src/dev/index.ts +19 -0
- package/src/dev/reloadClient.ts +39 -0
- package/src/env/Def.ts +232 -0
- package/src/env/EnvSchema.ts +105 -0
- package/src/env/index.ts +34 -0
- package/src/env/t.ts +128 -0
- package/src/errors/ConfigError.ts +12 -0
- package/src/errors/ContainerErrors.ts +143 -0
- package/src/errors/HttpError.ts +127 -0
- package/src/errors/ValidationError.ts +19 -0
- package/src/errors/ZerotalError.ts +25 -0
- package/src/errors/index.ts +46 -0
- package/src/events/CallQueuedListener.ts +66 -0
- package/src/events/Emitter.ts +280 -0
- package/src/events/EventFake.ts +160 -0
- package/src/events/FrameworkEvents.ts +252 -0
- package/src/facade/Facade.ts +101 -0
- package/src/facade/facades/App.ts +155 -0
- package/src/facade/facades/Artisan.ts +63 -0
- package/src/facade/facades/Config.ts +21 -0
- package/src/facade/facades/Events.ts +19 -0
- package/src/facade/facades/index.ts +28 -0
- package/src/global.d.ts +9 -0
- package/src/hash/Hash.ts +60 -0
- package/src/health/Health.ts +221 -0
- package/src/health/index.ts +27 -0
- package/src/helpers/Collection.ts +435 -0
- package/src/helpers/config.ts +59 -0
- package/src/helpers/fluent.ts +52 -0
- package/src/helpers/html.ts +11 -0
- package/src/helpers/index.ts +266 -0
- package/src/helpers/make.ts +35 -0
- package/src/helpers/markdown.ts +73 -0
- package/src/helpers/pageElements.ts +27 -0
- package/src/helpers/request.ts +62 -0
- package/src/helpers/response.ts +411 -0
- package/src/helpers/str.ts +208 -0
- package/src/http/Http.ts +298 -0
- package/src/http/HttpClient.ts +289 -0
- package/src/http/Resource.ts +171 -0
- package/src/http/UploadedFile.ts +204 -0
- package/src/http/Uri.ts +490 -0
- package/src/http/index.ts +46 -0
- package/src/http/negotiate.ts +213 -0
- package/src/http/originGuard.ts +76 -0
- package/src/http/sniffContentType.ts +105 -0
- package/src/http/url.ts +204 -0
- package/src/http/withHeaders.ts +24 -0
- package/src/index.ts +250 -0
- package/src/lock/LockManager.ts +228 -0
- package/src/lock/config.ts +49 -0
- package/src/lock/drivers/LockDriver.ts +32 -0
- package/src/lock/drivers/MemoryLockDriver.ts +52 -0
- package/src/lock/drivers/RedisLockDriver.ts +58 -0
- package/src/lock/drivers/SqliteLockDriver.ts +85 -0
- package/src/lock/errors.ts +20 -0
- package/src/lock/facades/Lock.ts +114 -0
- package/src/lock/index.ts +53 -0
- package/src/logger/Log.ts +35 -0
- package/src/logger/LogManager.ts +430 -0
- package/src/logger/LoggerMiddleware.ts +125 -0
- package/src/logger/channels/ConsoleChannel.ts +139 -0
- package/src/logger/channels/DailyChannel.ts +74 -0
- package/src/logger/channels/NullChannel.ts +17 -0
- package/src/logger/channels/SingleChannel.ts +34 -0
- package/src/logger/channels/StackChannel.ts +29 -0
- package/src/logger/config.ts +90 -0
- package/src/logger/format.ts +96 -0
- package/src/logger/frameworkLog.ts +93 -0
- package/src/logger/index.ts +68 -0
- package/src/logger/renderTable.ts +111 -0
- package/src/logger/types.ts +212 -0
- package/src/macros/config.macro.ts +50 -0
- package/src/metrics/HttpMetrics.ts +114 -0
- package/src/metrics/index.ts +18 -0
- package/src/middleware/BaseMiddleware.ts +72 -0
- package/src/middleware/CorsMiddleware.ts +152 -0
- package/src/middleware/RateLimiter.ts +255 -0
- package/src/middleware/SecureHeadersMiddleware.ts +127 -0
- package/src/middleware/ThrottleMiddleware.ts +252 -0
- package/src/middleware/WebhookMiddleware.ts +204 -0
- package/src/pipeline/ContextRegistry.ts +42 -0
- package/src/pipeline/HttpContext.ts +865 -0
- package/src/pipeline/Pipeline.ts +150 -0
- package/src/pipeline/currentPage.ts +46 -0
- package/src/pipeline/types.ts +80 -0
- package/src/provider/LockProvider.ts +64 -0
- package/src/provider/LogProvider.ts +137 -0
- package/src/provider/ServiceProvider.ts +84 -0
- package/src/provider/StorageProvider.ts +45 -0
- package/src/router/FileRouter.ts +526 -0
- package/src/router/Route.ts +76 -0
- package/src/router/RouteHandler.ts +335 -0
- package/src/router/Router.ts +1247 -0
- package/src/router/domain.ts +65 -0
- package/src/security/index.ts +22 -0
- package/src/storage/FakeDisk.ts +233 -0
- package/src/storage/StorageFilesMiddleware.ts +150 -0
- package/src/storage/StorageManager.ts +173 -0
- package/src/storage/config.ts +47 -0
- package/src/storage/drivers/LocalDriver.ts +138 -0
- package/src/storage/drivers/S3Driver.ts +169 -0
- package/src/storage/errors.ts +135 -0
- package/src/storage/facades/Storage.ts +3 -0
- package/src/storage/global.d.ts +7 -0
- package/src/storage/index.ts +22 -0
- package/src/storage/root.ts +59 -0
- package/src/storage/types.ts +104 -0
- package/src/support/appKey.ts +38 -0
- package/src/support/cookie.ts +72 -0
- package/src/support/crypto.ts +52 -0
- package/src/support/deepMerge.ts +117 -0
- package/src/support/env.ts +71 -0
- package/src/support/network.ts +79 -0
- package/src/support/port.ts +197 -0
- package/src/support/str.ts +122 -0
- package/src/view/FileRouteResolver.ts +59 -0
- package/src/view/index.ts +144 -0
- package/src/view/jsx-runtime.ts +233 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { unlink, stat, copyFile, rename, appendFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import { resolve, sep, dirname } from "node:path";
|
|
3
|
+
import { safeEqual, hmacHex } from "../../support/crypto.ts";
|
|
4
|
+
import type { StorageDriver, PutOptions } from "../types.ts";
|
|
5
|
+
import { PathTraversalError, StorageKeyMissingError } from "../errors.ts";
|
|
6
|
+
import { assertInsideStorageRoot } from "../root.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Local filesystem driver — backed by Bun.file / Bun.write.
|
|
10
|
+
* Suitable for single-server deployments and local development.
|
|
11
|
+
*/
|
|
12
|
+
export class LocalDriver implements StorageDriver {
|
|
13
|
+
private readonly _rootAbs: string;
|
|
14
|
+
|
|
15
|
+
constructor(
|
|
16
|
+
private _root: string,
|
|
17
|
+
private _urlBase: string | undefined = undefined,
|
|
18
|
+
) {
|
|
19
|
+
// Absolute, normalised root used to confine every resolved path.
|
|
20
|
+
this._rootAbs = resolve(_root);
|
|
21
|
+
// …and the root itself is confined to the storage root, so a disk cannot be
|
|
22
|
+
// pointed at the filesystem at large. Checked here rather than in config so
|
|
23
|
+
// it holds for every LocalDriver, however it was constructed.
|
|
24
|
+
assertInsideStorageRoot(this._rootAbs, "disk");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolve `path` against the disk root and reject anything that escapes it.
|
|
29
|
+
* `resolve()` collapses `..`/`.` segments; we then verify the result is the
|
|
30
|
+
* root itself or a descendant of it, defeating `../../etc/passwd`-style
|
|
31
|
+
* traversal on every operation (get/put/delete/copy/move).
|
|
32
|
+
*/
|
|
33
|
+
private _fullPath(path: string): string {
|
|
34
|
+
const full = resolve(this._rootAbs, path.replace(/^[/\\]+/, ""));
|
|
35
|
+
if (full !== this._rootAbs && !full.startsWith(this._rootAbs + sep)) {
|
|
36
|
+
throw new PathTraversalError(path);
|
|
37
|
+
}
|
|
38
|
+
return full;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async put(
|
|
42
|
+
path: string,
|
|
43
|
+
content: string | Uint8Array | Blob,
|
|
44
|
+
_options?: PutOptions,
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
await Bun.write(this._fullPath(path), content);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async append(path: string, content: string | Uint8Array): Promise<void> {
|
|
50
|
+
const full = this._fullPath(path);
|
|
51
|
+
// `dirname`, not a regex: a hand-rolled one that only knows `/` silently
|
|
52
|
+
// matches nothing on a Windows path and then creates a *directory* where
|
|
53
|
+
// the file should go.
|
|
54
|
+
await mkdir(dirname(full), { recursive: true });
|
|
55
|
+
await appendFile(full, content);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async get(path: string): Promise<string | null> {
|
|
59
|
+
const file = Bun.file(this._fullPath(path));
|
|
60
|
+
if (!(await file.exists())) return null;
|
|
61
|
+
return file.text();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async stream(path: string): Promise<Blob | null> {
|
|
65
|
+
// `Bun.file` is a lazy handle, so returning it hands the file to the
|
|
66
|
+
// response without pulling it through memory first.
|
|
67
|
+
const file = Bun.file(this._fullPath(path));
|
|
68
|
+
return (await file.exists()) ? file : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async getBuffer(path: string): Promise<Uint8Array | null> {
|
|
72
|
+
const file = Bun.file(this._fullPath(path));
|
|
73
|
+
if (!(await file.exists())) return null;
|
|
74
|
+
return new Uint8Array(await file.arrayBuffer());
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async exists(path: string): Promise<boolean> {
|
|
78
|
+
return Bun.file(this._fullPath(path)).exists();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async delete(path: string): Promise<void> {
|
|
82
|
+
await unlink(this._fullPath(path)).catch(() => {});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
url(path: string): string {
|
|
86
|
+
const base = this._urlBase?.replace(/\/$/, "") ?? "";
|
|
87
|
+
return `${base}/${path.replace(/^\//, "")}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async copy(source: string, destination: string): Promise<void> {
|
|
91
|
+
await copyFile(this._fullPath(source), this._fullPath(destination));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async move(source: string, destination: string): Promise<void> {
|
|
95
|
+
await rename(this._fullPath(source), this._fullPath(destination));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async size(path: string): Promise<number | null> {
|
|
99
|
+
return stat(this._fullPath(path))
|
|
100
|
+
.then((s) => s.size)
|
|
101
|
+
.catch(() => null);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async lastModified(path: string): Promise<number | null> {
|
|
105
|
+
return stat(this._fullPath(path))
|
|
106
|
+
.then((s) => s.mtimeMs)
|
|
107
|
+
.catch(() => null);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Local signed URL — encodes path + expiry in a HMAC-signed query string,
|
|
112
|
+
* keyed off `APP_KEY` (throws when it is unset, so a signed URL is never
|
|
113
|
+
* emitted with a guessable key). The signature is validated server-side by
|
|
114
|
+
* your application's route handler; verify it with {@link verifyTemporaryUrl}.
|
|
115
|
+
*/
|
|
116
|
+
async temporaryUrl(path: string, expiresInSeconds: number): Promise<string> {
|
|
117
|
+
const expiresAt = Math.floor(Date.now() / 1000) + expiresInSeconds;
|
|
118
|
+
const sig = hmacHex(`${path}:${expiresAt}`, _signingKey());
|
|
119
|
+
const base = this._urlBase?.replace(/\/$/, "") ?? "";
|
|
120
|
+
return `${base}/${path.replace(/^\//, "")}?expires=${expiresAt}&signature=${sig}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Verify a signature produced by {@link temporaryUrl}. Returns `false` when
|
|
125
|
+
* the link has expired or the signature does not match (constant-time).
|
|
126
|
+
*/
|
|
127
|
+
static verifyTemporaryUrl(path: string, expiresAt: number, signature: string): boolean {
|
|
128
|
+
if (Math.floor(Date.now() / 1000) > expiresAt) return false;
|
|
129
|
+
return safeEqual(signature, hmacHex(`${path}:${expiresAt}`, _signingKey()));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The APP_KEY used to sign temporary URLs. Throws when it is not configured. */
|
|
134
|
+
function _signingKey(): string {
|
|
135
|
+
const key = Bun.env["APP_KEY"];
|
|
136
|
+
if (!key) throw new StorageKeyMissingError();
|
|
137
|
+
return key;
|
|
138
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { S3Client } from "bun";
|
|
2
|
+
import type { StorageDriver, PutOptions } from "../types.ts";
|
|
3
|
+
import { UnsupportedOperationError } from "../errors.ts";
|
|
4
|
+
|
|
5
|
+
/** Returns true when err is an S3 "object not found" error. */
|
|
6
|
+
function _isNotFound(err: unknown): boolean {
|
|
7
|
+
if (!(err instanceof Error)) return false;
|
|
8
|
+
// Bun throws an error named 'S3Error' with a code property for S3 failures.
|
|
9
|
+
// NoSuchKey / 404 is the only case we treat as "absent" — all others propagate.
|
|
10
|
+
const s3Err = err as { code?: string };
|
|
11
|
+
return (
|
|
12
|
+
err.name === "S3Error" &&
|
|
13
|
+
(s3Err.code === "NoSuchKey" ||
|
|
14
|
+
err.message.includes("does not exist") ||
|
|
15
|
+
err.message.includes("NoSuchKey"))
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* S3-compatible storage driver — works with AWS S3, Cloudflare R2, MinIO, etc.
|
|
21
|
+
* Powered by Bun's native `s3()` client: zero npm dependencies, automatic SigV4
|
|
22
|
+
* signing, connection reuse, and built-in retry/backoff.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* StorageConfig({
|
|
26
|
+
* disks: {
|
|
27
|
+
* r2: {
|
|
28
|
+
* driver: 's3',
|
|
29
|
+
* key: Bun.env.R2_KEY!,
|
|
30
|
+
* secret: Bun.env.R2_SECRET!,
|
|
31
|
+
* region: 'auto',
|
|
32
|
+
* bucket: 'my-bucket',
|
|
33
|
+
* endpoint: `https://${Bun.env.CF_ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
|
34
|
+
* },
|
|
35
|
+
* },
|
|
36
|
+
* });
|
|
37
|
+
*/
|
|
38
|
+
export class S3Driver implements StorageDriver {
|
|
39
|
+
private _s3: S3Client;
|
|
40
|
+
private _bucket: string;
|
|
41
|
+
private _region: string;
|
|
42
|
+
private _endpoint: string | undefined;
|
|
43
|
+
private _urlBase: string | undefined;
|
|
44
|
+
|
|
45
|
+
constructor(
|
|
46
|
+
key: string,
|
|
47
|
+
secret: string,
|
|
48
|
+
region: string,
|
|
49
|
+
bucket: string,
|
|
50
|
+
endpoint?: string,
|
|
51
|
+
urlBase?: string,
|
|
52
|
+
) {
|
|
53
|
+
this._bucket = bucket;
|
|
54
|
+
this._region = region;
|
|
55
|
+
this._endpoint = endpoint;
|
|
56
|
+
this._urlBase = urlBase;
|
|
57
|
+
this._s3 = new S3Client({
|
|
58
|
+
accessKeyId: key,
|
|
59
|
+
secretAccessKey: secret,
|
|
60
|
+
region,
|
|
61
|
+
bucket,
|
|
62
|
+
...(endpoint && { endpoint }),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async put(
|
|
67
|
+
path: string,
|
|
68
|
+
content: string | Uint8Array | Blob,
|
|
69
|
+
options?: PutOptions,
|
|
70
|
+
): Promise<void> {
|
|
71
|
+
await this._s3.write(path, content, {
|
|
72
|
+
type: options?.contentType ?? "application/octet-stream",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async append(_path: string, _content: string | Uint8Array): Promise<void> {
|
|
77
|
+
// An S3 object is immutable: "appending" means downloading, concatenating
|
|
78
|
+
// and re-uploading the whole object. A caller appending a log line per
|
|
79
|
+
// request would not survive that, so this refuses instead.
|
|
80
|
+
throw new UnsupportedOperationError("append", "s3");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async get(path: string): Promise<string | null> {
|
|
84
|
+
try {
|
|
85
|
+
return await this._s3.file(path).text();
|
|
86
|
+
} catch (err) {
|
|
87
|
+
if (_isNotFound(err)) return null;
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async getBuffer(path: string): Promise<Uint8Array | null> {
|
|
93
|
+
try {
|
|
94
|
+
return new Uint8Array(await this._s3.file(path).arrayBuffer());
|
|
95
|
+
} catch (err) {
|
|
96
|
+
if (_isNotFound(err)) return null;
|
|
97
|
+
throw err;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async exists(path: string): Promise<boolean> {
|
|
102
|
+
try {
|
|
103
|
+
await this._s3.file(path).stat();
|
|
104
|
+
return true;
|
|
105
|
+
} catch (err) {
|
|
106
|
+
if (_isNotFound(err)) return false;
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async delete(path: string): Promise<void> {
|
|
112
|
+
await this._s3.delete(path);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
url(path: string): string {
|
|
116
|
+
if (this._urlBase) {
|
|
117
|
+
return `${this._urlBase.replace(/\/$/, "")}/${path.replace(/^\//, "")}`;
|
|
118
|
+
}
|
|
119
|
+
return this._publicUrl(path);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async copy(source: string, destination: string): Promise<void> {
|
|
123
|
+
// Pass the S3File (a Blob) directly — Bun streams source → destination
|
|
124
|
+
// without buffering the object locally.
|
|
125
|
+
await this._s3.write(destination, this._s3.file(source));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async move(source: string, destination: string): Promise<void> {
|
|
129
|
+
await this.copy(source, destination);
|
|
130
|
+
await this.delete(source);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async size(path: string): Promise<number | null> {
|
|
134
|
+
try {
|
|
135
|
+
const stat = await this._s3.file(path).stat();
|
|
136
|
+
return stat.size;
|
|
137
|
+
} catch (err) {
|
|
138
|
+
if (_isNotFound(err)) return null;
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async lastModified(path: string): Promise<number | null> {
|
|
144
|
+
try {
|
|
145
|
+
const stat = await this._s3.file(path).stat();
|
|
146
|
+
const lm = stat.lastModified;
|
|
147
|
+
return lm instanceof Date ? lm.getTime() : new Date(lm as unknown as string).getTime();
|
|
148
|
+
} catch (err) {
|
|
149
|
+
if (_isNotFound(err)) return null;
|
|
150
|
+
throw err;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async temporaryUrl(path: string, expiresInSeconds: number): Promise<string> {
|
|
155
|
+
return this._s3.file(path).presign({ expiresIn: expiresInSeconds });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private _publicUrl(path: string): string {
|
|
159
|
+
const cleanPath = path.replace(/^\//, "");
|
|
160
|
+
if (this._endpoint) {
|
|
161
|
+
const ep = this._endpoint.replace(/\/$/, "");
|
|
162
|
+
if (!ep.includes(`${this._bucket}.s3.`)) {
|
|
163
|
+
return `${ep}/${this._bucket}/${cleanPath}`;
|
|
164
|
+
}
|
|
165
|
+
return `${ep}/${cleanPath}`;
|
|
166
|
+
}
|
|
167
|
+
return `https://${this._bucket}.s3.${this._region}.amazonaws.com/${cleanPath}`;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { ZerotalError } from "../errors/ZerotalError.ts";
|
|
2
|
+
|
|
3
|
+
/** Base class for all storage errors. */
|
|
4
|
+
export class StorageError extends ZerotalError {
|
|
5
|
+
constructor(
|
|
6
|
+
message: string,
|
|
7
|
+
code = "E_STORAGE",
|
|
8
|
+
status = 500,
|
|
9
|
+
context?: Record<string, unknown>,
|
|
10
|
+
) {
|
|
11
|
+
super(message, code, status, context);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Thrown when a disk name is not present in the storage config. */
|
|
16
|
+
export class DiskNotConfiguredError extends StorageError {
|
|
17
|
+
constructor(disk: string) {
|
|
18
|
+
super(
|
|
19
|
+
`[Zerotal Storage] Disk "${disk}" is not configured.`,
|
|
20
|
+
"E_STORAGE_DISK_NOT_CONFIGURED",
|
|
21
|
+
500,
|
|
22
|
+
{ disk },
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Thrown when a requested path would escape the disk's root directory. */
|
|
28
|
+
export class PathTraversalError extends StorageError {
|
|
29
|
+
constructor(path: string) {
|
|
30
|
+
super(
|
|
31
|
+
`[Zerotal Storage] Path "${path}" escapes the disk root. ` +
|
|
32
|
+
`Relative segments that resolve outside the configured root are rejected.`,
|
|
33
|
+
"E_STORAGE_PATH_TRAVERSAL",
|
|
34
|
+
400,
|
|
35
|
+
{ path },
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Thrown when a signing operation is attempted without an APP_KEY configured. */
|
|
41
|
+
export class StorageKeyMissingError extends StorageError {
|
|
42
|
+
constructor() {
|
|
43
|
+
super(
|
|
44
|
+
"[Zerotal Storage] Signed URLs require APP_KEY. Generate one with `zerotal key:generate`.",
|
|
45
|
+
"E_STORAGE_NO_KEY",
|
|
46
|
+
500,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Thrown when a driver is asked for something its backend cannot do.
|
|
53
|
+
*
|
|
54
|
+
* The contract is one API over very different backends, and a couple of
|
|
55
|
+
* operations only make sense on some of them — appending to an object in S3
|
|
56
|
+
* means rewriting the whole object. Refusing loudly beats emulating badly.
|
|
57
|
+
*/
|
|
58
|
+
export class UnsupportedOperationError extends StorageError {
|
|
59
|
+
constructor(operation: string, driver: string) {
|
|
60
|
+
super(
|
|
61
|
+
`[Zerotal Storage] The ${driver} driver does not support ${operation}().`,
|
|
62
|
+
"E_STORAGE_UNSUPPORTED",
|
|
63
|
+
500,
|
|
64
|
+
{ operation, driver },
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Thrown when a local disk is rooted outside the storage root.
|
|
71
|
+
*
|
|
72
|
+
* The per-disk traversal guard stops a *path* escaping its disk; this stops the
|
|
73
|
+
* *disk* being pointed somewhere it has no business reading or writing. A
|
|
74
|
+
* misconfigured root is not a runtime edge case to degrade around — it is a
|
|
75
|
+
* config bug, and it fails at construction so it surfaces on boot rather than
|
|
76
|
+
* on the first upload.
|
|
77
|
+
*/
|
|
78
|
+
export class StorageRootEscapeError extends StorageError {
|
|
79
|
+
constructor(path: string, root: string, what: string) {
|
|
80
|
+
super(
|
|
81
|
+
`[Zerotal Storage] The ${what} root "${path}" is outside the storage root "${root}".\n` +
|
|
82
|
+
`Every local disk must live inside the storage root. Move it under that ` +
|
|
83
|
+
`directory, or set ZT_STORAGE_ROOT if your data volume is mounted elsewhere.`,
|
|
84
|
+
"E_STORAGE_ROOT_ESCAPE",
|
|
85
|
+
500,
|
|
86
|
+
{ path, root },
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Thrown when a public URL is asked for a disk that has none.
|
|
93
|
+
*
|
|
94
|
+
* A disk with no `serve` block and no `url` base is not reachable over HTTP.
|
|
95
|
+
* Handing back a path anyway produces the worst kind of failure — a link that
|
|
96
|
+
* looks right, resolves relative to whatever page embedded it, and 404s
|
|
97
|
+
* somewhere unrelated to the disk.
|
|
98
|
+
*/
|
|
99
|
+
export class DiskNotServedError extends StorageError {
|
|
100
|
+
constructor(disk: string) {
|
|
101
|
+
super(
|
|
102
|
+
`[Zerotal Storage] The "${disk}" disk has no public URL.\n` +
|
|
103
|
+
`Add a \`serve\` block to expose it (\`serve: { path: "/files" }\`, or ` +
|
|
104
|
+
`\`{ path: "/files", signed: true }\` for signed links), or set \`url\` to ` +
|
|
105
|
+
`point at a CDN. Use Storage.isServed("${disk}") to branch instead of catching.`,
|
|
106
|
+
"E_STORAGE_DISK_NOT_SERVED",
|
|
107
|
+
500,
|
|
108
|
+
{ disk },
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Thrown when a disk outside the public directory is served without signing.
|
|
115
|
+
*
|
|
116
|
+
* Everything under the storage root is private except `storage/public`. A disk
|
|
117
|
+
* rooted elsewhere can still be exposed — but only behind `signed: true`, where
|
|
118
|
+
* each request carries a signature you issued. Serving it openly would make
|
|
119
|
+
* private files world-readable through a URL prefix, which is the mistake this
|
|
120
|
+
* refuses to let a config express.
|
|
121
|
+
*/
|
|
122
|
+
export class UnsafePublicMountError extends StorageError {
|
|
123
|
+
constructor(disk: string, root: string, publicRoot: string) {
|
|
124
|
+
super(
|
|
125
|
+
`[Zerotal Storage] The "${disk}" disk is served without \`signed\`, but its root ` +
|
|
126
|
+
`"${root}" is outside the public directory "${publicRoot}".\n` +
|
|
127
|
+
`Everything under the storage root is private except that directory. Either ` +
|
|
128
|
+
`move the disk inside it, or serve it with \`signed: true\` so each request ` +
|
|
129
|
+
`has to carry a signature you issued.`,
|
|
130
|
+
"E_STORAGE_UNSAFE_PUBLIC_MOUNT",
|
|
131
|
+
500,
|
|
132
|
+
{ disk, root, publicRoot },
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export { StorageManager } from "./StorageManager.ts";
|
|
2
|
+
export { StorageProvider } from "../provider/StorageProvider.ts";
|
|
3
|
+
export { Storage } from "./facades/Storage.ts";
|
|
4
|
+
export { StorageConfig } from "./config.ts";
|
|
5
|
+
export { LocalDriver } from "./drivers/LocalDriver.ts";
|
|
6
|
+
export { StorageFilesMiddleware, mountsFrom } from "./StorageFilesMiddleware.ts";
|
|
7
|
+
export type { StorageFilesOptions } from "./StorageFilesMiddleware.ts";
|
|
8
|
+
export { S3Driver } from "./drivers/S3Driver.ts";
|
|
9
|
+
export { FakeDisk } from "./FakeDisk.ts";
|
|
10
|
+
export type { FakeStoredFile } from "./FakeDisk.ts";
|
|
11
|
+
export type {
|
|
12
|
+
StorageDriver,
|
|
13
|
+
StorageConfigShape,
|
|
14
|
+
DiskConfig,
|
|
15
|
+
LocalDiskConfig,
|
|
16
|
+
S3DiskConfig,
|
|
17
|
+
PutOptions,
|
|
18
|
+
DiskServeConfig,
|
|
19
|
+
} from "./types.ts";
|
|
20
|
+
|
|
21
|
+
// Typed error vocabulary
|
|
22
|
+
export * from "./errors.ts";
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { resolve, sep } from "node:path";
|
|
2
|
+
import { StorageRootEscapeError } from "./errors.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The one directory every local disk must live inside.
|
|
6
|
+
*
|
|
7
|
+
* A per-disk root already stops `../../etc/passwd` from escaping *that* disk,
|
|
8
|
+
* but nothing stopped the disk itself from being rooted at `/etc` in the first
|
|
9
|
+
* place — a config typo, a copied snippet, or a path built from user input, and
|
|
10
|
+
* a "storage" disk is reading the filesystem. This is the outer boundary: every
|
|
11
|
+
* local root resolves inside it or the driver refuses to exist.
|
|
12
|
+
*
|
|
13
|
+
* Defaults to `<cwd>/storage`. `ZT_STORAGE_ROOT` moves it — for a deployment
|
|
14
|
+
* whose data volume is mounted elsewhere, or a test that works in a temp
|
|
15
|
+
* directory. It is read on every call rather than cached so that a change takes
|
|
16
|
+
* effect immediately.
|
|
17
|
+
*/
|
|
18
|
+
export function storageRoot(): string {
|
|
19
|
+
return resolve(Bun.env["ZT_STORAGE_ROOT"] ?? "storage");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The one directory whose contents are readable without a signature.
|
|
24
|
+
*
|
|
25
|
+
* Everything under {@link storageRoot} is private; this is the single carve-out.
|
|
26
|
+
* Keeping it a real directory rather than a naming convention means "is this
|
|
27
|
+
* file public?" is answered by where it lives, which is checkable, instead of by
|
|
28
|
+
* which config block someone edited last.
|
|
29
|
+
*/
|
|
30
|
+
export function publicRoot(): string {
|
|
31
|
+
return resolve(storageRoot(), "public");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Whether `path` resolves to the public directory or something beneath it. */
|
|
35
|
+
export function isInsidePublicRoot(path: string): boolean {
|
|
36
|
+
const root = publicRoot();
|
|
37
|
+
const full = resolve(path);
|
|
38
|
+
return full === root || full.startsWith(root + sep);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Whether `path` resolves to the storage root or something beneath it. */
|
|
42
|
+
export function isInsideStorageRoot(path: string): boolean {
|
|
43
|
+
const root = storageRoot();
|
|
44
|
+
const full = resolve(path);
|
|
45
|
+
return full === root || full.startsWith(root + sep);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Throw unless `path` resolves inside {@link storageRoot}.
|
|
50
|
+
*
|
|
51
|
+
* @param path - The path to check; resolved against the working directory.
|
|
52
|
+
* @param what - What is being rooted there, for the error message.
|
|
53
|
+
* @throws {@link StorageRootEscapeError}
|
|
54
|
+
*/
|
|
55
|
+
export function assertInsideStorageRoot(path: string, what = "disk"): void {
|
|
56
|
+
if (!isInsideStorageRoot(path)) {
|
|
57
|
+
throw new StorageRootEscapeError(path, storageRoot(), what);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export interface PutOptions {
|
|
2
|
+
contentType?: string;
|
|
3
|
+
visibility?: "public" | "private";
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface StorageDriver {
|
|
7
|
+
put(path: string, content: string | Uint8Array | Blob, options?: PutOptions): Promise<void>;
|
|
8
|
+
get(path: string): Promise<string | null>;
|
|
9
|
+
getBuffer(path: string): Promise<Uint8Array | null>;
|
|
10
|
+
exists(path: string): Promise<boolean>;
|
|
11
|
+
delete(path: string): Promise<void>;
|
|
12
|
+
/** Return a public URL for the given path. */
|
|
13
|
+
url(path: string): string;
|
|
14
|
+
|
|
15
|
+
/** Copy a file within the same disk. */
|
|
16
|
+
copy(source: string, destination: string): Promise<void>;
|
|
17
|
+
/** Move (rename) a file within the same disk. */
|
|
18
|
+
move(source: string, destination: string): Promise<void>;
|
|
19
|
+
/** Return the file size in bytes, or null if not found. */
|
|
20
|
+
size(path: string): Promise<number | null>;
|
|
21
|
+
/** Return the last-modified timestamp (ms since epoch), or null if not found. */
|
|
22
|
+
lastModified(path: string): Promise<number | null>;
|
|
23
|
+
/**
|
|
24
|
+
* Return a temporary URL valid for `expiresInSeconds` seconds.
|
|
25
|
+
* For local driver this is a signed token URL; for S3 it is a presigned URL.
|
|
26
|
+
*/
|
|
27
|
+
temporaryUrl(path: string, expiresInSeconds: number): Promise<string>;
|
|
28
|
+
/**
|
|
29
|
+
* Append to the end of a file, creating it when absent.
|
|
30
|
+
*
|
|
31
|
+
* Only a filesystem can do this. An object store rewrites the whole object on
|
|
32
|
+
* every write, so emulating an append there would quietly turn one line of a
|
|
33
|
+
* log into a full download-and-upload of the day's file — {@link S3Driver}
|
|
34
|
+
* throws {@link UnsupportedOperationError} rather than pretend. Use it for
|
|
35
|
+
* things that are genuinely local and line-oriented, like the log trail.
|
|
36
|
+
*/
|
|
37
|
+
append(path: string, content: string | Uint8Array): Promise<void>;
|
|
38
|
+
/**
|
|
39
|
+
* A lazy handle for `path`, or `null` when absent.
|
|
40
|
+
*
|
|
41
|
+
* Optional. When a backend can hand back something streamable, serving a file
|
|
42
|
+
* does not have to read it into memory first — which is the difference
|
|
43
|
+
* between serving a 2 GB video and falling over. {@link StorageFilesMiddleware}
|
|
44
|
+
* uses it when present and falls back to {@link getBuffer} when not.
|
|
45
|
+
*/
|
|
46
|
+
stream?(path: string): Promise<Blob | null>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* How a disk is exposed over HTTP by {@link StorageFilesMiddleware}.
|
|
51
|
+
*
|
|
52
|
+
* Omit it and the disk is not reachable over the network at all — which is the
|
|
53
|
+
* right default for anything holding private uploads.
|
|
54
|
+
*/
|
|
55
|
+
export interface DiskServeConfig {
|
|
56
|
+
/** URL prefix the disk is served under, e.g. `/storage`. */
|
|
57
|
+
path: string;
|
|
58
|
+
/**
|
|
59
|
+
* Require a valid `?expires=&signature=` on every request, as produced by
|
|
60
|
+
* `temporaryUrl()`. Use it to expose a private disk without making it public:
|
|
61
|
+
* only someone holding a link you signed can read a file, and only until it
|
|
62
|
+
* expires.
|
|
63
|
+
*/
|
|
64
|
+
signed?: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* How long a signed link stays valid, in seconds. Only meaningful with
|
|
67
|
+
* `signed`. Defaults to 900 (15 minutes).
|
|
68
|
+
*/
|
|
69
|
+
expiresIn?: number;
|
|
70
|
+
/** Extra response headers, e.g. `Cache-Control`. */
|
|
71
|
+
headers?: Record<string, string>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface LocalDiskConfig {
|
|
75
|
+
driver: "local";
|
|
76
|
+
/** Absolute or relative root directory for file storage. */
|
|
77
|
+
root: string;
|
|
78
|
+
/** Optional base URL for url() — e.g. '/storage' or 'https://cdn.example.com'. */
|
|
79
|
+
url?: string;
|
|
80
|
+
/** Expose this disk over HTTP. Omit to keep it unreachable. */
|
|
81
|
+
serve?: DiskServeConfig;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface S3DiskConfig {
|
|
85
|
+
driver: "s3";
|
|
86
|
+
key: string;
|
|
87
|
+
secret: string;
|
|
88
|
+
region: string;
|
|
89
|
+
bucket: string;
|
|
90
|
+
/** Override endpoint for R2, MinIO, etc. E.g. 'https://<account>.r2.cloudflarestorage.com' */
|
|
91
|
+
endpoint?: string;
|
|
92
|
+
/** Custom public URL base (e.g. CDN or R2 public domain). */
|
|
93
|
+
url?: string;
|
|
94
|
+
/** Expose this disk over HTTP, proxied through the app. Omit to keep it unreachable. */
|
|
95
|
+
serve?: DiskServeConfig;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type DiskConfig = LocalDiskConfig | S3DiskConfig;
|
|
99
|
+
|
|
100
|
+
export interface StorageConfigShape {
|
|
101
|
+
/** Name of the default disk. */
|
|
102
|
+
default: string;
|
|
103
|
+
disks: Record<string, DiskConfig>;
|
|
104
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* APP_KEY strength check.
|
|
3
|
+
*
|
|
4
|
+
* `APP_KEY` is the single secret every signing and encryption primitive derives
|
|
5
|
+
* from — sessions, signed URLs, Flow snapshots, remember-me tokens. Because
|
|
6
|
+
* each derives its working key by hashing the value through SHA-256, *any* length
|
|
7
|
+
* "works" mechanically, so a short or low-entropy key (`APP_KEY=secret`) silently
|
|
8
|
+
* weakens the whole framework with no error. This surfaces that at boot instead.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Minimum key material, in bytes. `key:generate` mints 32 random bytes. */
|
|
12
|
+
export const MIN_APP_KEY_BYTES = 32;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Bytes of key material an `APP_KEY` carries: the decoded length for a
|
|
16
|
+
* `base64:`-prefixed value, otherwise its UTF-8 byte length.
|
|
17
|
+
*/
|
|
18
|
+
export function appKeyByteLength(key: string): number {
|
|
19
|
+
const raw = key.startsWith("base64:")
|
|
20
|
+
? Buffer.from(key.slice(7), "base64")
|
|
21
|
+
: Buffer.from(key, "utf8");
|
|
22
|
+
return raw.length;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A warning message when `key` is too weak to key the framework's cryptography,
|
|
27
|
+
* or `null` when it is absent (handled at point-of-use) or strong enough.
|
|
28
|
+
*/
|
|
29
|
+
export function appKeyStrengthWarning(key: string | undefined): string | null {
|
|
30
|
+
if (!key) return null; // absence is enforced where the key is actually needed
|
|
31
|
+
const bytes = appKeyByteLength(key);
|
|
32
|
+
if (bytes >= MIN_APP_KEY_BYTES) return null;
|
|
33
|
+
return (
|
|
34
|
+
`[Zerotal] APP_KEY carries only ${bytes} bytes of key material (minimum ${MIN_APP_KEY_BYTES}). ` +
|
|
35
|
+
`Every session, signed URL, and Flow snapshot derives from it, so a short key weakens them all. ` +
|
|
36
|
+
`Generate a strong one with \`bun zt key:generate\`.`
|
|
37
|
+
);
|
|
38
|
+
}
|