@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,38 @@
|
|
|
1
|
+
// Who is reading a `links: per-reader` page, decided before anything of the page is rendered.
|
|
2
|
+
// Every refusal is the same quiet page; a revoked or expired reader learns only that it is gone.
|
|
3
|
+
import { parseRidParam, verifyRid } from '../artifacts/recipients.js';
|
|
4
|
+
import { PRIVATE_REF } from '../artifacts/assets.js';
|
|
5
|
+
export const UNAVAILABLE = 'This page is no longer available.';
|
|
6
|
+
export const NO_LINK = 'This page opens from the link it was sent with.';
|
|
7
|
+
export async function perReaderGate(o) {
|
|
8
|
+
// An unset or empty secret verifies nothing: fail closed before any signature is compared.
|
|
9
|
+
if (!o.secret || !o.recipients)
|
|
10
|
+
return { state: 'shut', why: 'closed' };
|
|
11
|
+
const fromParam = parseRidParam(o.param);
|
|
12
|
+
const fromCookie = parseRidParam(o.cookie);
|
|
13
|
+
const pick = fromParam && verifyRid(o.secret, o.id, fromParam.rid, fromParam.sig) ? { ...fromParam, fromLink: true }
|
|
14
|
+
: fromCookie && verifyRid(o.secret, o.id, fromCookie.rid, fromCookie.sig) ? { ...fromCookie, fromLink: false } : null;
|
|
15
|
+
if (!pick)
|
|
16
|
+
return { state: 'shut', why: 'no-link' };
|
|
17
|
+
const rec = await o.recipients.get(o.id);
|
|
18
|
+
const r = rec?.recipients[pick.rid];
|
|
19
|
+
if (!rec || !r)
|
|
20
|
+
return { state: 'shut', why: 'no-link' };
|
|
21
|
+
if (r.revokedAt)
|
|
22
|
+
return { state: 'shut', why: 'revoked' };
|
|
23
|
+
// A malformed expireAt parses to NaN: treat it as expired, never as no expiry.
|
|
24
|
+
if (rec.expireAt && !(Date.parse(rec.expireAt) > (o.now ?? Date.now())))
|
|
25
|
+
return { state: 'shut', why: 'expired' };
|
|
26
|
+
return { state: 'open', rid: pick.rid, sig: pick.sig, label: r.label, note: r.note ?? null, owner: r.owner === true, fromLink: pick.fromLink };
|
|
27
|
+
}
|
|
28
|
+
const REF_IN_MD = /\]\((private:[A-Za-z0-9][A-Za-z0-9._-]{0,140})\)/g;
|
|
29
|
+
export async function withPrivateUrls(markdown, id, privateUrl) {
|
|
30
|
+
const refs = [...new Set([...markdown.matchAll(REF_IN_MD)].map((m) => m[1]))];
|
|
31
|
+
let out = markdown;
|
|
32
|
+
for (const ref of refs) {
|
|
33
|
+
const stored = PRIVATE_REF.exec(ref)?.[1];
|
|
34
|
+
const url = stored && privateUrl ? await privateUrl(id, stored) : '#';
|
|
35
|
+
out = out.split(`](${ref})`).join(`](${url})`);
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
2
|
+
import { type RecipientsStore } from '../artifacts/recipients-store.js';
|
|
3
|
+
import type { ArtifactStore } from '../artifacts/store.js';
|
|
4
|
+
import type { StateStore } from '../artifacts/state-store.js';
|
|
5
|
+
import type { ArtifactAssets } from '../artifacts/assets.js';
|
|
6
|
+
type Params = {
|
|
7
|
+
params: Promise<{
|
|
8
|
+
id: string;
|
|
9
|
+
}>;
|
|
10
|
+
};
|
|
11
|
+
export type RecipientsRoutesCtx = {
|
|
12
|
+
store: ArtifactStore;
|
|
13
|
+
recipients?: RecipientsStore;
|
|
14
|
+
recipientSecret?: () => string | undefined;
|
|
15
|
+
publishKey: () => string | undefined;
|
|
16
|
+
state?: StateStore;
|
|
17
|
+
assets?: ArtifactAssets;
|
|
18
|
+
pageUrl: (id: string) => string;
|
|
19
|
+
authed: (r: Request) => boolean;
|
|
20
|
+
/** Where the sweep reports a page it skipped or could not finish. Defaults to console.warn. */
|
|
21
|
+
log?: (message: string) => void;
|
|
22
|
+
};
|
|
23
|
+
export declare function createRecipientsRoutes(given: RecipientsRoutesCtx): {
|
|
24
|
+
SEEN: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown>>;
|
|
25
|
+
RECIPIENTS: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown>>;
|
|
26
|
+
VIEWS: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown>>;
|
|
27
|
+
sweepExpired: (now?: Date) => Promise<{
|
|
28
|
+
pages: number;
|
|
29
|
+
files: number;
|
|
30
|
+
skipped: string[];
|
|
31
|
+
failed: string[];
|
|
32
|
+
}>;
|
|
33
|
+
};
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// The per-reader routes. SEEN is public (the page calls it); everything else needs the publish key.
|
|
2
|
+
import { NextResponse } from 'next/server';
|
|
3
|
+
import { RID_RE, usableSecret, beatKey, isUnfurlAgent, ridLink, verifyRenderToken, verifyRid } from '../artifacts/recipients.js';
|
|
4
|
+
import { summarizeViews } from '../artifacts/recipients-store.js';
|
|
5
|
+
import { COMMENTS_SLOT } from '../artifacts/comments.js';
|
|
6
|
+
import { ARTIFACT_ID_RE } from './ids.js';
|
|
7
|
+
// Session and device ids become part of a stored view's id, so only a short plain shape is let in.
|
|
8
|
+
const SESSION = /^[a-z0-9]{1,32}$/;
|
|
9
|
+
const MEDIA = /^[A-Za-z0-9][A-Za-z0-9._-]{0,140}$/;
|
|
10
|
+
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/;
|
|
11
|
+
const none = () => new NextResponse(null, { status: 204, headers: { 'cache-control': 'no-store' } });
|
|
12
|
+
const json = (b, status = 200) => NextResponse.json(b, { status, headers: { 'cache-control': 'no-store' } });
|
|
13
|
+
export function createRecipientsRoutes(given) {
|
|
14
|
+
// A secret under the documented minimum is no secret: every route treats it as unset.
|
|
15
|
+
const ctx = { ...given, recipientSecret: () => usableSecret(given.recipientSecret?.()) };
|
|
16
|
+
/** POST /api/artifacts/<id>/seen: the page's own signal that a reader looked at it. */
|
|
17
|
+
async function SEEN(req, { params }) {
|
|
18
|
+
const { id } = await params;
|
|
19
|
+
// An unfurl agent is dropped before anything is read: no state, no answer worth reading.
|
|
20
|
+
if (isUnfurlAgent(req.headers.get('user-agent')))
|
|
21
|
+
return none();
|
|
22
|
+
// An unset or empty secret verifies nothing: fail closed before any signature is compared.
|
|
23
|
+
const secret = ctx.recipientSecret?.();
|
|
24
|
+
if (!secret || !ctx.recipients || !ARTIFACT_ID_RE.test(id))
|
|
25
|
+
return json({ error: 'not found' }, 404);
|
|
26
|
+
const b = (await req.json().catch(() => null));
|
|
27
|
+
const str = (v) => (typeof v === 'string' ? v : '');
|
|
28
|
+
const rid = str(b?.rid), sig = str(b?.sig), session = str(b?.session), device = str(b?.device);
|
|
29
|
+
if (!RID_RE.test(rid) || !SESSION.test(session) || !SESSION.test(device))
|
|
30
|
+
return json({ error: 'bad signal' }, 400);
|
|
31
|
+
if (!verifyRid(secret, id, rid, sig))
|
|
32
|
+
return json({ error: 'forbidden' }, 403);
|
|
33
|
+
// The rid must be one this page was sent to, still live: a signature alone proves only that
|
|
34
|
+
// the host minted it once.
|
|
35
|
+
const rec = await ctx.recipients.get(id);
|
|
36
|
+
const r = rec?.recipients[rid];
|
|
37
|
+
// A malformed expireAt parses to NaN: treat it as expired, never as no expiry.
|
|
38
|
+
if (!rec || !r || r.revokedAt || (rec.expireAt && !(Date.parse(rec.expireAt) > Date.now())))
|
|
39
|
+
return json({ error: 'gone' }, 410);
|
|
40
|
+
// The owner's own opens record zero.
|
|
41
|
+
if (r.owner)
|
|
42
|
+
return none();
|
|
43
|
+
const at = new Date().toISOString();
|
|
44
|
+
const event = b?.event;
|
|
45
|
+
if (event === 'view') {
|
|
46
|
+
const t = verifyRenderToken(secret, id, rid, str(b?.token));
|
|
47
|
+
if (!t.ok || !(await ctx.recipients.useToken(t.key, t.expiresAt)))
|
|
48
|
+
return json({ error: 'not a fresh render' }, 409);
|
|
49
|
+
await ctx.recipients.recordView({ artifactId: id, rid, session, device, at, addSeconds: 2 });
|
|
50
|
+
return json({ beat: beatKey(secret, id, rid, session) });
|
|
51
|
+
}
|
|
52
|
+
if (event === 'beat' || event === 'play') {
|
|
53
|
+
if (str(b?.beat) !== beatKey(secret, id, rid, session))
|
|
54
|
+
return json({ error: 'forbidden' }, 403);
|
|
55
|
+
const media = event === 'play' && typeof b?.media === 'string' && MEDIA.test(b.media) ? b.media : undefined;
|
|
56
|
+
const seconds = event === 'beat' ? Math.max(0, Math.min(20, Number(b?.seconds) || 0)) : 0;
|
|
57
|
+
await ctx.recipients.recordView({ artifactId: id, rid, session, device, at, addSeconds: seconds, ...(media ? { play: media } : {}) });
|
|
58
|
+
return json({});
|
|
59
|
+
}
|
|
60
|
+
return json({ error: 'event must be view, beat or play' }, 400);
|
|
61
|
+
}
|
|
62
|
+
const unauth = () => json({ error: 'publish key required' }, 401);
|
|
63
|
+
async function listing(id) {
|
|
64
|
+
const secret = ctx.recipientSecret();
|
|
65
|
+
const rec = (await ctx.recipients.get(id)) ?? { recipients: {}, expireAt: null, keep: false };
|
|
66
|
+
return {
|
|
67
|
+
id, expireAt: rec.expireAt, keep: rec.keep,
|
|
68
|
+
recipients: Object.values(rec.recipients).map((r) => ({ rid: r.rid, label: r.label, group: r.group === true, owner: r.owner === true, revoked: !!r.revokedAt, url: ridLink(ctx.pageUrl(id), id, r.rid, secret) })),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** GET and POST /api/artifacts/<id>/recipients: who a per-reader page was sent to (publish key). */
|
|
72
|
+
async function RECIPIENTS(req, { params }) {
|
|
73
|
+
const { id } = await params;
|
|
74
|
+
if (!ctx.authed(req))
|
|
75
|
+
return unauth();
|
|
76
|
+
if (!ctx.recipients || !ctx.recipientSecret?.())
|
|
77
|
+
return json({ error: 'this host keeps no per-reader links' }, 501);
|
|
78
|
+
const a = ARTIFACT_ID_RE.test(id) ? await ctx.store.get(id) : null;
|
|
79
|
+
if (!a)
|
|
80
|
+
return json({ error: `no artifact with id ${id}` }, 404);
|
|
81
|
+
if (req.method === 'GET')
|
|
82
|
+
return json(await listing(id));
|
|
83
|
+
if (a.links !== 'per-reader')
|
|
84
|
+
return json({ error: 'this page is not links: per-reader' }, 409);
|
|
85
|
+
const b = (await req.json().catch(() => null));
|
|
86
|
+
if (!b)
|
|
87
|
+
return json({ error: 'body must be JSON' }, 400);
|
|
88
|
+
const secret = ctx.recipientSecret();
|
|
89
|
+
let added = [];
|
|
90
|
+
if (Array.isArray(b.add) && b.add.some((x) => !x || typeof x !== 'object' || typeof x.label !== 'string' || !x.label.trim()))
|
|
91
|
+
return json({ error: 'each reader to add needs a label' }, 400);
|
|
92
|
+
if (typeof b.expires === 'string' && Number.isNaN(Date.parse(b.expires)))
|
|
93
|
+
return json({ error: 'expires must be an ISO time or null' }, 400);
|
|
94
|
+
if (typeof b.expires === 'string' && Date.parse(b.expires) <= Date.now())
|
|
95
|
+
return json({ error: 'expires must be in the future' }, 400);
|
|
96
|
+
if (Array.isArray(b.add) && b.add.length) {
|
|
97
|
+
const made = await ctx.recipients.add(id, b.add);
|
|
98
|
+
added = made.map((r) => ({ rid: r.rid, label: r.label, url: ridLink(ctx.pageUrl(id), id, r.rid, secret) }));
|
|
99
|
+
}
|
|
100
|
+
const revoked = b.revoke === '*' || Array.isArray(b.revoke)
|
|
101
|
+
? await ctx.recipients.revoke(id, b.revoke === '*' ? '*' : b.revoke.filter((x) => typeof x === 'string' && RID_RE.test(x)))
|
|
102
|
+
: [];
|
|
103
|
+
if (b.expires === null || typeof b.expires === 'string') {
|
|
104
|
+
await ctx.recipients.setExpiry(id, b.expires === null ? null : new Date(b.expires).toISOString());
|
|
105
|
+
}
|
|
106
|
+
return json({ ...(await listing(id)), added, revoked });
|
|
107
|
+
}
|
|
108
|
+
/** GET /api/artifacts/<id>/views: one line per reader, with their comments (publish key). */
|
|
109
|
+
async function VIEWS(req, { params }) {
|
|
110
|
+
const { id } = await params;
|
|
111
|
+
if (!ctx.authed(req))
|
|
112
|
+
return unauth();
|
|
113
|
+
if (!ctx.recipients)
|
|
114
|
+
return json({ error: 'this host keeps no per-reader links' }, 501);
|
|
115
|
+
const a = ARTIFACT_ID_RE.test(id) ? await ctx.store.get(id) : null;
|
|
116
|
+
if (!a)
|
|
117
|
+
return json({ error: `no artifact with id ${id}` }, 404);
|
|
118
|
+
// A page nobody has been added to yet has no readers, which is an answer and not a missing page.
|
|
119
|
+
const rec = (await ctx.recipients.get(id)) ?? { recipients: {}, expireAt: null, keep: false };
|
|
120
|
+
const entries = ctx.state ? await ctx.state.entries(id) : [];
|
|
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
|
+
return json({ id, expireAt: rec.expireAt, keep: rec.keep, recipients: summarizeViews(rec, await ctx.recipients.views(id), comments) });
|
|
123
|
+
}
|
|
124
|
+
/** The daily sweep: deletes only pages whose recipients record carries an expireAt that is a
|
|
125
|
+
* well-formed time in the past and is not kept. An unparseable expireAt is never deleted (the
|
|
126
|
+
* gate already shuts the page); it is reported in `skipped`. A page that fails midway stays
|
|
127
|
+
* listed (the expiry record is forgotten last) and is retried by the next sweep. */
|
|
128
|
+
async function sweepExpired(now = new Date()) {
|
|
129
|
+
const out = { pages: 0, files: 0, skipped: [], failed: [] };
|
|
130
|
+
if (!ctx.recipients)
|
|
131
|
+
return out;
|
|
132
|
+
const log = ctx.log ?? ((m) => console.warn(m));
|
|
133
|
+
for (const id of await ctx.recipients.expiring(now.toISOString())) {
|
|
134
|
+
try {
|
|
135
|
+
const rec = await ctx.recipients.get(id);
|
|
136
|
+
const at = rec && !rec.keep && typeof rec.expireAt === 'string' && ISO.test(rec.expireAt) ? Date.parse(rec.expireAt) : NaN;
|
|
137
|
+
if (!rec || rec.keep || !(at <= now.getTime())) {
|
|
138
|
+
if (rec && !rec.keep) {
|
|
139
|
+
out.skipped.push(id);
|
|
140
|
+
log(`sweepExpired: not deleting ${id}, expireAt is not a past ISO time`);
|
|
141
|
+
}
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const page = await ctx.store.get(id);
|
|
145
|
+
// The page is gone already: only the leftover record is cleaned up.
|
|
146
|
+
if (!page) {
|
|
147
|
+
await ctx.recipients.forget(id);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
// A page republished without per-reader links keeps its old record; it is no longer a broadcast page.
|
|
151
|
+
if (page.links !== 'per-reader') {
|
|
152
|
+
out.skipped.push(id);
|
|
153
|
+
log(`sweepExpired: not deleting ${id}, it is not a per-reader page`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (!ctx.assets?.deletePage)
|
|
157
|
+
throw new Error('media deletion is not configured, page record kept so no media is orphaned');
|
|
158
|
+
const files = await ctx.assets.deletePage(id);
|
|
159
|
+
// Keep may have landed while the files were being removed: the page record and comments stay.
|
|
160
|
+
const again = await ctx.recipients.get(id);
|
|
161
|
+
if (!again || again.keep) {
|
|
162
|
+
log(`sweepExpired: ${id} was kept during the sweep`);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
// Reader text expires with the page: every comment and answer, whoever wrote it.
|
|
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);
|
|
169
|
+
await ctx.store.delete(id);
|
|
170
|
+
await ctx.recipients.forget(id);
|
|
171
|
+
out.pages++;
|
|
172
|
+
out.files += files;
|
|
173
|
+
}
|
|
174
|
+
catch (e) {
|
|
175
|
+
out.failed.push(id);
|
|
176
|
+
log(`sweepExpired: ${id} not finished, will retry: ${e instanceof Error ? e.message : String(e)}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
return { SEEN, RECIPIENTS, VIEWS, sweepExpired };
|
|
182
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import type { RecipientsStore } from '../artifacts/recipients-store.js';
|
|
2
3
|
import { type StateStore } from '../artifacts/state-store.js';
|
|
3
4
|
import type { ArtifactStore } from '../artifacts/store.js';
|
|
4
5
|
import type { ReadersStore } from '../artifacts/readers-store.js';
|
|
@@ -20,6 +21,10 @@ export type StateRoutesContext = {
|
|
|
20
21
|
state?: StateStore;
|
|
21
22
|
readers?: ReadersStore;
|
|
22
23
|
readerSecret: () => string | undefined;
|
|
24
|
+
/** Who each per-reader page was sent to, and the secret their links are signed with. Without
|
|
25
|
+
* both, a `links: per-reader` page takes no answers. */
|
|
26
|
+
recipients?: RecipientsStore;
|
|
27
|
+
recipientSecret?: () => string | undefined;
|
|
23
28
|
publishKey: () => string | undefined;
|
|
24
29
|
/** Publisher passes, as in ArtifactRoutesConfig. Both absent means passes are refused. */
|
|
25
30
|
publisherSecret?: () => string | undefined;
|
|
@@ -18,6 +18,8 @@ import { isPublisherAuthed } from '../artifacts/auth.js';
|
|
|
18
18
|
import { ARTIFACT_ID_RE } from './ids.js';
|
|
19
19
|
import { GRANT_COOKIE, decide, firstName, signInUrl, verifyGrant } from '../artifacts/reader.js';
|
|
20
20
|
import { isUnlocked, unlockCookieName } from '../artifacts/unlock.js';
|
|
21
|
+
import { ridCookieName, usableSecret } from '../artifacts/recipients.js';
|
|
22
|
+
import { perReaderGate } from './per-reader.js';
|
|
21
23
|
import { ANON_WRITES_PER_MINUTE, MAX_ENTRIES_PER_SLOT, checkValue, effectiveWriters, slotVisibility } from '../artifacts/state.js';
|
|
22
24
|
import { COMMENTS_SLOT, MAX_COMMENT_NAME_CHARS, cleanResolveNote, resolutionOf, validateComment } from '../artifacts/comments.js';
|
|
23
25
|
import { isCommentAudioPath } from '../artifacts/audio.js';
|
|
@@ -83,9 +85,19 @@ export function createStateRoutes(ctx) {
|
|
|
83
85
|
if (!(await ctx.readers.acknowledged(id, reader.email)))
|
|
84
86
|
return { error: json({ error: 'accept the agreement first' }, 403) };
|
|
85
87
|
}
|
|
88
|
+
// A per-reader page is answered through the link it was sent with: the rid cookie names the
|
|
89
|
+
// reader, and what they write is theirs under that key. A revoked or expired rid is shut.
|
|
90
|
+
let recipient = null;
|
|
91
|
+
// The owner and the page's admins open it without a link: their comment layer is signed-in, never a reader's.
|
|
92
|
+
if (a.links === 'per-reader' && !isOwner(reader)) {
|
|
93
|
+
const g = await perReaderGate({ id, param: undefined, cookie: req.cookies.get(ridCookieName(id))?.value, secret: usableSecret(ctx.recipientSecret?.()), recipients: ctx.recipients });
|
|
94
|
+
if (g.state === 'shut')
|
|
95
|
+
return { error: json({ error: 'this page is locked' }, 403) };
|
|
96
|
+
recipient = { rid: g.rid, label: g.label };
|
|
97
|
+
}
|
|
86
98
|
const rawAnon = req.cookies.get(ANON_COOKIE)?.value;
|
|
87
99
|
const anonId = rawAnon && ANON_ID.test(rawAnon) ? rawAnon : null;
|
|
88
|
-
return { a, reader, anonId, signIn };
|
|
100
|
+
return { a, reader, anonId, signIn, recipient };
|
|
89
101
|
}
|
|
90
102
|
const ownerEmail = ctx.ownerEmail?.trim().toLowerCase();
|
|
91
103
|
const admins = new Set((ctx.admins ?? []).map((e) => e.trim().toLowerCase()));
|
|
@@ -107,11 +119,19 @@ export function createStateRoutes(ctx) {
|
|
|
107
119
|
}
|
|
108
120
|
return rows;
|
|
109
121
|
}
|
|
110
|
-
async function viewBody(a, reader, readerKey) {
|
|
122
|
+
async function viewBody(a, reader, readerKey, canWriteAs = false) {
|
|
111
123
|
const allow = reader && ctx.readers && a.access ? await ctx.readers.allowList(a.id) : [];
|
|
112
124
|
const entries = await ctx.state.entries(a.id);
|
|
113
125
|
const slots = stateView(a.state, entries, readerKey);
|
|
114
|
-
|
|
126
|
+
// A reader who came through their own link sees only their own answers: whatever the page's
|
|
127
|
+
// settings say, nothing of another reader's is shared with them.
|
|
128
|
+
if (canWriteAs)
|
|
129
|
+
for (const v of Object.values(slots)) {
|
|
130
|
+
v.visibility = 'private';
|
|
131
|
+
delete v.shared;
|
|
132
|
+
delete v.tally;
|
|
133
|
+
}
|
|
134
|
+
const owner = !canWriteAs && isOwner(reader);
|
|
115
135
|
// The owner reads every comment with its writer's full name, whatever the page shows readers.
|
|
116
136
|
if (owner && commentsOn(a)) {
|
|
117
137
|
slots[COMMENTS_SLOT].shared = entries.filter((e) => e.slot === COMMENTS_SLOT)
|
|
@@ -125,15 +145,15 @@ export function createStateRoutes(ctx) {
|
|
|
125
145
|
}
|
|
126
146
|
return {
|
|
127
147
|
reader: reader ? { firstName: firstName(reader, allow) } : null,
|
|
128
|
-
canWrite: !!reader || effectiveWriters(a.state, a.access) === 'anyone',
|
|
148
|
+
canWrite: !!reader || canWriteAs || effectiveWriters(a.state, a.access) === 'anyone',
|
|
129
149
|
...(owner ? { owner: true } : {}),
|
|
130
150
|
slots,
|
|
131
151
|
};
|
|
132
152
|
}
|
|
133
153
|
/** The comments a writer can see, by id: every one on a readers-visible page, else their own,
|
|
134
154
|
* and every one for the owner. A reply may name only a comment its writer was shown. */
|
|
135
|
-
async function visibleComments(a, writerKey, reader) {
|
|
136
|
-
const all = slotVisibility(a.state, COMMENTS_SLOT) === 'shared' || isOwner(reader);
|
|
155
|
+
async function visibleComments(a, writerKey, reader, perReader = false) {
|
|
156
|
+
const all = !perReader && (slotVisibility(a.state, COMMENTS_SLOT) === 'shared' || isOwner(reader));
|
|
137
157
|
const byId = new Map();
|
|
138
158
|
for (const e of await ctx.state.entries(a.id))
|
|
139
159
|
if (e.slot === COMMENTS_SLOT && (all || e.readerKey === writerKey))
|
|
@@ -150,14 +170,14 @@ export function createStateRoutes(ctx) {
|
|
|
150
170
|
const o = await open(req, id);
|
|
151
171
|
if ('error' in o)
|
|
152
172
|
return o.error;
|
|
153
|
-
const { a, reader, anonId, signIn } = o;
|
|
173
|
+
const { a, reader, anonId, signIn, recipient } = o;
|
|
154
174
|
let clearAnon = false;
|
|
155
|
-
if (reader && anonId) {
|
|
175
|
+
if (reader && anonId && !recipient) {
|
|
156
176
|
await ctx.state.moveReader(readerKeyFor.anonymous(anonId), writerFor(reader));
|
|
157
177
|
clearAnon = true;
|
|
158
178
|
}
|
|
159
|
-
const key = reader ? readerKeyFor.signedIn(reader.uid) : anonId ? readerKeyFor.anonymous(anonId) : null;
|
|
160
|
-
const res = json({ ...(await viewBody(a, reader, key)), ...(reader ? {} : { signIn }) });
|
|
179
|
+
const key = recipient ? readerKeyFor.recipient(recipient.rid) : reader ? readerKeyFor.signedIn(reader.uid) : anonId ? readerKeyFor.anonymous(anonId) : null;
|
|
180
|
+
const res = json({ ...(await viewBody(a, reader, key, !!recipient)), ...(reader || recipient ? {} : { signIn }) });
|
|
161
181
|
if (clearAnon)
|
|
162
182
|
res.headers.append('set-cookie', anonCookie('', 0));
|
|
163
183
|
return res;
|
|
@@ -192,7 +212,7 @@ export function createStateRoutes(ctx) {
|
|
|
192
212
|
const o = await open(req, id);
|
|
193
213
|
if ('error' in o)
|
|
194
214
|
return o.error;
|
|
195
|
-
const { a, reader, signIn } = o;
|
|
215
|
+
const { a, reader, signIn, recipient } = o;
|
|
196
216
|
let { anonId } = o;
|
|
197
217
|
const slot = typeof b.slot === 'string' ? b.slot : '';
|
|
198
218
|
// Object.hasOwn, never a bracket read: `slots['__proto__']` or `slots['constructor']`
|
|
@@ -215,7 +235,9 @@ export function createStateRoutes(ctx) {
|
|
|
215
235
|
const isComments = slot === COMMENTS_SLOT && commentsOn(a);
|
|
216
236
|
let setCookie = null;
|
|
217
237
|
let writer;
|
|
218
|
-
if (
|
|
238
|
+
if (recipient)
|
|
239
|
+
writer = { key: readerKeyFor.recipient(recipient.rid), name: recipient.label, anonymous: true };
|
|
240
|
+
else if (reader)
|
|
219
241
|
writer = writerFor(reader);
|
|
220
242
|
else {
|
|
221
243
|
if (effectiveWriters(a.state, a.access, slot) !== 'anyone')
|
|
@@ -258,7 +280,7 @@ export function createStateRoutes(ctx) {
|
|
|
258
280
|
return json({ error: bad }, 400);
|
|
259
281
|
let value = b.value;
|
|
260
282
|
if (isComments) {
|
|
261
|
-
const seen = await visibleComments(a, writer.key, reader);
|
|
283
|
+
const seen = await visibleComments(a, writer.key, reader, !!recipient);
|
|
262
284
|
const c = validateComment(b.value, (cid) => seen.get(cid) ?? null);
|
|
263
285
|
if (!c.ok)
|
|
264
286
|
return json({ error: c.error }, 400);
|
|
@@ -279,7 +301,7 @@ export function createStateRoutes(ctx) {
|
|
|
279
301
|
const r = await ctx.state.replace({ artifactId: id, slot, readerKey: writer.key, entryId: entryId, value });
|
|
280
302
|
if (!r)
|
|
281
303
|
return json({ error: 'no answer of yours with that id' }, 404);
|
|
282
|
-
const res = json(await viewBody(a, reader, writer.key));
|
|
304
|
+
const res = json(await viewBody(a, reader, writer.key, !!recipient));
|
|
283
305
|
if (setCookie)
|
|
284
306
|
res.headers.append('set-cookie', setCookie);
|
|
285
307
|
return res;
|
|
@@ -299,7 +321,7 @@ export function createStateRoutes(ctx) {
|
|
|
299
321
|
if ('full' in r)
|
|
300
322
|
return json({ error: 'you have left the most answers this page takes here' }, 409);
|
|
301
323
|
}
|
|
302
|
-
const res = json(await viewBody(a, reader, writer.key));
|
|
324
|
+
const res = json(await viewBody(a, reader, writer.key, !!recipient));
|
|
303
325
|
if (setCookie)
|
|
304
326
|
res.headers.append('set-cookie', setCookie);
|
|
305
327
|
return res;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/artifacts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.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",
|