Skip to content
File

Blob: src/worker/routes/page-tree.ts

typescript114 lines
1import { Hono } from "hono";
2import { eq, and, isNull, asc } from "drizzle-orm";
3 
4import type { AppContext } from "@/worker/app-context";
5import { pages } from "@/worker/db/d1/schema";
6import { optionalAuth } from "@/worker/middleware/auth";
7import { rateLimit } from "@/worker/middleware/rate-limit";
8import { canAccessPages, resolvePrincipal } from "@/worker/lib/permissions";
9import { getPage } from "@/worker/lib/page-access";
10import { getPageAncestorChain } from "@/worker/lib/page-tree";
11 
12const pageTreeRouter = new Hono<AppContext>();
13 
14// GET /workspaces/:wid/pages/:id/children - List children
15// Supports both JWT auth (workspace members) and ?share=<token> (shared-link users)
16pageTreeRouter.get("/workspaces/:wid/pages/:id/children", optionalAuth, rateLimit("RL_API"), async (c) => {
17 const workspaceId = c.req.param("wid");
18 const pageId = c.req.param("id");
19 const user = c.get("user");
20 const db = c.get("db");
21 const shareToken = c.req.query("share");
22 
23 const resolved = await resolvePrincipal(db, user, workspaceId, {
24 surface: shareToken ? "shared" : "canonical",
25 shareToken,
26 });
27 if (!resolved) {
28 return c.json({ error: "unauthorized", message: "Authentication required" }, 401);
29 }
30 
31 // Resolve parent access before loading metadata or children so an inaccessible
32 // existing parent returns the same `not_found` as a missing parent (no existence leak).
33 const parentAccess = await canAccessPages(db, resolved.principal, [pageId], workspaceId, "view");
34 if (!parentAccess.get(pageId)) {
35 return c.json({ error: "not_found", message: "Page not found" }, 404);
36 }
37 
38 const parentPage = await getPage(db, pageId, workspaceId);
39 if (!parentPage) return c.json({ error: "not_found", message: "Page not found" }, 404);
40 
41 const children = await db
42 .select()
43 .from(pages)
44 .where(and(eq(pages.workspace_id, workspaceId), eq(pages.parent_id, pageId), isNull(pages.archived_at)))
45 .orderBy(asc(pages.position));
46 
47 if (children.length === 0) {
48 return c.json({ pages: [] });
49 }
50 
51 // `canAccessPages` fast-paths canonical members internally, so the same branch
52 // handles members, guests, and shared-link viewers.
53 const childAccess = await canAccessPages(
54 db,
55 resolved.principal,
56 children.map((child) => child.id),
57 workspaceId,
58 "view",
59 );
60 const visible = children.filter((child) => childAccess.get(child.id));
61 return c.json({ pages: visible });
62});
63 
64// GET /workspaces/:wid/pages/:id/ancestors - Ancestor chain with access info
65// Returns root-first array. Inaccessible ancestors have null title/icon (no title leak). §20.2
66pageTreeRouter.get("/workspaces/:wid/pages/:id/ancestors", optionalAuth, rateLimit("RL_API"), async (c) => {
67 const workspaceId = c.req.param("wid");
68 const pageId = c.req.param("id");
69 const user = c.get("user");
70 const db = c.get("db");
71 const shareToken = c.req.query("share");
72 
73 const resolved = await resolvePrincipal(db, user, workspaceId, {
74 surface: shareToken ? "shared" : "canonical",
75 shareToken,
76 });
77 if (!resolved) {
78 return c.json({ error: "unauthorized", message: "Authentication required" }, 401);
79 }
80 
81 // Gate on target page access before returning ancestor chain. `canAccessPages`
82 // applies the member fast-path internally for canonical viewers.
83 const access = await canAccessPages(db, resolved.principal, [pageId], workspaceId, "view");
84 if (!access.get(pageId)) {
85 return c.json({ error: "not_found", message: "Page not found" }, 404);
86 }
87 
88 const chain = await getPageAncestorChain(db, pageId, workspaceId);
89 // chain is [page, parent, grandparent, ...] — remove self (first element), then reverse to root-first
90 chain.shift();
91 chain.reverse();
92 
93 const ancestorAccess = await canAccessPages(
94 db,
95 resolved.principal,
96 chain.map((ancestor) => ancestor.id),
97 workspaceId,
98 "view",
99 );
100 const ancestors = chain.map((a) => {
101 const accessible = ancestorAccess.get(a.id) ?? false;
102 return {
103 id: a.id,
104 title: accessible ? a.title : null,
105 icon: accessible ? a.icon : null,
106 accessible,
107 };
108 });
109 
110 return c.json({ ancestors });
111});
112 
113export { pageTreeRouter };