@supersuit/artifacts 0.35.0 → 0.37.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 +70 -0
- package/README.md +65 -3
- package/lib/artifacts/assets.d.ts +15 -3
- package/lib/artifacts/assets.js +32 -15
- package/lib/artifacts/front-matter.d.ts +14 -2
- package/lib/artifacts/front-matter.js +38 -5
- package/lib/artifacts/index.d.ts +3 -3
- package/lib/artifacts/index.js +3 -3
- 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 +173 -15
- 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,76 @@
|
|
|
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.37.0
|
|
7
|
+
|
|
8
|
+
**A broadcast is its own page kind.** `template: broadcast` in front matter makes a page one
|
|
9
|
+
message sent to several people, each through their own link, instead of a document with extra
|
|
10
|
+
lines. Documents are unchanged: an ordinary page renders byte for byte as before (the golden render
|
|
11
|
+
test is untouched) and lists as it always would have.
|
|
12
|
+
|
|
13
|
+
- The message card: the sender's words, then the media in the order written (a video with sound
|
|
14
|
+
and controls, photos, a voice note's player, link cards), then the reader's own note. No table of
|
|
15
|
+
contents, no version line, no read-along player, no page menu for a reader.
|
|
16
|
+
- The kind's defaults: per-reader links implied; private media implied wherever the body has a
|
|
17
|
+
stored private file or a local one (a remote URL alone stores nothing); the kind is sticky like
|
|
18
|
+
`links:` (a republish leaving `template:` out stays a broadcast, `template: document` is refused
|
|
19
|
+
with 409); `access`, `password` and `comments_visible: readers` refused in words; comments off unless
|
|
20
|
+
the file turns them on; no narration player (a voice note is its own audio). A body pointing at a
|
|
21
|
+
local file without `media: private` is refused, because that line is what makes the publisher
|
|
22
|
+
upload it privately.
|
|
23
|
+
- Expiry: `POST /api/artifacts` gives a broadcast with no date one 90 days out (`BROADCAST_TTL_MS`)
|
|
24
|
+
and answers it as `expireAt`. A date already set stands; Keep holds.
|
|
25
|
+
- `GET /api/artifacts?kind=broadcast|document` (`LIST`, publish key or the owner or an admin signed
|
|
26
|
+
in): the owner's two lists. A broadcast's line carries `readers` (live links) and `revoked`; a
|
|
27
|
+
recipients store that fails fails the list. `ArtifactStore.list(kind)` (optional; the Firestore store has it),
|
|
28
|
+
`kindOf`, `listOfKind`, `ARTIFACT_KINDS`.
|
|
29
|
+
- An unfurl fetcher never gets a signed media URL. DETECTOR: a per-reader page fetched with a
|
|
30
|
+
preview agent's user agent rendered its private media's signed URLs into the HTML, which a
|
|
31
|
+
preview service may cache. REMEDY: none in the host; the page now renders its text only to a
|
|
32
|
+
known fetcher or a request with no agent (`readHeader`, default through `next/headers`).
|
|
33
|
+
- `DELETE /api/artifacts/<id>` removes the page's files from both buckets (`deletePage`), then its
|
|
34
|
+
readers, their notes and views and every comment on it, before the record. DETECTOR: a deleted
|
|
35
|
+
page's media stayed in the bucket, and a kept broadcast's per-person notes and comments outlived
|
|
36
|
+
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
|
|
37
|
+
page id is refused before the bucket is touched.
|
|
38
|
+
- `shutPageResponse(request)`: a revoked or expired per-reader link answers 410 Gone with the same
|
|
39
|
+
quiet words. DETECTOR: a revoked link answered 200. REMEDY: call it from your `proxy.ts` (README,
|
|
40
|
+
Broadcasts: skip requests with no link or rid cookie before loading anything, and catch every
|
|
41
|
+
failure); without it the page still says the same words with a 200.
|
|
42
|
+
- Documents: the one output change is the bot rule above, on per-reader documents only.
|
|
43
|
+
- TESTS: `src/routes/broadcast.test.tsx`, `src/artifacts/store.test.ts` (listing one kind),
|
|
44
|
+
`src/artifacts/index.test.ts`, `src/artifacts/front-matter.test.ts`.
|
|
45
|
+
|
|
46
|
+
## 0.36.0
|
|
47
|
+
|
|
48
|
+
**Private media moves to its own private bucket, and a host without one refuses it.** 0.35.0 kept
|
|
49
|
+
`media: private` files under `<prefix>/<id>/private/` in the same bucket as public assets and
|
|
50
|
+
never called `makePublic` on them. That is no protection on a bucket that carries a bucket-wide
|
|
51
|
+
public read grant: every "private" object was readable by anyone holding its URL.
|
|
52
|
+
|
|
53
|
+
- `createArtifactAssets(bucket, prefix, { privateBucket })`. With `privateBucket`, `putPrivate`,
|
|
54
|
+
`signPrivateUpload`, `finishPrivateUpload` and `privateUrl` use it (same `<prefix>/<id>/private/`
|
|
55
|
+
keys); `put`, `signUpload`, `finishUpload`, covers and narration stay on `bucket`. `deletePage`
|
|
56
|
+
clears the page from both. Passing the public bucket again as the private one throws.
|
|
57
|
+
- Without `privateBucket` the four private methods are not defined, so the host fails closed:
|
|
58
|
+
`POST /api/artifacts` refuses a page with `media: private` (or a republish of a page that already
|
|
59
|
+
keeps private media) with 501 and saves nothing, and `?private=1` uploads answer 501, all with
|
|
60
|
+
the same words naming `privateBucket`.
|
|
61
|
+
- `PRIVATE_READ_TTL_MS` (exported, `60 * 60 * 1000`): the life of a signed V4 read URL. One is
|
|
62
|
+
minted per private file on every render that passed the per-reader gate, and none for a revoked
|
|
63
|
+
or expired link.
|
|
64
|
+
- DETECTOR: your host passes `media: private` pages and calls `createArtifactAssets(bucket, prefix)`
|
|
65
|
+
with two arguments; or `gcloud storage buckets get-iam-policy` on your bucket lists `allUsers`
|
|
66
|
+
and you have objects under `*/private/`.
|
|
67
|
+
- REMEDY: create a separate bucket (uniform access, public access prevention enforced, object
|
|
68
|
+
access for the host's service account only), pass it as `privateBucket`, give it CORS for `PUT`
|
|
69
|
+
from your origin if browsers upload to it directly, and republish any page whose private media
|
|
70
|
+
sits in the public bucket (then delete those old objects). A host that publishes no private
|
|
71
|
+
media needs nothing; until it adds a private bucket, `media: private` is refused.
|
|
72
|
+
- TESTS: `src/artifacts/assets.test.ts` (private media), `src/routes/private-media.test.tsx`
|
|
73
|
+
(publishing media: private), `src/routes/per-reader.test.tsx` (mints a fresh signed URL on every
|
|
74
|
+
passing render), `src/artifacts/index.test.ts`.
|
|
75
|
+
|
|
6
76
|
## 0.35.0
|
|
7
77
|
|
|
8
78
|
**Per-reader links, private media, video with sound, quiet views, and expiry.** A page can now go
|
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ Firestore), and optionally `@google-cloud/storage >= 7` for uploaded files.
|
|
|
22
22
|
| `createArtifactRoutes(config)` | Every route handler: the page, its metadata, the publish API, the share card, the reader sign-in exchange, access control |
|
|
23
23
|
| `createArtifactStore(db, path)` | Firestore store for pages and their version history, under any collection path |
|
|
24
24
|
| `createReadersStore(db, path)` | The record for confidential pages: allowlist, agreements, reading sessions, flags |
|
|
25
|
-
| `createArtifactAssets(bucket, prefix)` | Uploaded images for a page, in a Cloud Storage bucket |
|
|
25
|
+
| `createArtifactAssets(bucket, prefix, { privateBucket? })` | Uploaded images for a page, in a Cloud Storage bucket; private media in a separate private bucket |
|
|
26
26
|
| `freedomDefault`, `BrandPack` | The default look and the type any other look implements |
|
|
27
27
|
| `BrandGround`, `BrandMark` | The pack's page backdrop and mark, for your own pages (a home, a 404) |
|
|
28
28
|
| `parseArtifactSource` | The front-matter contract, as a parser you can call before publishing (widget fences included) |
|
|
@@ -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
|
|
|
@@ -849,6 +849,14 @@ media: private
|
|
|
849
849
|
|
|
850
850
|
`links: per-reader` shuts the page to everyone who does not arrive through a valid, unrevoked, unexpired link. It cannot be combined with `password`, `access` or `comments_visible: readers` (readers never see each other's comments). `media: private` keeps body media under the page's private prefix; `.mp4` and `.m4a` (and `.mp3`) are supported, and `sound:` at the start of a video's alt makes it a clip with controls and sound. Both lines are sticky: republishing a file that leaves them out changes nothing.
|
|
851
851
|
|
|
852
|
+
### Private media lives in its own bucket
|
|
853
|
+
|
|
854
|
+
`media: private` needs a private store: `createArtifactAssets(bucket, prefix, { privateBucket })`. Private files go to `privateBucket` under `<prefix>/<id>/private/`; covers, images and narration stay on the public `bucket`. Without `privateBucket` the private methods are not defined, and the host refuses private media with 501 at every door: publishing a page with `media: private` (or republishing a page that already keeps it), and `?private=1` uploads. Nothing is saved. This is on purpose: a public bucket often carries a bucket-wide public read grant, so an object written there is readable by anyone who has its URL, whatever the object's own settings say.
|
|
855
|
+
|
|
856
|
+
The private bucket must be a different bucket from the public one (passing the same bucket twice throws), with uniform bucket-level access, public access prevention enforced, and object access granted only to the host's own service account. The page signs a V4 read URL per private file on every render that passed the per-reader gate, valid `PRIVATE_READ_TTL_MS` (one hour); a revoked or expired link renders nothing and mints nothing. A reader revoked after a render can keep loading a URL already handed to them for at most that hour. Expiry's `deletePage` clears the page from both buckets.
|
|
857
|
+
|
|
858
|
+
If the publisher uploads straight to the private bucket from a browser (`?private=1` on `UPLOAD`), give that bucket a CORS rule allowing `PUT` from your origin with the `content-type` and `cache-control` headers; a reader's browser playing a signed URL needs `GET` (and `HEAD`) from your origin only if your player fetches it with CORS.
|
|
859
|
+
|
|
852
860
|
Routes (spread into your route files by `createArtifactRoutes`): `RECIPIENTS`, `VIEWS`, `SEEN`, and `sweepExpired`.
|
|
853
861
|
|
|
854
862
|
| Route | Who | What |
|
|
@@ -868,7 +876,61 @@ A view is the page rendered in a real browser, visible, for at least 2 seconds.
|
|
|
868
876
|
|
|
869
877
|
### Expiry
|
|
870
878
|
|
|
871
|
-
`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. A Keep that lands mid-sweep is honored. Deleting a page also removes every prior version of its body.
|
|
879
|
+
`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
|
+
## Broadcasts (`template: broadcast`)
|
|
882
|
+
|
|
883
|
+
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.
|
|
884
|
+
|
|
885
|
+
```markdown
|
|
886
|
+
---
|
|
887
|
+
title: At the lake
|
|
888
|
+
summary: We finally made it up there.
|
|
889
|
+
template: broadcast
|
|
890
|
+
links: per-reader
|
|
891
|
+
media: private
|
|
892
|
+
voice: none
|
|
893
|
+
---
|
|
894
|
+
We finally made it up there.
|
|
895
|
+
|
|
896
|
+

|
|
897
|
+
```
|
|
898
|
+
|
|
899
|
+
What the kind decides, so the file does not have to:
|
|
900
|
+
|
|
901
|
+
- **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.
|
|
902
|
+
- **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.
|
|
903
|
+
- **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.
|
|
904
|
+
- **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.
|
|
905
|
+
- **Silent view tracking**: the quiet view beacon, as on every per-reader page.
|
|
906
|
+
|
|
907
|
+
Routes:
|
|
908
|
+
|
|
909
|
+
| Route | Who | What |
|
|
910
|
+
|---|---|---|
|
|
911
|
+
| `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 |
|
|
912
|
+
| `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 |
|
|
913
|
+
|
|
914
|
+
```ts
|
|
915
|
+
// proxy.ts
|
|
916
|
+
import { NextResponse, type NextRequest } from 'next/server'
|
|
917
|
+
export async function proxy(request: NextRequest) {
|
|
918
|
+
// Only a request carrying a reader's link or its cookie can be shut; skip the rest cheaply.
|
|
919
|
+
const hasLink = request.nextUrl.searchParams.has('r') || request.cookies.getAll().some((c) => c.name.startsWith('artifact_r_'))
|
|
920
|
+
if (!hasLink) return NextResponse.next()
|
|
921
|
+
try {
|
|
922
|
+
const { routes } = await import('./routes') // your createArtifactRoutes(...)
|
|
923
|
+
const shut = await routes.shutPageResponse(request)
|
|
924
|
+
if (shut) return shut
|
|
925
|
+
} catch {
|
|
926
|
+
// A store outage never costs a reader the page: the page applies the same gate itself.
|
|
927
|
+
}
|
|
928
|
+
return NextResponse.next()
|
|
929
|
+
}
|
|
930
|
+
export const config = { matcher: ['/:id([a-z0-9]{8})'] }
|
|
931
|
+
```
|
|
932
|
+
|
|
933
|
+
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.
|
|
872
934
|
|
|
873
935
|
## Brand packs
|
|
874
936
|
|
|
@@ -35,15 +35,27 @@ export interface ArtifactAssets {
|
|
|
35
35
|
}>;
|
|
36
36
|
/** After a signed private upload: `private:<stored name>`, or null if nothing arrived. */
|
|
37
37
|
finishPrivateUpload?(id: string, name: string, digest: string): Promise<string | null>;
|
|
38
|
-
/** A signed read URL for a stored private name, valid one hour. */
|
|
38
|
+
/** A V4 signed read URL for a stored private name, valid PRIVATE_READ_TTL_MS (one hour). */
|
|
39
39
|
privateUrl?(id: string, stored: string): Promise<string>;
|
|
40
|
-
/** Delete every object under `<prefix>/<id
|
|
40
|
+
/** Delete every object under `<prefix>/<id>/`, in the public bucket and the private one; returns how many. */
|
|
41
41
|
deletePage?(id: string): Promise<number>;
|
|
42
42
|
}
|
|
43
43
|
/** A reference to a private file as it appears in a page: `private:<stored name>`. */
|
|
44
44
|
export declare const PRIVATE_REF: RegExp;
|
|
45
45
|
/** The digest half of a stored name: the first 8 hex of the bytes' sha256. */
|
|
46
46
|
export declare const ASSET_DIGEST: RegExp;
|
|
47
|
+
/** How long a signed read URL for private media stays valid: one hour. A reader revoked after a
|
|
48
|
+
* render can keep loading a URL already handed to them for at most this long. */
|
|
49
|
+
export declare const PRIVATE_READ_TTL_MS: number;
|
|
50
|
+
export interface ArtifactAssetsOptions {
|
|
51
|
+
/**
|
|
52
|
+
* The bucket private media lives in. It must be a different bucket from the public one, with
|
|
53
|
+
* uniform access, public access prevention enforced, and object access granted only to the
|
|
54
|
+
* host's own service account. Without it the private methods are not defined at all, so a
|
|
55
|
+
* `media: private` page is refused instead of being stored where anyone could read it by URL.
|
|
56
|
+
*/
|
|
57
|
+
privateBucket?: Bucket;
|
|
58
|
+
}
|
|
47
59
|
/** Names are one path segment: letters, digits, dot, dash, underscore. */
|
|
48
60
|
export declare const ASSET_NAME: RegExp;
|
|
49
61
|
export declare const ASSET_TYPES: Record<string, string>;
|
|
@@ -67,4 +79,4 @@ export declare function contentTypeFor(name: string): string | undefined;
|
|
|
67
79
|
export declare function hashedName(name: string, bytes: Buffer): string;
|
|
68
80
|
/** `name` with `digest` spliced in before the extension, the way hashedName stores it. */
|
|
69
81
|
export declare function storedName(name: string, digest: string): string;
|
|
70
|
-
export declare function createArtifactAssets(bucket: Bucket, prefix: string): ArtifactAssets;
|
|
82
|
+
export declare function createArtifactAssets(bucket: Bucket, prefix: string, options?: ArtifactAssetsOptions): ArtifactAssets;
|
package/lib/artifacts/assets.js
CHANGED
|
@@ -10,6 +10,9 @@ export const PRIVATE_REF = /^private:([A-Za-z0-9][A-Za-z0-9._-]{0,140})$/;
|
|
|
10
10
|
/** The digest half of a stored name: the first 8 hex of the bytes' sha256. */
|
|
11
11
|
export const ASSET_DIGEST = /^[0-9a-f]{8}$/;
|
|
12
12
|
const CACHE_CONTROL = 'public, max-age=31536000, immutable';
|
|
13
|
+
/** How long a signed read URL for private media stays valid: one hour. A reader revoked after a
|
|
14
|
+
* render can keep loading a URL already handed to them for at most this long. */
|
|
15
|
+
export const PRIVATE_READ_TTL_MS = 60 * 60 * 1000;
|
|
13
16
|
/** Names are one path segment: letters, digits, dot, dash, underscore. */
|
|
14
17
|
export const ASSET_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/;
|
|
15
18
|
export const ASSET_TYPES = {
|
|
@@ -52,9 +55,13 @@ export function storedName(name, digest) {
|
|
|
52
55
|
// A name with no extension is legal here, so append rather than assume there is a dot.
|
|
53
56
|
return dot <= 0 ? `${name}.${digest}` : `${name.slice(0, dot)}.${digest}${name.slice(dot)}`;
|
|
54
57
|
}
|
|
55
|
-
export function createArtifactAssets(bucket, prefix) {
|
|
58
|
+
export function createArtifactAssets(bucket, prefix, options = {}) {
|
|
56
59
|
const clean = prefix.replace(/^\/+|\/+$/g, '');
|
|
57
|
-
|
|
60
|
+
const priv = options.privateBucket;
|
|
61
|
+
// The same bucket twice would put "private" media back where a public read grant reaches it.
|
|
62
|
+
if (priv && priv.name === bucket.name)
|
|
63
|
+
throw new Error('the private bucket must be a different bucket from the public one');
|
|
64
|
+
const assets = {
|
|
58
65
|
async put(id, name, bytes, contentType) {
|
|
59
66
|
const key = `${clean}/${id}/${hashedName(name, bytes)}`;
|
|
60
67
|
const file = bucket.file(key);
|
|
@@ -135,36 +142,46 @@ export function createArtifactAssets(bucket, prefix) {
|
|
|
135
142
|
const [url] = await bucket.file(`${clean}/${id}/${path}`).getSignedUrl({ version: 'v4', action: 'read', expires: Date.now() + 60 * 60 * 1000 });
|
|
136
143
|
return url;
|
|
137
144
|
},
|
|
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,
|
|
138
164
|
async putPrivate(id, name, bytes, contentType) {
|
|
139
165
|
const stored = hashedName(name, bytes);
|
|
140
|
-
await
|
|
166
|
+
await privateFile(id, stored).save(bytes, { contentType, resumable: false, metadata: { cacheControl: 'private, max-age=3600' } });
|
|
141
167
|
return `private:${stored}`;
|
|
142
168
|
},
|
|
143
169
|
async signPrivateUpload(id, name, digest, contentType) {
|
|
144
170
|
const stored = storedName(name, digest);
|
|
145
171
|
const headers = { 'content-type': contentType, 'cache-control': 'private, max-age=3600' };
|
|
146
|
-
const [uploadUrl] = await
|
|
172
|
+
const [uploadUrl] = await privateFile(id, stored).getSignedUrl({ version: 'v4', action: 'write', expires: Date.now() + 15 * 60 * 1000, contentType, extensionHeaders: { 'cache-control': headers['cache-control'] } });
|
|
147
173
|
return { uploadUrl, headers, url: `private:${stored}` };
|
|
148
174
|
},
|
|
149
175
|
async finishPrivateUpload(id, name, digest) {
|
|
150
176
|
const stored = storedName(name, digest);
|
|
151
|
-
const [there] = await
|
|
177
|
+
const [there] = await privateFile(id, stored).exists();
|
|
152
178
|
return there ? `private:${stored}` : null;
|
|
153
179
|
},
|
|
154
180
|
async privateUrl(id, stored) {
|
|
155
181
|
if (!PRIVATE_REF.test(`private:${stored}`))
|
|
156
182
|
throw new Error('not a private file name');
|
|
157
|
-
const [url] = await
|
|
183
|
+
const [url] = await privateFile(id, stored).getSignedUrl({ version: 'v4', action: 'read', expires: Date.now() + PRIVATE_READ_TTL_MS });
|
|
158
184
|
return url;
|
|
159
185
|
},
|
|
160
|
-
async deletePage(id) {
|
|
161
|
-
// An empty or path-shaped id would make the prefix wider than one page.
|
|
162
|
-
if (!ARTIFACT_ID_RE.test(id))
|
|
163
|
-
throw new Error('not an artifact id');
|
|
164
|
-
const [files] = await bucket.getFiles({ prefix: `${clean}/${id}/` });
|
|
165
|
-
for (const f of files)
|
|
166
|
-
await f.delete();
|
|
167
|
-
return files.length;
|
|
168
|
-
},
|
|
169
186
|
};
|
|
170
187
|
}
|
|
@@ -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,10 +1,10 @@
|
|
|
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
|
-
export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAME, ASSET_DIGEST, type ArtifactAssets } from './assets.js';
|
|
7
|
+
export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAME, ASSET_DIGEST, PRIVATE_READ_TTL_MS, type ArtifactAssets, type ArtifactAssetsOptions } from './assets.js';
|
|
8
8
|
export { isUnlocked, keyHash, unlockCookieName, unlockedUrl } from './unlock.js';
|
|
9
9
|
export { verifyPass, mintPass, verifyGrant, mintGrant, decide, firstName, signInUrl, safeReturnPath, GRANT_COOKIE, ACCESS_LEVELS, type Access, type Reader, type AllowEntry, type Decision } from './reader.js';
|
|
10
10
|
export { createReadersStore, summarize, FLAG_KINDS, type ReadersStore, type FlagKind, type SessionDoc, type FlagDoc, type ReaderSummary } from './readers-store.js';
|
package/lib/artifacts/index.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
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
|
-
export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAME, ASSET_DIGEST } from './assets.js';
|
|
7
|
+
export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAME, ASSET_DIGEST, PRIVATE_READ_TTL_MS } from './assets.js';
|
|
8
8
|
export { isUnlocked, keyHash, unlockCookieName, unlockedUrl } from './unlock.js';
|
|
9
9
|
export { verifyPass, mintPass, verifyGrant, mintGrant, decide, firstName, signInUrl, safeReturnPath, GRANT_COOKIE, ACCESS_LEVELS } from './reader.js';
|
|
10
10
|
export { createReadersStore, summarize, FLAG_KINDS } from './readers-store.js';
|
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,10 +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;
|
|
52
|
+
/** What a host with no private store answers to private media, in words that say what to set up. */
|
|
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';
|
|
44
54
|
async function defaultReadCookie(name) {
|
|
45
55
|
const { cookies } = await import('next/headers');
|
|
46
56
|
return (await cookies()).get(name)?.value;
|
|
47
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
|
+
}
|
|
48
64
|
export function createArtifactRoutes(config) {
|
|
49
65
|
const { store, siteUrl } = config;
|
|
50
66
|
// The pack as configured draws the share card; the page paints with its theme variables, so a
|
|
@@ -280,30 +296,40 @@ export function createArtifactRoutes(config) {
|
|
|
280
296
|
const render = (top, reader) => (n === null ? Body(a, { top, reader }) : VersionBody(a, n, { top, reader }));
|
|
281
297
|
if (a.access)
|
|
282
298
|
return GatedPage(a, id, render, n);
|
|
283
|
-
|
|
299
|
+
const broadcast = kindOf(a) === 'broadcast';
|
|
300
|
+
if (a.links === 'per-reader' || broadcast) {
|
|
284
301
|
if (n !== null)
|
|
285
302
|
notFound(); // a past version of a per-reader page is never served
|
|
286
303
|
const sp = (await searchParams) ?? {};
|
|
287
304
|
const param = Array.isArray(sp.r) ? sp.r[0] : sp.r;
|
|
288
305
|
const readCookie = config.readCookie ?? defaultReadCookie;
|
|
289
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);
|
|
290
312
|
// A signed-in owner or admin gets through as the owner, with no link and no tracking.
|
|
291
313
|
const me = readerSecret() ? verifyGrant(readerSecret(), await readCookie(GRANT_COOKIE).catch(() => undefined)) : null;
|
|
292
314
|
if (isAdmin(me)) {
|
|
293
|
-
const markdown =
|
|
294
|
-
|
|
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 });
|
|
295
318
|
}
|
|
296
319
|
const secret = usableSecret(config.recipientSecret?.());
|
|
297
320
|
const g = await perReaderGate({ id, param, cookie: await readCookie(ridCookieName(id)).catch(() => undefined), secret, recipients: config.recipients });
|
|
298
321
|
if (g.state === 'shut') {
|
|
299
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 }) }));
|
|
300
323
|
}
|
|
301
|
-
|
|
302
|
-
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
|
|
303
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(';')
|
|
304
327
|
: null;
|
|
305
|
-
const
|
|
306
|
-
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 });
|
|
307
333
|
return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [remember ? _jsx("script", { dangerouslySetInnerHTML: { __html: remember } }) : null, body] }));
|
|
308
334
|
}
|
|
309
335
|
// A password shuts the body, never the title: the header stays so the reader knows which
|
|
@@ -418,6 +444,21 @@ export function createArtifactRoutes(config) {
|
|
|
418
444
|
const toc = showToc(v.markdown, a.toc) ? tocOf(v.markdown) : [];
|
|
419
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 }) })] }));
|
|
420
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
|
+
}
|
|
421
462
|
/** The page itself, shared by open and gated pages. */
|
|
422
463
|
async function Body(a, { top, reader, perReader }) {
|
|
423
464
|
const { pack, brand } = lookOf(a);
|
|
@@ -697,14 +738,34 @@ export function createArtifactRoutes(config) {
|
|
|
697
738
|
const [sessions, flags, acks] = await Promise.all([config.readers.sessions(id), config.readers.flags(id), config.readers.acks(id)]);
|
|
698
739
|
return NextResponse.json({ id, title: a.title, access: a.access ?? null, views: a.views, ...summarize(sessions, flags), acks: acks.map((k) => ({ email: k.email, name: k.name, at: k.at })) }, { headers: { 'cache-control': 'no-store' } });
|
|
699
740
|
}
|
|
741
|
+
/** Whether this host can store and serve private media: both halves come from a private bucket. */
|
|
742
|
+
const keepsPrivateMedia = () => Boolean(config.assets?.putPrivate && config.assets.privateUrl);
|
|
700
743
|
async function POST(request) {
|
|
701
744
|
if (!authed(request))
|
|
702
745
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
703
746
|
const text = await request.text();
|
|
704
|
-
|
|
747
|
+
let parsed = parseArtifactSource(text);
|
|
705
748
|
if (!parsed.ok)
|
|
706
749
|
return NextResponse.json({ error: parsed.error }, { status: 400 });
|
|
707
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
|
+
}
|
|
764
|
+
// Fail closed: private media needs a private store. Without one the media would have to sit in
|
|
765
|
+
// the public bucket, which may carry a bucket-wide public read grant, so nothing is saved.
|
|
766
|
+
// `media:` is sticky, so a stored page that already keeps private media counts too.
|
|
767
|
+
if (!keepsPrivateMedia() && (parsed.meta.privateMedia || (id && (await store.get(id))?.privateMedia)))
|
|
768
|
+
return NextResponse.json({ error: NO_PRIVATE_STORE }, { status: 501 });
|
|
708
769
|
if (id && parsed.meta.state) {
|
|
709
770
|
const existing = await store.get(id);
|
|
710
771
|
const changed = shapeChanges(existing?.state, parsed.meta.state);
|
|
@@ -736,9 +797,24 @@ export function createArtifactRoutes(config) {
|
|
|
736
797
|
const result = await store.save({ id, meta: parsed.meta, markdown: parsed.body, note, amend });
|
|
737
798
|
if ('notFound' in result)
|
|
738
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
|
+
}
|
|
739
814
|
revalidatePath(pagePath(result.id));
|
|
740
815
|
return NextResponse.json({
|
|
741
816
|
id: result.id, url: pageUrl(result.id), version: result.version,
|
|
817
|
+
...(expireAt !== undefined ? { expireAt } : {}),
|
|
742
818
|
...(parsed.meta.state && !config.state ? { warning: 'this host keeps no answers; state: is stored but inert' } : {}),
|
|
743
819
|
}, { status: result.created ? 201 : 200 });
|
|
744
820
|
}
|
|
@@ -759,7 +835,7 @@ export function createArtifactRoutes(config) {
|
|
|
759
835
|
return NextResponse.json({ error: 'this host does not store assets' }, { status: 501 });
|
|
760
836
|
const priv = request.nextUrl.searchParams.get('private') === '1';
|
|
761
837
|
if (priv && !config.assets.putPrivate)
|
|
762
|
-
return NextResponse.json({ error:
|
|
838
|
+
return NextResponse.json({ error: NO_PRIVATE_STORE }, { status: 501 });
|
|
763
839
|
const { id, name } = await params;
|
|
764
840
|
if (!ARTIFACT_ID.test(id))
|
|
765
841
|
return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
|
|
@@ -806,7 +882,7 @@ export function createArtifactRoutes(config) {
|
|
|
806
882
|
const priv = request.nextUrl.searchParams.get('private') === '1';
|
|
807
883
|
if (priv) {
|
|
808
884
|
if (!assets?.signPrivateUpload || !assets.finishPrivateUpload)
|
|
809
|
-
return NextResponse.json({ error:
|
|
885
|
+
return NextResponse.json({ error: NO_PRIVATE_STORE }, { status: 501 });
|
|
810
886
|
}
|
|
811
887
|
else if (!assets?.signUpload || !assets.finishUpload)
|
|
812
888
|
return NextResponse.json({ error: 'this host does not take direct uploads' }, { status: 501 });
|
|
@@ -865,15 +941,97 @@ export function createArtifactRoutes(config) {
|
|
|
865
941
|
revalidatePath(pagePath(id));
|
|
866
942
|
return NextResponse.json({ id, versions: await store.history(id) });
|
|
867
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. */
|
|
868
948
|
async function DELETE(request, { params }) {
|
|
869
949
|
if (!authed(request))
|
|
870
950
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
871
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
|
+
}
|
|
872
970
|
const ok = await store.delete(id);
|
|
873
971
|
if (!ok)
|
|
874
972
|
return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
|
|
875
973
|
revalidatePath(pagePath(id));
|
|
876
|
-
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' } });
|
|
877
1035
|
}
|
|
878
|
-
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 };
|
|
879
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.37.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",
|