@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,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-aware routing support for `Router.group({ domain })`.
|
|
3
|
+
*
|
|
4
|
+
* A domain pattern like `:tenant.app.com` is compiled into a matcher that both
|
|
5
|
+
* tests a request's host and extracts the dynamic labels as subdomain params,
|
|
6
|
+
* exposed on the request via `ctx.subdomains`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface CompiledDomain {
|
|
10
|
+
/** The original pattern, e.g. ':tenant.app.com'. */
|
|
11
|
+
source: string;
|
|
12
|
+
regex: RegExp;
|
|
13
|
+
/** Param names in order, e.g. ['tenant']. */
|
|
14
|
+
params: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Compile a domain pattern into a host matcher. `:label` segments are dynamic. */
|
|
18
|
+
export function compileDomain(pattern: string): CompiledDomain {
|
|
19
|
+
const params: string[] = [];
|
|
20
|
+
const body = pattern
|
|
21
|
+
.split(".")
|
|
22
|
+
.map((segment) => {
|
|
23
|
+
if (segment.startsWith(":")) {
|
|
24
|
+
params.push(segment.slice(1));
|
|
25
|
+
return "([^.]+)";
|
|
26
|
+
}
|
|
27
|
+
return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
28
|
+
})
|
|
29
|
+
.join("\\.");
|
|
30
|
+
return { source: pattern, regex: new RegExp(`^${body}$`, "i"), params };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Match a request host against a compiled domain. The port is ignored.
|
|
35
|
+
* Returns the extracted subdomain params, or `null` when the host doesn't match.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* matchDomain(compileDomain(':tenant.app.com'), 'acme.app.com'); // { tenant: 'acme' }
|
|
39
|
+
* matchDomain(compileDomain(':tenant.app.com'), 'app.com'); // null
|
|
40
|
+
*/
|
|
41
|
+
export function matchDomain(compiled: CompiledDomain, host: string): Record<string, string> | null {
|
|
42
|
+
const hostname = (host.split(":")[0] ?? "").toLowerCase();
|
|
43
|
+
const match = compiled.regex.exec(hostname);
|
|
44
|
+
if (!match) return null;
|
|
45
|
+
const subdomains: Record<string, string> = {};
|
|
46
|
+
compiled.params.forEach((paramName, index) => {
|
|
47
|
+
subdomains[paramName] = match[index + 1]!;
|
|
48
|
+
});
|
|
49
|
+
return subdomains;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Per-request subdomain store ───────────────────────────────────────────────
|
|
53
|
+
// Set by the router's host dispatcher, read by HttpContext.subdomains.
|
|
54
|
+
|
|
55
|
+
const _store = new WeakMap<Request, Record<string, string>>();
|
|
56
|
+
|
|
57
|
+
/** @internal Record the subdomain params extracted for a request; read via {@link getRequestSubdomains}. */
|
|
58
|
+
export function setRequestSubdomains(req: Request, params: Record<string, string>): void {
|
|
59
|
+
_store.set(req, params);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @internal The subdomain params extracted for a request, or `{}` if none were set. */
|
|
63
|
+
export function getRequestSubdomains(req: Request): Record<string, string> {
|
|
64
|
+
return _store.get(req) ?? {};
|
|
65
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security primitives for Zerotal (the `@zerotal/core/security` subpath):
|
|
3
|
+
* {@link Crypt} for `APP_KEY`-keyed AES-256-GCM encryption and {@link Hash} for
|
|
4
|
+
* argon2id/bcrypt password hashing. Both are zero-config facades — no provider
|
|
5
|
+
* registration required — that read `APP_KEY` from the environment on first use.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { Crypt, Hash } from "@zerotal/core/security";
|
|
10
|
+
*
|
|
11
|
+
* const token = Crypt.encryptString("secret");
|
|
12
|
+
* Crypt.decryptString(token); // "secret"
|
|
13
|
+
*
|
|
14
|
+
* const digest = await Hash.make("hunter2");
|
|
15
|
+
* await Hash.verify("hunter2", digest); // true
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* @packageDocumentation
|
|
19
|
+
*/
|
|
20
|
+
export { Crypt, CryptKeyMissingError, DecryptionError } from "../crypt/Crypt.ts";
|
|
21
|
+
export { Hash } from "../hash/Hash.ts";
|
|
22
|
+
export type { HashAlgorithm } from "../hash/Hash.ts";
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import type { StorageDriver, PutOptions } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/** What {@link FakeDisk} remembers about a stored file. */
|
|
4
|
+
export interface FakeStoredFile {
|
|
5
|
+
path: string;
|
|
6
|
+
contents: Uint8Array;
|
|
7
|
+
contentType: string | undefined;
|
|
8
|
+
visibility: "public" | "private" | undefined;
|
|
9
|
+
lastModified: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* An in-memory storage disk for tests.
|
|
14
|
+
*
|
|
15
|
+
* Writing real files to test an upload leaves the suite dependent on a
|
|
16
|
+
* filesystem it has to clean up — and a test that forgets to passes the second
|
|
17
|
+
* time for the wrong reason. This keeps everything in a `Map`, so each test
|
|
18
|
+
* starts empty by construction, and adds the assertions that a plain driver has
|
|
19
|
+
* no reason to carry.
|
|
20
|
+
*
|
|
21
|
+
* Install it with `Storage.fake()`, which swaps it in for a named disk and
|
|
22
|
+
* returns it.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* const disk = Storage.fake();
|
|
26
|
+
*
|
|
27
|
+
* await app.multipart('/avatar', { avatar: fakeFile.image('me.png') });
|
|
28
|
+
*
|
|
29
|
+
* disk.assertExists('avatars/me.png');
|
|
30
|
+
* disk.assertCount(1);
|
|
31
|
+
*/
|
|
32
|
+
export class FakeDisk implements StorageDriver {
|
|
33
|
+
private readonly _files = new Map<string, FakeStoredFile>();
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param baseUrl - Prefix returned by {@link url}, so a test can assert on the
|
|
37
|
+
* URL a controller hands back.
|
|
38
|
+
*/
|
|
39
|
+
constructor(private readonly baseUrl: string = "/storage") {}
|
|
40
|
+
|
|
41
|
+
// ── StorageDriver ─────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
async put(
|
|
44
|
+
path: string,
|
|
45
|
+
content: string | Uint8Array | Blob,
|
|
46
|
+
options: PutOptions = {},
|
|
47
|
+
): Promise<void> {
|
|
48
|
+
this._files.set(_normalise(path), {
|
|
49
|
+
path: _normalise(path),
|
|
50
|
+
contents: await _bytes(content),
|
|
51
|
+
contentType: options.contentType,
|
|
52
|
+
visibility: options.visibility,
|
|
53
|
+
lastModified: Date.now(),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async append(path: string, content: string | Uint8Array): Promise<void> {
|
|
58
|
+
const existing = this._files.get(_normalise(path))?.contents ?? new Uint8Array(0);
|
|
59
|
+
const added = await _bytes(content);
|
|
60
|
+
const merged = new Uint8Array(existing.length + added.length);
|
|
61
|
+
merged.set(existing);
|
|
62
|
+
merged.set(added, existing.length);
|
|
63
|
+
await this.put(path, merged);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async get(path: string): Promise<string | null> {
|
|
67
|
+
const file = this._files.get(_normalise(path));
|
|
68
|
+
return file ? new TextDecoder().decode(file.contents) : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async stream(path: string): Promise<Blob | null> {
|
|
72
|
+
const file = this._files.get(_normalise(path));
|
|
73
|
+
return file ? new Blob([file.contents as BlobPart], { type: file.contentType ?? "" }) : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async getBuffer(path: string): Promise<Uint8Array | null> {
|
|
77
|
+
return this._files.get(_normalise(path))?.contents ?? null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async exists(path: string): Promise<boolean> {
|
|
81
|
+
return this._files.has(_normalise(path));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async delete(path: string): Promise<void> {
|
|
85
|
+
this._files.delete(_normalise(path));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
url(path: string): string {
|
|
89
|
+
return `${this.baseUrl.replace(/\/+$/, "")}/${_normalise(path)}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async copy(source: string, destination: string): Promise<void> {
|
|
93
|
+
const file = this._files.get(_normalise(source));
|
|
94
|
+
if (!file) throw new Error(`[Zerotal/storage] Cannot copy missing file "${source}".`);
|
|
95
|
+
this._files.set(_normalise(destination), {
|
|
96
|
+
...file,
|
|
97
|
+
path: _normalise(destination),
|
|
98
|
+
lastModified: Date.now(),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async move(source: string, destination: string): Promise<void> {
|
|
103
|
+
await this.copy(source, destination);
|
|
104
|
+
await this.delete(source);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async size(path: string): Promise<number | null> {
|
|
108
|
+
return this._files.get(_normalise(path))?.contents.length ?? null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async lastModified(path: string): Promise<number | null> {
|
|
112
|
+
return this._files.get(_normalise(path))?.lastModified ?? null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async temporaryUrl(path: string, expiresInSeconds: number): Promise<string> {
|
|
116
|
+
const expires = Math.floor(Date.now() / 1000) + expiresInSeconds;
|
|
117
|
+
return `${this.url(path)}?expires=${expires}&signature=fake`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Inspection ────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
/** Every stored path, in insertion order. */
|
|
123
|
+
paths(): string[] {
|
|
124
|
+
return [...this._files.keys()];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The full record for a stored file, or `undefined`. */
|
|
128
|
+
file(path: string): FakeStoredFile | undefined {
|
|
129
|
+
return this._files.get(_normalise(path));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Number of files currently stored. */
|
|
133
|
+
get count(): number {
|
|
134
|
+
return this._files.size;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Forget everything stored so far. */
|
|
138
|
+
clear(): void {
|
|
139
|
+
this._files.clear();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── Assertions ────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
/** Assert a file exists at `path`, optionally matching its exact contents. */
|
|
145
|
+
assertExists(path: string, contents?: string | Uint8Array): this {
|
|
146
|
+
const file = this._files.get(_normalise(path));
|
|
147
|
+
if (!file) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`assertExists: expected "${path}" on the disk, but it holds ${this._listing()}`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
if (contents !== undefined) {
|
|
153
|
+
const expected = typeof contents === "string" ? contents : new TextDecoder().decode(contents);
|
|
154
|
+
const actual = new TextDecoder().decode(file.contents);
|
|
155
|
+
if (actual !== expected) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`assertExists: "${path}" exists but its contents differ.\n` +
|
|
158
|
+
` expected: ${JSON.stringify(expected.slice(0, 120))}\n` +
|
|
159
|
+
` actual: ${JSON.stringify(actual.slice(0, 120))}`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return this;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Assert nothing is stored at `path`. */
|
|
167
|
+
assertMissing(path: string): this {
|
|
168
|
+
if (this._files.has(_normalise(path))) {
|
|
169
|
+
throw new Error(`assertMissing: expected "${path}" to be absent, but it is stored.`);
|
|
170
|
+
}
|
|
171
|
+
return this;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Assert a file exists whose path matches `pattern` — the way to assert on an
|
|
176
|
+
* upload stored under a generated name.
|
|
177
|
+
*
|
|
178
|
+
* @example
|
|
179
|
+
* disk.assertExistsMatching(/^avatars\/[0-9a-f-]+\.png$/);
|
|
180
|
+
*/
|
|
181
|
+
assertExistsMatching(pattern: RegExp): this {
|
|
182
|
+
if (!this.paths().some((p) => pattern.test(p))) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`assertExistsMatching: no stored path matched ${pattern}. The disk holds ${this._listing()}`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
return this;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Assert the file at `path` was stored with the given content type. */
|
|
191
|
+
assertContentType(path: string, contentType: string): this {
|
|
192
|
+
this.assertExists(path);
|
|
193
|
+
const actual = this._files.get(_normalise(path))!.contentType;
|
|
194
|
+
if (actual !== contentType) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`assertContentType: expected "${path}" to be stored as "${contentType}" but it was ` +
|
|
197
|
+
`"${actual ?? "unset"}".`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
return this;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Assert exactly `expected` files are stored. */
|
|
204
|
+
assertCount(expected: number): this {
|
|
205
|
+
if (this._files.size !== expected) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`assertCount: expected ${expected} file(s) but the disk holds ${this._listing()}`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
return this;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Assert nothing at all was stored. */
|
|
214
|
+
assertNothingStored(): this {
|
|
215
|
+
return this.assertCount(0);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private _listing(): string {
|
|
219
|
+
const paths = this.paths();
|
|
220
|
+
return paths.length === 0 ? "no files." : `${paths.length}: [${paths.join(", ")}].`;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Drop a leading slash so `avatars/a.png` and `/avatars/a.png` are one file. */
|
|
225
|
+
function _normalise(path: string): string {
|
|
226
|
+
return path.replace(/^\/+/, "");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function _bytes(content: string | Uint8Array | Blob): Promise<Uint8Array> {
|
|
230
|
+
if (typeof content === "string") return new TextEncoder().encode(content);
|
|
231
|
+
if (content instanceof Uint8Array) return content;
|
|
232
|
+
return new Uint8Array(await content.arrayBuffer());
|
|
233
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { NextFn } from "../pipeline/types.ts";
|
|
2
|
+
import type { HttpContext } from "../pipeline/HttpContext.ts";
|
|
3
|
+
import { BaseMiddleware } from "../middleware/BaseMiddleware.ts";
|
|
4
|
+
import type { StorageManager } from "./StorageManager.ts";
|
|
5
|
+
import type { DiskServeConfig, StorageConfigShape, StorageDriver } from "./types.ts";
|
|
6
|
+
import { isInsidePublicRoot, publicRoot } from "./root.ts";
|
|
7
|
+
import { UnsafePublicMountError } from "./errors.ts";
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
|
|
10
|
+
/** One disk mounted at a URL prefix. */
|
|
11
|
+
interface Mount {
|
|
12
|
+
disk: string;
|
|
13
|
+
prefix: string;
|
|
14
|
+
serve: DiskServeConfig;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface StorageFilesOptions {
|
|
18
|
+
/** Disks to serve, in match order. Built from config by {@link StorageProvider}. */
|
|
19
|
+
mounts?: Mount[];
|
|
20
|
+
/** The manager the files are read through. */
|
|
21
|
+
storage?: StorageManager;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Serves files from a storage disk over HTTP.
|
|
26
|
+
*
|
|
27
|
+
* Only disks that declare `serve` in `config/storage.ts` are reachable; the rest
|
|
28
|
+
* have no URL at all. That is the safe default for a disk holding private
|
|
29
|
+
* uploads — exposure is something you ask for, one disk at a time, rather than
|
|
30
|
+
* something you remember to switch off.
|
|
31
|
+
*
|
|
32
|
+
* `Router.static()` cannot do this job: it registers the files it finds at boot,
|
|
33
|
+
* so anything uploaded afterwards is invisible until a restart. This resolves
|
|
34
|
+
* per request, through the driver, so an S3-backed disk can be proxied the same
|
|
35
|
+
* way a local one is served.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* // config/storage.ts — public assets, and private files behind signed links
|
|
40
|
+
* disks: {
|
|
41
|
+
* public: { driver: "local", root: "./storage/public",
|
|
42
|
+
* serve: { path: "/storage/public" } },
|
|
43
|
+
* private: { driver: "local", root: "./storage/invoices",
|
|
44
|
+
* serve: { path: "/files", signed: true } },
|
|
45
|
+
* }
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export class StorageFilesMiddleware extends BaseMiddleware<StorageFilesOptions> {
|
|
49
|
+
protected options: StorageFilesOptions = {};
|
|
50
|
+
|
|
51
|
+
async handle(http: HttpContext, next: NextFn): Promise<Response | void> {
|
|
52
|
+
const { mounts, storage } = this.options;
|
|
53
|
+
if (!mounts?.length || !storage) return next();
|
|
54
|
+
|
|
55
|
+
// Only GET and HEAD read files; anything else belongs to the application.
|
|
56
|
+
const method = http.request.method.toUpperCase();
|
|
57
|
+
if (method !== "GET" && method !== "HEAD") return next();
|
|
58
|
+
|
|
59
|
+
const pathname = decodeURIComponent(http.url.pathname);
|
|
60
|
+
const mount = mounts.find((m) => pathname === m.prefix || pathname.startsWith(m.prefix + "/"));
|
|
61
|
+
if (!mount) return next();
|
|
62
|
+
|
|
63
|
+
const relative = pathname.slice(mount.prefix.length).replace(/^\/+/, "");
|
|
64
|
+
if (!relative) return next();
|
|
65
|
+
|
|
66
|
+
if (mount.serve.signed && !this._verifySignature(http, storage, relative)) {
|
|
67
|
+
// Deliberately 404, not 403: a bad signature should not confirm that the
|
|
68
|
+
// file exists to someone guessing paths.
|
|
69
|
+
return this._notFound();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let driver: StorageDriver;
|
|
73
|
+
try {
|
|
74
|
+
driver = storage.disk(mount.disk);
|
|
75
|
+
} catch {
|
|
76
|
+
return next(); // the disk vanished from config — not this middleware's file
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const body = await this._read(driver, relative);
|
|
80
|
+
if (body === null) return this._notFound();
|
|
81
|
+
|
|
82
|
+
const headers = new Headers(mount.serve.headers ?? {});
|
|
83
|
+
// A traversal attempt never reaches here — the driver rejects it — so any
|
|
84
|
+
// path that resolved is one the disk owns.
|
|
85
|
+
const modified = await driver.lastModified(relative).catch(() => null);
|
|
86
|
+
if (modified !== null) headers.set("Last-Modified", new Date(modified).toUTCString());
|
|
87
|
+
if (!headers.has("Cache-Control")) {
|
|
88
|
+
headers.set(
|
|
89
|
+
"Cache-Control",
|
|
90
|
+
mount.serve.signed ? "private, no-store" : "public, max-age=300",
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return new Response(method === "HEAD" ? null : body, { headers });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Prefer a streamable handle; fall back to bytes for drivers without one. */
|
|
98
|
+
private async _read(driver: StorageDriver, path: string): Promise<Blob | null> {
|
|
99
|
+
try {
|
|
100
|
+
if (driver.stream) return await driver.stream(path);
|
|
101
|
+
const bytes = await driver.getBuffer(path);
|
|
102
|
+
return bytes === null ? null : new Blob([bytes as BlobPart]);
|
|
103
|
+
} catch {
|
|
104
|
+
// A traversal rejection or an unreadable file both mean "no file here".
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private _verifySignature(http: HttpContext, storage: StorageManager, path: string): boolean {
|
|
110
|
+
const expires = Number(http.url.searchParams.get("expires"));
|
|
111
|
+
const signature = http.url.searchParams.get("signature");
|
|
112
|
+
if (!Number.isFinite(expires) || !signature) return false;
|
|
113
|
+
try {
|
|
114
|
+
return storage.verifyTemporaryUrl(path, expires, signature);
|
|
115
|
+
} catch {
|
|
116
|
+
// Signing needs APP_KEY; without it no signature can be valid.
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private _notFound(): Response {
|
|
122
|
+
return new Response("Not Found", { status: 404, headers: { "Cache-Control": "no-store" } });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Build the mount list from a storage config, in longest-prefix-first order.
|
|
128
|
+
*
|
|
129
|
+
* Also enforces the one rule that keeps the storage root private: a local disk
|
|
130
|
+
* served *without* `signed` has to live inside the public directory. Checked
|
|
131
|
+
* here because this runs at boot, so an unsafe config fails before the server
|
|
132
|
+
* accepts a request rather than the first time someone guesses a path.
|
|
133
|
+
*
|
|
134
|
+
* @throws {@link UnsafePublicMountError}
|
|
135
|
+
*/
|
|
136
|
+
export function mountsFrom(config: StorageConfigShape): Mount[] {
|
|
137
|
+
const mounts: Mount[] = [];
|
|
138
|
+
for (const [disk, cfg] of Object.entries(config.disks)) {
|
|
139
|
+
if (!cfg.serve) continue;
|
|
140
|
+
// Only local disks have a filesystem root to police. An S3 bucket is
|
|
141
|
+
// outside the storage root by nature, and its exposure is the bucket's own
|
|
142
|
+
// policy to decide.
|
|
143
|
+
if (cfg.driver === "local" && !cfg.serve.signed && !isInsidePublicRoot(cfg.root)) {
|
|
144
|
+
throw new UnsafePublicMountError(disk, resolve(cfg.root), publicRoot());
|
|
145
|
+
}
|
|
146
|
+
mounts.push({ disk, prefix: `/${cfg.serve.path.replace(/^\/+|\/+$/g, "")}`, serve: cfg.serve });
|
|
147
|
+
}
|
|
148
|
+
// Longest prefix first, so `/storage/private` is matched before `/storage`.
|
|
149
|
+
return mounts.sort((a, b) => b.prefix.length - a.prefix.length);
|
|
150
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type { StorageDriver, StorageConfigShape } from "./types.ts";
|
|
2
|
+
import { DiskNotConfiguredError, DiskNotServedError } from "./errors.ts";
|
|
3
|
+
import { LocalDriver } from "./drivers/LocalDriver.ts";
|
|
4
|
+
import { S3Driver } from "./drivers/S3Driver.ts";
|
|
5
|
+
import { FakeDisk } from "./FakeDisk.ts";
|
|
6
|
+
|
|
7
|
+
export class StorageManager {
|
|
8
|
+
private _config: StorageConfigShape;
|
|
9
|
+
private _drivers: Map<string, StorageDriver> = new Map();
|
|
10
|
+
/** Drivers displaced by {@link fake}, keyed by disk name, for {@link restoreFakes}. */
|
|
11
|
+
private _realDrivers: Map<string, StorageDriver | undefined> = new Map();
|
|
12
|
+
|
|
13
|
+
constructor(config: StorageConfigShape) {
|
|
14
|
+
this._config = config;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Get the driver for the named disk (or the default disk if omitted).
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* Storage.disk('local').put('avatars/alice.jpg', buffer);
|
|
22
|
+
* Storage.disk('s3').get('reports/2025.pdf');
|
|
23
|
+
* Storage.disk().exists('tmp/cache.json');
|
|
24
|
+
*/
|
|
25
|
+
disk(name?: string): StorageDriver {
|
|
26
|
+
const diskName = name ?? this._config.default;
|
|
27
|
+
|
|
28
|
+
const cached = this._drivers.get(diskName);
|
|
29
|
+
if (cached) return cached;
|
|
30
|
+
|
|
31
|
+
const cfg = this._config.disks[diskName];
|
|
32
|
+
if (!cfg) throw new DiskNotConfiguredError(diskName);
|
|
33
|
+
|
|
34
|
+
let driver: StorageDriver;
|
|
35
|
+
|
|
36
|
+
// A served disk's URL base defaults to where it is mounted, so `url()` can
|
|
37
|
+
// never point somewhere nothing answers. An explicit `url` still wins — that
|
|
38
|
+
// is how you put a CDN in front.
|
|
39
|
+
const base = cfg.url ?? cfg.serve?.path;
|
|
40
|
+
|
|
41
|
+
if (cfg.driver === "local") {
|
|
42
|
+
driver = new LocalDriver(cfg.root, base);
|
|
43
|
+
} else {
|
|
44
|
+
driver = new S3Driver(cfg.key, cfg.secret, cfg.region, cfg.bucket, cfg.endpoint, base);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
this._drivers.set(diskName, driver);
|
|
48
|
+
return driver;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Swap a disk for an in-memory {@link FakeDisk} and return it, so a test can
|
|
53
|
+
* exercise uploads without writing to the filesystem or reaching S3.
|
|
54
|
+
*
|
|
55
|
+
* The fake stays installed until {@link restoreFakes} — call that in an
|
|
56
|
+
* `afterEach`, or a later test expecting real storage will silently write
|
|
57
|
+
* into memory and find its files gone.
|
|
58
|
+
*
|
|
59
|
+
* @param name - Disk to replace; defaults to the configured default disk.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* const disk = Storage.fake('s3');
|
|
63
|
+
* await report.export();
|
|
64
|
+
* disk.assertExists('reports/2025.pdf');
|
|
65
|
+
*/
|
|
66
|
+
fake(name?: string): FakeDisk {
|
|
67
|
+
const diskName = name ?? this._config.default;
|
|
68
|
+
const fake = new FakeDisk(this._config.disks[diskName]?.url ?? "/storage");
|
|
69
|
+
if (!this._realDrivers.has(diskName)) {
|
|
70
|
+
this._realDrivers.set(diskName, this._drivers.get(diskName));
|
|
71
|
+
}
|
|
72
|
+
this._drivers.set(diskName, fake);
|
|
73
|
+
return fake;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Restore every disk swapped by {@link fake}. Call in `afterEach`. */
|
|
77
|
+
restoreFakes(): void {
|
|
78
|
+
for (const [name, driver] of this._realDrivers) {
|
|
79
|
+
if (driver) this._drivers.set(name, driver);
|
|
80
|
+
else this._drivers.delete(name);
|
|
81
|
+
}
|
|
82
|
+
this._realDrivers.clear();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A URL a browser can actually fetch — the one thing a template wants.
|
|
87
|
+
*
|
|
88
|
+
* The disk's own config decides which kind, so the caller does not have to
|
|
89
|
+
* know whether the file is public:
|
|
90
|
+
*
|
|
91
|
+
* - **Served, unsigned** → a permanent URL under the disk's mount.
|
|
92
|
+
* - **Served with `signed`** → a time-limited signed URL.
|
|
93
|
+
* - **Not served** → {@link DiskNotServedError}, rather than a plausible
|
|
94
|
+
* string that 404s.
|
|
95
|
+
*
|
|
96
|
+
* That last case is the point. `url()` on an unserved disk returns a path
|
|
97
|
+
* nothing answers, and a relative one at that — which resolves against
|
|
98
|
+
* whatever page embedded it and produces a broken image somewhere confusing.
|
|
99
|
+
* A URL you cannot fetch is not a URL, so asking for one is an error.
|
|
100
|
+
*
|
|
101
|
+
* @param path - Path on the disk.
|
|
102
|
+
* @param options.disk - Disk name; defaults to the configured default disk.
|
|
103
|
+
* @param options.expiresIn - Seconds a signed link stays valid. Defaults to
|
|
104
|
+
* the disk's `serve.expiresIn`, then 900.
|
|
105
|
+
* @throws {@link DiskNotConfiguredError} when the disk does not exist.
|
|
106
|
+
* @throws {@link DiskNotServedError} when the disk has no public URL.
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* // Public disk → /storage/avatars/alice.jpg
|
|
110
|
+
* await Storage.publicUrl("avatars/alice.jpg", { disk: "public" });
|
|
111
|
+
*
|
|
112
|
+
* // Signed disk → /invoices/q1.pdf?expires=…&signature=…
|
|
113
|
+
* await Storage.publicUrl("q1.pdf", { disk: "invoices" });
|
|
114
|
+
*/
|
|
115
|
+
async publicUrl(
|
|
116
|
+
path: string,
|
|
117
|
+
options: { disk?: string; expiresIn?: number } = {},
|
|
118
|
+
): Promise<string> {
|
|
119
|
+
const diskName = options.disk ?? this._config.default;
|
|
120
|
+
const cfg = this._config.disks[diskName];
|
|
121
|
+
if (!cfg) throw new DiskNotConfiguredError(diskName);
|
|
122
|
+
|
|
123
|
+
if (cfg.serve?.signed) {
|
|
124
|
+
const expiresIn = options.expiresIn ?? cfg.serve.expiresIn ?? 900;
|
|
125
|
+
return this.disk(diskName).temporaryUrl(path, expiresIn);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!cfg.serve && !cfg.url) throw new DiskNotServedError(diskName);
|
|
129
|
+
|
|
130
|
+
return this.disk(diskName).url(path);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Whether `disk` has a public URL at all — a served disk, or one pointed at a
|
|
135
|
+
* CDN. Use it to branch a template without catching an error.
|
|
136
|
+
*/
|
|
137
|
+
isServed(disk?: string): boolean {
|
|
138
|
+
const cfg = this._config.disks[disk ?? this._config.default];
|
|
139
|
+
return Boolean(cfg && (cfg.serve || cfg.url));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Verify a signature produced by a local disk's `temporaryUrl()`. Returns
|
|
144
|
+
* `false` when the link has expired or the signature does not match
|
|
145
|
+
* (constant-time). Wire this into the route that serves protected files:
|
|
146
|
+
*
|
|
147
|
+
* @example
|
|
148
|
+
* Router.get('/files/:path*', ({ params, query, response }) => {
|
|
149
|
+
* const path = params.path;
|
|
150
|
+
* if (!Storage.verifyTemporaryUrl(path, Number(query('expires')), query('signature') ?? '')) {
|
|
151
|
+
* return response.status(403).send('Invalid or expired link');
|
|
152
|
+
* }
|
|
153
|
+
* return response.stream(await Storage.disk().getBuffer(path));
|
|
154
|
+
* });
|
|
155
|
+
*
|
|
156
|
+
* S3 presigned URLs are verified by S3 itself and never pass through here.
|
|
157
|
+
*/
|
|
158
|
+
verifyTemporaryUrl(path: string, expiresAt: number, signature: string): boolean {
|
|
159
|
+
return LocalDriver.verifyTemporaryUrl(path, expiresAt, signature);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Convenience over {@link verifyTemporaryUrl} that reads `?expires=&signature=`
|
|
164
|
+
* from a full request URL and checks them against `path`.
|
|
165
|
+
*/
|
|
166
|
+
verifyTemporaryUrlFor(path: string, url: URL | string): boolean {
|
|
167
|
+
const u = typeof url === "string" ? new URL(url) : url;
|
|
168
|
+
const expires = Number(u.searchParams.get("expires"));
|
|
169
|
+
const signature = u.searchParams.get("signature");
|
|
170
|
+
if (!Number.isFinite(expires) || !signature) return false;
|
|
171
|
+
return LocalDriver.verifyTemporaryUrl(path, expires, signature);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { deepMerge } from "../support/deepMerge.ts";
|
|
3
|
+
import type { StorageConfigShape } from "./types.ts";
|
|
4
|
+
import { storageRoot, publicRoot } from "./root.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The built-in disks, derived from the storage root rather than hardcoding
|
|
8
|
+
* `./storage`.
|
|
9
|
+
*
|
|
10
|
+
* Computed per call because the root is read from the environment: a literal
|
|
11
|
+
* `"./storage/app"` here would sit outside a `ZT_STORAGE_ROOT` pointed at a
|
|
12
|
+
* mounted volume, and the default config would fail its own boundary check.
|
|
13
|
+
*/
|
|
14
|
+
function defaults(): StorageConfigShape {
|
|
15
|
+
return {
|
|
16
|
+
default: "local",
|
|
17
|
+
disks: {
|
|
18
|
+
// The default disk holds private uploads and has no `serve` block, so it
|
|
19
|
+
// is not reachable over HTTP. Hand out links to a file here with
|
|
20
|
+
// `temporaryUrl()`, or move the file to `public`.
|
|
21
|
+
local: { driver: "local", root: join(storageRoot(), "app") },
|
|
22
|
+
// The one public directory. Its filesystem path and its URL are the same
|
|
23
|
+
// shape on purpose — `storage/public/a.png` is `/storage/public/a.png` —
|
|
24
|
+
// so "is this file public?" is answered by where the file lives rather
|
|
25
|
+
// than by which config block someone edited last. No `url` is needed: a
|
|
26
|
+
// served disk's URL base defaults to where it is mounted.
|
|
27
|
+
public: {
|
|
28
|
+
driver: "local",
|
|
29
|
+
root: publicRoot(),
|
|
30
|
+
serve: { path: "/storage/public" },
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function StorageConfig(options: Partial<StorageConfigShape> = {}): StorageConfigShape {
|
|
37
|
+
// `disks` is a name-keyed object, so deepMerge MERGES custom disks with the
|
|
38
|
+
// built-in `local`/`public` defaults rather than replacing them.
|
|
39
|
+
return deepMerge(defaults(), options);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Register this package's config namespace for typed config() dot-paths.
|
|
43
|
+
declare module "@zerotal/core" {
|
|
44
|
+
interface ConfigRegistry {
|
|
45
|
+
storage: StorageConfigShape;
|
|
46
|
+
}
|
|
47
|
+
}
|