canopycms 0.1.0 → 0.2.0-int.121
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/CHANGELOG.md +202 -43
- package/dist/api/assets.d.ts +27 -7
- package/dist/api/assets.js +118 -47
- package/dist/api/client.d.ts +5 -3
- package/dist/api/client.js +7 -3
- package/dist/api/index.d.ts +1 -1
- package/dist/assets/asset-prefixes.d.ts +4 -1
- package/dist/assets/asset-prefixes.js +3 -1
- package/dist/assets/editor-thumbnail-widths.d.ts +11 -0
- package/dist/assets/editor-thumbnail-widths.js +7 -0
- package/dist/assets/finalize.d.ts +7 -6
- package/dist/assets/finalize.js +29 -10
- package/dist/assets/keys.d.ts +4 -1
- package/dist/assets/keys.js +4 -1
- package/dist/assets/materialize.d.ts +17 -2
- package/dist/assets/materialize.js +57 -4
- package/dist/assets/store-local.d.ts +18 -9
- package/dist/assets/store-local.js +58 -26
- package/dist/assets/store-s3.d.ts +25 -6
- package/dist/assets/store-s3.js +81 -19
- package/dist/assets/types.d.ts +33 -4
- package/dist/cli/cli.js +151 -56
- package/dist/config/types.d.ts +3 -2
- package/dist/editor/BranchManager.js +2 -1
- package/dist/editor/CommentsPanel.js +67 -29
- package/dist/editor/Editor.d.ts +3 -0
- package/dist/editor/Editor.js +93 -39
- package/dist/editor/EntryNavigator.d.ts +17 -5
- package/dist/editor/EntryNavigator.js +66 -31
- package/dist/editor/FormHeader.d.ts +2 -1
- package/dist/editor/FormHeader.js +9 -5
- package/dist/editor/FormRenderer.d.ts +7 -2
- package/dist/editor/FormRenderer.js +51 -16
- package/dist/editor/PreviewFrame.d.ts +13 -2
- package/dist/editor/PreviewFrame.js +62 -5
- package/dist/editor/PreviewPane.d.ts +17 -1
- package/dist/editor/PreviewPane.js +55 -8
- package/dist/editor/PreviewToolbarTools.d.ts +8 -0
- package/dist/editor/PreviewToolbarTools.js +9 -1
- package/dist/editor/branch-status.d.ts +6 -0
- package/dist/editor/branch-status.js +10 -0
- package/dist/editor/comments/BranchComments.d.ts +2 -4
- package/dist/editor/comments/BranchComments.js +7 -11
- package/dist/editor/comments/ThreadCarousel.d.ts +2 -8
- package/dist/editor/comments/ThreadCarousel.js +4 -49
- package/dist/editor/comments/ThreadReplyControls.js +5 -2
- package/dist/editor/components/BranchLockBanner.d.ts +8 -7
- package/dist/editor/components/BranchLockBanner.js +30 -3
- package/dist/editor/components/BranchPill.d.ts +5 -4
- package/dist/editor/components/BranchPill.js +6 -8
- package/dist/editor/components/BranchRowLock.d.ts +12 -0
- package/dist/editor/components/BranchRowLock.js +19 -0
- package/dist/editor/components/BranchesDrawer.js +3 -3
- package/dist/editor/components/EditorHeader.d.ts +2 -6
- package/dist/editor/components/EditorHeader.js +14 -28
- package/dist/editor/components/EditorRail.d.ts +3 -4
- package/dist/editor/components/EditorRail.js +3 -4
- package/dist/editor/components/EntryBreadcrumb.d.ts +9 -10
- package/dist/editor/components/EntryBreadcrumb.js +8 -11
- package/dist/editor/components/PreviewLinkToast.d.ts +5 -0
- package/dist/editor/components/PreviewLinkToast.js +33 -0
- package/dist/editor/components/QuickOpenPalette.d.ts +7 -1
- package/dist/editor/components/QuickOpenPalette.js +6 -1
- package/dist/editor/components/RenameEntryModal.d.ts +6 -1
- package/dist/editor/components/RenameEntryModal.js +20 -5
- package/dist/editor/components/SaveSplitButton.d.ts +3 -2
- package/dist/editor/components/SaveSplitButton.js +25 -12
- package/dist/editor/components/StagedChangesDrawer.d.ts +0 -1
- package/dist/editor/components/StagedChangesDrawer.js +5 -4
- package/dist/editor/components/UndoToastMessage.d.ts +6 -5
- package/dist/editor/components/UndoToastMessage.js +6 -4
- package/dist/editor/components/UnsavedMark.d.ts +9 -0
- package/dist/editor/components/UnsavedMark.js +20 -0
- package/dist/editor/components/UserBadge.js +13 -11
- package/dist/editor/copy.d.ts +4 -0
- package/dist/editor/copy.js +4 -0
- package/dist/editor/editor-utils.d.ts +49 -9
- package/dist/editor/editor-utils.js +117 -16
- package/dist/editor/entry-search.d.ts +4 -2
- package/dist/editor/entry-search.js +2 -3
- package/dist/editor/fields/FieldLabel.d.ts +1 -1
- package/dist/editor/fields/FieldLabel.js +1 -1
- package/dist/editor/fields/ImageField.d.ts +1 -1
- package/dist/editor/fields/ImageField.js +36 -27
- package/dist/editor/fields/ObjectField.d.ts +13 -2
- package/dist/editor/fields/ObjectField.js +38 -9
- package/dist/editor/fields/entry-link/EntryLinkContext.d.ts +2 -0
- package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -1
- package/dist/editor/group-manager/index.js +1 -1
- package/dist/editor/hooks/useBranchManager.d.ts +2 -0
- package/dist/editor/hooks/useBranchManager.js +1 -0
- package/dist/editor/hooks/useEditorLayout.d.ts +4 -0
- package/dist/editor/hooks/useEditorLayout.js +5 -0
- package/dist/editor/hooks/useEntryManager.d.ts +2 -0
- package/dist/editor/hooks/useEntryManager.js +10 -8
- package/dist/editor/media/AssetCard.d.ts +6 -3
- package/dist/editor/media/AssetCard.js +24 -9
- package/dist/editor/media/AssetListRow.d.ts +9 -4
- package/dist/editor/media/AssetListRow.js +18 -9
- package/dist/editor/media/CropStep.d.ts +8 -10
- package/dist/editor/media/CropStep.js +39 -27
- package/dist/editor/media/MediaLibrary.js +5 -4
- package/dist/editor/media/MediaLibraryBody.d.ts +1 -1
- package/dist/editor/media/MediaLibraryBody.js +65 -37
- package/dist/editor/media/crop-availability.d.ts +6 -0
- package/dist/editor/media/crop-availability.js +19 -0
- package/dist/editor/media/crop-math.d.ts +19 -13
- package/dist/editor/media/crop-math.js +50 -11
- package/dist/editor/media/useAssetLibrary.d.ts +6 -3
- package/dist/editor/media/useAssetLibrary.js +17 -10
- package/dist/editor/pane-header.d.ts +6 -0
- package/dist/editor/pane-header.js +6 -0
- package/dist/editor/permission-manager/index.js +1 -1
- package/dist/editor/preview-bridge.d.ts +23 -0
- package/dist/editor/preview-bridge.js +105 -4
- package/dist/editor/preview-path.d.ts +16 -4
- package/dist/editor/preview-path.js +80 -19
- package/dist/editor/shell/ActivityRail.d.ts +1 -3
- package/dist/editor/shell/ActivityRail.js +3 -3
- package/dist/editor/shell/EditorShell.d.ts +15 -0
- package/dist/editor/shell/EditorShell.js +17 -1
- package/dist/editor/shell/OverlayHeader.d.ts +19 -0
- package/dist/editor/shell/OverlayHeader.js +15 -0
- package/dist/editor/shell/PaneLayout.d.ts +6 -1
- package/dist/editor/shell/PaneLayout.js +68 -13
- package/dist/editor/shell/SidePanelHost.d.ts +8 -6
- package/dist/editor/shell/SidePanelHost.js +70 -33
- package/dist/editor/shell/TopBar.js +2 -1
- package/dist/editor/theme.d.ts +1 -0
- package/dist/editor/theme.js +19 -1
- package/dist/editor/undo-removal.d.ts +19 -10
- package/dist/editor/undo-removal.js +44 -16
- package/dist/server.d.ts +2 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/dist/api/assets.js
CHANGED
|
@@ -42,12 +42,14 @@ const finalizeAssetBodySchema = z.object({
|
|
|
42
42
|
const listAssetsParamsSchema = z.object({
|
|
43
43
|
cursor: z.string().optional(),
|
|
44
44
|
limit: z.coerce.number().int().positive().max(100).optional(),
|
|
45
|
+
/** Admin only: also list files removed from the library, each marked `hidden: true`. */
|
|
46
|
+
includeHidden: z.enum(['true', 'false']).optional(),
|
|
45
47
|
});
|
|
46
48
|
/** hash32 is a sha-256 truncated to 32 hex chars (keys.ts's `hashBytes`) - never any other shape. */
|
|
47
49
|
const hash32Schema = z
|
|
48
50
|
.string()
|
|
49
51
|
.regex(/^[a-f0-9]{32}$/, 'key must be a 32-character lowercase hex string');
|
|
50
|
-
const
|
|
52
|
+
const assetKeyBodySchema = z.object({ key: hash32Schema });
|
|
51
53
|
/**
|
|
52
54
|
* Presign a direct (or proxied) upload target. Any authenticated user - there
|
|
53
55
|
* is no finer "editor" role, and upload needs to work for every non-admin
|
|
@@ -75,7 +77,8 @@ const presignAssetHandler = async (ctx, _req, body) => {
|
|
|
75
77
|
/**
|
|
76
78
|
* Finalize a staged upload: read the staged bytes, run the finalize pipeline
|
|
77
79
|
* (sniff/hash/dims/sanitize), write originals + meta, and return the result.
|
|
78
|
-
* Any authenticated user (same rationale as presign).
|
|
80
|
+
* Any authenticated user (same rationale as presign). Uploading a file that was
|
|
81
|
+
* removed from the library puts it back; `finalizeAsset` says why.
|
|
79
82
|
*/
|
|
80
83
|
const finalizeAssetHandler = async (ctx, req, body) => {
|
|
81
84
|
if (!ctx.assetStore)
|
|
@@ -168,49 +171,82 @@ const uploadProxiedHandler = async (ctx, req) => {
|
|
|
168
171
|
/**
|
|
169
172
|
* List assets - any authenticated user can list assets (key enumeration is
|
|
170
173
|
* accepted: unlisted != private, see .claude/future-tasks/resolved/assets-media-system.md).
|
|
174
|
+
* Files removed from the library are left out; only an admin may ask for them too.
|
|
171
175
|
*/
|
|
172
|
-
const listAssetsHandler = async (ctx,
|
|
176
|
+
const listAssetsHandler = async (ctx, req, params) => {
|
|
173
177
|
if (!ctx.assetStore)
|
|
174
178
|
return { ok: false, status: 501, error: 'Asset store not configured' };
|
|
179
|
+
const includeHidden = params.includeHidden === 'true';
|
|
180
|
+
if (includeHidden && !isAdmin(req.user.groups)) {
|
|
181
|
+
return { ok: false, status: 403, error: 'Only admins can list removed files' };
|
|
182
|
+
}
|
|
175
183
|
const { items, nextCursor } = await ctx.assetStore.listMeta({
|
|
176
184
|
cursor: params.cursor,
|
|
177
185
|
limit: params.limit,
|
|
186
|
+
includeHidden,
|
|
178
187
|
});
|
|
179
188
|
return { ok: true, status: 200, data: { assets: items.map(toAssetRecord), nextCursor } };
|
|
180
189
|
};
|
|
181
190
|
/**
|
|
182
|
-
*
|
|
183
|
-
* recorded `uploadedBy` is them. `
|
|
184
|
-
* Deletes the meta sidecar only - blobs are immortal until a future GC worker task (see
|
|
185
|
-
* .claude/future-tasks/resolved/assets-media-system.md).
|
|
191
|
+
* Who may remove a file from the library or restore it: an admin may act on any file; anyone else
|
|
192
|
+
* only on a file whose recorded `uploadedBy` is them. `null` when the caller may act.
|
|
186
193
|
*
|
|
187
|
-
* The
|
|
188
|
-
*
|
|
194
|
+
* The check lives here, not in a declarative guard, because it needs the asset's meta, which
|
|
195
|
+
* guards can't read. Two fail-closed choices:
|
|
189
196
|
* - Meta with no `uploadedBy` (the field is optional) is admin-only — defaulting to "anyone may
|
|
190
|
-
*
|
|
197
|
+
* act" would open every legacy asset to everyone.
|
|
191
198
|
* - A missing asset returns the same 403 as an unowned one for non-admins, so the endpoint isn't
|
|
192
|
-
* an existence oracle over a content-addressed keyspace.
|
|
199
|
+
* an existence oracle over a content-addressed keyspace. An admin, who can list every file,
|
|
200
|
+
* gets a 404, so no marker is ever written for a file that does not exist.
|
|
193
201
|
*
|
|
194
202
|
* `uploadedBy` records the FIRST uploader only: finalizeAsset dedups on content hash, so a second
|
|
195
|
-
* person uploading an identical file gains no
|
|
196
|
-
*
|
|
197
|
-
* safeinsights/canopycms#572).
|
|
203
|
+
* person uploading an identical file gains no rights over it — benign (a 403 where they expected
|
|
204
|
+
* success, never the reverse; see safeinsights/canopycms#572).
|
|
198
205
|
*
|
|
199
206
|
* Coupled to the blob-GC follow-up in safeinsights/canopycms#575: this permission is safe because
|
|
200
|
-
*
|
|
201
|
-
*
|
|
207
|
+
* removal hides a file and destroys nothing, so nothing any branch or build references breaks. If
|
|
208
|
+
* GC ever deletes blobs, it needs a reference check of its own.
|
|
209
|
+
*/
|
|
210
|
+
async function visibilityRefusal(store, req, key, verb) {
|
|
211
|
+
const meta = await store.getMeta(key);
|
|
212
|
+
if (isAdmin(req.user.groups)) {
|
|
213
|
+
return meta ? null : { ok: false, status: 404, error: 'File not found' };
|
|
214
|
+
}
|
|
215
|
+
if (!meta?.uploadedBy || meta.uploadedBy !== req.user.userId) {
|
|
216
|
+
return { ok: false, status: 403, error: `You can only ${verb} files you uploaded` };
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Remove a file from the library: write its hidden marker and leave its meta, originals and
|
|
222
|
+
* public objects alone, so every URL and every transform, stored or not yet computed, keeps
|
|
223
|
+
* working for content on any branch and in any build. `visibilityRefusal` says who may.
|
|
224
|
+
*
|
|
225
|
+
* The marker is create-only, so a second remove finds it and changes nothing. Remove, restore
|
|
226
|
+
* and a re-upload's un-hide touch only the marker, and nothing but the library listing reads it:
|
|
227
|
+
* the worst interleaving of two of them lists or omits one file until the next action.
|
|
202
228
|
*/
|
|
203
|
-
const
|
|
229
|
+
const hideAssetHandler = async (ctx, req, body) => {
|
|
204
230
|
if (!ctx.assetStore)
|
|
205
231
|
return { ok: false, status: 501, error: 'Asset store not configured' };
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
return { ok: true, status: 200, data: {
|
|
232
|
+
const refusal = await visibilityRefusal(ctx.assetStore, req, body.key, 'remove');
|
|
233
|
+
if (refusal)
|
|
234
|
+
return refusal;
|
|
235
|
+
await ctx.assetStore.hideAsset(body.key, {
|
|
236
|
+
hiddenAt: new Date().toISOString(),
|
|
237
|
+
hiddenBy: req.user.userId,
|
|
238
|
+
});
|
|
239
|
+
return { ok: true, status: 200, data: { hidden: true } };
|
|
240
|
+
};
|
|
241
|
+
/** Put a removed file back in the library by deleting its hidden marker; see `hideAssetHandler`. */
|
|
242
|
+
const restoreAssetHandler = async (ctx, req, body) => {
|
|
243
|
+
if (!ctx.assetStore)
|
|
244
|
+
return { ok: false, status: 501, error: 'Asset store not configured' };
|
|
245
|
+
const refusal = await visibilityRefusal(ctx.assetStore, req, body.key, 'restore');
|
|
246
|
+
if (refusal)
|
|
247
|
+
return refusal;
|
|
248
|
+
await ctx.assetStore.unhideAsset(body.key);
|
|
249
|
+
return { ok: true, status: 200, data: { hidden: false } };
|
|
214
250
|
};
|
|
215
251
|
/**
|
|
216
252
|
* The largest body this route returns inline. The CMS Lambda's Function URL buffers its response
|
|
@@ -219,15 +255,19 @@ const deleteAssetHandler = async (ctx, req, params) => {
|
|
|
219
255
|
*/
|
|
220
256
|
const INLINE_BODY_LIMIT_BYTES = 4 * 1024 * 1024;
|
|
221
257
|
/**
|
|
222
|
-
* A 302 to a presigned store URL
|
|
223
|
-
*
|
|
258
|
+
* A 302 to a presigned store URL, cacheable by this browser alone for as long as the store says
|
|
259
|
+
* the URL stays reusable: the redirect then outlives a sign-out by at most that long, which is
|
|
260
|
+
* accepted because the target is a public-prefix object under an unguessable key.
|
|
224
261
|
*/
|
|
225
|
-
function presignedRedirect(url) {
|
|
262
|
+
function presignedRedirect({ url, maxAgeSeconds }) {
|
|
226
263
|
return {
|
|
227
264
|
kind: 'binary',
|
|
228
265
|
status: 302,
|
|
229
266
|
body: new Uint8Array(),
|
|
230
|
-
headers: {
|
|
267
|
+
headers: {
|
|
268
|
+
location: url,
|
|
269
|
+
cacheControl: maxAgeSeconds > 0 ? `private, max-age=${maxAgeSeconds}` : 'no-store',
|
|
270
|
+
},
|
|
231
271
|
};
|
|
232
272
|
}
|
|
233
273
|
/**
|
|
@@ -243,9 +283,9 @@ async function serveLazyTransform(assetStore, parsed, canonicalKey) {
|
|
|
243
283
|
return { ok: false, status: transformed.status, error };
|
|
244
284
|
}
|
|
245
285
|
if (transformed.data.byteLength > INLINE_BODY_LIMIT_BYTES && assetStore.presignPublicObjectRead) {
|
|
246
|
-
const
|
|
247
|
-
if (
|
|
248
|
-
return presignedRedirect(
|
|
286
|
+
const presigned = await assetStore.presignPublicObjectRead(canonicalKey);
|
|
287
|
+
if (presigned)
|
|
288
|
+
return presignedRedirect(presigned);
|
|
249
289
|
}
|
|
250
290
|
return {
|
|
251
291
|
kind: 'binary',
|
|
@@ -254,11 +294,17 @@ async function serveLazyTransform(assetStore, parsed, canonicalKey) {
|
|
|
254
294
|
headers: { contentType: transformed.contentType, cacheControl: TRANSFORM_CACHE_CONTROL },
|
|
255
295
|
};
|
|
256
296
|
}
|
|
297
|
+
/**
|
|
298
|
+
* For a streamed object stored without a Cache-Control of its own. Every key under the public
|
|
299
|
+
* prefix is content-addressed, so its bytes never change; `private` because this route is
|
|
300
|
+
* authenticated.
|
|
301
|
+
*/
|
|
302
|
+
const STREAMED_OBJECT_CACHE_CONTROL = 'private, max-age=31536000, immutable';
|
|
257
303
|
/** A redirect to, or the bytes of, the public object stored at `key`; `null` when there is none. */
|
|
258
304
|
async function serveStoredObject(assetStore, key) {
|
|
259
305
|
if (assetStore.presignPublicObjectRead) {
|
|
260
|
-
const
|
|
261
|
-
return
|
|
306
|
+
const presigned = await assetStore.presignPublicObjectRead(key);
|
|
307
|
+
return presigned ? presignedRedirect(presigned) : null;
|
|
262
308
|
}
|
|
263
309
|
const object = await assetStore.readPublicObject(key);
|
|
264
310
|
if (!object)
|
|
@@ -270,7 +316,7 @@ async function serveStoredObject(assetStore, key) {
|
|
|
270
316
|
headers: {
|
|
271
317
|
contentType: object.contentType,
|
|
272
318
|
contentDisposition: object.contentDisposition,
|
|
273
|
-
cacheControl: object.cacheControl,
|
|
319
|
+
cacheControl: object.cacheControl ?? STREAMED_OBJECT_CACHE_CONTROL,
|
|
274
320
|
},
|
|
275
321
|
};
|
|
276
322
|
}
|
|
@@ -382,20 +428,43 @@ const listAssets = defineEndpoint({
|
|
|
382
428
|
defaultMockData: { assets: [] },
|
|
383
429
|
handler: listAssetsHandler,
|
|
384
430
|
});
|
|
385
|
-
|
|
386
|
-
* DELETE /assets?key={hash32}
|
|
387
|
-
*/
|
|
388
|
-
const deleteAsset = defineEndpoint({
|
|
431
|
+
const hideAsset = defineEndpoint({
|
|
389
432
|
namespace: 'assets',
|
|
390
|
-
name: '
|
|
391
|
-
method: '
|
|
392
|
-
path: '/assets',
|
|
393
|
-
|
|
394
|
-
|
|
433
|
+
name: 'hide',
|
|
434
|
+
method: 'POST',
|
|
435
|
+
path: '/assets/hide',
|
|
436
|
+
body: assetKeyBodySchema,
|
|
437
|
+
bodyType: 'AssetKeyBody',
|
|
438
|
+
responseType: 'AssetVisibilityResponse',
|
|
395
439
|
response: {},
|
|
396
|
-
defaultMockData: {
|
|
397
|
-
handler:
|
|
440
|
+
defaultMockData: { hidden: true },
|
|
441
|
+
handler: hideAssetHandler,
|
|
398
442
|
});
|
|
443
|
+
const restoreAsset = defineEndpoint({
|
|
444
|
+
namespace: 'assets',
|
|
445
|
+
name: 'restore',
|
|
446
|
+
method: 'POST',
|
|
447
|
+
path: '/assets/restore',
|
|
448
|
+
body: assetKeyBodySchema,
|
|
449
|
+
bodyType: 'AssetKeyBody',
|
|
450
|
+
responseType: 'AssetVisibilityResponse',
|
|
451
|
+
response: {},
|
|
452
|
+
defaultMockData: { hidden: false },
|
|
453
|
+
handler: restoreAssetHandler,
|
|
454
|
+
});
|
|
455
|
+
/**
|
|
456
|
+
* DELETE /assets answers 410 instead of falling through to `DELETE /:branch` as a branch named
|
|
457
|
+
* "assets". Hand-built like `assetRawRoute`, so the client generator emits no method for it.
|
|
458
|
+
*/
|
|
459
|
+
const deleteAssetGone = {
|
|
460
|
+
method: 'DELETE',
|
|
461
|
+
pattern: ['assets'],
|
|
462
|
+
handler: async () => ({
|
|
463
|
+
ok: false,
|
|
464
|
+
status: 410,
|
|
465
|
+
error: 'DELETE /assets is gone: remove a file from the library with POST /assets/hide',
|
|
466
|
+
}),
|
|
467
|
+
};
|
|
399
468
|
/**
|
|
400
469
|
* Exported routes for router registration and client codegen.
|
|
401
470
|
*/
|
|
@@ -404,7 +473,9 @@ export const ASSET_ROUTES = {
|
|
|
404
473
|
finalize: finalizeAsset,
|
|
405
474
|
uploadProxied,
|
|
406
475
|
list: listAssets,
|
|
407
|
-
|
|
476
|
+
hide: hideAsset,
|
|
477
|
+
restore: restoreAsset,
|
|
478
|
+
deleteGone: deleteAssetGone,
|
|
408
479
|
};
|
|
409
480
|
/**
|
|
410
481
|
* GET /assets/raw/{key...} - see `rawAssetHandler` above for why this is
|
package/dist/api/client.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ import type { ContentReadResponse, ContentWriteResponse, ReferenceValidationResp
|
|
|
15
15
|
import type { ReferenceOptionsResponse } from './reference-options.js';
|
|
16
16
|
import type { ResolveReferencesBody, ResolveReferencesResponse } from './resolve-references.js';
|
|
17
17
|
import type { DeleteEntryResponse, EntriesResponse } from './entries.js';
|
|
18
|
-
import type {
|
|
18
|
+
import type { AssetKeyBody, AssetVisibilityResponse, AssetsListResponse, FinalizeAssetBody, FinalizeAssetResponse, PresignAssetBody, PresignAssetResponse } from './assets.js';
|
|
19
19
|
import type { BatchGetUserMetadataBody, BatchGetUserMetadataResponse, GetUserMetadataResponse, ListGroupsResponse, PermissionsResponse, SearchUsersResponse, UpdatePermissionsBody } from './permissions.js';
|
|
20
20
|
import type { ExternalGroupsResponse, InternalGroupsResponse, UpdateInternalGroupsBody, UpdateInternalGroupsResponse } from './groups.js';
|
|
21
21
|
import type { UserInfoResponse } from './user.js';
|
|
@@ -131,8 +131,10 @@ export declare class CanopyApiClient {
|
|
|
131
131
|
} & RequestOptions) => Promise<FinalizeAssetResponse>;
|
|
132
132
|
/** GET /assets */
|
|
133
133
|
list: (params: Record<string, string>, options?: RequestOptions) => Promise<AssetsListResponse>;
|
|
134
|
-
/**
|
|
135
|
-
|
|
134
|
+
/** POST /assets/hide */
|
|
135
|
+
hide: (body: AssetKeyBody, options?: RequestOptions) => Promise<AssetVisibilityResponse>;
|
|
136
|
+
/** POST /assets/restore */
|
|
137
|
+
restore: (body: AssetKeyBody, options?: RequestOptions) => Promise<AssetVisibilityResponse>;
|
|
136
138
|
};
|
|
137
139
|
readonly permissions: {
|
|
138
140
|
/** GET /permissions */
|
package/dist/api/client.js
CHANGED
|
@@ -139,9 +139,13 @@ export class CanopyApiClient {
|
|
|
139
139
|
list: (params, options) => {
|
|
140
140
|
return this.request('GET', this.buildPath('/assets', params), undefined, 'standard', options);
|
|
141
141
|
},
|
|
142
|
-
/**
|
|
143
|
-
|
|
144
|
-
return this.request('
|
|
142
|
+
/** POST /assets/hide */
|
|
143
|
+
hide: (body, options) => {
|
|
144
|
+
return this.request('POST', '/assets/hide', body, 'standard', options);
|
|
145
|
+
},
|
|
146
|
+
/** POST /assets/restore */
|
|
147
|
+
restore: (body, options) => {
|
|
148
|
+
return this.request('POST', '/assets/restore', body, 'standard', options);
|
|
145
149
|
},
|
|
146
150
|
};
|
|
147
151
|
this.permissions = {
|
package/dist/api/index.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export type { CommentsResponse, AddCommentResponse, ResolveCommentResponse } fro
|
|
|
5
5
|
export type { EntriesResponse, ListEntriesResponse, CollectionItem, ListEntriesParams, DeleteEntryResponse, } from './entries.js';
|
|
6
6
|
export type { PermissionsResponse, UpdatePermissionsBody, SearchUsersParams, GetUserMetadataResponse, GetUserMetadataParams, } from './permissions.js';
|
|
7
7
|
export type { UserInfoResponse } from './user.js';
|
|
8
|
-
export type { AssetRecord, AssetsListResponse, PresignAssetResponse, PresignAssetBody, FinalizeAssetResponse, FinalizeAssetBody,
|
|
8
|
+
export type { AssetRecord, AssetsListResponse, PresignAssetResponse, PresignAssetBody, FinalizeAssetResponse, FinalizeAssetBody, AssetKeyBody, AssetVisibilityResponse, } from './assets.js';
|
|
9
9
|
export type { InternalGroupsResponse, ExternalGroupsResponse, UpdateInternalGroupsBody, SearchExternalGroupsParams, ExternalGroup, } from './groups.js';
|
|
10
10
|
export type { ReferenceOptionsResponse } from './reference-options.js';
|
|
11
11
|
export type { ResolveReferencesResponse } from './resolve-references.js';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The six S3/local bucket-prefix strings (see keys.ts's module doc for the full layout).
|
|
3
3
|
*
|
|
4
4
|
* Split into its own dependency-free file so isomorphic modules (transform-directives.ts,
|
|
5
5
|
* asset-url.ts) can import the prefixes without pulling keys.ts's `node:crypto` import into a
|
|
@@ -9,6 +9,8 @@ export declare const ASSET_PREFIXES: {
|
|
|
9
9
|
readonly originals: "asset-originals";
|
|
10
10
|
readonly staging: "asset-staging";
|
|
11
11
|
readonly meta: "asset-meta";
|
|
12
|
+
/** One marker per file removed from the media library; its meta stays (see `hideAsset`). */
|
|
13
|
+
readonly hidden: "asset-hidden";
|
|
12
14
|
readonly public: "assets";
|
|
13
15
|
/** Transform-output prefix; directive-aware key building lives in transform-directives.ts. */
|
|
14
16
|
readonly transform: "assets/t";
|
|
@@ -21,6 +23,7 @@ export interface AssetPrefixes {
|
|
|
21
23
|
originals: string;
|
|
22
24
|
staging: string;
|
|
23
25
|
meta: string;
|
|
26
|
+
hidden: string;
|
|
24
27
|
public: string;
|
|
25
28
|
transform: string;
|
|
26
29
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The six S3/local bucket-prefix strings (see keys.ts's module doc for the full layout).
|
|
3
3
|
*
|
|
4
4
|
* Split into its own dependency-free file so isomorphic modules (transform-directives.ts,
|
|
5
5
|
* asset-url.ts) can import the prefixes without pulling keys.ts's `node:crypto` import into a
|
|
@@ -9,6 +9,8 @@ export const ASSET_PREFIXES = {
|
|
|
9
9
|
originals: 'asset-originals',
|
|
10
10
|
staging: 'asset-staging',
|
|
11
11
|
meta: 'asset-meta',
|
|
12
|
+
/** One marker per file removed from the media library; its meta stays (see `hideAsset`). */
|
|
13
|
+
hidden: 'asset-hidden',
|
|
12
14
|
public: 'assets',
|
|
13
15
|
/** Transform-output prefix; directive-aware key building lives in transform-directives.ts. */
|
|
14
16
|
transform: 'assets/t',
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The widths the editor's media UI requests through the raw route: a library list row, a library
|
|
3
|
+
* card and an image field's preview. Finalize stores each for a still raster upload, new or
|
|
4
|
+
* re-uploaded (`storeEditorThumbnails`), so a first view reads a stored object instead of
|
|
5
|
+
* transforming in the request. Isomorphic: the editor components and finalize both import it.
|
|
6
|
+
*/
|
|
7
|
+
export declare const EDITOR_THUMBNAIL_WIDTHS: {
|
|
8
|
+
readonly listRow: 80;
|
|
9
|
+
readonly card: 160;
|
|
10
|
+
readonly imageField: 320;
|
|
11
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The widths the editor's media UI requests through the raw route: a library list row, a library
|
|
3
|
+
* card and an image field's preview. Finalize stores each for a still raster upload, new or
|
|
4
|
+
* re-uploaded (`storeEditorThumbnails`), so a first view reads a stored object instead of
|
|
5
|
+
* transforming in the request. Isomorphic: the editor components and finalize both import it.
|
|
6
|
+
*/
|
|
7
|
+
export const EDITOR_THUMBNAIL_WIDTHS = { listRow: 80, card: 160, imageField: 320 };
|
|
@@ -15,12 +15,13 @@ export type FinalizeAssetResult = {
|
|
|
15
15
|
* Run the finalize pipeline over raw bytes and persist the result.
|
|
16
16
|
*
|
|
17
17
|
* Order matters here too: dedup FIRST (after hashing, inside the pipeline) -
|
|
18
|
-
* if an identical blob already exists, return the existing meta
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* point for an asset's existence,
|
|
22
|
-
* leaves only orphaned (harmless,
|
|
23
|
-
* record pointing at a missing blob.
|
|
18
|
+
* if an identical blob already exists, return the existing meta, touching
|
|
19
|
+
* only its hidden marker and any editor thumbnails it lacks (first-name-wins).
|
|
20
|
+
* Otherwise write the original, then the optional public object, then
|
|
21
|
+
* `putMetaIfAbsent` LAST: meta is the commit point for an asset's existence,
|
|
22
|
+
* so a crash between `putOriginal` and here leaves only orphaned (harmless,
|
|
23
|
+
* content-addressed) blobs, never a meta record pointing at a missing blob.
|
|
24
|
+
* The editor thumbnails come after the commit.
|
|
24
25
|
* @internal Exported for tests.
|
|
25
26
|
*/
|
|
26
27
|
export declare function finalizeAsset(store: AssetStore, input: FinalizeInput): Promise<FinalizeAssetResult>;
|
package/dist/assets/finalize.js
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { runFinalizePipeline } from './pipeline.js';
|
|
7
7
|
import { ASSET_PREFIXES } from './keys.js';
|
|
8
|
+
import { storeEditorThumbnails } from './materialize.js';
|
|
9
|
+
import { getErrorMessage } from '../utils/error.js';
|
|
10
|
+
import { canopyLogWarn } from '../utils/logger.js';
|
|
8
11
|
/**
|
|
9
12
|
* A client-supplied staging key must be exactly `asset-staging/{uuid}` — the
|
|
10
13
|
* shape beginUpload generates. Without this check, finalize's read + cleanup
|
|
@@ -23,12 +26,13 @@ export function isValidStagingKey(key) {
|
|
|
23
26
|
* Run the finalize pipeline over raw bytes and persist the result.
|
|
24
27
|
*
|
|
25
28
|
* Order matters here too: dedup FIRST (after hashing, inside the pipeline) -
|
|
26
|
-
* if an identical blob already exists, return the existing meta
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* point for an asset's existence,
|
|
30
|
-
* leaves only orphaned (harmless,
|
|
31
|
-
* record pointing at a missing blob.
|
|
29
|
+
* if an identical blob already exists, return the existing meta, touching
|
|
30
|
+
* only its hidden marker and any editor thumbnails it lacks (first-name-wins).
|
|
31
|
+
* Otherwise write the original, then the optional public object, then
|
|
32
|
+
* `putMetaIfAbsent` LAST: meta is the commit point for an asset's existence,
|
|
33
|
+
* so a crash between `putOriginal` and here leaves only orphaned (harmless,
|
|
34
|
+
* content-addressed) blobs, never a meta record pointing at a missing blob.
|
|
35
|
+
* The editor thumbnails come after the commit.
|
|
32
36
|
* @internal Exported for tests.
|
|
33
37
|
*/
|
|
34
38
|
export async function finalizeAsset(store, input) {
|
|
@@ -37,8 +41,21 @@ export async function finalizeAsset(store, input) {
|
|
|
37
41
|
return result;
|
|
38
42
|
const { meta, data, publicObject } = result;
|
|
39
43
|
const existing = await store.getMeta(meta.hash32);
|
|
40
|
-
if (existing)
|
|
44
|
+
if (existing) {
|
|
45
|
+
// Uploading a file removed from the library puts it back. Hiding is a de-list, not access
|
|
46
|
+
// control: whoever uploads the bytes has the file, and a picker selecting an upload the
|
|
47
|
+
// library never shows would be a dead end. A failed un-hide only leaves the file out of the
|
|
48
|
+
// listing, so the upload still succeeds.
|
|
49
|
+
try {
|
|
50
|
+
await store.unhideAsset(meta.hash32);
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
canopyLogWarn(`[canopycms] could not restore removed asset ${meta.hash32} to the library on re-upload: ${getErrorMessage(err)}`);
|
|
54
|
+
}
|
|
55
|
+
// Stores any thumbnail the existing asset lacks; this upload's bytes are its original's.
|
|
56
|
+
await storeEditorThumbnails(store, existing, data, { skipStored: true });
|
|
41
57
|
return { ok: true, meta: existing };
|
|
58
|
+
}
|
|
42
59
|
// Either put may find its key taken (`already-exists`): by an earlier attempt that stopped
|
|
43
60
|
// before the meta commit, or by a concurrent identical upload. The object kept there is this
|
|
44
61
|
// content's, so finalize goes on to commit the meta either way.
|
|
@@ -49,11 +66,13 @@ export async function finalizeAsset(store, input) {
|
|
|
49
66
|
const putResult = await store.putMetaIfAbsent(meta.hash32, meta);
|
|
50
67
|
if (putResult === 'already-exists') {
|
|
51
68
|
// Lost a race against a concurrent identical upload - the winner's meta
|
|
52
|
-
// (already committed) is authoritative; return it rather than ours.
|
|
69
|
+
// (already committed) is authoritative; return it rather than ours. The
|
|
70
|
+
// winner's own finalize stores its thumbnails.
|
|
53
71
|
const winner = await store.getMeta(meta.hash32);
|
|
54
|
-
|
|
55
|
-
return { ok: true, meta: winner };
|
|
72
|
+
return { ok: true, meta: winner ?? meta };
|
|
56
73
|
}
|
|
74
|
+
// After the commit point: a thumbnail is keyed by the committed meta's slug.
|
|
75
|
+
await storeEditorThumbnails(store, meta, data);
|
|
57
76
|
return { ok: true, meta };
|
|
58
77
|
}
|
|
59
78
|
/**
|
package/dist/assets/keys.d.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* asset-originals/{hash32}.{ext} private; full-fidelity originals
|
|
8
8
|
* asset-staging/{uuid} presigned-POST target
|
|
9
9
|
* asset-meta/{hash32}.json private; filename/uploader/dims/mime
|
|
10
|
+
* asset-hidden/{hash32}.json private; marks a file removed from the library
|
|
10
11
|
* assets/t/{directives}/{hash32}/{slug} transform outputs (see transform-directives.ts)
|
|
11
12
|
* assets/{hash32}/{slug}.{ext} public static: sanitized SVG + PDF only
|
|
12
13
|
*
|
|
@@ -50,8 +51,10 @@ export declare function createKeyBuilders(prefixes?: AssetPrefixes): {
|
|
|
50
51
|
stagingKey: (uuid: string) => string;
|
|
51
52
|
metaKey: (hash32: string) => string;
|
|
52
53
|
metaPrefix: () => string;
|
|
54
|
+
hiddenKey: (hash32: string) => string;
|
|
55
|
+
hiddenPrefix: () => string;
|
|
53
56
|
publicKey: (hash32: string, slug: string, ext: string) => string;
|
|
54
57
|
};
|
|
55
|
-
export declare const originalKey: (hash32: string, ext: string) => string, stagingKey: (uuid: string) => string, metaKey: (hash32: string) => string, publicKey: (hash32: string, slug: string, ext: string) => string;
|
|
58
|
+
export declare const originalKey: (hash32: string, ext: string) => string, stagingKey: (uuid: string) => string, metaKey: (hash32: string) => string, hiddenKey: (hash32: string) => string, publicKey: (hash32: string, slug: string, ext: string) => string;
|
|
56
59
|
/** @internal Exported for tests. */
|
|
57
60
|
export declare const originalPrefix: (hash32: string) => string, metaPrefix: () => string;
|
package/dist/assets/keys.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* asset-originals/{hash32}.{ext} private; full-fidelity originals
|
|
8
8
|
* asset-staging/{uuid} presigned-POST target
|
|
9
9
|
* asset-meta/{hash32}.json private; filename/uploader/dims/mime
|
|
10
|
+
* asset-hidden/{hash32}.json private; marks a file removed from the library
|
|
10
11
|
* assets/t/{directives}/{hash32}/{slug} transform outputs (see transform-directives.ts)
|
|
11
12
|
* assets/{hash32}/{slug}.{ext} public static: sanitized SVG + PDF only
|
|
12
13
|
*
|
|
@@ -81,10 +82,12 @@ export function createKeyBuilders(prefixes = ASSET_PREFIXES) {
|
|
|
81
82
|
stagingKey: (uuid) => `${prefixes.staging}/${uuid}`,
|
|
82
83
|
metaKey: (hash32) => `${prefixes.meta}/${hash32}.json`,
|
|
83
84
|
metaPrefix: () => `${prefixes.meta}/`,
|
|
85
|
+
hiddenKey: (hash32) => `${prefixes.hidden}/${hash32}.json`,
|
|
86
|
+
hiddenPrefix: () => `${prefixes.hidden}/`,
|
|
84
87
|
publicKey: (hash32, slug, ext) => `${prefixes.public}/${hash32}/${slug}.${ext}`,
|
|
85
88
|
};
|
|
86
89
|
}
|
|
87
90
|
const keyBuilders = createKeyBuilders();
|
|
88
|
-
export const { originalKey, stagingKey, metaKey, publicKey } = keyBuilders;
|
|
91
|
+
export const { originalKey, stagingKey, metaKey, hiddenKey, publicKey } = keyBuilders;
|
|
89
92
|
/** @internal Exported for tests. */
|
|
90
93
|
export const { originalPrefix, metaPrefix } = keyBuilders;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* before that build is released) and canopycms-cdk's lazy transform Lambda.
|
|
7
7
|
*/
|
|
8
8
|
import { type ParsedTransformPath } from './transform-directives.js';
|
|
9
|
-
import type { AssetStore, CreateOnlyResult } from './types.js';
|
|
9
|
+
import type { AssetMeta, AssetStore, CreateOnlyResult } from './types.js';
|
|
10
10
|
/** Every transform output is stored under a content-addressed key, so it never changes. */
|
|
11
11
|
export declare const TRANSFORM_CACHE_CONTROL = "public, max-age=31536000, immutable";
|
|
12
12
|
/**
|
|
@@ -39,11 +39,26 @@ export type StoreTransformResult = {
|
|
|
39
39
|
* `canonicalizeTransformPath`, so the stored pixels always match their key. A rejection is a fact
|
|
40
40
|
* about the asset or the URL and is never worth retrying; a thrown error belongs to the store or
|
|
41
41
|
* the environment. A 404's `error` names what was missing, so a caller answering a browser
|
|
42
|
-
* replaces it.
|
|
42
|
+
* replaces it. A caller already holding the asset's committed meta and original bytes passes them
|
|
43
|
+
* as `source`, and the store is not read.
|
|
43
44
|
*/
|
|
44
45
|
export declare function storeTransform(store: AssetStore, parsed: ParsedTransformPath, key: string, options?: {
|
|
45
46
|
tags?: Readonly<Record<string, string>>;
|
|
47
|
+
source?: {
|
|
48
|
+
meta: AssetMeta;
|
|
49
|
+
data: Uint8Array;
|
|
50
|
+
};
|
|
46
51
|
}): Promise<StoreTransformResult>;
|
|
52
|
+
/**
|
|
53
|
+
* Store the editor thumbnails of a raster asset whose meta is committed, from `data`, its
|
|
54
|
+
* original's bytes. Best effort: the raw route computes a missing one on first view, so a failure
|
|
55
|
+
* is logged, never thrown, and the first one stops the rest, which would decode the same bytes in
|
|
56
|
+
* the same environment. One width at a time, so one decode is in memory. A multi-page (animated)
|
|
57
|
+
* source is skipped. `skipStored` HEADs each key first, for an asset that may already have them.
|
|
58
|
+
*/
|
|
59
|
+
export declare function storeEditorThumbnails(store: AssetStore, meta: AssetMeta, data: Uint8Array, options?: {
|
|
60
|
+
skipStored?: boolean;
|
|
61
|
+
}): Promise<void>;
|
|
47
62
|
/** One key a build references, and where it was referenced, for the failure report. */
|
|
48
63
|
export interface MaterializeTarget {
|
|
49
64
|
/** `assets/t/{directives}/{hash32}/{slug}.{ext}`. */
|
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
* before that build is released) and canopycms-cdk's lazy transform Lambda.
|
|
7
7
|
*/
|
|
8
8
|
import { getErrorMessage, isNodeError } from '../utils/error.js';
|
|
9
|
+
import { canopyLogWarn } from '../utils/logger.js';
|
|
9
10
|
import { ASSET_PREFIXES } from './asset-prefixes.js';
|
|
10
11
|
import { loadSharp } from './sharp-loader.js';
|
|
11
12
|
import { applyTransform } from './transform.js';
|
|
12
|
-
import {
|
|
13
|
+
import { EDITOR_THUMBNAIL_WIDTHS } from './editor-thumbnail-widths.js';
|
|
14
|
+
import { canonicalizeTransformPath, formatDirectives, } from './transform-directives.js';
|
|
13
15
|
/** Every transform output is stored under a content-addressed key, so it never changes. */
|
|
14
16
|
export const TRANSFORM_CACHE_CONTROL = 'public, max-age=31536000, immutable';
|
|
15
17
|
/**
|
|
@@ -25,10 +27,15 @@ export const LAZY_TRANSFORM_TAG = { key: 'canopy-transform', value: 'lazy' };
|
|
|
25
27
|
* `canonicalizeTransformPath`, so the stored pixels always match their key. A rejection is a fact
|
|
26
28
|
* about the asset or the URL and is never worth retrying; a thrown error belongs to the store or
|
|
27
29
|
* the environment. A 404's `error` names what was missing, so a caller answering a browser
|
|
28
|
-
* replaces it.
|
|
30
|
+
* replaces it. A caller already holding the asset's committed meta and original bytes passes them
|
|
31
|
+
* as `source`, and the store is not read.
|
|
29
32
|
*/
|
|
30
33
|
export async function storeTransform(store, parsed, key, options = {}) {
|
|
31
|
-
const
|
|
34
|
+
const { source } = options;
|
|
35
|
+
if (source && source.meta.hash32 !== parsed.hash32) {
|
|
36
|
+
throw new Error(`source is asset ${source.meta.hash32}, not ${parsed.hash32}`);
|
|
37
|
+
}
|
|
38
|
+
const meta = source?.meta ?? (await store.getMeta(parsed.hash32));
|
|
32
39
|
if (!meta) {
|
|
33
40
|
return { ok: false, status: 404, error: `Asset ${parsed.hash32} has no meta in the store` };
|
|
34
41
|
}
|
|
@@ -51,7 +58,9 @@ export async function storeTransform(store, parsed, key, options = {}) {
|
|
|
51
58
|
if (requestedFormat === undefined && parsed.ext !== meta.ext) {
|
|
52
59
|
return { ok: false, status: 400, error: 'Extension does not match the source format' };
|
|
53
60
|
}
|
|
54
|
-
const original =
|
|
61
|
+
const original = source
|
|
62
|
+
? { data: source.data, ext: meta.ext }
|
|
63
|
+
: await store.readOriginal(parsed.hash32, meta.ext);
|
|
55
64
|
if (!original) {
|
|
56
65
|
return { ok: false, status: 404, error: `Asset ${parsed.hash32} has no original in the store` };
|
|
57
66
|
}
|
|
@@ -68,6 +77,50 @@ export async function storeTransform(store, parsed, key, options = {}) {
|
|
|
68
77
|
});
|
|
69
78
|
return { ok: true, data: transformed.data, contentType: transformed.contentType, stored };
|
|
70
79
|
}
|
|
80
|
+
/** The canonical keys of an asset's `EDITOR_THUMBNAIL_WIDTHS`, the keys the raw route reads them at. */
|
|
81
|
+
function editorThumbnailTargets(meta) {
|
|
82
|
+
return Object.values(EDITOR_THUMBNAIL_WIDTHS).map((width) => {
|
|
83
|
+
const canonical = canonicalizeTransformPath([formatDirectives({ identity: false, width }), meta.hash32, `${meta.slug}.${meta.ext}`], 'any');
|
|
84
|
+
if (!canonical.ok)
|
|
85
|
+
throw new Error(canonical.error);
|
|
86
|
+
return { key: `${ASSET_PREFIXES.transform}/${canonical.canonicalPath}`, parsed: canonical };
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Store the editor thumbnails of a raster asset whose meta is committed, from `data`, its
|
|
91
|
+
* original's bytes. Best effort: the raw route computes a missing one on first view, so a failure
|
|
92
|
+
* is logged, never thrown, and the first one stops the rest, which would decode the same bytes in
|
|
93
|
+
* the same environment. One width at a time, so one decode is in memory. A multi-page (animated)
|
|
94
|
+
* source is skipped. `skipStored` HEADs each key first, for an asset that may already have them.
|
|
95
|
+
*/
|
|
96
|
+
export async function storeEditorThumbnails(store, meta, data, options = {}) {
|
|
97
|
+
if (meta.kind !== 'raster')
|
|
98
|
+
return;
|
|
99
|
+
let failure;
|
|
100
|
+
try {
|
|
101
|
+
// An animated source re-encodes every frame at every width, over 15 s for a large GIF: too
|
|
102
|
+
// long to hold an upload for, so its first view transforms it instead. The probe decodes no
|
|
103
|
+
// pixels.
|
|
104
|
+
const sharp = await loadSharp();
|
|
105
|
+
if (((await sharp(data).metadata()).pages ?? 1) > 1)
|
|
106
|
+
return;
|
|
107
|
+
for (const { key, parsed } of editorThumbnailTargets(meta)) {
|
|
108
|
+
if (options.skipStored && (await store.hasPublicObject(key)))
|
|
109
|
+
continue;
|
|
110
|
+
const result = await storeTransform(store, parsed, key, { source: { meta, data } });
|
|
111
|
+
if (!result.ok) {
|
|
112
|
+
failure = `${key}: ${result.error}`;
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
failure = getErrorMessage(err);
|
|
119
|
+
}
|
|
120
|
+
if (failure !== undefined) {
|
|
121
|
+
canopyLogWarn(`[canopycms] could not store editor thumbnails of ${meta.hash32}: ${failure}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
71
124
|
/** The `schemaVersion` of every `MaterializeReport`; a consumer gating on the JSON checks it. */
|
|
72
125
|
export const MATERIALIZE_REPORT_SCHEMA_VERSION = 1;
|
|
73
126
|
/** sharp cannot load in this process, so nothing missing can be produced here. */
|