Skip to content
File

Blob: src/worker/routes/pages.ts

typescript547 lines
1import { Hono } from "hono";
2import { eq, and, isNull, asc } from "drizzle-orm";
3import { ulid } from "ulid";
4 
5import type { AppContext } from "@/worker/app-context";
6import { pages } from "@/worker/db/d1/schema";
7import { requireAuth, optionalAuth } from "@/worker/middleware/auth";
8import { rateLimit } from "@/worker/middleware/rate-limit";
9import { checkMembership, requireMembership } from "@/worker/lib/membership";
10import {
11 canEdit,
12 canAccessPage,
13 canAccessPages,
14 resolvePageAccessLevels,
15 resolvePrincipal,
16} from "@/worker/lib/permissions";
17import { parseBody } from "@/worker/lib/validate";
18import { createLogger } from "@/worker/lib/logger";
19import { bumpPublicSiteRevision } from "@/worker/lib/site-invalidation";
20import { DEFAULT_PAGE_TITLE } from "@/worker/lib/constants";
21import { MAX_TREE_DEPTH } from "@/shared/constants";
22import {
23 getPageEditEntitlements,
24 getPageStructureEntitlements,
25 type PageAccessLevel,
26 type ResolvedWorkspaceRole,
27} from "@/shared/entitlements";
28import { getPage } from "@/worker/lib/page-access";
29import {
30 archivePageSubtree,
31 getArchivedAncestorRows,
32 getArchivedPageRootRows,
33 getPageAncestorChain,
34 getPageAncestorDepthFromChain,
35 getPageSubtreeRows,
36 restorePageSubtree,
37 validatePageMove,
38 type PageSubtreeRow,
39} from "@/worker/lib/page-tree";
40import type { TasksQueueMessage } from "@/worker/queues/messages";
41import { CreatePageRequest, UpdatePageRequest, type Page } from "@/shared/types";
42import { isGradientPreset, parseUploadCoverUrl } from "@/shared/page-cover";
43 
44const log = createLogger("pages");
45const INDEX_QUEUE_BATCH_SIZE = 100;
46 
47const pagesRouter = new Hono<AppContext>();
48 
49// POST /workspaces/:wid/pages - Create page
50pagesRouter.post("/workspaces/:wid/pages", requireAuth, rateLimit("RL_API"), async (c) => {
51 const workspaceId = c.req.param("wid");
52 const user = c.get("user")!;
53 const db = c.get("db");
54 
55 const membership = await requireMembership(c, db, user.id, workspaceId, true);
56 if (membership instanceof Response) return membership;
57 if (!getPageStructureEntitlements(membership.role, false).createPage) {
58 return c.json({ error: "forbidden", message: "You do not have permission to create pages in this workspace" }, 403);
59 }
60 
61 const data = await parseBody(c, CreatePageRequest);
62 if (data instanceof Response) return data;
63 
64 const { kind, title, icon, parent_id, position } = data;
65 
66 // If parent_id specified, validate parent exists and check depth
67 if (parent_id) {
68 const parentPage = await getPage(db, parent_id, workspaceId);
69 if (!parentPage) {
70 return c.json({ error: "not_found", message: "Parent page not found" }, 404);
71 }
72 
73 // Check that parent's depth from root + 1 doesn't exceed max
74 const parentDepth = getPageAncestorDepthFromChain(await getPageAncestorChain(db, parent_id, workspaceId));
75 if (parentDepth === null || parentDepth + 1 >= MAX_TREE_DEPTH) {
76 return c.json({ error: "bad_request", message: `Maximum nesting depth of ${MAX_TREE_DEPTH} exceeded` }, 400);
77 }
78 }
79 
80 // Determine position: use provided or compute next
81 let pagePosition = position ?? 0;
82 if (position === undefined) {
83 // Get the max position among siblings
84 const siblings = await db
85 .select({ position: pages.position })
86 .from(pages)
87 .where(
88 and(
89 eq(pages.workspace_id, workspaceId),
90 parent_id ? eq(pages.parent_id, parent_id) : isNull(pages.parent_id),
91 isNull(pages.archived_at),
92 ),
93 )
94 .orderBy(asc(pages.position));
95 
96 if (siblings.length > 0) {
97 pagePosition = siblings[siblings.length - 1].position + 1;
98 }
99 }
100 
101 const pageId = ulid();
102 
103 await db.insert(pages).values({
104 id: pageId,
105 workspace_id: workspaceId,
106 parent_id: parent_id ?? null,
107 kind,
108 title: title ?? DEFAULT_PAGE_TITLE,
109 icon: icon ?? null,
110 position: pagePosition,
111 created_by: user.id,
112 });
113 
114 log.info("page_created", { pageId, workspaceId, kind, parentId: parent_id ?? null, userId: user.id });
115 
116 // Index and project newly created pages through derived background tasks.
117 try {
118 const messages: TasksQueueMessage[] = [{ type: "index-page", pageId }];
119 if (kind === "doc") messages.push({ type: "page-projection", pageId });
120 await c.env.TASKS_QUEUE.sendBatch(messages.map((body) => ({ body })));
121 } catch {
122 // Non-critical: FTS and Sites JSON are derived projections.
123 }
124 
125 const now = new Date().toISOString();
126 
127 return c.json(
128 {
129 page: {
130 id: pageId,
131 workspace_id: workspaceId,
132 parent_id: parent_id ?? null,
133 kind,
134 title: title ?? DEFAULT_PAGE_TITLE,
135 icon: icon ?? null,
136 cover_url: null,
137 position: pagePosition,
138 created_by: user.id,
139 created_at: now,
140 updated_at: now,
141 archived_at: null,
142 archive_root_id: null,
143 },
144 },
145 201,
146 );
147});
148 
149// GET /workspaces/:wid/pages - List all workspace pages (sidebar tree)
150pagesRouter.get("/workspaces/:wid/pages", requireAuth, rateLimit("RL_API"), async (c) => {
151 const workspaceId = c.req.param("wid");
152 const user = c.get("user")!;
153 const db = c.get("db");
154 
155 const membership = await checkMembership(db, user.id, workspaceId);
156 
157 const allPages = await db
158 .select()
159 .from(pages)
160 .where(and(eq(pages.workspace_id, workspaceId), isNull(pages.archived_at)))
161 .orderBy(asc(pages.position));
162 
163 // Guests and non-members only see pages they have share access to (spec §20.2)
164 if (!membership || membership.role === "guest") {
165 const principal = { type: "user" as const, userId: user.id };
166 const accessByPage = await canAccessPages(
167 db,
168 principal,
169 allPages.map((page) => page.id),
170 workspaceId,
171 "view",
172 );
173 const visible = allPages.filter((page) => accessByPage.get(page.id));
174 // Reparent pages whose parent is not in the visible set so they appear as roots
175 const visibleIds = new Set(visible.map((p) => p.id));
176 const reparented = visible.map((p) =>
177 p.parent_id && !visibleIds.has(p.parent_id) ? { ...p, parent_id: null } : p,
178 );
179 return c.json({ pages: reparented });
180 }
181 
182 return c.json({ pages: allPages });
183});
184 
185// GET /workspaces/:wid/pages/archived - List archive roots for trash management
186pagesRouter.get("/workspaces/:wid/pages/archived", requireAuth, rateLimit("RL_API"), async (c) => {
187 const workspaceId = c.req.param("wid");
188 const user = c.get("user")!;
189 const db = c.get("db");
190 
191 const membership = await requireMembership(c, db, user.id, workspaceId, true);
192 if (membership instanceof Response) return membership;
193 
194 const canListAllArchivedPages = membership.role === "owner" || membership.role === "admin";
195 const archivedPages = await getArchivedPageRootRows(
196 db,
197 workspaceId,
198 canListAllArchivedPages ? {} : { createdBy: user.id },
199 );
200 
201 return c.json({ pages: archivedPages });
202});
203 
204// GET /workspaces/:wid/pages/:id - Get page metadata
205// Supports both JWT auth (workspace members) and ?share=<token> (shared-link users)
206pagesRouter.get("/workspaces/:wid/pages/:id", optionalAuth, rateLimit("RL_API"), async (c) => {
207 const workspaceId = c.req.param("wid");
208 const pageId = c.req.param("id");
209 const user = c.get("user");
210 const db = c.get("db");
211 const shareToken = c.req.query("share");
212 
213 const resolved = await resolvePrincipal(db, user, workspaceId, {
214 surface: shareToken ? "shared" : "canonical",
215 shareToken,
216 });
217 if (!resolved) {
218 return c.json({ error: "unauthorized", message: "Authentication required" }, 401);
219 }
220 
221 // Resolve access first to avoid leaking page existence. `resolvePageAccessLevels`
222 // fast-paths canonical members internally, so we do not branch on that here.
223 const accessLevels = await resolvePageAccessLevels(db, resolved.principal, [pageId], workspaceId);
224 const accessLevel = accessLevels.get(pageId) ?? "none";
225 
226 if (accessLevel === "none") {
227 return c.json({ error: "not_found", message: "Page not found" }, 404);
228 }
229 
230 const page = await getPage(db, pageId, workspaceId);
231 if (!page) {
232 return c.json({ error: "not_found", message: "Page not found" }, 404);
233 }
234 
235 return c.json({ page, can_edit: accessLevel === "edit" });
236});
237 
238// GET /workspaces/:wid/pages/:id/snapshot - Bootstrap persisted Yjs snapshot
239// Supports both JWT auth (workspace members) and ?share=<token> (shared-link users)
240pagesRouter.get("/workspaces/:wid/pages/:id/snapshot", optionalAuth, rateLimit("RL_API"), async (c) => {
241 const workspaceId = c.req.param("wid");
242 const pageId = c.req.param("id");
243 const user = c.get("user");
244 const db = c.get("db");
245 const shareToken = c.req.query("share");
246 
247 const resolved = await resolvePrincipal(db, user, workspaceId, {
248 surface: shareToken ? "shared" : "canonical",
249 shareToken,
250 });
251 if (!resolved) {
252 return c.json({ error: "unauthorized", message: "Authentication required" }, 401);
253 }
254 
255 const accessLevels = await resolvePageAccessLevels(db, resolved.principal, [pageId], workspaceId);
256 if ((accessLevels.get(pageId) ?? "none") === "none") {
257 return c.json({ error: "not_found", message: "Page not found" }, 404);
258 }
259 
260 const page = await getPage(db, pageId, workspaceId);
261 if (!page) {
262 return c.json({ error: "not_found", message: "Page not found" }, 404);
263 }
264 
265 const doc = c.env.DocSync.getByName(pageId);
266 const snapshot = await doc.getSnapshotResponse(pageId);
267 if (snapshot.kind === "missing") {
268 return new Response(null, { status: 204 });
269 }
270 
271 return snapshot.response;
272});
273 
274// PATCH /workspaces/:wid/pages/:id - Update page
275pagesRouter.patch("/workspaces/:wid/pages/:id", requireAuth, rateLimit("RL_API"), async (c) => {
276 const workspaceId = c.req.param("wid");
277 const pageId = c.req.param("id");
278 const user = c.get("user")!;
279 const db = c.get("db");
280 
281 // Verify page exists before permission checks to avoid leaking existence info
282 const existing = await getPage(db, pageId, workspaceId);
283 if (!existing) {
284 return c.json({ error: "not_found", message: "Page not found" }, 404);
285 }
286 
287 const membership = await checkMembership(db, user.id, workspaceId);
288 const workspaceRole: ResolvedWorkspaceRole = membership?.role ?? "none";
289 let pageAccess: PageAccessLevel;
290 
291 if (membership && canEdit(membership.role)) {
292 pageAccess = "edit";
293 } else {
294 const hasEdit = await canAccessPage(db, { type: "user", userId: user.id }, pageId, workspaceId, "edit");
295 if (!hasEdit) {
296 return c.json({ error: "forbidden", message: "You do not have edit access to this page" }, 403);
297 }
298 pageAccess = "edit";
299 }
300 
301 const data = await parseBody(c, UpdatePageRequest);
302 if (data instanceof Response) return data;
303 const editEntitlements = getPageEditEntitlements("canonical", pageAccess);
304 const structureEntitlements = getPageStructureEntitlements(workspaceRole, existing.created_by === user.id);
305 
306 if (!editEntitlements.editPageMetadata) {
307 return c.json({ error: "forbidden", message: "You do not have edit access to this page" }, 403);
308 }
309 
310 if ((data.parent_id !== undefined || data.position !== undefined) && !structureEntitlements.movePage) {
311 return c.json({ error: "forbidden", message: "Shared users cannot move pages" }, 403);
312 }
313 
314 const updates = data;
315 
316 // Handle parent_id change (move operation)
317 if (updates.parent_id !== undefined && updates.parent_id !== existing.parent_id) {
318 const newParentId = updates.parent_id;
319 
320 if (newParentId !== null) {
321 // Verify new parent exists
322 const newParent = await getPage(db, newParentId, workspaceId);
323 if (!newParent) {
324 return c.json({ error: "not_found", message: "New parent page not found" }, 404);
325 }
326 
327 const moveValidation = await validatePageMove(db, pageId, newParentId, workspaceId);
328 if (!moveValidation.ok) {
329 if (moveValidation.reason === "self_parent") {
330 return c.json({ error: "bad_request", message: "A page cannot be its own parent" }, 400);
331 }
332 if (moveValidation.reason === "cycle") {
333 return c.json({ error: "bad_request", message: "Moving this page would create a cycle" }, 400);
334 }
335 return c.json(
336 {
337 error: "bad_request",
338 message: `Moving this page would exceed the maximum nesting depth of ${MAX_TREE_DEPTH}`,
339 },
340 400,
341 );
342 }
343 }
344 }
345 
346 const updatedAt = new Date().toISOString();
347 const updateValues: Record<string, unknown> = {
348 updated_at: updatedAt,
349 };
350 
351 if (updates.icon !== undefined) updateValues.icon = updates.icon;
352 if (updates.cover_url !== undefined) {
353 if (!isAllowedPageCoverUrl(updates.cover_url)) {
354 return c.json({ error: "bad_request", message: "Cover must be a gradient or an uploaded image" }, 400);
355 }
356 updateValues.cover_url = updates.cover_url;
357 }
358 if (updates.position !== undefined) updateValues.position = updates.position;
359 if (updates.parent_id !== undefined) updateValues.parent_id = updates.parent_id;
360 
361 if (updates.parent_id !== undefined && updates.parent_id !== existing.parent_id) {
362 log.info("page_moved", { pageId, workspaceId, from: existing.parent_id, to: updates.parent_id });
363 }
364 
365 await db.update(pages).set(updateValues).where(eq(pages.id, pageId));
366 if (
367 updates.icon !== undefined ||
368 updates.cover_url !== undefined ||
369 (updates.parent_id !== undefined && updates.parent_id !== existing.parent_id)
370 ) {
371 await bumpPublicSiteRevision(db, workspaceId, updatedAt);
372 }
373 if (updates.cover_url !== undefined && updates.cover_url !== null) {
374 await enqueueSiteCover(c.env, pageId);
375 }
376 log.debug("page_updated", { pageId, workspaceId, fields: Object.keys(updateValues) });
377 
378 const updated = await db.select().from(pages).where(eq(pages.id, pageId)).get();
379 
380 if (!updated) {
381 return c.json({ error: "not_found", message: "Page not found" }, 404);
382 }
383 
384 return c.json({ page: updated });
385});
386 
387// POST /workspaces/:wid/pages/:id/restore - Restore an archived page operation
388pagesRouter.post("/workspaces/:wid/pages/:id/restore", requireAuth, rateLimit("RL_API"), async (c) => {
389 const workspaceId = c.req.param("wid");
390 const pageId = c.req.param("id");
391 const user = c.get("user")!;
392 const db = c.get("db");
393 
394 const membership = await requireMembership(c, db, user.id, workspaceId, true);
395 if (membership instanceof Response) return membership;
396 
397 const existing = await db
398 .select()
399 .from(pages)
400 .where(and(eq(pages.id, pageId), eq(pages.workspace_id, workspaceId)))
401 .get();
402 if (!existing) {
403 return c.json({ error: "not_found", message: "Page not found" }, 404);
404 }
405 if (!existing.archived_at) {
406 return c.json({ error: "not_archived", message: "Page is not archived" }, 409);
407 }
408 if (existing.archive_root_id !== pageId) {
409 return c.json({ error: "not_archive_root", message: "Only archive roots can be restored" }, 409);
410 }
411 
412 const archivedAncestors = await getArchivedAncestorRows(db, pageId, workspaceId);
413 if (archivedAncestors.length > 0) {
414 return c.json({ error: "archived_ancestor", message: "Restore the archived parent first" }, 409);
415 }
416 
417 const subtreeRows = await getPageSubtreeRows(db, pageId, workspaceId);
418 const rowsToRestore = subtreeRows.filter((row) => row.archive_root_id === pageId);
419 const entitlements = getPageStructureEntitlements(membership.role, existing.created_by === user.id);
420 if (
421 !entitlements.archiveAnyPage &&
422 (!entitlements.archiveOwnPage || rowsToRestore.some((row) => row.created_by !== user.id))
423 ) {
424 return c.json({ error: "forbidden", message: "You do not have permission to restore this archived page" }, 403);
425 }
426 
427 const now = new Date().toISOString();
428 await restorePageSubtree(db, pageId, workspaceId, now);
429 await bumpPublicSiteRevision(db, workspaceId, now);
430 log.info("page_restored", { pageId, workspaceId, userId: user.id, restoredCount: rowsToRestore.length });
431 
432 try {
433 await enqueueIndexPageMessages(
434 c.env,
435 rowsToRestore.map((row) => row.id),
436 );
437 } catch {
438 // Non-critical: FTS is a derived projection.
439 }
440 
441 return c.json({
442 ok: true,
443 pages: rowsToRestore.map((row) =>
444 serializeSubtreePage(row, { archived_at: null, archive_root_id: null, updated_at: now }),
445 ),
446 });
447});
448 
449// DELETE /workspaces/:wid/pages/:id - Archive page
450pagesRouter.delete("/workspaces/:wid/pages/:id", requireAuth, rateLimit("RL_API"), async (c) => {
451 const workspaceId = c.req.param("wid");
452 const pageId = c.req.param("id");
453 const user = c.get("user")!;
454 const db = c.get("db");
455 
456 const membership = await requireMembership(c, db, user.id, workspaceId, true);
457 if (membership instanceof Response) return membership;
458 
459 const existing = await getPage(db, pageId, workspaceId);
460 if (!existing) {
461 return c.json({ error: "not_found", message: "Page not found" }, 404);
462 }
463 
464 // Check permission: page creator, admin, or owner
465 const isCreator = existing.created_by === user.id;
466 
467 const entitlements = getPageStructureEntitlements(membership.role, isCreator);
468 if (!entitlements.archivePage) {
469 return c.json({ error: "forbidden", message: "Only the page creator or workspace admins can archive pages" }, 403);
470 }
471 
472 const subtreeRows = await getPageSubtreeRows(db, pageId, workspaceId);
473 const rowsToArchive = subtreeRows.filter((row) => row.archived_at === null);
474 if (!entitlements.archiveAnyPage && rowsToArchive.some((row) => row.created_by !== user.id)) {
475 return c.json({ error: "forbidden", message: "Members can only archive pages they created" }, 403);
476 }
477 
478 const now = new Date().toISOString();
479 
480 await archivePageSubtree(db, pageId, workspaceId, now);
481 await bumpPublicSiteRevision(db, workspaceId, now);
482 log.info("page_archived", { pageId, workspaceId, userId: user.id, archivedCount: rowsToArchive.length });
483 
484 try {
485 await enqueueIndexPageMessages(
486 c.env,
487 rowsToArchive.map((row) => row.id),
488 );
489 } catch {
490 // Non-critical: FTS is a derived projection.
491 }
492 
493 return c.json({ ok: true, archived_page_ids: rowsToArchive.map((row) => row.id) });
494});
495 
496export { pagesRouter };
497 
498function isAllowedPageCoverUrl(coverUrl: string | null): boolean {
499 if (coverUrl === null) return true;
500 if (parseUploadCoverUrl(coverUrl)) return true;
501 return isGradientPreset(coverUrl);
502}
503 
504async function enqueueSiteCover(env: Pick<Env, "TASKS_QUEUE">, pageId: string): Promise<void> {
505 try {
506 await env.TASKS_QUEUE.send({ type: "site-cover", pageId });
507 } catch {
508 // Cover images are derived artifacts; save success should not depend on queue delivery.
509 }
510}
511 
512function serializeSubtreePage(
513 row: PageSubtreeRow,
514 overrides: Partial<Pick<Page, "archived_at" | "archive_root_id" | "updated_at">> = {},
515): Page {
516 return {
517 id: row.id,
518 workspace_id: row.workspace_id,
519 parent_id: row.parent_id,
520 kind: row.kind,
521 title: row.title,
522 icon: row.icon,
523 cover_url: row.cover_url,
524 position: row.position,
525 created_by: row.created_by,
526 created_at: row.created_at,
527 updated_at: "updated_at" in overrides ? (overrides.updated_at ?? row.updated_at) : row.updated_at,
528 archived_at: "archived_at" in overrides ? (overrides.archived_at ?? null) : row.archived_at,
529 archive_root_id: "archive_root_id" in overrides ? (overrides.archive_root_id ?? null) : row.archive_root_id,
530 };
531}
532 
533export async function enqueueIndexPageMessages(
534 env: Pick<Env, "TASKS_QUEUE">,
535 pageIds: readonly string[],
536): Promise<void> {
537 for (let offset = 0; offset < pageIds.length; offset += INDEX_QUEUE_BATCH_SIZE) {
538 const batch = pageIds.slice(offset, offset + INDEX_QUEUE_BATCH_SIZE).map((pageId) => {
539 const body: TasksQueueMessage = { type: "index-page", pageId };
540 return { body };
541 });
542 if (batch.length > 0) {
543 await env.TASKS_QUEUE.sendBatch(batch);
544 }
545 }
546}