transloadit 6.8.0 → 6.9.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/README.md +67 -3
- package/dist/Transloadit.d.ts +6 -0
- package/dist/Transloadit.d.ts.map +1 -1
- package/dist/Transloadit.js +9 -0
- package/dist/Transloadit.js.map +1 -1
- package/dist/cli/commands/storage.d.ts.map +1 -1
- package/dist/cli/commands/storage.js +110 -37
- package/dist/cli/commands/storage.js.map +1 -1
- package/dist/cli/storageReceipts.d.ts +80 -1
- package/dist/cli/storageReceipts.d.ts.map +1 -1
- package/dist/cli/storageReceipts.js +48 -12
- package/dist/cli/storageReceipts.js.map +1 -1
- package/dist/storageAsset.d.ts +44 -0
- package/dist/storageAsset.d.ts.map +1 -0
- package/dist/storageAsset.js +173 -0
- package/dist/storageAsset.js.map +1 -0
- package/dist/storageImage.d.ts +11 -35
- package/dist/storageImage.d.ts.map +1 -1
- package/dist/storageImage.js +21 -141
- package/dist/storageImage.js.map +1 -1
- package/package.json +2 -2
- package/src/Transloadit.ts +22 -0
- package/src/cli/commands/storage.ts +141 -67
- package/src/cli/storageReceipts.ts +50 -11
- package/src/storageAsset.ts +271 -0
- package/src/storageImage.ts +43 -201
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { StoredAssetReceipt } from '../../storageAsset.ts'
|
|
2
2
|
|
|
3
3
|
import { createHash } from 'node:crypto'
|
|
4
4
|
import { createReadStream } from 'node:fs'
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
assertStorageCatalogOrigin,
|
|
26
26
|
assertStorageWorkspace,
|
|
27
27
|
defaultStorageCatalog,
|
|
28
|
+
isStorageImageReceipt,
|
|
28
29
|
readStorageCatalog,
|
|
29
30
|
storageCatalogDelivery,
|
|
30
31
|
storageTypesPath,
|
|
@@ -40,7 +41,7 @@ abstract class StorageProjectCommand extends AuthenticatedCommand {
|
|
|
40
41
|
.origin
|
|
41
42
|
}
|
|
42
43
|
receipts = Option.String('--receipts', defaultStorageCatalog, {
|
|
43
|
-
description: '
|
|
44
|
+
description: 'Project catalog with workspace, public prefixes and asset receipts',
|
|
44
45
|
})
|
|
45
46
|
workspace = Option.String('--workspace', {
|
|
46
47
|
description:
|
|
@@ -203,12 +204,12 @@ export class StoragePublicationsCommand extends AuthenticatedCommand {
|
|
|
203
204
|
}
|
|
204
205
|
}
|
|
205
206
|
|
|
206
|
-
interface
|
|
207
|
+
interface CliStoredAssetReceipt extends StoredAssetReceipt {
|
|
207
208
|
source?: string
|
|
208
209
|
apiOrigin?: string
|
|
209
210
|
}
|
|
210
211
|
|
|
211
|
-
async function
|
|
212
|
+
async function hashAssetFile(
|
|
212
213
|
file: string,
|
|
213
214
|
signal: AbortSignal,
|
|
214
215
|
): Promise<{ md5hash: string; size: number }> {
|
|
@@ -219,7 +220,7 @@ async function hashImageFile(
|
|
|
219
220
|
hash.update(chunk)
|
|
220
221
|
size += chunk.length
|
|
221
222
|
}
|
|
222
|
-
if (size === 0) throw new Error('Cannot store an empty
|
|
223
|
+
if (size === 0) throw new Error('Cannot store an empty file')
|
|
223
224
|
signal.throwIfAborted()
|
|
224
225
|
return { md5hash: hash.digest('hex'), size }
|
|
225
226
|
}
|
|
@@ -230,7 +231,7 @@ export class StorageStoreCommand extends StorageProjectCommand {
|
|
|
230
231
|
|
|
231
232
|
static override usage = Command.Usage({
|
|
232
233
|
category: 'Storage',
|
|
233
|
-
description: 'Store original
|
|
234
|
+
description: 'Store original files and save verified, version-pinned asset receipts',
|
|
234
235
|
details: `
|
|
235
236
|
Uses the CLI's Assembly credentials (environment, .env or ~/.transloadit/credentials).
|
|
236
237
|
Storage writes must be enabled. Existing Storage paths conflict unless --overwrite is explicit.
|
|
@@ -239,7 +240,7 @@ export class StorageStoreCommand extends StorageProjectCommand {
|
|
|
239
240
|
--json includes uploaded for each receipt: false means this file reused a verified receipt.
|
|
240
241
|
--placeholder blur requests server metadata on new uploads. Successful extraction adds
|
|
241
242
|
metadata usage of 20% of the file bytes. Missing placeholders never retry a completed upload.
|
|
242
|
-
The project catalog binds workspace,
|
|
243
|
+
The project catalog binds workspace, public prefixes, images and other files. Each successful
|
|
243
244
|
upload is saved atomically before the next. Do not run two writers against the same catalog.
|
|
244
245
|
The catalog defaults to transloadit.images.json; --receipts selects another file.
|
|
245
246
|
`,
|
|
@@ -247,6 +248,10 @@ export class StorageStoreCommand extends StorageProjectCommand {
|
|
|
247
248
|
['Store a hero image', 'transloadit storage store ./hero.jpg website/hero.jpg'],
|
|
248
249
|
['Store a content-addressed image', 'transloadit storage store ./hero.jpg website/ --hashed'],
|
|
249
250
|
['Store a directory of originals', 'transloadit storage store ./images/*.jpg website/'],
|
|
251
|
+
[
|
|
252
|
+
'Store mixed media',
|
|
253
|
+
'transloadit storage store ./photo.jpg ./clip.mp4 ./guide.pdf website/media/',
|
|
254
|
+
],
|
|
250
255
|
],
|
|
251
256
|
})
|
|
252
257
|
|
|
@@ -267,7 +272,7 @@ export class StorageStoreCommand extends StorageProjectCommand {
|
|
|
267
272
|
})
|
|
268
273
|
protected async run(): Promise<number | undefined> {
|
|
269
274
|
const file = resolve(this.receipts)
|
|
270
|
-
let stored: { receipt?:
|
|
275
|
+
let stored: { receipt?: CliStoredAssetReceipt } = {}
|
|
271
276
|
let destination = this.destination
|
|
272
277
|
let workspace: string | undefined
|
|
273
278
|
let saved = false
|
|
@@ -282,9 +287,9 @@ export class StorageStoreCommand extends StorageProjectCommand {
|
|
|
282
287
|
'--hashed cannot be combined with --overwrite; changed bytes get a new name',
|
|
283
288
|
)
|
|
284
289
|
const apiOrigin = this.apiOrigin
|
|
285
|
-
const uploaded = new Map<string,
|
|
290
|
+
const uploaded = new Map<string, CliStoredAssetReceipt>()
|
|
286
291
|
if (this.files.length > 1 && !this.destination.endsWith('/'))
|
|
287
|
-
throw new Error('Multiple
|
|
292
|
+
throw new Error('Multiple files need a directory destination ending in /')
|
|
288
293
|
const inputs = this.files.map((input) => ({
|
|
289
294
|
file: input,
|
|
290
295
|
path: this.destination.endsWith('/')
|
|
@@ -293,14 +298,14 @@ export class StorageStoreCommand extends StorageProjectCommand {
|
|
|
293
298
|
}))
|
|
294
299
|
for (const input of inputs) {
|
|
295
300
|
if (file === resolve(input.file))
|
|
296
|
-
throw new Error('The receipts file cannot be the input
|
|
301
|
+
throw new Error('The receipts file cannot be the input file')
|
|
297
302
|
if (resolve(storageTypesPath(file)) === resolve(input.file))
|
|
298
|
-
throw new Error('The generated declarations file cannot be the input
|
|
303
|
+
throw new Error('The generated declarations file cannot be the input file')
|
|
299
304
|
validateStoragePath(input.path)
|
|
300
305
|
}
|
|
301
306
|
if (!this.hashed && new Set(inputs.map((input) => input.path)).size !== inputs.length)
|
|
302
307
|
throw new Error(
|
|
303
|
-
'Input
|
|
308
|
+
'Input file basenames collide in the destination directory; rename them first',
|
|
304
309
|
)
|
|
305
310
|
const publicPrefix = this.publicDelivery
|
|
306
311
|
? normalizeStoragePublicPrefix(
|
|
@@ -311,6 +316,7 @@ export class StorageStoreCommand extends StorageProjectCommand {
|
|
|
311
316
|
for (const input of inputs) {
|
|
312
317
|
let publicImage = false
|
|
313
318
|
let unchanged = false
|
|
319
|
+
let uploadFailure: Error | undefined
|
|
314
320
|
destination = input.path
|
|
315
321
|
stored = {}
|
|
316
322
|
saved = false
|
|
@@ -327,24 +333,36 @@ export class StorageStoreCommand extends StorageProjectCommand {
|
|
|
327
333
|
if (receipts?.workspace === workspace)
|
|
328
334
|
assertStorageCatalogOrigin(receipts, apiOrigin, this.receipts)
|
|
329
335
|
signal.throwIfAborted()
|
|
330
|
-
if (
|
|
331
|
-
|
|
336
|
+
if (
|
|
337
|
+
!this.overwrite &&
|
|
338
|
+
(this.hashed ||
|
|
339
|
+
uploaded.has(destination) ||
|
|
340
|
+
(receipts?.workspace === workspace &&
|
|
341
|
+
(Object.hasOwn(receipts.images, destination) ||
|
|
342
|
+
Object.hasOwn(receipts.files ?? {}, destination))))
|
|
343
|
+
) {
|
|
344
|
+
// Hash only to choose a hashed path or compare an existing receipt. storeAsset verifies its
|
|
332
345
|
// upload input; sharing a caller-supplied checksum would need an immutable-file contract.
|
|
333
|
-
const { md5hash, size } = await
|
|
334
|
-
|
|
335
|
-
|
|
346
|
+
const { md5hash, size } = await hashAssetFile(input.file, signal)
|
|
347
|
+
if (this.hashed) {
|
|
348
|
+
const extension = posix.extname(destination)
|
|
349
|
+
destination = `${destination.slice(0, destination.length - extension.length)}.${md5hash.slice(0, 8)}${extension}`
|
|
350
|
+
}
|
|
336
351
|
validateStoragePath(destination)
|
|
337
352
|
// Explicit workspace overrides leave the catalog alone; still deduplicate this batch.
|
|
338
353
|
const candidate =
|
|
339
354
|
uploaded.get(destination) ??
|
|
340
355
|
(receipts?.workspace === workspace && Object.hasOwn(receipts.images, destination)
|
|
341
356
|
? receipts.images[destination]
|
|
342
|
-
:
|
|
357
|
+
: receipts?.workspace === workspace &&
|
|
358
|
+
Object.hasOwn(receipts.files ?? {}, destination)
|
|
359
|
+
? receipts.files?.[destination]
|
|
360
|
+
: undefined)
|
|
343
361
|
if (candidate !== undefined) {
|
|
344
362
|
const previous = hashedReceiptSchema.safeParse(candidate)
|
|
345
363
|
if (!previous.success || previous.data.path !== destination)
|
|
346
364
|
throw new Error(
|
|
347
|
-
`Catalog receipt for ${JSON.stringify(destination)} is incomplete or does not match this file. Run transloadit storage receipts sync ${quoteCliArgument(destination)} --receipts ${quoteCliArgument(this.receipts)}${this.endpoint === undefined ? '' : ` --endpoint ${quoteCliArgument(this.endpoint)}`} to recover version identity from the same API environment,
|
|
365
|
+
`Catalog receipt for ${JSON.stringify(destination)} is incomplete or does not match this file. Run transloadit storage receipts sync ${quoteCliArgument(destination)} --receipts ${quoteCliArgument(this.receipts)}${this.endpoint === undefined ? '' : ` --endpoint ${quoteCliArgument(this.endpoint)}`} to recover version identity and any available checksum from the same API environment. If the checksum remains unavailable, ${this.hashed ? 'choose another destination basename' : 'choose a fresh name or use --overwrite only to deliberately replace it'}; sync cannot generate a missing checksum. Nothing uploaded.`,
|
|
348
366
|
)
|
|
349
367
|
if (previous.data.apiOrigin !== apiOrigin)
|
|
350
368
|
throw new Error(
|
|
@@ -352,39 +370,60 @@ export class StorageStoreCommand extends StorageProjectCommand {
|
|
|
352
370
|
)
|
|
353
371
|
if (previous.data.md5hash !== md5hash || previous.data.size !== size)
|
|
354
372
|
throw new Error(
|
|
355
|
-
|
|
373
|
+
this.hashed
|
|
374
|
+
? `Stored bytes for ${JSON.stringify(destination)} differ from this file. An older deployment may have transformed the upload, or the short hashes collided. Restoring the same receipt will not help; choose another destination basename. Nothing uploaded or replaced.`
|
|
375
|
+
: `Stored bytes for ${JSON.stringify(destination)} differ from this file. Choose a fresh name or use --overwrite only to deliberately replace it. Nothing uploaded or replaced.`,
|
|
356
376
|
)
|
|
357
377
|
stored.receipt = previous.data
|
|
358
378
|
unchanged = true
|
|
359
379
|
}
|
|
360
380
|
}
|
|
361
|
-
stored.receipt ??= await this.client
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
: `${expected.size.toLocaleString('en-US')} → ${receipt.size.toLocaleString('en-US')} bytes`
|
|
381
|
-
this.output.warn(
|
|
382
|
-
`Stored bytes differ from ${input.file} (${difference}); the workspace plan may have transformed the upload (for example, a Community-plan watermark on older deployments). The receipt describes the stored image.`,
|
|
381
|
+
stored.receipt ??= await this.client
|
|
382
|
+
.storeAsset(input.file, {
|
|
383
|
+
path: destination,
|
|
384
|
+
...(placeholder === undefined ? {} : { placeholder }),
|
|
385
|
+
signal,
|
|
386
|
+
onReceipt: (receipt, expected, assemblyId) => {
|
|
387
|
+
// Receipt identity is validated before byte fidelity; retain an actual write even
|
|
388
|
+
// when the server transformed the original and storeAsset subsequently rejects.
|
|
389
|
+
stored.receipt = receipt
|
|
390
|
+
const sizeMatches = receipt.size === expected.size
|
|
391
|
+
const md5Matches = receipt.md5hash === expected.md5hash
|
|
392
|
+
this.output.debug(
|
|
393
|
+
JSON.stringify({
|
|
394
|
+
assemblyId,
|
|
395
|
+
result: receipt,
|
|
396
|
+
input: expected,
|
|
397
|
+
sizeMatches,
|
|
398
|
+
md5Matches,
|
|
399
|
+
}),
|
|
383
400
|
)
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
401
|
+
if (!sizeMatches || !md5Matches) {
|
|
402
|
+
const difference = sizeMatches
|
|
403
|
+
? 'same size, different MD5'
|
|
404
|
+
: `${expected.size.toLocaleString('en-US')} → ${receipt.size.toLocaleString('en-US')} bytes`
|
|
405
|
+
this.output.warn(
|
|
406
|
+
`Stored bytes differ from ${input.file} (${difference}); the workspace plan may have transformed the upload (for example, a Community-plan watermark on older deployments). The receipt describes the stored file.`,
|
|
407
|
+
)
|
|
408
|
+
}
|
|
409
|
+
},
|
|
410
|
+
...(this.overwrite ? { overwrite: true } : {}),
|
|
411
|
+
})
|
|
412
|
+
.catch((error: unknown) => {
|
|
413
|
+
// The error owns recovery evidence; the optional observer need not have run.
|
|
414
|
+
const completed = z
|
|
415
|
+
.object({
|
|
416
|
+
receipt: storedAssetSchema.extend({
|
|
417
|
+
md5hash: storedAssetSchema.shape.md5hash.unwrap(),
|
|
418
|
+
}),
|
|
419
|
+
})
|
|
420
|
+
.safeParse(error instanceof InconsistentResponseError ? error.cause : undefined)
|
|
421
|
+
if (completed.success && completed.data.receipt.path === destination)
|
|
422
|
+
stored.receipt = completed.data.receipt
|
|
423
|
+
if (stored.receipt === undefined) throw error
|
|
424
|
+
uploadFailure = ensureError(error)
|
|
425
|
+
return stored.receipt
|
|
426
|
+
})
|
|
388
427
|
if (stored.receipt.workspace !== workspace)
|
|
389
428
|
throw new Error(
|
|
390
429
|
`The stored receipt belongs to Workspace ${stored.receipt.workspace}, not ${workspace}. The project catalog was not changed; inspect the Assembly before retrying.`,
|
|
@@ -395,7 +434,7 @@ export class StorageStoreCommand extends StorageProjectCommand {
|
|
|
395
434
|
...(this.hashed ? { source: basename(input.file) } : {}),
|
|
396
435
|
apiOrigin,
|
|
397
436
|
}
|
|
398
|
-
|
|
437
|
+
uploaded.set(destination, stored.receipt)
|
|
399
438
|
if (receipts !== undefined && receipts.workspace !== workspace) {
|
|
400
439
|
this.output.notice(
|
|
401
440
|
`Catalog ${this.receipts} was not changed; it belongs to ${receipts.workspace}. Use --receipts for a separate catalog.`,
|
|
@@ -405,6 +444,16 @@ export class StorageStoreCommand extends StorageProjectCommand {
|
|
|
405
444
|
publicImage = receipts?.public.some((prefix) => destination.startsWith(prefix)) ?? false
|
|
406
445
|
// Replays must not reorder fields or discard application metadata from a saved receipt.
|
|
407
446
|
if (unchanged) return receipts
|
|
447
|
+
let images = { ...receipts?.images }
|
|
448
|
+
let files = { ...receipts?.files }
|
|
449
|
+
// Updating an existing key preserves catalog order; remove only a former category.
|
|
450
|
+
if (isStorageImageReceipt(stored.receipt)) {
|
|
451
|
+
delete files[destination]
|
|
452
|
+
images = { ...images, [destination]: stored.receipt }
|
|
453
|
+
} else {
|
|
454
|
+
delete images[destination]
|
|
455
|
+
files = { ...files, [destination]: stored.receipt }
|
|
456
|
+
}
|
|
408
457
|
return {
|
|
409
458
|
...receipts,
|
|
410
459
|
workspace,
|
|
@@ -413,7 +462,8 @@ export class StorageStoreCommand extends StorageProjectCommand {
|
|
|
413
462
|
receipts?.delivery ??
|
|
414
463
|
storageCatalogDelivery(this.endpoint ?? this.cliConfig.endpoint),
|
|
415
464
|
public: receipts?.public ?? [],
|
|
416
|
-
images
|
|
465
|
+
images,
|
|
466
|
+
...(Object.keys(files).length > 0 || receipts?.files !== undefined ? { files } : {}),
|
|
417
467
|
}
|
|
418
468
|
},
|
|
419
469
|
() => {
|
|
@@ -421,6 +471,7 @@ export class StorageStoreCommand extends StorageProjectCommand {
|
|
|
421
471
|
},
|
|
422
472
|
)
|
|
423
473
|
if (stored.receipt === undefined) throw new Error('Storage did not return a receipt')
|
|
474
|
+
if (uploadFailure) throw uploadFailure
|
|
424
475
|
if (publicPrefix !== undefined && published === undefined) {
|
|
425
476
|
this.output.notice(
|
|
426
477
|
`Publishing ${publicPrefix} recursively: all current and future objects under this prefix will be public.`,
|
|
@@ -440,6 +491,13 @@ export class StorageStoreCommand extends StorageProjectCommand {
|
|
|
440
491
|
})
|
|
441
492
|
}
|
|
442
493
|
const receipt = stored.receipt
|
|
494
|
+
if (!isStorageImageReceipt(receipt)) {
|
|
495
|
+
this.output.print(
|
|
496
|
+
`${unchanged ? `Unchanged ${receipt.path}; no upload needed.\n` : ''}${saved ? `Saved ${receipt.path} in ${this.receipts}.` : `Stored ${receipt.path}; the different-workspace catalog was left unchanged.`}\nUse the asset/version receipt for original delivery or a Smart CDN preview.`,
|
|
497
|
+
{ ...receipt, uploaded: !unchanged },
|
|
498
|
+
)
|
|
499
|
+
continue
|
|
500
|
+
}
|
|
443
501
|
if (placeholder === 'blur' && receipt.thumbhash === undefined) {
|
|
444
502
|
this.output.notice(
|
|
445
503
|
unchanged
|
|
@@ -613,8 +671,6 @@ const uploadEvidenceSchema = storedAssetSchema.extend({
|
|
|
613
671
|
|
|
614
672
|
const hashedReceiptSchema = uploadEvidenceSchema.extend({
|
|
615
673
|
md5hash: storedAssetSchema.shape.md5hash.unwrap(),
|
|
616
|
-
width: storedAssetSchema.shape.width.unwrap(),
|
|
617
|
-
height: storedAssetSchema.shape.height.unwrap(),
|
|
618
674
|
})
|
|
619
675
|
|
|
620
676
|
/** Recovers pinned receipts from bounded native catalog pages without downloading originals. */
|
|
@@ -626,7 +682,9 @@ export class StorageReceiptsSyncCommand extends UnauthenticatedCommand {
|
|
|
626
682
|
details: `
|
|
627
683
|
Uses native catalog pages and dam:write scope to also recover declared public prefixes.
|
|
628
684
|
Adds or refreshes matched paths; never prunes unmatched entries. Local placeholder metadata
|
|
629
|
-
survives only for the same retained version.
|
|
685
|
+
survives only for the same retained version. Known image MIME types with dimensions enter
|
|
686
|
+
images; other media and unknown MIME types enter files in the same catalog.
|
|
687
|
+
Mixed prefixes require this CLI version or newer.
|
|
630
688
|
Any failure preserves the previous file. No Assembly, original download or remote write occurs.
|
|
631
689
|
`,
|
|
632
690
|
examples: [['Recover website images', 'transloadit storage receipts sync website/']],
|
|
@@ -663,23 +721,13 @@ export class StorageReceiptsSyncCommand extends UnauthenticatedCommand {
|
|
|
663
721
|
assertStorageCatalogOrigin(previous, endpoint, this.receipts)
|
|
664
722
|
const assets = await listStorageAssets(client, workspace, this.prefix, signal)
|
|
665
723
|
const entries = assets.map((asset) => {
|
|
666
|
-
try {
|
|
667
|
-
validateStoragePath(asset.path)
|
|
668
|
-
} catch (cause) {
|
|
669
|
-
throw new Error(
|
|
670
|
-
`Storage path ${JSON.stringify(asset.path)} is valid for listing but unsupported by the image renderer. Rename it before recovering an image catalog.`,
|
|
671
|
-
{ cause },
|
|
672
|
-
)
|
|
673
|
-
}
|
|
674
|
-
if (asset.width === undefined || asset.height === undefined) {
|
|
675
|
-
throw new Error(
|
|
676
|
-
`Storage image ${JSON.stringify(asset.path)} needs positive catalog width and height. Select an image-only prefix and backfill missing dimensions before retrying.`,
|
|
677
|
-
)
|
|
678
|
-
}
|
|
679
724
|
const evidence = uploadEvidenceSchema.safeParse(
|
|
680
725
|
previous?.workspace === workspace && Object.hasOwn(previous.images, asset.path)
|
|
681
726
|
? previous.images[asset.path]
|
|
682
|
-
:
|
|
727
|
+
: previous?.workspace === workspace &&
|
|
728
|
+
Object.hasOwn(previous.files ?? {}, asset.path)
|
|
729
|
+
? previous.files?.[asset.path]
|
|
730
|
+
: undefined,
|
|
683
731
|
)
|
|
684
732
|
const retained =
|
|
685
733
|
evidence.success &&
|
|
@@ -693,6 +741,18 @@ export class StorageReceiptsSyncCommand extends UnauthenticatedCommand {
|
|
|
693
741
|
apiOrigin: endpoint,
|
|
694
742
|
}
|
|
695
743
|
if (asset.has_alpha !== undefined) delete merged.hasAlpha
|
|
744
|
+
// Listing already validates native paths. Only Viewer images use path-based delivery;
|
|
745
|
+
// generic files use asset/version IDs and may contain renderer delimiters such as |.
|
|
746
|
+
if (isStorageImageReceipt(merged)) {
|
|
747
|
+
try {
|
|
748
|
+
validateStoragePath(asset.path)
|
|
749
|
+
} catch (cause) {
|
|
750
|
+
throw new Error(
|
|
751
|
+
`Storage path ${JSON.stringify(asset.path)} is valid for listing but unsupported by the image renderer. Rename it before recovering an image catalog.`,
|
|
752
|
+
{ cause },
|
|
753
|
+
)
|
|
754
|
+
}
|
|
755
|
+
}
|
|
696
756
|
return [asset.path, merged]
|
|
697
757
|
})
|
|
698
758
|
count = entries.length
|
|
@@ -731,6 +791,17 @@ export class StorageReceiptsSyncCommand extends UnauthenticatedCommand {
|
|
|
731
791
|
)
|
|
732
792
|
})
|
|
733
793
|
catalogUpdated = true
|
|
794
|
+
const images = new Map(Object.entries(previous?.images ?? {}))
|
|
795
|
+
const files = new Map(Object.entries(previous?.files ?? {}))
|
|
796
|
+
for (const [path, receipt] of Object.entries(synced)) {
|
|
797
|
+
if (isStorageImageReceipt(receipt)) {
|
|
798
|
+
files.delete(path)
|
|
799
|
+
images.set(path, receipt)
|
|
800
|
+
} else {
|
|
801
|
+
images.delete(path)
|
|
802
|
+
files.set(path, receipt)
|
|
803
|
+
}
|
|
804
|
+
}
|
|
734
805
|
if (policy.public_prefixes.length === 0)
|
|
735
806
|
this.output.notice(
|
|
736
807
|
'No public prefixes are declared on the server. For public delivery, deliberately publish a directory with storage publish; otherwise configure authorize for private images. Sync never publishes files.',
|
|
@@ -743,7 +814,10 @@ export class StorageReceiptsSyncCommand extends UnauthenticatedCommand {
|
|
|
743
814
|
previous?.delivery ??
|
|
744
815
|
storageCatalogDelivery(this.endpoint ?? config.credentialsEndpoint),
|
|
745
816
|
public: policy.public_prefixes.map(({ prefix }) => prefix),
|
|
746
|
-
images:
|
|
817
|
+
images: Object.fromEntries(images),
|
|
818
|
+
...(files.size > 0 || previous?.files !== undefined
|
|
819
|
+
? { files: Object.fromEntries(files) }
|
|
820
|
+
: {}),
|
|
747
821
|
}
|
|
748
822
|
})
|
|
749
823
|
this.output.print(
|
|
@@ -15,11 +15,12 @@ const receiptsSchema = z.custom<Record<string, unknown>>(
|
|
|
15
15
|
(value: unknown) => typeof value === 'object' && value !== null && !Array.isArray(value),
|
|
16
16
|
)
|
|
17
17
|
const deliveryParameterSchema = z.union([z.string(), z.number(), z.boolean()])
|
|
18
|
+
const workspaceSchema = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/)
|
|
18
19
|
|
|
19
20
|
/** Project identity and rendering metadata (including optional preview pixels), without credentials. */
|
|
20
21
|
export const storageCatalogSchema = z
|
|
21
22
|
.object({
|
|
22
|
-
workspace:
|
|
23
|
+
workspace: workspaceSchema,
|
|
23
24
|
apiOrigin: z.string().url().optional(),
|
|
24
25
|
public: z.array(
|
|
25
26
|
z.string().refine((prefix) => {
|
|
@@ -31,6 +32,7 @@ export const storageCatalogSchema = z
|
|
|
31
32
|
}, 'Expected a normalized public directory'),
|
|
32
33
|
),
|
|
33
34
|
images: receiptsSchema,
|
|
35
|
+
files: receiptsSchema.optional(),
|
|
34
36
|
delivery: z
|
|
35
37
|
.object({
|
|
36
38
|
baseUrl: z.string().url().optional(),
|
|
@@ -42,6 +44,29 @@ export const storageCatalogSchema = z
|
|
|
42
44
|
.optional(),
|
|
43
45
|
})
|
|
44
46
|
.passthrough()
|
|
47
|
+
.superRefine((catalog, context) => {
|
|
48
|
+
for (const [path, value] of Object.entries(catalog.files ?? {})) {
|
|
49
|
+
const receipt = storedAssetSchema.safeParse(value)
|
|
50
|
+
if (
|
|
51
|
+
!receipt.success ||
|
|
52
|
+
receipt.data.path !== path ||
|
|
53
|
+
receipt.data.workspace !== catalog.workspace
|
|
54
|
+
) {
|
|
55
|
+
context.addIssue({
|
|
56
|
+
code: 'custom',
|
|
57
|
+
path: ['files', path],
|
|
58
|
+
message: 'Expected a matching Storage asset receipt',
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
if (Object.hasOwn(catalog.images, path)) {
|
|
62
|
+
context.addIssue({
|
|
63
|
+
code: 'custom',
|
|
64
|
+
path: ['files', path],
|
|
65
|
+
message: 'A path cannot occur in both images and files',
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
})
|
|
45
70
|
export type StorageProjectCatalog = z.infer<typeof storageCatalogSchema>
|
|
46
71
|
|
|
47
72
|
/** Default project catalog; --receipts can select a separate project explicitly. */
|
|
@@ -60,6 +85,13 @@ const imageReceiptSchema = storedAssetSchema.extend({
|
|
|
60
85
|
height: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
|
|
61
86
|
})
|
|
62
87
|
|
|
88
|
+
/** Only image receipts with intrinsic dimensions belong in the Viewer map. */
|
|
89
|
+
export function isStorageImageReceipt(value: unknown): value is z.infer<typeof imageReceiptSchema> {
|
|
90
|
+
const result = imageReceiptSchema.safeParse(value)
|
|
91
|
+
// Dimensions also describe videos; an unknown MIME type cannot establish a Viewer image.
|
|
92
|
+
return result.success && result.data.mime?.startsWith('image/') === true
|
|
93
|
+
}
|
|
94
|
+
|
|
63
95
|
const legacyTypesHeader =
|
|
64
96
|
'// Generated by transloadit storage. Commit this file with the image catalog; do not edit.'
|
|
65
97
|
const typesHeader =
|
|
@@ -88,14 +120,20 @@ function catalogTypes(catalog: StorageProjectCatalog, file?: string): string {
|
|
|
88
120
|
return [
|
|
89
121
|
typesHeader,
|
|
90
122
|
...(file === undefined ? [] : [`// Catalog: ${JSON.stringify(basename(file))}`]),
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
123
|
+
// Headerless legacy ownership must still match the old empty interface exactly. Newly
|
|
124
|
+
// generated file-only catalogs have an owner line and need no Viewer dependency.
|
|
125
|
+
...(properties.length === 0 && file !== undefined
|
|
126
|
+
? ['export {}', '']
|
|
127
|
+
: [
|
|
128
|
+
"import '@transloadit/viewer/next'",
|
|
129
|
+
'',
|
|
130
|
+
"declare module '@transloadit/viewer/next' {",
|
|
131
|
+
' interface RegisteredStorageImages {',
|
|
132
|
+
...properties,
|
|
133
|
+
' }',
|
|
134
|
+
'}',
|
|
135
|
+
'',
|
|
136
|
+
]),
|
|
99
137
|
].join('\n')
|
|
100
138
|
}
|
|
101
139
|
|
|
@@ -128,7 +166,7 @@ export async function readStorageCatalog(file: string): Promise<StorageProjectCa
|
|
|
128
166
|
|
|
129
167
|
/** A workspace override selects another workspace, never another key or implicit project rebinding. */
|
|
130
168
|
export function assertStorageWorkspace(actual: string, project?: string, requested?: string): void {
|
|
131
|
-
if (!
|
|
169
|
+
if (!workspaceSchema.safeParse(actual).success)
|
|
132
170
|
throw new Error(
|
|
133
171
|
`Invalid Storage Workspace ${JSON.stringify(actual)}: expected a URL-safe slug beginning with a letter or digit.`,
|
|
134
172
|
)
|
|
@@ -158,12 +196,13 @@ export function assertStorageCatalogOrigin(
|
|
|
158
196
|
catalog !== undefined &&
|
|
159
197
|
catalog.apiOrigin === undefined &&
|
|
160
198
|
Object.keys(catalog.images).length === 0 &&
|
|
199
|
+
Object.keys(catalog.files ?? {}).length === 0 &&
|
|
161
200
|
(catalog.public.length > 0 || catalog.delivery !== undefined)
|
|
162
201
|
)
|
|
163
202
|
throw new Error(
|
|
164
203
|
`Catalog ${JSON.stringify(file)} has no verified API origin. Run storage receipts sync into a new --receipts file using the original API --endpoint, then review and replace this catalog. The existing file was preserved.`,
|
|
165
204
|
)
|
|
166
|
-
for (const [path, value] of Object.entries(catalog?.images
|
|
205
|
+
for (const [path, value] of Object.entries({ ...catalog?.images, ...catalog?.files })) {
|
|
167
206
|
const previous = receiptOriginSchema.safeParse(value)
|
|
168
207
|
if (
|
|
169
208
|
!previous.success ||
|