@rubar/lavish-publish-cf 0.1.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/LICENSE +21 -0
- package/README.md +83 -0
- package/explainer-image.png +0 -0
- package/package.json +58 -0
- package/scripts/install.sh +168 -0
- package/skill/SKILL.md +128 -0
- package/skill/references/cloudflare.md +99 -0
- package/skill/references/design-md.md +56 -0
- package/skill/references/manage.md +68 -0
- package/skill/references/themes.md +77 -0
- package/worker/CLAUDE.md +64 -0
- package/worker/README.md +222 -0
- package/worker/cli/index.js +592 -0
- package/worker/src/index.ts +1374 -0
- package/worker/tsconfig.json +19 -0
- package/worker/wrangler.toml +16 -0
|
@@ -0,0 +1,1374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* publish-cloudflare — self-hosted htmlship clone on Cloudflare Workers + KV.
|
|
3
|
+
*
|
|
4
|
+
* One file by design. Endpoints:
|
|
5
|
+
* POST /api/v1/pages
|
|
6
|
+
* GET /api/v1/pages/:slug
|
|
7
|
+
* PATCH /api/v1/pages/:slug
|
|
8
|
+
* DELETE /api/v1/pages/:slug
|
|
9
|
+
* GET /v/:slug (and POST for password gate)
|
|
10
|
+
* GET / (landing page)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface Env {
|
|
14
|
+
PAGES: KVNamespace;
|
|
15
|
+
VIEW_BASE_URL?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface PageRecord {
|
|
19
|
+
slug: string;
|
|
20
|
+
html: string;
|
|
21
|
+
title: string;
|
|
22
|
+
owner_key_hash: string;
|
|
23
|
+
password_hash: string | null;
|
|
24
|
+
created_at: number;
|
|
25
|
+
expires_at: number | null;
|
|
26
|
+
size_bytes: number;
|
|
27
|
+
// Optional for backwards-compatibility with records written before this field
|
|
28
|
+
// existed. Treat `undefined` as `true` everywhere that reads it.
|
|
29
|
+
comments_enabled?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Comment {
|
|
33
|
+
id: string;
|
|
34
|
+
slug: string;
|
|
35
|
+
anchor: {
|
|
36
|
+
quote: string;
|
|
37
|
+
prefix: string;
|
|
38
|
+
suffix: string;
|
|
39
|
+
} | null;
|
|
40
|
+
body: string;
|
|
41
|
+
author: string;
|
|
42
|
+
status: "open" | "resolved";
|
|
43
|
+
created_at: number;
|
|
44
|
+
resolved_at: number | null;
|
|
45
|
+
resolved_by: string | null;
|
|
46
|
+
resolution_note: string | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const MAX_HTML_BYTES = 5 * 1024 * 1024; // 5 MB
|
|
50
|
+
const MAX_EXPIRES_MINUTES = 60 * 24 * 30; // 30 days
|
|
51
|
+
const SLUG_LEN = 8;
|
|
52
|
+
const OWNER_KEY_LEN = 32;
|
|
53
|
+
const COMMENT_ID_LEN = 10;
|
|
54
|
+
const MAX_COMMENT_BODY = 2000;
|
|
55
|
+
const MAX_COMMENT_AUTHOR = 60;
|
|
56
|
+
const MAX_ANCHOR_QUOTE = 200;
|
|
57
|
+
const MAX_ANCHOR_CONTEXT = 64;
|
|
58
|
+
const MAX_RESOLUTION_NOTE = 500;
|
|
59
|
+
const PAGE_CSP =
|
|
60
|
+
"default-src 'self' data: blob: https:; " +
|
|
61
|
+
"script-src 'none'; " +
|
|
62
|
+
"style-src 'self' 'unsafe-inline' https:; " +
|
|
63
|
+
"img-src 'self' data: blob: https:; " +
|
|
64
|
+
"font-src 'self' data: https:; " +
|
|
65
|
+
"frame-ancestors 'self'; " +
|
|
66
|
+
"base-uri 'none';";
|
|
67
|
+
const WRAPPER_CSP =
|
|
68
|
+
"default-src 'self'; " +
|
|
69
|
+
"script-src 'self' 'unsafe-inline'; " +
|
|
70
|
+
"style-src 'self' 'unsafe-inline'; " +
|
|
71
|
+
"img-src 'self' data: blob: https:; " +
|
|
72
|
+
"font-src 'self' data: https:; " +
|
|
73
|
+
"frame-src 'self'; " +
|
|
74
|
+
"connect-src 'self'; " +
|
|
75
|
+
"frame-ancestors 'none'; " +
|
|
76
|
+
"base-uri 'none';";
|
|
77
|
+
|
|
78
|
+
// ---------- favicon ----------
|
|
79
|
+
// Editorial pilcrow (¶) in a rounded slate square. Self-contained SVG —
|
|
80
|
+
// no fonts, no external assets — served at /favicon.svg and /favicon.ico
|
|
81
|
+
// and referenced by the wrapper + landing pages via <link rel="icon">.
|
|
82
|
+
const FAVICON_SVG =
|
|
83
|
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">' +
|
|
84
|
+
'<rect width="32" height="32" rx="6" fill="#111827"/>' +
|
|
85
|
+
'<path d="M22 6H13a5 5 0 0 0 0 10h4v10h2V16h1v10h2V6z" fill="#fbfaf7"/>' +
|
|
86
|
+
"</svg>";
|
|
87
|
+
|
|
88
|
+
function serveFavicon(): Response {
|
|
89
|
+
return new Response(FAVICON_SVG, {
|
|
90
|
+
status: 200,
|
|
91
|
+
headers: {
|
|
92
|
+
"content-type": "image/svg+xml",
|
|
93
|
+
"cache-control": "public, max-age=604800, immutable",
|
|
94
|
+
"x-content-type-options": "nosniff",
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------- helpers ----------
|
|
100
|
+
|
|
101
|
+
const ALPHA = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
102
|
+
function randomString(len: number, alphabet = ALPHA): string {
|
|
103
|
+
const bytes = new Uint8Array(len);
|
|
104
|
+
crypto.getRandomValues(bytes);
|
|
105
|
+
let out = "";
|
|
106
|
+
for (let i = 0; i < len; i++) out += alphabet[bytes[i] % alphabet.length];
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function sha256(s: string): Promise<string> {
|
|
111
|
+
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s));
|
|
112
|
+
const bytes = new Uint8Array(buf);
|
|
113
|
+
let hex = "";
|
|
114
|
+
for (const b of bytes) hex += b.toString(16).padStart(2, "0");
|
|
115
|
+
return hex;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// constant-time hex compare
|
|
119
|
+
function timingSafeEqualHex(a: string, b: string): boolean {
|
|
120
|
+
if (a.length !== b.length) return false;
|
|
121
|
+
let r = 0;
|
|
122
|
+
for (let i = 0; i < a.length; i++) r |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
123
|
+
return r === 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function json(body: unknown, status = 200, extra: Record<string, string> = {}): Response {
|
|
127
|
+
return new Response(JSON.stringify(body), {
|
|
128
|
+
status,
|
|
129
|
+
headers: {
|
|
130
|
+
"content-type": "application/json; charset=utf-8",
|
|
131
|
+
"cache-control": "no-store",
|
|
132
|
+
"access-control-allow-origin": "*",
|
|
133
|
+
...extra,
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function err(status: number, message: string, code?: string): Response {
|
|
139
|
+
return json({ error: { code: code ?? `http_${status}`, message } }, status);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function corsPreflight(): Response {
|
|
143
|
+
return new Response(null, {
|
|
144
|
+
status: 204,
|
|
145
|
+
headers: {
|
|
146
|
+
"access-control-allow-origin": "*",
|
|
147
|
+
"access-control-allow-methods": "GET,POST,PATCH,DELETE,OPTIONS",
|
|
148
|
+
"access-control-allow-headers": "content-type, x-owner-key, x-resolved-by",
|
|
149
|
+
"access-control-max-age": "86400",
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function viewBase(env: Env, request: Request): string {
|
|
155
|
+
if (env.VIEW_BASE_URL) return env.VIEW_BASE_URL.replace(/\/$/, "");
|
|
156
|
+
const u = new URL(request.url);
|
|
157
|
+
return `${u.protocol}//${u.host}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function viewUrl(env: Env, request: Request, slug: string): string {
|
|
161
|
+
const base = viewBase(env, request);
|
|
162
|
+
// If VIEW_BASE_URL is a dedicated view host (no path), use root /<slug>; otherwise /v/<slug>.
|
|
163
|
+
if (env.VIEW_BASE_URL) return `${base}/v/${slug}`;
|
|
164
|
+
return `${base}/v/${slug}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function getPage(env: Env, slug: string): Promise<PageRecord | null> {
|
|
168
|
+
const raw = await env.PAGES.get(`page:${slug}`, "json");
|
|
169
|
+
if (!raw) return null;
|
|
170
|
+
const rec = raw as PageRecord;
|
|
171
|
+
if (rec.expires_at && Date.now() > rec.expires_at) {
|
|
172
|
+
// best-effort cleanup
|
|
173
|
+
await env.PAGES.delete(`page:${slug}`);
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
return rec;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function commentKey(slug: string, id: string): string {
|
|
180
|
+
return `comment:${slug}:${id}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function getComment(env: Env, slug: string, id: string): Promise<Comment | null> {
|
|
184
|
+
const raw = await env.PAGES.get(commentKey(slug, id), "json");
|
|
185
|
+
if (!raw) return null;
|
|
186
|
+
return raw as Comment;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function listComments(env: Env, slug: string): Promise<Comment[]> {
|
|
190
|
+
const prefix = `comment:${slug}:`;
|
|
191
|
+
const out: Comment[] = [];
|
|
192
|
+
let cursor: string | undefined;
|
|
193
|
+
// KV list pagination: tens per artifact expected, but be safe.
|
|
194
|
+
// eslint-disable-next-line no-constant-condition
|
|
195
|
+
while (true) {
|
|
196
|
+
const res: KVNamespaceListResult<unknown, string> = await env.PAGES.list({ prefix, cursor });
|
|
197
|
+
for (const k of res.keys) {
|
|
198
|
+
const c = await env.PAGES.get(k.name, "json");
|
|
199
|
+
if (c) out.push(c as Comment);
|
|
200
|
+
}
|
|
201
|
+
if (res.list_complete) break;
|
|
202
|
+
cursor = res.cursor;
|
|
203
|
+
if (!cursor) break;
|
|
204
|
+
}
|
|
205
|
+
out.sort((a, b) => a.created_at - b.created_at);
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function escapeHtml(s: string): string {
|
|
210
|
+
return s
|
|
211
|
+
.replace(/&/g, "&")
|
|
212
|
+
.replace(/</g, "<")
|
|
213
|
+
.replace(/>/g, ">")
|
|
214
|
+
.replace(/"/g, """)
|
|
215
|
+
.replace(/'/g, "'");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ---------- handlers ----------
|
|
219
|
+
|
|
220
|
+
async function handleCreate(request: Request, env: Env): Promise<Response> {
|
|
221
|
+
let body: any;
|
|
222
|
+
try {
|
|
223
|
+
body = await request.json();
|
|
224
|
+
} catch {
|
|
225
|
+
return err(400, "Invalid JSON body");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const html = typeof body?.html === "string" ? body.html : null;
|
|
229
|
+
if (!html) return err(400, "Field 'html' is required and must be a string");
|
|
230
|
+
|
|
231
|
+
const size = new TextEncoder().encode(html).byteLength;
|
|
232
|
+
if (size > MAX_HTML_BYTES) {
|
|
233
|
+
return err(413, `HTML exceeds limit of ${MAX_HTML_BYTES} bytes (got ${size})`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const title = typeof body?.title === "string" && body.title.trim() ? body.title.trim().slice(0, 200) : "Untitled";
|
|
237
|
+
const password = typeof body?.password === "string" && body.password ? String(body.password) : null;
|
|
238
|
+
const commentsEnabled = body?.comments_enabled === false ? false : true;
|
|
239
|
+
|
|
240
|
+
let expiresAt: number | null = null;
|
|
241
|
+
if (body?.expires_in != null) {
|
|
242
|
+
const mins = Number(body.expires_in);
|
|
243
|
+
if (!Number.isFinite(mins) || mins <= 0) return err(400, "expires_in must be a positive number of minutes");
|
|
244
|
+
if (mins > MAX_EXPIRES_MINUTES) return err(400, `expires_in cannot exceed ${MAX_EXPIRES_MINUTES} minutes`);
|
|
245
|
+
expiresAt = Date.now() + mins * 60 * 1000;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// generate slug with collision retry
|
|
249
|
+
let slug = "";
|
|
250
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
251
|
+
const candidate = randomString(SLUG_LEN);
|
|
252
|
+
const exists = await env.PAGES.get(`page:${candidate}`);
|
|
253
|
+
if (!exists) {
|
|
254
|
+
slug = candidate;
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (!slug) return err(500, "Could not allocate unique slug, please retry");
|
|
259
|
+
|
|
260
|
+
const ownerKey = `ws_${randomString(OWNER_KEY_LEN)}`;
|
|
261
|
+
const ownerKeyHash = await sha256(ownerKey);
|
|
262
|
+
const passwordHash = password ? await sha256(password) : null;
|
|
263
|
+
|
|
264
|
+
const record: PageRecord = {
|
|
265
|
+
slug,
|
|
266
|
+
html,
|
|
267
|
+
title,
|
|
268
|
+
owner_key_hash: ownerKeyHash,
|
|
269
|
+
password_hash: passwordHash,
|
|
270
|
+
created_at: Date.now(),
|
|
271
|
+
expires_at: expiresAt,
|
|
272
|
+
size_bytes: size,
|
|
273
|
+
comments_enabled: commentsEnabled,
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const putOpts: KVNamespacePutOptions = {};
|
|
277
|
+
if (expiresAt) {
|
|
278
|
+
const ttl = Math.max(60, Math.ceil((expiresAt - Date.now()) / 1000));
|
|
279
|
+
putOpts.expirationTtl = ttl;
|
|
280
|
+
}
|
|
281
|
+
await env.PAGES.put(`page:${slug}`, JSON.stringify(record), putOpts);
|
|
282
|
+
|
|
283
|
+
return json(
|
|
284
|
+
{
|
|
285
|
+
slug,
|
|
286
|
+
url: viewUrl(env, request, slug),
|
|
287
|
+
owner_key: ownerKey,
|
|
288
|
+
expires_at: expiresAt,
|
|
289
|
+
size_bytes: size,
|
|
290
|
+
comments_enabled: commentsEnabled,
|
|
291
|
+
},
|
|
292
|
+
201,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function commentsOn(rec: PageRecord): boolean {
|
|
297
|
+
// Backwards-compat: records created before the field existed default to ON.
|
|
298
|
+
return rec.comments_enabled !== false;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function metaFor(rec: PageRecord, env: Env, request: Request) {
|
|
302
|
+
return {
|
|
303
|
+
slug: rec.slug,
|
|
304
|
+
title: rec.title,
|
|
305
|
+
url: viewUrl(env, request, rec.slug),
|
|
306
|
+
has_password: rec.password_hash !== null,
|
|
307
|
+
created_at: rec.created_at,
|
|
308
|
+
expires_at: rec.expires_at,
|
|
309
|
+
size_bytes: rec.size_bytes,
|
|
310
|
+
comments_enabled: commentsOn(rec),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function handleGetMeta(env: Env, request: Request, slug: string): Promise<Response> {
|
|
315
|
+
const rec = await getPage(env, slug);
|
|
316
|
+
if (!rec) return err(404, "Page not found");
|
|
317
|
+
return json(metaFor(rec, env, request));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function requireOwner(request: Request, rec: PageRecord): Promise<Response | null> {
|
|
321
|
+
const provided = request.headers.get("x-owner-key");
|
|
322
|
+
if (!provided) return err(401, "Missing X-Owner-Key header");
|
|
323
|
+
const providedHash = await sha256(provided);
|
|
324
|
+
if (!timingSafeEqualHex(providedHash, rec.owner_key_hash)) return err(403, "Invalid owner_key");
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function handlePatch(request: Request, env: Env, slug: string): Promise<Response> {
|
|
329
|
+
const rec = await getPage(env, slug);
|
|
330
|
+
if (!rec) return err(404, "Page not found");
|
|
331
|
+
const authErr = await requireOwner(request, rec);
|
|
332
|
+
if (authErr) return authErr;
|
|
333
|
+
|
|
334
|
+
let body: any;
|
|
335
|
+
try {
|
|
336
|
+
body = await request.json();
|
|
337
|
+
} catch {
|
|
338
|
+
return err(400, "Invalid JSON body");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (typeof body?.html === "string") {
|
|
342
|
+
const size = new TextEncoder().encode(body.html).byteLength;
|
|
343
|
+
if (size > MAX_HTML_BYTES) return err(413, `HTML exceeds limit of ${MAX_HTML_BYTES} bytes (got ${size})`);
|
|
344
|
+
rec.html = body.html;
|
|
345
|
+
rec.size_bytes = size;
|
|
346
|
+
}
|
|
347
|
+
if (typeof body?.title === "string" && body.title.trim()) {
|
|
348
|
+
rec.title = body.title.trim().slice(0, 200);
|
|
349
|
+
}
|
|
350
|
+
if (typeof body?.comments_enabled === "boolean") {
|
|
351
|
+
rec.comments_enabled = body.comments_enabled;
|
|
352
|
+
}
|
|
353
|
+
if (body && "password" in body) {
|
|
354
|
+
if (body.password === null) {
|
|
355
|
+
rec.password_hash = null;
|
|
356
|
+
} else if (typeof body.password === "string" && body.password) {
|
|
357
|
+
rec.password_hash = await sha256(body.password);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const putOpts: KVNamespacePutOptions = {};
|
|
362
|
+
if (rec.expires_at) {
|
|
363
|
+
const ttl = Math.max(60, Math.ceil((rec.expires_at - Date.now()) / 1000));
|
|
364
|
+
putOpts.expirationTtl = ttl;
|
|
365
|
+
}
|
|
366
|
+
await env.PAGES.put(`page:${slug}`, JSON.stringify(rec), putOpts);
|
|
367
|
+
return json(metaFor(rec, env, request));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function handleDelete(request: Request, env: Env, slug: string): Promise<Response> {
|
|
371
|
+
const rec = await getPage(env, slug);
|
|
372
|
+
if (!rec) return err(404, "Page not found");
|
|
373
|
+
const authErr = await requireOwner(request, rec);
|
|
374
|
+
if (authErr) return authErr;
|
|
375
|
+
await env.PAGES.delete(`page:${slug}`);
|
|
376
|
+
return json({ slug, deleted: true });
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ---------- view ----------
|
|
380
|
+
|
|
381
|
+
function passwordCookieName(slug: string): string {
|
|
382
|
+
return `hp_${slug}`;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function cookieAuthorized(request: Request, slug: string): boolean {
|
|
386
|
+
const cookie = request.headers.get("cookie") || "";
|
|
387
|
+
const name = passwordCookieName(slug);
|
|
388
|
+
const re = new RegExp(`(?:^|;\\s*)${name}=ok(?:;|$)`);
|
|
389
|
+
return re.test(cookie);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function passwordPrompt(slug: string, error?: string): Response {
|
|
393
|
+
const html = `<!doctype html>
|
|
394
|
+
<meta charset="utf-8">
|
|
395
|
+
<title>Password required</title>
|
|
396
|
+
<style>
|
|
397
|
+
:root { color-scheme: light dark; }
|
|
398
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; max-width: 28rem; margin: 4rem auto; padding: 0 1rem; }
|
|
399
|
+
form { display: grid; gap: .75rem; }
|
|
400
|
+
input { padding: .6rem .75rem; font-size: 1rem; border: 1px solid #888; border-radius: .4rem; }
|
|
401
|
+
button { padding: .6rem .75rem; font-size: 1rem; border: 0; border-radius: .4rem; background: #2563eb; color: white; cursor: pointer; }
|
|
402
|
+
.err { color: #b91c1c; }
|
|
403
|
+
</style>
|
|
404
|
+
<h1>Password required</h1>
|
|
405
|
+
<p>This page is protected.</p>
|
|
406
|
+
${error ? `<p class="err">${escapeHtml(error)}</p>` : ""}
|
|
407
|
+
<form method="POST" action="/v/${escapeHtml(slug)}">
|
|
408
|
+
<input type="password" name="password" autofocus required autocomplete="current-password" placeholder="Password" />
|
|
409
|
+
<button type="submit">Unlock</button>
|
|
410
|
+
</form>`;
|
|
411
|
+
return new Response(html, {
|
|
412
|
+
status: error ? 401 : 200,
|
|
413
|
+
headers: {
|
|
414
|
+
"content-type": "text/html; charset=utf-8",
|
|
415
|
+
"cache-control": "no-store",
|
|
416
|
+
},
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function servePage(rec: PageRecord): Response {
|
|
421
|
+
return new Response(rec.html, {
|
|
422
|
+
status: 200,
|
|
423
|
+
headers: {
|
|
424
|
+
"content-type": "text/html; charset=utf-8",
|
|
425
|
+
"cache-control": "no-store",
|
|
426
|
+
"content-security-policy": PAGE_CSP,
|
|
427
|
+
"x-content-type-options": "nosniff",
|
|
428
|
+
"referrer-policy": "no-referrer",
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Returns null when the viewer is authorized; otherwise returns a Response
|
|
434
|
+
// (either a password prompt for HTML clients or a 401 JSON for API clients).
|
|
435
|
+
async function requireViewerAccess(
|
|
436
|
+
rec: PageRecord,
|
|
437
|
+
request: Request,
|
|
438
|
+
opts: { json?: boolean } = {},
|
|
439
|
+
): Promise<Response | null> {
|
|
440
|
+
if (!rec.password_hash) return null;
|
|
441
|
+
if (cookieAuthorized(request, rec.slug)) return null;
|
|
442
|
+
|
|
443
|
+
// Owner key is strictly more privileged than the viewer password.
|
|
444
|
+
// If the caller can prove ownership, skip the password gate.
|
|
445
|
+
const ownerKey = request.headers.get("x-owner-key");
|
|
446
|
+
if (ownerKey) {
|
|
447
|
+
const providedHash = await sha256(ownerKey);
|
|
448
|
+
if (timingSafeEqualHex(providedHash, rec.owner_key_hash)) return null;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (opts.json) {
|
|
452
|
+
return json({ error: { code: "password_required", message: "Password required" } }, 401);
|
|
453
|
+
}
|
|
454
|
+
return passwordPrompt(rec.slug);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function handleViewRaw(request: Request, env: Env, slug: string): Promise<Response> {
|
|
458
|
+
const rec = await getPage(env, slug);
|
|
459
|
+
if (!rec) return new Response("Not found", { status: 404 });
|
|
460
|
+
const gate = await requireViewerAccess(rec, request);
|
|
461
|
+
if (gate) return gate;
|
|
462
|
+
return servePage(rec);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function handleViewWrapper(request: Request, env: Env, slug: string): Promise<Response> {
|
|
466
|
+
const rec = await getPage(env, slug);
|
|
467
|
+
if (!rec) return new Response("Not found", { status: 404 });
|
|
468
|
+
|
|
469
|
+
if (request.method === "POST" && rec.password_hash) {
|
|
470
|
+
// password submission
|
|
471
|
+
const ct = request.headers.get("content-type") || "";
|
|
472
|
+
let provided = "";
|
|
473
|
+
if (ct.includes("application/x-www-form-urlencoded")) {
|
|
474
|
+
const form = await request.formData();
|
|
475
|
+
provided = String(form.get("password") || "");
|
|
476
|
+
} else {
|
|
477
|
+
try {
|
|
478
|
+
const body: any = await request.json();
|
|
479
|
+
provided = String(body?.password || "");
|
|
480
|
+
} catch {}
|
|
481
|
+
}
|
|
482
|
+
if (!provided) return passwordPrompt(slug, "Enter a password");
|
|
483
|
+
const ph = await sha256(provided);
|
|
484
|
+
if (!timingSafeEqualHex(ph, rec.password_hash)) return passwordPrompt(slug, "Incorrect password");
|
|
485
|
+
// set cookie + redirect to GET
|
|
486
|
+
return new Response(null, {
|
|
487
|
+
status: 303,
|
|
488
|
+
headers: {
|
|
489
|
+
location: `/v/${slug}`,
|
|
490
|
+
"set-cookie": `${passwordCookieName(slug)}=ok; Max-Age=3600; Path=/v/${slug}; HttpOnly; Secure; SameSite=Lax`,
|
|
491
|
+
},
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const gate = await requireViewerAccess(rec, request);
|
|
496
|
+
if (gate) return gate;
|
|
497
|
+
|
|
498
|
+
// When comments are disabled, the wrapper UI is dead weight: serve the
|
|
499
|
+
// artifact directly under the strict PAGE_CSP. Same result as /v/:slug/raw,
|
|
500
|
+
// but at the canonical view URL so links don't have to change.
|
|
501
|
+
if (!commentsOn(rec)) return servePage(rec);
|
|
502
|
+
|
|
503
|
+
return serveWrapper(rec);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function serveWrapper(rec: PageRecord): Response {
|
|
507
|
+
const slug = rec.slug;
|
|
508
|
+
const title = rec.title || "Untitled";
|
|
509
|
+
const html = wrapperHtml(slug, title);
|
|
510
|
+
return new Response(html, {
|
|
511
|
+
status: 200,
|
|
512
|
+
headers: {
|
|
513
|
+
"content-type": "text/html; charset=utf-8",
|
|
514
|
+
"cache-control": "no-store",
|
|
515
|
+
"content-security-policy": WRAPPER_CSP,
|
|
516
|
+
"x-content-type-options": "nosniff",
|
|
517
|
+
"referrer-policy": "no-referrer",
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function wrapperHtml(slug: string, title: string): string {
|
|
523
|
+
const safeSlug = escapeHtml(slug);
|
|
524
|
+
const safeTitle = escapeHtml(title);
|
|
525
|
+
// JSON-encoded slug for safe inline JS embedding.
|
|
526
|
+
const slugJson = JSON.stringify(slug);
|
|
527
|
+
return `<!doctype html>
|
|
528
|
+
<html lang="en">
|
|
529
|
+
<head>
|
|
530
|
+
<meta charset="utf-8">
|
|
531
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
532
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
533
|
+
<title>${safeTitle}</title>
|
|
534
|
+
<style>
|
|
535
|
+
:root {
|
|
536
|
+
color-scheme: light dark;
|
|
537
|
+
--fg: #111; --muted: #666; --bg: #fff; --panel: #fafafa; --border: #e5e7eb;
|
|
538
|
+
--accent: #2563eb; --danger: #b91c1c; --highlight: rgba(250,204,21,.45);
|
|
539
|
+
--highlight-flash: rgba(250,204,21,.85);
|
|
540
|
+
}
|
|
541
|
+
@media (prefers-color-scheme: dark) {
|
|
542
|
+
:root {
|
|
543
|
+
--fg:#eaeaea; --muted:#9ca3af; --bg:#0b0b0c; --panel:#121214; --border:#27272a;
|
|
544
|
+
--accent:#3b82f6; --danger:#f87171; --highlight: rgba(250,204,21,.35);
|
|
545
|
+
--highlight-flash: rgba(250,204,21,.7);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
* { box-sizing: border-box; }
|
|
549
|
+
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--fg); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
|
|
550
|
+
.app { display: grid; grid-template-rows: auto 1fr; height: 100vh; }
|
|
551
|
+
header.bar { display: flex; align-items: center; gap: .75rem; padding: .55rem .9rem; border-bottom: 1px solid var(--border); background: var(--panel); }
|
|
552
|
+
header.bar h1 { font-size: .95rem; margin: 0; font-weight: 600; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
553
|
+
header.bar .who { font-size: .8rem; color: var(--muted); }
|
|
554
|
+
header.bar button { font-size: .8rem; padding: .35rem .6rem; border: 1px solid var(--border); background: transparent; color: var(--fg); border-radius: .35rem; cursor: pointer; }
|
|
555
|
+
.main { display: grid; grid-template-columns: 1fr 360px; min-height: 0; }
|
|
556
|
+
.stage { position: relative; min-height: 0; overflow: hidden; background: var(--bg); }
|
|
557
|
+
.stage iframe { border: 0; width: 100%; height: 100%; display: block; background: white; }
|
|
558
|
+
.overlay { position: absolute; inset: 0; pointer-events: none; }
|
|
559
|
+
.hl { position: absolute; background: var(--highlight); border-radius: 2px; transition: background .25s; }
|
|
560
|
+
.hl.flash { background: var(--highlight-flash); }
|
|
561
|
+
.gutter { position: absolute; top: 0; right: 0; bottom: 0; width: 14px; pointer-events: none; }
|
|
562
|
+
.gutter .dot { position: absolute; right: 3px; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); opacity: .75; pointer-events: auto; cursor: pointer; }
|
|
563
|
+
.gutter .dot.resolved { background: var(--muted); opacity: .35; }
|
|
564
|
+
#commentBtn { position: absolute; z-index: 20; padding: .35rem .6rem; font-size: .8rem; border-radius: .35rem; border: 0; background: var(--accent); color: white; cursor: pointer; box-shadow: 0 1px 6px rgba(0,0,0,.25); display: none; }
|
|
565
|
+
aside.side { border-left: 1px solid var(--border); background: var(--panel); display: flex; flex-direction: column; min-height: 0; }
|
|
566
|
+
.side-head { display: flex; align-items: center; gap: .5rem; padding: .6rem .8rem; border-bottom: 1px solid var(--border); font-size: .85rem; }
|
|
567
|
+
.side-head label { display: flex; align-items: center; gap: .35rem; color: var(--muted); cursor: pointer; }
|
|
568
|
+
.side-list { flex: 1; overflow-y: auto; padding: .5rem; }
|
|
569
|
+
.empty { padding: 1rem; color: var(--muted); font-size: .9rem; text-align: center; }
|
|
570
|
+
.c { padding: .55rem .65rem; border: 1px solid var(--border); border-radius: .45rem; background: var(--bg); margin-bottom: .5rem; cursor: pointer; }
|
|
571
|
+
.c.resolved { opacity: .55; }
|
|
572
|
+
.c .meta { display: flex; gap: .4rem; font-size: .75rem; color: var(--muted); margin-bottom: .25rem; }
|
|
573
|
+
.c .meta .author { color: var(--fg); font-weight: 600; }
|
|
574
|
+
.c .quote { font-size: .78rem; color: var(--muted); border-left: 2px solid var(--border); padding-left: .5rem; margin: .25rem 0; font-style: italic; max-height: 2.6em; overflow: hidden; }
|
|
575
|
+
.c .body { font-size: .87rem; white-space: pre-wrap; word-wrap: break-word; }
|
|
576
|
+
.c .badge { font-size: .65rem; padding: .05rem .35rem; border-radius: .25rem; background: var(--border); color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
|
|
577
|
+
.c .badge.orphan { background: var(--danger); color: white; }
|
|
578
|
+
.c .note { font-size: .78rem; color: var(--muted); margin-top: .35rem; border-top: 1px dashed var(--border); padding-top: .35rem; }
|
|
579
|
+
.composer { border-top: 1px solid var(--border); padding: .6rem .7rem; display: none; flex-direction: column; gap: .4rem; }
|
|
580
|
+
.composer.active { display: flex; }
|
|
581
|
+
.composer .ctx { font-size: .75rem; color: var(--muted); border-left: 2px solid var(--accent); padding-left: .5rem; max-height: 4em; overflow: hidden; }
|
|
582
|
+
.composer textarea { width: 100%; min-height: 4.5rem; padding: .4rem; font: inherit; font-size: .85rem; border: 1px solid var(--border); border-radius: .35rem; background: var(--bg); color: var(--fg); resize: vertical; }
|
|
583
|
+
.composer .row { display: flex; gap: .4rem; justify-content: flex-end; }
|
|
584
|
+
.composer button { font-size: .8rem; padding: .35rem .65rem; border-radius: .35rem; border: 1px solid var(--border); background: transparent; color: var(--fg); cursor: pointer; }
|
|
585
|
+
.composer button.primary { background: var(--accent); color: white; border-color: var(--accent); }
|
|
586
|
+
dialog { border: 1px solid var(--border); border-radius: .5rem; padding: 1rem; max-width: 22rem; color: var(--fg); background: var(--bg); }
|
|
587
|
+
dialog::backdrop { background: rgba(0,0,0,.4); }
|
|
588
|
+
dialog input { width: 100%; padding: .45rem; margin-top: .35rem; border: 1px solid var(--border); border-radius: .35rem; font: inherit; background: var(--bg); color: var(--fg); }
|
|
589
|
+
dialog .row { margin-top: .75rem; display: flex; gap: .4rem; justify-content: flex-end; }
|
|
590
|
+
dialog button { font-size: .85rem; padding: .35rem .75rem; border-radius: .35rem; border: 1px solid var(--border); background: transparent; color: var(--fg); cursor: pointer; }
|
|
591
|
+
dialog button.primary { background: var(--accent); color: white; border-color: var(--accent); }
|
|
592
|
+
#mobToggle {
|
|
593
|
+
display: none;
|
|
594
|
+
position: fixed; bottom: 1.1rem; right: 1.1rem; z-index: 110;
|
|
595
|
+
align-items: center; gap: .4rem;
|
|
596
|
+
padding: .5rem .85rem; border: 0; border-radius: 2rem;
|
|
597
|
+
background: var(--accent); color: white;
|
|
598
|
+
font-size: .85rem; font-weight: 600; cursor: pointer;
|
|
599
|
+
box-shadow: 0 2px 10px rgba(0,0,0,.3);
|
|
600
|
+
}
|
|
601
|
+
@media (max-width: 720px) {
|
|
602
|
+
.main { grid-template-columns: 1fr; }
|
|
603
|
+
aside.side {
|
|
604
|
+
display: none;
|
|
605
|
+
position: fixed; inset: 0; z-index: 100;
|
|
606
|
+
border-left: 0; border-top: 0;
|
|
607
|
+
}
|
|
608
|
+
aside.side.mob-open {
|
|
609
|
+
display: flex;
|
|
610
|
+
}
|
|
611
|
+
#mobToggle {
|
|
612
|
+
display: flex;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
</style>
|
|
616
|
+
</head>
|
|
617
|
+
<body>
|
|
618
|
+
<div class="app">
|
|
619
|
+
<header class="bar">
|
|
620
|
+
<h1>${safeTitle}</h1>
|
|
621
|
+
<span class="who" id="whoLabel"></span>
|
|
622
|
+
<button id="renameBtn" type="button">Rename</button>
|
|
623
|
+
</header>
|
|
624
|
+
<div class="main">
|
|
625
|
+
<div class="stage" id="stage">
|
|
626
|
+
<iframe id="art" src="/v/${safeSlug}/raw" title="${safeTitle}"></iframe>
|
|
627
|
+
<div class="overlay" id="overlay"></div>
|
|
628
|
+
<div class="gutter" id="gutter"></div>
|
|
629
|
+
<button id="commentBtn" type="button">Comment</button>
|
|
630
|
+
</div>
|
|
631
|
+
<aside class="side" id="side">
|
|
632
|
+
<div class="side-head">
|
|
633
|
+
<strong style="flex:1">Comments</strong>
|
|
634
|
+
<label><input type="checkbox" id="showResolved"> show resolved</label>
|
|
635
|
+
<a href="/v/${safeSlug}/raw" title="View without comments sidebar" style="font-size:.75rem;color:var(--muted);text-decoration:none;padding:.2rem .4rem;border:1px solid var(--border);border-radius:.3rem;white-space:nowrap">bare view</a>
|
|
636
|
+
</div>
|
|
637
|
+
<div class="side-list" id="list"><div class="empty">Loading…</div></div>
|
|
638
|
+
<form class="composer" id="composer">
|
|
639
|
+
<div class="ctx" id="composerCtx"></div>
|
|
640
|
+
<textarea id="composerBody" maxlength="${MAX_COMMENT_BODY}" placeholder="Add a comment…" required></textarea>
|
|
641
|
+
<div class="row">
|
|
642
|
+
<button type="button" id="composerCancel">Cancel</button>
|
|
643
|
+
<button type="submit" class="primary">Post</button>
|
|
644
|
+
</div>
|
|
645
|
+
</form>
|
|
646
|
+
</aside>
|
|
647
|
+
</div>
|
|
648
|
+
</div>
|
|
649
|
+
<button id="mobToggle" type="button" aria-label="Toggle comments">Comments</button>
|
|
650
|
+
|
|
651
|
+
<dialog id="nameDlg">
|
|
652
|
+
<form method="dialog" id="nameForm">
|
|
653
|
+
<strong>Your display name</strong>
|
|
654
|
+
<p style="margin:.3rem 0 0; color:var(--muted); font-size:.85rem">Shown next to your comments.</p>
|
|
655
|
+
<input id="nameInput" maxlength="${MAX_COMMENT_AUTHOR}" required placeholder="e.g. Alex" autocomplete="off">
|
|
656
|
+
<div class="row">
|
|
657
|
+
<button value="cancel" type="button" id="nameCancel">Cancel</button>
|
|
658
|
+
<button value="ok" type="submit" class="primary">Save</button>
|
|
659
|
+
</div>
|
|
660
|
+
</form>
|
|
661
|
+
</dialog>
|
|
662
|
+
|
|
663
|
+
<script>
|
|
664
|
+
(function(){
|
|
665
|
+
const SLUG = ${slugJson};
|
|
666
|
+
const MAX_BODY = ${MAX_COMMENT_BODY};
|
|
667
|
+
const MAX_AUTHOR = ${MAX_COMMENT_AUTHOR};
|
|
668
|
+
const MAX_QUOTE = ${MAX_ANCHOR_QUOTE};
|
|
669
|
+
const MAX_CTX = ${MAX_ANCHOR_CONTEXT};
|
|
670
|
+
|
|
671
|
+
const iframe = document.getElementById('art');
|
|
672
|
+
const stage = document.getElementById('stage');
|
|
673
|
+
const overlay = document.getElementById('overlay');
|
|
674
|
+
const gutter = document.getElementById('gutter');
|
|
675
|
+
const listEl = document.getElementById('list');
|
|
676
|
+
const commentBtn = document.getElementById('commentBtn');
|
|
677
|
+
const composer = document.getElementById('composer');
|
|
678
|
+
const composerBody = document.getElementById('composerBody');
|
|
679
|
+
const composerCtx = document.getElementById('composerCtx');
|
|
680
|
+
const composerCancel = document.getElementById('composerCancel');
|
|
681
|
+
const showResolvedEl = document.getElementById('showResolved');
|
|
682
|
+
const renameBtn = document.getElementById('renameBtn');
|
|
683
|
+
const whoLabel = document.getElementById('whoLabel');
|
|
684
|
+
const nameDlg = document.getElementById('nameDlg');
|
|
685
|
+
const nameForm = document.getElementById('nameForm');
|
|
686
|
+
const nameInput = document.getElementById('nameInput');
|
|
687
|
+
const nameCancel = document.getElementById('nameCancel');
|
|
688
|
+
const side = document.getElementById('side');
|
|
689
|
+
const mobToggle = document.getElementById('mobToggle');
|
|
690
|
+
|
|
691
|
+
// Mobile sidebar toggle.
|
|
692
|
+
function isMobile() { return window.innerWidth <= 720; }
|
|
693
|
+
function updateMobToggleLabel(count) {
|
|
694
|
+
if (!mobToggle) return;
|
|
695
|
+
mobToggle.textContent = 'Comments' + (count > 0 ? ' (' + count + ')' : '');
|
|
696
|
+
}
|
|
697
|
+
if (mobToggle && side) {
|
|
698
|
+
mobToggle.addEventListener('click', function() {
|
|
699
|
+
const open = side.classList.toggle('mob-open');
|
|
700
|
+
mobToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
701
|
+
});
|
|
702
|
+
// Close sidebar when tapping outside of it on mobile.
|
|
703
|
+
document.addEventListener('click', function(e) {
|
|
704
|
+
if (!isMobile()) return;
|
|
705
|
+
if (side.classList.contains('mob-open') &&
|
|
706
|
+
!side.contains(e.target) && e.target !== mobToggle) {
|
|
707
|
+
side.classList.remove('mob-open');
|
|
708
|
+
mobToggle.setAttribute('aria-expanded', 'false');
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
let comments = [];
|
|
714
|
+
let pendingAnchor = null;
|
|
715
|
+
let resolvedAnchors = new Map(); // id -> {rects, found, topY}
|
|
716
|
+
|
|
717
|
+
function getName() { return localStorage.getItem('pcf_name') || ''; }
|
|
718
|
+
function setName(n) { localStorage.setItem('pcf_name', n); refreshWho(); }
|
|
719
|
+
function refreshWho() {
|
|
720
|
+
const n = getName();
|
|
721
|
+
whoLabel.textContent = n ? ('as ' + n) : '(no name yet)';
|
|
722
|
+
}
|
|
723
|
+
refreshWho();
|
|
724
|
+
|
|
725
|
+
function askName() {
|
|
726
|
+
return new Promise(function(resolve){
|
|
727
|
+
nameInput.value = getName();
|
|
728
|
+
const onCancel = function(){ nameDlg.close('cancel'); };
|
|
729
|
+
nameCancel.onclick = onCancel;
|
|
730
|
+
nameForm.onsubmit = function(e){
|
|
731
|
+
const v = (nameInput.value || '').trim().slice(0, MAX_AUTHOR);
|
|
732
|
+
if (!v) { e.preventDefault(); return; }
|
|
733
|
+
setName(v);
|
|
734
|
+
// Let the dialog close naturally via method="dialog"
|
|
735
|
+
};
|
|
736
|
+
nameDlg.addEventListener('close', function once(){
|
|
737
|
+
nameDlg.removeEventListener('close', once);
|
|
738
|
+
resolve(getName());
|
|
739
|
+
});
|
|
740
|
+
try { nameDlg.showModal(); } catch(_) { /* fallback */ const v = prompt('Your display name', getName() || ''); if (v) setName(v.trim().slice(0, MAX_AUTHOR)); resolve(getName()); }
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
renameBtn.addEventListener('click', function(){ askName(); });
|
|
745
|
+
|
|
746
|
+
function escapeText(s){ return String(s).replace(/[&<>"']/g, function(c){ return ({'&':'&','<':'<','>':'>','"':'"','\\'':'''})[c]; }); }
|
|
747
|
+
|
|
748
|
+
async function fetchComments() {
|
|
749
|
+
try {
|
|
750
|
+
const r = await fetch('/v/' + SLUG + '/comments?status=all', { credentials: 'same-origin' });
|
|
751
|
+
if (!r.ok) {
|
|
752
|
+
if (r.status === 401) { listEl.innerHTML = '<div class="empty">Password required to view comments.</div>'; return; }
|
|
753
|
+
throw new Error('HTTP ' + r.status);
|
|
754
|
+
}
|
|
755
|
+
const data = await r.json();
|
|
756
|
+
const fresh = Array.isArray(data.comments) ? data.comments : [];
|
|
757
|
+
// Merge: trust the server for any comment it returns, but keep
|
|
758
|
+
// optimistic locals (created in this session) that haven't propagated
|
|
759
|
+
// to KV's list view yet. Reconciles within ~1min.
|
|
760
|
+
const byId = new Map();
|
|
761
|
+
for (const c of fresh) byId.set(c.id, c);
|
|
762
|
+
for (const c of comments) if (!byId.has(c.id)) byId.set(c.id, c);
|
|
763
|
+
comments = Array.from(byId.values()).sort(function(a,b){ return a.created_at - b.created_at; });
|
|
764
|
+
renderList();
|
|
765
|
+
resolveAnchors();
|
|
766
|
+
} catch (e) {
|
|
767
|
+
listEl.innerHTML = '<div class="empty">Failed to load comments.</div>';
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function renderList() {
|
|
772
|
+
const showR = showResolvedEl.checked;
|
|
773
|
+
const visible = comments.filter(function(c){ return showR || c.status === 'open'; });
|
|
774
|
+
updateMobToggleLabel(comments.filter(function(c){ return c.status === 'open'; }).length);
|
|
775
|
+
if (!visible.length) { listEl.innerHTML = '<div class="empty">No comments yet. Select text in the page to add one.</div>'; return; }
|
|
776
|
+
const parts = visible.map(function(c){
|
|
777
|
+
const orphan = c.anchor && resolvedAnchors.get(c.id) && resolvedAnchors.get(c.id).found === false;
|
|
778
|
+
const badge = c.status === 'resolved'
|
|
779
|
+
? '<span class="badge">resolved</span>'
|
|
780
|
+
: (orphan ? '<span class="badge orphan">orphaned</span>' : '');
|
|
781
|
+
const time = new Date(c.created_at).toLocaleString();
|
|
782
|
+
const quote = c.anchor && c.anchor.quote ? '<div class="quote">"' + escapeText(c.anchor.quote) + '"</div>' : '';
|
|
783
|
+
const note = c.resolution_note ? '<div class="note">' + escapeText(c.resolution_note) + '</div>' : '';
|
|
784
|
+
return '<div class="c ' + (c.status === 'resolved' ? 'resolved' : '') + '" data-id="' + escapeText(c.id) + '">' +
|
|
785
|
+
'<div class="meta"><span class="author">' + escapeText(c.author) + '</span>' +
|
|
786
|
+
'<span>' + escapeText(time) + '</span>' + badge + '</div>' +
|
|
787
|
+
quote +
|
|
788
|
+
'<div class="body">' + escapeText(c.body) + '</div>' +
|
|
789
|
+
note +
|
|
790
|
+
'</div>';
|
|
791
|
+
});
|
|
792
|
+
listEl.innerHTML = parts.join('');
|
|
793
|
+
Array.from(listEl.querySelectorAll('.c')).forEach(function(el){
|
|
794
|
+
el.addEventListener('click', function(){ scrollToComment(el.getAttribute('data-id')); });
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
showResolvedEl.addEventListener('change', function(){ renderList(); paintOverlays(); });
|
|
799
|
+
|
|
800
|
+
function getIframeDoc() {
|
|
801
|
+
try { return iframe.contentDocument; } catch(_) { return null; }
|
|
802
|
+
}
|
|
803
|
+
function getIframeWin() {
|
|
804
|
+
try { return iframe.contentWindow; } catch(_) { return null; }
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function findRangeForAnchor(anchor) {
|
|
808
|
+
const doc = getIframeDoc();
|
|
809
|
+
if (!doc || !anchor) return null;
|
|
810
|
+
const body = doc.body;
|
|
811
|
+
if (!body) return null;
|
|
812
|
+
const fullText = body.innerText;
|
|
813
|
+
const target = anchor.prefix + anchor.quote + anchor.suffix;
|
|
814
|
+
let idx = fullText.indexOf(target);
|
|
815
|
+
let offset = anchor.prefix.length;
|
|
816
|
+
if (idx < 0) {
|
|
817
|
+
// fallback: just quote
|
|
818
|
+
idx = fullText.indexOf(anchor.quote);
|
|
819
|
+
offset = 0;
|
|
820
|
+
if (idx < 0) return null;
|
|
821
|
+
}
|
|
822
|
+
const start = idx + offset;
|
|
823
|
+
const end = start + anchor.quote.length;
|
|
824
|
+
return rangeFromTextOffsets(doc, body, start, end);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function rangeFromTextOffsets(doc, root, start, end) {
|
|
828
|
+
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
|
|
829
|
+
let pos = 0;
|
|
830
|
+
let startNode = null, startOff = 0, endNode = null, endOff = 0;
|
|
831
|
+
let n;
|
|
832
|
+
while ((n = walker.nextNode())) {
|
|
833
|
+
const t = n.nodeValue || '';
|
|
834
|
+
const len = t.length;
|
|
835
|
+
if (!startNode && pos + len >= start) { startNode = n; startOff = start - pos; }
|
|
836
|
+
if (!endNode && pos + len >= end) { endNode = n; endOff = end - pos; break; }
|
|
837
|
+
pos += len;
|
|
838
|
+
}
|
|
839
|
+
if (!startNode || !endNode) return null;
|
|
840
|
+
try {
|
|
841
|
+
const r = doc.createRange();
|
|
842
|
+
r.setStart(startNode, startOff);
|
|
843
|
+
r.setEnd(endNode, endOff);
|
|
844
|
+
return r;
|
|
845
|
+
} catch(_) { return null; }
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function resolveAnchors() {
|
|
849
|
+
resolvedAnchors = new Map();
|
|
850
|
+
const doc = getIframeDoc();
|
|
851
|
+
if (!doc) { paintOverlays(); return; }
|
|
852
|
+
for (const c of comments) {
|
|
853
|
+
if (!c.anchor) { resolvedAnchors.set(c.id, { rects: [], found: false, topY: 0 }); continue; }
|
|
854
|
+
const r = findRangeForAnchor(c.anchor);
|
|
855
|
+
if (!r) { resolvedAnchors.set(c.id, { rects: [], found: false, topY: 0 }); continue; }
|
|
856
|
+
const rects = Array.from(r.getClientRects());
|
|
857
|
+
const topY = rects.length ? rects[0].top : 0;
|
|
858
|
+
resolvedAnchors.set(c.id, { rects, found: true, topY, range: r });
|
|
859
|
+
}
|
|
860
|
+
paintOverlays();
|
|
861
|
+
renderList();
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function paintOverlays() {
|
|
865
|
+
const showR = showResolvedEl.checked;
|
|
866
|
+
overlay.innerHTML = '';
|
|
867
|
+
gutter.innerHTML = '';
|
|
868
|
+
const stageH = stage.getBoundingClientRect().height || 1;
|
|
869
|
+
const iframeRect = iframe.getBoundingClientRect();
|
|
870
|
+
const stageRect = stage.getBoundingClientRect();
|
|
871
|
+
const offX = iframeRect.left - stageRect.left;
|
|
872
|
+
const offY = iframeRect.top - stageRect.top;
|
|
873
|
+
const iwin = getIframeWin();
|
|
874
|
+
const idoc = getIframeDoc();
|
|
875
|
+
// Total document height inside the iframe — used to map gutter dot to
|
|
876
|
+
// the artifact's full scroll range, not just the visible window.
|
|
877
|
+
const docH = (idoc && idoc.documentElement)
|
|
878
|
+
? Math.max(
|
|
879
|
+
idoc.documentElement.scrollHeight,
|
|
880
|
+
(idoc.body && idoc.body.scrollHeight) || 0,
|
|
881
|
+
idoc.documentElement.clientHeight
|
|
882
|
+
)
|
|
883
|
+
: 0;
|
|
884
|
+
const scrollY = (iwin && iwin.scrollY) || 0;
|
|
885
|
+
for (const c of comments) {
|
|
886
|
+
if (c.status === 'resolved' && !showR) continue;
|
|
887
|
+
const info = resolvedAnchors.get(c.id);
|
|
888
|
+
if (!info || !info.found) continue;
|
|
889
|
+
// Recompute rects live from the resolved Range. getClientRects() on a
|
|
890
|
+
// Range inside the iframe returns rects relative to the iframe window
|
|
891
|
+
// viewport, which already accounts for the iframe's internal scroll.
|
|
892
|
+
// Cached rects (from resolveAnchors) would not move when the iframe
|
|
893
|
+
// scrolls, so we always read fresh ones here.
|
|
894
|
+
let rects = info.rects;
|
|
895
|
+
if (info.range) {
|
|
896
|
+
try {
|
|
897
|
+
const live = Array.from(info.range.getClientRects());
|
|
898
|
+
if (live.length) rects = live;
|
|
899
|
+
} catch(_) {}
|
|
900
|
+
}
|
|
901
|
+
for (const rect of rects) {
|
|
902
|
+
const div = document.createElement('div');
|
|
903
|
+
div.className = 'hl';
|
|
904
|
+
if (c.status === 'resolved') div.style.opacity = '.4';
|
|
905
|
+
div.style.left = (offX + rect.left) + 'px';
|
|
906
|
+
div.style.top = (offY + rect.top) + 'px';
|
|
907
|
+
div.style.width = rect.width + 'px';
|
|
908
|
+
div.style.height = rect.height + 'px';
|
|
909
|
+
div.setAttribute('data-id', c.id);
|
|
910
|
+
overlay.appendChild(div);
|
|
911
|
+
}
|
|
912
|
+
// gutter dot — map the anchor's document-space Y to the gutter's
|
|
913
|
+
// visible height. Document-space Y is rect.top + scrollY at the time
|
|
914
|
+
// we measured. Using the live rects keeps it accurate after scroll.
|
|
915
|
+
const firstRect = rects[0];
|
|
916
|
+
const docY = firstRect ? (firstRect.top + scrollY) : 0;
|
|
917
|
+
const yFrac = docH > 0 ? Math.max(0, Math.min(1, docY / docH)) : 0;
|
|
918
|
+
const dot = document.createElement('div');
|
|
919
|
+
dot.className = 'dot' + (c.status === 'resolved' ? ' resolved' : '');
|
|
920
|
+
dot.style.top = (yFrac * stageH) + 'px';
|
|
921
|
+
dot.title = c.author + ': ' + c.body.slice(0, 80);
|
|
922
|
+
dot.addEventListener('click', function(){ scrollToComment(c.id); });
|
|
923
|
+
gutter.appendChild(dot);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function scrollToComment(id) {
|
|
928
|
+
const info = resolvedAnchors.get(id);
|
|
929
|
+
const win = getIframeWin();
|
|
930
|
+
if (info && info.found && info.range && win) {
|
|
931
|
+
const rect = info.range.getBoundingClientRect();
|
|
932
|
+
const targetY = (win.scrollY || 0) + rect.top - 80;
|
|
933
|
+
try { win.scrollTo({ top: targetY, behavior: 'smooth' }); } catch(_) { win.scrollTo(0, targetY); }
|
|
934
|
+
// flash highlight
|
|
935
|
+
setTimeout(function(){
|
|
936
|
+
resolveAnchors();
|
|
937
|
+
const els = overlay.querySelectorAll('[data-id="' + id + '"]');
|
|
938
|
+
els.forEach(function(el){ el.classList.add('flash'); });
|
|
939
|
+
setTimeout(function(){ els.forEach(function(el){ el.classList.remove('flash'); }); }, 900);
|
|
940
|
+
}, 300);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function buildAnchorFromSelection() {
|
|
945
|
+
const doc = getIframeDoc();
|
|
946
|
+
const win = getIframeWin();
|
|
947
|
+
if (!doc || !win) return null;
|
|
948
|
+
const sel = win.getSelection && win.getSelection();
|
|
949
|
+
if (!sel || sel.isCollapsed || !sel.rangeCount) return null;
|
|
950
|
+
const range = sel.getRangeAt(0);
|
|
951
|
+
let quote = String(sel.toString() || '').trim();
|
|
952
|
+
if (!quote) return null;
|
|
953
|
+
if (quote.length > MAX_QUOTE) quote = quote.slice(0, MAX_QUOTE);
|
|
954
|
+
const full = doc.body.innerText;
|
|
955
|
+
const idx = full.indexOf(quote);
|
|
956
|
+
let prefix = '', suffix = '';
|
|
957
|
+
if (idx >= 0) {
|
|
958
|
+
prefix = full.slice(Math.max(0, idx - MAX_CTX), idx);
|
|
959
|
+
suffix = full.slice(idx + quote.length, idx + quote.length + MAX_CTX);
|
|
960
|
+
}
|
|
961
|
+
const rect = range.getBoundingClientRect();
|
|
962
|
+
return { anchor: { quote, prefix, suffix }, rect };
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function showCommentButtonForSelection() {
|
|
966
|
+
const sel = buildAnchorFromSelection();
|
|
967
|
+
if (!sel) { commentBtn.style.display = 'none'; return; }
|
|
968
|
+
const iframeRect = iframe.getBoundingClientRect();
|
|
969
|
+
const stageRect = stage.getBoundingClientRect();
|
|
970
|
+
const x = (iframeRect.left - stageRect.left) + sel.rect.left + sel.rect.width;
|
|
971
|
+
const y = (iframeRect.top - stageRect.top) + sel.rect.top - 30;
|
|
972
|
+
commentBtn.style.left = Math.max(4, x - 70) + 'px';
|
|
973
|
+
commentBtn.style.top = Math.max(4, y) + 'px';
|
|
974
|
+
commentBtn.style.display = 'block';
|
|
975
|
+
commentBtn._pending = sel.anchor;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
commentBtn.addEventListener('click', async function(){
|
|
979
|
+
let name = getName();
|
|
980
|
+
if (!name) { await askName(); name = getName(); }
|
|
981
|
+
if (!name) return;
|
|
982
|
+
pendingAnchor = commentBtn._pending || null;
|
|
983
|
+
composerCtx.textContent = pendingAnchor ? ('"' + pendingAnchor.quote + '"') : '(no anchor)';
|
|
984
|
+
composer.classList.add('active');
|
|
985
|
+
composerBody.focus();
|
|
986
|
+
commentBtn.style.display = 'none';
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
composerCancel.addEventListener('click', function(){
|
|
990
|
+
composer.classList.remove('active');
|
|
991
|
+
composerBody.value = '';
|
|
992
|
+
pendingAnchor = null;
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
composer.addEventListener('submit', async function(e){
|
|
996
|
+
e.preventDefault();
|
|
997
|
+
const body = (composerBody.value || '').trim();
|
|
998
|
+
if (!body) return;
|
|
999
|
+
const author = getName();
|
|
1000
|
+
if (!author) { await askName(); }
|
|
1001
|
+
const payload = { anchor: pendingAnchor, body: body.slice(0, MAX_BODY), author: getName() };
|
|
1002
|
+
try {
|
|
1003
|
+
const r = await fetch('/v/' + SLUG + '/comments', {
|
|
1004
|
+
method: 'POST',
|
|
1005
|
+
credentials: 'same-origin',
|
|
1006
|
+
headers: { 'content-type': 'application/json' },
|
|
1007
|
+
body: JSON.stringify(payload),
|
|
1008
|
+
});
|
|
1009
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
1010
|
+
const created = await r.json();
|
|
1011
|
+
composer.classList.remove('active');
|
|
1012
|
+
composerBody.value = '';
|
|
1013
|
+
pendingAnchor = null;
|
|
1014
|
+
// Optimistic insert — KV list() is eventually consistent and may not
|
|
1015
|
+
// reflect this comment for up to ~60s. Show it immediately, then let
|
|
1016
|
+
// the next poll reconcile.
|
|
1017
|
+
if (created && created.id && !comments.some(function(c){ return c.id === created.id; })) {
|
|
1018
|
+
comments.push(created);
|
|
1019
|
+
renderList();
|
|
1020
|
+
resolveAnchors();
|
|
1021
|
+
}
|
|
1022
|
+
// Clear the iframe selection so the next selection re-triggers the button.
|
|
1023
|
+
try { getIframeWin().getSelection().removeAllRanges(); } catch(_) {}
|
|
1024
|
+
commentBtn.style.display = 'none';
|
|
1025
|
+
} catch (err) {
|
|
1026
|
+
alert('Failed to post comment: ' + err);
|
|
1027
|
+
}
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
let listenersAttached = false;
|
|
1031
|
+
let scrollPaintScheduled = false;
|
|
1032
|
+
function schedulePaint() {
|
|
1033
|
+
if (scrollPaintScheduled) return;
|
|
1034
|
+
scrollPaintScheduled = true;
|
|
1035
|
+
requestAnimationFrame(function(){
|
|
1036
|
+
scrollPaintScheduled = false;
|
|
1037
|
+
paintOverlays();
|
|
1038
|
+
// Also re-show or re-position the floating comment button if a
|
|
1039
|
+
// selection is active, so it tracks the text while scrolling.
|
|
1040
|
+
try { showCommentButtonForSelection(); } catch(_) {}
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
function attachIframeListeners() {
|
|
1044
|
+
const doc = getIframeDoc();
|
|
1045
|
+
const win = getIframeWin();
|
|
1046
|
+
if (!doc || !win) return;
|
|
1047
|
+
if (listenersAttached) return;
|
|
1048
|
+
listenersAttached = true;
|
|
1049
|
+
doc.addEventListener('selectionchange', function(){
|
|
1050
|
+
// Debounce-ish: defer to next frame so the rect is final.
|
|
1051
|
+
requestAnimationFrame(showCommentButtonForSelection);
|
|
1052
|
+
});
|
|
1053
|
+
doc.addEventListener('mouseup', function(){
|
|
1054
|
+
requestAnimationFrame(showCommentButtonForSelection);
|
|
1055
|
+
});
|
|
1056
|
+
win.addEventListener('scroll', schedulePaint, { passive: true });
|
|
1057
|
+
win.addEventListener('resize', function(){ resolveAnchors(); });
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function tryAttachNow() {
|
|
1061
|
+
const doc = getIframeDoc();
|
|
1062
|
+
if (doc && doc.readyState && doc.readyState !== 'loading') {
|
|
1063
|
+
attachIframeListeners();
|
|
1064
|
+
resolveAnchors();
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
iframe.addEventListener('load', function(){
|
|
1069
|
+
listenersAttached = false; // re-attach to the new contentWindow on reload
|
|
1070
|
+
attachIframeListeners();
|
|
1071
|
+
resolveAnchors();
|
|
1072
|
+
});
|
|
1073
|
+
// The iframe may already be loaded by the time this script runs (same-origin
|
|
1074
|
+
// /raw fetch can complete before the wrapper script executes). Without this,
|
|
1075
|
+
// the 'load' listener above never fires and scroll/resize handlers are
|
|
1076
|
+
// never wired up — so highlights would not track iframe scrolling.
|
|
1077
|
+
tryAttachNow();
|
|
1078
|
+
window.addEventListener('resize', function(){ resolveAnchors(); });
|
|
1079
|
+
// Wrapper-window scroll (e.g. mobile chrome bar / overflow) should also
|
|
1080
|
+
// repaint, since overlay coordinates are stage-relative.
|
|
1081
|
+
window.addEventListener('scroll', schedulePaint, { passive: true });
|
|
1082
|
+
|
|
1083
|
+
// Initial load + poll.
|
|
1084
|
+
fetchComments();
|
|
1085
|
+
setInterval(fetchComments, 5000);
|
|
1086
|
+
|
|
1087
|
+
// Prompt for name on first interaction if missing.
|
|
1088
|
+
document.addEventListener('click', function once(){
|
|
1089
|
+
if (!getName()) askName();
|
|
1090
|
+
document.removeEventListener('click', once);
|
|
1091
|
+
}, { once: true });
|
|
1092
|
+
})();
|
|
1093
|
+
</script>
|
|
1094
|
+
</body>
|
|
1095
|
+
</html>`;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// ---------- comment handlers ----------
|
|
1099
|
+
|
|
1100
|
+
const COMMENT_ID_ALPHA = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
1101
|
+
|
|
1102
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
1103
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
function validateAnchor(raw: unknown): { ok: true; anchor: Comment["anchor"] } | { ok: false; message: string } {
|
|
1107
|
+
if (raw === null || raw === undefined) return { ok: true, anchor: null };
|
|
1108
|
+
if (!isPlainObject(raw)) return { ok: false, message: "anchor must be null or an object" };
|
|
1109
|
+
const quote = raw.quote;
|
|
1110
|
+
const prefix = raw.prefix;
|
|
1111
|
+
const suffix = raw.suffix;
|
|
1112
|
+
if (typeof quote !== "string" || typeof prefix !== "string" || typeof suffix !== "string") {
|
|
1113
|
+
return { ok: false, message: "anchor.quote, anchor.prefix, anchor.suffix must be strings" };
|
|
1114
|
+
}
|
|
1115
|
+
if (quote.length === 0) return { ok: false, message: "anchor.quote must be non-empty" };
|
|
1116
|
+
if (quote.length > MAX_ANCHOR_QUOTE) return { ok: false, message: `anchor.quote exceeds ${MAX_ANCHOR_QUOTE} chars` };
|
|
1117
|
+
if (prefix.length > MAX_ANCHOR_CONTEXT)
|
|
1118
|
+
return { ok: false, message: `anchor.prefix exceeds ${MAX_ANCHOR_CONTEXT} chars` };
|
|
1119
|
+
if (suffix.length > MAX_ANCHOR_CONTEXT)
|
|
1120
|
+
return { ok: false, message: `anchor.suffix exceeds ${MAX_ANCHOR_CONTEXT} chars` };
|
|
1121
|
+
return { ok: true, anchor: { quote, prefix, suffix } };
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
async function handleCommentList(env: Env, request: Request, slug: string): Promise<Response> {
|
|
1125
|
+
const rec = await getPage(env, slug);
|
|
1126
|
+
if (!rec) return err(404, "Page not found");
|
|
1127
|
+
if (!commentsOn(rec)) return err(403, "Comments are disabled for this page", "comments_disabled");
|
|
1128
|
+
const gate = await requireViewerAccess(rec, request, { json: true });
|
|
1129
|
+
if (gate) return gate;
|
|
1130
|
+
|
|
1131
|
+
const url = new URL(request.url);
|
|
1132
|
+
const statusFilter = (url.searchParams.get("status") || "open").toLowerCase();
|
|
1133
|
+
if (statusFilter !== "open" && statusFilter !== "all") {
|
|
1134
|
+
return err(400, "status must be 'open' or 'all'");
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
let all = await listComments(env, slug);
|
|
1138
|
+
if (statusFilter === "open") all = all.filter((c) => c.status === "open");
|
|
1139
|
+
return json({ slug, comments: all });
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
async function handleCommentCreate(env: Env, request: Request, slug: string): Promise<Response> {
|
|
1143
|
+
const rec = await getPage(env, slug);
|
|
1144
|
+
if (!rec) return err(404, "Page not found");
|
|
1145
|
+
if (!commentsOn(rec)) return err(403, "Comments are disabled for this page", "comments_disabled");
|
|
1146
|
+
const gate = await requireViewerAccess(rec, request, { json: true });
|
|
1147
|
+
if (gate) return gate;
|
|
1148
|
+
|
|
1149
|
+
let body: any;
|
|
1150
|
+
try {
|
|
1151
|
+
body = await request.json();
|
|
1152
|
+
} catch {
|
|
1153
|
+
return err(400, "Invalid JSON body");
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
const text = typeof body?.body === "string" ? body.body : null;
|
|
1157
|
+
if (!text || !text.trim()) return err(400, "Field 'body' is required");
|
|
1158
|
+
if (text.length > MAX_COMMENT_BODY) return err(400, `body exceeds ${MAX_COMMENT_BODY} chars`);
|
|
1159
|
+
|
|
1160
|
+
const author = typeof body?.author === "string" ? body.author.trim() : "";
|
|
1161
|
+
if (!author) return err(400, "Field 'author' is required");
|
|
1162
|
+
if (author.length > MAX_COMMENT_AUTHOR) return err(400, `author exceeds ${MAX_COMMENT_AUTHOR} chars`);
|
|
1163
|
+
|
|
1164
|
+
const anchorResult = validateAnchor(body?.anchor);
|
|
1165
|
+
if (!anchorResult.ok) return err(400, anchorResult.message);
|
|
1166
|
+
|
|
1167
|
+
const id = `c_${randomString(COMMENT_ID_LEN, COMMENT_ID_ALPHA)}`;
|
|
1168
|
+
const comment: Comment = {
|
|
1169
|
+
id,
|
|
1170
|
+
slug,
|
|
1171
|
+
anchor: anchorResult.anchor,
|
|
1172
|
+
body: text,
|
|
1173
|
+
author,
|
|
1174
|
+
status: "open",
|
|
1175
|
+
created_at: Date.now(),
|
|
1176
|
+
resolved_at: null,
|
|
1177
|
+
resolved_by: null,
|
|
1178
|
+
resolution_note: null,
|
|
1179
|
+
};
|
|
1180
|
+
|
|
1181
|
+
const putOpts: KVNamespacePutOptions = {};
|
|
1182
|
+
if (rec.expires_at) {
|
|
1183
|
+
const ttl = Math.max(60, Math.ceil((rec.expires_at - Date.now()) / 1000));
|
|
1184
|
+
putOpts.expirationTtl = ttl;
|
|
1185
|
+
}
|
|
1186
|
+
await env.PAGES.put(commentKey(slug, id), JSON.stringify(comment), putOpts);
|
|
1187
|
+
|
|
1188
|
+
return json(comment, 201);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
async function handleCommentPatch(request: Request, env: Env, slug: string, id: string): Promise<Response> {
|
|
1192
|
+
const rec = await getPage(env, slug);
|
|
1193
|
+
if (!rec) return err(404, "Page not found");
|
|
1194
|
+
const authErr = await requireOwner(request, rec);
|
|
1195
|
+
if (authErr) return authErr;
|
|
1196
|
+
|
|
1197
|
+
const comment = await getComment(env, slug, id);
|
|
1198
|
+
if (!comment) return err(404, "Comment not found");
|
|
1199
|
+
|
|
1200
|
+
let body: any;
|
|
1201
|
+
try {
|
|
1202
|
+
body = await request.json();
|
|
1203
|
+
} catch {
|
|
1204
|
+
return err(400, "Invalid JSON body");
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
if (body?.status !== undefined) {
|
|
1208
|
+
if (body.status !== "open" && body.status !== "resolved") {
|
|
1209
|
+
return err(400, "status must be 'open' or 'resolved'");
|
|
1210
|
+
}
|
|
1211
|
+
if (body.status === "resolved" && comment.status !== "resolved") {
|
|
1212
|
+
comment.status = "resolved";
|
|
1213
|
+
comment.resolved_at = Date.now();
|
|
1214
|
+
const by = request.headers.get("x-resolved-by");
|
|
1215
|
+
comment.resolved_by = by && by.toLowerCase() === "agent" ? "agent" : "owner";
|
|
1216
|
+
} else if (body.status === "open" && comment.status !== "open") {
|
|
1217
|
+
comment.status = "open";
|
|
1218
|
+
comment.resolved_at = null;
|
|
1219
|
+
comment.resolved_by = null;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
if (body?.resolution_note !== undefined) {
|
|
1224
|
+
if (body.resolution_note === null) {
|
|
1225
|
+
comment.resolution_note = null;
|
|
1226
|
+
} else if (typeof body.resolution_note === "string") {
|
|
1227
|
+
if (body.resolution_note.length > MAX_RESOLUTION_NOTE) {
|
|
1228
|
+
return err(400, `resolution_note exceeds ${MAX_RESOLUTION_NOTE} chars`);
|
|
1229
|
+
}
|
|
1230
|
+
comment.resolution_note = body.resolution_note;
|
|
1231
|
+
} else {
|
|
1232
|
+
return err(400, "resolution_note must be a string or null");
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
const putOpts: KVNamespacePutOptions = {};
|
|
1237
|
+
if (rec.expires_at) {
|
|
1238
|
+
const ttl = Math.max(60, Math.ceil((rec.expires_at - Date.now()) / 1000));
|
|
1239
|
+
putOpts.expirationTtl = ttl;
|
|
1240
|
+
}
|
|
1241
|
+
await env.PAGES.put(commentKey(slug, id), JSON.stringify(comment), putOpts);
|
|
1242
|
+
return json(comment);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
async function handleCommentDelete(request: Request, env: Env, slug: string, id: string): Promise<Response> {
|
|
1246
|
+
const rec = await getPage(env, slug);
|
|
1247
|
+
if (!rec) return err(404, "Page not found");
|
|
1248
|
+
const authErr = await requireOwner(request, rec);
|
|
1249
|
+
if (authErr) return authErr;
|
|
1250
|
+
|
|
1251
|
+
const existing = await getComment(env, slug, id);
|
|
1252
|
+
if (!existing) return err(404, "Comment not found");
|
|
1253
|
+
await env.PAGES.delete(commentKey(slug, id));
|
|
1254
|
+
return json({ slug, id, deleted: true });
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// ---------- landing ----------
|
|
1258
|
+
|
|
1259
|
+
function landing(request: Request): Response {
|
|
1260
|
+
const u = new URL(request.url);
|
|
1261
|
+
const base = `${u.protocol}//${u.host}`;
|
|
1262
|
+
const html = `<!doctype html>
|
|
1263
|
+
<meta charset="utf-8">
|
|
1264
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
1265
|
+
<title>publish-cloudflare</title>
|
|
1266
|
+
<style>
|
|
1267
|
+
:root { color-scheme: light dark; --fg:#111; --muted:#555; --bg:#fff; --code:#f4f4f5; --border:#e5e7eb; }
|
|
1268
|
+
@media (prefers-color-scheme: dark) { :root { --fg:#eee; --muted:#aaa; --bg:#0b0b0c; --code:#1a1a1d; --border:#27272a; } }
|
|
1269
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; max-width: 44rem; margin: 3rem auto; padding: 0 1.25rem; color: var(--fg); background: var(--bg); line-height: 1.55; }
|
|
1270
|
+
code, pre { background: var(--code); border-radius: .35rem; }
|
|
1271
|
+
code { padding: .1rem .35rem; font-size: .92em; }
|
|
1272
|
+
pre { padding: .9rem 1rem; overflow-x: auto; border: 1px solid var(--border); }
|
|
1273
|
+
h1 { margin-bottom: .25rem; }
|
|
1274
|
+
.muted { color: var(--muted); }
|
|
1275
|
+
table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
|
|
1276
|
+
th, td { text-align: left; padding: .45rem .6rem; border-bottom: 1px solid var(--border); font-size: .92rem; }
|
|
1277
|
+
</style>
|
|
1278
|
+
<h1>publish-cloudflare</h1>
|
|
1279
|
+
<p class="muted">A self-hosted clone of htmlship.com on Cloudflare Workers + KV.</p>
|
|
1280
|
+
|
|
1281
|
+
<h2>Quick publish</h2>
|
|
1282
|
+
<pre><code>publish-cf publish report.html</code></pre>
|
|
1283
|
+
|
|
1284
|
+
<h2>API</h2>
|
|
1285
|
+
<table>
|
|
1286
|
+
<tr><th>Method</th><th>Path</th><th>Purpose</th></tr>
|
|
1287
|
+
<tr><td>POST</td><td><code>/api/v1/pages</code></td><td>Create page (returns slug + owner_key)</td></tr>
|
|
1288
|
+
<tr><td>GET</td><td><code>/api/v1/pages/:slug</code></td><td>Metadata only</td></tr>
|
|
1289
|
+
<tr><td>PATCH</td><td><code>/api/v1/pages/:slug</code></td><td>Update html/title/comments_enabled/password (X-Owner-Key)</td></tr>
|
|
1290
|
+
<tr><td>DELETE</td><td><code>/api/v1/pages/:slug</code></td><td>Delete (X-Owner-Key)</td></tr>
|
|
1291
|
+
<tr><td>GET</td><td><code>/v/:slug</code></td><td>View rendered page (CSP enforced). Password-gated pages can be unlocked by viewer cookie or by sending <code>X-Owner-Key</code>.</td></tr>
|
|
1292
|
+
<tr><td>GET</td><td><code>/v/:slug/comments</code></td><td>List comments. Same auth: viewer cookie or <code>X-Owner-Key</code>.</td></tr>
|
|
1293
|
+
</table>
|
|
1294
|
+
|
|
1295
|
+
<p class="muted">Endpoint: <code>${escapeHtml(base)}</code></p>
|
|
1296
|
+
`;
|
|
1297
|
+
return new Response(html, {
|
|
1298
|
+
status: 200,
|
|
1299
|
+
headers: { "content-type": "text/html; charset=utf-8", "cache-control": "public, max-age=300" },
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
// ---------- router ----------
|
|
1304
|
+
|
|
1305
|
+
export default {
|
|
1306
|
+
async fetch(request: Request, env: Env): Promise<Response> {
|
|
1307
|
+
const url = new URL(request.url);
|
|
1308
|
+
const { pathname } = url;
|
|
1309
|
+
const method = request.method.toUpperCase();
|
|
1310
|
+
|
|
1311
|
+
if (method === "OPTIONS") return corsPreflight();
|
|
1312
|
+
|
|
1313
|
+
// Treat HEAD like GET (return same headers, no body — Workers strip body automatically).
|
|
1314
|
+
const effectiveMethod = method === "HEAD" ? "GET" : method;
|
|
1315
|
+
|
|
1316
|
+
// GET /
|
|
1317
|
+
if (pathname === "/" && effectiveMethod === "GET") return landing(request);
|
|
1318
|
+
|
|
1319
|
+
// GET /favicon.svg and GET /favicon.ico — both serve the same SVG.
|
|
1320
|
+
// Browsers default-request /favicon.ico, so we honor it with image/svg+xml.
|
|
1321
|
+
if ((pathname === "/favicon.svg" || pathname === "/favicon.ico") && effectiveMethod === "GET") {
|
|
1322
|
+
return serveFavicon();
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// /api/v1/pages and /api/v1/pages/:slug
|
|
1326
|
+
if (pathname === "/api/v1/pages" && method === "POST") return handleCreate(request, env);
|
|
1327
|
+
const apiMatch = pathname.match(/^\/api\/v1\/pages\/([a-z0-9]+)\/?$/);
|
|
1328
|
+
if (apiMatch) {
|
|
1329
|
+
const slug = apiMatch[1];
|
|
1330
|
+
if (effectiveMethod === "GET") return handleGetMeta(env, request, slug);
|
|
1331
|
+
if (method === "PATCH") return handlePatch(request, env, slug);
|
|
1332
|
+
if (method === "DELETE") return handleDelete(request, env, slug);
|
|
1333
|
+
return err(405, `Method ${method} not allowed`);
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// /v/:slug/comments and /v/:slug/comments/:id (must come before /v/:slug)
|
|
1337
|
+
const commentItemMatch = pathname.match(/^\/v\/([a-z0-9]+)\/comments\/(c_[a-z0-9]+)\/?$/);
|
|
1338
|
+
if (commentItemMatch) {
|
|
1339
|
+
const slug = commentItemMatch[1];
|
|
1340
|
+
const id = commentItemMatch[2];
|
|
1341
|
+
if (method === "PATCH") return handleCommentPatch(request, env, slug, id);
|
|
1342
|
+
if (method === "DELETE") return handleCommentDelete(request, env, slug, id);
|
|
1343
|
+
return err(405, `Method ${method} not allowed`);
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
const commentListMatch = pathname.match(/^\/v\/([a-z0-9]+)\/comments\/?$/);
|
|
1347
|
+
if (commentListMatch) {
|
|
1348
|
+
const slug = commentListMatch[1];
|
|
1349
|
+
if (effectiveMethod === "GET") return handleCommentList(env, request, slug);
|
|
1350
|
+
if (method === "POST") return handleCommentCreate(env, request, slug);
|
|
1351
|
+
return err(405, `Method ${method} not allowed`);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// /v/:slug/raw — original artifact under strict CSP
|
|
1355
|
+
const rawMatch = pathname.match(/^\/v\/([a-z0-9]+)\/raw\/?$/);
|
|
1356
|
+
if (rawMatch) {
|
|
1357
|
+
const slug = rawMatch[1];
|
|
1358
|
+
if (effectiveMethod === "GET") return handleViewRaw(request, env, slug);
|
|
1359
|
+
return new Response("Method not allowed", { status: 405 });
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// /v/:slug — wrapper page
|
|
1363
|
+
const viewMatch = pathname.match(/^\/v\/([a-z0-9]+)\/?$/);
|
|
1364
|
+
if (viewMatch) {
|
|
1365
|
+
const slug = viewMatch[1];
|
|
1366
|
+
if (effectiveMethod === "GET" || method === "POST") return handleViewWrapper(request, env, slug);
|
|
1367
|
+
return new Response("Method not allowed", { status: 405 });
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
if (pathname === "/healthz" && effectiveMethod === "GET") return new Response("ok", { status: 200 });
|
|
1371
|
+
|
|
1372
|
+
return new Response("Not found", { status: 404 });
|
|
1373
|
+
},
|
|
1374
|
+
} satisfies ExportedHandler<Env>;
|