@12-apps/storage 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.
@@ -0,0 +1,96 @@
1
+ import { Hono } from 'hono';
2
+ import type { Context } from 'hono';
3
+
4
+ import {
5
+ createApiStorage,
6
+ type ApiStorage,
7
+ type ApiStorageConfig,
8
+ } from '../server/create-api-storage';
9
+ import type { StorageActor, StorageRouteResponse } from '../server/routes';
10
+
11
+ /**
12
+ * `@12-apps/storage/hono` — the upload surface as a mountable router.
13
+ *
14
+ * The framework-neutral descriptors in `/server` are the contract; this is the
15
+ * adapter for the framework we happen to use, behind its own subpath with `hono`
16
+ * as an OPTIONAL peer — a host on Express, or one that only wants the React half,
17
+ * never resolves it.
18
+ *
19
+ * A host writes:
20
+ *
21
+ * const storage = storageRouter({ …config, resolveActor });
22
+ * app.route('/api', storage.router);
23
+ *
24
+ * and keeps what is genuinely its own: who the caller is, and which tenant they
25
+ * are acting for. The wildcard the serve route needs is spelled here (`:key{.+}`)
26
+ * rather than in the descriptor, because the syntax is this framework's.
27
+ */
28
+
29
+ /**
30
+ * Resolve the caller. `null` is unauthenticated and answers 401 before any
31
+ * handler runs — which is also where a host's own gates belong (a read-only
32
+ * impersonation, a plan that does not include uploads): answered before
33
+ * delegating, never inside the package.
34
+ */
35
+ export type ResolveStorageActor = (
36
+ c: Context,
37
+ ) => Promise<StorageActor | null> | StorageActor | null;
38
+
39
+ export interface StorageHonoConfig extends ApiStorageConfig {
40
+ resolveActor: ResolveStorageActor;
41
+ /** 401 body message. pt-BR by default, like every other refusal here. */
42
+ unauthenticatedMessage?: string;
43
+ }
44
+
45
+ export interface StorageHono extends ApiStorage {
46
+ router: Hono;
47
+ }
48
+
49
+ /** A response the descriptors chose, rendered in this framework's terms. */
50
+ function send(c: Context, response: StorageRouteResponse): Response {
51
+ if ('redirect' in response) return c.redirect(response.redirect, 302);
52
+ if ('bytes' in response) {
53
+ // The bytes as their own ArrayBuffer: a Uint8Array over a pooled buffer would
54
+ // hand the client whatever else shares that allocation.
55
+ const body = response.bytes.slice().buffer as ArrayBuffer;
56
+ return c.body(body, response.status as 200, {
57
+ ...response.headers,
58
+ 'content-length': String(response.bytes.byteLength),
59
+ });
60
+ }
61
+ // The status travels with the body the handler chose; this adapter never
62
+ // reinterprets either, or the two halves of the contract would drift — which is
63
+ // the whole reason the handlers live in the package.
64
+ return c.json(response.body as Record<string, unknown>, response.status as 200);
65
+ }
66
+
67
+ export function storageRouter(config: StorageHonoConfig): StorageHono {
68
+ const { resolveActor, unauthenticatedMessage, ...apiConfig } = config;
69
+ const api = createApiStorage(apiConfig);
70
+ const unauthenticated = unauthenticatedMessage ?? 'Não autenticado.';
71
+ const router = new Hono();
72
+
73
+ for (const route of api.routes) {
74
+ const handler = async (c: Context): Promise<Response> => {
75
+ const actor = route.auth === 'public' ? null : await resolveActor(c);
76
+ if (route.auth === 'actor' && !actor) {
77
+ return c.json({ error: unauthenticated }, 401);
78
+ }
79
+ const response = await route.handle({
80
+ actor,
81
+ params: c.req.param() as Record<string, string | undefined>,
82
+ request: c.req.raw,
83
+ });
84
+ return send(c, response);
85
+ };
86
+
87
+ // `:key{.+}` and not `*`: the whole remainder is one parameter, which is what
88
+ // makes a nested key (`products/<scope>/<uuid>/card-320.webp`) arrive intact
89
+ // instead of as a path Hono has already split.
90
+ const path = route.wildcardParam ? `${route.path}/:${route.wildcardParam}{.+}` : route.path;
91
+ if (route.method === 'GET') router.get(path, handler);
92
+ else router.post(path, handler);
93
+ }
94
+
95
+ return { ...api, router };
96
+ }
package/src/index.ts ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * `@12-apps/storage` — uploads that land at YOUR OWN ORIGIN.
3
+ *
4
+ * The root entry is the isomorphic half: the format allowlist, the upload
5
+ * ceiling, the object-key grammar, the rendition list, the refusal taxonomy and
6
+ * the wire schemas. Both other halves are built on it, which is what keeps the
7
+ * browser and the server agreeing about what an upload is.
8
+ *
9
+ * The design decision this package exists to preserve: **the browser PUTs at our
10
+ * own origin, never at the bucket.** A presigned URL makes a working upload
11
+ * depend on the bucket's CORS rules — configuration that lives in neither the
12
+ * host repository nor any deploy it runs. When it is missing, the preflight is
13
+ * refused, THE SERVER SEES NOTHING (no request arrives, no log line, no error
14
+ * rate), the browser cannot say why (a blocked cross-origin fetch is
15
+ * deliberately indistinguishable from an offline network), and the message
16
+ * reaches a store owner about a bucket only an operator can configure. There is
17
+ * therefore no "just presign it" mode here, and adding one would reintroduce all
18
+ * three symptoms at once.
19
+ *
20
+ * A second reason it cannot come back: a stored photo is a SET of objects the
21
+ * server derives from the bytes, so an upload the server never sees cannot
22
+ * produce one.
23
+ */
24
+
25
+ export {
26
+ ACCEPTED_CONTENT_TYPES,
27
+ CONTENT_TYPE_BY_EXTENSION,
28
+ EXTENSION_BY_CONTENT_TYPE,
29
+ acceptedContentTypeOf,
30
+ isAcceptedContentType,
31
+ } from './content-types';
32
+
33
+ export { DEFAULT_MAX_UPLOAD_BYTES, maxBase64Length, megabytes } from './limits';
34
+
35
+ export { STORAGE_PATHS } from './paths';
36
+
37
+ export {
38
+ DEFAULT_KEY_PREFIX,
39
+ hasRenditions,
40
+ isLegalScope,
41
+ isObjectKey,
42
+ keyInScope,
43
+ mintObjectKey,
44
+ mintObjectSetKey,
45
+ parseObjectKey,
46
+ parseObjectMemberKey,
47
+ renditionKey,
48
+ type MintKeyInput,
49
+ type ObjectKey,
50
+ type ObjectMemberKey,
51
+ } from './keys';
52
+
53
+ export {
54
+ CATALOG_RENDITIONS,
55
+ RENDITION_CONTENT_TYPE,
56
+ renditionFamilies,
57
+ type RenditionFamily,
58
+ type RenditionOutput,
59
+ type RenditionSpec,
60
+ } from './renditions';
61
+
62
+ export {
63
+ decodeImagePayload,
64
+ sniffImageContentType,
65
+ verifyImageBytes,
66
+ type DecodedPayload,
67
+ } from './payload';
68
+
69
+ export {
70
+ STORAGE_PROBLEM_STATUS,
71
+ StorageNotConfiguredError,
72
+ StorageProblemError,
73
+ defaultStorageMessages,
74
+ isStorageProblem,
75
+ type StorageMessageContext,
76
+ type StorageMessages,
77
+ type StorageProblem,
78
+ } from './problems';
79
+
80
+ export {
81
+ imageSources,
82
+ objectUrl,
83
+ versionedObjectUrl,
84
+ type ImageRenditionSet,
85
+ type ImageSources,
86
+ type ImageSourcesOptions,
87
+ type PublicUrlResolver,
88
+ } from './urls';
89
+
90
+ export {
91
+ inlineImageSchema,
92
+ objectKeySchema,
93
+ refineImageInput,
94
+ uploadAcceptedSchema,
95
+ type ImageFields,
96
+ type InlineImage,
97
+ type UploadAccepted,
98
+ } from './wire';
package/src/keys.ts ADDED
@@ -0,0 +1,331 @@
1
+ import { EXTENSION_BY_CONTENT_TYPE } from './content-types';
2
+
3
+ /**
4
+ * The object key: what it looks like, what its SHAPE promises, and which
5
+ * tenant it belongs to.
6
+ *
7
+ * ## Server-minted, always
8
+ *
9
+ * The key is what lands in the host's database and what a serve route resolves,
10
+ * and stored objects are world-readable (a store's logo is printed in its own
11
+ * storefront HTML). A caller that could choose its own key could therefore
12
+ * write over any object whose URL it can read.
13
+ *
14
+ * ## Two shapes, and the difference is a FACT about the object
15
+ *
16
+ * A photo stored WITH its derived crops is a directory of objects sharing a
17
+ * uuid, and the key saved on the row names the uncropped member:
18
+ *
19
+ * products/<scope>/<uuid>/full.webp ← the key on the row
20
+ * products/<scope>/<uuid>/card-320.webp ← its siblings
21
+ * products/<scope>/<uuid>/thumb-128.webp
22
+ *
23
+ * A photo stored ALONE keeps the flat shape:
24
+ *
25
+ * products/<scope>/<uuid>.webp
26
+ *
27
+ * So the shape is a truthful answer to "may I ask for this photo's 4:3 card
28
+ * crop?" ({@link ObjectKey.hasRenditions}) — and that is deliberately the ONLY
29
+ * place that question is answered. The alternative is a column recording which
30
+ * derivatives exist, on the row and on every draft, change request, recycle-bin
31
+ * snapshot and version the row is copied into, kept in step with the bucket by
32
+ * nothing. A key that describes its own storage cannot fall out of step, and a
33
+ * consumer handed a key with no set simply asks for no crops.
34
+ *
35
+ * ## The SCOPE segment is the tenant boundary
36
+ *
37
+ * `<scope>` is the tenant the object belongs to, and it is what makes
38
+ * cross-tenant reclaim a parse rather than a query: `deleteIfOrphaned` refuses
39
+ * a key whose scope is not the caller's before it consults any reference probe.
40
+ * A scheme without it leaves "may this tenant delete this object?" answerable
41
+ * only by proving no row of THEIRS points at it — which is true of every other
42
+ * tenant's objects too.
43
+ *
44
+ * UNSCOPED keys parse as well, with `scope: null`, because an adopting host has
45
+ * a table full of them. They are the reason `unscopedKeys` is a required
46
+ * choice rather than a default (see `create-api-storage.ts`): for such a key
47
+ * the structural guarantee above does not exist, and only the host's probes
48
+ * stand between one tenant and another's bytes.
49
+ *
50
+ * The four shapes stay mutually unambiguous even when a scope is itself
51
+ * uuid-shaped (a tenant id): a set key's leaf stem is always `full`, and a flat
52
+ * key's leaf stem is always the object uuid, which is never the word `full`.
53
+ */
54
+
55
+ /** Default top-level segment every key starts with. */
56
+ export const DEFAULT_KEY_PREFIX = 'products';
57
+
58
+ /** The member of a rendition set that holds the uncropped picture. */
59
+ const FULL_STEM = 'full';
60
+
61
+ /**
62
+ * A legal scope segment.
63
+ *
64
+ * Deliberately narrower than "a tenant slug": the local-disk driver turns a key
65
+ * into a filesystem path, so a segment that can carry `.`, `/` or whitespace is
66
+ * a traversal waiting for a caller who assembles a key by hand. A host whose
67
+ * tenant identifiers do not fit passes their id instead of their slug.
68
+ */
69
+ const SCOPE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
70
+ /** A parsed object key. */
71
+ export interface ObjectKey {
72
+ prefix: string;
73
+ /** The tenant the object belongs to; `null` for a legacy unscoped key. */
74
+ scope: string | null;
75
+ /** The object's own uuid — shared by every member of a rendition set. */
76
+ uuid: string;
77
+ /** Extension of the uncropped object, which is not always `webp`. */
78
+ extension: string;
79
+ /** Was this photo stored with the crops a storefront draws? */
80
+ hasRenditions: boolean;
81
+ }
82
+
83
+ /** Is `scope` usable as a key segment? Checked at mint time, not assumed. */
84
+ export function isLegalScope(scope: string): boolean {
85
+ return SCOPE.test(scope);
86
+ }
87
+
88
+ /**
89
+ * The four shapes, as anchored regexes built once per prefix.
90
+ *
91
+ * Regexes rather than a segment walk because the four are MUTUALLY EXCLUSIVE by
92
+ * construction and that is the property worth making obvious: a set key's leaf
93
+ * stem is the literal `full`, a flat key's is the object uuid, and a uuid is never
94
+ * the word `full`. So the three-segment shapes — `<prefix>/<scope>/<uuid>.ext` and
95
+ * `<prefix>/<uuid>/full.ext` — cannot both match one key, whether or not the scope
96
+ * happens to be uuid-shaped itself.
97
+ *
98
+ * The scope group is always present and simply matches nothing on the unscoped
99
+ * shapes, so a caller reads it the same way every time.
100
+ */
101
+ interface KeyShape {
102
+ re: RegExp;
103
+ hasRenditions: boolean;
104
+ }
105
+
106
+ const UUID_SOURCE = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
107
+ const SCOPE_SOURCE = '[A-Za-z0-9][A-Za-z0-9_-]{0,63}';
108
+ const EXT_SOURCE = '[A-Za-z0-9]{2,4}';
109
+ const STEM_SOURCE = '[A-Za-z0-9][A-Za-z0-9_-]*';
110
+
111
+ function escapeRegExp(literal: string): string {
112
+ return literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
113
+ }
114
+
115
+ /** Built per prefix and cached: a key is parsed on every read of every row. */
116
+ const SHAPES = new Map<string, readonly KeyShape[]>();
117
+ const MEMBER_SHAPES = new Map<string, readonly RegExp[]>();
118
+
119
+ function shapesFor(prefix: string): readonly KeyShape[] {
120
+ const cached = SHAPES.get(prefix);
121
+ if (cached) return cached;
122
+ const p = escapeRegExp(prefix);
123
+ const shapes: readonly KeyShape[] = [
124
+ // scoped set, scoped flat, unscoped set, unscoped flat
125
+ { re: key(`${p}/(${SCOPE_SOURCE})/(${UUID_SOURCE})/${FULL_STEM}`), hasRenditions: true },
126
+ { re: key(`${p}/(${SCOPE_SOURCE})/(${UUID_SOURCE})`), hasRenditions: false },
127
+ { re: key(`${p}/()(${UUID_SOURCE})/${FULL_STEM}`), hasRenditions: true },
128
+ { re: key(`${p}/()(${UUID_SOURCE})`), hasRenditions: false },
129
+ ];
130
+ SHAPES.set(prefix, shapes);
131
+ return shapes;
132
+ }
133
+
134
+ /** `<body>.<ext>`, anchored and case-insensitive. */
135
+ function key(body: string): RegExp {
136
+ return new RegExp(`^${body}\\.(${EXT_SOURCE})$`, 'i');
137
+ }
138
+
139
+ /**
140
+ * The MEMBER shapes: a crop's leaf stem is `card-320`, which is deliberately not a
141
+ * key any row may name.
142
+ */
143
+ function memberShapesFor(prefix: string): readonly RegExp[] {
144
+ const cached = MEMBER_SHAPES.get(prefix);
145
+ if (cached) return cached;
146
+ const p = escapeRegExp(prefix);
147
+ const shapes: readonly RegExp[] = [
148
+ new RegExp(
149
+ `^${p}/(${SCOPE_SOURCE})/(${UUID_SOURCE})/(${STEM_SOURCE})\\.(${EXT_SOURCE})$`,
150
+ 'i',
151
+ ),
152
+ new RegExp(`^${p}/()(${UUID_SOURCE})/(${STEM_SOURCE})\\.(${EXT_SOURCE})$`, 'i'),
153
+ ];
154
+ MEMBER_SHAPES.set(prefix, shapes);
155
+ return shapes;
156
+ }
157
+
158
+ /** A capture group, which the shapes above always define. */
159
+ function group(match: RegExpExecArray, index: number): string {
160
+ return match[index] ?? '';
161
+ }
162
+
163
+ /**
164
+ * Parse `key`, or `null` for anything this scheme did not mint.
165
+ *
166
+ * Total: every accepted key is one of the four shapes documented above, so a near
167
+ * miss reads as "not ours" rather than as a photo whose crops five URLs promise and
168
+ * no object backs.
169
+ */
170
+ export function parseObjectKey(key: string, prefix = DEFAULT_KEY_PREFIX): ObjectKey | null {
171
+ for (const shape of shapesFor(prefix)) {
172
+ const match = shape.re.exec(key);
173
+ if (match) {
174
+ return {
175
+ prefix,
176
+ // Empty on the unscoped shapes, where the group matches nothing.
177
+ scope: group(match, 1) || null,
178
+ uuid: group(match, 2),
179
+ extension: group(match, 3).toLowerCase(),
180
+ hasRenditions: shape.hasRenditions,
181
+ };
182
+ }
183
+ }
184
+ return null;
185
+ }
186
+
187
+ /** Is `key` one this scheme minted — any of the four shapes? */
188
+ export function isObjectKey(key: string, prefix = DEFAULT_KEY_PREFIX): boolean {
189
+ return parseObjectKey(key, prefix) !== null;
190
+ }
191
+
192
+ /** Does `key` name a photo that was stored with its derived crops? */
193
+ export function hasRenditions(key: string, prefix = DEFAULT_KEY_PREFIX): boolean {
194
+ return parseObjectKey(key, prefix)?.hasRenditions ?? false;
195
+ }
196
+
197
+ /** How a key is minted: the tenant it belongs to and the stored format. */
198
+ export interface MintKeyInput {
199
+ /** The tenant the object belongs to. */
200
+ scope: string;
201
+ /**
202
+ * The type the object is ACTUALLY stored as — on a re-encoding pipeline that is
203
+ * the produced type, not the one the caller declared. The extension here is what
204
+ * the object is later served with, so minting from the declared type would label
205
+ * a downscaled WebP `.png`.
206
+ */
207
+ contentType: string;
208
+ prefix?: string;
209
+ /** A caller-supplied uuid. Tests only; production mints one per upload. */
210
+ uuid?: string;
211
+ }
212
+
213
+ function extensionFor(contentType: string): string {
214
+ const extension = EXTENSION_BY_CONTENT_TYPE[contentType];
215
+ if (!extension) {
216
+ throw new Error(`Refusing to mint a key for an unsupported type: ${contentType}`);
217
+ }
218
+ return extension;
219
+ }
220
+
221
+ function assertScope(scope: string): void {
222
+ if (!isLegalScope(scope)) {
223
+ throw new Error(`Refusing to mint a key with an unsafe scope segment: ${scope}`);
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Mint a STANDALONE object key: `<prefix>/<scope>/<uuid>.<ext>`.
229
+ *
230
+ * The shape for everything that is one picture and nothing more — a logo, an app
231
+ * icon, and a photo whose source cannot be cropped frame by frame.
232
+ */
233
+ export function mintObjectKey(input: MintKeyInput): string {
234
+ assertScope(input.scope);
235
+ const prefix = input.prefix ?? DEFAULT_KEY_PREFIX;
236
+ return `${prefix}/${input.scope}/${input.uuid ?? crypto.randomUUID()}.${extensionFor(
237
+ input.contentType,
238
+ )}`;
239
+ }
240
+
241
+ /**
242
+ * Mint the key for the uncropped member of a rendition SET:
243
+ * `<prefix>/<scope>/<uuid>/full.<ext>`. Siblings are named from it by
244
+ * {@link renditionKey}.
245
+ */
246
+ export function mintObjectSetKey(input: MintKeyInput): string {
247
+ assertScope(input.scope);
248
+ const prefix = input.prefix ?? DEFAULT_KEY_PREFIX;
249
+ const uuid = input.uuid ?? crypto.randomUUID();
250
+ return `${prefix}/${input.scope}/${uuid}/${FULL_STEM}.${extensionFor(input.contentType)}`;
251
+ }
252
+
253
+ /**
254
+ * The key of one sibling in `key`'s rendition set, or `null` for a key with no set.
255
+ *
256
+ * Null rather than a plausible-looking string: constructing a key for an object
257
+ * nobody ever wrote is how a card ends up drawing the broken-image glyph.
258
+ */
259
+ export function renditionKey(
260
+ key: string,
261
+ name: string,
262
+ prefix = DEFAULT_KEY_PREFIX,
263
+ ): string | null {
264
+ if (!hasRenditions(key, prefix)) return null;
265
+ return `${key.slice(0, key.lastIndexOf('/'))}/${name}.webp`;
266
+ }
267
+
268
+ /** One member of a stored photo — the uncropped object or any of its crops. */
269
+ export interface ObjectMemberKey {
270
+ scope: string | null;
271
+ uuid: string;
272
+ /** `full`, `card-320`, or the uuid itself for a flat key. */
273
+ stem: string;
274
+ extension: string;
275
+ }
276
+
277
+ /**
278
+ * Parse any MEMBER of a stored photo, crops included.
279
+ *
280
+ * The serve route needs this and {@link parseObjectKey} will not do: the two
281
+ * questions are different — "is this a key I could have saved?" versus "is this an
282
+ * object I could have written?" — and answering the second with the first would
283
+ * 400 every crop in the catalog.
284
+ */
285
+ export function parseObjectMemberKey(
286
+ key: string,
287
+ prefix = DEFAULT_KEY_PREFIX,
288
+ ): ObjectMemberKey | null {
289
+ const direct = parseObjectKey(key, prefix);
290
+ if (direct) {
291
+ return {
292
+ scope: direct.scope,
293
+ uuid: direct.uuid,
294
+ stem: direct.hasRenditions ? FULL_STEM : direct.uuid,
295
+ extension: direct.extension,
296
+ };
297
+ }
298
+ for (const re of memberShapesFor(prefix)) {
299
+ const match = re.exec(key);
300
+ if (match) {
301
+ return {
302
+ scope: group(match, 1) || null,
303
+ uuid: group(match, 2),
304
+ stem: group(match, 3),
305
+ extension: group(match, 4).toLowerCase(),
306
+ };
307
+ }
308
+ }
309
+ return null;
310
+ }
311
+
312
+ /**
313
+ * Does `key` belong to `scope`?
314
+ *
315
+ * `unscopedKeys` decides the legacy case and has no safe default, which is why
316
+ * the mount requires the host to choose it. `'accept'` means a key with no
317
+ * scope segment is treated as in-scope — correct for a host whose existing rows
318
+ * predate the scope segment, and the reason such a host still needs reference
319
+ * probes. `'reject'` means only a key carrying this scope is ever touched.
320
+ */
321
+ export function keyInScope(
322
+ key: string,
323
+ scope: string,
324
+ unscopedKeys: 'accept' | 'reject',
325
+ prefix = DEFAULT_KEY_PREFIX,
326
+ ): boolean {
327
+ const parsed = parseObjectKey(key, prefix);
328
+ if (!parsed) return false;
329
+ if (parsed.scope === null) return unscopedKeys === 'accept';
330
+ return parsed.scope === scope;
331
+ }
package/src/limits.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The upload ceiling, and the one rule about it: there is exactly ONE number.
3
+ *
4
+ * A cap that differed by entrance would mean a file the app accepts from a
5
+ * browser and refuses from an agent, for no reason the person uploading could
6
+ * see. So this module owns the number and everything derived from it, and
7
+ * {@link createApiStorage} takes `maxBytes` as a REQUIRED config value rather
8
+ * than defaulting quietly — a permissive default is the failure that cannot be
9
+ * noticed from inside the host that has it.
10
+ *
11
+ * {@link DEFAULT_MAX_UPLOAD_BYTES} is the recommended value, exported so a host
12
+ * can hand the SAME constant to its upload mount and to any tool schema that
13
+ * advertises the ceiling. That is the anti-drift seam the extraction exists for:
14
+ * `createApiStorage(…).limits` echoes back what the mount actually enforces, so
15
+ * a schema built from it cannot describe a limit the endpoint does not have.
16
+ */
17
+
18
+ /** The recommended upload cap: 8 MiB. */
19
+ export const DEFAULT_MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
20
+
21
+ /**
22
+ * Ceiling for a base64-encoded field carrying `maxBytes` of file.
23
+ *
24
+ * 4 characters carry 3 bytes, plus room for a `data:` URL wrapper and for line
25
+ * wrapping. Used as a schema `max`, so an oversize payload is refused by
26
+ * validation before a handler ever allocates it.
27
+ */
28
+ export function maxBase64Length(maxBytes: number): number {
29
+ return Math.ceil(maxBytes / 3) * 4 + 1024;
30
+ }
31
+
32
+ /** `8 MB`, `1,5 MB` — pt-BR decimal comma, for a message a store owner reads. */
33
+ export function megabytes(bytes: number): string {
34
+ const mb = bytes / 1024 / 1024;
35
+ const rendered = Number.isInteger(mb) ? String(mb) : mb.toFixed(1).replace('.', ',');
36
+ return `${rendered} MB`;
37
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Where the two endpoints live, relative to wherever the router is mounted.
3
+ *
4
+ * Its own module, and part of the ISOMORPHIC entry, because both halves need it
5
+ * and neither may spell it itself: the browser posts to `upload`, the local driver
6
+ * builds display URLs from `serve`, and a host composes its driver's
7
+ * `publicPathPrefix` as `<mount base> + STORAGE_PATHS.serve`. A string retyped in
8
+ * three places is three chances to have written objects at one path and served
9
+ * them from another.
10
+ */
11
+ export const STORAGE_PATHS = {
12
+ upload: '/uploads/image',
13
+ serve: '/uploads/local',
14
+ } as const;