@supersuit/artifacts 0.11.1 → 0.12.1
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 +40 -0
- package/README.md +40 -0
- package/lib/artifacts/assets.d.ts +11 -0
- package/lib/artifacts/assets.js +35 -0
- package/lib/artifacts/audio.d.ts +21 -0
- package/lib/artifacts/audio.js +43 -0
- package/lib/artifacts/index.d.ts +3 -1
- package/lib/artifacts/index.js +3 -1
- package/lib/artifacts/personal-store.d.ts +4 -0
- package/lib/artifacts/personal-store.js +5 -1
- package/lib/artifacts/transcribe.d.ts +8 -0
- package/lib/artifacts/transcribe.js +56 -0
- package/lib/reader/comment-card.d.ts +16 -3
- package/lib/reader/comment-card.js +42 -6
- package/lib/reader/comment-layer.d.ts +7 -1
- package/lib/reader/comment-layer.js +86 -13
- package/lib/reader/device-audio.d.ts +7 -0
- package/lib/reader/device-audio.js +43 -0
- package/lib/reader/index.d.ts +3 -0
- package/lib/reader/index.js +2 -0
- package/lib/reader/no-zoom.d.ts +3 -0
- package/lib/reader/no-zoom.js +6 -0
- package/lib/reader/voice-recorder.d.ts +52 -0
- package/lib/reader/voice-recorder.js +213 -0
- package/lib/routes/artifacts.d.ts +12 -7
- package/lib/routes/artifacts.js +26 -4
- package/lib/routes/personal-routes.d.ts +2 -0
- package/lib/routes/personal-routes.js +15 -2
- package/lib/routes/state-routes.d.ts +3 -0
- package/lib/routes/state-routes.js +24 -0
- package/lib/routes/voice-routes.d.ts +35 -0
- package/lib/routes/voice-routes.js +176 -0
- package/lib/widgets/notes.js +2 -1
- package/package.json +1 -1
package/lib/routes/artifacts.js
CHANGED
|
@@ -28,6 +28,7 @@ import { AckDoor, ConfidentialBanner, NO_PRINT_CSS, NotAllowedDoor, SignInDoor,
|
|
|
28
28
|
import { ARTIFACT_ID_RE } from './ids.js';
|
|
29
29
|
import { createStateRoutes } from './state-routes.js';
|
|
30
30
|
import { createPersonalRoutes } from './personal-routes.js';
|
|
31
|
+
import { createVoiceRoutes } from './voice-routes.js';
|
|
31
32
|
/** Ids are 8 chars from the safe alphabet; anything else is not a page and never reaches the store. */
|
|
32
33
|
export const ARTIFACT_ID = ARTIFACT_ID_RE;
|
|
33
34
|
/** A version number as it appears in a URL: no zero, no leading zero, so each version has one address. */
|
|
@@ -53,11 +54,18 @@ export function createArtifactRoutes(config) {
|
|
|
53
54
|
const readerSecret = () => config.readerSecret?.();
|
|
54
55
|
const signOutUrl = (id) => `/api/reader/leave?to=${encodeURIComponent(pagePath(id))}`;
|
|
55
56
|
const shareCardUrl = (id, updatedAt) => `${pageUrl(id)}/share.png?v=${encodeURIComponent(updatedAt)}`;
|
|
57
|
+
// Readers' recordings are played through short-lived signed URLs, issued only beside the comment
|
|
58
|
+
// or note they belong to; a host whose assets cannot sign them shows no play control.
|
|
59
|
+
const audioUrl = config.assets?.audioUrl ? (id, path) => config.assets.audioUrl(id, path) : undefined;
|
|
56
60
|
const stateRoutes = createStateRoutes({
|
|
57
61
|
store, state: config.state, readers: config.readers, readerSecret, publishKey: config.publishKey, pageUrl, signInOrigin, siteUrl,
|
|
58
|
-
clientIp: config.clientIp, ownerEmail: config.ownerEmail,
|
|
62
|
+
clientIp: config.clientIp, ownerEmail: config.ownerEmail, audioUrl,
|
|
63
|
+
});
|
|
64
|
+
const personalRoutes = createPersonalRoutes({ store, personal: config.personal, readers: config.readers, readerSecret, pageUrl, signInOrigin, siteUrl, audioUrl });
|
|
65
|
+
const voiceRoutes = createVoiceRoutes({
|
|
66
|
+
store, state: config.state, personal: config.personal, readers: config.readers, assets: config.assets, transcribe: config.transcribe,
|
|
67
|
+
readerSecret, pageUrl, signInOrigin, siteUrl, clientIp: config.clientIp,
|
|
59
68
|
});
|
|
60
|
-
const personalRoutes = createPersonalRoutes({ store, personal: config.personal, readers: config.readers, readerSecret, pageUrl, signInOrigin, siteUrl });
|
|
61
69
|
async function generateMetadata({ params }) {
|
|
62
70
|
const { id } = await params;
|
|
63
71
|
const a = ARTIFACT_ID.test(id) ? await store.get(id) : null;
|
|
@@ -439,6 +447,21 @@ export function createArtifactRoutes(config) {
|
|
|
439
447
|
* bytes never arrived. 501 on a host whose assets cannot sign, so the publisher falls back.
|
|
440
448
|
*/
|
|
441
449
|
async function UPLOAD(request, { params }) {
|
|
450
|
+
const raw = await request.text().catch(() => '');
|
|
451
|
+
let body = {};
|
|
452
|
+
try {
|
|
453
|
+
const parsed = raw.length <= 4096 ? JSON.parse(raw) : null;
|
|
454
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
455
|
+
body = parsed;
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
body = {};
|
|
459
|
+
}
|
|
460
|
+
// A reader's voice memo: the reader's own doors, never the publish key (voice-routes.ts).
|
|
461
|
+
if (body.audio !== undefined) {
|
|
462
|
+
const { id, name } = await params;
|
|
463
|
+
return voiceRoutes.readerUpload(request, id, name, body);
|
|
464
|
+
}
|
|
442
465
|
if (!isPublishAuthed(request, config.publishKey()))
|
|
443
466
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
444
467
|
const assets = config.assets;
|
|
@@ -454,7 +477,6 @@ export function createArtifactRoutes(config) {
|
|
|
454
477
|
const type = contentTypeFor(name);
|
|
455
478
|
if (!type)
|
|
456
479
|
return NextResponse.json({ error: 'asset type not allowed; use webp, png, jpg, gif, mp3, mp4 or json' }, { status: 415 });
|
|
457
|
-
const body = (await request.json().catch(() => ({})));
|
|
458
480
|
const digest = typeof body.digest === 'string' ? body.digest : '';
|
|
459
481
|
if (!ASSET_DIGEST.test(digest))
|
|
460
482
|
return NextResponse.json({ error: 'digest must be the first 8 hex of the sha256 of the bytes' }, { status: 400 });
|
|
@@ -508,5 +530,5 @@ export function createArtifactRoutes(config) {
|
|
|
508
530
|
revalidatePath(pagePath(id));
|
|
509
531
|
return NextResponse.json({ deleted: true });
|
|
510
532
|
}
|
|
511
|
-
return { Page, generateMetadata, VersionPage, generateVersionMetadata, VERSIONS, POST, GET, DELETE, PUT_ASSET, UPLOAD, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, READS, ...stateRoutes, ...personalRoutes, dynamic: 'force-dynamic', maxDuration: 30 };
|
|
533
|
+
return { Page, generateMetadata, VersionPage, generateVersionMetadata, VERSIONS, POST, GET, DELETE, PUT_ASSET, UPLOAD, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, READS, TRANSCRIBE: voiceRoutes.TRANSCRIBE, ...stateRoutes, ...personalRoutes, dynamic: 'force-dynamic', maxDuration: 30 };
|
|
512
534
|
}
|
|
@@ -10,6 +10,8 @@ export type PersonalRoutesContext = {
|
|
|
10
10
|
pageUrl: (id: string) => string;
|
|
11
11
|
signInOrigin?: string;
|
|
12
12
|
siteUrl: string;
|
|
13
|
+
/** A signed URL to play one of the reader's own recordings. */
|
|
14
|
+
audioUrl?: (id: string, path: string) => Promise<string>;
|
|
13
15
|
};
|
|
14
16
|
type Params = {
|
|
15
17
|
params: Promise<{
|
|
@@ -12,7 +12,8 @@ import { NextResponse } from 'next/server';
|
|
|
12
12
|
import { ARTIFACT_ID_RE } from './ids.js';
|
|
13
13
|
import { GRANT_COOKIE, decide, signInUrl, verifyGrant } from '../artifacts/reader.js';
|
|
14
14
|
import { isUnlocked, unlockCookieName } from '../artifacts/unlock.js';
|
|
15
|
-
import { validatePersonal } from '../artifacts/personal-store.js';
|
|
15
|
+
import { personalAudioDir, validatePersonal } from '../artifacts/personal-store.js';
|
|
16
|
+
import { isPersonalAudioPath } from '../artifacts/audio.js';
|
|
16
17
|
const MAX_BODY = 16 * 1024;
|
|
17
18
|
export function createPersonalRoutes(ctx) {
|
|
18
19
|
const json = (body, status = 200) => NextResponse.json(body, { status, headers: { 'cache-control': 'no-store' } });
|
|
@@ -43,7 +44,16 @@ export function createPersonalRoutes(ctx) {
|
|
|
43
44
|
return { error: json({ error: 'sign in to keep notes everywhere', device: true, signIn }, 401) };
|
|
44
45
|
return { id, uid: reader.uid, personal: ctx.personal };
|
|
45
46
|
}
|
|
46
|
-
|
|
47
|
+
// Each note with a recording carries a signed URL to play it, issued to its owner here and nowhere else.
|
|
48
|
+
const list = async (o) => {
|
|
49
|
+
const notes = await o.personal.list(o.id, o.uid);
|
|
50
|
+
const dir = personalAudioDir(o.uid);
|
|
51
|
+
if (ctx.audioUrl)
|
|
52
|
+
for (const n of notes)
|
|
53
|
+
if (isPersonalAudioPath(n.value.audio, dir))
|
|
54
|
+
n.audioUrl = await ctx.audioUrl(o.id, n.value.audio);
|
|
55
|
+
return json({ notes });
|
|
56
|
+
};
|
|
47
57
|
// A write carries the reader's cookies; a browser always names its Origin on one, so a foreign one is refused.
|
|
48
58
|
const foreign = (req) => { const origin = req.headers.get('origin'); return origin !== null && origin !== siteOrigin; };
|
|
49
59
|
async function PERSONAL_GET(req, { params }) {
|
|
@@ -75,6 +85,9 @@ export function createPersonalRoutes(ctx) {
|
|
|
75
85
|
const v = validatePersonal(b.value);
|
|
76
86
|
if (!v.ok)
|
|
77
87
|
return json({ error: v.error }, 400);
|
|
88
|
+
// A note's recording is one this reader uploaded as a personal note: never a comment's, never another reader's.
|
|
89
|
+
if (v.value.audio !== undefined && !isPersonalAudioPath(v.value.audio, personalAudioDir(o.uid)))
|
|
90
|
+
return json({ error: 'a note\'s audio is the path its upload returned' }, 400);
|
|
78
91
|
if (b.entry !== undefined) {
|
|
79
92
|
if (typeof b.entry !== 'string' || !(await o.personal.replace(o.id, o.uid, b.entry, v.value)))
|
|
80
93
|
return json({ error: 'no note of yours with that id' }, 404);
|
|
@@ -24,6 +24,9 @@ export type StateRoutesContext = {
|
|
|
24
24
|
/** The page owner's sign-in email. Signed in as them, a page's comments show every entry with
|
|
25
25
|
* full names, whatever `comments_visible:` says. */
|
|
26
26
|
ownerEmail?: string;
|
|
27
|
+
/** A signed URL to play a comment's recording. Called only for `comments/` paths, and only for
|
|
28
|
+
* comments the asker is already being shown. Without it a recording has no play control. */
|
|
29
|
+
audioUrl?: (id: string, path: string) => Promise<string>;
|
|
27
30
|
};
|
|
28
31
|
type Params = {
|
|
29
32
|
params: Promise<{
|
|
@@ -16,6 +16,7 @@ import { GRANT_COOKIE, decide, firstName, signInUrl, verifyGrant } from '../arti
|
|
|
16
16
|
import { isUnlocked, unlockCookieName } from '../artifacts/unlock.js';
|
|
17
17
|
import { ANON_WRITES_PER_MINUTE, MAX_ENTRIES_PER_SLOT, checkValue, effectiveWriters, slotVisibility } from '../artifacts/state.js';
|
|
18
18
|
import { COMMENTS_SLOT, validateComment } from '../artifacts/comments.js';
|
|
19
|
+
import { isCommentAudioPath } from '../artifacts/audio.js';
|
|
19
20
|
import { readerKeyFor } from '../artifacts/state-store.js';
|
|
20
21
|
import { NOTES_SLOT, checkNoteValue, hasNotesWidget } from '../artifacts/widgets.js';
|
|
21
22
|
import { responsesCsv, responsesOf, stateView } from '../artifacts/state-view.js';
|
|
@@ -70,6 +71,18 @@ export function createStateRoutes(ctx) {
|
|
|
70
71
|
/** Comments are on for this page: the slot exists because `comments:` put it there. */
|
|
71
72
|
const commentsOn = (a) => !!a.comments && a.comments !== 'off' && Object.hasOwn(a.state.slots, COMMENTS_SLOT);
|
|
72
73
|
const writerFor = (r) => ({ key: readerKeyFor.signedIn(r.uid), uid: r.uid, email: r.email, name: r.name, anonymous: false });
|
|
74
|
+
/** Beside each comment row with a recording, a signed URL to play it. Only ever a `comments/`
|
|
75
|
+
* path: a personal recording can never be named by a comment, and is never signed here. */
|
|
76
|
+
async function withAudioUrls(id, rows) {
|
|
77
|
+
if (!ctx.audioUrl)
|
|
78
|
+
return rows;
|
|
79
|
+
for (const r of rows) {
|
|
80
|
+
const audio = r.value?.audio;
|
|
81
|
+
if (isCommentAudioPath(audio))
|
|
82
|
+
r.audioUrl = await ctx.audioUrl(id, audio);
|
|
83
|
+
}
|
|
84
|
+
return rows;
|
|
85
|
+
}
|
|
73
86
|
async function viewBody(a, reader, readerKey) {
|
|
74
87
|
const allow = reader && ctx.readers && a.access ? await ctx.readers.allowList(a.id) : [];
|
|
75
88
|
const entries = await ctx.state.entries(a.id);
|
|
@@ -81,6 +94,11 @@ export function createStateRoutes(ctx) {
|
|
|
81
94
|
.sort((x, y) => x.at.localeCompare(y.at) || x.id.localeCompare(y.id))
|
|
82
95
|
.map((e) => ({ id: e.id, name: e.writer.name?.trim() || 'a reader', value: e.value, at: e.at, mine: e.readerKey === readerKey }));
|
|
83
96
|
}
|
|
97
|
+
if (commentsOn(a) && slots[COMMENTS_SLOT]) {
|
|
98
|
+
const v = slots[COMMENTS_SLOT];
|
|
99
|
+
await withAudioUrls(a.id, Array.isArray(v.mine) ? v.mine : []);
|
|
100
|
+
await withAudioUrls(a.id, v.shared ?? []);
|
|
101
|
+
}
|
|
84
102
|
return {
|
|
85
103
|
reader: reader ? { firstName: firstName(reader, allow) } : null,
|
|
86
104
|
canWrite: !!reader || effectiveWriters(a.state, a.access) === 'anyone',
|
|
@@ -217,6 +235,10 @@ export function createStateRoutes(ctx) {
|
|
|
217
235
|
const c = validateComment(b.value, (cid) => seen.get(cid) ?? null);
|
|
218
236
|
if (!c.ok)
|
|
219
237
|
return json({ error: c.error }, 400);
|
|
238
|
+
// A shared comment's recording is one uploaded as a comment, never a path elsewhere under the
|
|
239
|
+
// page: naming a personal recording here would get it signed for the publisher.
|
|
240
|
+
if (c.value.audio !== undefined && !isCommentAudioPath(c.value.audio))
|
|
241
|
+
return json({ error: 'a comment\'s audio is the path its upload returned' }, 400);
|
|
220
242
|
// An edit keeps the comment where it is in its thread: a reply stays a reply to the same one.
|
|
221
243
|
if (op === 'replace' && seen.has(entryId) && (seen.get(entryId).parent ?? null) !== (c.value.parent ?? null))
|
|
222
244
|
return json({ error: 'an edit cannot move a comment into or out of a thread' }, 400);
|
|
@@ -269,6 +291,8 @@ export function createStateRoutes(ctx) {
|
|
|
269
291
|
const rows = responsesOf(await ctx.state.entries(id));
|
|
270
292
|
if (req.nextUrl.searchParams.get('format') === 'csv')
|
|
271
293
|
return new NextResponse(responsesCsv(rows), { headers: { 'content-type': 'text/csv; charset=utf-8', 'cache-control': 'no-store' } });
|
|
294
|
+
// The publisher hears a shared comment's recording from here; the rows are signed in place.
|
|
295
|
+
await withAudioUrls(id, rows.filter((r) => r.slot === COMMENTS_SLOT));
|
|
272
296
|
return json({ id, title: a.title, responses: rows });
|
|
273
297
|
}
|
|
274
298
|
return { STATE_GET, STATE_POST, RESPONSES };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import type { StateStore } from '../artifacts/state-store.js';
|
|
3
|
+
import { type PersonalStore } from '../artifacts/personal-store.js';
|
|
4
|
+
import type { ArtifactStore } from '../artifacts/store.js';
|
|
5
|
+
import type { ReadersStore } from '../artifacts/readers-store.js';
|
|
6
|
+
import type { ArtifactAssets } from '../artifacts/assets.js';
|
|
7
|
+
import type { Transcriber } from '../artifacts/transcribe.js';
|
|
8
|
+
/** Host transcriptions per reader per page per hour. */
|
|
9
|
+
export declare const TRANSCRIBE_PER_HOUR = 20;
|
|
10
|
+
export type VoiceRoutesContext = {
|
|
11
|
+
store: ArtifactStore;
|
|
12
|
+
state?: StateStore;
|
|
13
|
+
personal?: PersonalStore;
|
|
14
|
+
readers?: ReadersStore;
|
|
15
|
+
assets?: ArtifactAssets;
|
|
16
|
+
transcribe?: Transcriber;
|
|
17
|
+
readerSecret: () => string | undefined;
|
|
18
|
+
pageUrl: (id: string) => string;
|
|
19
|
+
signInOrigin?: string;
|
|
20
|
+
siteUrl: string;
|
|
21
|
+
clientIp?: (req: NextRequest) => string;
|
|
22
|
+
};
|
|
23
|
+
type Params = {
|
|
24
|
+
params: Promise<{
|
|
25
|
+
id: string;
|
|
26
|
+
}>;
|
|
27
|
+
};
|
|
28
|
+
export declare function createVoiceRoutes(ctx: VoiceRoutesContext): {
|
|
29
|
+
TRANSCRIBE: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown>>;
|
|
30
|
+
readerUpload: (req: NextRequest, id: string, name: string, body: {
|
|
31
|
+
audio?: unknown;
|
|
32
|
+
done?: unknown;
|
|
33
|
+
}) => Promise<NextResponse<unknown>>;
|
|
34
|
+
};
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Readers' voice memos: the upload, and the host's transcription of it.
|
|
2
|
+
//
|
|
3
|
+
// POST /api/artifacts/<id>/uploads/<memo>.<ext> { audio: 'comment'|'personal' [, done: true] }
|
|
4
|
+
// the same UPLOAD route the publisher uses, dispatched here when the body names `audio`:
|
|
5
|
+
// a signed URL to PUT one recording to, then `done` to confirm it arrived
|
|
6
|
+
// POST /api/artifacts/<id>/transcribe { asset: <path the upload returned> }
|
|
7
|
+
//
|
|
8
|
+
// Who may upload or transcribe: for a shared comment, whoever may write the page's comments slot
|
|
9
|
+
// (the same doors the state POST opens: password, access, agreement, `comments:` writers); for a
|
|
10
|
+
// personal note, a signed-in reader, into a directory only their uid derives. A reader who is not
|
|
11
|
+
// signed in keeps a personal memo on their device and never reaches this file.
|
|
12
|
+
//
|
|
13
|
+
// The transcription key lives in `ctx.transcribe`'s closure and is never read here. Every
|
|
14
|
+
// failure of the service answers one fixed line: whatever it threw is dropped, never echoed.
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import { NextResponse } from 'next/server';
|
|
17
|
+
import { ARTIFACT_ID_RE } from './ids.js';
|
|
18
|
+
import { GRANT_COOKIE, decide, signInUrl, verifyGrant } from '../artifacts/reader.js';
|
|
19
|
+
import { isUnlocked, unlockCookieName } from '../artifacts/unlock.js';
|
|
20
|
+
import { ANON_WRITES_PER_MINUTE, effectiveWriters } from '../artifacts/state.js';
|
|
21
|
+
import { COMMENTS_SLOT } from '../artifacts/comments.js';
|
|
22
|
+
import { personalAudioDir } from '../artifacts/personal-store.js';
|
|
23
|
+
import { MAX_AUDIO_BYTES, MEMO_NAME, audioTypeFor, commentAudioPath, isCommentAudioPath, isPersonalAudioPath, personalAudioPath, } from '../artifacts/audio.js';
|
|
24
|
+
import { ANON_COOKIE, rateKey } from './state-routes.js';
|
|
25
|
+
/** Host transcriptions per reader per page per hour. */
|
|
26
|
+
export const TRANSCRIBE_PER_HOUR = 20;
|
|
27
|
+
const ANON_ID = /^[A-Za-z0-9]{24}$/;
|
|
28
|
+
export function createVoiceRoutes(ctx) {
|
|
29
|
+
const json = (body, status = 200) => NextResponse.json(body, { status, headers: { 'cache-control': 'no-store' } });
|
|
30
|
+
const siteOrigin = new URL(ctx.siteUrl).origin;
|
|
31
|
+
const foreign = (req) => { const o = req.headers.get('origin'); return o !== null && o !== siteOrigin; };
|
|
32
|
+
const clientIp = (req) => (ctx.clientIp ? ctx.clientIp(req) : (req.headers.get('x-forwarded-for') ?? '').split(',')[0].trim());
|
|
33
|
+
const hash = (s) => createHash('sha256').update(s).digest('hex').slice(0, 16);
|
|
34
|
+
/** The page and the reader, through every door the page has; or the refusal. */
|
|
35
|
+
async function open(req, id) {
|
|
36
|
+
const a = ARTIFACT_ID_RE.test(id) ? await ctx.store.get(id) : null;
|
|
37
|
+
if (!a)
|
|
38
|
+
return { error: json({ error: `no artifact with id ${id}` }, 404) };
|
|
39
|
+
if (a.password && !isUnlocked({ id, password: a.password, cookie: req.cookies.get(unlockCookieName(id))?.value }))
|
|
40
|
+
return { error: json({ error: 'this page is locked' }, 403) };
|
|
41
|
+
const reader = verifyGrant(ctx.readerSecret(), req.cookies.get(GRANT_COOKIE)?.value);
|
|
42
|
+
const signIn = ctx.signInOrigin ? signInUrl(ctx.signInOrigin, ctx.pageUrl(id)) : null;
|
|
43
|
+
if (a.access) {
|
|
44
|
+
if (!ctx.readers)
|
|
45
|
+
return { error: json({ error: 'this page is closed' }, 403) };
|
|
46
|
+
if (!reader)
|
|
47
|
+
return { error: json({ error: 'sign in to read this page', signIn }, 401) };
|
|
48
|
+
if (!decide(a.access, reader, await ctx.readers.allowList(id)).open)
|
|
49
|
+
return { error: json({ error: 'this page is not open to you' }, 403) };
|
|
50
|
+
if (!(await ctx.readers.acknowledged(id, reader.email)))
|
|
51
|
+
return { error: json({ error: 'accept the agreement first' }, 403) };
|
|
52
|
+
}
|
|
53
|
+
return { a, reader, signIn };
|
|
54
|
+
}
|
|
55
|
+
/** May this reader write in `scope` on page `a`? Null when yes, else the refusal. */
|
|
56
|
+
function mayWrite(a, reader, scope, signIn) {
|
|
57
|
+
if (scope === 'personal') {
|
|
58
|
+
if (!ctx.personal)
|
|
59
|
+
return json({ error: 'this host keeps no personal notes', device: true }, 501);
|
|
60
|
+
if (!reader)
|
|
61
|
+
return json({ error: 'sign in to keep recordings everywhere', device: true, signIn }, 401);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const on = !!ctx.state && !!a.state && !!a.comments && a.comments !== 'off' && Object.hasOwn(a.state.slots, COMMENTS_SLOT);
|
|
65
|
+
if (!on)
|
|
66
|
+
return json({ error: 'this page takes no comments' }, 403);
|
|
67
|
+
if (!reader && effectiveWriters(a.state, a.access, COMMENTS_SLOT) !== 'anyone')
|
|
68
|
+
return json({ error: 'sign in to comment', signIn }, 401);
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
/** Who is asking, for the per-reader counters: the account, else the anonymous cookie, else the address. */
|
|
72
|
+
const askerKey = (req, reader) => {
|
|
73
|
+
if (reader)
|
|
74
|
+
return `u:${reader.uid}`;
|
|
75
|
+
const anon = req.cookies.get(ANON_COOKIE)?.value;
|
|
76
|
+
return anon && ANON_ID.test(anon) ? `a:${anon}` : `ip:${rateKey(clientIp(req), ctx.siteUrl, ctx.readerSecret())}`;
|
|
77
|
+
};
|
|
78
|
+
/** The reader branch of UPLOAD. `body` is already parsed; `name` is `<memo>.<ext>`. */
|
|
79
|
+
async function readerUpload(req, id, name, body) {
|
|
80
|
+
if (foreign(req))
|
|
81
|
+
return json({ error: 'wrong origin' }, 403);
|
|
82
|
+
const assets = ctx.assets;
|
|
83
|
+
if (!assets?.signAudioUpload || !assets.audioSize)
|
|
84
|
+
return json({ error: 'this host does not keep recordings' }, 501);
|
|
85
|
+
const scope = body.audio;
|
|
86
|
+
if (scope !== 'comment' && scope !== 'personal')
|
|
87
|
+
return json({ error: 'audio is comment or personal' }, 400);
|
|
88
|
+
if (!MEMO_NAME.test(name))
|
|
89
|
+
return json({ error: 'a recording is named <id>.<webm|m4a|mp3|ogg>' }, 400);
|
|
90
|
+
const type = audioTypeFor(name);
|
|
91
|
+
const o = await open(req, id);
|
|
92
|
+
if ('error' in o)
|
|
93
|
+
return o.error;
|
|
94
|
+
const refused = mayWrite(o.a, o.reader, scope, o.signIn);
|
|
95
|
+
if (refused)
|
|
96
|
+
return refused;
|
|
97
|
+
const path = scope === 'comment' ? commentAudioPath(name) : personalAudioPath(personalAudioDir(o.reader.uid), name);
|
|
98
|
+
if (body.done === true) {
|
|
99
|
+
const size = await assets.audioSize(id, path);
|
|
100
|
+
if (size === null)
|
|
101
|
+
return json({ error: 'nothing was uploaded to that url' }, 409);
|
|
102
|
+
if (size > MAX_AUDIO_BYTES)
|
|
103
|
+
return json({ error: 'a recording is at most 4 MB' }, 413);
|
|
104
|
+
return json({ path }, 201);
|
|
105
|
+
}
|
|
106
|
+
// An anonymous signer is counted like an anonymous answer, so a public page is not free storage.
|
|
107
|
+
if (!o.reader) {
|
|
108
|
+
const n = ctx.state ? await ctx.state.countAnonWrite(id, `au.${hash(askerKey(req, null))}`, Math.floor(Date.now() / 60000)) : 0;
|
|
109
|
+
if (n > ANON_WRITES_PER_MINUTE)
|
|
110
|
+
return json({ error: 'too many recordings from here; try again in a minute' }, 429);
|
|
111
|
+
}
|
|
112
|
+
return json({ path, ...(await assets.signAudioUpload(id, path, type)) }, 201);
|
|
113
|
+
}
|
|
114
|
+
async function TRANSCRIBE(req, { params }) {
|
|
115
|
+
// No transcriber: the reader's browser is the floor, and a 404 is how it learns that.
|
|
116
|
+
if (!ctx.transcribe)
|
|
117
|
+
return json({ error: 'this host does not transcribe' }, 404);
|
|
118
|
+
if (foreign(req))
|
|
119
|
+
return json({ error: 'wrong origin' }, 403);
|
|
120
|
+
const raw = await req.text();
|
|
121
|
+
if (raw.length > 1024)
|
|
122
|
+
return json({ error: 'request over 1 KB' }, 413);
|
|
123
|
+
let asset;
|
|
124
|
+
try {
|
|
125
|
+
asset = JSON.parse(raw)?.asset;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return json({ error: 'body is { asset }' }, 400);
|
|
129
|
+
}
|
|
130
|
+
const o = await open(req, (await params).id);
|
|
131
|
+
if ('error' in o)
|
|
132
|
+
return o.error;
|
|
133
|
+
const { a, reader, signIn } = o;
|
|
134
|
+
let scope;
|
|
135
|
+
if (isCommentAudioPath(asset))
|
|
136
|
+
scope = 'comment';
|
|
137
|
+
else if (typeof asset === 'string' && asset.startsWith('personal/')) {
|
|
138
|
+
if (!reader)
|
|
139
|
+
return json({ error: 'sign in to transcribe a personal note', signIn }, 401);
|
|
140
|
+
// A personal recording is transcribed only for the reader it belongs to.
|
|
141
|
+
if (!isPersonalAudioPath(asset, personalAudioDir(reader.uid)))
|
|
142
|
+
return json({ error: 'no recording of yours at that path' }, 404);
|
|
143
|
+
scope = 'personal';
|
|
144
|
+
}
|
|
145
|
+
else
|
|
146
|
+
return json({ error: 'asset is the path the upload returned' }, 400);
|
|
147
|
+
const refused = mayWrite(a, reader, scope, signIn);
|
|
148
|
+
if (refused)
|
|
149
|
+
return refused;
|
|
150
|
+
if (!ctx.state)
|
|
151
|
+
return json({ error: 'this host keeps no counters' }, 501);
|
|
152
|
+
if (!ctx.assets?.readAudio)
|
|
153
|
+
return json({ error: 'this host does not keep recordings' }, 501);
|
|
154
|
+
const n = await ctx.state.countAnonWrite(a.id, `tx.${hash(askerKey(req, reader))}`, Math.floor(Date.now() / 3_600_000));
|
|
155
|
+
if (n > TRANSCRIBE_PER_HOUR)
|
|
156
|
+
return json({ error: 'that is enough transcriptions for this hour; type it instead' }, 429);
|
|
157
|
+
const path = asset;
|
|
158
|
+
const bytes = await ctx.assets.readAudio(a.id, path);
|
|
159
|
+
if (!bytes)
|
|
160
|
+
return json({ error: 'no recording at that path' }, 404);
|
|
161
|
+
if (bytes.length > MAX_AUDIO_BYTES)
|
|
162
|
+
return json({ error: 'a recording is at most 4 MB' }, 413);
|
|
163
|
+
const mime = audioTypeFor(path);
|
|
164
|
+
let out = null;
|
|
165
|
+
try {
|
|
166
|
+
out = await ctx.transcribe(new Blob([new Uint8Array(bytes)], { type: mime }), mime);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
out = null;
|
|
170
|
+
}
|
|
171
|
+
if (!out || typeof out.text !== 'string')
|
|
172
|
+
return json({ error: 'the transcription service did not answer' }, 502);
|
|
173
|
+
return json({ text: out.text });
|
|
174
|
+
}
|
|
175
|
+
return { TRANSCRIBE, readerUpload };
|
|
176
|
+
}
|
package/lib/widgets/notes.js
CHANGED
|
@@ -8,6 +8,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
8
8
|
// Everything drawn here carries data-nospeak: narration reads the prose, never the notes, and
|
|
9
9
|
// the read-along highlighter skips the same nodes. Notes are shown as text, never as markup.
|
|
10
10
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
|
11
|
+
import { NO_ZOOM_FONT } from '../reader/no-zoom.js';
|
|
11
12
|
import { MAX_NOTE_CHARS, NOTES_SLOT, placeNotes } from '../artifacts/notes-place.js';
|
|
12
13
|
const NotesContext = createContext(null);
|
|
13
14
|
const isNote = (v) => !!v && typeof v === 'object' && typeof v.slug === 'string' &&
|
|
@@ -112,7 +113,7 @@ function NoteForm({ slug, heading, c }) {
|
|
|
112
113
|
if (!c.canWrite) {
|
|
113
114
|
return c.signIn ? (_jsx("p", { className: "my-2 text-sm", children: _jsx("a", { href: c.signIn, className: "underline", style: { color: c.accent }, children: "Sign in to leave a note" }) })) : (_jsx("p", { className: "my-2 text-sm opacity-70", children: "Notes here are open to signed-in readers." }));
|
|
114
115
|
}
|
|
115
|
-
return (_jsxs("form", { onSubmit: submit, className: "my-2", children: [_jsx("textarea", { value: text, onChange: (e) => setText(e.target.value), maxLength: MAX_NOTE_CHARS, rows: 3, "aria-label": `A note on ${heading}`, placeholder: `A note on “${heading}”`, className: "w-full rounded-lg border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-[15px] text-[color:var(--a-strong)]" }), c.error ? _jsx("p", { className: "mt-1 text-xs text-amber-500", children: c.error }) : null, _jsxs("div", { className: "mt-2 flex gap-3", children: [_jsx("button", { type: "submit", disabled: c.busy || !text.trim(), className: "rounded-full px-4 py-1 text-sm font-medium disabled:opacity-40", style: { background: c.accent, color: 'var(--a-on-accent, #111)' }, children: "Save note" }), _jsx("button", { type: "button", onClick: () => c.setOpen(null), className: "text-sm opacity-70 hover:opacity-100", children: "Cancel" })] })] }));
|
|
116
|
+
return (_jsxs("form", { onSubmit: submit, className: "my-2", children: [_jsx("textarea", { value: text, onChange: (e) => setText(e.target.value), style: NO_ZOOM_FONT, maxLength: MAX_NOTE_CHARS, rows: 3, "aria-label": `A note on ${heading}`, placeholder: `A note on “${heading}”`, className: "w-full rounded-lg border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-[15px] text-[color:var(--a-strong)]" }), c.error ? _jsx("p", { className: "mt-1 text-xs text-amber-500", children: c.error }) : null, _jsxs("div", { className: "mt-2 flex gap-3", children: [_jsx("button", { type: "submit", disabled: c.busy || !text.trim(), className: "rounded-full px-4 py-1 text-sm font-medium disabled:opacity-40", style: { background: c.accent, color: 'var(--a-on-accent, #111)' }, children: "Save note" }), _jsx("button", { type: "button", onClick: () => c.setOpen(null), className: "text-sm opacity-70 hover:opacity-100", children: "Cancel" })] })] }));
|
|
116
117
|
}
|
|
117
118
|
/** After each heading: its notes, and the box when its control is open. */
|
|
118
119
|
export function HeadingNotes({ slug, text }) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/artifacts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
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",
|