@supersuit/artifacts 0.34.0 → 0.36.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 +53 -0
- package/README.md +50 -1
- package/lib/artifacts/assets.d.ts +29 -1
- package/lib/artifacts/assets.js +54 -2
- package/lib/artifacts/front-matter.d.ts +4 -0
- package/lib/artifacts/front-matter.js +19 -1
- package/lib/artifacts/index.d.ts +3 -1
- package/lib/artifacts/index.js +4 -1
- package/lib/artifacts/recipients-store.d.ts +75 -0
- package/lib/artifacts/recipients-store.js +160 -0
- package/lib/artifacts/recipients.d.ts +31 -0
- package/lib/artifacts/recipients.js +87 -0
- package/lib/artifacts/render.js +18 -3
- package/lib/artifacts/state-store.d.ts +1 -0
- package/lib/artifacts/state-store.js +1 -0
- package/lib/artifacts/store.d.ts +4 -0
- package/lib/artifacts/store.js +9 -0
- package/lib/reader/index.d.ts +1 -0
- package/lib/reader/index.js +1 -0
- package/lib/reader/quiet-view.d.ts +7 -0
- package/lib/reader/quiet-view.js +118 -0
- package/lib/routes/artifacts.d.ts +29 -0
- package/lib/routes/artifacts.js +65 -9
- package/lib/routes/per-reader.d.ts +25 -0
- package/lib/routes/per-reader.js +38 -0
- package/lib/routes/recipients-routes.d.ts +34 -0
- package/lib/routes/recipients-routes.js +182 -0
- package/lib/routes/state-routes.d.ts +5 -0
- package/lib/routes/state-routes.js +37 -15
- package/package.json +1 -1
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// src/artifacts/recipients.ts
|
|
2
|
+
// A page published with `links: per-reader` opens only through a link made for one reader. The
|
|
3
|
+
// link names an opaque rid and a signature over the page and the rid, so a rid cannot be guessed
|
|
4
|
+
// or carried to another page. A view is counted only from a signal the rendered page sends, and
|
|
5
|
+
// that signal carries a token minted for this one render: a fetch that never runs the page (every
|
|
6
|
+
// messaging app's preview fetcher) can never produce one.
|
|
7
|
+
import { createHmac, randomBytes, timingSafeEqual, createHash } from 'node:crypto';
|
|
8
|
+
export const RID_RE = /^[a-z0-9]{12}$/;
|
|
9
|
+
const SIG_RE = /^[0-9a-f]{16}$/;
|
|
10
|
+
export const RID_COOKIE_MAX_AGE = 7_776_000;
|
|
11
|
+
export const VIEW_AFTER_MS = 2000;
|
|
12
|
+
export const TOKEN_MAX_AGE_MS = 86_400_000;
|
|
13
|
+
const ALPHA = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
14
|
+
/** A signing secret shorter than this is treated as unset by the routes: the page shuts. */
|
|
15
|
+
export const RECIPIENT_SECRET_MIN = 32;
|
|
16
|
+
export function usableSecret(secret) {
|
|
17
|
+
return typeof secret === 'string' && secret.length >= RECIPIENT_SECRET_MIN ? secret : undefined;
|
|
18
|
+
}
|
|
19
|
+
const mac = (secret, text) => {
|
|
20
|
+
// An empty key still produces a valid MAC that anyone can compute; never sign with one.
|
|
21
|
+
if (!secret)
|
|
22
|
+
throw new Error('a signing secret is required');
|
|
23
|
+
return createHmac('sha256', secret).update(text).digest('hex').slice(0, 16);
|
|
24
|
+
};
|
|
25
|
+
const same = (a, b) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
26
|
+
export function newRid() {
|
|
27
|
+
const bytes = randomBytes(12);
|
|
28
|
+
let s = '';
|
|
29
|
+
for (const b of bytes)
|
|
30
|
+
s += ALPHA[b % ALPHA.length];
|
|
31
|
+
return s;
|
|
32
|
+
}
|
|
33
|
+
export function ridSig(secret, id, rid) {
|
|
34
|
+
return mac(secret, `${id}\n${rid}`);
|
|
35
|
+
}
|
|
36
|
+
export function ridLink(pageUrl, id, rid, secret) {
|
|
37
|
+
return `${pageUrl}?r=${rid}.${ridSig(secret, id, rid)}`;
|
|
38
|
+
}
|
|
39
|
+
export function parseRidParam(v) {
|
|
40
|
+
if (typeof v !== 'string')
|
|
41
|
+
return null;
|
|
42
|
+
const [rid, sig, extra] = v.split('.');
|
|
43
|
+
if (extra !== undefined || !RID_RE.test(rid ?? '') || !SIG_RE.test(sig ?? ''))
|
|
44
|
+
return null;
|
|
45
|
+
return { rid, sig };
|
|
46
|
+
}
|
|
47
|
+
export function verifyRid(secret, id, rid, sig) {
|
|
48
|
+
if (!secret || !RID_RE.test(rid) || !SIG_RE.test(sig))
|
|
49
|
+
return false;
|
|
50
|
+
return same(ridSig(secret, id, rid), sig);
|
|
51
|
+
}
|
|
52
|
+
export function ridCookieName(id) {
|
|
53
|
+
return `artifact_r_${id}`;
|
|
54
|
+
}
|
|
55
|
+
/** `<time base36>.<nonce 16 hex>.<mac 16 hex>`, minted once per server render. */
|
|
56
|
+
export function mintRenderToken(secret, id, rid, now = Date.now()) {
|
|
57
|
+
const ts = now.toString(36);
|
|
58
|
+
const nonce = randomBytes(8).toString('hex');
|
|
59
|
+
return `${ts}.${nonce}.${mac(secret, `${id}\n${rid}\n${ts}\n${nonce}`)}`;
|
|
60
|
+
}
|
|
61
|
+
export function verifyRenderToken(secret, id, rid, token, now = Date.now()) {
|
|
62
|
+
const parts = String(token ?? '').split('.');
|
|
63
|
+
if (parts.length !== 3)
|
|
64
|
+
return { ok: false, why: 'malformed' };
|
|
65
|
+
if (!secret)
|
|
66
|
+
return { ok: false, why: 'no secret' };
|
|
67
|
+
const [ts, nonce, m] = parts;
|
|
68
|
+
if (!/^[0-9a-z]{1,12}$/.test(ts) || !SIG_RE.test(nonce) || !SIG_RE.test(m))
|
|
69
|
+
return { ok: false, why: 'malformed' };
|
|
70
|
+
if (!same(mac(secret, `${id}\n${rid}\n${ts}\n${nonce}`), m))
|
|
71
|
+
return { ok: false, why: 'not minted here' };
|
|
72
|
+
const at = parseInt(ts, 36);
|
|
73
|
+
if (!Number.isFinite(at) || now - at > TOKEN_MAX_AGE_MS || at - now > 60_000)
|
|
74
|
+
return { ok: false, why: 'expired' };
|
|
75
|
+
return { ok: true, key: createHash('sha256').update(token).digest('hex').slice(0, 32), expiresAt: at + TOKEN_MAX_AGE_MS };
|
|
76
|
+
}
|
|
77
|
+
export function beatKey(secret, id, rid, session) {
|
|
78
|
+
return mac(secret, `beat\n${id}\n${rid}\n${session}`);
|
|
79
|
+
}
|
|
80
|
+
/** Messaging apps' and search engines' preview fetchers. Apple's LinkPresentation presents as
|
|
81
|
+
* `facebookexternalhit/1.1 Facebot Twitterbot/1.0`. A request with no user agent is treated as one. */
|
|
82
|
+
export const UNFURL_AGENTS = /facebookexternalhit|facebot|twitterbot|slackbot|whatsapp|linkedinbot|discordbot|telegrambot|skypeuripreview|redditbot|applebot|googlebot|bingbot|embedly|iframely|pinterest|bitlybot|vkshare|mastodon|preview/i;
|
|
83
|
+
export function isUnfurlAgent(ua) {
|
|
84
|
+
if (!ua || !ua.trim())
|
|
85
|
+
return true;
|
|
86
|
+
return UNFURL_AGENTS.test(ua);
|
|
87
|
+
}
|
package/lib/artifacts/render.js
CHANGED
|
@@ -77,7 +77,18 @@ const VIDEO = /\.mp4(?:[?#].*)?$/i;
|
|
|
77
77
|
/** `` is a clip the reader plays: an annotated conversation, a voice note, a
|
|
78
78
|
* quote heard rather than read. Native controls, and `preload="none"` so a page of clips fetches
|
|
79
79
|
* nothing until one is pressed. The alt is its caption, shown above the player. */
|
|
80
|
-
const AUDIO = /\.mp3(?:[?#].*)?$/i;
|
|
80
|
+
const AUDIO = /\.(mp3|m4a)(?:[?#].*)?$/i;
|
|
81
|
+
/** The stored file's name, for play tracking: the last path segment before any query. */
|
|
82
|
+
const mediaName = (src) => {
|
|
83
|
+
const last = src.split(/[?#]/)[0].split('/').pop() ?? '';
|
|
84
|
+
try {
|
|
85
|
+
return decodeURIComponent(last);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return last;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
const SOUND = /^sound:\s*/i;
|
|
81
92
|
const VIDEO_IN_MD = /\]\([^)\s]+\.mp4(?:[?#][^)]*)?\)/i;
|
|
82
93
|
// A fenced block, which `links` and `notes` fences are too; mounting for those costs one
|
|
83
94
|
// no-op effect and is cheaper than parsing the markdown twice to find out.
|
|
@@ -88,11 +99,15 @@ const escapeAttr = (s) => s.replace(/&/g, '&').replace(/"/g, '"').repla
|
|
|
88
99
|
// iPhone from going full screen. No `autoplay`: VideoAutoplay starts each one as it nears the
|
|
89
100
|
// screen, so a page of videos does not download them all on open.
|
|
90
101
|
function InlineVideo({ src, label }) {
|
|
91
|
-
const
|
|
102
|
+
const name = escapeAttr(mediaName(src));
|
|
103
|
+
// `sound:` in the alt is a moment to watch: controls and sound, started by the reader.
|
|
104
|
+
const html = SOUND.test(label)
|
|
105
|
+
? `<video src="${escapeAttr(src)}" aria-label="${escapeAttr(label.replace(SOUND, ''))}" data-artifact-media="${name}" controls playsinline preload="metadata" class="block w-full"></video>`
|
|
106
|
+
: `<video src="${escapeAttr(src)}" aria-label="${escapeAttr(label)}" data-artifact-video data-artifact-media="${name}" muted loop playsinline preload="metadata" disablepictureinpicture class="block w-full"></video>`;
|
|
92
107
|
return _jsx("span", { className: "my-6 block overflow-hidden rounded-lg border border-[color:var(--a-line)]", dangerouslySetInnerHTML: { __html: html } });
|
|
93
108
|
}
|
|
94
109
|
function InlineAudio({ src, label }) {
|
|
95
|
-
const html = `<audio src="${escapeAttr(src)}" aria-label="${escapeAttr(label)}" data-artifact-audio controls preload="none" class="block w-full"></audio>`;
|
|
110
|
+
const html = `<audio src="${escapeAttr(src)}" aria-label="${escapeAttr(label)}" data-artifact-audio data-artifact-media="${escapeAttr(mediaName(src))}" controls preload="none" class="block w-full"></audio>`;
|
|
96
111
|
return (_jsxs("span", { className: "my-6 block rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-surface)] px-4 py-3", children: [label ? _jsx("span", { className: "mb-2 block text-sm text-[color:var(--a-strong)]", children: label }) : null, _jsx("span", { className: "block", dangerouslySetInnerHTML: { __html: html } })] }));
|
|
97
112
|
}
|
|
98
113
|
/** An embed block (```simulator, ```messages): the player in a frame when the host names one, its
|
|
@@ -81,6 +81,7 @@ export interface StateStore {
|
|
|
81
81
|
export declare const readerKeyFor: {
|
|
82
82
|
signedIn: (uid: string) => string;
|
|
83
83
|
anonymous: (id: string) => string;
|
|
84
|
+
recipient: (rid: string) => string;
|
|
84
85
|
};
|
|
85
86
|
export declare function createStateStore(db: Firestore, base: string): StateStore;
|
|
86
87
|
/** The same contract in memory: for tests, and for hosts' own tests. */
|
|
@@ -17,6 +17,7 @@ import { MAX_MANY_PER_READER } from './state.js';
|
|
|
17
17
|
export const readerKeyFor = {
|
|
18
18
|
signedIn: (uid) => `u:${uid}`,
|
|
19
19
|
anonymous: (id) => `a:${id}`,
|
|
20
|
+
recipient: (rid) => `r:${rid}`,
|
|
20
21
|
};
|
|
21
22
|
const writerOf = (w) => ({
|
|
22
23
|
...(w.uid ? { uid: w.uid } : {}), ...(w.email ? { email: w.email } : {}), name: w.name, anonymous: w.anonymous,
|
package/lib/artifacts/store.d.ts
CHANGED
|
@@ -22,6 +22,10 @@ export type ArtifactRecord = {
|
|
|
22
22
|
narrationHash?: string;
|
|
23
23
|
password?: string;
|
|
24
24
|
access?: Access;
|
|
25
|
+
/** `per-reader`: opens only through a link made for one reader. */
|
|
26
|
+
links?: 'per-reader';
|
|
27
|
+
/** Body media are stored unlisted and served only to a valid reader. */
|
|
28
|
+
privateMedia?: true;
|
|
25
29
|
state?: StateConfig;
|
|
26
30
|
theme?: ThemeMode;
|
|
27
31
|
/** The brand pack the page asked for by name; see ArtifactMeta.pack. */
|
package/lib/artifacts/store.js
CHANGED
|
@@ -193,6 +193,10 @@ async function saveArtifact(col, input) {
|
|
|
193
193
|
const to = input.meta.to ? {} : { to: FieldValue.delete() };
|
|
194
194
|
// How the page looks is content too: a line the file no longer carries goes back to the default.
|
|
195
195
|
const look = {
|
|
196
|
+
// Who may open the page is a door, not content: like `access`, a file that leaves these lines
|
|
197
|
+
// out changes nothing, so a publisher that strips unknown keys can never reopen the page.
|
|
198
|
+
...(input.meta.links ? { links: input.meta.links } : {}),
|
|
199
|
+
...(input.meta.privateMedia ? { privateMedia: true } : {}),
|
|
196
200
|
...(input.meta.theme ? {} : { theme: FieldValue.delete() }),
|
|
197
201
|
...(input.meta.pack ? {} : { pack: FieldValue.delete() }),
|
|
198
202
|
...(input.meta.toc !== undefined ? {} : { toc: FieldValue.delete() }),
|
|
@@ -219,6 +223,8 @@ async function saveArtifact(col, input) {
|
|
|
219
223
|
...fields,
|
|
220
224
|
...(live.access ? { access: live.access } : {}),
|
|
221
225
|
...(live.password ? { password: live.password } : {}),
|
|
226
|
+
...(input.meta.links ? { links: input.meta.links } : {}),
|
|
227
|
+
...(input.meta.privateMedia ? { privateMedia: true } : {}),
|
|
222
228
|
...(live.state ? { state: live.state } : {}),
|
|
223
229
|
...(live.comments ? { comments: live.comments } : {}),
|
|
224
230
|
...(live.commentsVisible ? { commentsVisible: live.commentsVisible } : {}),
|
|
@@ -238,6 +244,9 @@ async function deleteArtifact(col, id) {
|
|
|
238
244
|
const snap = await ref.get();
|
|
239
245
|
if (!snap.exists)
|
|
240
246
|
return false;
|
|
247
|
+
// Every prior body lives in the versions subcollection and outlives the page doc unless it goes too.
|
|
248
|
+
const past = await ref.collection('versions').get();
|
|
249
|
+
await Promise.all(past.docs.map((d) => ref.collection('versions').doc(d.id).delete()));
|
|
241
250
|
await ref.delete();
|
|
242
251
|
return true;
|
|
243
252
|
}
|
package/lib/reader/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { ArtifactReader, wrapWords, seekableWord, litWordIndex, type WordTiming } from './artifact-reader.js';
|
|
2
2
|
export { PlayerBar } from './player-bar.js';
|
|
3
|
+
export { QuietView } from './quiet-view.js';
|
|
3
4
|
export { CommentLayer } from './comment-layer.js';
|
|
4
5
|
export { PageMenu, roleLine, menuItems, type PageMenuProps, type PageRole } from './page-menu.js';
|
|
5
6
|
export { SettingsSheet, settingsChanges, ACCESS_LABEL, COMMENTS_LABEL, VISIBLE_LABEL, REOPEN_KEY, type PageSettings, type SettingsFact, type SheetKind, type AccessLevel } from './settings-sheet.js';
|
package/lib/reader/index.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// own 'use client'; this barrel is plain re-exports so it can be imported from either side.
|
|
3
3
|
export { ArtifactReader, wrapWords, seekableWord, litWordIndex } from './artifact-reader.js';
|
|
4
4
|
export { PlayerBar } from './player-bar.js';
|
|
5
|
+
export { QuietView } from './quiet-view.js';
|
|
5
6
|
export { CommentLayer } from './comment-layer.js';
|
|
6
7
|
export { PageMenu, roleLine, menuItems } from './page-menu.js';
|
|
7
8
|
export { SettingsSheet, settingsChanges, ACCESS_LABEL, COMMENTS_LABEL, VISIBLE_LABEL, REOPEN_KEY } from './settings-sheet.js';
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
// The page's own word that a person looked at it. Silent: it renders nothing and never tells the
|
|
3
|
+
// reader (the publisher chose that). It waits until the page is actually on screen for two
|
|
4
|
+
// seconds, which a link preview fetcher never does because it never runs the page.
|
|
5
|
+
import { useEffect } from 'react';
|
|
6
|
+
const VIEW_AFTER_MS = 2000;
|
|
7
|
+
const BEAT_MS = 15_000;
|
|
8
|
+
const ALPHA = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
9
|
+
const rand = (n) => Array.from(crypto.getRandomValues(new Uint8Array(n)), (b) => ALPHA[b % 36]).join('');
|
|
10
|
+
function deviceId() {
|
|
11
|
+
try {
|
|
12
|
+
let d = localStorage.getItem('artifact_device');
|
|
13
|
+
if (!d || !/^[a-z0-9]{16}$/.test(d)) {
|
|
14
|
+
d = rand(16);
|
|
15
|
+
localStorage.setItem('artifact_device', d);
|
|
16
|
+
}
|
|
17
|
+
return d;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return rand(16);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function QuietView({ id, rid, sig, token, endpoint }) {
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const url = endpoint ?? `/api/artifacts/${id}/seen`;
|
|
26
|
+
const session = rand(16);
|
|
27
|
+
const device = deviceId();
|
|
28
|
+
const base = { rid, sig, session, device };
|
|
29
|
+
let beat = null;
|
|
30
|
+
let viewSent = false;
|
|
31
|
+
let wait = null;
|
|
32
|
+
let tick = null;
|
|
33
|
+
let since = 0;
|
|
34
|
+
const post = async (body) => {
|
|
35
|
+
try {
|
|
36
|
+
const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ...base, ...body }), keepalive: true });
|
|
37
|
+
return r.ok ? (await r.json().catch(() => ({}))) : null;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const visible = () => document.visibilityState === 'visible' && !document.prerendering;
|
|
44
|
+
const flush = (useBeacon) => {
|
|
45
|
+
if (!beat || !since)
|
|
46
|
+
return;
|
|
47
|
+
const seconds = Math.round((Date.now() - since) / 1000);
|
|
48
|
+
since = 0;
|
|
49
|
+
if (seconds <= 0)
|
|
50
|
+
return;
|
|
51
|
+
if (useBeacon && typeof navigator.sendBeacon === 'function') {
|
|
52
|
+
navigator.sendBeacon(url, new Blob([JSON.stringify({ ...base, event: 'beat', beat, seconds })], { type: 'application/json' }));
|
|
53
|
+
}
|
|
54
|
+
else
|
|
55
|
+
void post({ event: 'beat', beat, seconds });
|
|
56
|
+
};
|
|
57
|
+
const startBeats = () => {
|
|
58
|
+
since = Date.now();
|
|
59
|
+
tick = setInterval(() => { flush(false); since = Date.now(); }, BEAT_MS);
|
|
60
|
+
};
|
|
61
|
+
const arm = () => {
|
|
62
|
+
if (!visible())
|
|
63
|
+
return;
|
|
64
|
+
if (beat) {
|
|
65
|
+
if (!tick)
|
|
66
|
+
startBeats();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
// One render token, one view: once it is sent, a later show never sends another.
|
|
70
|
+
if (viewSent || wait)
|
|
71
|
+
return;
|
|
72
|
+
wait = setTimeout(async () => {
|
|
73
|
+
wait = null;
|
|
74
|
+
if (!visible())
|
|
75
|
+
return;
|
|
76
|
+
viewSent = true;
|
|
77
|
+
const r = await post({ event: 'view', token });
|
|
78
|
+
if (r?.beat && visible()) {
|
|
79
|
+
beat = r.beat;
|
|
80
|
+
startBeats();
|
|
81
|
+
}
|
|
82
|
+
else if (r?.beat)
|
|
83
|
+
beat = r.beat;
|
|
84
|
+
}, VIEW_AFTER_MS);
|
|
85
|
+
};
|
|
86
|
+
const disarm = () => {
|
|
87
|
+
if (wait) {
|
|
88
|
+
clearTimeout(wait);
|
|
89
|
+
wait = null;
|
|
90
|
+
}
|
|
91
|
+
if (tick) {
|
|
92
|
+
clearInterval(tick);
|
|
93
|
+
tick = null;
|
|
94
|
+
flush(true);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
const onVisibility = () => (visible() ? arm() : disarm());
|
|
98
|
+
const onPlay = (e) => {
|
|
99
|
+
const el = e.target;
|
|
100
|
+
const media = el?.getAttribute?.('data-artifact-media');
|
|
101
|
+
if (media && beat)
|
|
102
|
+
void post({ event: 'play', beat, media });
|
|
103
|
+
};
|
|
104
|
+
document.addEventListener('visibilitychange', onVisibility);
|
|
105
|
+
document.addEventListener('prerenderingchange', onVisibility);
|
|
106
|
+
document.addEventListener('play', onPlay, true);
|
|
107
|
+
window.addEventListener('pagehide', disarm);
|
|
108
|
+
arm();
|
|
109
|
+
return () => {
|
|
110
|
+
document.removeEventListener('visibilitychange', onVisibility);
|
|
111
|
+
document.removeEventListener('prerenderingchange', onVisibility);
|
|
112
|
+
document.removeEventListener('play', onPlay, true);
|
|
113
|
+
window.removeEventListener('pagehide', disarm);
|
|
114
|
+
disarm();
|
|
115
|
+
};
|
|
116
|
+
}, [id, rid, sig, token, endpoint]);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
@@ -4,6 +4,7 @@ import { type ArtifactRecord, type ArtifactStore, type VersionEntry } from '../a
|
|
|
4
4
|
import type { StateStore } from '../artifacts/state-store.js';
|
|
5
5
|
import { type ArtifactAssets } from '../artifacts/assets.js';
|
|
6
6
|
import type { BrandPack } from '../brand/pack.js';
|
|
7
|
+
import type { RecipientsStore } from '../artifacts/recipients-store.js';
|
|
7
8
|
import type { PageSettings, SettingsFact } from '../reader/settings-sheet.js';
|
|
8
9
|
import { type Access, type AllowEntry } from '../artifacts/reader.js';
|
|
9
10
|
import { type ReadersStore } from '../artifacts/readers-store.js';
|
|
@@ -104,6 +105,11 @@ export type ArtifactRoutesConfig = {
|
|
|
104
105
|
* bare URL sees, and search engines). Called only for the owner and admins; a throw falls back
|
|
105
106
|
* to the package's rows. */
|
|
106
107
|
shareFacts?: (artifact: ArtifactRecord) => SettingsFact[] | Promise<SettingsFact[]>;
|
|
108
|
+
/** Who each per-reader page was sent to (recipients-store.ts). Without it, or without
|
|
109
|
+
* `recipientSecret`, a `links: per-reader` page is shut to everyone. */
|
|
110
|
+
recipients?: RecipientsStore;
|
|
111
|
+
/** Signs per-reader links and render tokens. At least 32 characters. Read at request time. */
|
|
112
|
+
recipientSecret?: () => string | undefined;
|
|
107
113
|
};
|
|
108
114
|
/** Ids are 8 chars from the safe alphabet; anything else is not a page and never reaches the store. */
|
|
109
115
|
export declare const ARTIFACT_ID: RegExp;
|
|
@@ -115,6 +121,7 @@ type Params = {
|
|
|
115
121
|
type PageProps = Params & {
|
|
116
122
|
searchParams?: Promise<{
|
|
117
123
|
key?: string | string[];
|
|
124
|
+
r?: string | string[];
|
|
118
125
|
}>;
|
|
119
126
|
};
|
|
120
127
|
type VersionParams = {
|
|
@@ -126,11 +133,33 @@ type VersionParams = {
|
|
|
126
133
|
type VersionProps = VersionParams & {
|
|
127
134
|
searchParams?: Promise<{
|
|
128
135
|
key?: string | string[];
|
|
136
|
+
r?: string | string[];
|
|
129
137
|
}>;
|
|
130
138
|
};
|
|
131
139
|
export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
|
|
132
140
|
dynamic: "force-dynamic";
|
|
133
141
|
maxDuration: number;
|
|
142
|
+
SEEN: (req: NextRequest, { params }: {
|
|
143
|
+
params: Promise<{
|
|
144
|
+
id: string;
|
|
145
|
+
}>;
|
|
146
|
+
}) => Promise<NextResponse<unknown>>;
|
|
147
|
+
RECIPIENTS: (req: NextRequest, { params }: {
|
|
148
|
+
params: Promise<{
|
|
149
|
+
id: string;
|
|
150
|
+
}>;
|
|
151
|
+
}) => Promise<NextResponse<unknown>>;
|
|
152
|
+
VIEWS: (req: NextRequest, { params }: {
|
|
153
|
+
params: Promise<{
|
|
154
|
+
id: string;
|
|
155
|
+
}>;
|
|
156
|
+
}) => Promise<NextResponse<unknown>>;
|
|
157
|
+
sweepExpired: (now?: Date) => Promise<{
|
|
158
|
+
pages: number;
|
|
159
|
+
files: number;
|
|
160
|
+
skipped: string[];
|
|
161
|
+
failed: string[];
|
|
162
|
+
}>;
|
|
134
163
|
PERSONAL_GET: (req: NextRequest, { params }: {
|
|
135
164
|
params: Promise<{
|
|
136
165
|
id: string;
|
package/lib/routes/artifacts.js
CHANGED
|
@@ -21,6 +21,8 @@ import { ArtifactReader } from '../reader/artifact-reader.js';
|
|
|
21
21
|
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
|
+
import { perReaderGate, withPrivateUrls, NO_LINK, UNAVAILABLE } from './per-reader.js';
|
|
25
|
+
import { ridCookieName, mintRenderToken, usableSecret, RID_COOKIE_MAX_AGE } from '../artifacts/recipients.js';
|
|
24
26
|
import { PageMenu } from '../reader/page-menu.js';
|
|
25
27
|
import { COMMENTS_MODES } from '../artifacts/comments.js';
|
|
26
28
|
import { newPageKey } from '../artifacts/settings.js';
|
|
@@ -31,12 +33,16 @@ import { readSecondsFor, wordCount } from '../artifacts/read-alerts.js';
|
|
|
31
33
|
import { AckDoor, ConfidentialBanner, NO_PRINT_CSS, NotAllowedDoor, SignInDoor, Watermark, ackText } from '../artifacts/confidential.js';
|
|
32
34
|
import { ARTIFACT_ID_RE } from './ids.js';
|
|
33
35
|
import { createStateRoutes } from './state-routes.js';
|
|
36
|
+
import { createRecipientsRoutes } from './recipients-routes.js';
|
|
37
|
+
import { QuietView } from '../reader/quiet-view.js';
|
|
34
38
|
import { createPersonalRoutes } from './personal-routes.js';
|
|
35
39
|
import { createVoiceRoutes } from './voice-routes.js';
|
|
36
40
|
/** Ids are 8 chars from the safe alphabet; anything else is not a page and never reaches the store. */
|
|
37
41
|
export const ARTIFACT_ID = ARTIFACT_ID_RE;
|
|
38
42
|
/** A version number as it appears in a URL: no zero, no leading zero, so each version has one address. */
|
|
39
43
|
const VERSION_N = /^[1-9]\d{0,5}$/;
|
|
44
|
+
/** What a host with no private store answers to private media, in words that say what to set up. */
|
|
45
|
+
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';
|
|
40
46
|
async function defaultReadCookie(name) {
|
|
41
47
|
const { cookies } = await import('next/headers');
|
|
42
48
|
return (await cookies()).get(name)?.value;
|
|
@@ -87,10 +93,14 @@ export function createArtifactRoutes(config) {
|
|
|
87
93
|
const publishAuth = { publishKey: config.publishKey, publisherSecret: config.publisherSecret, publisherHost: config.publisherHost };
|
|
88
94
|
const authed = (request) => isPublisherAuthed(request, publishAuth);
|
|
89
95
|
const stateRoutes = createStateRoutes({
|
|
90
|
-
store, state: config.state, readers: config.readers, readerSecret, publishKey: config.publishKey,
|
|
96
|
+
store, state: config.state, readers: config.readers, readerSecret, recipients: config.recipients, recipientSecret: config.recipientSecret, publishKey: config.publishKey,
|
|
91
97
|
publisherSecret: config.publisherSecret, publisherHost: config.publisherHost, pageUrl, signInOrigin, siteUrl,
|
|
92
98
|
clientIp: config.clientIp, ownerEmail: config.ownerEmail, admins: config.admins, audioUrl, onResolve: config.onResolve,
|
|
93
99
|
});
|
|
100
|
+
const recipientsRoutes = createRecipientsRoutes({
|
|
101
|
+
store, recipients: config.recipients, recipientSecret: config.recipientSecret, publishKey: config.publishKey,
|
|
102
|
+
state: config.state, assets: config.assets, pageUrl, authed,
|
|
103
|
+
});
|
|
94
104
|
const personalRoutes = createPersonalRoutes({ store, personal: config.personal, readers: config.readers, readerSecret, pageUrl, signInOrigin, siteUrl, audioUrl });
|
|
95
105
|
const voiceRoutes = createVoiceRoutes({
|
|
96
106
|
store, state: config.state, personal: config.personal, readers: config.readers, assets: config.assets, transcribe: config.transcribe,
|
|
@@ -272,6 +282,32 @@ export function createArtifactRoutes(config) {
|
|
|
272
282
|
const render = (top, reader) => (n === null ? Body(a, { top, reader }) : VersionBody(a, n, { top, reader }));
|
|
273
283
|
if (a.access)
|
|
274
284
|
return GatedPage(a, id, render, n);
|
|
285
|
+
if (a.links === 'per-reader') {
|
|
286
|
+
if (n !== null)
|
|
287
|
+
notFound(); // a past version of a per-reader page is never served
|
|
288
|
+
const sp = (await searchParams) ?? {};
|
|
289
|
+
const param = Array.isArray(sp.r) ? sp.r[0] : sp.r;
|
|
290
|
+
const readCookie = config.readCookie ?? defaultReadCookie;
|
|
291
|
+
const privateUrl = config.assets?.privateUrl?.bind(config.assets);
|
|
292
|
+
// A signed-in owner or admin gets through as the owner, with no link and no tracking.
|
|
293
|
+
const me = readerSecret() ? verifyGrant(readerSecret(), await readCookie(GRANT_COOKIE).catch(() => undefined)) : null;
|
|
294
|
+
if (isAdmin(me)) {
|
|
295
|
+
const markdown = a.privateMedia ? await withPrivateUrls(a.markdown, id, privateUrl) : a.markdown;
|
|
296
|
+
return _jsx(BrandGround, { pack: pack, mode: a.theme, children: await Body({ ...a, markdown }, { top: true, reader: me }) });
|
|
297
|
+
}
|
|
298
|
+
const secret = usableSecret(config.recipientSecret?.());
|
|
299
|
+
const g = await perReaderGate({ id, param, cookie: await readCookie(ridCookieName(id)).catch(() => undefined), secret, recipients: config.recipients });
|
|
300
|
+
if (g.state === 'shut') {
|
|
301
|
+
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
|
+
}
|
|
303
|
+
const token = mintRenderToken(secret, id, g.rid);
|
|
304
|
+
const remember = g.fromLink
|
|
305
|
+
? [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
|
+
: null;
|
|
307
|
+
const markdown = a.privateMedia ? await withPrivateUrls(a.markdown, id, privateUrl) : a.markdown;
|
|
308
|
+
const body = await Body({ ...a, markdown }, { top: true, reader: null, perReader: { rid: g.rid, sig: g.sig, label: g.label, note: g.note, owner: g.owner, token } });
|
|
309
|
+
return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [remember ? _jsx("script", { dangerouslySetInnerHTML: { __html: remember } }) : null, body] }));
|
|
310
|
+
}
|
|
275
311
|
// A password shuts the body, never the title: the header stays so the reader knows which
|
|
276
312
|
// page they were sent, and the unfurl (generateMetadata) keeps reading as the page.
|
|
277
313
|
const sp = (await searchParams) ?? {};
|
|
@@ -385,7 +421,7 @@ export function createArtifactRoutes(config) {
|
|
|
385
421
|
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 }) })] }));
|
|
386
422
|
}
|
|
387
423
|
/** The page itself, shared by open and gated pages. */
|
|
388
|
-
async function Body(a, { top, reader }) {
|
|
424
|
+
async function Body(a, { top, reader, perReader }) {
|
|
389
425
|
const { pack, brand } = lookOf(a);
|
|
390
426
|
let words = [];
|
|
391
427
|
if (a.narration && a.timings) {
|
|
@@ -407,10 +443,10 @@ export function createArtifactRoutes(config) {
|
|
|
407
443
|
const n = a.version ?? (a.versions?.length ?? 0) + 1;
|
|
408
444
|
const meta = (_jsxs(_Fragment, { children: [_jsx(VersionLine, { n: n, at: a.updatedAt }), top && reader ? (_jsxs("p", { "data-nospeak": true, "data-reader-line": true, className: "mt-2 text-xs opacity-60", children: [`Signed in as ${reader.email} · `, _jsx("a", { href: switchUrl(a.id), className: "underline", children: "Use a different account" }), ' · ', _jsx("a", { href: signOutUrl(a.id), className: "underline", children: "Sign out" })] })) : null] }));
|
|
409
445
|
const header = pack.Cover ? (_jsx(pack.Cover, { title: a.title, subtitle: a.subtitle, summary: a.summary, to: a.to, markdown: a.markdown, kicker: _jsx(Kicker, { to: a.to, brand: brand }), meta: meta, version: n, updatedAt: a.updatedAt, createdAt: a.createdAt })) : (_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-24 sm:pt-28' : 'pt-12'} pb-8 text-center`, children: [_jsx(Kicker, { to: a.to, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), a.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: a.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: a.summary }), meta] }));
|
|
410
|
-
return (_jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: _jsxs(_Fragment, { children: [header, a.cover ? (_jsx("div", { className: "mx-auto max-w-2xl px-6 pb-8", children: _jsx("img", { src: a.cover, alt: "", className: "w-full rounded-xl border border-[color:var(--a-line)]" }) })) : null] }), children: _jsx(ArtifactMarkdown, { markdown: a.markdown, definitions: a.definitions, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined,
|
|
446
|
+
return (_jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: _jsxs(_Fragment, { children: [perReader?.note ? (_jsx("p", { "data-for-reader": true, className: "mx-auto mt-6 mb-0 max-w-2xl px-6 text-lg", children: `For ${perReader.label}: ${perReader.note}` })) : null, header, a.cover ? (_jsx("div", { className: "mx-auto max-w-2xl px-6 pb-8", children: _jsx("img", { src: a.cover, alt: "", className: "w-full rounded-xl border border-[color:var(--a-line)]" }) })) : null] }), children: _jsx(ArtifactMarkdown, { markdown: a.markdown, definitions: a.definitions, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined,
|
|
411
447
|
// Ticks are kept in the reader's browser on any host; Send needs one that keeps answers.
|
|
412
448
|
checklist: { artifactId: a.id, send: !!config.state && !!a.state && Object.hasOwn(a.state.slots, CHECKLIST_SLOT), owner, accent: brand.accent }, Section: pack.Section, figure: pack.figure, simulator: config.simulatorEmbed ? { embed: config.simulatorEmbed } : undefined, messages: config.messagesEmbed ? { embed: config.messagesEmbed } : undefined }) }));
|
|
413
|
-
})() }), _jsx(CommentLayer, { artifactId: a.id, rootId: "artifact-narration-root", ownerName: owner, comments: mode, canShare: mode !== 'off' && (reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone'), signedIn: reader !== null, isOwner: isAdmin(reader), version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null }), a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", accent: brand.accent, ground: brand.ground })) : null /* No recorded narration: no player. Never the browser's own voice (the operator,
|
|
449
|
+
})() }), _jsx(CommentLayer, { artifactId: a.id, rootId: "artifact-narration-root", ownerName: owner, comments: mode, canShare: mode !== 'off' && (reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone'), signedIn: reader !== null, isOwner: isAdmin(reader), version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null }), perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null, a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", accent: brand.accent, ground: brand.ground })) : null /* No recorded narration: no player. Never the browser's own voice (the operator,
|
|
414
450
|
2026-09-29: "NEVER PUT DEFAULT SAFARI VOICE DICTATION"); the audio is Kokoro or ElevenLabs,
|
|
415
451
|
recorded at publish, or there is none. */] }));
|
|
416
452
|
}
|
|
@@ -523,6 +559,8 @@ export function createArtifactRoutes(config) {
|
|
|
523
559
|
if (request.method === 'GET')
|
|
524
560
|
return NextResponse.json({ id, access: a.access ?? null, readers: await config.readers.allowList(id) });
|
|
525
561
|
const b = (await request.json().catch(() => null));
|
|
562
|
+
if (a.links === 'per-reader' && b?.access !== undefined)
|
|
563
|
+
return NextResponse.json({ error: 'a per-reader page opens only from its own links, so its access and password cannot be changed' }, { status: 409 });
|
|
526
564
|
let access = a.access ?? null;
|
|
527
565
|
if (b?.access !== undefined) {
|
|
528
566
|
if (b.access !== 'public' && !ACCESS_LEVELS.includes(b.access))
|
|
@@ -587,6 +625,8 @@ export function createArtifactRoutes(config) {
|
|
|
587
625
|
}
|
|
588
626
|
if (!b || typeof b !== 'object' || Array.isArray(b))
|
|
589
627
|
return no('the body is an object', 400);
|
|
628
|
+
if (a.links === 'per-reader' && (b.access !== undefined || b.password !== undefined))
|
|
629
|
+
return no('a per-reader page opens only from its own links, so its access and password cannot be changed', 409);
|
|
590
630
|
const KEYS = ['access', 'password', 'comments', 'commentsVisible', 'add', 'remove'];
|
|
591
631
|
const unknown = Object.keys(b).filter((k) => !KEYS.includes(k));
|
|
592
632
|
if (unknown.length)
|
|
@@ -659,6 +699,8 @@ export function createArtifactRoutes(config) {
|
|
|
659
699
|
const [sessions, flags, acks] = await Promise.all([config.readers.sessions(id), config.readers.flags(id), config.readers.acks(id)]);
|
|
660
700
|
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' } });
|
|
661
701
|
}
|
|
702
|
+
/** Whether this host can store and serve private media: both halves come from a private bucket. */
|
|
703
|
+
const keepsPrivateMedia = () => Boolean(config.assets?.putPrivate && config.assets.privateUrl);
|
|
662
704
|
async function POST(request) {
|
|
663
705
|
if (!authed(request))
|
|
664
706
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
@@ -667,6 +709,11 @@ export function createArtifactRoutes(config) {
|
|
|
667
709
|
if (!parsed.ok)
|
|
668
710
|
return NextResponse.json({ error: parsed.error }, { status: 400 });
|
|
669
711
|
const id = request.nextUrl.searchParams.get('id') ?? parsed.meta.id ?? undefined;
|
|
712
|
+
// Fail closed: private media needs a private store. Without one the media would have to sit in
|
|
713
|
+
// the public bucket, which may carry a bucket-wide public read grant, so nothing is saved.
|
|
714
|
+
// `media:` is sticky, so a stored page that already keeps private media counts too.
|
|
715
|
+
if (!keepsPrivateMedia() && (parsed.meta.privateMedia || (id && (await store.get(id))?.privateMedia)))
|
|
716
|
+
return NextResponse.json({ error: NO_PRIVATE_STORE }, { status: 501 });
|
|
670
717
|
if (id && parsed.meta.state) {
|
|
671
718
|
const existing = await store.get(id);
|
|
672
719
|
const changed = shapeChanges(existing?.state, parsed.meta.state);
|
|
@@ -719,6 +766,9 @@ export function createArtifactRoutes(config) {
|
|
|
719
766
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
720
767
|
if (!config.assets)
|
|
721
768
|
return NextResponse.json({ error: 'this host does not store assets' }, { status: 501 });
|
|
769
|
+
const priv = request.nextUrl.searchParams.get('private') === '1';
|
|
770
|
+
if (priv && !config.assets.putPrivate)
|
|
771
|
+
return NextResponse.json({ error: NO_PRIVATE_STORE }, { status: 501 });
|
|
722
772
|
const { id, name } = await params;
|
|
723
773
|
if (!ARTIFACT_ID.test(id))
|
|
724
774
|
return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
|
|
@@ -734,7 +784,7 @@ export function createArtifactRoutes(config) {
|
|
|
734
784
|
return NextResponse.json({ error: 'empty body' }, { status: 400 });
|
|
735
785
|
if (bytes.length > 16 * 1024 * 1024)
|
|
736
786
|
return NextResponse.json({ error: 'asset over 16 MiB' }, { status: 413 });
|
|
737
|
-
const url = await config.assets.put(id, name, bytes, type);
|
|
787
|
+
const url = priv ? await config.assets.putPrivate(id, name, bytes, type) : await config.assets.put(id, name, bytes, type);
|
|
738
788
|
return NextResponse.json({ id, name, url }, { status: 201 });
|
|
739
789
|
}
|
|
740
790
|
/**
|
|
@@ -762,7 +812,12 @@ export function createArtifactRoutes(config) {
|
|
|
762
812
|
if (!authed(request))
|
|
763
813
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
764
814
|
const assets = config.assets;
|
|
765
|
-
|
|
815
|
+
const priv = request.nextUrl.searchParams.get('private') === '1';
|
|
816
|
+
if (priv) {
|
|
817
|
+
if (!assets?.signPrivateUpload || !assets.finishPrivateUpload)
|
|
818
|
+
return NextResponse.json({ error: NO_PRIVATE_STORE }, { status: 501 });
|
|
819
|
+
}
|
|
820
|
+
else if (!assets?.signUpload || !assets.finishUpload)
|
|
766
821
|
return NextResponse.json({ error: 'this host does not take direct uploads' }, { status: 501 });
|
|
767
822
|
const { id, name } = await params;
|
|
768
823
|
if (!ARTIFACT_ID.test(id))
|
|
@@ -778,12 +833,13 @@ export function createArtifactRoutes(config) {
|
|
|
778
833
|
if (!ASSET_DIGEST.test(digest))
|
|
779
834
|
return NextResponse.json({ error: 'digest must be the first 8 hex of the sha256 of the bytes' }, { status: 400 });
|
|
780
835
|
if (body.done === true) {
|
|
781
|
-
const url = await assets.finishUpload(id, name, digest);
|
|
836
|
+
const url = priv ? await assets.finishPrivateUpload(id, name, digest) : await assets.finishUpload(id, name, digest);
|
|
782
837
|
if (!url)
|
|
783
838
|
return NextResponse.json({ error: 'nothing was uploaded to that url' }, { status: 409 });
|
|
784
839
|
return NextResponse.json({ id, name, url }, { status: 201 });
|
|
785
840
|
}
|
|
786
|
-
|
|
841
|
+
const signed = priv ? await assets.signPrivateUpload(id, name, digest, type) : await assets.signUpload(id, name, digest, type);
|
|
842
|
+
return NextResponse.json({ id, name, ...signed }, { status: 201 });
|
|
787
843
|
}
|
|
788
844
|
/** GET /<id>/share.png: the page's title card. 404 when the pack draws none, so a
|
|
789
845
|
* tenant on the static default never serves a half-styled card. */
|
|
@@ -828,5 +884,5 @@ export function createArtifactRoutes(config) {
|
|
|
828
884
|
revalidatePath(pagePath(id));
|
|
829
885
|
return NextResponse.json({ deleted: true });
|
|
830
886
|
}
|
|
831
|
-
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, dynamic: 'force-dynamic', maxDuration: 30 };
|
|
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 };
|
|
832
888
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { RecipientsStore } from '../artifacts/recipients-store.js';
|
|
2
|
+
export type PerReaderOpen = {
|
|
3
|
+
state: 'open';
|
|
4
|
+
rid: string;
|
|
5
|
+
sig: string;
|
|
6
|
+
label: string;
|
|
7
|
+
note: string | null;
|
|
8
|
+
owner: boolean;
|
|
9
|
+
fromLink: boolean;
|
|
10
|
+
};
|
|
11
|
+
export type PerReaderShut = {
|
|
12
|
+
state: 'shut';
|
|
13
|
+
why: 'no-link' | 'revoked' | 'expired' | 'closed';
|
|
14
|
+
};
|
|
15
|
+
export declare const UNAVAILABLE = "This page is no longer available.";
|
|
16
|
+
export declare const NO_LINK = "This page opens from the link it was sent with.";
|
|
17
|
+
export declare function perReaderGate(o: {
|
|
18
|
+
id: string;
|
|
19
|
+
param: string | undefined;
|
|
20
|
+
cookie: string | undefined;
|
|
21
|
+
secret: string | undefined;
|
|
22
|
+
recipients: RecipientsStore | undefined;
|
|
23
|
+
now?: number;
|
|
24
|
+
}): Promise<PerReaderOpen | PerReaderShut>;
|
|
25
|
+
export declare function withPrivateUrls(markdown: string, id: string, privateUrl: ((id: string, stored: string) => Promise<string>) | undefined): Promise<string>;
|