@wxxb789/anc 0.0.2 → 0.0.3
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/README.md +63 -34
- package/bin/anc.mjs +120 -11
- package/package.json +8 -1
- package/scripts/copy-assets.js +141 -0
- package/scripts/init-repository.js +18 -0
- package/scripts/load-config.js +258 -5
- package/scripts/markdown-to-artifact.js +310 -6
- package/scripts/publish-media.js +468 -0
- package/scripts/resolve-links.js +318 -42
- package/scripts/scan-residue.js +17 -6
- package/scripts/verify-output-inventory.js +53 -0
- package/src/lib/content.js +22 -0
- package/src/lib/font-coverage.js +845 -0
- package/src/lib/markdown.js +257 -11
- package/src/lib/media-codec.js +355 -0
- package/src/lib/media-delivery.js +76 -0
- package/src/lib/media.js +1426 -0
- package/src/lib/schema.js +83 -8
- package/src/lib/svg-css.js +685 -0
- package/src/lib/svg-policy.js +1699 -0
- package/src/lib/svg-refusal.js +1 -0
- package/src/lib/svg-render.js +343 -0
- package/src/pages/notes/[slug].astro +10 -1
- package/src/pages/privacy.astro +9 -5
- package/src/styles/global.css +29 -0
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in publication of already-authorized images to Cloudflare R2.
|
|
3
|
+
*
|
|
4
|
+
* Goal 0013's publisher, and the only module in this repository that may write
|
|
5
|
+
* to a network service. Nothing reaches it unless `anc build --release --media
|
|
6
|
+
* r2` ran: ordinary `build`, `preview`, and `build --release` never import this
|
|
7
|
+
* file, and this file imports `aws4fetch` only inside {@link publishMedia}, so
|
|
8
|
+
* even a caller holding the planning helpers never loads the signer.
|
|
9
|
+
*
|
|
10
|
+
* ## What it is allowed to publish
|
|
11
|
+
*
|
|
12
|
+
* Exactly the artifact's declared `assets` list — the producer's one
|
|
13
|
+
* published representation per referenced image. A draft-only attachment, an
|
|
14
|
+
* excluded file, and a note's source path never reach this module: the artifact
|
|
15
|
+
* does not carry them and the staging directory beside it was already checked
|
|
16
|
+
* by the copy step. The object path is the canonical same-origin asset path
|
|
17
|
+
* (`/_assets/<sha256>-<name>`), so a local file and its remote object are one
|
|
18
|
+
* identity rather than two schemes to keep in step. No note slug, source
|
|
19
|
+
* directory, or private report name is used to form a key.
|
|
20
|
+
*
|
|
21
|
+
* ## Ordering and what "done" means
|
|
22
|
+
*
|
|
23
|
+
* Plan every object from staged bytes and hashes first, then upload/reuse all
|
|
24
|
+
* of them, then read every object back through the public custom domain and
|
|
25
|
+
* hash it. The caller promotes the candidate site only after this returns, so a
|
|
26
|
+
* failed upload, a mismatched existing object, or a CDN that serves the wrong
|
|
27
|
+
* bytes leaves the last good local output in place. A `PUT` that succeeds but
|
|
28
|
+
* whose response was lost is retried *conditionally*; a `404` read is never
|
|
29
|
+
* treated as proof that such a write will not commit.
|
|
30
|
+
*
|
|
31
|
+
* ## Why `HEAD` is not used
|
|
32
|
+
*
|
|
33
|
+
* Size, MIME, and an S3 ETag are not SHA-256 proof, and a `HEAD` before a
|
|
34
|
+
* conditional `PUT` either repeats a check the `412` path already performs or
|
|
35
|
+
* invites treating metadata as evidence. Existing objects are proven by reading
|
|
36
|
+
* and hashing their bytes.
|
|
37
|
+
*/
|
|
38
|
+
import { createHash } from 'node:crypto';
|
|
39
|
+
import { lstat, readFile } from 'node:fs/promises';
|
|
40
|
+
import { join } from 'node:path';
|
|
41
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
42
|
+
import { formatForExtension, imageMimeType, parseAssetUrl } from "../src/lib/media.js";
|
|
43
|
+
import { BuildFailure } from "./write-report.js";
|
|
44
|
+
export const MEDIA_PUBLISH_LIMITS = Object.freeze({
|
|
45
|
+
concurrency: 4,
|
|
46
|
+
attempts: 3,
|
|
47
|
+
requestTimeoutMs: 30_000,
|
|
48
|
+
retryDelayMs: 250,
|
|
49
|
+
maxObjectBytes: 32 * 1024 * 1024,
|
|
50
|
+
});
|
|
51
|
+
/** A status that may succeed on a later identical attempt. */
|
|
52
|
+
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
53
|
+
/** The cache metadata for an immutable, content-addressed object. */
|
|
54
|
+
const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable';
|
|
55
|
+
/**
|
|
56
|
+
* Read the two credentials named by the configuration.
|
|
57
|
+
*
|
|
58
|
+
* The names are config values, so the public message is a key location and the
|
|
59
|
+
* report carries the name. Values are never read into a message: the failure is
|
|
60
|
+
* "unset", which is a boolean about the environment rather than the secret.
|
|
61
|
+
*/
|
|
62
|
+
export function resolveMediaCredentials(config, environment = process.env) {
|
|
63
|
+
const accessKeyId = environment[config.accessKeyIdEnv];
|
|
64
|
+
const secretAccessKey = environment[config.secretAccessKeyEnv];
|
|
65
|
+
const missing = [];
|
|
66
|
+
if (typeof accessKeyId !== 'string' || accessKeyId === '')
|
|
67
|
+
missing.push('media.access_key_id_env');
|
|
68
|
+
if (typeof secretAccessKey !== 'string' || secretAccessKey === '')
|
|
69
|
+
missing.push('media.secret_access_key_env');
|
|
70
|
+
if (missing.length > 0) {
|
|
71
|
+
throw new BuildFailure('media-credentials-missing', `media credentials missing: ${missing.join(' and ')} ${missing.length === 1 ? 'names an environment variable that is' : 'name environment variables that are'} unset or empty`, `unset: ${[config.accessKeyIdEnv, config.secretAccessKeyEnv].filter((name) => !environment[name]).join(', ')}`);
|
|
72
|
+
}
|
|
73
|
+
return { accessKeyId: accessKeyId, secretAccessKey: secretAccessKey };
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Read, hash, and type every declared asset before touching the network.
|
|
77
|
+
*
|
|
78
|
+
* Failing here is a message about the private candidate, never a partial
|
|
79
|
+
* upload. The staged filename is the decoded `<digest>-<name>`; the public path
|
|
80
|
+
* is the canonical encoded URL from the artifact. They are two spellings of one
|
|
81
|
+
* identity, which is why both are derived from `parseAssetUrl` rather than
|
|
82
|
+
* recomputed from a filename pattern.
|
|
83
|
+
*/
|
|
84
|
+
async function planObjects(input, limits) {
|
|
85
|
+
const planned = [];
|
|
86
|
+
const seen = new Set();
|
|
87
|
+
for (const asset of [...input.assets].sort((left, right) => (left.url < right.url ? -1 : left.url > right.url ? 1 : 0))) {
|
|
88
|
+
// Repeated references are one object. The artifact's schema already
|
|
89
|
+
// requires a unique list, so this is the boundary that keeps a caller
|
|
90
|
+
// holding a repeated list from issuing a second conditional write for a
|
|
91
|
+
// key it has already proved.
|
|
92
|
+
if (seen.has(asset.url))
|
|
93
|
+
continue;
|
|
94
|
+
seen.add(asset.url);
|
|
95
|
+
const parsed = parseAssetUrl(asset.url);
|
|
96
|
+
if (parsed === undefined) {
|
|
97
|
+
throw new BuildFailure('media-asset-url', 'a declared image asset is not a canonical /_assets/<sha256>-<name> path', `artifact asset URL was ${JSON.stringify(asset.url)}`);
|
|
98
|
+
}
|
|
99
|
+
const format = formatForExtension(parsed.fileName.slice(parsed.fileName.lastIndexOf('.')));
|
|
100
|
+
if (format === undefined) {
|
|
101
|
+
throw new BuildFailure('media-asset-url', 'a declared image asset has an extension no published image format names', `artifact asset URL was ${JSON.stringify(asset.url)}`);
|
|
102
|
+
}
|
|
103
|
+
const staged = join(input.assetDirectory, `${parsed.digest}-${parsed.fileName}`);
|
|
104
|
+
let info;
|
|
105
|
+
try {
|
|
106
|
+
info = await lstat(staged);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
throw new BuildFailure('media-asset-staged', 'a declared image asset is missing from the private staging directory', `absent staged file for ${asset.url}`);
|
|
110
|
+
}
|
|
111
|
+
if (!info.isFile()) {
|
|
112
|
+
throw new BuildFailure('media-asset-staged', 'a declared image asset is not a regular file in the private staging directory', `not a regular file: staged entry for ${asset.url}`);
|
|
113
|
+
}
|
|
114
|
+
// `readFile` allocates an ordinary, non-shared buffer; the cast records that
|
|
115
|
+
// for `BodyInit` rather than copying the whole asset to satisfy a generic.
|
|
116
|
+
const bytes = (await readFile(staged));
|
|
117
|
+
if (bytes.length === 0) {
|
|
118
|
+
throw new BuildFailure('media-asset-staged', 'a declared image asset is empty in the private staging directory', `empty staged file for ${asset.url}`);
|
|
119
|
+
}
|
|
120
|
+
if (bytes.length > limits.maxObjectBytes) {
|
|
121
|
+
throw new BuildFailure('media-asset-too-large', 'a declared image asset exceeds the validated image size budget', `${asset.url}: ${bytes.length} bytes, budget ${limits.maxObjectBytes}`);
|
|
122
|
+
}
|
|
123
|
+
const digest = createHash('sha256').update(bytes).digest('hex');
|
|
124
|
+
if (digest !== parsed.digest) {
|
|
125
|
+
throw new BuildFailure('media-asset-staged', 'a declared image asset does not hash to the digest its content address names', `${asset.url}: expected ${parsed.digest}, found ${digest}`);
|
|
126
|
+
}
|
|
127
|
+
planned.push({
|
|
128
|
+
path: asset.url,
|
|
129
|
+
publicUrl: `${input.config.publicBaseUrl}${asset.url}`,
|
|
130
|
+
digest,
|
|
131
|
+
mime: imageMimeType(format),
|
|
132
|
+
bytes,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return planned;
|
|
136
|
+
}
|
|
137
|
+
/** Release a response body without reading it. */
|
|
138
|
+
async function discard(response) {
|
|
139
|
+
try {
|
|
140
|
+
await response.body?.cancel();
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// A body that is already closed or errored has nothing left to release.
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** The media type of a `Content-Type` header, without parameters. */
|
|
147
|
+
function mediaType(header) {
|
|
148
|
+
if (header === null)
|
|
149
|
+
return undefined;
|
|
150
|
+
const type = header.split(';', 1)[0]?.trim().toLowerCase();
|
|
151
|
+
return type === undefined || type === '' ? undefined : type;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Hash and count a response body without ever holding more than one chunk.
|
|
155
|
+
*
|
|
156
|
+
* A public domain or storage endpoint can answer with a chunked body and no
|
|
157
|
+
* `Content-Length`. Buffering that body and comparing its length afterwards
|
|
158
|
+
* would let a remote response exhaust this host's memory before the intended
|
|
159
|
+
* budget failure could fire. This reads one chunk at a time, hashes it, and
|
|
160
|
+
* cancels the stream the moment the running count passes `limit`, so retained
|
|
161
|
+
* bytes stay bounded by one chunk plus the hash state. The caller checks a
|
|
162
|
+
* declared length first; this is the second, authoritative check.
|
|
163
|
+
*
|
|
164
|
+
* A `null` body has no stream to cancel. The runtime offers no other read, so
|
|
165
|
+
* that case buffers — but only after the caller's declared-length refusal —
|
|
166
|
+
* and still refuses on the measured count.
|
|
167
|
+
*/
|
|
168
|
+
async function readBoundedBody(response, limit, overBudget) {
|
|
169
|
+
if (response.body === null) {
|
|
170
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
171
|
+
if (bytes.length > limit)
|
|
172
|
+
throw overBudget(bytes.length);
|
|
173
|
+
return { digest: createHash('sha256').update(bytes).digest('hex'), bytes: bytes.length };
|
|
174
|
+
}
|
|
175
|
+
const reader = response.body.getReader();
|
|
176
|
+
const hash = createHash('sha256');
|
|
177
|
+
let total = 0;
|
|
178
|
+
try {
|
|
179
|
+
for (;;) {
|
|
180
|
+
const { done, value } = await reader.read();
|
|
181
|
+
if (done)
|
|
182
|
+
break;
|
|
183
|
+
total += value.byteLength;
|
|
184
|
+
if (total > limit) {
|
|
185
|
+
// The budget failure is the one to report; an already-closed or
|
|
186
|
+
// errored stream has nothing left to cancel.
|
|
187
|
+
await reader.cancel().catch(() => { });
|
|
188
|
+
throw overBudget(total);
|
|
189
|
+
}
|
|
190
|
+
hash.update(value);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
reader.releaseLock();
|
|
195
|
+
}
|
|
196
|
+
return { digest: hash.digest('hex'), bytes: total };
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Send one signed request with `redirect: 'manual'`.
|
|
200
|
+
*
|
|
201
|
+
* Manual redirects are the security property, not a convenience: a `3xx` from
|
|
202
|
+
* the storage endpoint is refused before the runtime can forward an
|
|
203
|
+
* `Authorization` header to a different host. The public URL is fetched the
|
|
204
|
+
* same way for the same class of reason.
|
|
205
|
+
*/
|
|
206
|
+
async function signedFetch(signer, config, path, init, limits) {
|
|
207
|
+
const signed = await signer.sign(`${config.endpoint}/${config.bucket}${path}`, init);
|
|
208
|
+
return fetch(signed, {
|
|
209
|
+
redirect: 'manual',
|
|
210
|
+
signal: AbortSignal.timeout(limits.requestTimeoutMs),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
/** Map an upload response status to the failure it represents, or `undefined` to retry. */
|
|
214
|
+
function uploadFailure(status, path) {
|
|
215
|
+
if (status === 401 || status === 403) {
|
|
216
|
+
return new BuildFailure('media-credentials-rejected', 'media credentials were rejected by the storage endpoint', `${path}: status ${status}`);
|
|
217
|
+
}
|
|
218
|
+
if (status === 404) {
|
|
219
|
+
return new BuildFailure('media-bucket-not-found', 'the configured media bucket was not found at the storage endpoint', `${path}: status ${status}`);
|
|
220
|
+
}
|
|
221
|
+
if (status >= 300 && status < 400) {
|
|
222
|
+
return new BuildFailure('media-endpoint-redirect', 'the storage endpoint answered with a redirect, which is refused so signed headers cannot be forwarded', `${path}: status ${status}`);
|
|
223
|
+
}
|
|
224
|
+
if (RETRYABLE_STATUS.has(status))
|
|
225
|
+
return undefined;
|
|
226
|
+
return new BuildFailure('media-object-upload-failed', 'a media object could not be uploaded', `${path}: status ${status}`);
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Read one object from the storage API and prove its bytes against the digest.
|
|
230
|
+
*
|
|
231
|
+
* This is the path a `412` takes and the path a second publication of unchanged
|
|
232
|
+
* content takes. A `404` here is reported rather than interpreted: by the time
|
|
233
|
+
* this runs, a conditional write may have committed even if the client never
|
|
234
|
+
* saw its response, and R2's consistency documentation states that a negative
|
|
235
|
+
* read does not wait for or cancel an in-flight write.
|
|
236
|
+
*/
|
|
237
|
+
async function verifyStoredObject(signer, config, object, limits) {
|
|
238
|
+
for (let attempt = 1; attempt <= limits.attempts; attempt += 1) {
|
|
239
|
+
let response;
|
|
240
|
+
try {
|
|
241
|
+
response = await signedFetch(signer, config, object.path, { method: 'GET' }, limits);
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
if (attempt === limits.attempts) {
|
|
245
|
+
throw new BuildFailure('media-object-missing', 'a media object could not be read back from the storage endpoint', `${object.path}: ${String(error.message ?? error)}`);
|
|
246
|
+
}
|
|
247
|
+
await delay(limits.retryDelayMs * attempt);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (response.status === 200) {
|
|
251
|
+
const declared = response.headers.get('content-length');
|
|
252
|
+
if (declared !== null && Number(declared) > limits.maxObjectBytes) {
|
|
253
|
+
await discard(response);
|
|
254
|
+
throw new BuildFailure('media-object-mismatch', 'a media object already present at its content address exceeds the validated image size budget', `${object.path}: content-length ${declared}`);
|
|
255
|
+
}
|
|
256
|
+
const body = await readBoundedBody(response, limits.maxObjectBytes, (bytes) => new BuildFailure('media-object-mismatch', 'a media object already present at its content address exceeds the validated image size budget', `${object.path}: read ${bytes} bytes`));
|
|
257
|
+
if (body.digest !== object.digest || body.bytes !== object.bytes.length) {
|
|
258
|
+
throw new BuildFailure('media-object-mismatch', 'a media object already present at its content address holds different bytes', `${object.path}: expected ${object.digest}/${object.bytes.length} bytes, ` +
|
|
259
|
+
`found ${body.digest}/${body.bytes} bytes`);
|
|
260
|
+
}
|
|
261
|
+
const storedType = mediaType(response.headers.get('content-type'));
|
|
262
|
+
if (storedType !== object.mime) {
|
|
263
|
+
throw new BuildFailure('media-object-mismatch', 'a media object already present at its content address has the wrong content type', `${object.path}: expected ${object.mime}, found ${storedType ?? 'none'}`);
|
|
264
|
+
}
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (response.status === 401 || response.status === 403) {
|
|
268
|
+
await discard(response);
|
|
269
|
+
throw new BuildFailure('media-credentials-rejected', 'media credentials were rejected by the storage endpoint', `${object.path}: status ${response.status}`);
|
|
270
|
+
}
|
|
271
|
+
if (response.status === 404) {
|
|
272
|
+
await discard(response);
|
|
273
|
+
throw new BuildFailure('media-object-missing', 'a media object could not be read back at its content address', `${object.path}: status 404 — a negative read is not proof that an ambiguous PUT will not commit`);
|
|
274
|
+
}
|
|
275
|
+
if (response.status >= 300 && response.status < 400) {
|
|
276
|
+
await discard(response);
|
|
277
|
+
throw new BuildFailure('media-endpoint-redirect', 'the storage endpoint answered with a redirect, which is refused so signed headers cannot be forwarded', `${object.path}: status ${response.status}`);
|
|
278
|
+
}
|
|
279
|
+
if (!RETRYABLE_STATUS.has(response.status) || attempt === limits.attempts) {
|
|
280
|
+
await discard(response);
|
|
281
|
+
throw new BuildFailure('media-object-missing', 'a media object could not be read back from the storage endpoint', `${object.path}: status ${response.status}`);
|
|
282
|
+
}
|
|
283
|
+
await discard(response);
|
|
284
|
+
await delay(limits.retryDelayMs * attempt);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Make one object available with create-once semantics.
|
|
289
|
+
*
|
|
290
|
+
* Every attempt is a conditional `PUT`, including a retry after a timeout or a
|
|
291
|
+
* dropped connection. `412` means another writer — possibly this client's own
|
|
292
|
+
* earlier ambiguous attempt — owns the key; the object is then read and hashed
|
|
293
|
+
* rather than overwritten. Success means the bucket accepted this exact
|
|
294
|
+
* payload; the public read later proves what a reader receives.
|
|
295
|
+
*/
|
|
296
|
+
async function ensureObject(signer, config, object, limits) {
|
|
297
|
+
const headers = {
|
|
298
|
+
'If-None-Match': '*',
|
|
299
|
+
'Content-Type': object.mime,
|
|
300
|
+
'Cache-Control': IMMUTABLE_CACHE,
|
|
301
|
+
// Transport integrity, not security: the server can reject truncated or
|
|
302
|
+
// corrupted bytes. `UNSIGNED-PAYLOAD` remains the signature form R2
|
|
303
|
+
// documents for `aws4fetch`, so this is the payload check the API offers.
|
|
304
|
+
'Content-MD5': createHash('md5').update(object.bytes).digest('base64'),
|
|
305
|
+
};
|
|
306
|
+
for (let attempt = 1; attempt <= limits.attempts; attempt += 1) {
|
|
307
|
+
let response;
|
|
308
|
+
try {
|
|
309
|
+
response = await signedFetch(signer, config, object.path,
|
|
310
|
+
// `Uint8Array` rather than a stream: the bytes are already in memory and
|
|
311
|
+
// a retry must replay exactly the same payload.
|
|
312
|
+
{ method: 'PUT', headers, body: object.bytes }, limits);
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
if (attempt === limits.attempts) {
|
|
316
|
+
throw new BuildFailure('media-object-upload-failed', 'a media object could not be uploaded after bounded retries', `${object.path}: ${String(error.message ?? error)}`);
|
|
317
|
+
}
|
|
318
|
+
await delay(limits.retryDelayMs * attempt);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (response.status === 200 || response.status === 201 || response.status === 204) {
|
|
322
|
+
await discard(response);
|
|
323
|
+
return 'uploaded';
|
|
324
|
+
}
|
|
325
|
+
if (response.status === 412) {
|
|
326
|
+
await discard(response);
|
|
327
|
+
await verifyStoredObject(signer, config, object, limits);
|
|
328
|
+
return 'reused';
|
|
329
|
+
}
|
|
330
|
+
const known = uploadFailure(response.status, object.path);
|
|
331
|
+
if (known !== undefined) {
|
|
332
|
+
await discard(response);
|
|
333
|
+
throw known;
|
|
334
|
+
}
|
|
335
|
+
await discard(response);
|
|
336
|
+
if (attempt < limits.attempts)
|
|
337
|
+
await delay(limits.retryDelayMs * attempt);
|
|
338
|
+
}
|
|
339
|
+
throw new BuildFailure('media-object-upload-failed', 'a media object could not be uploaded after bounded retries', `${object.path}: retry budget exhausted`);
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Fetch the public custom-domain URL and prove what a reader actually gets.
|
|
343
|
+
*
|
|
344
|
+
* This runs only after every object is available. A `404` is never retried: the
|
|
345
|
+
* design forbids probing an absent object before upload precisely because
|
|
346
|
+
* Cloudflare caches negative responses, and a cached `404` that survives a
|
|
347
|
+
* successful `PUT` is a real failure the operator must purge or fix rather than
|
|
348
|
+
* something this run should paper over.
|
|
349
|
+
*/
|
|
350
|
+
async function verifyPublicObject(object, limits) {
|
|
351
|
+
for (let attempt = 1; attempt <= limits.attempts; attempt += 1) {
|
|
352
|
+
let response;
|
|
353
|
+
try {
|
|
354
|
+
response = await fetch(object.publicUrl, {
|
|
355
|
+
redirect: 'manual',
|
|
356
|
+
signal: AbortSignal.timeout(limits.requestTimeoutMs),
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
if (attempt === limits.attempts) {
|
|
361
|
+
throw new BuildFailure('media-public-unreachable', 'a published media URL could not be fetched from the public custom domain', `${object.publicUrl}: ${String(error.message ?? error)}`);
|
|
362
|
+
}
|
|
363
|
+
await delay(limits.retryDelayMs * attempt);
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
if (response.status >= 300 && response.status < 400) {
|
|
367
|
+
await discard(response);
|
|
368
|
+
throw new BuildFailure('media-public-unreachable', 'the public media URL answered with a redirect, which is refused', `${object.publicUrl}: status ${response.status}`);
|
|
369
|
+
}
|
|
370
|
+
if (response.status === 404) {
|
|
371
|
+
await discard(response);
|
|
372
|
+
throw new BuildFailure('media-public-unreachable', 'the public media URL returned 404 after the object was uploaded — a cached negative response stays until the cache TTL expires or is purged', `${object.publicUrl}: status 404`);
|
|
373
|
+
}
|
|
374
|
+
if (RETRYABLE_STATUS.has(response.status) && attempt < limits.attempts) {
|
|
375
|
+
await discard(response);
|
|
376
|
+
await delay(limits.retryDelayMs * attempt);
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
if (response.status !== 200) {
|
|
380
|
+
await discard(response);
|
|
381
|
+
throw new BuildFailure('media-public-unreachable', 'the public media URL did not return a successful response', `${object.publicUrl}: status ${response.status}`);
|
|
382
|
+
}
|
|
383
|
+
const declared = response.headers.get('content-length');
|
|
384
|
+
if (declared !== null && Number(declared) > limits.maxObjectBytes) {
|
|
385
|
+
await discard(response);
|
|
386
|
+
throw new BuildFailure('media-public-mismatch', 'the public media URL served a body larger than the validated image size budget', `${object.publicUrl}: content-length ${declared}`);
|
|
387
|
+
}
|
|
388
|
+
const body = await readBoundedBody(response, limits.maxObjectBytes, (bytes) => new BuildFailure('media-public-mismatch', 'the public media URL served a body larger than the validated image size budget', `${object.publicUrl}: read ${bytes} bytes`));
|
|
389
|
+
if (body.digest !== object.digest || body.bytes !== object.bytes.length) {
|
|
390
|
+
throw new BuildFailure('media-public-mismatch', 'the public media URL served bytes that do not match its content address', `${object.publicUrl}: expected ${object.digest}/${object.bytes.length} bytes, ` +
|
|
391
|
+
`found ${body.digest}/${body.bytes} bytes`);
|
|
392
|
+
}
|
|
393
|
+
const servedType = mediaType(response.headers.get('content-type'));
|
|
394
|
+
if (servedType !== object.mime) {
|
|
395
|
+
throw new BuildFailure('media-public-type', 'the public media URL served an unexpected content type', `${object.publicUrl}: expected ${object.mime}, found ${servedType ?? 'none'}`);
|
|
396
|
+
}
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Run `worker` over `items` with bounded concurrency and stop *scheduling* after
|
|
402
|
+
* the first failure. Started requests are awaited so no promise is left behind;
|
|
403
|
+
* the first failure is the one reported, which is the one a user can act on.
|
|
404
|
+
*/
|
|
405
|
+
async function forEachLimited(items, concurrency, worker) {
|
|
406
|
+
let next = 0;
|
|
407
|
+
let failed;
|
|
408
|
+
let failureSeen = false;
|
|
409
|
+
const runners = Array.from({ length: Math.max(1, Math.min(concurrency, items.length)) }, async () => {
|
|
410
|
+
while (!failureSeen && next < items.length) {
|
|
411
|
+
const item = items[next];
|
|
412
|
+
next += 1;
|
|
413
|
+
try {
|
|
414
|
+
await worker(item);
|
|
415
|
+
}
|
|
416
|
+
catch (error) {
|
|
417
|
+
if (!failureSeen) {
|
|
418
|
+
failed = error;
|
|
419
|
+
failureSeen = true;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
await Promise.all(runners);
|
|
425
|
+
if (failureSeen)
|
|
426
|
+
throw failed;
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Publish every declared asset and verify what the public domain serves.
|
|
430
|
+
*
|
|
431
|
+
* The signer is imported here, not at module scope: this module's planning
|
|
432
|
+
* helpers are testable without evaluating `aws4fetch`, and a process that never
|
|
433
|
+
* calls this function never loads it at all.
|
|
434
|
+
*/
|
|
435
|
+
export async function publishMedia(input, limits = MEDIA_PUBLISH_LIMITS) {
|
|
436
|
+
const objects = await planObjects(input, limits);
|
|
437
|
+
if (objects.length === 0) {
|
|
438
|
+
return { referenced: 0, uploaded: 0, reused: 0, verified: 0, bytesUploaded: 0 };
|
|
439
|
+
}
|
|
440
|
+
const { AwsClient } = await import('aws4fetch');
|
|
441
|
+
const signer = new AwsClient({
|
|
442
|
+
accessKeyId: input.credentials.accessKeyId,
|
|
443
|
+
secretAccessKey: input.credentials.secretAccessKey,
|
|
444
|
+
service: 's3',
|
|
445
|
+
region: 'auto',
|
|
446
|
+
});
|
|
447
|
+
let uploaded = 0;
|
|
448
|
+
let reused = 0;
|
|
449
|
+
let bytesUploaded = 0;
|
|
450
|
+
await forEachLimited(objects, limits.concurrency, async (object) => {
|
|
451
|
+
const outcome = await ensureObject(signer, input.config, object, limits);
|
|
452
|
+
if (outcome === 'uploaded') {
|
|
453
|
+
uploaded += 1;
|
|
454
|
+
bytesUploaded += object.bytes.length;
|
|
455
|
+
}
|
|
456
|
+
else {
|
|
457
|
+
reused += 1;
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
await forEachLimited(objects, limits.concurrency, (object) => verifyPublicObject(object, limits));
|
|
461
|
+
return {
|
|
462
|
+
referenced: objects.length,
|
|
463
|
+
uploaded,
|
|
464
|
+
reused,
|
|
465
|
+
verified: objects.length,
|
|
466
|
+
bytesUploaded,
|
|
467
|
+
};
|
|
468
|
+
}
|