@supersuit/artifacts 0.37.0 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +7 -6
- package/lib/artifacts/assets.js +39 -27
- package/lib/artifacts/index.d.ts +1 -0
- package/lib/artifacts/index.js +1 -0
- package/lib/artifacts/migrate-memos.d.ts +14 -0
- package/lib/artifacts/migrate-memos.js +36 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,29 @@
|
|
|
3
3
|
`@supersuit/artifacts`. One entry per version, newest first. Each entry says what changed, how a
|
|
4
4
|
host can tell whether it is affected (DETECTOR), what a host does about it (REMEDY), and the tests.
|
|
5
5
|
|
|
6
|
+
## 0.38.0
|
|
7
|
+
|
|
8
|
+
**Readers' voice memos live in the private bucket.** A memo left on a page (`comments/<memo>`, or a
|
|
9
|
+
reader's own `personal/<dir>/<memo>`) was written to the PUBLIC bucket. Every URL the package handed
|
|
10
|
+
out was signed, but that bucket carries a bucket-wide public read grant, so the memo itself answered
|
|
11
|
+
an unsigned GET to anyone holding its path. Memos are now written to and read from the private
|
|
12
|
+
bucket (`privateBucket`), under the same key. A host with no private bucket defines no memo method,
|
|
13
|
+
so memos stay on the reader's device instead of going anywhere public.
|
|
14
|
+
|
|
15
|
+
DETECTOR: an unsigned `curl -o /dev/null -w '%{http_code}'` of
|
|
16
|
+
`https://storage.googleapis.com/<public bucket>/<prefix>/<id>/comments/<memo>.webm` answers 200.
|
|
17
|
+
|
|
18
|
+
REMEDY: take 0.38.0, then move what is already there:
|
|
19
|
+
`node scripts/migrate-memos-private.mjs --public <bucket> --private <bucket> --prefix tenants/<t>/artifacts`
|
|
20
|
+
(copies each memo to the same key in the private bucket, confirms it arrived, then deletes the
|
|
21
|
+
public one; safe to re-run; `--dry-run` lists). Until a memo is moved the package reads it from the
|
|
22
|
+
public bucket, so nothing stops playing during the move. The same unsigned GET then answers 403.
|
|
23
|
+
|
|
24
|
+
Tests: `assets.test.ts` (memos sign, size, read and play in the private bucket and never touch the
|
|
25
|
+
public one; an unmoved memo is still read; no private bucket, no memo methods),
|
|
26
|
+
`migrate-memos.test.ts` (moves only memos, never deletes a public memo whose copy did not arrive,
|
|
27
|
+
dry run, same bucket refused).
|
|
28
|
+
|
|
6
29
|
## 0.37.0
|
|
7
30
|
|
|
8
31
|
**A broadcast is its own page kind.** `template: broadcast` in front matter makes a page one
|
package/README.md
CHANGED
|
@@ -673,12 +673,13 @@ recognition, the words appear in the box as the reader speaks. A memo stops at t
|
|
|
673
673
|
|
|
674
674
|
Recordings go up through the same `uploads/<name>` route the publisher uses, to
|
|
675
675
|
`<page>/comments/<memo>.<ext>` or, for a signed-in reader's personal note,
|
|
676
|
-
`<page>/personal/<dir>/<memo>.<ext
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
676
|
+
`<page>/personal/<dir>/<memo>.<ext>`, in the PRIVATE bucket (`privateBucket`, the same one
|
|
677
|
+
`media: private` uses). They are never made public: playback is a signed URL, valid about an hour,
|
|
678
|
+
issued beside the comment or note it belongs to, and an unsigned GET is refused (403). That needs the
|
|
679
|
+
four audio methods `createArtifactAssets` provides (`signAudioUpload`, `audioSize`, `readAudio`,
|
|
680
|
+
`audioUrl`), which it defines only when it is given a private bucket; a host without one, or with
|
|
681
|
+
its own `ArtifactAssets` and none of them, keeps memos on the reader's device. Memos recorded before
|
|
682
|
+
0.38.0 sat in the public bucket: `scripts/migrate-memos-private.mjs` moves them (see CHANGELOG).
|
|
682
683
|
|
|
683
684
|
To transcribe on the server, pass `transcribe` and add the route. The key stays on the server;
|
|
684
685
|
without `transcribe` the route answers 404 and readers keep the browser's live transcript (or an
|
package/lib/artifacts/assets.js
CHANGED
|
@@ -108,12 +108,44 @@ export function createArtifactAssets(bucket, prefix, options = {}) {
|
|
|
108
108
|
}
|
|
109
109
|
return `https://storage.googleapis.com/${bucket.name}/${key}`;
|
|
110
110
|
},
|
|
111
|
+
async deletePage(id) {
|
|
112
|
+
// An empty or path-shaped id would make the prefix wider than one page.
|
|
113
|
+
if (!ARTIFACT_ID_RE.test(id))
|
|
114
|
+
throw new Error('not an artifact id');
|
|
115
|
+
let n = 0;
|
|
116
|
+
for (const b of priv ? [bucket, priv] : [bucket]) {
|
|
117
|
+
const [files] = await b.getFiles({ prefix: `${clean}/${id}/` });
|
|
118
|
+
for (const f of files)
|
|
119
|
+
await f.delete();
|
|
120
|
+
n += files.length;
|
|
121
|
+
}
|
|
122
|
+
return n;
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
if (!priv)
|
|
126
|
+
return assets;
|
|
127
|
+
const privateFile = (id, stored) => priv.file(`${clean}/${id}/private/${stored}`);
|
|
128
|
+
// READERS' VOICE MEMOS LIVE IN THE PRIVATE BUCKET (0.38.0). Until then they went to the public
|
|
129
|
+
// bucket, unlisted and read through signed URLs, but a bucket-wide public read grant served any
|
|
130
|
+
// memo to anyone holding its path, unsigned. A memo recorded before 0.38.0 is still read from the
|
|
131
|
+
// public bucket until scripts/migrate-memos-private.mjs moves it; nothing new is written there.
|
|
132
|
+
const memo = (id, path) => priv.file(`${clean}/${id}/${path}`);
|
|
133
|
+
const legacyMemo = (id, path) => bucket.file(`${clean}/${id}/${path}`);
|
|
134
|
+
const memoFile = async (id, path) => {
|
|
135
|
+
const f = memo(id, path);
|
|
136
|
+
if ((await f.exists())[0])
|
|
137
|
+
return f;
|
|
138
|
+
const old = legacyMemo(id, path);
|
|
139
|
+
return (await old.exists())[0] ? old : null;
|
|
140
|
+
};
|
|
141
|
+
return {
|
|
142
|
+
...assets,
|
|
111
143
|
// The size cap rides in the signature (`x-goog-content-length-range`), so the bucket itself
|
|
112
144
|
// refuses an oversize body; the browser must send the returned headers verbatim.
|
|
113
145
|
async signAudioUpload(id, path, contentType) {
|
|
114
146
|
const range = `0,${MAX_AUDIO_BYTES}`;
|
|
115
147
|
const headers = { 'content-type': contentType, 'cache-control': 'private, max-age=3600', 'x-goog-content-length-range': range };
|
|
116
|
-
const [uploadUrl] = await
|
|
148
|
+
const [uploadUrl] = await memo(id, path).getSignedUrl({
|
|
117
149
|
version: 'v4',
|
|
118
150
|
action: 'write',
|
|
119
151
|
expires: Date.now() + 15 * 60 * 1000,
|
|
@@ -123,44 +155,24 @@ export function createArtifactAssets(bucket, prefix, options = {}) {
|
|
|
123
155
|
return { uploadUrl, headers };
|
|
124
156
|
},
|
|
125
157
|
async audioSize(id, path) {
|
|
126
|
-
const file =
|
|
127
|
-
|
|
128
|
-
if (!there)
|
|
158
|
+
const file = await memoFile(id, path);
|
|
159
|
+
if (!file)
|
|
129
160
|
return null;
|
|
130
161
|
const [meta] = await file.getMetadata();
|
|
131
162
|
return Number(meta.size ?? 0);
|
|
132
163
|
},
|
|
133
164
|
async readAudio(id, path) {
|
|
134
|
-
const file =
|
|
135
|
-
|
|
136
|
-
if (!there)
|
|
165
|
+
const file = await memoFile(id, path);
|
|
166
|
+
if (!file)
|
|
137
167
|
return null;
|
|
138
168
|
const [bytes] = await file.download();
|
|
139
169
|
return bytes;
|
|
140
170
|
},
|
|
141
171
|
async audioUrl(id, path) {
|
|
142
|
-
const
|
|
172
|
+
const file = (await memoFile(id, path)) ?? memo(id, path);
|
|
173
|
+
const [url] = await file.getSignedUrl({ version: 'v4', action: 'read', expires: Date.now() + 60 * 60 * 1000 });
|
|
143
174
|
return url;
|
|
144
175
|
},
|
|
145
|
-
async deletePage(id) {
|
|
146
|
-
// An empty or path-shaped id would make the prefix wider than one page.
|
|
147
|
-
if (!ARTIFACT_ID_RE.test(id))
|
|
148
|
-
throw new Error('not an artifact id');
|
|
149
|
-
let n = 0;
|
|
150
|
-
for (const b of priv ? [bucket, priv] : [bucket]) {
|
|
151
|
-
const [files] = await b.getFiles({ prefix: `${clean}/${id}/` });
|
|
152
|
-
for (const f of files)
|
|
153
|
-
await f.delete();
|
|
154
|
-
n += files.length;
|
|
155
|
-
}
|
|
156
|
-
return n;
|
|
157
|
-
},
|
|
158
|
-
};
|
|
159
|
-
if (!priv)
|
|
160
|
-
return assets;
|
|
161
|
-
const privateFile = (id, stored) => priv.file(`${clean}/${id}/private/${stored}`);
|
|
162
|
-
return {
|
|
163
|
-
...assets,
|
|
164
176
|
async putPrivate(id, name, bytes, contentType) {
|
|
165
177
|
const stored = hashedName(name, bytes);
|
|
166
178
|
await privateFile(id, stored).save(bytes, { contentType, resumable: false, metadata: { cacheControl: 'private, max-age=3600' } });
|
package/lib/artifacts/index.d.ts
CHANGED
|
@@ -26,3 +26,4 @@ export { settleOnPublish, applySettingsPatch, withComments, declaredOf, password
|
|
|
26
26
|
export type { PageSettings, SettingsFact, AccessLevel } from '../reader/settings-sheet.js';
|
|
27
27
|
export { ridLink, parseRidParam, verifyRid, isUnfurlAgent, usableSecret, RECIPIENT_SECRET_MIN } from './recipients.js';
|
|
28
28
|
export { createRecipientsStore, memoryRecipientsStore, summarizeViews, type RecipientsStore, type Recipient, type RecipientsRecord, type ViewDoc, type RecipientSummary } from './recipients-store.js';
|
|
29
|
+
export { migrateMemosToPrivate, MEMO_KEY, type MemoMigration } from './migrate-memos.js';
|
package/lib/artifacts/index.js
CHANGED
|
@@ -26,3 +26,4 @@ export { settleOnPublish, applySettingsPatch, withComments, declaredOf, password
|
|
|
26
26
|
// Per-reader links: the host-facing surface only. The signing and token primitives stay internal.
|
|
27
27
|
export { ridLink, parseRidParam, verifyRid, isUnfurlAgent, usableSecret, RECIPIENT_SECRET_MIN } from './recipients.js';
|
|
28
28
|
export { createRecipientsStore, memoryRecipientsStore, summarizeViews } from './recipients-store.js';
|
|
29
|
+
export { migrateMemosToPrivate, MEMO_KEY } from './migrate-memos.js';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Bucket } from '@google-cloud/storage';
|
|
2
|
+
/** A reader's memo, by key under the prefix: `<id>/comments/<memo>.<ext>` or `<id>/personal/<dir>/<memo>.<ext>`. */
|
|
3
|
+
export declare const MEMO_KEY: RegExp;
|
|
4
|
+
export interface MemoMigration {
|
|
5
|
+
moved: string[];
|
|
6
|
+
alreadyPrivate: string[];
|
|
7
|
+
failed: {
|
|
8
|
+
key: string;
|
|
9
|
+
error: string;
|
|
10
|
+
}[];
|
|
11
|
+
}
|
|
12
|
+
export declare function migrateMemosToPrivate(bucket: Bucket, privateBucket: Bucket, prefix: string, { dryRun }?: {
|
|
13
|
+
dryRun?: boolean | undefined;
|
|
14
|
+
}): Promise<MemoMigration>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** A reader's memo, by key under the prefix: `<id>/comments/<memo>.<ext>` or `<id>/personal/<dir>/<memo>.<ext>`. */
|
|
2
|
+
export const MEMO_KEY = /^[^/]+\/(?:comments\/[^/]+|personal\/[^/]+\/[^/]+)\.(?:webm|m4a|mp4|ogg|wav)$/;
|
|
3
|
+
export async function migrateMemosToPrivate(bucket, privateBucket, prefix, { dryRun = false } = {}) {
|
|
4
|
+
if (privateBucket.name === bucket.name)
|
|
5
|
+
throw new Error('the private bucket must be a different bucket from the public one');
|
|
6
|
+
const clean = prefix.replace(/^\/+|\/+$/g, '');
|
|
7
|
+
const [files] = await bucket.getFiles({ prefix: `${clean}/` });
|
|
8
|
+
const out = { moved: [], alreadyPrivate: [], failed: [] };
|
|
9
|
+
for (const f of files) {
|
|
10
|
+
const rel = f.name.slice(clean.length + 1);
|
|
11
|
+
if (!MEMO_KEY.test(rel))
|
|
12
|
+
continue;
|
|
13
|
+
try {
|
|
14
|
+
const dest = privateBucket.file(f.name);
|
|
15
|
+
if ((await dest.exists())[0]) {
|
|
16
|
+
if (!dryRun)
|
|
17
|
+
await f.delete();
|
|
18
|
+
out.alreadyPrivate.push(f.name);
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (dryRun) {
|
|
22
|
+
out.moved.push(f.name);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
await f.copy(dest);
|
|
26
|
+
if (!(await dest.exists())[0])
|
|
27
|
+
throw new Error('the copy did not arrive in the private bucket');
|
|
28
|
+
await f.delete();
|
|
29
|
+
out.moved.push(f.name);
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
out.failed.push({ key: f.name, error: e.message });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/artifacts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"description": "Publish markdown as branded, read-aloud pages from a Next.js app: the artifacts store, the route factory, the read-along reader, brand packs, password and confidential pages, and share cards.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|