@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.
- package/ADOPTING.md +281 -0
- package/README.md +229 -0
- package/package.json +89 -0
- package/src/content-types.ts +47 -0
- package/src/hono/index.ts +96 -0
- package/src/index.ts +98 -0
- package/src/keys.ts +331 -0
- package/src/limits.ts +37 -0
- package/src/paths.ts +14 -0
- package/src/payload.ts +127 -0
- package/src/problems.ts +116 -0
- package/src/react/create-web-storage.tsx +100 -0
- package/src/react/failures.ts +201 -0
- package/src/react/image-field.tsx +119 -0
- package/src/react/index.ts +44 -0
- package/src/react/optimize-image.ts +215 -0
- package/src/react/use-upload.ts +121 -0
- package/src/renditions.ts +71 -0
- package/src/s3/index.ts +105 -0
- package/src/server/create-api-storage.ts +221 -0
- package/src/server/driver.ts +43 -0
- package/src/server/drivers/local-disk.ts +104 -0
- package/src/server/index.ts +61 -0
- package/src/server/pipeline/passthrough.ts +28 -0
- package/src/server/pipeline/port.ts +59 -0
- package/src/server/pipeline/sharp-module.ts +57 -0
- package/src/server/pipeline/sharp-renditions.ts +238 -0
- package/src/server/pipeline/sharp.ts +142 -0
- package/src/server/read-body.ts +42 -0
- package/src/server/reclaim.ts +240 -0
- package/src/server/remote-url.ts +57 -0
- package/src/server/routes.ts +189 -0
- package/src/server/store-image.ts +168 -0
- package/src/urls.ts +118 -0
- package/src/wire.ts +103 -0
|
@@ -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;
|