@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,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API Resource transformer — converts models to JSON response shapes.
|
|
3
|
+
*
|
|
4
|
+
* Extend this class, implement toArray(), and use it in controllers to
|
|
5
|
+
* separate presentation from persistence. Wraps data in a `{ data: ... }`
|
|
6
|
+
* envelope by default (toggle with `Resource.withoutWrapping()`).
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* export class UserResource extends Resource<User> {
|
|
10
|
+
* toArray(): Record<string, unknown> {
|
|
11
|
+
* return {
|
|
12
|
+
* id: this.resource.id,
|
|
13
|
+
* name: this.resource.name,
|
|
14
|
+
* email: this.resource.email,
|
|
15
|
+
* };
|
|
16
|
+
* }
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* // In a controller:
|
|
20
|
+
* ctx.json(new UserResource(user).toJson()); // { data: { id, name, email } }
|
|
21
|
+
* ctx.json(UserResource.collection(UserResource, users)); // { data: [...] }
|
|
22
|
+
* ctx.response = new UserResource(user).toResponse(); // 200 application/json
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Minimal shape required to build a ResourceCollection.
|
|
27
|
+
* Structurally compatible with PaginateResult<T> from @zerotal/orm so no
|
|
28
|
+
* cross-package import is needed.
|
|
29
|
+
*/
|
|
30
|
+
export interface PaginatedData<T> {
|
|
31
|
+
data: T[];
|
|
32
|
+
total: number;
|
|
33
|
+
page: number;
|
|
34
|
+
perPage: number;
|
|
35
|
+
lastPage: number;
|
|
36
|
+
nextPageUrl(baseUrl?: string, query?: Record<string, string>): string | null;
|
|
37
|
+
previousPageUrl(baseUrl?: string, query?: Record<string, string>): string | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Process-level toggle for the `{ data: ... }` envelope. Intentionally
|
|
41
|
+
// module-global: wrapping is an app-wide presentation choice set once at boot,
|
|
42
|
+
// not per-request state.
|
|
43
|
+
let _wrap = true;
|
|
44
|
+
|
|
45
|
+
export abstract class Resource<T> {
|
|
46
|
+
constructor(protected readonly resource: T) {}
|
|
47
|
+
|
|
48
|
+
/** Override this to define the serialized representation. */
|
|
49
|
+
abstract toArray(): Record<string, unknown>;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Return the resource as a plain object.
|
|
53
|
+
* Includes the `{ data: ... }` wrapper unless `withoutWrapping()` was called.
|
|
54
|
+
*/
|
|
55
|
+
toJson(): Record<string, unknown> {
|
|
56
|
+
const payload = this.toArray();
|
|
57
|
+
return _wrap ? { data: payload } : payload;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Return a `Response` with the JSON-serialized resource and the given status.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* ctx.response = new UserResource(user).toResponse(201);
|
|
65
|
+
*/
|
|
66
|
+
toResponse(status = 200): Response {
|
|
67
|
+
return Response.json(this.toJson(), { status });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Static helpers ────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Serialize a collection of models, wrapping the array in `{ data: [...] }`.
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* ctx.json(UserResource.collection(UserResource, users));
|
|
77
|
+
*/
|
|
78
|
+
static collection<T, R extends Resource<T>>(
|
|
79
|
+
ResourceClass: new (item: T) => R,
|
|
80
|
+
items: T[],
|
|
81
|
+
meta?: Record<string, unknown>,
|
|
82
|
+
): Record<string, unknown> | Record<string, unknown>[] {
|
|
83
|
+
const data = items.map((item) => new ResourceClass(item).toArray());
|
|
84
|
+
// Flat mode returns the bare array; a meta object always needs an envelope.
|
|
85
|
+
if (!_wrap && !meta) return data;
|
|
86
|
+
const result: Record<string, unknown> = { data };
|
|
87
|
+
if (meta) Object.assign(result, { meta });
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Disable the `{ data: ... }` wrapping for all resources in this request.
|
|
93
|
+
* Call once at the application level if you prefer flat responses.
|
|
94
|
+
*/
|
|
95
|
+
static withoutWrapping(): void {
|
|
96
|
+
_wrap = false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Re-enable the `{ data: ... }` wrapper (the default). */
|
|
100
|
+
static withWrapping(): void {
|
|
101
|
+
_wrap = true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Returns whether wrapping is currently enabled. */
|
|
105
|
+
static wrapping(): boolean {
|
|
106
|
+
return _wrap;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Paginated API resource collection — wraps a PaginateResult into the standard
|
|
112
|
+
* `{ data, meta, links }` envelope.
|
|
113
|
+
*
|
|
114
|
+
* @example
|
|
115
|
+
* // In a controller:
|
|
116
|
+
* const posts = await Post.query().paginate(15, page);
|
|
117
|
+
* ctx.json(ResourceCollection.of(PostResource, posts));
|
|
118
|
+
* // → {
|
|
119
|
+
* // data: [...],
|
|
120
|
+
* // meta: { current_page, last_page, total, per_page, from, to },
|
|
121
|
+
* // links: { first, prev, next, last }
|
|
122
|
+
* // }
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* // With a base URL for fully-qualified pagination links:
|
|
126
|
+
* ctx.json(ResourceCollection.of(PostResource, posts, '/api/posts'));
|
|
127
|
+
*/
|
|
128
|
+
export class ResourceCollection {
|
|
129
|
+
/**
|
|
130
|
+
* Transform a paginated result set using the given Resource class.
|
|
131
|
+
*
|
|
132
|
+
* @param ResourceClass - The Resource subclass to transform each item.
|
|
133
|
+
* @param paginated - A PaginateResult (or any object matching PaginatedData<T>).
|
|
134
|
+
* @param baseUrl - Optional base URL; used to build `links.*` href strings.
|
|
135
|
+
* @param query - Extra query params merged into every pagination link.
|
|
136
|
+
*/
|
|
137
|
+
static of<T, R extends Resource<T>>(
|
|
138
|
+
ResourceClass: new (item: T) => R,
|
|
139
|
+
paginated: PaginatedData<T>,
|
|
140
|
+
baseUrl = "",
|
|
141
|
+
query: Record<string, string> = {},
|
|
142
|
+
): Record<string, unknown> {
|
|
143
|
+
const data = paginated.data.map((item) => new ResourceClass(item).toArray());
|
|
144
|
+
|
|
145
|
+
const from = paginated.data.length > 0 ? (paginated.page - 1) * paginated.perPage + 1 : null;
|
|
146
|
+
const to =
|
|
147
|
+
paginated.data.length > 0
|
|
148
|
+
? (paginated.page - 1) * paginated.perPage + paginated.data.length
|
|
149
|
+
: null;
|
|
150
|
+
|
|
151
|
+
const meta = {
|
|
152
|
+
current_page: paginated.page,
|
|
153
|
+
last_page: paginated.lastPage,
|
|
154
|
+
total: paginated.total,
|
|
155
|
+
per_page: paginated.perPage,
|
|
156
|
+
from,
|
|
157
|
+
to,
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const links = {
|
|
161
|
+
first: baseUrl ? `${baseUrl}?${new URLSearchParams({ ...query, page: "1" })}` : null,
|
|
162
|
+
prev: paginated.previousPageUrl(baseUrl, query),
|
|
163
|
+
next: paginated.nextPageUrl(baseUrl, query),
|
|
164
|
+
last: baseUrl
|
|
165
|
+
? `${baseUrl}?${new URLSearchParams({ ...query, page: String(paginated.lastPage) })}`
|
|
166
|
+
: null,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
return { data, meta, links };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { sniffContentType, type SniffedType } from "./sniffContentType.ts";
|
|
2
|
+
import type { StorageDriver } from "../storage/types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The part of a storage disk `UploadedFile.store()` needs: somewhere to put
|
|
6
|
+
* bytes, and a URL for what was put.
|
|
7
|
+
*
|
|
8
|
+
* Narrower than {@link StorageDriver} on purpose — `store()` has no business
|
|
9
|
+
* with `delete` or `temporaryUrl` — but it is now a view of the real contract
|
|
10
|
+
* rather than a look-alike, so the two cannot drift.
|
|
11
|
+
*/
|
|
12
|
+
export type StorageDisk = Pick<StorageDriver, "url"> & {
|
|
13
|
+
put(path: string, content: Uint8Array, options?: { contentType?: string }): Promise<void>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Rules for {@link UploadedFile.isValid}. Omit a field to skip that check; an
|
|
18
|
+
* empty options object only verifies that a non-empty file was received.
|
|
19
|
+
*/
|
|
20
|
+
export interface FileValidationOptions {
|
|
21
|
+
/** Maximum allowed size in bytes. */
|
|
22
|
+
maxSize?: number;
|
|
23
|
+
/** Allowed MIME types — e.g. `['image/jpeg', 'image/png', 'application/pdf']`. */
|
|
24
|
+
mimes?: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Wraps a browser `File` from a multipart form upload and adds Zerotal-native
|
|
29
|
+
* validation and one-line storage.
|
|
30
|
+
*
|
|
31
|
+
* Instances are created by `HttpContext.file()` / `HttpContext.files()`.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* async updateAvatar(ctx: HttpContext): HttpResponse {
|
|
35
|
+
* const avatar = await ctx.file('avatar');
|
|
36
|
+
*
|
|
37
|
+
* if (!avatar?.isValid({ maxSize: 2 * 1024 * 1024, mimes: ['image/jpeg', 'image/png'] })) {
|
|
38
|
+
* return redirect().back().withErrors({ avatar: 'Must be a JPEG or PNG under 2 MB.' });
|
|
39
|
+
* }
|
|
40
|
+
*
|
|
41
|
+
* const path = await avatar.store('avatars', Storage.disk());
|
|
42
|
+
* await ctx.user!.update({ avatarPath: path });
|
|
43
|
+
* return redirect('/profile').withSuccess('Avatar updated!');
|
|
44
|
+
* }
|
|
45
|
+
*/
|
|
46
|
+
export class UploadedFile {
|
|
47
|
+
readonly #file: File;
|
|
48
|
+
|
|
49
|
+
constructor(file: File) {
|
|
50
|
+
this.#file = file;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Original filename as sent by the browser (may be untrusted — sanitise before display). */
|
|
54
|
+
get originalName(): string {
|
|
55
|
+
return this.#file.name;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** MIME type as reported by the browser (e.g. `'image/jpeg'`). */
|
|
59
|
+
get mimeType(): string {
|
|
60
|
+
return this.#file.type;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** File size in bytes. */
|
|
64
|
+
get size(): number {
|
|
65
|
+
return this.#file.size;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Lowercase extension derived from the original filename, without the dot, stripped to
|
|
70
|
+
* `[a-z0-9]`.
|
|
71
|
+
*
|
|
72
|
+
* The filename is client-supplied, so anything outside that set — a null byte, a slash,
|
|
73
|
+
* a `%00` — is a smuggling attempt rather than an extension. This is what the *client
|
|
74
|
+
* called* the file; {@link store} does not trust it when naming what it writes.
|
|
75
|
+
*/
|
|
76
|
+
extension(): string {
|
|
77
|
+
const parts = this.#file.name.split(".");
|
|
78
|
+
if (parts.length < 2) return "";
|
|
79
|
+
return parts[parts.length - 1]!.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Returns `true` when the file satisfies all given rules.
|
|
84
|
+
* Pass no options to simply confirm a file was received.
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* avatar.isValid({ maxSize: 5 * 1024 * 1024, mimes: ['image/jpeg', 'image/png'] })
|
|
88
|
+
*/
|
|
89
|
+
isValid(options: FileValidationOptions = {}): boolean {
|
|
90
|
+
if (this.#file.size === 0) return false;
|
|
91
|
+
if (options.maxSize !== undefined && this.#file.size > options.maxSize) return false;
|
|
92
|
+
if (options.mimes && options.mimes.length > 0) {
|
|
93
|
+
if (!options.mimes.includes(this.#file.type)) return false;
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Read the file as raw bytes. */
|
|
99
|
+
async bytes(): Promise<Uint8Array> {
|
|
100
|
+
return new Uint8Array(await this.#file.arrayBuffer());
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Read the file as a UTF-8 string. */
|
|
104
|
+
async text(): Promise<string> {
|
|
105
|
+
return this.#file.text();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Write the file to a disk and return the stored path.
|
|
110
|
+
*
|
|
111
|
+
* The filename defaults to `<uuid>.<ext>` — predictable, safe, and collision-free.
|
|
112
|
+
* Pass `filename` to override it.
|
|
113
|
+
*
|
|
114
|
+
* **Both the extension and the stored `Content-Type` come from the file's own bytes,
|
|
115
|
+
* not from the client.** The multipart part's `Content-Type` and the filename's suffix
|
|
116
|
+
* are claims the uploader controls: `avatar` sent as `x.html` with `text/html` would
|
|
117
|
+
* otherwise be written as `avatars/<uuid>.html` and served as HTML, which is stored XSS
|
|
118
|
+
* on whatever origin serves the disk. Bytes that match no known format are stored as
|
|
119
|
+
* `application/octet-stream` with a `.bin` extension, which downloads rather than
|
|
120
|
+
* executes. See {@link sniffContentType}.
|
|
121
|
+
*
|
|
122
|
+
* An explicit `filename` is taken at face value — you chose it, so it is yours to get
|
|
123
|
+
* right — but the sniffed content type still applies.
|
|
124
|
+
*
|
|
125
|
+
* @param directory Target directory, e.g. `'avatars'` or `'uploads/docs'`
|
|
126
|
+
* @param disk Any `StorageDisk` — typically `Storage.disk()` or `Storage.disk('s3')`
|
|
127
|
+
* @param filename Optional override; defaults to `<uuid>.<sniffed-ext>`
|
|
128
|
+
* @returns The stored path, e.g. `'avatars/f47ac10b.jpg'`
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* const path = await avatar.store('avatars', Storage.disk());
|
|
132
|
+
* const path = await avatar.store('docs', Storage.disk('s3'), 'terms-v2.pdf');
|
|
133
|
+
*/
|
|
134
|
+
async store(directory: string, disk: StorageDisk, filename?: string): Promise<string> {
|
|
135
|
+
const bytes = await this.bytes();
|
|
136
|
+
const sniffed = sniffContentType(bytes);
|
|
137
|
+
const name = filename ?? `${crypto.randomUUID()}.${sniffed.extension}`;
|
|
138
|
+
const path = `${directory.replace(/\/+$/, "")}/${name}`;
|
|
139
|
+
|
|
140
|
+
await disk.put(path, bytes, { contentType: sniffed.contentType });
|
|
141
|
+
|
|
142
|
+
return path;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* What the file's own bytes say it is, ignoring both client-supplied claims.
|
|
147
|
+
*
|
|
148
|
+
* Use it to reject an upload whose contents disagree with its declared type — a `.jpg`
|
|
149
|
+
* whose bytes are a ZIP, say — before storing it.
|
|
150
|
+
*
|
|
151
|
+
* @returns The detected content type, canonical extension, and whether detection succeeded.
|
|
152
|
+
* @example
|
|
153
|
+
* const { contentType, recognised } = await avatar.detectType();
|
|
154
|
+
* if (!recognised || !contentType.startsWith('image/')) return badRequest('Not an image.');
|
|
155
|
+
*/
|
|
156
|
+
async detectType(): Promise<SniffedType> {
|
|
157
|
+
return sniffContentType(await this.bytes());
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Store the file and immediately return its public URL.
|
|
162
|
+
*
|
|
163
|
+
* @example
|
|
164
|
+
* const url = await avatar.storeAndGetUrl('avatars', Storage.disk('s3'));
|
|
165
|
+
* await user.update({ avatarUrl: url });
|
|
166
|
+
*/
|
|
167
|
+
async storeAndGetUrl(directory: string, disk: StorageDisk, filename?: string): Promise<string> {
|
|
168
|
+
return disk.url(await this.store(directory, disk, filename));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Build an `UploadedFile` from scratch, for unit-testing the code that
|
|
173
|
+
* receives one without going through a multipart request.
|
|
174
|
+
*
|
|
175
|
+
* The contents are arbitrary bytes, so the type this reports is the type you
|
|
176
|
+
* declare — which is the point for a size or extension check, but means
|
|
177
|
+
* {@link detectType} and {@link store} will see unrecognised bytes and fall
|
|
178
|
+
* back to `application/octet-stream`. When the test turns on what the bytes
|
|
179
|
+
* actually are, build a real one with `fakeFile` from `@zerotal/testing` and
|
|
180
|
+
* pass it here.
|
|
181
|
+
*
|
|
182
|
+
* @param name - Filename the "client" sent.
|
|
183
|
+
* @param options.type - MIME type to report from {@link mimeType}.
|
|
184
|
+
* @param options.size - Size in bytes; the file is padded to it.
|
|
185
|
+
* @param options.content - Exact contents, overriding `size`.
|
|
186
|
+
*
|
|
187
|
+
* @example
|
|
188
|
+
* const file = UploadedFile.fake('avatar.png', { type: 'image/png', size: 1024 });
|
|
189
|
+
* expect(file.isValid({ maxSize: 2048, mimes: ['image/png'] })).toBe(true);
|
|
190
|
+
*/
|
|
191
|
+
static fake(
|
|
192
|
+
name = "file.txt",
|
|
193
|
+
options: {
|
|
194
|
+
type?: string;
|
|
195
|
+
size?: number;
|
|
196
|
+
content?: string | Uint8Array<ArrayBuffer> | File;
|
|
197
|
+
} = {},
|
|
198
|
+
): UploadedFile {
|
|
199
|
+
const { type = "application/octet-stream", size = 1024, content } = options;
|
|
200
|
+
if (content instanceof File) return new UploadedFile(content);
|
|
201
|
+
const parts: BlobPart[] = content === undefined ? [new Uint8Array(size)] : [content];
|
|
202
|
+
return new UploadedFile(new File(parts, name, { type }));
|
|
203
|
+
}
|
|
204
|
+
}
|