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
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* bucket-prefix layout as S3AssetStore (see keys.ts) so content behaves
|
|
4
4
|
* identically across adapters.
|
|
5
5
|
*/
|
|
6
|
-
import type { AssetMeta, AssetStore, BeginUploadInput, CreateOnlyResult, PublicObject, StagedUploadTarget } from './types.js';
|
|
6
|
+
import type { AssetMeta, AssetStore, BeginUploadInput, CreateOnlyResult, HiddenAssetMarker, ListedAssetMeta, PublicObject, StagedUploadTarget } from './types.js';
|
|
7
7
|
export interface LocalAssetStoreOptions {
|
|
8
|
-
/** Root directory. The
|
|
8
|
+
/** Root directory. The six bucket prefixes are created as subdirectories under it. */
|
|
9
9
|
root: string;
|
|
10
10
|
}
|
|
11
11
|
export declare class LocalAssetStore implements AssetStore {
|
|
@@ -33,11 +33,12 @@ export declare class LocalAssetStore implements AssetStore {
|
|
|
33
33
|
/** Link `tempPath` in at `filePath`; `false` when something is already there. */
|
|
34
34
|
private linkIfAbsent;
|
|
35
35
|
/**
|
|
36
|
-
* Create-only write of a blob and its headers sidecar. Each is written
|
|
37
|
-
* `link()`ed into place, which fails with EEXIST rather than replacing
|
|
38
|
-
* The sidecar is linked first, so a reader never finds a blob without
|
|
39
|
-
* already there was linked by an earlier writer of this key and is kept,
|
|
40
|
-
* first writer's headers win, as on S3 (a static's Content-Disposition names
|
|
36
|
+
* Create-only write of a blob and, when `headers` is given, its headers sidecar. Each is written
|
|
37
|
+
* whole to a temp file, then `link()`ed into place, which fails with EEXIST rather than replacing
|
|
38
|
+
* (see docs/concurrency.md). The sidecar is linked first, so a reader never finds a blob without
|
|
39
|
+
* its headers. A sidecar already there was linked by an earlier writer of this key and is kept,
|
|
40
|
+
* as the blob is: the first writer's headers win, as on S3 (a static's Content-Disposition names
|
|
41
|
+
* its uploaded file).
|
|
41
42
|
*/
|
|
42
43
|
private createExclusive;
|
|
43
44
|
beginUpload(_input: BeginUploadInput): Promise<StagedUploadTarget>;
|
|
@@ -85,12 +86,20 @@ export declare class LocalAssetStore implements AssetStore {
|
|
|
85
86
|
*/
|
|
86
87
|
putMetaIfAbsent(hash32: string, meta: AssetMeta): Promise<CreateOnlyResult>;
|
|
87
88
|
getMeta(hash32: string): Promise<AssetMeta | null>;
|
|
89
|
+
/**
|
|
90
|
+
* The cursor names the last item returned, and the next page starts after it in sort order, so a
|
|
91
|
+
* page boundary holds when that item is hidden in between: its meta is never deleted, and the
|
|
92
|
+
* comparison needs only its place, not its presence in the filtered list.
|
|
93
|
+
*/
|
|
88
94
|
listMeta(input?: {
|
|
89
95
|
cursor?: string;
|
|
90
96
|
limit?: number;
|
|
97
|
+
includeHidden?: boolean;
|
|
91
98
|
}): Promise<{
|
|
92
|
-
items:
|
|
99
|
+
items: ListedAssetMeta[];
|
|
93
100
|
nextCursor?: string;
|
|
94
101
|
}>;
|
|
95
|
-
|
|
102
|
+
private hiddenHashes;
|
|
103
|
+
hideAsset(hash32: string, marker: HiddenAssetMarker): Promise<CreateOnlyResult>;
|
|
104
|
+
unhideAsset(hash32: string): Promise<boolean>;
|
|
96
105
|
}
|
|
@@ -8,7 +8,7 @@ import path from 'node:path';
|
|
|
8
8
|
import { randomUUID } from 'node:crypto';
|
|
9
9
|
import { getErrorMessage, isFileExistsError, isNotFoundError } from '../utils/error.js';
|
|
10
10
|
import { atomicWriteFile } from '../utils/atomic-write.js';
|
|
11
|
-
import { ASSET_PREFIXES, metaKey, originalKey, stagingKey } from './keys.js';
|
|
11
|
+
import { ASSET_PREFIXES, hiddenKey, metaKey, originalKey, stagingKey } from './keys.js';
|
|
12
12
|
/** Matches S3AssetStore's default; local has no separate configuration knob for it. */
|
|
13
13
|
const DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
14
14
|
const DEFAULT_LIST_LIMIT = 24;
|
|
@@ -20,6 +20,16 @@ const HEADERS_SIDECAR_SUFFIX = '.headers.json';
|
|
|
20
20
|
const TEMP_DIR = '.asset-tmp';
|
|
21
21
|
/** An original's file name after `{hash32}.`; a sidecar or any other name in the directory fails it. */
|
|
22
22
|
const ORIGINAL_EXT_RE = /^[a-z0-9]+$/;
|
|
23
|
+
/** A hidden marker's file name; a stray file in the directory fails it. */
|
|
24
|
+
const HIDDEN_MARKER_RE = /^([a-f0-9]{32})\.json$/;
|
|
25
|
+
/** Newest first; hash32 breaks a tie of equal timestamps, so every page boundary is stable. */
|
|
26
|
+
function compareNewestFirst(a, b) {
|
|
27
|
+
if (a.uploadedAt !== b.uploadedAt)
|
|
28
|
+
return a.uploadedAt < b.uploadedAt ? 1 : -1;
|
|
29
|
+
if (a.hash32 === b.hash32)
|
|
30
|
+
return 0;
|
|
31
|
+
return a.hash32 < b.hash32 ? 1 : -1;
|
|
32
|
+
}
|
|
23
33
|
export class LocalAssetStore {
|
|
24
34
|
constructor(options) {
|
|
25
35
|
this.capabilities = { directUpload: false };
|
|
@@ -95,11 +105,12 @@ export class LocalAssetStore {
|
|
|
95
105
|
}
|
|
96
106
|
}
|
|
97
107
|
/**
|
|
98
|
-
* Create-only write of a blob and its headers sidecar. Each is written
|
|
99
|
-
* `link()`ed into place, which fails with EEXIST rather than replacing
|
|
100
|
-
* The sidecar is linked first, so a reader never finds a blob without
|
|
101
|
-
* already there was linked by an earlier writer of this key and is kept,
|
|
102
|
-
* first writer's headers win, as on S3 (a static's Content-Disposition names
|
|
108
|
+
* Create-only write of a blob and, when `headers` is given, its headers sidecar. Each is written
|
|
109
|
+
* whole to a temp file, then `link()`ed into place, which fails with EEXIST rather than replacing
|
|
110
|
+
* (see docs/concurrency.md). The sidecar is linked first, so a reader never finds a blob without
|
|
111
|
+
* its headers. A sidecar already there was linked by an earlier writer of this key and is kept,
|
|
112
|
+
* as the blob is: the first writer's headers win, as on S3 (a static's Content-Disposition names
|
|
113
|
+
* its uploaded file).
|
|
103
114
|
*/
|
|
104
115
|
async createExclusive(key, data, headers) {
|
|
105
116
|
const filePath = this.resolveKey(key);
|
|
@@ -112,8 +123,10 @@ export class LocalAssetStore {
|
|
|
112
123
|
const tempHeaders = path.join(tempDir, randomUUID());
|
|
113
124
|
try {
|
|
114
125
|
await fs.writeFile(tempBlob, data);
|
|
115
|
-
|
|
116
|
-
|
|
126
|
+
if (headers) {
|
|
127
|
+
await fs.writeFile(tempHeaders, JSON.stringify(headers), 'utf-8');
|
|
128
|
+
await this.linkIfAbsent(tempHeaders, this.headersSidecarPath(filePath));
|
|
129
|
+
}
|
|
117
130
|
return (await this.linkIfAbsent(tempBlob, filePath)) ? 'created' : 'already-exists';
|
|
118
131
|
}
|
|
119
132
|
finally {
|
|
@@ -249,6 +262,11 @@ export class LocalAssetStore {
|
|
|
249
262
|
throw err;
|
|
250
263
|
}
|
|
251
264
|
}
|
|
265
|
+
/**
|
|
266
|
+
* The cursor names the last item returned, and the next page starts after it in sort order, so a
|
|
267
|
+
* page boundary holds when that item is hidden in between: its meta is never deleted, and the
|
|
268
|
+
* comparison needs only its place, not its presence in the filtered list.
|
|
269
|
+
*/
|
|
252
270
|
async listMeta(input) {
|
|
253
271
|
const limit = input?.limit ?? DEFAULT_LIST_LIMIT;
|
|
254
272
|
const dir = this.resolveKey(ASSET_PREFIXES.meta);
|
|
@@ -269,42 +287,56 @@ export class LocalAssetStore {
|
|
|
269
287
|
all.push(JSON.parse(raw));
|
|
270
288
|
}
|
|
271
289
|
catch (err) {
|
|
272
|
-
//
|
|
273
|
-
//
|
|
290
|
+
// Only a file removed by hand since the directory read is skipped; anything else is a real
|
|
291
|
+
// problem worth surfacing via getErrorMessage.
|
|
274
292
|
if (!isNotFoundError(err)) {
|
|
275
293
|
throw new Error(`Failed to read asset meta file '${name}': ${getErrorMessage(err)}`);
|
|
276
294
|
}
|
|
277
295
|
}
|
|
278
296
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
return a.uploadedAt < b.uploadedAt ? 1 : -1;
|
|
285
|
-
return a.hash32 < b.hash32 ? 1 : -1;
|
|
286
|
-
});
|
|
297
|
+
all.sort(compareNewestFirst);
|
|
298
|
+
const hidden = await this.hiddenHashes();
|
|
299
|
+
const listed = input?.includeHidden
|
|
300
|
+
? all.map((meta) => (hidden.has(meta.hash32) ? { ...meta, hidden: true } : meta))
|
|
301
|
+
: all.filter((meta) => !hidden.has(meta.hash32));
|
|
287
302
|
let startIndex = 0;
|
|
288
303
|
if (input?.cursor) {
|
|
289
304
|
const afterHash32 = Buffer.from(input.cursor, 'base64').toString('utf-8');
|
|
290
|
-
const
|
|
291
|
-
|
|
305
|
+
const anchor = all.find((m) => m.hash32 === afterHash32);
|
|
306
|
+
if (anchor) {
|
|
307
|
+
const after = listed.findIndex((m) => compareNewestFirst(m, anchor) > 0);
|
|
308
|
+
startIndex = after === -1 ? listed.length : after;
|
|
309
|
+
}
|
|
292
310
|
}
|
|
293
|
-
const items =
|
|
294
|
-
const hasMore = startIndex + limit <
|
|
311
|
+
const items = listed.slice(startIndex, startIndex + limit);
|
|
312
|
+
const hasMore = startIndex + limit < listed.length;
|
|
295
313
|
const nextCursor = hasMore
|
|
296
314
|
? Buffer.from(items[items.length - 1].hash32, 'utf-8').toString('base64')
|
|
297
315
|
: undefined;
|
|
298
316
|
return { items, nextCursor };
|
|
299
317
|
}
|
|
300
|
-
async
|
|
301
|
-
const filePath = this.resolveKey(metaKey(hash32));
|
|
318
|
+
async hiddenHashes() {
|
|
302
319
|
try {
|
|
303
|
-
await fs.
|
|
320
|
+
const names = await fs.readdir(this.resolveKey(ASSET_PREFIXES.hidden));
|
|
321
|
+
return new Set(names.flatMap((name) => HIDDEN_MARKER_RE.exec(name)?.[1] ?? []));
|
|
322
|
+
}
|
|
323
|
+
catch (err) {
|
|
324
|
+
if (isNotFoundError(err))
|
|
325
|
+
return new Set();
|
|
326
|
+
throw err;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
async hideAsset(hash32, marker) {
|
|
330
|
+
return this.createExclusive(hiddenKey(hash32), new TextEncoder().encode(JSON.stringify(marker)));
|
|
331
|
+
}
|
|
332
|
+
async unhideAsset(hash32) {
|
|
333
|
+
try {
|
|
334
|
+
await fs.unlink(this.resolveKey(hiddenKey(hash32)));
|
|
335
|
+
return true;
|
|
304
336
|
}
|
|
305
337
|
catch (err) {
|
|
306
338
|
if (isNotFoundError(err))
|
|
307
|
-
return;
|
|
339
|
+
return false;
|
|
308
340
|
throw err;
|
|
309
341
|
}
|
|
310
342
|
}
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
* S3-backed AssetStore. Same bucket-prefix layout as LocalAssetStore (see
|
|
3
3
|
* keys.ts). Assumes an EXISTING content bucket (versioning/SSE/replication
|
|
4
4
|
* already configured by the site's CDK stack). It reads and writes under the
|
|
5
|
-
*
|
|
5
|
+
* six asset prefixes, plus a caller's output prefix for `copyPublicObject`.
|
|
6
6
|
*/
|
|
7
7
|
import { type AssetPrefixes } from './keys.js';
|
|
8
|
-
import type { AssetMeta, AssetStore, BeginUploadInput, CreateOnlyResult, PublicObject, StagedUploadTarget } from './types.js';
|
|
8
|
+
import type { AssetMeta, AssetStore, BeginUploadInput, CreateOnlyResult, HiddenAssetMarker, ListedAssetMeta, PresignedRead, PublicObject, StagedUploadTarget } from './types.js';
|
|
9
9
|
export interface S3AssetStoreOptions {
|
|
10
10
|
bucket: string;
|
|
11
11
|
region: string;
|
|
@@ -102,18 +102,37 @@ export declare class S3AssetStore implements AssetStore {
|
|
|
102
102
|
*/
|
|
103
103
|
hasPublicObject(key: string): Promise<boolean>;
|
|
104
104
|
listPublicObjectKeys(prefix: string): AsyncIterable<string>;
|
|
105
|
-
|
|
106
|
-
|
|
105
|
+
private listKeys;
|
|
106
|
+
/**
|
|
107
|
+
* HEADs first: signing is local and succeeds for a missing key too. A URL signed with temporary
|
|
108
|
+
* credentials dies when they expire, whatever its own expiry, so `maxAgeSeconds` also stops short
|
|
109
|
+
* of the credentials' `expiration` when the provider reports one.
|
|
110
|
+
*/
|
|
111
|
+
presignPublicObjectRead(key: string): Promise<PresignedRead | null>;
|
|
107
112
|
putMetaIfAbsent(hash32: string, meta: AssetMeta): Promise<CreateOnlyResult>;
|
|
108
113
|
getMeta(hash32: string): Promise<AssetMeta | null>;
|
|
114
|
+
/**
|
|
115
|
+
* One page of meta keys, filtered by a full listing of the hidden markers: few files are hidden,
|
|
116
|
+
* and a listing needs only `s3:ListBucket`. A hidden file's meta is fetched only for
|
|
117
|
+
* `includeHidden`.
|
|
118
|
+
*/
|
|
109
119
|
listMeta(input?: {
|
|
110
120
|
cursor?: string;
|
|
111
121
|
limit?: number;
|
|
122
|
+
includeHidden?: boolean;
|
|
112
123
|
}): Promise<{
|
|
113
|
-
items:
|
|
124
|
+
items: ListedAssetMeta[];
|
|
114
125
|
nextCursor?: string;
|
|
115
126
|
}>;
|
|
116
|
-
|
|
127
|
+
private listHiddenHashes;
|
|
128
|
+
hideAsset(hash32: string, marker: HiddenAssetMarker): Promise<CreateOnlyResult>;
|
|
129
|
+
/**
|
|
130
|
+
* Lists before deleting: a DELETE of an absent key writes a delete marker on a versioned bucket.
|
|
131
|
+
* The listing needs only `s3:ListBucket` covering `asset-hidden/` (a bucket-wide grant does; a
|
|
132
|
+
* policy scoping it by prefix must name this one), no object permission, so finalize can call
|
|
133
|
+
* this on every re-upload under a role that has never hidden a file.
|
|
134
|
+
*/
|
|
135
|
+
unhideAsset(hash32: string): Promise<boolean>;
|
|
117
136
|
private getObject;
|
|
118
137
|
private getObjectBytes;
|
|
119
138
|
}
|
package/dist/assets/store-s3.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* S3-backed AssetStore. Same bucket-prefix layout as LocalAssetStore (see
|
|
3
3
|
* keys.ts). Assumes an EXISTING content bucket (versioning/SSE/replication
|
|
4
4
|
* already configured by the site's CDK stack). It reads and writes under the
|
|
5
|
-
*
|
|
5
|
+
* six asset prefixes, plus a caller's output prefix for `copyPublicObject`.
|
|
6
6
|
*/
|
|
7
7
|
import { randomUUID } from 'node:crypto';
|
|
8
8
|
import { CopyObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client, paginateListObjectsV2, } from '@aws-sdk/client-s3';
|
|
@@ -11,9 +11,18 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
|
|
11
11
|
import { isHttpUrlOrSameOriginPath } from '../utils/sanitize-href.js';
|
|
12
12
|
import { ASSET_PREFIXES, createKeyBuilders } from './keys.js';
|
|
13
13
|
const DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
14
|
+
/** What `metaKey` and `hiddenKey` put after the hash32. */
|
|
15
|
+
const JSON_SUFFIX = '.json';
|
|
14
16
|
const PRESIGN_EXPIRY_SECONDS = 15 * 60;
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
+
/**
|
|
18
|
+
* A presigned read is signed at the start of its window, so every read of one key inside a window
|
|
19
|
+
* gets the same URL, and a browser can reuse both the redirect to it and the object behind it.
|
|
20
|
+
*/
|
|
21
|
+
const PRESIGNED_READ_WINDOW_SECONDS = 60 * 60;
|
|
22
|
+
/** Valid a full window past the window's end, so a redirect cached until then never expires first. */
|
|
23
|
+
const PRESIGNED_READ_EXPIRY_SECONDS = 2 * PRESIGNED_READ_WINDOW_SECONDS;
|
|
24
|
+
/** How long before its signing credentials expire a presigned read stops being reusable. */
|
|
25
|
+
const CREDENTIAL_EXPIRY_MARGIN_SECONDS = 60;
|
|
17
26
|
function matchesAwsError(err, name, httpStatusCode) {
|
|
18
27
|
if (!(err instanceof Error))
|
|
19
28
|
return false;
|
|
@@ -234,7 +243,10 @@ export class S3AssetStore {
|
|
|
234
243
|
throw err;
|
|
235
244
|
}
|
|
236
245
|
}
|
|
237
|
-
|
|
246
|
+
listPublicObjectKeys(prefix) {
|
|
247
|
+
return this.listKeys(prefix);
|
|
248
|
+
}
|
|
249
|
+
async *listKeys(prefix) {
|
|
238
250
|
const pages = paginateListObjectsV2({ client: this.client }, { Bucket: this.bucket, Prefix: prefix });
|
|
239
251
|
for await (const page of pages) {
|
|
240
252
|
for (const object of page.Contents ?? []) {
|
|
@@ -243,13 +255,25 @@ export class S3AssetStore {
|
|
|
243
255
|
}
|
|
244
256
|
}
|
|
245
257
|
}
|
|
246
|
-
/**
|
|
258
|
+
/**
|
|
259
|
+
* HEADs first: signing is local and succeeds for a missing key too. A URL signed with temporary
|
|
260
|
+
* credentials dies when they expire, whatever its own expiry, so `maxAgeSeconds` also stops short
|
|
261
|
+
* of the credentials' `expiration` when the provider reports one.
|
|
262
|
+
*/
|
|
247
263
|
async presignPublicObjectRead(key) {
|
|
248
264
|
if (!(await this.hasPublicObject(key)))
|
|
249
265
|
return null;
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
266
|
+
// Read before signing: a provider refresh in between leaves the signing credentials the newer.
|
|
267
|
+
const { expiration } = await this.client.config.credentials();
|
|
268
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
269
|
+
const windowStart = nowSeconds - (nowSeconds % PRESIGNED_READ_WINDOW_SECONDS);
|
|
270
|
+
const url = await getSignedUrl(this.client, new GetObjectCommand({ Bucket: this.bucket, Key: key }), { expiresIn: PRESIGNED_READ_EXPIRY_SECONDS, signingDate: new Date(windowStart * 1000) });
|
|
271
|
+
let maxAgeSeconds = windowStart + PRESIGNED_READ_WINDOW_SECONDS - nowSeconds;
|
|
272
|
+
if (expiration) {
|
|
273
|
+
const credentialSeconds = Math.floor(expiration.getTime() / 1000) - nowSeconds;
|
|
274
|
+
maxAgeSeconds = Math.min(maxAgeSeconds, credentialSeconds - CREDENTIAL_EXPIRY_MARGIN_SECONDS);
|
|
275
|
+
}
|
|
276
|
+
return { url, maxAgeSeconds: Math.max(0, maxAgeSeconds) };
|
|
253
277
|
}
|
|
254
278
|
async putMetaIfAbsent(hash32, meta) {
|
|
255
279
|
return this.putIfAbsent({
|
|
@@ -264,31 +288,69 @@ export class S3AssetStore {
|
|
|
264
288
|
return null;
|
|
265
289
|
return JSON.parse(Buffer.from(bytes).toString('utf-8'));
|
|
266
290
|
}
|
|
291
|
+
/**
|
|
292
|
+
* One page of meta keys, filtered by a full listing of the hidden markers: few files are hidden,
|
|
293
|
+
* and a listing needs only `s3:ListBucket`. A hidden file's meta is fetched only for
|
|
294
|
+
* `includeHidden`.
|
|
295
|
+
*/
|
|
267
296
|
async listMeta(input) {
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
297
|
+
const metaPrefix = this.keys.metaPrefix();
|
|
298
|
+
const [listed, hidden] = await Promise.all([
|
|
299
|
+
this.client.send(new ListObjectsV2Command({
|
|
300
|
+
Bucket: this.bucket,
|
|
301
|
+
Prefix: metaPrefix,
|
|
302
|
+
MaxKeys: input?.limit,
|
|
303
|
+
ContinuationToken: input?.cursor,
|
|
304
|
+
})),
|
|
305
|
+
this.listHiddenHashes(),
|
|
306
|
+
]);
|
|
274
307
|
const objectKeys = (listed.Contents ?? [])
|
|
275
308
|
.map((obj) => obj.Key)
|
|
276
309
|
.filter((key) => Boolean(key));
|
|
277
310
|
const fetched = await Promise.all(objectKeys.map(async (key) => {
|
|
311
|
+
const isHidden = hidden.has(key.slice(metaPrefix.length, -JSON_SUFFIX.length));
|
|
312
|
+
if (isHidden && !input?.includeHidden)
|
|
313
|
+
return null;
|
|
278
314
|
const bytes = await this.getObjectBytes(key);
|
|
279
|
-
// A meta object deleted between LIST and GET (deleteMeta race) reads
|
|
280
|
-
// as null; skip it rather than failing the whole page.
|
|
281
315
|
if (!bytes)
|
|
282
316
|
return null;
|
|
283
|
-
|
|
317
|
+
const meta = JSON.parse(Buffer.from(bytes).toString('utf-8'));
|
|
318
|
+
return isHidden ? { ...meta, hidden: true } : meta;
|
|
284
319
|
}));
|
|
285
320
|
return {
|
|
286
321
|
items: fetched.filter((meta) => meta !== null),
|
|
287
322
|
nextCursor: listed.IsTruncated ? listed.NextContinuationToken : undefined,
|
|
288
323
|
};
|
|
289
324
|
}
|
|
290
|
-
async
|
|
291
|
-
|
|
325
|
+
async listHiddenHashes() {
|
|
326
|
+
const prefix = this.keys.hiddenPrefix();
|
|
327
|
+
const hashes = new Set();
|
|
328
|
+
for await (const key of this.listKeys(prefix)) {
|
|
329
|
+
if (key.endsWith(JSON_SUFFIX))
|
|
330
|
+
hashes.add(key.slice(prefix.length, -JSON_SUFFIX.length));
|
|
331
|
+
}
|
|
332
|
+
return hashes;
|
|
333
|
+
}
|
|
334
|
+
async hideAsset(hash32, marker) {
|
|
335
|
+
return this.putIfAbsent({
|
|
336
|
+
Key: this.keys.hiddenKey(hash32),
|
|
337
|
+
Body: JSON.stringify(marker),
|
|
338
|
+
ContentType: 'application/json',
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Lists before deleting: a DELETE of an absent key writes a delete marker on a versioned bucket.
|
|
343
|
+
* The listing needs only `s3:ListBucket` covering `asset-hidden/` (a bucket-wide grant does; a
|
|
344
|
+
* policy scoping it by prefix must name this one), no object permission, so finalize can call
|
|
345
|
+
* this on every re-upload under a role that has never hidden a file.
|
|
346
|
+
*/
|
|
347
|
+
async unhideAsset(hash32) {
|
|
348
|
+
const key = this.keys.hiddenKey(hash32);
|
|
349
|
+
const listed = await this.client.send(new ListObjectsV2Command({ Bucket: this.bucket, Prefix: key, MaxKeys: 1 }));
|
|
350
|
+
if (listed.Contents?.[0]?.Key !== key)
|
|
351
|
+
return false;
|
|
352
|
+
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
|
|
353
|
+
return true;
|
|
292
354
|
}
|
|
293
355
|
async getObject(key) {
|
|
294
356
|
try {
|
package/dist/assets/types.d.ts
CHANGED
|
@@ -19,6 +19,15 @@ export interface AssetMeta {
|
|
|
19
19
|
uploadedBy?: string;
|
|
20
20
|
uploadedAt: string;
|
|
21
21
|
}
|
|
22
|
+
/** A listed asset; `hidden` is set only on a listing that includes hidden files. */
|
|
23
|
+
export type ListedAssetMeta = AssetMeta & {
|
|
24
|
+
hidden?: true;
|
|
25
|
+
};
|
|
26
|
+
/** The body of a hidden marker. Only the marker's presence hides a file; this records who and when. */
|
|
27
|
+
export interface HiddenAssetMarker {
|
|
28
|
+
hiddenAt: string;
|
|
29
|
+
hiddenBy?: string;
|
|
30
|
+
}
|
|
22
31
|
export type StagedUploadTarget = {
|
|
23
32
|
mode: 'direct';
|
|
24
33
|
url: string;
|
|
@@ -41,6 +50,11 @@ export interface PublicObject {
|
|
|
41
50
|
contentDisposition?: string;
|
|
42
51
|
cacheControl?: string;
|
|
43
52
|
}
|
|
53
|
+
/** A presigned GET, and how long a client may keep reusing that URL (0: not at all). */
|
|
54
|
+
export interface PresignedRead {
|
|
55
|
+
url: string;
|
|
56
|
+
maxAgeSeconds: number;
|
|
57
|
+
}
|
|
44
58
|
/**
|
|
45
59
|
* What a create-only write did. Every key the store writes this way is content-addressed, so no
|
|
46
60
|
* correct writer ever replaces an object: `already-exists` means an object was already at the key
|
|
@@ -93,16 +107,31 @@ export interface AssetStore {
|
|
|
93
107
|
hasPublicObject(key: string): Promise<boolean>;
|
|
94
108
|
/** Every public object key under `prefix`, in the store's own pages. S3 only. */
|
|
95
109
|
listPublicObjectKeys?(prefix: string): AsyncIterable<string>;
|
|
96
|
-
/** A
|
|
97
|
-
presignPublicObjectRead?(key: string): Promise<
|
|
110
|
+
/** A URL a browser can GET `key` from directly, or `null` if absent. S3 only. */
|
|
111
|
+
presignPublicObjectRead?(key: string): Promise<PresignedRead | null>;
|
|
112
|
+
/**
|
|
113
|
+
* Meta is create-only and never deleted: every transform not yet stored, at upload, release or
|
|
114
|
+
* after a lazy output expires, is computed from it, and content anywhere may still name the file.
|
|
115
|
+
*/
|
|
98
116
|
putMetaIfAbsent(hash32: string, meta: AssetMeta): Promise<CreateOnlyResult>;
|
|
99
117
|
getMeta(hash32: string): Promise<AssetMeta | null>;
|
|
118
|
+
/**
|
|
119
|
+
* A page of meta, without hidden files unless `includeHidden`, which marks each hidden one
|
|
120
|
+
* `hidden: true`. A page may hold fewer than `limit` items while `nextCursor` is still set.
|
|
121
|
+
*/
|
|
100
122
|
listMeta(input?: {
|
|
101
123
|
cursor?: string;
|
|
102
124
|
limit?: number;
|
|
125
|
+
includeHidden?: boolean;
|
|
103
126
|
}): Promise<{
|
|
104
|
-
items:
|
|
127
|
+
items: ListedAssetMeta[];
|
|
105
128
|
nextCursor?: string;
|
|
106
129
|
}>;
|
|
107
|
-
|
|
130
|
+
/**
|
|
131
|
+
* Hide a file from `listMeta`: a create-only marker beside its meta, which stays, so every URL
|
|
132
|
+
* and transform keeps working. `already-exists` when it was hidden already.
|
|
133
|
+
*/
|
|
134
|
+
hideAsset(hash32: string, marker: HiddenAssetMarker): Promise<CreateOnlyResult>;
|
|
135
|
+
/** Remove the hidden marker; `false` when there was none. Needs no permission on a store that has never hidden a file. */
|
|
136
|
+
unhideAsset(hash32: string): Promise<boolean>;
|
|
108
137
|
}
|