@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,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@12-apps/storage/react` — the browser half.
|
|
3
|
+
*
|
|
4
|
+
* One factory, {@link createWebStorage}. Everything else exported here is a part
|
|
5
|
+
* of it that a host may legitimately want on its own: the hook for a bespoke
|
|
6
|
+
* affordance, the re-encode for a form that shrinks a file before doing something
|
|
7
|
+
* else with it, and the failure vocabulary for a host rendering its own messages.
|
|
8
|
+
*
|
|
9
|
+
* There is deliberately no export that uploads anywhere but this package's own
|
|
10
|
+
* endpoint on the app's own origin.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
createWebStorage,
|
|
15
|
+
type WebStorage,
|
|
16
|
+
type WebStorageConfig,
|
|
17
|
+
} from './create-web-storage';
|
|
18
|
+
|
|
19
|
+
export { ImageField, type BoundImageFieldProps, type ImageFieldProps } from './image-field';
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
useUpload,
|
|
23
|
+
type UploadOptions,
|
|
24
|
+
type UploadState,
|
|
25
|
+
type UseUploadConfig,
|
|
26
|
+
} from './use-upload';
|
|
27
|
+
|
|
28
|
+
export {
|
|
29
|
+
CATALOG_IMAGE_PROFILE,
|
|
30
|
+
optimizeImage,
|
|
31
|
+
resetWebpSupportProbe,
|
|
32
|
+
scaledSize,
|
|
33
|
+
webpName,
|
|
34
|
+
type ImageProfile,
|
|
35
|
+
} from './optimize-image';
|
|
36
|
+
|
|
37
|
+
export {
|
|
38
|
+
defaultWebStorageMessages,
|
|
39
|
+
rejectFileUpfront,
|
|
40
|
+
transportFailure,
|
|
41
|
+
uploadFailure,
|
|
42
|
+
type WebStorageMessageContext,
|
|
43
|
+
type WebStorageMessages,
|
|
44
|
+
} from './failures';
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shrink a picked image BEFORE it is uploaded.
|
|
3
|
+
*
|
|
4
|
+
* A store owner picks the photo their phone or their supplier gave them, and that
|
|
5
|
+
* is what used to reach the bucket byte for byte: catalog objects are routinely
|
|
6
|
+
* 1–2 MB PNGs at 2000px, served whole into a card ~165px wide. The buyer
|
|
7
|
+
* downloads a hundred times the pixels they can see, on a phone, on mobile data,
|
|
8
|
+
* before the first price is legible — which is the entire "images take forever to
|
|
9
|
+
* load" complaint.
|
|
10
|
+
*
|
|
11
|
+
* WHY IN THE BROWSER as well as on the server. Doing it here means the bytes never
|
|
12
|
+
* travel at full size at all, so the upload itself gets faster and the size cap
|
|
13
|
+
* stops rejecting photos that are perfectly usable once resized. It is the device
|
|
14
|
+
* that picked the file doing work it can do for free. The server-side pipeline is
|
|
15
|
+
* not a duplicate of this: it exists for callers that are not browsers.
|
|
16
|
+
*
|
|
17
|
+
* WHAT IT WILL NOT DO. Three deliberate pass-throughs, each returning the
|
|
18
|
+
* ORIGINAL file untouched rather than a worse one:
|
|
19
|
+
*
|
|
20
|
+
* - **GIFs.** A canvas holds one frame, so re-encoding would silently turn an
|
|
21
|
+
* animation into a still. Better large than broken.
|
|
22
|
+
* - **Anything the browser cannot decode or encode.** Engines without
|
|
23
|
+
* `createImageBitmap`, a corrupt file, a test environment with no canvas. The
|
|
24
|
+
* upload must keep working: optimization is an improvement, never a
|
|
25
|
+
* prerequisite — and never a new way for an upload to hang.
|
|
26
|
+
* - **A result that is not actually smaller.** An already-optimized 40 KB WebP
|
|
27
|
+
* can round-trip LARGER. The size comparison at the end is what makes this
|
|
28
|
+
* safe to run unconditionally.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** How an image is re-encoded. */
|
|
32
|
+
export interface ImageProfile {
|
|
33
|
+
/**
|
|
34
|
+
* Longest side, in pixels, of the uploaded image.
|
|
35
|
+
*
|
|
36
|
+
* Chosen against the largest box the photo is ever drawn into — a 4:3 hero on a
|
|
37
|
+
* desktop viewport, with 2× headroom — NOT against the card. Storing only what
|
|
38
|
+
* the card needs would make every zoom soft, and a zoom is exactly where
|
|
39
|
+
* somebody looks closely.
|
|
40
|
+
*/
|
|
41
|
+
maxEdge: number;
|
|
42
|
+
/** WebP quality, 0–1. */
|
|
43
|
+
quality: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const CATALOG_IMAGE_PROFILE: ImageProfile = { maxEdge: 1280, quality: 0.82 };
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Types worth re-encoding. GIF is absent on purpose (see the header), and so is
|
|
50
|
+
* anything outside the upload allowlist — an unrecognised type is the upload
|
|
51
|
+
* layer's rejection to report, not ours to mangle first.
|
|
52
|
+
*/
|
|
53
|
+
const OPTIMIZABLE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Refuse to even DECODE beyond this. Decoding allocates width × height × 4 bytes
|
|
57
|
+
* of bitmap, so a hostile or accidental 100 MB file is a tab crash rather than a
|
|
58
|
+
* slow upload. Past this the file falls through untouched and the upload layer's
|
|
59
|
+
* own cap rejects it with a message that names the size.
|
|
60
|
+
*/
|
|
61
|
+
const DECODE_CEILING_BYTES = 32 * 1024 * 1024;
|
|
62
|
+
|
|
63
|
+
/** Target box for `maxEdge`, preserving aspect ratio. Never enlarges. */
|
|
64
|
+
export function scaledSize(
|
|
65
|
+
width: number,
|
|
66
|
+
height: number,
|
|
67
|
+
maxEdge: number,
|
|
68
|
+
): { width: number; height: number } {
|
|
69
|
+
const longest = Math.max(width, height);
|
|
70
|
+
if (longest <= maxEdge) return { width, height };
|
|
71
|
+
const ratio = maxEdge / longest;
|
|
72
|
+
// At least 1px on the short side: a 4000×3 panorama would otherwise scale to a
|
|
73
|
+
// zero-height canvas, and `drawImage` onto one throws.
|
|
74
|
+
return {
|
|
75
|
+
width: Math.max(1, Math.round(width * ratio)),
|
|
76
|
+
height: Math.max(1, Math.round(height * ratio)),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** `photo.png` → `photo.webp`; a name with no extension simply gains one. */
|
|
81
|
+
export function webpName(name: string): string {
|
|
82
|
+
return `${name.replace(/\.[^.]+$/, '')}.webp`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Whether this browser's canvas can actually emit WebP. Probed once. */
|
|
86
|
+
let webpSupport: boolean | null = null;
|
|
87
|
+
|
|
88
|
+
function canEncodeWebp(): boolean {
|
|
89
|
+
if (webpSupport !== null) return webpSupport;
|
|
90
|
+
try {
|
|
91
|
+
const canvas = document.createElement('canvas');
|
|
92
|
+
canvas.width = 1;
|
|
93
|
+
canvas.height = 1;
|
|
94
|
+
// `toDataURL` with an unsupported type does not throw — it silently answers a
|
|
95
|
+
// PNG data URL. Reading the prefix back is the only honest probe, and it is
|
|
96
|
+
// the same trap `toBlob` sets: it would hand back a PNG labelled WebP.
|
|
97
|
+
webpSupport = canvas.toDataURL('image/webp').startsWith('data:image/webp');
|
|
98
|
+
} catch {
|
|
99
|
+
webpSupport = false;
|
|
100
|
+
}
|
|
101
|
+
return webpSupport;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Reset the cached probe. Tests only — a real browser's answer never changes. */
|
|
105
|
+
export function resetWebpSupportProbe(): void {
|
|
106
|
+
webpSupport = null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Decode the file, or `null` if this environment cannot.
|
|
111
|
+
*
|
|
112
|
+
* `createImageBitmap` ONLY, deliberately. An `<img>` element whose `onload` and
|
|
113
|
+
* `onerror` BOTH stay silent is a real state — and the one jsdom is permanently in
|
|
114
|
+
* — which would leave this awaiting a promise that never settles, taking the
|
|
115
|
+
* upload with it. Trading an unbounded hang for "no optimization on an old
|
|
116
|
+
* browser" is not a close call. Decoding off the main thread is also the whole
|
|
117
|
+
* point on the phone this runs on.
|
|
118
|
+
*/
|
|
119
|
+
async function decode(file: File): Promise<ImageBitmap | null> {
|
|
120
|
+
if (typeof createImageBitmap !== 'function') return null;
|
|
121
|
+
try {
|
|
122
|
+
return await createImageBitmap(file);
|
|
123
|
+
} catch {
|
|
124
|
+
// A corrupt file, or a format this decoder will not take. Either way the
|
|
125
|
+
// upload proceeds with exactly the bytes the owner picked.
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Draw `source` into a `target`-sized canvas and encode it.
|
|
132
|
+
*
|
|
133
|
+
* The canvas is left TRANSPARENT rather than filled: the bottle-on-white PNGs this
|
|
134
|
+
* exists for carry an alpha channel, and WebP keeps it. That is also why the
|
|
135
|
+
* non-WebP fallback re-encodes to the SOURCE type instead of to JPEG — a JPEG has
|
|
136
|
+
* no alpha, so "optimizing" a cut-out product photo that way would hand the
|
|
137
|
+
* storefront a black rectangle behind every bottle.
|
|
138
|
+
*/
|
|
139
|
+
async function encode(
|
|
140
|
+
source: ImageBitmap,
|
|
141
|
+
target: { width: number; height: number },
|
|
142
|
+
profile: ImageProfile,
|
|
143
|
+
sourceType: string,
|
|
144
|
+
): Promise<Blob | null> {
|
|
145
|
+
// Probed BEFORE the drawing canvas exists: the probe allocates a canvas of its
|
|
146
|
+
// own, and interleaving the two makes the order they are created in part of this
|
|
147
|
+
// function's observable behaviour for no benefit.
|
|
148
|
+
const type = canEncodeWebp() ? 'image/webp' : sourceType;
|
|
149
|
+
|
|
150
|
+
const canvas = document.createElement('canvas');
|
|
151
|
+
canvas.width = target.width;
|
|
152
|
+
canvas.height = target.height;
|
|
153
|
+
const context = canvas.getContext('2d');
|
|
154
|
+
if (!context) return null;
|
|
155
|
+
context.imageSmoothingEnabled = true;
|
|
156
|
+
context.imageSmoothingQuality = 'high';
|
|
157
|
+
context.drawImage(source, 0, 0, target.width, target.height);
|
|
158
|
+
|
|
159
|
+
return new Promise((resolve) => {
|
|
160
|
+
if (typeof canvas.toBlob !== 'function') {
|
|
161
|
+
resolve(null);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
canvas.toBlob((blob) => resolve(blob), type, profile.quality);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Wrap an encoded blob as the file to upload, or `null` when it is not worth
|
|
170
|
+
* swapping in.
|
|
171
|
+
*
|
|
172
|
+
* `>=` and not `>`: a tie is no reason to trade a known-good original for a
|
|
173
|
+
* re-encode, and it keeps an already-optimal upload byte-identical. The rename is
|
|
174
|
+
* not cosmetic either — the server stores the object under the type this file
|
|
175
|
+
* declares.
|
|
176
|
+
*/
|
|
177
|
+
function asImprovedFile(blob: Blob | null, original: File): File | null {
|
|
178
|
+
if (!blob || blob.size >= original.size) return null;
|
|
179
|
+
const type = blob.type || original.type;
|
|
180
|
+
const name = type === 'image/webp' ? webpName(original.name) : original.name;
|
|
181
|
+
return new File([blob], name, { type, lastModified: original.lastModified });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* A smaller, web-ready version of `file` — or `file` itself when that is not
|
|
186
|
+
* possible or not an improvement.
|
|
187
|
+
*
|
|
188
|
+
* Never throws and never rejects: every failure mode degrades to the original
|
|
189
|
+
* file, so a caller can await it unconditionally.
|
|
190
|
+
*/
|
|
191
|
+
export async function optimizeImage(
|
|
192
|
+
file: File,
|
|
193
|
+
profile: ImageProfile = CATALOG_IMAGE_PROFILE,
|
|
194
|
+
): Promise<File> {
|
|
195
|
+
if (!OPTIMIZABLE_TYPES.has(file.type) || file.size > DECODE_CEILING_BYTES) return file;
|
|
196
|
+
|
|
197
|
+
const source = await decode(file);
|
|
198
|
+
if (!source) return file;
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
const { width, height } = source;
|
|
202
|
+
if (width === 0 || height === 0) return file;
|
|
203
|
+
const target = scaledSize(width, height, profile.maxEdge);
|
|
204
|
+
const blob = await encode(source, target, profile, file.type);
|
|
205
|
+
return asImprovedFile(blob, file) ?? file;
|
|
206
|
+
} catch {
|
|
207
|
+
// Any surprise from canvas/drawImage — a tainted source, an out-of-memory
|
|
208
|
+
// allocation — leaves the upload with the file the owner actually picked.
|
|
209
|
+
return file;
|
|
210
|
+
} finally {
|
|
211
|
+
// Frees the decoded bitmap immediately rather than at the next GC — on a phone
|
|
212
|
+
// a 4000×3000 photo is 48 MB of it.
|
|
213
|
+
source.close();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import { STORAGE_PATHS } from '../paths';
|
|
4
|
+
import {
|
|
5
|
+
rejectFileUpfront,
|
|
6
|
+
transportFailure,
|
|
7
|
+
uploadFailure,
|
|
8
|
+
type WebStorageMessages,
|
|
9
|
+
} from './failures';
|
|
10
|
+
import { optimizeImage, type ImageProfile } from './optimize-image';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Storing an image from the browser: ONE request, to the package's own endpoint,
|
|
14
|
+
* which answers with the key to save.
|
|
15
|
+
*
|
|
16
|
+
* It is one request rather than two — and never a presigned bucket URL — for the
|
|
17
|
+
* reasons the root entry states at length: a key minted before the bytes exist has
|
|
18
|
+
* to PREDICT whether crops will accompany it, and a cross-origin PUT fails
|
|
19
|
+
* invisibly server-side and unfixably for the store owner. Neither is a trade this
|
|
20
|
+
* hook is allowed to reopen; there is no `presign` option.
|
|
21
|
+
*
|
|
22
|
+
* Every exit path that returns `null` sets a SPECIFIC `error` first — the file was
|
|
23
|
+
* too big, the session expired, the server could not read the image, the
|
|
24
|
+
* connection never landed. That is the difference between an owner who can fix
|
|
25
|
+
* their upload (or hand support a real symptom) and one staring at a red box that
|
|
26
|
+
* says only that something went wrong.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** Per-call knobs. Everything else about an upload is the same every time. */
|
|
30
|
+
export interface UploadOptions {
|
|
31
|
+
/**
|
|
32
|
+
* Re-encode and downscale before sending (default `true`).
|
|
33
|
+
*
|
|
34
|
+
* Turned OFF for files that are not a picked photo — an app icon rendered to
|
|
35
|
+
* exactly 192 and 512 px, say, whose manifest declares `image/png` in a literal.
|
|
36
|
+
* Quietly handing that a WebP makes the manifest describe an asset that does not
|
|
37
|
+
* exist, and takes the app's installability with it.
|
|
38
|
+
*/
|
|
39
|
+
optimize?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface UploadState {
|
|
43
|
+
upload: (file: File, options?: UploadOptions) => Promise<string | null>;
|
|
44
|
+
uploading: boolean;
|
|
45
|
+
error: string | null;
|
|
46
|
+
clearError: () => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface UseUploadConfig {
|
|
50
|
+
/** Where the router is mounted, e.g. `/api`. */
|
|
51
|
+
apiBase: string;
|
|
52
|
+
/** The ceiling the SERVER enforces. The check here is only an early refusal. */
|
|
53
|
+
maxBytes: number;
|
|
54
|
+
/** The browser-side re-encode profile. */
|
|
55
|
+
profile?: ImageProfile;
|
|
56
|
+
/** How the request is made. Default: same-origin `fetch`. */
|
|
57
|
+
fetchImpl?: typeof fetch;
|
|
58
|
+
/** pt-BR refusal copy overrides, for the codes only the host can explain. */
|
|
59
|
+
messages?: Partial<WebStorageMessages>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function useUpload(config: UseUploadConfig): UploadState {
|
|
63
|
+
const [uploading, setUploading] = useState(false);
|
|
64
|
+
const [error, setError] = useState<string | null>(null);
|
|
65
|
+
const endpoint = `${config.apiBase.replace(/\/$/, '')}${STORAGE_PATHS.upload}`;
|
|
66
|
+
|
|
67
|
+
async function send(file: File): Promise<string | null> {
|
|
68
|
+
const request = config.fetchImpl ?? fetch;
|
|
69
|
+
let response: Response;
|
|
70
|
+
try {
|
|
71
|
+
response = await request(endpoint, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
// The raw file, not base64 or multipart: the server reads the bytes
|
|
74
|
+
// straight off the stream against its own cap, and the type it stores the
|
|
75
|
+
// object as is this header.
|
|
76
|
+
headers: { 'content-type': file.type },
|
|
77
|
+
body: file,
|
|
78
|
+
});
|
|
79
|
+
} catch {
|
|
80
|
+
setError(transportFailure(config.messages));
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
if (!response.ok) {
|
|
84
|
+
setError(await uploadFailure(response, config.maxBytes, config.messages));
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const payload = (await response.json()) as { data?: { imageKey?: string } };
|
|
88
|
+
const key = payload.data?.imageKey;
|
|
89
|
+
if (!key) {
|
|
90
|
+
setError('O servidor aceitou a imagem mas não devolveu a chave. Tente de novo.');
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
return key;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function upload(file: File, options: UploadOptions = {}): Promise<string | null> {
|
|
97
|
+
setUploading(true);
|
|
98
|
+
setError(null);
|
|
99
|
+
try {
|
|
100
|
+
// Shrink FIRST, then validate — the order is the point (see
|
|
101
|
+
// `rejectFileUpfront`). `optimizeImage` never throws and returns the original
|
|
102
|
+
// when it cannot help, so the checks below still see a real file either way.
|
|
103
|
+
const upright =
|
|
104
|
+
options.optimize === false ? file : await optimizeImage(file, config.profile);
|
|
105
|
+
|
|
106
|
+
const upfront = rejectFileUpfront(upright, config.maxBytes);
|
|
107
|
+
if (upfront) {
|
|
108
|
+
setError(upfront);
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
return await send(upright);
|
|
112
|
+
} catch {
|
|
113
|
+
setError('Falha ao enviar a imagem. Tente de novo em instantes.');
|
|
114
|
+
return null;
|
|
115
|
+
} finally {
|
|
116
|
+
setUploading(false);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { upload, uploading, error, clearError: () => setError(null) };
|
|
121
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The DERIVED sizes of a stored photo — the crops a storefront actually draws,
|
|
3
|
+
* cut once at upload instead of by every visitor's browser.
|
|
4
|
+
*
|
|
5
|
+
* Downscaling the uncropped object fixes the MEGABYTE. It does not fix the
|
|
6
|
+
* FRAMING, and the framing is what a buyer sees: a card's media block is a 4:3
|
|
7
|
+
* box, a supplier's photo is a 600×1200 bottle on white, and drawn `contain`
|
|
8
|
+
* the bottle occupies a third of the box with two flat columns beside it. The
|
|
9
|
+
* other half of the same waste is the byte — a 4:3 hero and a 64px thumbnail
|
|
10
|
+
* both served that one 1280px object.
|
|
11
|
+
*
|
|
12
|
+
* So an upload writes a SET:
|
|
13
|
+
*
|
|
14
|
+
* <prefix>/<scope>/<uuid>/full.webp the whole picture, uncropped
|
|
15
|
+
* <prefix>/<scope>/<uuid>/card-320.webp 4:3, one card on a phone
|
|
16
|
+
* <prefix>/<scope>/<uuid>/card-640.webp 4:3, a retina card / phone hero
|
|
17
|
+
* <prefix>/<scope>/<uuid>/card-1280.webp 4:3, a retina hero on a desktop
|
|
18
|
+
* <prefix>/<scope>/<uuid>/thumb-128.webp 1:1, a 64px row thumbnail
|
|
19
|
+
* <prefix>/<scope>/<uuid>/thumb-256.webp 1:1, the same row at 2×
|
|
20
|
+
*
|
|
21
|
+
* ONE 4:3 FAMILY SERVES BOTH the card and the hero, because both draw a 4:3
|
|
22
|
+
* box. Three widths plus a `sizes` attribute is how "mobile and desktop" is
|
|
23
|
+
* expressed on the web; cutting a `detail-640` next to an identical `card-640`
|
|
24
|
+
* would double the objects to say the same thing.
|
|
25
|
+
*
|
|
26
|
+
* The list is in ONE place so the writer, the URL builder and the reclaimer
|
|
27
|
+
* cannot disagree about what exists.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** Which `srcset` family a size belongs to. */
|
|
31
|
+
export type RenditionFamily = 'card' | 'thumb';
|
|
32
|
+
|
|
33
|
+
/** One derived size: the object's name suffix and the exact canvas it fills. */
|
|
34
|
+
export interface RenditionSpec {
|
|
35
|
+
/** File stem — the key is `<set>/<name>.webp`. */
|
|
36
|
+
name: string;
|
|
37
|
+
width: number;
|
|
38
|
+
height: number;
|
|
39
|
+
family: RenditionFamily;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The default rendition set.
|
|
44
|
+
*
|
|
45
|
+
* The widths are drawn sizes at 1× and 2×: a card is ~165–250 CSS px, a hero is
|
|
46
|
+
* the modal's full width (~390 on a phone, ~600 on a desktop), and a row
|
|
47
|
+
* thumbnail is 64. A host that draws different boxes passes its own list.
|
|
48
|
+
*/
|
|
49
|
+
export const CATALOG_RENDITIONS: readonly RenditionSpec[] = [
|
|
50
|
+
{ name: 'card-320', width: 320, height: 240, family: 'card' },
|
|
51
|
+
{ name: 'card-640', width: 640, height: 480, family: 'card' },
|
|
52
|
+
{ name: 'card-1280', width: 1280, height: 960, family: 'card' },
|
|
53
|
+
{ name: 'thumb-128', width: 128, height: 128, family: 'thumb' },
|
|
54
|
+
{ name: 'thumb-256', width: 256, height: 256, family: 'thumb' },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
/** Every rendition is WebP, whatever the source was. */
|
|
58
|
+
export const RENDITION_CONTENT_TYPE = 'image/webp';
|
|
59
|
+
|
|
60
|
+
/** A cut rendition: which spec it satisfies and the bytes to store. */
|
|
61
|
+
export interface RenditionOutput {
|
|
62
|
+
spec: RenditionSpec;
|
|
63
|
+
bytes: Uint8Array;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The distinct families in a rendition list, in first-seen order. */
|
|
67
|
+
export function renditionFamilies(
|
|
68
|
+
specs: readonly RenditionSpec[],
|
|
69
|
+
): readonly RenditionFamily[] {
|
|
70
|
+
return [...new Set(specs.map((spec) => spec.family))];
|
|
71
|
+
}
|
package/src/s3/index.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { DeleteObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
|
2
|
+
|
|
3
|
+
import { StorageNotConfiguredError } from '../problems';
|
|
4
|
+
import type { StorageDriver } from '../server/driver';
|
|
5
|
+
import {
|
|
6
|
+
assertRemoteUrlConfig,
|
|
7
|
+
remoteObjectUrl,
|
|
8
|
+
type RemoteObjectUrlConfig,
|
|
9
|
+
} from '../server/remote-url';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `@12-apps/storage/s3` — objects in an S3-compatible bucket.
|
|
13
|
+
*
|
|
14
|
+
* Behind its own subpath with `@aws-sdk/client-s3` as an OPTIONAL peer, so a host
|
|
15
|
+
* on local disk never resolves the SDK. `./server` deliberately does not
|
|
16
|
+
* re-export this.
|
|
17
|
+
*
|
|
18
|
+
* ONE driver covers every S3-compatible vendor. AWS needs a `region`; DigitalOcean
|
|
19
|
+
* Spaces, MinIO, R2 and the rest need an `endpoint`, plus `forcePathStyle` for the
|
|
20
|
+
* ones that cannot do virtual-hosted addressing. A second vendor is therefore a
|
|
21
|
+
* config entry and nothing else — no branch in the upload path, and no host code.
|
|
22
|
+
*
|
|
23
|
+
* Nothing here reads `process.env`. The credentials a deployment holds are the
|
|
24
|
+
* deployment's business, and a package that read them would silently pick up
|
|
25
|
+
* whatever happened to be exported in a test.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export interface S3DriverConfig extends RemoteObjectUrlConfig {
|
|
29
|
+
/** Explicit credentials. Omit to use the SDK's default chain (IAM role, …). */
|
|
30
|
+
credentials?: { accessKeyId: string; secretAccessKey: string };
|
|
31
|
+
/**
|
|
32
|
+
* Canned ACL for stored objects. `public-read` by default, because an object is
|
|
33
|
+
* loaded straight by an `<img>` with no bucket policy — and a private object
|
|
34
|
+
* behind a public URL 403s for every visitor while the upload reports success.
|
|
35
|
+
*
|
|
36
|
+
* `undefined` for a bucket with `BucketOwnerEnforced`, which rejects ANY ACL
|
|
37
|
+
* header; such a bucket needs a policy or a CDN in front of it instead.
|
|
38
|
+
*/
|
|
39
|
+
acl?: 'private' | 'public-read' | undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function buildClient(config: S3DriverConfig): S3Client {
|
|
43
|
+
return new S3Client({
|
|
44
|
+
...(config.region ? { region: config.region } : {}),
|
|
45
|
+
...(config.endpoint ? { endpoint: config.endpoint } : {}),
|
|
46
|
+
...(config.forcePathStyle ? { forcePathStyle: true } : {}),
|
|
47
|
+
...(config.credentials ? { credentials: config.credentials } : {}),
|
|
48
|
+
// Do NOT compute an integrity checksum unless the operation requires one.
|
|
49
|
+
// Since v3.729 the SDK adds a CRC32 by default; for a PRESIGNED PUT it is
|
|
50
|
+
// computed over a body that is empty at signing time and then hoisted into the
|
|
51
|
+
// URL, so a store that validates it rejects the upload for a digest mismatch
|
|
52
|
+
// on bytes the signer never saw. This package never presigns, but the setting
|
|
53
|
+
// is kept as a matter of record: it is the trap that made "the bytes go
|
|
54
|
+
// through the server" the only shape here.
|
|
55
|
+
requestChecksumCalculation: 'WHEN_REQUIRED',
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Objects in a bucket, written by the app server with credentials it already
|
|
61
|
+
* holds. There is no method that hands a browser an address — see the root
|
|
62
|
+
* entry for why that is the whole point.
|
|
63
|
+
*/
|
|
64
|
+
export function createS3Driver(config: S3DriverConfig): StorageDriver {
|
|
65
|
+
// At construction, not at the first upload: an operator finds out from a boot
|
|
66
|
+
// failure rather than from a store owner whose photo would not save.
|
|
67
|
+
assertRemoteUrlConfig(config);
|
|
68
|
+
const acl = 'acl' in config ? config.acl : 'public-read';
|
|
69
|
+
let client: S3Client | undefined;
|
|
70
|
+
// Built lazily and cached: endpoint resolution and the credential chain are not
|
|
71
|
+
// free per call, and a host that only ever reads URLs never pays for either.
|
|
72
|
+
const send = (command: PutObjectCommand | DeleteObjectCommand): Promise<unknown> => {
|
|
73
|
+
client ??= buildClient(config);
|
|
74
|
+
return client.send(command);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
name: config.endpoint ? 's3-compatible' : 's3',
|
|
79
|
+
put: async (key, bytes, contentType) => {
|
|
80
|
+
if (!config.bucket) {
|
|
81
|
+
throw new StorageNotConfiguredError('Storing an object needs a bucket.');
|
|
82
|
+
}
|
|
83
|
+
await send(
|
|
84
|
+
new PutObjectCommand({
|
|
85
|
+
Bucket: config.bucket,
|
|
86
|
+
Key: key,
|
|
87
|
+
Body: bytes,
|
|
88
|
+
ContentType: contentType,
|
|
89
|
+
// A real request header, which the SDK signs. Passing `x-amz-acl` as a
|
|
90
|
+
// presigned QUERY parameter is accepted and then silently ignored — the
|
|
91
|
+
// object stores private behind a public URL.
|
|
92
|
+
...(acl ? { ACL: acl } : {}),
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
},
|
|
96
|
+
// S3's DeleteObject is idempotent — removing a key that is not there answers
|
|
97
|
+
// 204, not an error — which is exactly the contract the port advertises.
|
|
98
|
+
delete: async (key) => {
|
|
99
|
+
await send(new DeleteObjectCommand({ Bucket: config.bucket, Key: key }));
|
|
100
|
+
},
|
|
101
|
+
publicUrl: (key) => remoteObjectUrl(config, key),
|
|
102
|
+
// No `read`: the bytes are in the bucket, so the serve route redirects to
|
|
103
|
+
// their public URL instead of streaming them through the app server.
|
|
104
|
+
};
|
|
105
|
+
}
|