@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,57 @@
|
|
|
1
|
+
import { StorageNotConfiguredError } from '../problems';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The public URL of an object in an S3-compatible bucket.
|
|
5
|
+
*
|
|
6
|
+
* Pure, and in `./server` rather than in `./s3`, for two reasons: a host that
|
|
7
|
+
* serves display URLs from a CDN needs the same builder without the SDK, and
|
|
8
|
+
* every branch below is a real deployment that got this wrong once.
|
|
9
|
+
*/
|
|
10
|
+
export interface RemoteObjectUrlConfig {
|
|
11
|
+
bucket: string;
|
|
12
|
+
/** AWS region — the only thing needed for an AWS-hosted bucket. */
|
|
13
|
+
region?: string;
|
|
14
|
+
/** Endpoint of a non-AWS S3-compatible store (Spaces, MinIO, R2…). */
|
|
15
|
+
endpoint?: string;
|
|
16
|
+
/** Path-style addressing, for stores that cannot do virtual-hosted. */
|
|
17
|
+
forcePathStyle?: boolean;
|
|
18
|
+
/** A CDN in front of the bucket. Wins over everything else. */
|
|
19
|
+
publicBaseUrl?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Config that cannot produce a URL at all.
|
|
24
|
+
*
|
|
25
|
+
* Raised at MOUNT time rather than at render time: a driver that returned the
|
|
26
|
+
* bare key here would put a relative path in an `<img src>`, which resolves
|
|
27
|
+
* against the app's own origin and 404s — a broken image with no error anywhere,
|
|
28
|
+
* for every product in the catalog.
|
|
29
|
+
*/
|
|
30
|
+
export function assertRemoteUrlConfig(config: RemoteObjectUrlConfig): void {
|
|
31
|
+
if (!config.bucket) {
|
|
32
|
+
throw new StorageNotConfiguredError('An S3-compatible driver needs a bucket.');
|
|
33
|
+
}
|
|
34
|
+
if (!config.region && !config.endpoint && !config.publicBaseUrl) {
|
|
35
|
+
throw new StorageNotConfiguredError(
|
|
36
|
+
'An S3-compatible driver needs a region, an endpoint or a publicBaseUrl to build display URLs.',
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function remoteObjectUrl(config: RemoteObjectUrlConfig, key: string): string {
|
|
42
|
+
if (config.publicBaseUrl) {
|
|
43
|
+
return `${config.publicBaseUrl.replace(/\/$/, '')}/${key}`;
|
|
44
|
+
}
|
|
45
|
+
if (config.endpoint) {
|
|
46
|
+
const base = config.endpoint.replace(/\/$/, '');
|
|
47
|
+
if (config.forcePathStyle) {
|
|
48
|
+
// Path-style, matching how such a store addresses objects. The endpoint's
|
|
49
|
+
// own scheme is kept: a local http://localhost:9000 must not be rewritten
|
|
50
|
+
// to https.
|
|
51
|
+
return `${base}/${config.bucket}/${key}`;
|
|
52
|
+
}
|
|
53
|
+
// Virtual-hosted style on the S3-compatible endpoint (Spaces and friends).
|
|
54
|
+
return `https://${config.bucket}.${base.replace(/^https?:\/\//i, '')}/${key}`;
|
|
55
|
+
}
|
|
56
|
+
return `https://${config.bucket}.s3.${config.region}.amazonaws.com/${key}`;
|
|
57
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { CONTENT_TYPE_BY_EXTENSION, acceptedContentTypeOf } from '../content-types';
|
|
2
|
+
import { parseObjectMemberKey } from '../keys';
|
|
3
|
+
import { STORAGE_PATHS } from '../paths';
|
|
4
|
+
import { isStorageProblem, type StorageMessages } from '../problems';
|
|
5
|
+
import type { StorageDriver } from './driver';
|
|
6
|
+
import { readBodyCapped } from './read-body';
|
|
7
|
+
import { storeImage, type StoreImageDeps } from './store-image';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The endpoints, as framework-neutral descriptors. There are exactly two, and
|
|
11
|
+
* neither hands a caller an address to upload to.
|
|
12
|
+
*
|
|
13
|
+
* POST <mount>/uploads/image the ONE way a browser stores bytes
|
|
14
|
+
* GET <mount>/uploads/local/… how an object is read back
|
|
15
|
+
*
|
|
16
|
+
* ## Why one POST, and why never a presigned URL
|
|
17
|
+
*
|
|
18
|
+
* An earlier contract had the browser ask for an ADDRESS and then PUT to it. Two
|
|
19
|
+
* requests — and the key had to be minted at the first, before anybody had seen
|
|
20
|
+
* the bytes, which means PREDICTING whether crops will exist. The prediction
|
|
21
|
+
* needed flags and special cases to stay true and a 422 for when it did not.
|
|
22
|
+
*
|
|
23
|
+
* The obvious form of that second hop is a presigned bucket URL, and it is the
|
|
24
|
+
* one thing this package must never offer. A `PUT` with a `Content-Type` is never
|
|
25
|
+
* a "simple" request, so the browser sends an `OPTIONS` preflight; a freshly
|
|
26
|
+
* created bucket answers it with 403 and no `Access-Control-Allow-*`, and the
|
|
27
|
+
* upload is never sent. THE SERVER SEES NOTHING — no request, no log line, no
|
|
28
|
+
* error rate. The browser cannot say why, because a blocked cross-origin fetch is
|
|
29
|
+
* deliberately indistinguishable from an offline network. And the person who sees
|
|
30
|
+
* the message is a store owner, about a bucket only an operator can configure.
|
|
31
|
+
*
|
|
32
|
+
* One same-origin POST removes the second hop, the prediction and the CORS class
|
|
33
|
+
* together. It is also the only shape that CAN work now: a stored photo is a set
|
|
34
|
+
* of objects the server derives from the bytes, so an upload the server never
|
|
35
|
+
* sees cannot produce one.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Who is calling — resolved by the HOST and never computed here.
|
|
40
|
+
*
|
|
41
|
+
* `mayUpload` is one boolean on purpose. Two independent questions feed it —
|
|
42
|
+
* "may this person store objects?" (roles, memberships, plan) and "may this
|
|
43
|
+
* SESSION change anything?" (a read-only impersonation, a maintenance mode) — and
|
|
44
|
+
* both are the host's, answered before it delegates. A package that tried to
|
|
45
|
+
* compute either would be wrong for every host but the first.
|
|
46
|
+
*/
|
|
47
|
+
export interface StorageActor {
|
|
48
|
+
/** The tenant whose objects this call may touch — the key's scope segment. */
|
|
49
|
+
scope: string;
|
|
50
|
+
mayUpload: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface StorageRouteContext {
|
|
54
|
+
actor: StorageActor | null;
|
|
55
|
+
params: Record<string, string | undefined>;
|
|
56
|
+
request: Request;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A JSON answer, an object's bytes, or a redirect to where the object lives. */
|
|
60
|
+
export type StorageRouteResponse =
|
|
61
|
+
| { status: number; body: unknown }
|
|
62
|
+
| { status: number; bytes: Uint8Array; headers: Record<string, string> }
|
|
63
|
+
| { status: number; redirect: string };
|
|
64
|
+
|
|
65
|
+
export interface StorageRoute {
|
|
66
|
+
method: 'GET' | 'POST';
|
|
67
|
+
/** Path suffix, relative to the mount. */
|
|
68
|
+
path: string;
|
|
69
|
+
/** `actor` routes answer 401 before the handler runs; `public` ones do not. */
|
|
70
|
+
auth: 'actor' | 'public';
|
|
71
|
+
/**
|
|
72
|
+
* The path ends in a catch-all whose whole remainder is the `key` param. An
|
|
73
|
+
* adapter spells that its own way; the descriptor says only that it is there.
|
|
74
|
+
*/
|
|
75
|
+
wildcardParam?: 'key';
|
|
76
|
+
handle(context: StorageRouteContext): Promise<StorageRouteResponse>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface RoutesDeps extends StoreImageDeps {
|
|
80
|
+
driver: StorageDriver;
|
|
81
|
+
messages: StorageMessages;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Keys are content-addressed (a uuid), so an object may be cached forever. */
|
|
85
|
+
const IMMUTABLE = 'public, max-age=31536000, immutable';
|
|
86
|
+
|
|
87
|
+
async function handleUpload(
|
|
88
|
+
deps: RoutesDeps,
|
|
89
|
+
context: StorageRouteContext,
|
|
90
|
+
): Promise<StorageRouteResponse> {
|
|
91
|
+
const actor = context.actor;
|
|
92
|
+
if (!actor?.mayUpload) {
|
|
93
|
+
// Stays a CODE, unlike the refusals below, and deliberately: `mayUpload` is the
|
|
94
|
+
// host's own boolean, so the package cannot phrase WHY without asserting a role
|
|
95
|
+
// model it does not have. The browser half owns that sentence and lets the host
|
|
96
|
+
// override it (`WebStorageMessages.forbidden`).
|
|
97
|
+
return { status: 403, body: { error: 'forbidden' } };
|
|
98
|
+
}
|
|
99
|
+
const contentType = acceptedContentTypeOf(context.request.headers.get('content-type'));
|
|
100
|
+
if (!contentType) {
|
|
101
|
+
return { status: 400, body: { error: deps.messages.unsupported_content_type } };
|
|
102
|
+
}
|
|
103
|
+
// Capped WHILE STREAMING, so an oversize body is refused without ever being
|
|
104
|
+
// held whole in memory.
|
|
105
|
+
const bytes = await readBodyCapped(context.request, deps.maxBytes);
|
|
106
|
+
if (bytes === null) {
|
|
107
|
+
// The configured SENTENCE, not the code `file_too_large`. These two refusals
|
|
108
|
+
// are decided before the write path runs, so they used to be the only ones the
|
|
109
|
+
// endpoint stated as a bare code — and the browser then re-derived the number
|
|
110
|
+
// from its OWN default. A mount capped at 4 MB refusing a 6 MB file produced
|
|
111
|
+
// "o limite é 8 MB": a ceiling nothing enforced, in the one package whose
|
|
112
|
+
// headline claim is that there is exactly ONE number. The sentence is built
|
|
113
|
+
// from this mount's `maxBytes` and carries this mount's `messages` overrides.
|
|
114
|
+
return { status: 413, body: { error: deps.messages.file_too_large } };
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
// No byte check here: `storeImage` owns it, so the endpoint and a host write
|
|
118
|
+
// holding raw bytes cannot disagree about whether it ran (see store-image.ts).
|
|
119
|
+
const imageKey = await storeImage(deps, { bytes, contentType, scope: actor.scope });
|
|
120
|
+
return { status: 200, body: { data: { imageKey } } };
|
|
121
|
+
} catch (error) {
|
|
122
|
+
// The write path speaks in StorageProblemError precisely so both entrances
|
|
123
|
+
// answer with the same status and the same sentence.
|
|
124
|
+
if (isStorageProblem(error)) {
|
|
125
|
+
return { status: error.status, body: { error: error.message } };
|
|
126
|
+
}
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Serve a stored object, or send the caller where it actually lives.
|
|
133
|
+
*
|
|
134
|
+
* Which of the two applies is read off the DRIVER — a driver that holds its own
|
|
135
|
+
* bytes implements `read`, a bucket-backed one does not. That used to be an
|
|
136
|
+
* environment flag consulted inside the route, i.e. the same fact recorded in a
|
|
137
|
+
* second place where it could disagree with the driver in use.
|
|
138
|
+
*
|
|
139
|
+
* The redirect is not a nicety: a consumer may still be asking for this route
|
|
140
|
+
* after a switch to a bucket — a cached page, a storefront bundle mid-deploy that
|
|
141
|
+
* fell back to the bare key — and a 404 there is a catalog full of broken images.
|
|
142
|
+
*/
|
|
143
|
+
async function handleServe(
|
|
144
|
+
deps: RoutesDeps,
|
|
145
|
+
context: StorageRouteContext,
|
|
146
|
+
): Promise<StorageRouteResponse> {
|
|
147
|
+
const key = context.params.key ?? '';
|
|
148
|
+
const member = parseObjectMemberKey(key, deps.keyPrefix);
|
|
149
|
+
if (!member || !(member.extension in CONTENT_TYPE_BY_EXTENSION)) {
|
|
150
|
+
return { status: 400, body: { error: 'invalid_key' } };
|
|
151
|
+
}
|
|
152
|
+
if (!deps.driver.read) {
|
|
153
|
+
const url = deps.driver.publicUrl(key);
|
|
154
|
+
// `publicUrl` may answer a relative path on a misconfigured driver, and a
|
|
155
|
+
// redirect to one loops back here forever. 404 instead — the same answer the
|
|
156
|
+
// object being absent gives.
|
|
157
|
+
return /^https?:\/\//i.test(url)
|
|
158
|
+
? { status: 302, redirect: url }
|
|
159
|
+
: { status: 404, body: { error: 'not_found' } };
|
|
160
|
+
}
|
|
161
|
+
const object = await deps.driver.read(key);
|
|
162
|
+
if (!object) return { status: 404, body: { error: 'not_found' } };
|
|
163
|
+
return {
|
|
164
|
+
status: 200,
|
|
165
|
+
bytes: object.bytes,
|
|
166
|
+
headers: { 'content-type': object.contentType, 'cache-control': IMMUTABLE },
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function storageRoutes(deps: RoutesDeps): readonly StorageRoute[] {
|
|
171
|
+
return [
|
|
172
|
+
{
|
|
173
|
+
method: 'POST',
|
|
174
|
+
path: STORAGE_PATHS.upload,
|
|
175
|
+
auth: 'actor',
|
|
176
|
+
handle: (context) => handleUpload(deps, context),
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
method: 'GET',
|
|
180
|
+
path: STORAGE_PATHS.serve,
|
|
181
|
+
// Objects are world-readable by design — a store's logo is printed in its
|
|
182
|
+
// own storefront HTML — and the key is an unguessable uuid path. Gating the
|
|
183
|
+
// read would mean every `<img>` in a public storefront carried a session.
|
|
184
|
+
auth: 'public',
|
|
185
|
+
wildcardParam: 'key',
|
|
186
|
+
handle: (context) => handleServe(deps, context),
|
|
187
|
+
},
|
|
188
|
+
];
|
|
189
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { mintObjectKey, mintObjectSetKey, renditionKey } from '../keys';
|
|
2
|
+
import { decodeImagePayload, verifyImageBytes } from '../payload';
|
|
3
|
+
import {
|
|
4
|
+
StorageNotConfiguredError,
|
|
5
|
+
StorageProblemError,
|
|
6
|
+
type StorageMessages,
|
|
7
|
+
} from '../problems';
|
|
8
|
+
import { RENDITION_CONTENT_TYPE, type RenditionSpec } from '../renditions';
|
|
9
|
+
import type { InlineImage } from '../wire';
|
|
10
|
+
import type { StorageDriver } from './driver';
|
|
11
|
+
import type { ImagePipeline } from './pipeline/port';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* THE writer. Every stored photo is stored here, and nothing else decides what a
|
|
15
|
+
* stored photo is made of.
|
|
16
|
+
*
|
|
17
|
+
* That single-owner rule is the point, and it was learned the hard way. An
|
|
18
|
+
* earlier design had the answer in three places: the inline path minted a key
|
|
19
|
+
* from what it had actually produced, the presign path GUESSED the same key's
|
|
20
|
+
* shape from a content type and a config flag before a single byte existed, and
|
|
21
|
+
* both then looped over the rendition list storing objects. Two of the three
|
|
22
|
+
* could disagree — and did, structurally: a key minted on a guess promises crops
|
|
23
|
+
* the bytes may turn out not to support, which is why that design needed a
|
|
24
|
+
* `purpose` flag, an animation special case, a direct-upload special case, and a
|
|
25
|
+
* 422 to break the promise afterwards. None of that is here. The key is minted
|
|
26
|
+
* from FINISHED work, so it cannot promise anything that is not already stored.
|
|
27
|
+
*
|
|
28
|
+
* The two ways bytes arrive keep their own TRANSPORT and share this LOGIC:
|
|
29
|
+
*
|
|
30
|
+
* - an AGENT sends them base64 inside the write that uses them, because a tool
|
|
31
|
+
* call is JSON and it cannot make an HTTP request of its own;
|
|
32
|
+
* - a BROWSER POSTs them to the upload endpoint and saves the key it gets back,
|
|
33
|
+
* which is what lets a draft reference a photo picked before the row exists.
|
|
34
|
+
*
|
|
35
|
+
* The order matters:
|
|
36
|
+
*
|
|
37
|
+
* 1. process the uncropped object — the whole picture, which is what a zoom
|
|
38
|
+
* shows;
|
|
39
|
+
* 2. cut the crops from the ORIGINAL bytes, not from step 1's output;
|
|
40
|
+
* 3. mint the key from the RESULT — the extension from the type the object was
|
|
41
|
+
* actually stored as, and the SHAPE from whether step 2 produced anything.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
export interface StoreImageDeps {
|
|
45
|
+
driver: StorageDriver;
|
|
46
|
+
pipeline: ImagePipeline;
|
|
47
|
+
messages: StorageMessages;
|
|
48
|
+
maxBytes: number;
|
|
49
|
+
specs: readonly RenditionSpec[];
|
|
50
|
+
keyPrefix: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface StoreImageInput {
|
|
54
|
+
bytes: Uint8Array;
|
|
55
|
+
/** The type the caller declares. Corroborated against the bytes themselves. */
|
|
56
|
+
contentType: string;
|
|
57
|
+
/** The tenant the object belongs to — the key's scope segment. */
|
|
58
|
+
scope: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function refuse(deps: StoreImageDeps, problem: keyof StorageMessages): never {
|
|
62
|
+
throw new StorageProblemError(problem, deps.messages[problem]);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Refuse bytes that are not really an image of `contentType`.
|
|
67
|
+
*
|
|
68
|
+
* Called by {@link storeImage} ITSELF, which is the only arrangement that holds.
|
|
69
|
+
* It used to be each entrance's job — the endpoint called it before delegating,
|
|
70
|
+
* and the base64 path got it as part of decoding — and the public `storeImage` a
|
|
71
|
+
* host calls with raw bytes in hand was then the one that forgot. An adversarial
|
|
72
|
+
* probe stored `<html><script>alert(1)</script></html>` under a `full.png` key,
|
|
73
|
+
* served as `image/png`, straight through that gap.
|
|
74
|
+
*
|
|
75
|
+
* The gap was reachable with the REAL pipeline and not only with a stub: a
|
|
76
|
+
* re-encode that is not SMALLER deliberately keeps the original bytes and the
|
|
77
|
+
* DECLARED type (see `sharp.ts`), and libvips decodes plenty of formats this
|
|
78
|
+
* allowlist excludes — so `process` had no reason to object either.
|
|
79
|
+
*
|
|
80
|
+
* `payload.ts` already said this check must run on both entrances "because
|
|
81
|
+
* neither may be the one that forgets". The fix is to stop asking entrances: the
|
|
82
|
+
* writer verifies once, and no path to storage goes around it.
|
|
83
|
+
*/
|
|
84
|
+
function assertImageBytes(
|
|
85
|
+
deps: StoreImageDeps,
|
|
86
|
+
bytes: Uint8Array,
|
|
87
|
+
contentType: string,
|
|
88
|
+
): void {
|
|
89
|
+
const problem = verifyImageBytes(bytes, contentType);
|
|
90
|
+
if (problem) refuse(deps, problem);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Write the crops first, then the object the row will name. */
|
|
94
|
+
async function writeSet(
|
|
95
|
+
deps: StoreImageDeps,
|
|
96
|
+
key: string,
|
|
97
|
+
full: { bytes: Uint8Array; contentType: string },
|
|
98
|
+
renditions: readonly { spec: RenditionSpec; bytes: Uint8Array }[],
|
|
99
|
+
): Promise<void> {
|
|
100
|
+
try {
|
|
101
|
+
// The uncropped object goes LAST, after every crop its key implies is already
|
|
102
|
+
// in place. A request that dies partway then leaves objects nothing points at
|
|
103
|
+
// — which a bucket forgets — rather than a row pointing at a photo whose card
|
|
104
|
+
// crop 404s in every grid that draws it.
|
|
105
|
+
await Promise.all(
|
|
106
|
+
renditions.map((rendition) =>
|
|
107
|
+
// Non-null: `renditions` is only non-empty for a set key.
|
|
108
|
+
deps.driver.put(
|
|
109
|
+
renditionKey(key, rendition.spec.name, deps.keyPrefix) as string,
|
|
110
|
+
rendition.bytes,
|
|
111
|
+
RENDITION_CONTENT_TYPE,
|
|
112
|
+
),
|
|
113
|
+
),
|
|
114
|
+
);
|
|
115
|
+
await deps.driver.put(key, full.bytes, full.contentType);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (error instanceof StorageNotConfiguredError) refuse(deps, 'storage_not_configured');
|
|
118
|
+
refuse(deps, 'storage_unavailable');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Store `bytes` and return the key to save on the row.
|
|
124
|
+
*
|
|
125
|
+
* Throws {@link StorageProblemError} for anything the caller can fix, so a bad
|
|
126
|
+
* image fails the whole write with a usable status instead of creating a row with
|
|
127
|
+
* a silently missing photo.
|
|
128
|
+
*/
|
|
129
|
+
export async function storeImage(
|
|
130
|
+
deps: StoreImageDeps,
|
|
131
|
+
input: StoreImageInput,
|
|
132
|
+
): Promise<string> {
|
|
133
|
+
// FIRST, and before a decoder sees anything: bytes that are not really an image
|
|
134
|
+
// of the declared type never reach the pipeline at all.
|
|
135
|
+
assertImageBytes(deps, input.bytes, input.contentType);
|
|
136
|
+
const [processed, renditions] = await Promise.all([
|
|
137
|
+
deps.pipeline.process(input.bytes, input.contentType),
|
|
138
|
+
deps.pipeline.cut(input.bytes, deps.specs),
|
|
139
|
+
]);
|
|
140
|
+
if (!processed.ok) refuse(deps, processed.problem);
|
|
141
|
+
|
|
142
|
+
const { contentType } = processed.image;
|
|
143
|
+
const mint = { scope: input.scope, contentType, prefix: deps.keyPrefix };
|
|
144
|
+
// An empty rendition list is the animated case and the pipeline-declined case,
|
|
145
|
+
// and both want the flat key: it is what tells every later reader that this
|
|
146
|
+
// photo has no crops to ask for. Decided from the RESULT, never a prediction.
|
|
147
|
+
const key = renditions.length > 0 ? mintObjectSetKey(mint) : mintObjectKey(mint);
|
|
148
|
+
|
|
149
|
+
await writeSet(deps, key, processed.image, renditions);
|
|
150
|
+
return key;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* {@link storeImage} for bytes that arrived base64-encoded inside a write body.
|
|
155
|
+
*
|
|
156
|
+
* The decode is the only thing this adds. Everything that happens to the bytes
|
|
157
|
+
* afterwards is the same code a browser's upload runs, so a photo is the same set
|
|
158
|
+
* of objects however it arrived.
|
|
159
|
+
*/
|
|
160
|
+
export async function storeInlineImage(
|
|
161
|
+
deps: StoreImageDeps,
|
|
162
|
+
image: InlineImage,
|
|
163
|
+
scope: string,
|
|
164
|
+
): Promise<string> {
|
|
165
|
+
const decoded = decodeImagePayload(image.contentBase64, image.contentType, deps.maxBytes);
|
|
166
|
+
if (!decoded.ok) refuse(deps, decoded.problem);
|
|
167
|
+
return storeImage(deps, { bytes: decoded.bytes, contentType: image.contentType, scope });
|
|
168
|
+
}
|
package/src/urls.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { hasRenditions, renditionKey } from './keys';
|
|
2
|
+
import {
|
|
3
|
+
CATALOG_RENDITIONS,
|
|
4
|
+
renditionFamilies,
|
|
5
|
+
type RenditionFamily,
|
|
6
|
+
type RenditionSpec,
|
|
7
|
+
} from './renditions';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Turning a stored KEY back into URLs a browser can load.
|
|
11
|
+
*
|
|
12
|
+
* The key, never the URL, is what lands in the host's database. Display code
|
|
13
|
+
* resolves it through the ACTIVE driver, so the same row renders correctly
|
|
14
|
+
* whether the object lives on a dev machine's disk or in a bucket behind a CDN —
|
|
15
|
+
* and only the server knows which of those is running, which is why these run
|
|
16
|
+
* server-side and the client receives finished `src`/`srcSet` pairs.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** How a key becomes a loadable URL. A driver's `publicUrl`, in practice. */
|
|
20
|
+
export type PublicUrlResolver = (key: string) => string;
|
|
21
|
+
|
|
22
|
+
/** One `srcset` family, ready to hand an `<img>`. */
|
|
23
|
+
export interface ImageRenditionSet {
|
|
24
|
+
/**
|
|
25
|
+
* What a browser without `srcset` support falls back to — a real member of
|
|
26
|
+
* the family rather than the uncropped object, because a fallback that changed
|
|
27
|
+
* the framing would make one card in a grid taller than the rest.
|
|
28
|
+
*/
|
|
29
|
+
src: string;
|
|
30
|
+
/** `w`-descriptor srcset — the widths are the canvases the crops were cut at. */
|
|
31
|
+
srcSet: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The derived crops of one photo, by the shape they are drawn in. `null` for a
|
|
36
|
+
* photo stored without them — an animated source, a passthrough pipeline, or
|
|
37
|
+
* anything uploaded before renditions existed — which every consumer reads as
|
|
38
|
+
* "draw the uncropped object the way it has always been drawn".
|
|
39
|
+
*/
|
|
40
|
+
export type ImageSources = Readonly<Record<RenditionFamily, ImageRenditionSet>>;
|
|
41
|
+
|
|
42
|
+
/** Resolve a stored key, or an already-absolute URL, to something loadable. */
|
|
43
|
+
export function objectUrl(key: string | null, resolve: PublicUrlResolver): string | null {
|
|
44
|
+
if (!key) return null;
|
|
45
|
+
// A value that is already a URL is returned unchanged: a host migrating off
|
|
46
|
+
// an older column may still hold absolute URLs beside real keys.
|
|
47
|
+
if (/^https?:\/\//i.test(key)) return key;
|
|
48
|
+
return resolve(key);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function familySet(
|
|
52
|
+
key: string,
|
|
53
|
+
family: RenditionFamily,
|
|
54
|
+
specs: readonly RenditionSpec[],
|
|
55
|
+
resolve: PublicUrlResolver,
|
|
56
|
+
prefix: string | undefined,
|
|
57
|
+
): ImageRenditionSet {
|
|
58
|
+
const members = specs.filter((spec) => spec.family === family);
|
|
59
|
+
// Non-null throughout: `hasRenditions(key)` is the caller's precondition, and
|
|
60
|
+
// `family` came from this same spec list.
|
|
61
|
+
const url = (spec: RenditionSpec): string =>
|
|
62
|
+
resolve(renditionKey(key, spec.name, prefix) as string);
|
|
63
|
+
const first = members[0] as RenditionSpec;
|
|
64
|
+
return {
|
|
65
|
+
// The SMALLEST of the family: `src` is only reached without srcset support,
|
|
66
|
+
// and over-fetching is the worse of the two ways to be wrong there.
|
|
67
|
+
src: url(first),
|
|
68
|
+
srcSet: members.map((spec) => `${url(spec)} ${spec.width}w`).join(', '),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ImageSourcesOptions {
|
|
73
|
+
specs?: readonly RenditionSpec[];
|
|
74
|
+
prefix?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The crop families stored beside `key`, or `null` when it has none.
|
|
79
|
+
*
|
|
80
|
+
* Derived from the key's SHAPE alone — see `keys.ts` for why that is a truthful
|
|
81
|
+
* answer and not a guess.
|
|
82
|
+
*/
|
|
83
|
+
export function imageSources(
|
|
84
|
+
key: string | null,
|
|
85
|
+
resolve: PublicUrlResolver,
|
|
86
|
+
options: ImageSourcesOptions = {},
|
|
87
|
+
): ImageSources | null {
|
|
88
|
+
const specs = options.specs ?? CATALOG_RENDITIONS;
|
|
89
|
+
if (!key || !hasRenditions(key, options.prefix)) return null;
|
|
90
|
+
return Object.fromEntries(
|
|
91
|
+
renditionFamilies(specs).map((family) => [
|
|
92
|
+
family,
|
|
93
|
+
familySet(key, family, specs, resolve, options.prefix),
|
|
94
|
+
]),
|
|
95
|
+
) as ImageSources;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* {@link objectUrl} with a cache-busting stamp appended.
|
|
100
|
+
*
|
|
101
|
+
* Exists for an app icon, where a stable URL is a bug rather than a nicety: a
|
|
102
|
+
* manifest and its icons are cached hard, and an ALREADY-INSTALLED home-screen
|
|
103
|
+
* icon is the last asset a device re-fetches — so a store that replaces its icon
|
|
104
|
+
* keeps showing the old one on precisely the devices that matter, with nothing
|
|
105
|
+
* on screen to explain it.
|
|
106
|
+
*
|
|
107
|
+
* The separator is chosen rather than assumed: a CDN base may legitimately carry
|
|
108
|
+
* query parameters of its own, and a blind `?` would produce a URL with two.
|
|
109
|
+
*/
|
|
110
|
+
export function versionedObjectUrl(
|
|
111
|
+
key: string | null,
|
|
112
|
+
version: string | null,
|
|
113
|
+
resolve: PublicUrlResolver,
|
|
114
|
+
): string | null {
|
|
115
|
+
const url = objectUrl(key, resolve);
|
|
116
|
+
if (!url || !version) return url;
|
|
117
|
+
return `${url}${url.includes('?') ? '&' : '?'}v=${encodeURIComponent(version)}`;
|
|
118
|
+
}
|
package/src/wire.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
import { ACCEPTED_CONTENT_TYPES } from './content-types';
|
|
4
|
+
import { DEFAULT_KEY_PREFIX, isObjectKey } from './keys';
|
|
5
|
+
import { maxBase64Length } from './limits';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The wire shapes, as zod — including the one a TOOL schema is built from.
|
|
9
|
+
*
|
|
10
|
+
* `inlineImageSchema(maxBytes)` is a factory rather than a constant because its
|
|
11
|
+
* ceiling IS the mount's ceiling. That is the whole anti-drift point of the
|
|
12
|
+
* extraction: a host builds its agent-facing tool schema from
|
|
13
|
+
* `createApiStorage(…).schemas.inlineImage`, so the schema cannot advertise a
|
|
14
|
+
* limit the endpoint does not enforce, and raising one raises the other.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** An image supplied by VALUE, base64-encoded because a tool call is JSON. */
|
|
18
|
+
export function inlineImageSchema(
|
|
19
|
+
maxBytes: number,
|
|
20
|
+
): z.ZodObject<{ contentType: z.ZodEnum<Record<string, string>>; contentBase64: z.ZodString }> {
|
|
21
|
+
return z.object({
|
|
22
|
+
contentType: z.enum(
|
|
23
|
+
Object.fromEntries(ACCEPTED_CONTENT_TYPES.map((type) => [type, type])),
|
|
24
|
+
),
|
|
25
|
+
/** The file's bytes, base64-encoded. A `data:` URL prefix is accepted. */
|
|
26
|
+
contentBase64: z.string().min(1).max(maxBase64Length(maxBytes)),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** An image supplied by value: `{ contentType, contentBase64 }`. */
|
|
31
|
+
export interface InlineImage {
|
|
32
|
+
contentType: string;
|
|
33
|
+
contentBase64: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A key this scheme actually minted — the schema an `imageKey` FIELD should use.
|
|
38
|
+
*
|
|
39
|
+
* Ships beside {@link inlineImageSchema} because the pair is what a host's write
|
|
40
|
+
* body is made of, and because `z.string()` is the wrong answer in a way that is
|
|
41
|
+
* not obvious. An `imageKey` is not free text: it is a key the server minted, it is
|
|
42
|
+
* rendered straight into an `<img src>` by `objectUrl`, and `objectUrl`
|
|
43
|
+
* deliberately returns an ALREADY-ABSOLUTE value unchanged (a documented migration
|
|
44
|
+
* affordance for a host moving off a URL column).
|
|
45
|
+
*
|
|
46
|
+
* Compose those two and a recipe that accepts any string accepts
|
|
47
|
+
* `imageKey: 'https://tracker.example/p.png'` from anyone who may edit the row —
|
|
48
|
+
* after which every buyer loading that storefront page sends their IP and
|
|
49
|
+
* user-agent to a third party. No upload, no bucket and no driver is involved, so
|
|
50
|
+
* nothing on the storage path can catch it; the only place to refuse it is where
|
|
51
|
+
* the value arrives.
|
|
52
|
+
*
|
|
53
|
+
* The cross-tenant variant (`products/<otherTenant>/<uuid>/full.webp`) parses here
|
|
54
|
+
* and is refused later by the reclaim's `keyInScope` — but a host that wants it
|
|
55
|
+
* refused on the WRITE should pair this with `keyInScope(key, scope, …)`, which
|
|
56
|
+
* this schema cannot do because it does not know the caller.
|
|
57
|
+
*
|
|
58
|
+
* A `javascript:`-shaped value is already inert: `objectUrl` only passes through
|
|
59
|
+
* `^https?://`, so `javascript:alert(1)` is handed to the driver and comes back as
|
|
60
|
+
* a path under the mount. Inert is not the same as valid, and it is refused here too.
|
|
61
|
+
*/
|
|
62
|
+
export function objectKeySchema(prefix: string = DEFAULT_KEY_PREFIX): z.ZodString {
|
|
63
|
+
return z.string().refine((key) => isObjectKey(key, prefix), {
|
|
64
|
+
message: 'Not an object key minted by this storage mount.',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The pair of fields an image-bearing write body carries. */
|
|
69
|
+
export interface ImageFields {
|
|
70
|
+
imageKey?: string | null;
|
|
71
|
+
image?: InlineImage;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Reject a body stating BOTH an existing key and new bytes.
|
|
76
|
+
*
|
|
77
|
+
* Silently preferring one makes the other's presence a no-op the caller cannot
|
|
78
|
+
* see, and the two mean opposite things — "keep pointing at this object" versus
|
|
79
|
+
* "store these bytes and point at the result". An explicit refusal is the only
|
|
80
|
+
* reading that cannot quietly discard an upload.
|
|
81
|
+
*/
|
|
82
|
+
export function refineImageInput(
|
|
83
|
+
value: ImageFields,
|
|
84
|
+
ctx: z.RefinementCtx,
|
|
85
|
+
path: readonly (string | number)[] = [],
|
|
86
|
+
): void {
|
|
87
|
+
if (value.image && value.imageKey) {
|
|
88
|
+
ctx.addIssue({
|
|
89
|
+
code: 'custom',
|
|
90
|
+
path: [...path, 'image'],
|
|
91
|
+
message:
|
|
92
|
+
'Send either imageKey (an object already stored) or image (the bytes to store), not both.',
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** What `POST <mount>/uploads/image` answers on success. */
|
|
98
|
+
export const uploadAcceptedSchema = z.object({
|
|
99
|
+
data: z.object({ imageKey: z.string() }),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/** The success body of an upload, as the browser half parses it. */
|
|
103
|
+
export type UploadAccepted = z.infer<typeof uploadAcceptedSchema>;
|