@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.
Files changed (201) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/LICENSE +21 -0
  3. package/README.md +128 -0
  4. package/package.json +72 -0
  5. package/src/application/Application.ts +1671 -0
  6. package/src/application/BootDoctor.ts +108 -0
  7. package/src/application/DevErrorPage.ts +567 -0
  8. package/src/application/ExceptionHandler.ts +183 -0
  9. package/src/application/currentApp.ts +73 -0
  10. package/src/assets/assets.ts +79 -0
  11. package/src/assets/index.ts +16 -0
  12. package/src/auth/AuthenticatedUser.ts +18 -0
  13. package/src/build/PackageLinter.ts +146 -0
  14. package/src/build/PackageScaffold.ts +127 -0
  15. package/src/build/codemod.ts +64 -0
  16. package/src/build/index.ts +12 -0
  17. package/src/command/Command.ts +254 -0
  18. package/src/command/CommandRunner.ts +593 -0
  19. package/src/command/OutputWriter.ts +61 -0
  20. package/src/command/builtin/CompileCommand.ts +46 -0
  21. package/src/command/builtin/CssBuildCommand.ts +71 -0
  22. package/src/command/builtin/KeyGenerateCommand.ts +58 -0
  23. package/src/command/builtin/LintPackagesCommand.ts +72 -0
  24. package/src/command/builtin/MakeCommandCommand.ts +85 -0
  25. package/src/command/builtin/MakeControllerCommand.ts +95 -0
  26. package/src/command/builtin/MakeEventCommand.ts +85 -0
  27. package/src/command/builtin/MakeJobCommand.ts +53 -0
  28. package/src/command/builtin/MakeListenerCommand.ts +35 -0
  29. package/src/command/builtin/MakeMiddlewareCommand.ts +63 -0
  30. package/src/command/builtin/MakeNotificationCommand.ts +48 -0
  31. package/src/command/builtin/MakeObserverCommand.ts +78 -0
  32. package/src/command/builtin/MakePackageCommand.ts +45 -0
  33. package/src/command/builtin/MakePolicyCommand.ts +66 -0
  34. package/src/command/builtin/MakeProviderCommand.ts +75 -0
  35. package/src/command/builtin/MakeRequestCommand.ts +47 -0
  36. package/src/command/builtin/MakeResourceCommand.ts +61 -0
  37. package/src/command/builtin/MakeTestCommand.ts +120 -0
  38. package/src/command/builtin/ReloadCommand.ts +52 -0
  39. package/src/command/builtin/ReplCommand.ts +174 -0
  40. package/src/command/builtin/RouteListCommand.ts +188 -0
  41. package/src/command/builtin/ServeCommand.ts +321 -0
  42. package/src/command/builtin/StartCommand.ts +3 -0
  43. package/src/command/builtin/StatusCommand.ts +71 -0
  44. package/src/command/builtin/TestCommand.ts +172 -0
  45. package/src/command/builtin/WorkerCommand.ts +27 -0
  46. package/src/command/builtin/index.ts +53 -0
  47. package/src/command/scaffold/worker.ts.txt +12 -0
  48. package/src/command/scaffold/zerotal.ts.txt +26 -0
  49. package/src/command/startZerotal.ts +55 -0
  50. package/src/config/AppConfig.ts +253 -0
  51. package/src/config/ConfigLoader.ts +117 -0
  52. package/src/config/ConfigManager.ts +169 -0
  53. package/src/config/index.ts +46 -0
  54. package/src/config/registry.ts +59 -0
  55. package/src/config/validation.ts +117 -0
  56. package/src/container/Container.ts +606 -0
  57. package/src/container/ContextualBindingBuilder.ts +57 -0
  58. package/src/container/ScopedResolver.ts +117 -0
  59. package/src/container/index.ts +32 -0
  60. package/src/container/inject.ts +55 -0
  61. package/src/container/types.ts +71 -0
  62. package/src/context/RequestContext.ts +91 -0
  63. package/src/contracts/auth.ts +24 -0
  64. package/src/contracts/index.ts +23 -0
  65. package/src/contracts/session.ts +70 -0
  66. package/src/contracts/transaction.ts +26 -0
  67. package/src/conventions/ConventionLoader.ts +128 -0
  68. package/src/conventions/builtinConcerns.ts +131 -0
  69. package/src/crypt/Crypt.ts +141 -0
  70. package/src/crypt/URLSigner.ts +96 -0
  71. package/src/datetime/Carbon.ts +1396 -0
  72. package/src/datetime/CarbonInterval.ts +421 -0
  73. package/src/datetime/clock.ts +28 -0
  74. package/src/datetime/index.ts +23 -0
  75. package/src/datetime/temporal-shim.ts +1 -0
  76. package/src/dev/BuildOutput.ts +131 -0
  77. package/src/dev/CssPlugins.ts +184 -0
  78. package/src/dev/DevBuildHook.ts +74 -0
  79. package/src/dev/DevOrchestrator.ts +213 -0
  80. package/src/dev/DevReloadMiddleware.ts +101 -0
  81. package/src/dev/DevReloadServer.ts +85 -0
  82. package/src/dev/DevWsServer.ts +45 -0
  83. package/src/dev/index.ts +19 -0
  84. package/src/dev/reloadClient.ts +39 -0
  85. package/src/env/Def.ts +232 -0
  86. package/src/env/EnvSchema.ts +105 -0
  87. package/src/env/index.ts +34 -0
  88. package/src/env/t.ts +128 -0
  89. package/src/errors/ConfigError.ts +12 -0
  90. package/src/errors/ContainerErrors.ts +143 -0
  91. package/src/errors/HttpError.ts +127 -0
  92. package/src/errors/ValidationError.ts +19 -0
  93. package/src/errors/ZerotalError.ts +25 -0
  94. package/src/errors/index.ts +46 -0
  95. package/src/events/CallQueuedListener.ts +66 -0
  96. package/src/events/Emitter.ts +280 -0
  97. package/src/events/EventFake.ts +160 -0
  98. package/src/events/FrameworkEvents.ts +252 -0
  99. package/src/facade/Facade.ts +101 -0
  100. package/src/facade/facades/App.ts +155 -0
  101. package/src/facade/facades/Artisan.ts +63 -0
  102. package/src/facade/facades/Config.ts +21 -0
  103. package/src/facade/facades/Events.ts +19 -0
  104. package/src/facade/facades/index.ts +28 -0
  105. package/src/global.d.ts +9 -0
  106. package/src/hash/Hash.ts +60 -0
  107. package/src/health/Health.ts +221 -0
  108. package/src/health/index.ts +27 -0
  109. package/src/helpers/Collection.ts +435 -0
  110. package/src/helpers/config.ts +59 -0
  111. package/src/helpers/fluent.ts +52 -0
  112. package/src/helpers/html.ts +11 -0
  113. package/src/helpers/index.ts +266 -0
  114. package/src/helpers/make.ts +35 -0
  115. package/src/helpers/markdown.ts +73 -0
  116. package/src/helpers/pageElements.ts +27 -0
  117. package/src/helpers/request.ts +62 -0
  118. package/src/helpers/response.ts +411 -0
  119. package/src/helpers/str.ts +208 -0
  120. package/src/http/Http.ts +298 -0
  121. package/src/http/HttpClient.ts +289 -0
  122. package/src/http/Resource.ts +171 -0
  123. package/src/http/UploadedFile.ts +204 -0
  124. package/src/http/Uri.ts +490 -0
  125. package/src/http/index.ts +46 -0
  126. package/src/http/negotiate.ts +213 -0
  127. package/src/http/originGuard.ts +76 -0
  128. package/src/http/sniffContentType.ts +105 -0
  129. package/src/http/url.ts +204 -0
  130. package/src/http/withHeaders.ts +24 -0
  131. package/src/index.ts +250 -0
  132. package/src/lock/LockManager.ts +228 -0
  133. package/src/lock/config.ts +49 -0
  134. package/src/lock/drivers/LockDriver.ts +32 -0
  135. package/src/lock/drivers/MemoryLockDriver.ts +52 -0
  136. package/src/lock/drivers/RedisLockDriver.ts +58 -0
  137. package/src/lock/drivers/SqliteLockDriver.ts +85 -0
  138. package/src/lock/errors.ts +20 -0
  139. package/src/lock/facades/Lock.ts +114 -0
  140. package/src/lock/index.ts +53 -0
  141. package/src/logger/Log.ts +35 -0
  142. package/src/logger/LogManager.ts +430 -0
  143. package/src/logger/LoggerMiddleware.ts +125 -0
  144. package/src/logger/channels/ConsoleChannel.ts +139 -0
  145. package/src/logger/channels/DailyChannel.ts +74 -0
  146. package/src/logger/channels/NullChannel.ts +17 -0
  147. package/src/logger/channels/SingleChannel.ts +34 -0
  148. package/src/logger/channels/StackChannel.ts +29 -0
  149. package/src/logger/config.ts +90 -0
  150. package/src/logger/format.ts +96 -0
  151. package/src/logger/frameworkLog.ts +93 -0
  152. package/src/logger/index.ts +68 -0
  153. package/src/logger/renderTable.ts +111 -0
  154. package/src/logger/types.ts +212 -0
  155. package/src/macros/config.macro.ts +50 -0
  156. package/src/metrics/HttpMetrics.ts +114 -0
  157. package/src/metrics/index.ts +18 -0
  158. package/src/middleware/BaseMiddleware.ts +72 -0
  159. package/src/middleware/CorsMiddleware.ts +152 -0
  160. package/src/middleware/RateLimiter.ts +255 -0
  161. package/src/middleware/SecureHeadersMiddleware.ts +127 -0
  162. package/src/middleware/ThrottleMiddleware.ts +252 -0
  163. package/src/middleware/WebhookMiddleware.ts +204 -0
  164. package/src/pipeline/ContextRegistry.ts +42 -0
  165. package/src/pipeline/HttpContext.ts +865 -0
  166. package/src/pipeline/Pipeline.ts +150 -0
  167. package/src/pipeline/currentPage.ts +46 -0
  168. package/src/pipeline/types.ts +80 -0
  169. package/src/provider/LockProvider.ts +64 -0
  170. package/src/provider/LogProvider.ts +137 -0
  171. package/src/provider/ServiceProvider.ts +84 -0
  172. package/src/provider/StorageProvider.ts +45 -0
  173. package/src/router/FileRouter.ts +526 -0
  174. package/src/router/Route.ts +76 -0
  175. package/src/router/RouteHandler.ts +335 -0
  176. package/src/router/Router.ts +1247 -0
  177. package/src/router/domain.ts +65 -0
  178. package/src/security/index.ts +22 -0
  179. package/src/storage/FakeDisk.ts +233 -0
  180. package/src/storage/StorageFilesMiddleware.ts +150 -0
  181. package/src/storage/StorageManager.ts +173 -0
  182. package/src/storage/config.ts +47 -0
  183. package/src/storage/drivers/LocalDriver.ts +138 -0
  184. package/src/storage/drivers/S3Driver.ts +169 -0
  185. package/src/storage/errors.ts +135 -0
  186. package/src/storage/facades/Storage.ts +3 -0
  187. package/src/storage/global.d.ts +7 -0
  188. package/src/storage/index.ts +22 -0
  189. package/src/storage/root.ts +59 -0
  190. package/src/storage/types.ts +104 -0
  191. package/src/support/appKey.ts +38 -0
  192. package/src/support/cookie.ts +72 -0
  193. package/src/support/crypto.ts +52 -0
  194. package/src/support/deepMerge.ts +117 -0
  195. package/src/support/env.ts +71 -0
  196. package/src/support/network.ts +79 -0
  197. package/src/support/port.ts +197 -0
  198. package/src/support/str.ts +122 -0
  199. package/src/view/FileRouteResolver.ts +59 -0
  200. package/src/view/index.ts +144 -0
  201. 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
+ }