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.
Files changed (136) hide show
  1. package/CHANGELOG.md +202 -43
  2. package/dist/api/assets.d.ts +27 -7
  3. package/dist/api/assets.js +118 -47
  4. package/dist/api/client.d.ts +5 -3
  5. package/dist/api/client.js +7 -3
  6. package/dist/api/index.d.ts +1 -1
  7. package/dist/assets/asset-prefixes.d.ts +4 -1
  8. package/dist/assets/asset-prefixes.js +3 -1
  9. package/dist/assets/editor-thumbnail-widths.d.ts +11 -0
  10. package/dist/assets/editor-thumbnail-widths.js +7 -0
  11. package/dist/assets/finalize.d.ts +7 -6
  12. package/dist/assets/finalize.js +29 -10
  13. package/dist/assets/keys.d.ts +4 -1
  14. package/dist/assets/keys.js +4 -1
  15. package/dist/assets/materialize.d.ts +17 -2
  16. package/dist/assets/materialize.js +57 -4
  17. package/dist/assets/store-local.d.ts +18 -9
  18. package/dist/assets/store-local.js +58 -26
  19. package/dist/assets/store-s3.d.ts +25 -6
  20. package/dist/assets/store-s3.js +81 -19
  21. package/dist/assets/types.d.ts +33 -4
  22. package/dist/cli/cli.js +151 -56
  23. package/dist/config/types.d.ts +3 -2
  24. package/dist/editor/BranchManager.js +2 -1
  25. package/dist/editor/CommentsPanel.js +67 -29
  26. package/dist/editor/Editor.d.ts +3 -0
  27. package/dist/editor/Editor.js +93 -39
  28. package/dist/editor/EntryNavigator.d.ts +17 -5
  29. package/dist/editor/EntryNavigator.js +66 -31
  30. package/dist/editor/FormHeader.d.ts +2 -1
  31. package/dist/editor/FormHeader.js +9 -5
  32. package/dist/editor/FormRenderer.d.ts +7 -2
  33. package/dist/editor/FormRenderer.js +51 -16
  34. package/dist/editor/PreviewFrame.d.ts +13 -2
  35. package/dist/editor/PreviewFrame.js +62 -5
  36. package/dist/editor/PreviewPane.d.ts +17 -1
  37. package/dist/editor/PreviewPane.js +55 -8
  38. package/dist/editor/PreviewToolbarTools.d.ts +8 -0
  39. package/dist/editor/PreviewToolbarTools.js +9 -1
  40. package/dist/editor/branch-status.d.ts +6 -0
  41. package/dist/editor/branch-status.js +10 -0
  42. package/dist/editor/comments/BranchComments.d.ts +2 -4
  43. package/dist/editor/comments/BranchComments.js +7 -11
  44. package/dist/editor/comments/ThreadCarousel.d.ts +2 -8
  45. package/dist/editor/comments/ThreadCarousel.js +4 -49
  46. package/dist/editor/comments/ThreadReplyControls.js +5 -2
  47. package/dist/editor/components/BranchLockBanner.d.ts +8 -7
  48. package/dist/editor/components/BranchLockBanner.js +30 -3
  49. package/dist/editor/components/BranchPill.d.ts +5 -4
  50. package/dist/editor/components/BranchPill.js +6 -8
  51. package/dist/editor/components/BranchRowLock.d.ts +12 -0
  52. package/dist/editor/components/BranchRowLock.js +19 -0
  53. package/dist/editor/components/BranchesDrawer.js +3 -3
  54. package/dist/editor/components/EditorHeader.d.ts +2 -6
  55. package/dist/editor/components/EditorHeader.js +14 -28
  56. package/dist/editor/components/EditorRail.d.ts +3 -4
  57. package/dist/editor/components/EditorRail.js +3 -4
  58. package/dist/editor/components/EntryBreadcrumb.d.ts +9 -10
  59. package/dist/editor/components/EntryBreadcrumb.js +8 -11
  60. package/dist/editor/components/PreviewLinkToast.d.ts +5 -0
  61. package/dist/editor/components/PreviewLinkToast.js +33 -0
  62. package/dist/editor/components/QuickOpenPalette.d.ts +7 -1
  63. package/dist/editor/components/QuickOpenPalette.js +6 -1
  64. package/dist/editor/components/RenameEntryModal.d.ts +6 -1
  65. package/dist/editor/components/RenameEntryModal.js +20 -5
  66. package/dist/editor/components/SaveSplitButton.d.ts +3 -2
  67. package/dist/editor/components/SaveSplitButton.js +25 -12
  68. package/dist/editor/components/StagedChangesDrawer.d.ts +0 -1
  69. package/dist/editor/components/StagedChangesDrawer.js +5 -4
  70. package/dist/editor/components/UndoToastMessage.d.ts +6 -5
  71. package/dist/editor/components/UndoToastMessage.js +6 -4
  72. package/dist/editor/components/UnsavedMark.d.ts +9 -0
  73. package/dist/editor/components/UnsavedMark.js +20 -0
  74. package/dist/editor/components/UserBadge.js +13 -11
  75. package/dist/editor/copy.d.ts +4 -0
  76. package/dist/editor/copy.js +4 -0
  77. package/dist/editor/editor-utils.d.ts +49 -9
  78. package/dist/editor/editor-utils.js +117 -16
  79. package/dist/editor/entry-search.d.ts +4 -2
  80. package/dist/editor/entry-search.js +2 -3
  81. package/dist/editor/fields/FieldLabel.d.ts +1 -1
  82. package/dist/editor/fields/FieldLabel.js +1 -1
  83. package/dist/editor/fields/ImageField.d.ts +1 -1
  84. package/dist/editor/fields/ImageField.js +36 -27
  85. package/dist/editor/fields/ObjectField.d.ts +13 -2
  86. package/dist/editor/fields/ObjectField.js +38 -9
  87. package/dist/editor/fields/entry-link/EntryLinkContext.d.ts +2 -0
  88. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -1
  89. package/dist/editor/group-manager/index.js +1 -1
  90. package/dist/editor/hooks/useBranchManager.d.ts +2 -0
  91. package/dist/editor/hooks/useBranchManager.js +1 -0
  92. package/dist/editor/hooks/useEditorLayout.d.ts +4 -0
  93. package/dist/editor/hooks/useEditorLayout.js +5 -0
  94. package/dist/editor/hooks/useEntryManager.d.ts +2 -0
  95. package/dist/editor/hooks/useEntryManager.js +10 -8
  96. package/dist/editor/media/AssetCard.d.ts +6 -3
  97. package/dist/editor/media/AssetCard.js +24 -9
  98. package/dist/editor/media/AssetListRow.d.ts +9 -4
  99. package/dist/editor/media/AssetListRow.js +18 -9
  100. package/dist/editor/media/CropStep.d.ts +8 -10
  101. package/dist/editor/media/CropStep.js +39 -27
  102. package/dist/editor/media/MediaLibrary.js +5 -4
  103. package/dist/editor/media/MediaLibraryBody.d.ts +1 -1
  104. package/dist/editor/media/MediaLibraryBody.js +65 -37
  105. package/dist/editor/media/crop-availability.d.ts +6 -0
  106. package/dist/editor/media/crop-availability.js +19 -0
  107. package/dist/editor/media/crop-math.d.ts +19 -13
  108. package/dist/editor/media/crop-math.js +50 -11
  109. package/dist/editor/media/useAssetLibrary.d.ts +6 -3
  110. package/dist/editor/media/useAssetLibrary.js +17 -10
  111. package/dist/editor/pane-header.d.ts +6 -0
  112. package/dist/editor/pane-header.js +6 -0
  113. package/dist/editor/permission-manager/index.js +1 -1
  114. package/dist/editor/preview-bridge.d.ts +23 -0
  115. package/dist/editor/preview-bridge.js +105 -4
  116. package/dist/editor/preview-path.d.ts +16 -4
  117. package/dist/editor/preview-path.js +80 -19
  118. package/dist/editor/shell/ActivityRail.d.ts +1 -3
  119. package/dist/editor/shell/ActivityRail.js +3 -3
  120. package/dist/editor/shell/EditorShell.d.ts +15 -0
  121. package/dist/editor/shell/EditorShell.js +17 -1
  122. package/dist/editor/shell/OverlayHeader.d.ts +19 -0
  123. package/dist/editor/shell/OverlayHeader.js +15 -0
  124. package/dist/editor/shell/PaneLayout.d.ts +6 -1
  125. package/dist/editor/shell/PaneLayout.js +68 -13
  126. package/dist/editor/shell/SidePanelHost.d.ts +8 -6
  127. package/dist/editor/shell/SidePanelHost.js +70 -33
  128. package/dist/editor/shell/TopBar.js +2 -1
  129. package/dist/editor/theme.d.ts +1 -0
  130. package/dist/editor/theme.js +19 -1
  131. package/dist/editor/undo-removal.d.ts +19 -10
  132. package/dist/editor/undo-removal.js +44 -16
  133. package/dist/server.d.ts +2 -0
  134. package/dist/version.d.ts +1 -1
  135. package/dist/version.js +1 -1
  136. 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 five bucket prefixes are created as subdirectories under it. */
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 whole to a temp file, then
37
- * `link()`ed into place, which fails with EEXIST rather than replacing (see docs/concurrency.md).
38
- * The sidecar is linked first, so a reader never finds a blob without its headers. A sidecar
39
- * already there was linked by an earlier writer of this key and is kept, as the blob is: the
40
- * first writer's headers win, as on S3 (a static's Content-Disposition names its uploaded file).
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: AssetMeta[];
99
+ items: ListedAssetMeta[];
93
100
  nextCursor?: string;
94
101
  }>;
95
- deleteMeta(hash32: string): Promise<void>;
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 whole to a temp file, then
99
- * `link()`ed into place, which fails with EEXIST rather than replacing (see docs/concurrency.md).
100
- * The sidecar is linked first, so a reader never finds a blob without its headers. A sidecar
101
- * already there was linked by an earlier writer of this key and is kept, as the blob is: the
102
- * first writer's headers win, as on S3 (a static's Content-Disposition names its uploaded file).
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
- await fs.writeFile(tempHeaders, JSON.stringify(headers), 'utf-8');
116
- await this.linkIfAbsent(tempHeaders, this.headersSidecarPath(filePath));
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
- // A meta file racing with deleteMeta is expected under concurrency;
273
- // anything else is a real problem worth surfacing via getErrorMessage.
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
- // Newest first; hash32 is the deterministic tiebreak for equal timestamps
280
- // (common in fast test loops using Date.now()), which keeps pagination
281
- // stable and duplicate/gap-free across calls.
282
- all.sort((a, b) => {
283
- if (a.uploadedAt !== b.uploadedAt)
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 idx = all.findIndex((m) => m.hash32 === afterHash32);
291
- startIndex = idx === -1 ? 0 : idx + 1;
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 = all.slice(startIndex, startIndex + limit);
294
- const hasMore = startIndex + limit < all.length;
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 deleteMeta(hash32) {
301
- const filePath = this.resolveKey(metaKey(hash32));
318
+ async hiddenHashes() {
302
319
  try {
303
- await fs.unlink(filePath);
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
- * five asset prefixes, plus a caller's output prefix for `copyPublicObject`.
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
- /** HEADs first: signing is local and succeeds for a missing key too. */
106
- presignPublicObjectRead(key: string): Promise<string | null>;
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: AssetMeta[];
124
+ items: ListedAssetMeta[];
114
125
  nextCursor?: string;
115
126
  }>;
116
- deleteMeta(hash32: string): Promise<void>;
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
  }
@@ -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
- * five asset prefixes, plus a caller's output prefix for `copyPublicObject`.
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
- /** A presigned read is followed by the browser at once; it is never stored or cached. */
16
- const PRESIGNED_READ_EXPIRY_SECONDS = 5 * 60;
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
- async *listPublicObjectKeys(prefix) {
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
- /** HEADs first: signing is local and succeeds for a missing key too. */
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
- return getSignedUrl(this.client, new GetObjectCommand({ Bucket: this.bucket, Key: key }), {
251
- expiresIn: PRESIGNED_READ_EXPIRY_SECONDS,
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 listed = await this.client.send(new ListObjectsV2Command({
269
- Bucket: this.bucket,
270
- Prefix: this.keys.metaPrefix(),
271
- MaxKeys: input?.limit,
272
- ContinuationToken: input?.cursor,
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
- return JSON.parse(Buffer.from(bytes).toString('utf-8'));
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 deleteMeta(hash32) {
291
- await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: this.keys.metaKey(hash32) }));
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 {
@@ -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 short-lived URL a browser can GET `key` from directly, or `null` if absent. S3 only. */
97
- presignPublicObjectRead?(key: string): Promise<string | null>;
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: AssetMeta[];
127
+ items: ListedAssetMeta[];
105
128
  nextCursor?: string;
106
129
  }>;
107
- deleteMeta(hash32: string): Promise<void>;
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
  }