@supersuit/artifacts 0.36.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 +63 -0
- package/README.md +62 -7
- package/lib/artifacts/assets.js +39 -27
- package/lib/artifacts/front-matter.d.ts +14 -2
- package/lib/artifacts/front-matter.js +38 -5
- package/lib/artifacts/index.d.ts +3 -2
- package/lib/artifacts/index.js +3 -2
- package/lib/artifacts/migrate-memos.d.ts +14 -0
- package/lib/artifacts/migrate-memos.js +36 -0
- package/lib/artifacts/store.d.ts +25 -2
- package/lib/artifacts/store.js +15 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/lib/routes/artifacts.d.ts +35 -0
- package/lib/routes/artifacts.js +162 -13
- package/lib/routes/recipients-routes.d.ts +1 -0
- package/lib/routes/recipients-routes.js +15 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,69 @@
|
|
|
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
|
+
|
|
29
|
+
## 0.37.0
|
|
30
|
+
|
|
31
|
+
**A broadcast is its own page kind.** `template: broadcast` in front matter makes a page one
|
|
32
|
+
message sent to several people, each through their own link, instead of a document with extra
|
|
33
|
+
lines. Documents are unchanged: an ordinary page renders byte for byte as before (the golden render
|
|
34
|
+
test is untouched) and lists as it always would have.
|
|
35
|
+
|
|
36
|
+
- The message card: the sender's words, then the media in the order written (a video with sound
|
|
37
|
+
and controls, photos, a voice note's player, link cards), then the reader's own note. No table of
|
|
38
|
+
contents, no version line, no read-along player, no page menu for a reader.
|
|
39
|
+
- The kind's defaults: per-reader links implied; private media implied wherever the body has a
|
|
40
|
+
stored private file or a local one (a remote URL alone stores nothing); the kind is sticky like
|
|
41
|
+
`links:` (a republish leaving `template:` out stays a broadcast, `template: document` is refused
|
|
42
|
+
with 409); `access`, `password` and `comments_visible: readers` refused in words; comments off unless
|
|
43
|
+
the file turns them on; no narration player (a voice note is its own audio). A body pointing at a
|
|
44
|
+
local file without `media: private` is refused, because that line is what makes the publisher
|
|
45
|
+
upload it privately.
|
|
46
|
+
- Expiry: `POST /api/artifacts` gives a broadcast with no date one 90 days out (`BROADCAST_TTL_MS`)
|
|
47
|
+
and answers it as `expireAt`. A date already set stands; Keep holds.
|
|
48
|
+
- `GET /api/artifacts?kind=broadcast|document` (`LIST`, publish key or the owner or an admin signed
|
|
49
|
+
in): the owner's two lists. A broadcast's line carries `readers` (live links) and `revoked`; a
|
|
50
|
+
recipients store that fails fails the list. `ArtifactStore.list(kind)` (optional; the Firestore store has it),
|
|
51
|
+
`kindOf`, `listOfKind`, `ARTIFACT_KINDS`.
|
|
52
|
+
- An unfurl fetcher never gets a signed media URL. DETECTOR: a per-reader page fetched with a
|
|
53
|
+
preview agent's user agent rendered its private media's signed URLs into the HTML, which a
|
|
54
|
+
preview service may cache. REMEDY: none in the host; the page now renders its text only to a
|
|
55
|
+
known fetcher or a request with no agent (`readHeader`, default through `next/headers`).
|
|
56
|
+
- `DELETE /api/artifacts/<id>` removes the page's files from both buckets (`deletePage`), then its
|
|
57
|
+
readers, their notes and views and every comment on it, before the record. DETECTOR: a deleted
|
|
58
|
+
page's media stayed in the bucket, and a kept broadcast's per-person notes and comments outlived
|
|
59
|
+
it. REMEDY: none; a failure to remove either answers 502 and keeps the record so the delete can be run again. An id that is not a
|
|
60
|
+
page id is refused before the bucket is touched.
|
|
61
|
+
- `shutPageResponse(request)`: a revoked or expired per-reader link answers 410 Gone with the same
|
|
62
|
+
quiet words. DETECTOR: a revoked link answered 200. REMEDY: call it from your `proxy.ts` (README,
|
|
63
|
+
Broadcasts: skip requests with no link or rid cookie before loading anything, and catch every
|
|
64
|
+
failure); without it the page still says the same words with a 200.
|
|
65
|
+
- Documents: the one output change is the bot rule above, on per-reader documents only.
|
|
66
|
+
- TESTS: `src/routes/broadcast.test.tsx`, `src/artifacts/store.test.ts` (listing one kind),
|
|
67
|
+
`src/artifacts/index.test.ts`, `src/artifacts/front-matter.test.ts`.
|
|
68
|
+
|
|
6
69
|
## 0.36.0
|
|
7
70
|
|
|
8
71
|
**Private media moves to its own private bucket, and a host without one refuses it.** 0.35.0 kept
|
package/README.md
CHANGED
|
@@ -254,7 +254,7 @@ summary: Three bets, what each costs, and what we stop doing to afford them.
|
|
|
254
254
|
| `links` | `per-reader`: the page opens only through a link made for one reader (see Per-reader links). Sticky: a republish whose file leaves the line out changes nothing |
|
|
255
255
|
| `media` | `private`: the page's body media is kept private and served through short-lived signed URLs. Needs `links: per-reader`. Sticky like `links` |
|
|
256
256
|
| `change` | One line saying what this version changed (see Version history) |
|
|
257
|
-
| `template` | `document
|
|
257
|
+
| `template` | `document` (the default, the read-along reader) or `broadcast` (one message card sent to several people; see Broadcasts) |
|
|
258
258
|
|
|
259
259
|
An unknown key is refused, so a typo fails loudly instead of being ignored.
|
|
260
260
|
|
|
@@ -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
|
|
@@ -878,6 +879,60 @@ A view is the page rendered in a real browser, visible, for at least 2 seconds.
|
|
|
878
879
|
|
|
879
880
|
`expires` sets the date the page, its media, its readers, their views and their comments go. `sweepExpired()` (call it daily from a cron route) deletes them and returns `{ pages, files, skipped, failed }`: pages removed, media files removed, ids it refused to delete (an expiry that is not a past ISO time, or a page republished without per-reader links), and ids that failed and will be retried by the next run. Your `assets` must provide `deletePage`, or every expired page lands in `failed` and nothing is deleted. With a private bucket configured it deletes from both buckets. A Keep that lands mid-sweep is honored. Deleting a page also removes every prior version of its body.
|
|
880
881
|
|
|
882
|
+
## Broadcasts (`template: broadcast`)
|
|
883
|
+
|
|
884
|
+
A broadcast is its own page kind, not a document with extra flags. It is one message sent to several people, each through their own link, and it renders as one card: the sender's words at the top, then the media in the order the words put it (a video with sound and controls, photos, a voice note's player, link cards), then that person's own note. None of the document's furniture: no table of contents, no version line, no read-along, and no page menu for a reader.
|
|
885
|
+
|
|
886
|
+
```markdown
|
|
887
|
+
---
|
|
888
|
+
title: At the lake
|
|
889
|
+
summary: We finally made it up there.
|
|
890
|
+
template: broadcast
|
|
891
|
+
links: per-reader
|
|
892
|
+
media: private
|
|
893
|
+
voice: none
|
|
894
|
+
---
|
|
895
|
+
We finally made it up there.
|
|
896
|
+
|
|
897
|
+

|
|
898
|
+
```
|
|
899
|
+
|
|
900
|
+
What the kind decides, so the file does not have to:
|
|
901
|
+
|
|
902
|
+
- **Per-reader links, always.** `links: per-reader` is implied. The kind is sticky like `links:`: a republish that leaves `template:` out stays a broadcast, and one that says `document` is refused (409). `access`, `password` and `comments_visible: readers` are refused in words.
|
|
903
|
+
- **Private media wherever there is media.** `media: private` is implied as soon as the body carries a stored private file or a local one (a remote URL alone stores nothing, so it does not count). A body that points at a local file (one the publisher will upload) must still say `media: private`, because that line is what makes the publisher upload it privately; without it the page is refused before anything is uploaded.
|
|
904
|
+
- **Expires 90 days after it is published** (`BROADCAST_TTL_MS`), set by `POST /api/artifacts` when the page has no date yet, and answered as `expireAt` in its response. A date set on the recipients route stands, a republish never moves it, and Keep (`expires: null`) holds.
|
|
905
|
+
- **Comments off** unless the file turns them on. **No narration player**, ever: a voice note is its own audio (`` draws a player). Write `voice: none` so the publisher does not record one.
|
|
906
|
+
- **Silent view tracking**: the quiet view beacon, as on every per-reader page.
|
|
907
|
+
|
|
908
|
+
Routes:
|
|
909
|
+
|
|
910
|
+
| Route | Who | What |
|
|
911
|
+
|---|---|---|
|
|
912
|
+
| `GET /api/artifacts?kind=broadcast` | publish key, or the owner or an admin signed in | Every broadcast, newest first: id, title, summary, url, dates, `expireAt`, `keep`, `readers` (people whose link still works) and `revoked`. `?kind=document` (the default) lists documents, never a broadcast |
|
|
913
|
+
| `shutPageResponse(request)` | your proxy | A per-reader page whose link was revoked or whose date passed answers **410 Gone** with the quiet words the page shows. Null for everything else, which the proxy passes on. Reads nothing unless the request carries a reader's link or its cookie |
|
|
914
|
+
|
|
915
|
+
```ts
|
|
916
|
+
// proxy.ts
|
|
917
|
+
import { NextResponse, type NextRequest } from 'next/server'
|
|
918
|
+
export async function proxy(request: NextRequest) {
|
|
919
|
+
// Only a request carrying a reader's link or its cookie can be shut; skip the rest cheaply.
|
|
920
|
+
const hasLink = request.nextUrl.searchParams.has('r') || request.cookies.getAll().some((c) => c.name.startsWith('artifact_r_'))
|
|
921
|
+
if (!hasLink) return NextResponse.next()
|
|
922
|
+
try {
|
|
923
|
+
const { routes } = await import('./routes') // your createArtifactRoutes(...)
|
|
924
|
+
const shut = await routes.shutPageResponse(request)
|
|
925
|
+
if (shut) return shut
|
|
926
|
+
} catch {
|
|
927
|
+
// A store outage never costs a reader the page: the page applies the same gate itself.
|
|
928
|
+
}
|
|
929
|
+
return NextResponse.next()
|
|
930
|
+
}
|
|
931
|
+
export const config = { matcher: ['/:id([a-z0-9]{8})'] }
|
|
932
|
+
```
|
|
933
|
+
|
|
934
|
+
Any per-reader page served to a known unfurl fetcher (or a request with no user agent) carries its text only: no media, no signed URL, no reader's note, no cookie. `readHeader` on `createArtifactRoutes` reads the agent (default through `next/headers`). `DELETE /api/artifacts/<id>` removes the page's files from both buckets (`deletePage`), then its readers, their notes and views and every comment, before the record, and keeps the record (502) if the files could not go, so it can be run again.
|
|
935
|
+
|
|
881
936
|
## Brand packs
|
|
882
937
|
|
|
883
938
|
A `BrandPack` is data plus at most two components: colours, type, the kicker line above a title,
|
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' } });
|
|
@@ -6,7 +6,9 @@ import { type Definition } from './definitions.js';
|
|
|
6
6
|
export type ArtifactMeta = {
|
|
7
7
|
title: string;
|
|
8
8
|
summary: string;
|
|
9
|
-
|
|
9
|
+
/** The page kind. `document` (the default) is the read-along reader; `broadcast` is one message
|
|
10
|
+
* card sent to several people, each through their own link (0.37.0). */
|
|
11
|
+
template: ArtifactKind;
|
|
10
12
|
/** A line under the title. Title, subtitle and summary are three separate fields; the summary
|
|
11
13
|
* is the teaser, and the unfurl carries the title and the summary. */
|
|
12
14
|
subtitle?: string;
|
|
@@ -59,11 +61,21 @@ export type ArtifactMeta = {
|
|
|
59
61
|
/** Who sees shared comments: `owner` (the default) or every reader (`readers`). */
|
|
60
62
|
commentsVisible?: CommentsVisible;
|
|
61
63
|
};
|
|
64
|
+
/** What a page is. A broadcast is its own kind, not a document with extra flags: it renders as a
|
|
65
|
+
* message card, opens only through per-reader links, keeps its media private, has comments off
|
|
66
|
+
* and no narration by default, and expires 90 days after publish unless kept. */
|
|
67
|
+
export type ArtifactKind = 'document' | 'broadcast';
|
|
68
|
+
export declare const ARTIFACT_KINDS: readonly ArtifactKind[];
|
|
62
69
|
/** A pack name, as a host registers one. */
|
|
63
70
|
export declare const PACK_NAME: RegExp;
|
|
64
71
|
/** The longest `to:` a page may carry: a name or two, never a sentence. */
|
|
65
72
|
export declare const MAX_TO_CHARS = 60;
|
|
66
|
-
export declare function parseArtifactSource(text: string
|
|
73
|
+
export declare function parseArtifactSource(text: string,
|
|
74
|
+
/** The kind a file that leaves `template:` out is read as: the stored page's kind on a
|
|
75
|
+
* republish, so a broadcast stays one (sticky, like `links:`). Default `document`. */
|
|
76
|
+
opts?: {
|
|
77
|
+
defaultKind?: ArtifactKind;
|
|
78
|
+
}): {
|
|
67
79
|
ok: true;
|
|
68
80
|
meta: ArtifactMeta;
|
|
69
81
|
body: string;
|
|
@@ -7,12 +7,23 @@ import { mergeChecklistState, mergeWidgetState, scanWidgets } from './widgets.js
|
|
|
7
7
|
import { COMMENTS_MODES, mergeCommentsState } from './comments.js';
|
|
8
8
|
import { THEME_MODES } from '../brand/theme.js';
|
|
9
9
|
import { parseDefinitions } from './definitions.js';
|
|
10
|
+
export const ARTIFACT_KINDS = ['document', 'broadcast'];
|
|
11
|
+
/** Any media in a body: an image, a video or an audio clip, written as markdown image syntax. */
|
|
12
|
+
const MEDIA_IN_BODY = /!\[[^\]]*\]\(\s*<?([^)\s>]+)/g;
|
|
13
|
+
/** A reference the publisher uploads before the page is finished: neither a URL, a site path nor
|
|
14
|
+
* an already-stored private file. */
|
|
15
|
+
const LOCAL_REF = /^(?![a-z][a-z0-9+.-]*:|\/|#)/i;
|
|
16
|
+
/** A file already stored in the page's private store. */
|
|
17
|
+
const PRIVATE_REF_IN_BODY = /^private:/;
|
|
10
18
|
/** A pack name, as a host registers one. */
|
|
11
19
|
export const PACK_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
12
20
|
/** The longest `to:` a page may carry: a name or two, never a sentence. */
|
|
13
21
|
export const MAX_TO_CHARS = 60;
|
|
14
22
|
const KNOWN = new Set(['title', 'summary', 'subtitle', 'to', 'template', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password', 'access', 'state', 'theme', 'pack', 'toc', 'definitions', 'change', 'comments', 'comments_visible', 'links', 'media']);
|
|
15
|
-
export function parseArtifactSource(text
|
|
23
|
+
export function parseArtifactSource(text,
|
|
24
|
+
/** The kind a file that leaves `template:` out is read as: the stored page's kind on a
|
|
25
|
+
* republish, so a broadcast stays one (sticky, like `links:`). Default `document`. */
|
|
26
|
+
opts = {}) {
|
|
16
27
|
const { data, content } = matter(text);
|
|
17
28
|
const d = data;
|
|
18
29
|
for (const k of Object.keys(d)) {
|
|
@@ -24,14 +35,24 @@ export function parseArtifactSource(text) {
|
|
|
24
35
|
if (typeof v !== 'string' || !v.trim())
|
|
25
36
|
return { ok: false, error: `front matter is missing ${k}` };
|
|
26
37
|
}
|
|
27
|
-
const template = d.template ?? 'document';
|
|
28
|
-
if (template
|
|
29
|
-
return { ok: false, error: 'template must be document' };
|
|
38
|
+
const template = d.template ?? opts.defaultKind ?? 'document';
|
|
39
|
+
if (!ARTIFACT_KINDS.includes(template))
|
|
40
|
+
return { ok: false, error: 'template must be document or broadcast' };
|
|
30
41
|
const meta = {
|
|
31
42
|
title: String(d.title).trim(),
|
|
32
43
|
summary: String(d.summary).trim(),
|
|
33
|
-
template:
|
|
44
|
+
template: template,
|
|
34
45
|
};
|
|
46
|
+
// A broadcast opens only through each person's own link: a door or a shared thread of comments
|
|
47
|
+
// would make it something else, so those lines are refused in words rather than dropped.
|
|
48
|
+
if (meta.template === 'broadcast') {
|
|
49
|
+
if (d.access !== undefined && d.access !== null)
|
|
50
|
+
return { ok: false, error: 'a broadcast opens only through each person\'s own link: drop access' };
|
|
51
|
+
if (d.password !== undefined && d.password !== null)
|
|
52
|
+
return { ok: false, error: 'a broadcast opens only through each person\'s own link: drop password' };
|
|
53
|
+
if (d.comments_visible === 'readers')
|
|
54
|
+
return { ok: false, error: 'a broadcast keeps each person\'s comments to the owner: drop comments_visible: readers' };
|
|
55
|
+
}
|
|
35
56
|
for (const k of ['subtitle', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password']) {
|
|
36
57
|
const v = d[k];
|
|
37
58
|
if (typeof v === 'string' && v)
|
|
@@ -106,6 +127,18 @@ export function parseArtifactSource(text) {
|
|
|
106
127
|
return { ok: false, error: 'comments_visible must be owner or readers' };
|
|
107
128
|
meta.commentsVisible = d.comments_visible;
|
|
108
129
|
}
|
|
130
|
+
if (meta.template === 'broadcast') {
|
|
131
|
+
// Per-reader links are what a broadcast is, whatever the file says or leaves out.
|
|
132
|
+
meta.links = 'per-reader';
|
|
133
|
+
const refs = [...content.matchAll(MEDIA_IN_BODY)].map((m) => m[1]).filter((r) => LOCAL_REF.test(r) || PRIVATE_REF_IN_BODY.test(r));
|
|
134
|
+
// The publisher uploads a local file privately only when the file itself says `media: private`,
|
|
135
|
+
// so a broadcast that would send one up in public is refused before anything is uploaded.
|
|
136
|
+
if (!meta.privateMedia && refs.some((r) => LOCAL_REF.test(r)))
|
|
137
|
+
return { ok: false, error: 'a broadcast keeps its media private: add media: private so the publisher uploads it privately' };
|
|
138
|
+
// A remote URL alone stores nothing here, so only a stored private file or a local one counts.
|
|
139
|
+
if (refs.length)
|
|
140
|
+
meta.privateMedia = true;
|
|
141
|
+
}
|
|
109
142
|
// Only the per-reader page resolves private media, so on any other page it would ship broken.
|
|
110
143
|
if (meta.privateMedia && meta.links !== 'per-reader')
|
|
111
144
|
return { ok: false, error: 'media: private needs links: per-reader' };
|
package/lib/artifacts/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
export { parseArtifactSource, type ArtifactMeta } from './front-matter.js';
|
|
1
|
+
export { parseArtifactSource, ARTIFACT_KINDS, type ArtifactMeta, type ArtifactKind } from './front-matter.js';
|
|
2
2
|
export { ArtifactMarkdown, artifactUrlTransform } from './render.js';
|
|
3
3
|
export { narrationText, narrationTextFromMarkdown, normalizeWord } from './narration.js';
|
|
4
|
-
export { createArtifactStore, newArtifactId, cleanNote, MAX_CHANGE_NOTE_CHARS, type ArtifactStore, type ArtifactRecord, type SaveResult, type SaveInput, type VersionEntry, type VersionRecord } from './store.js';
|
|
4
|
+
export { createArtifactStore, newArtifactId, cleanNote, kindOf, listOfKind, MAX_CHANGE_NOTE_CHARS, type ArtifactListItem, type ArtifactStore, type ArtifactRecord, type SaveResult, type SaveInput, type VersionEntry, type VersionRecord } from './store.js';
|
|
5
5
|
export { isPublishAuthed, isPublisherAuthed, type PublishAuth } from './auth.js';
|
|
6
6
|
export { mintPublisherPass, verifyPublisherPass, PUBLISHER_PASS_TTL_SECONDS, type Publisher } from './publisher.js';
|
|
7
7
|
export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAME, ASSET_DIGEST, PRIVATE_READ_TTL_MS, type ArtifactAssets, type ArtifactAssetsOptions } from './assets.js';
|
|
@@ -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
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
export { parseArtifactSource } from './front-matter.js';
|
|
1
|
+
export { parseArtifactSource, ARTIFACT_KINDS } from './front-matter.js';
|
|
2
2
|
export { ArtifactMarkdown, artifactUrlTransform } from './render.js';
|
|
3
3
|
export { narrationText, narrationTextFromMarkdown, normalizeWord } from './narration.js';
|
|
4
|
-
export { createArtifactStore, newArtifactId, cleanNote, MAX_CHANGE_NOTE_CHARS } from './store.js';
|
|
4
|
+
export { createArtifactStore, newArtifactId, cleanNote, kindOf, listOfKind, MAX_CHANGE_NOTE_CHARS } from './store.js';
|
|
5
5
|
export { isPublishAuthed, isPublisherAuthed } from './auth.js';
|
|
6
6
|
export { mintPublisherPass, verifyPublisherPass, PUBLISHER_PASS_TTL_SECONDS } from './publisher.js';
|
|
7
7
|
export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAME, ASSET_DIGEST, PRIVATE_READ_TTL_MS } from './assets.js';
|
|
@@ -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/lib/artifacts/store.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Firestore } from 'firebase-admin/firestore';
|
|
2
|
-
import type { ArtifactMeta } from './front-matter.js';
|
|
2
|
+
import type { ArtifactKind, ArtifactMeta } from './front-matter.js';
|
|
3
3
|
import type { Access } from './reader.js';
|
|
4
4
|
import type { StateConfig } from './state.js';
|
|
5
5
|
import type { ThemeMode } from '../brand/theme.js';
|
|
@@ -10,7 +10,8 @@ export type ArtifactRecord = {
|
|
|
10
10
|
id: string;
|
|
11
11
|
title: string;
|
|
12
12
|
summary: string;
|
|
13
|
-
template
|
|
13
|
+
/** The page kind; see ArtifactMeta.template. Pages saved before kinds existed carry `document`. */
|
|
14
|
+
template: ArtifactKind;
|
|
14
15
|
subtitle?: string;
|
|
15
16
|
/** Who the page is for; see ArtifactMeta.to. */
|
|
16
17
|
to?: string;
|
|
@@ -74,6 +75,25 @@ export type VersionRecord = VersionEntry & {
|
|
|
74
75
|
export declare const MAX_CHANGE_NOTE_CHARS = 280;
|
|
75
76
|
export declare function cleanNote(note: unknown): string | undefined;
|
|
76
77
|
export declare function newArtifactId(): string;
|
|
78
|
+
/** One line of the owner's list of pages: no body, no settings. */
|
|
79
|
+
export type ArtifactListItem = {
|
|
80
|
+
id: string;
|
|
81
|
+
title: string;
|
|
82
|
+
summary: string;
|
|
83
|
+
template: ArtifactKind;
|
|
84
|
+
createdAt: string;
|
|
85
|
+
updatedAt: string;
|
|
86
|
+
views: number;
|
|
87
|
+
};
|
|
88
|
+
/** The kind a stored page is: anything not saved as a broadcast is a document, so pages saved
|
|
89
|
+
* before kinds existed list where they always would have. */
|
|
90
|
+
export declare const kindOf: (a: {
|
|
91
|
+
template?: string;
|
|
92
|
+
}) => ArtifactKind;
|
|
93
|
+
/** The owner's list of one kind, newest first: the same rule every store applies. */
|
|
94
|
+
export declare function listOfKind(pages: Iterable<Partial<ArtifactRecord> & {
|
|
95
|
+
id: string;
|
|
96
|
+
}>, kind: ArtifactKind): ArtifactListItem[];
|
|
77
97
|
export type SaveResult = {
|
|
78
98
|
id: string;
|
|
79
99
|
version: number;
|
|
@@ -107,6 +127,9 @@ export interface ArtifactStore {
|
|
|
107
127
|
version?(id: string, n: number): Promise<VersionRecord | null>;
|
|
108
128
|
/** Write (or, with null, clear) the change note on one version after the fact. */
|
|
109
129
|
setNote?(id: string, n: number, note: string | null): Promise<boolean>;
|
|
130
|
+
/** Every page of one kind, newest first, without bodies (0.37.0): the owner's documents and the
|
|
131
|
+
* owner's broadcasts are two lists. */
|
|
132
|
+
list?(kind: ArtifactKind): Promise<ArtifactListItem[]>;
|
|
110
133
|
}
|
|
111
134
|
/** The Firestore-backed store. The instance hands in its own Firestore, so the shell
|
|
112
135
|
* never knows which project it is writing to; a hosted tenant passes a namespaced one. */
|
package/lib/artifacts/store.js
CHANGED
|
@@ -26,6 +26,16 @@ export function newArtifactId() {
|
|
|
26
26
|
s += ALPHABET[randomInt(ALPHABET.length)];
|
|
27
27
|
return s;
|
|
28
28
|
}
|
|
29
|
+
/** The kind a stored page is: anything not saved as a broadcast is a document, so pages saved
|
|
30
|
+
* before kinds existed list where they always would have. */
|
|
31
|
+
export const kindOf = (a) => (a.template === 'broadcast' ? 'broadcast' : 'document');
|
|
32
|
+
/** The owner's list of one kind, newest first: the same rule every store applies. */
|
|
33
|
+
export function listOfKind(pages, kind) {
|
|
34
|
+
return [...pages]
|
|
35
|
+
.filter((p) => kindOf(p) === kind)
|
|
36
|
+
.map((p) => ({ id: p.id, title: p.title ?? '', summary: p.summary ?? '', template: kind, createdAt: p.createdAt ?? '', updatedAt: p.updatedAt ?? '', views: p.views ?? 0 }))
|
|
37
|
+
.sort((x, y) => y.updatedAt.localeCompare(x.updatedAt) || x.id.localeCompare(y.id));
|
|
38
|
+
}
|
|
29
39
|
/** The Firestore-backed store. The instance hands in its own Firestore, so the shell
|
|
30
40
|
* never knows which project it is writing to; a hosted tenant passes a namespaced one. */
|
|
31
41
|
export function createArtifactStore(db, collection = 'artifacts') {
|
|
@@ -59,6 +69,11 @@ export function createArtifactStore(db, collection = 'artifacts') {
|
|
|
59
69
|
history: (id) => historyOf(col, id),
|
|
60
70
|
version: (id, n) => versionOf(col, id, n),
|
|
61
71
|
setNote: (id, n, note) => setNote(col, id, n, note),
|
|
72
|
+
list: async (kind) => {
|
|
73
|
+
// `select` keeps every body out of the listing; a tenant's pages number in the hundreds.
|
|
74
|
+
const snap = await col().select('title', 'summary', 'template', 'createdAt', 'updatedAt', 'views').get();
|
|
75
|
+
return listOfKind(snap.docs.map((d) => ({ ...d.data(), id: d.id })), kind);
|
|
76
|
+
},
|
|
62
77
|
};
|
|
63
78
|
}
|
|
64
79
|
const vid = (n) => String(n).padStart(6, '0');
|
package/lib/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export * from './artifacts/index.js';
|
|
2
2
|
export * from './brand/index.js';
|
|
3
3
|
export * from './gate.js';
|
|
4
|
-
export { createArtifactRoutes, type ArtifactRoutesConfig } from './routes/artifacts.js';
|
|
4
|
+
export { createArtifactRoutes, BROADCAST_TTL_MS, type ArtifactRoutesConfig } from './routes/artifacts.js';
|
|
5
5
|
export { ANON_COOKIE } from './routes/state-routes.js';
|
package/lib/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export * from './artifacts/index.js';
|
|
2
2
|
export * from './brand/index.js';
|
|
3
3
|
export * from './gate.js';
|
|
4
|
-
export { createArtifactRoutes } from './routes/artifacts.js';
|
|
4
|
+
export { createArtifactRoutes, BROADCAST_TTL_MS } from './routes/artifacts.js';
|
|
5
5
|
export { ANON_COOKIE } from './routes/state-routes.js';
|
|
@@ -53,6 +53,9 @@ export type ArtifactRoutesConfig = {
|
|
|
53
53
|
pagePrefix?: string;
|
|
54
54
|
/** Read one request cookie by name. Default reads through next/headers; tests inject one. */
|
|
55
55
|
readCookie?: (name: string) => Promise<string | undefined>;
|
|
56
|
+
/** Read one request header by name (the per-reader page reads `user-agent`, so an unfurl fetcher
|
|
57
|
+
* is never handed a signed media URL). Default reads through next/headers; tests inject one. */
|
|
58
|
+
readHeader?: (name: string) => Promise<string | undefined>;
|
|
56
59
|
/** The record for gated pages (`access:` in front matter). Without it a gated page stays shut
|
|
57
60
|
* to everyone, never open: failing closed is the only safe default for a confidential page. */
|
|
58
61
|
readers?: ReadersStore;
|
|
@@ -136,6 +139,9 @@ type VersionProps = VersionParams & {
|
|
|
136
139
|
r?: string | string[];
|
|
137
140
|
}>;
|
|
138
141
|
};
|
|
142
|
+
/** How long a broadcast lives when nobody gave it a date: 90 days from its first publish, unless
|
|
143
|
+
* the owner taps Keep (`expires: null` on the recipients route). */
|
|
144
|
+
export declare const BROADCAST_TTL_MS: number;
|
|
139
145
|
export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
|
|
140
146
|
dynamic: "force-dynamic";
|
|
141
147
|
maxDuration: number;
|
|
@@ -160,6 +166,7 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
|
|
|
160
166
|
skipped: string[];
|
|
161
167
|
failed: string[];
|
|
162
168
|
}>;
|
|
169
|
+
forgetReaders: (id: string) => Promise<void>;
|
|
163
170
|
PERSONAL_GET: (req: NextRequest, { params }: {
|
|
164
171
|
params: Promise<{
|
|
165
172
|
id: string;
|
|
@@ -210,6 +217,7 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
|
|
|
210
217
|
error: string;
|
|
211
218
|
}> | NextResponse<{
|
|
212
219
|
warning?: string | undefined;
|
|
220
|
+
expireAt?: string | null | undefined;
|
|
213
221
|
id: string;
|
|
214
222
|
url: string;
|
|
215
223
|
version: number;
|
|
@@ -224,7 +232,34 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
|
|
|
224
232
|
error: string;
|
|
225
233
|
}> | NextResponse<{
|
|
226
234
|
deleted: boolean;
|
|
235
|
+
files: number;
|
|
236
|
+
}>>;
|
|
237
|
+
LIST: (request: NextRequest) => Promise<NextResponse<{
|
|
238
|
+
error: string;
|
|
239
|
+
}> | NextResponse<{
|
|
240
|
+
kind: string;
|
|
241
|
+
pages: ({
|
|
242
|
+
views: number;
|
|
243
|
+
id: string;
|
|
244
|
+
title: string;
|
|
245
|
+
summary: string;
|
|
246
|
+
url: string;
|
|
247
|
+
createdAt: string;
|
|
248
|
+
updatedAt: string;
|
|
249
|
+
} | {
|
|
250
|
+
expireAt: string | null;
|
|
251
|
+
keep: boolean;
|
|
252
|
+
readers: number;
|
|
253
|
+
revoked: number;
|
|
254
|
+
id: string;
|
|
255
|
+
title: string;
|
|
256
|
+
summary: string;
|
|
257
|
+
url: string;
|
|
258
|
+
createdAt: string;
|
|
259
|
+
updatedAt: string;
|
|
260
|
+
})[];
|
|
227
261
|
}>>;
|
|
262
|
+
shutPageResponse: (request: NextRequest) => Promise<Response | null>;
|
|
228
263
|
PUT_ASSET: (request: NextRequest, { params }: {
|
|
229
264
|
params: Promise<{
|
|
230
265
|
id: string;
|
package/lib/routes/artifacts.js
CHANGED
|
@@ -3,13 +3,13 @@ import { revalidatePath } from 'next/cache';
|
|
|
3
3
|
import { notFound, redirect } from 'next/navigation';
|
|
4
4
|
import { NextResponse } from 'next/server';
|
|
5
5
|
import { isPublisherAuthed } from '../artifacts/auth.js';
|
|
6
|
-
import { parseArtifactSource } from '../artifacts/front-matter.js';
|
|
6
|
+
import { ARTIFACT_KINDS, parseArtifactSource } from '../artifacts/front-matter.js';
|
|
7
7
|
import { narrationText } from '../artifacts/narration.js';
|
|
8
8
|
import { ArtifactMarkdown } from '../artifacts/render.js';
|
|
9
9
|
import { CHECKLIST_SLOT } from '../artifacts/checklist.js';
|
|
10
10
|
import { ArtifactDoor } from '../artifacts/door.js';
|
|
11
11
|
import { isUnlocked, keyHash, unlockCookieName } from '../artifacts/unlock.js';
|
|
12
|
-
import { cleanNote } from '../artifacts/store.js';
|
|
12
|
+
import { cleanNote, kindOf } from '../artifacts/store.js';
|
|
13
13
|
import { effectiveWriters, shapeChanges } from '../artifacts/state.js';
|
|
14
14
|
import { COMMENTS_SLOT } from '../artifacts/comments.js';
|
|
15
15
|
import { ASSET_DIGEST, ASSET_NAME, contentTypeFor } from '../artifacts/assets.js';
|
|
@@ -22,7 +22,7 @@ import { CommentLayer } from '../reader/comment-layer.js';
|
|
|
22
22
|
import { ReaderWatch } from '../reader/reader-watch.js';
|
|
23
23
|
import { UpdatedTime } from '../reader/updated-time.js';
|
|
24
24
|
import { perReaderGate, withPrivateUrls, NO_LINK, UNAVAILABLE } from './per-reader.js';
|
|
25
|
-
import { ridCookieName, mintRenderToken, usableSecret, RID_COOKIE_MAX_AGE } from '../artifacts/recipients.js';
|
|
25
|
+
import { ridCookieName, mintRenderToken, usableSecret, isUnfurlAgent, RID_COOKIE_MAX_AGE } from '../artifacts/recipients.js';
|
|
26
26
|
import { PageMenu } from '../reader/page-menu.js';
|
|
27
27
|
import { COMMENTS_MODES } from '../artifacts/comments.js';
|
|
28
28
|
import { newPageKey } from '../artifacts/settings.js';
|
|
@@ -41,12 +41,26 @@ import { createVoiceRoutes } from './voice-routes.js';
|
|
|
41
41
|
export const ARTIFACT_ID = ARTIFACT_ID_RE;
|
|
42
42
|
/** A version number as it appears in a URL: no zero, no leading zero, so each version has one address. */
|
|
43
43
|
const VERSION_N = /^[1-9]\d{0,5}$/;
|
|
44
|
+
/** How long a broadcast lives when nobody gave it a date: 90 days from its first publish, unless
|
|
45
|
+
* the owner taps Keep (`expires: null` on the recipients route). */
|
|
46
|
+
export const BROADCAST_TTL_MS = 90 * 24 * 60 * 60 * 1000;
|
|
47
|
+
/** Media in a body, as markdown image syntax: what an unfurl fetcher's copy of a page leaves out. */
|
|
48
|
+
const MEDIA_MD = /!\[[^\]]*\]\([^)]*\)/g;
|
|
49
|
+
const withoutMedia = (md) => md.replace(MEDIA_MD, '');
|
|
50
|
+
/** A colour from a pack, only if it is plainly a colour: it is written into a raw HTML answer. */
|
|
51
|
+
const SAFE_COLOUR = /^(#[0-9a-f]{3,8}|[a-z]+|rgba?\([0-9.,\s%]+\))$/i;
|
|
44
52
|
/** What a host with no private store answers to private media, in words that say what to set up. */
|
|
45
53
|
const NO_PRIVATE_STORE = 'this host keeps no private store, so media: private is refused and nothing was published. Give createArtifactAssets a privateBucket (its own bucket, never the public one) to keep private media';
|
|
46
54
|
async function defaultReadCookie(name) {
|
|
47
55
|
const { cookies } = await import('next/headers');
|
|
48
56
|
return (await cookies()).get(name)?.value;
|
|
49
57
|
}
|
|
58
|
+
/** A header the request did not send reads as '' (so an agent that sends none is treated as a
|
|
59
|
+
* fetcher); a header that cannot be read at all, outside a request, reads as undefined. */
|
|
60
|
+
async function defaultReadHeader(name) {
|
|
61
|
+
const { headers } = await import('next/headers');
|
|
62
|
+
return (await headers()).get(name) ?? '';
|
|
63
|
+
}
|
|
50
64
|
export function createArtifactRoutes(config) {
|
|
51
65
|
const { store, siteUrl } = config;
|
|
52
66
|
// The pack as configured draws the share card; the page paints with its theme variables, so a
|
|
@@ -282,30 +296,40 @@ export function createArtifactRoutes(config) {
|
|
|
282
296
|
const render = (top, reader) => (n === null ? Body(a, { top, reader }) : VersionBody(a, n, { top, reader }));
|
|
283
297
|
if (a.access)
|
|
284
298
|
return GatedPage(a, id, render, n);
|
|
285
|
-
|
|
299
|
+
const broadcast = kindOf(a) === 'broadcast';
|
|
300
|
+
if (a.links === 'per-reader' || broadcast) {
|
|
286
301
|
if (n !== null)
|
|
287
302
|
notFound(); // a past version of a per-reader page is never served
|
|
288
303
|
const sp = (await searchParams) ?? {};
|
|
289
304
|
const param = Array.isArray(sp.r) ? sp.r[0] : sp.r;
|
|
290
305
|
const readCookie = config.readCookie ?? defaultReadCookie;
|
|
291
306
|
const privateUrl = config.assets?.privateUrl?.bind(config.assets);
|
|
307
|
+
// An unfurl fetcher (or anything sending no agent at all) gets the card's text and never a
|
|
308
|
+
// signed media URL: a preview service may cache what it fetched long after a link is revoked.
|
|
309
|
+
const ua = await (config.readHeader ?? defaultReadHeader)('user-agent').catch(() => undefined);
|
|
310
|
+
const bot = ua !== undefined && isUnfurlAgent(ua);
|
|
311
|
+
const media = async (md) => (bot ? withoutMedia(md) : a.privateMedia ? await withPrivateUrls(md, id, privateUrl) : md);
|
|
292
312
|
// A signed-in owner or admin gets through as the owner, with no link and no tracking.
|
|
293
313
|
const me = readerSecret() ? verifyGrant(readerSecret(), await readCookie(GRANT_COOKIE).catch(() => undefined)) : null;
|
|
294
314
|
if (isAdmin(me)) {
|
|
295
|
-
const markdown =
|
|
296
|
-
|
|
315
|
+
const markdown = await media(a.markdown);
|
|
316
|
+
const page = broadcast ? await BroadcastCard({ ...a, markdown }, { reader: me, bot }) : await Body({ ...a, markdown }, { top: true, reader: me });
|
|
317
|
+
return _jsx(BrandGround, { pack: pack, mode: a.theme, children: page });
|
|
297
318
|
}
|
|
298
319
|
const secret = usableSecret(config.recipientSecret?.());
|
|
299
320
|
const g = await perReaderGate({ id, param, cookie: await readCookie(ridCookieName(id)).catch(() => undefined), secret, recipients: config.recipients });
|
|
300
321
|
if (g.state === 'shut') {
|
|
301
322
|
return (_jsx(BrandGround, { pack: pack, mode: a.theme, children: _jsx("p", { "data-per-reader-shut": true, className: "mx-auto max-w-xl px-6 pt-32 text-center text-lg opacity-80", children: g.why === 'no-link' ? NO_LINK : UNAVAILABLE }) }));
|
|
302
323
|
}
|
|
303
|
-
|
|
304
|
-
const remember = g.fromLink
|
|
324
|
+
// A fetcher is not the reader: it sets no cookie, carries no render token and sees no note.
|
|
325
|
+
const remember = g.fromLink && !bot
|
|
305
326
|
? [pagePath(id), `/api/artifacts/${id}`].map((p) => `document.cookie=${JSON.stringify(`${ridCookieName(id)}=${g.rid}.${g.sig}; Path=${p}; Max-Age=${RID_COOKIE_MAX_AGE}; SameSite=Lax; Secure`)}`).join(';')
|
|
306
327
|
: null;
|
|
307
|
-
const
|
|
308
|
-
const
|
|
328
|
+
const perReader = bot ? undefined : { rid: g.rid, sig: g.sig, label: g.label, note: g.note, owner: g.owner, token: mintRenderToken(secret, id, g.rid) };
|
|
329
|
+
const markdown = await media(a.markdown);
|
|
330
|
+
const body = broadcast
|
|
331
|
+
? await BroadcastCard({ ...a, markdown }, { reader: null, perReader, bot })
|
|
332
|
+
: await Body({ ...a, markdown }, { top: true, reader: null, perReader });
|
|
309
333
|
return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [remember ? _jsx("script", { dangerouslySetInnerHTML: { __html: remember } }) : null, body] }));
|
|
310
334
|
}
|
|
311
335
|
// A password shuts the body, never the title: the header stays so the reader knows which
|
|
@@ -420,6 +444,21 @@ export function createArtifactRoutes(config) {
|
|
|
420
444
|
const toc = showToc(v.markdown, a.toc) ? tocOf(v.markdown) : [];
|
|
421
445
|
return (_jsxs(_Fragment, { children: [_jsx(Menu, { a: a, reader: reader, history: history, viewing: n, settings: await settingsFor(a, reader), source: isAdmin(reader) ? sourceOf(a.id, { title, subtitle: v.subtitle, summary, to: v.to }, v.markdown) : null }), toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { "data-nospeak": true, className: `mx-auto max-w-2xl px-6 ${top ? 'pt-20 sm:pt-24' : 'pt-8'}`, children: _jsxs("p", { "data-version-banner": true, role: "status", className: "rounded-lg border px-4 py-3 text-sm", style: { borderColor: brand.accent }, children: [`You are reading version ${n} of ${of}. `, _jsx("a", { href: pagePath(a.id), className: "underline underline-offset-4", style: { color: brand.accent }, children: "Read the current version" })] }) }), _jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: pack.Cover ? (_jsx(pack.Cover, { title: title, subtitle: v.subtitle, summary: summary, to: v.to, markdown: v.markdown, kicker: _jsx(Kicker, { to: v.to, brand: brand }), meta: _jsx(VersionLine, { n: n, at: v.at }), version: n, updatedAt: v.at, createdAt: a.createdAt })) : (_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-10 sm:pt-12' : 'pt-10'} pb-8 text-center`, children: [_jsx(Kicker, { to: v.to, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: title }), v.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: v.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: summary }), _jsx(VersionLine, { n: n, at: v.at })] })), children: _jsx(ArtifactMarkdown, { markdown: v.markdown, simulator: config.simulatorEmbed ? { embed: config.simulatorEmbed } : undefined, messages: config.messagesEmbed ? { embed: config.messagesEmbed } : undefined, definitions: a.definitions, checklist: { artifactId: a.id, accent: brand.accent }, Section: pack.Section, figure: pack.figure }) })] }));
|
|
422
446
|
}
|
|
447
|
+
/** A broadcast: one message card. The sender's words at the top, then the media (a video with
|
|
448
|
+
* sound and controls, photos, a voice note's player, link cards) in the order the words put
|
|
449
|
+
* them, then this reader's own note. None of the document's furniture: no table of contents, no
|
|
450
|
+
* version line, no read-along (a voice note is its own audio), and no page menu for a reader.
|
|
451
|
+
* Comments only when the file turned them on; the quiet view beacon always, for a reader. */
|
|
452
|
+
async function BroadcastCard(a, { reader, perReader, bot }) {
|
|
453
|
+
const { brand } = lookOf(a);
|
|
454
|
+
const mode = !bot && config.state && a.state && a.comments && a.comments !== 'off' && a.state.slots[COMMENTS_SLOT] ? a.comments : 'off';
|
|
455
|
+
const admin = isAdmin(reader);
|
|
456
|
+
// A photo sent with no other media can ride as the cover; with media in the body it would repeat it.
|
|
457
|
+
const cover = !bot && a.cover && !/!\[[^\]]*\]\(/.test(a.markdown) ? a.cover : null;
|
|
458
|
+
return (_jsxs(_Fragment, { children: [admin ? (_jsx(PageMenu, { role: roleOf(reader), name: reader ? reader.name ?? reader.email : null, share: null, comments: mode !== 'off', signIn: null, signOut: signOutUrl(a.id), history: null, markdown: null, settings: null })) : null, _jsx("main", { "data-broadcast": true, className: "mx-auto max-w-lg px-4 pt-16 pb-20 sm:pt-24", children: _jsxs("article", { id: "broadcast-root", "data-broadcast-card": true, className: "rounded-2xl border border-[color:var(--a-line)] bg-[color:var(--a-surface)] px-5 py-6 sm:px-7 sm:py-8", children: [_jsx("p", { "data-nospeak": true, "data-broadcast-from": true, className: "text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: `From ${owner}` }), _jsx("h1", { className: "mt-3 text-2xl sm:text-3xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), cover ? (
|
|
459
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
460
|
+
_jsx("img", { src: cover, alt: "", className: "mt-5 w-full rounded-xl border border-[color:var(--a-line)]" })) : null, _jsx("div", { "data-broadcast-body": true, className: "mt-4", children: _jsx(ArtifactMarkdown, { markdown: a.markdown, checklist: { artifactId: a.id, owner, accent: brand.accent }, simulator: config.simulatorEmbed ? { embed: config.simulatorEmbed } : undefined, messages: config.messagesEmbed ? { embed: config.messagesEmbed } : undefined }) }), perReader?.note ? (_jsxs("div", { "data-for-reader": true, className: "mt-6 rounded-xl border border-[color:var(--a-line)] px-4 py-3", children: [_jsx("p", { "data-nospeak": true, className: "text-[11px] uppercase tracking-[0.2em] opacity-70", children: `For ${perReader.label}` }), _jsx("p", { className: "mt-1 text-lg", children: perReader.note })] })) : null] }) }), mode !== 'off' ? (_jsx(CommentLayer, { artifactId: a.id, rootId: "broadcast-root", ownerName: owner, comments: mode, canShare: reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone', signedIn: reader !== null, isOwner: admin, version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: null })) : null, perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null] }));
|
|
461
|
+
}
|
|
423
462
|
/** The page itself, shared by open and gated pages. */
|
|
424
463
|
async function Body(a, { top, reader, perReader }) {
|
|
425
464
|
const { pack, brand } = lookOf(a);
|
|
@@ -705,10 +744,23 @@ export function createArtifactRoutes(config) {
|
|
|
705
744
|
if (!authed(request))
|
|
706
745
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
707
746
|
const text = await request.text();
|
|
708
|
-
|
|
747
|
+
let parsed = parseArtifactSource(text);
|
|
709
748
|
if (!parsed.ok)
|
|
710
749
|
return NextResponse.json({ error: parsed.error }, { status: 400 });
|
|
711
750
|
const id = request.nextUrl.searchParams.get('id') ?? parsed.meta.id ?? undefined;
|
|
751
|
+
// The kind is sticky, like `links:`: a republish of a broadcast that leaves `template:` out is
|
|
752
|
+
// still a broadcast, and one that says `document` is refused rather than quietly changed.
|
|
753
|
+
if (id && parsed.meta.template === 'document') {
|
|
754
|
+
const stored = await store.get(id);
|
|
755
|
+
if (stored && kindOf(stored) === 'broadcast') {
|
|
756
|
+
const again = parseArtifactSource(text, { defaultKind: 'broadcast' });
|
|
757
|
+
if (!again.ok)
|
|
758
|
+
return NextResponse.json({ error: again.error }, { status: 400 });
|
|
759
|
+
if (again.meta.template !== 'broadcast')
|
|
760
|
+
return NextResponse.json({ error: 'this page is a broadcast and stays one: drop template: document, or publish the document as a new page' }, { status: 409 });
|
|
761
|
+
parsed = again;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
712
764
|
// Fail closed: private media needs a private store. Without one the media would have to sit in
|
|
713
765
|
// the public bucket, which may carry a bucket-wide public read grant, so nothing is saved.
|
|
714
766
|
// `media:` is sticky, so a stored page that already keeps private media counts too.
|
|
@@ -745,9 +797,24 @@ export function createArtifactRoutes(config) {
|
|
|
745
797
|
const result = await store.save({ id, meta: parsed.meta, markdown: parsed.body, note, amend });
|
|
746
798
|
if ('notFound' in result)
|
|
747
799
|
return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
|
|
800
|
+
// A broadcast with no date gets one: 90 days from now. A date already set (by the sender, or
|
|
801
|
+
// by an earlier publish) stands, and Keep (expireAt null, keep true) is never undone.
|
|
802
|
+
let expireAt;
|
|
803
|
+
if (parsed.meta.template === 'broadcast' && config.recipients) {
|
|
804
|
+
const rec = await config.recipients.get(result.id);
|
|
805
|
+
if (rec?.keep)
|
|
806
|
+
expireAt = null;
|
|
807
|
+
else if (rec?.expireAt)
|
|
808
|
+
expireAt = rec.expireAt;
|
|
809
|
+
else {
|
|
810
|
+
expireAt = new Date(Date.now() + BROADCAST_TTL_MS).toISOString();
|
|
811
|
+
await config.recipients.setExpiry(result.id, expireAt);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
748
814
|
revalidatePath(pagePath(result.id));
|
|
749
815
|
return NextResponse.json({
|
|
750
816
|
id: result.id, url: pageUrl(result.id), version: result.version,
|
|
817
|
+
...(expireAt !== undefined ? { expireAt } : {}),
|
|
751
818
|
...(parsed.meta.state && !config.state ? { warning: 'this host keeps no answers; state: is stored but inert' } : {}),
|
|
752
819
|
}, { status: result.created ? 201 : 200 });
|
|
753
820
|
}
|
|
@@ -874,15 +941,97 @@ export function createArtifactRoutes(config) {
|
|
|
874
941
|
revalidatePath(pagePath(id));
|
|
875
942
|
return NextResponse.json({ id, versions: await store.history(id) });
|
|
876
943
|
}
|
|
944
|
+
/** DELETE /api/artifacts/<id>, publish key: the page and every file stored under it, public and
|
|
945
|
+
* private. The files go first, so a bucket that fails leaves the record to delete again rather
|
|
946
|
+
* than media nobody can find. The id is checked before anything is touched: a path-shaped id
|
|
947
|
+
* would widen the bucket prefix past one page. */
|
|
877
948
|
async function DELETE(request, { params }) {
|
|
878
949
|
if (!authed(request))
|
|
879
950
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
880
951
|
const { id } = await params;
|
|
952
|
+
if (!ARTIFACT_ID.test(id) || !(await store.get(id)))
|
|
953
|
+
return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
|
|
954
|
+
let files = 0;
|
|
955
|
+
if (config.assets?.deletePage) {
|
|
956
|
+
try {
|
|
957
|
+
files = await config.assets.deletePage(id);
|
|
958
|
+
}
|
|
959
|
+
catch (e) {
|
|
960
|
+
return NextResponse.json({ error: `the page's files could not be removed, so the page was kept; delete it again: ${e instanceof Error ? e.message : String(e)}` }, { status: 502 });
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
// Nothing of the page outlives it: readers, their notes and views, and every comment, kept or not.
|
|
964
|
+
try {
|
|
965
|
+
await recipientsRoutes.forgetReaders(id);
|
|
966
|
+
}
|
|
967
|
+
catch (e) {
|
|
968
|
+
return NextResponse.json({ error: `the page's readers could not be forgotten, so the page was kept; delete it again: ${e instanceof Error ? e.message : String(e)}` }, { status: 502 });
|
|
969
|
+
}
|
|
881
970
|
const ok = await store.delete(id);
|
|
882
971
|
if (!ok)
|
|
883
972
|
return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
|
|
884
973
|
revalidatePath(pagePath(id));
|
|
885
|
-
return NextResponse.json({ deleted: true });
|
|
974
|
+
return NextResponse.json({ deleted: true, files });
|
|
975
|
+
}
|
|
976
|
+
/** GET /api/artifacts?kind=document|broadcast: the owner's list of one kind, newest first, with
|
|
977
|
+
* no bodies. Documents and broadcasts are separate lists; `kind` defaults to document, so a
|
|
978
|
+
* documents list never carries a broadcast. A broadcast's line adds its expiry, whether it is
|
|
979
|
+
* kept, how many people still hold a live link (`readers`) and how many were revoked. The publish key, or the owner or an admin signed in. */
|
|
980
|
+
async function LIST(request) {
|
|
981
|
+
const no = (error, status) => NextResponse.json({ error }, { status, headers: { 'cache-control': 'no-store' } });
|
|
982
|
+
const me = verifyGrant(readerSecret(), request.cookies.get(GRANT_COOKIE)?.value);
|
|
983
|
+
if (!authed(request) && !isAdmin(me))
|
|
984
|
+
return no('Unauthorized', 401);
|
|
985
|
+
const kind = request.nextUrl.searchParams.get('kind') ?? 'document';
|
|
986
|
+
if (!ARTIFACT_KINDS.includes(kind))
|
|
987
|
+
return no(`kind must be one of: ${ARTIFACT_KINDS.join(', ')}`, 400);
|
|
988
|
+
if (!store.list)
|
|
989
|
+
return no('this store cannot list its pages', 501);
|
|
990
|
+
const items = await store.list(kind);
|
|
991
|
+
const pages = await Promise.all(items.map(async (p) => {
|
|
992
|
+
const line = { id: p.id, title: p.title, summary: p.summary, url: pageUrl(p.id), createdAt: p.createdAt, updatedAt: p.updatedAt };
|
|
993
|
+
if (kind !== 'broadcast')
|
|
994
|
+
return { ...line, views: p.views };
|
|
995
|
+
// A store that fails fails the list: a blank line would read as "kept, sent to nobody".
|
|
996
|
+
const rec = config.recipients ? await config.recipients.get(p.id) : null;
|
|
997
|
+
const people = rec ? Object.values(rec.recipients).filter((r) => !r.owner) : [];
|
|
998
|
+
return { ...line, expireAt: rec?.expireAt ?? null, keep: rec?.keep ?? false, readers: people.filter((r) => !r.revokedAt).length, revoked: people.filter((r) => r.revokedAt).length };
|
|
999
|
+
}));
|
|
1000
|
+
return NextResponse.json({ kind, pages }, { headers: { 'cache-control': 'no-store' } });
|
|
1001
|
+
}
|
|
1002
|
+
/** For a host's proxy, ahead of the page: a per-reader page whose link was revoked or whose date
|
|
1003
|
+
* passed answers 410 Gone with the same quiet words the page shows, so a crawler or a preview
|
|
1004
|
+
* service drops it. Null for everything else (no link at all, a valid link, a document, the
|
|
1005
|
+
* owner signed in, any other path), which the proxy passes on untouched. Reads nothing unless
|
|
1006
|
+
* the request carries a reader's link or its cookie. */
|
|
1007
|
+
async function shutPageResponse(request) {
|
|
1008
|
+
const path = request.nextUrl.pathname;
|
|
1009
|
+
if (!path.startsWith(prefix))
|
|
1010
|
+
return null;
|
|
1011
|
+
const id = path.slice(prefix.length);
|
|
1012
|
+
if (!ARTIFACT_ID.test(id))
|
|
1013
|
+
return null;
|
|
1014
|
+
const param = request.nextUrl.searchParams.get('r') ?? undefined;
|
|
1015
|
+
const cookie = request.cookies.get(ridCookieName(id))?.value;
|
|
1016
|
+
if (!param && !cookie)
|
|
1017
|
+
return null;
|
|
1018
|
+
const secret = usableSecret(config.recipientSecret?.());
|
|
1019
|
+
if (!secret || !config.recipients)
|
|
1020
|
+
return null;
|
|
1021
|
+
if (isAdmin(verifyGrant(readerSecret(), request.cookies.get(GRANT_COOKIE)?.value)))
|
|
1022
|
+
return null;
|
|
1023
|
+
const a = await store.get(id);
|
|
1024
|
+
if (!a || a.access || !(a.links === 'per-reader' || kindOf(a) === 'broadcast'))
|
|
1025
|
+
return null;
|
|
1026
|
+
const g = await perReaderGate({ id, param, cookie, secret, recipients: config.recipients });
|
|
1027
|
+
if (g.state !== 'shut' || (g.why !== 'revoked' && g.why !== 'expired'))
|
|
1028
|
+
return null;
|
|
1029
|
+
const look = lookOf(a).pack;
|
|
1030
|
+
const colour = (c, fallback) => (SAFE_COLOUR.test(c) ? c : fallback);
|
|
1031
|
+
const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex, nofollow"><title>${UNAVAILABLE}</title></head>`
|
|
1032
|
+
+ `<body style="margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:${colour(look.ground, '#0e0f13')};color:${colour(look.ink, '#ece7dc')};font-family:system-ui,sans-serif">`
|
|
1033
|
+
+ `<p data-per-reader-shut style="max-width:36rem;padding:0 1.5rem;text-align:center;font-size:1.125rem;opacity:.8">${UNAVAILABLE}</p></body></html>`;
|
|
1034
|
+
return new Response(html, { status: 410, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'x-robots-tag': 'noindex, nofollow' } });
|
|
886
1035
|
}
|
|
887
|
-
return { Page, generateMetadata, VersionPage, generateVersionMetadata, VERSIONS, POST, GET, DELETE, PUT_ASSET, UPLOAD, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, SETTINGS, READS, sweepReaders, TRANSCRIBE: voiceRoutes.TRANSCRIBE, ...stateRoutes, ...personalRoutes, ...recipientsRoutes, dynamic: 'force-dynamic', maxDuration: 30 };
|
|
1036
|
+
return { Page, generateMetadata, VersionPage, generateVersionMetadata, VERSIONS, POST, GET, DELETE, LIST, shutPageResponse, PUT_ASSET, UPLOAD, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, SETTINGS, READS, sweepReaders, TRANSCRIBE: voiceRoutes.TRANSCRIBE, ...stateRoutes, ...personalRoutes, ...recipientsRoutes, dynamic: 'force-dynamic', maxDuration: 30 };
|
|
888
1037
|
}
|
|
@@ -121,6 +121,19 @@ export function createRecipientsRoutes(given) {
|
|
|
121
121
|
const comments = entries.filter((e) => e.slot === COMMENTS_SLOT && e.readerKey.startsWith('r:')).map((e) => ({ readerKey: e.readerKey, at: e.at, text: String(e.value?.body ?? '') }));
|
|
122
122
|
return json({ id, expireAt: rec.expireAt, keep: rec.keep, recipients: summarizeViews(rec, await ctx.recipients.views(id), comments) });
|
|
123
123
|
}
|
|
124
|
+
/** Reader text goes with the page: every comment and answer on it, whoever wrote it. */
|
|
125
|
+
async function clearReaderText(id) {
|
|
126
|
+
if (ctx.state)
|
|
127
|
+
for (const key of new Set((await ctx.state.entries(id)).map((e) => e.readerKey)))
|
|
128
|
+
await ctx.state.removeReader(id, key);
|
|
129
|
+
}
|
|
130
|
+
/** Everything a page holds about its readers: their comments and answers, then who it was sent
|
|
131
|
+
* to, each person's note and their views. DELETE calls it before removing the page record, so a
|
|
132
|
+
* failure leaves the record and the delete can be run again. */
|
|
133
|
+
async function forgetReaders(id) {
|
|
134
|
+
await clearReaderText(id);
|
|
135
|
+
await ctx.recipients?.forget(id);
|
|
136
|
+
}
|
|
124
137
|
/** The daily sweep: deletes only pages whose recipients record carries an expireAt that is a
|
|
125
138
|
* well-formed time in the past and is not kept. An unparseable expireAt is never deleted (the
|
|
126
139
|
* gate already shuts the page); it is reported in `skipped`. A page that fails midway stays
|
|
@@ -162,10 +175,7 @@ export function createRecipientsRoutes(given) {
|
|
|
162
175
|
log(`sweepExpired: ${id} was kept during the sweep`);
|
|
163
176
|
continue;
|
|
164
177
|
}
|
|
165
|
-
|
|
166
|
-
if (ctx.state)
|
|
167
|
-
for (const key of new Set((await ctx.state.entries(id)).map((e) => e.readerKey)))
|
|
168
|
-
await ctx.state.removeReader(id, key);
|
|
178
|
+
await clearReaderText(id);
|
|
169
179
|
await ctx.store.delete(id);
|
|
170
180
|
await ctx.recipients.forget(id);
|
|
171
181
|
out.pages++;
|
|
@@ -178,5 +188,5 @@ export function createRecipientsRoutes(given) {
|
|
|
178
188
|
}
|
|
179
189
|
return out;
|
|
180
190
|
}
|
|
181
|
-
return { SEEN, RECIPIENTS, VIEWS, sweepExpired };
|
|
191
|
+
return { SEEN, RECIPIENTS, VIEWS, sweepExpired, forgetReaders };
|
|
182
192
|
}
|
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",
|