Skip to content
File

Blob: src/worker/routes/shares.ts

typescript448 lines
1import { Hono } from "hono";
2import { eq, and, isNull, desc, sql } from "drizzle-orm";
3import { ulid } from "ulid";
4 
5import type { AppContext } from "@/worker/app-context";
6import { pageShares, pages, users, workspaces, memberships } from "@/worker/db/d1/schema";
7import { optionalAuth, requireAuth } from "@/worker/middleware/auth";
8import { rateLimit } from "@/worker/middleware/rate-limit";
9import { checkMembership, requireMembership } from "@/worker/lib/membership";
10import { canAccessPage, resolvePrincipal, toResolvedViewerContext } from "@/worker/lib/permissions";
11import { generateSecureToken } from "@/worker/lib/auth";
12import { parseBody } from "@/worker/lib/validate";
13import { createLogger } from "@/worker/lib/logger";
14import { getPage } from "@/worker/lib/page-access";
15import { CreateShareRequest } from "@/shared/types";
16import {
17 canCreateLinkShare,
18 canCreateUserShare,
19 canCreateUserShareByEmail,
20 canRevealLinkTokens,
21 canRevealShareGranteeEmails,
22 canRevokeShare,
23} from "@/shared/entitlements";
24 
25const log = createLogger("shares");
26 
27// Share CRUD โ€” mounted under /api/v1
28export const sharesRouter = new Hono<AppContext>();
29 
30// GET /me/shared-pages - Pages shared with the current user.
31// Returns two partitions so the Shared With Me view can stay a pure
32// cross-workspace discovery surface:
33// * `items` - pages in workspaces the caller is NOT a member of
34// * `workspace_summaries` - grouped counts of same-workspace shares, so the
35// caller still sees "there are pages shared with
36// you in workspaces you already belong to" and can
37// jump there. The pages themselves are visible in
38// those workspaces' normal page tree.
39sharesRouter.get("/me/shared-pages", requireAuth, rateLimit("RL_API"), async (c) => {
40 const user = c.get("user")!;
41 const db = c.get("db");
42 
43 const sharedByUser = db.select({ id: users.id, name: users.name }).from(users).as("shared_by_user");
44 
45 const [crossWorkspaceRows, summaryRows] = await Promise.all([
46 db
47 .select({
48 page_id: pages.id,
49 title: pages.title,
50 icon: pages.icon,
51 cover_url: pages.cover_url,
52 workspace_id: workspaces.id,
53 workspace_name: workspaces.name,
54 workspace_slug: workspaces.slug,
55 workspace_icon: workspaces.icon,
56 permission: pageShares.permission,
57 shared_by: pageShares.created_by,
58 shared_by_name: sharedByUser.name,
59 shared_at: pageShares.created_at,
60 })
61 .from(pageShares)
62 .innerJoin(pages, eq(pageShares.page_id, pages.id))
63 .innerJoin(workspaces, eq(pages.workspace_id, workspaces.id))
64 .leftJoin(sharedByUser, eq(pageShares.created_by, sharedByUser.id))
65 .leftJoin(memberships, and(eq(memberships.workspace_id, pages.workspace_id), eq(memberships.user_id, user.id)))
66 .where(
67 and(
68 eq(pageShares.grantee_type, "user"),
69 eq(pageShares.grantee_id, user.id),
70 isNull(pages.archived_at),
71 isNull(memberships.user_id),
72 ),
73 )
74 .orderBy(desc(pageShares.created_at)),
75 
76 db
77 .select({
78 workspace_id: workspaces.id,
79 workspace_name: workspaces.name,
80 workspace_slug: workspaces.slug,
81 workspace_icon: workspaces.icon,
82 // Metric: number of direct user-share rows granting the caller access
83 // in this workspace. This is NOT effective accessible pages โ€” a single
84 // shared parent with many descendants still counts as one row. The
85 // Shared With Me summary copy reflects this explicitly.
86 //
87 // Defensive: route-layer writes prevent duplicate user shares, but the
88 // schema does not enforce one row per user/page pair yet.
89 count: sql<number>`COUNT(DISTINCT ${pageShares.page_id})`,
90 })
91 .from(pageShares)
92 .innerJoin(pages, eq(pageShares.page_id, pages.id))
93 .innerJoin(workspaces, eq(pages.workspace_id, workspaces.id))
94 .innerJoin(memberships, and(eq(memberships.workspace_id, pages.workspace_id), eq(memberships.user_id, user.id)))
95 .where(and(eq(pageShares.grantee_type, "user"), eq(pageShares.grantee_id, user.id), isNull(pages.archived_at)))
96 .groupBy(workspaces.id, workspaces.name, workspaces.slug, workspaces.icon),
97 ]);
98 
99 const items = crossWorkspaceRows.map((r) => ({
100 page_id: r.page_id,
101 title: r.title,
102 icon: r.icon,
103 cover_url: r.cover_url,
104 workspace: {
105 id: r.workspace_id,
106 name: r.workspace_name,
107 slug: r.workspace_slug,
108 icon: r.workspace_icon,
109 // Caller has no membership in this workspace by construction.
110 role: null,
111 },
112 permission: r.permission,
113 shared_by: r.shared_by,
114 shared_by_name: r.shared_by_name ?? "Unknown",
115 shared_at: r.shared_at,
116 }));
117 
118 const workspace_summaries = summaryRows.map((r) => ({
119 workspace: {
120 id: r.workspace_id,
121 name: r.workspace_name,
122 slug: r.workspace_slug,
123 icon: r.workspace_icon,
124 },
125 count: Number(r.count),
126 }));
127 
128 return c.json({ items, workspace_summaries });
129});
130 
131// POST /pages/:id/share - Create share
132sharesRouter.post("/pages/:id/share", requireAuth, rateLimit("RL_API"), async (c) => {
133 const pageId = c.req.param("id");
134 const user = c.get("user")!;
135 const db = c.get("db");
136 
137 const page = await getPage(db, pageId);
138 if (!page) {
139 return c.json({ error: "not_found", message: "Page not found" }, 404);
140 }
141 
142 const membership = await requireMembership(c, db, user.id, page.workspace_id, true);
143 if (membership instanceof Response) return membership;
144 
145 if (membership.role === "guest") {
146 return c.json({ error: "forbidden", message: "Guests cannot manage shares" }, 403);
147 }
148 
149 const data = await parseBody(c, CreateShareRequest);
150 if (data instanceof Response) return data;
151 
152 if (data.grantee_type === "link") {
153 if (!canCreateLinkShare(membership.role)) {
154 return c.json({ error: "forbidden", message: "Only admins and owners can create link shares" }, 403);
155 }
156 
157 // Prevent duplicate link shares with the same permission on the same page
158 const existingLink = await db
159 .select({ id: pageShares.id })
160 .from(pageShares)
161 .where(
162 and(
163 eq(pageShares.page_id, pageId),
164 eq(pageShares.grantee_type, "link"),
165 eq(pageShares.permission, data.permission),
166 ),
167 )
168 .get();
169 if (existingLink) {
170 return c.json({ error: "conflict", message: "A link share with this permission already exists" }, 409);
171 }
172 }
173 
174 let resolvedGranteeId: string | null = null;
175 
176 if (data.grantee_type === "user") {
177 if (data.grantee_id && data.grantee_email) {
178 return c.json({ error: "validation_error", message: "Provide grantee_id or grantee_email, not both" }, 400);
179 }
180 
181 if (data.grantee_id) {
182 resolvedGranteeId = data.grantee_id;
183 } else if (data.grantee_email) {
184 if (!canCreateUserShareByEmail(membership.role)) {
185 return c.json({ error: "forbidden", message: "Only admins and owners can share by email" }, 403);
186 }
187 const normalizedEmail = data.grantee_email.toLowerCase();
188 const target = await db.select({ id: users.id }).from(users).where(eq(users.email, normalizedEmail)).get();
189 if (!target) {
190 return c.json({ error: "not_found", message: "No account with that email" }, 404);
191 }
192 resolvedGranteeId = target.id;
193 } else {
194 return c.json(
195 { error: "validation_error", message: "grantee_id or grantee_email is required for user shares" },
196 400,
197 );
198 }
199 
200 if (resolvedGranteeId === user.id) {
201 return c.json({ error: "bad_request", message: "You cannot share a page with yourself" }, 400);
202 }
203 
204 const targetMembership = await checkMembership(db, resolvedGranteeId, page.workspace_id);
205 if (!canCreateUserShare(membership.role, !!targetMembership)) {
206 return c.json({ error: "bad_request", message: "Members can only share with workspace members" }, 400);
207 }
208 
209 // Prevent duplicate user shares for the same grantee on the same page
210 const existing = await db
211 .select({ id: pageShares.id })
212 .from(pageShares)
213 .where(
214 and(
215 eq(pageShares.page_id, pageId),
216 eq(pageShares.grantee_type, "user"),
217 eq(pageShares.grantee_id, resolvedGranteeId),
218 ),
219 )
220 .get();
221 if (existing) {
222 return c.json({ error: "conflict", message: "A share already exists for this user on this page" }, 409);
223 }
224 }
225 
226 const shareId = ulid();
227 const linkToken = data.grantee_type === "link" ? generateSecureToken() : null;
228 
229 await db.insert(pageShares).values({
230 id: shareId,
231 page_id: pageId,
232 grantee_type: data.grantee_type,
233 grantee_id: resolvedGranteeId,
234 permission: data.permission,
235 link_token: linkToken,
236 created_by: user.id,
237 });
238 
239 log.info("share_created", {
240 shareId,
241 pageId,
242 granteeType: data.grantee_type,
243 permission: data.permission,
244 });
245 
246 const row = await db
247 .select({
248 id: pageShares.id,
249 page_id: pageShares.page_id,
250 grantee_type: pageShares.grantee_type,
251 grantee_id: pageShares.grantee_id,
252 permission: pageShares.permission,
253 link_token: pageShares.link_token,
254 created_by: pageShares.created_by,
255 created_at: pageShares.created_at,
256 grantee_name: users.name,
257 grantee_email: users.email,
258 })
259 .from(pageShares)
260 .leftJoin(users, eq(pageShares.grantee_id, users.id))
261 .where(eq(pageShares.id, shareId))
262 .get();
263 
264 const share = row
265 ? {
266 id: row.id,
267 page_id: row.page_id,
268 grantee_type: row.grantee_type,
269 grantee_id: row.grantee_id,
270 permission: row.permission,
271 link_token: row.link_token,
272 created_by: row.created_by,
273 created_at: row.created_at,
274 grantee_user:
275 row.grantee_id && row.grantee_name
276 ? { id: row.grantee_id, name: row.grantee_name, email: row.grantee_email! }
277 : null,
278 }
279 : null;
280 
281 return c.json({ share }, 201);
282});
283 
284// GET /pages/:id/share - List shares on a page
285sharesRouter.get("/pages/:id/share", requireAuth, rateLimit("RL_API"), async (c) => {
286 const pageId = c.req.param("id");
287 const user = c.get("user")!;
288 const db = c.get("db");
289 
290 const page = await getPage(db, pageId);
291 if (!page) {
292 return c.json({ error: "not_found", message: "Page not found" }, 404);
293 }
294 
295 const membership = await checkMembership(db, user.id, page.workspace_id);
296 
297 // Guests and non-members need page-level view access
298 if (!membership || membership.role === "guest") {
299 const hasAccess = await canAccessPage(db, { type: "user", userId: user.id }, pageId, page.workspace_id, "view");
300 if (!hasAccess) {
301 return c.json({ error: "forbidden", message: "You do not have access to this page" }, 403);
302 }
303 }
304 
305 const rows = await db
306 .select({
307 id: pageShares.id,
308 page_id: pageShares.page_id,
309 grantee_type: pageShares.grantee_type,
310 grantee_id: pageShares.grantee_id,
311 permission: pageShares.permission,
312 link_token: pageShares.link_token,
313 created_by: pageShares.created_by,
314 created_at: pageShares.created_at,
315 grantee_name: users.name,
316 grantee_email: users.email,
317 })
318 .from(pageShares)
319 .leftJoin(users, eq(pageShares.grantee_id, users.id))
320 .where(eq(pageShares.page_id, pageId));
321 
322 const canSeeLinkToken = membership ? canRevealLinkTokens(membership.role) : false;
323 const canSeeGranteeEmails = membership ? canRevealShareGranteeEmails(membership.role) : false;
324 const result = rows.map((s) => ({
325 id: s.id,
326 page_id: s.page_id,
327 grantee_type: s.grantee_type,
328 grantee_id: s.grantee_id,
329 permission: s.permission,
330 link_token: canSeeLinkToken ? s.link_token : null,
331 created_by: s.created_by,
332 created_at: s.created_at,
333 grantee_user:
334 s.grantee_id && s.grantee_name
335 ? { id: s.grantee_id, name: s.grantee_name, email: canSeeGranteeEmails ? s.grantee_email! : "" }
336 : null,
337 }));
338 
339 return c.json({ shares: result });
340});
341 
342// DELETE /pages/:id/share/:shareId - Revoke share
343sharesRouter.delete("/pages/:id/share/:shareId", requireAuth, rateLimit("RL_API"), async (c) => {
344 const pageId = c.req.param("id");
345 const shareId = c.req.param("shareId");
346 const user = c.get("user")!;
347 const db = c.get("db");
348 
349 const page = await getPage(db, pageId);
350 if (!page) {
351 return c.json({ error: "not_found", message: "Page not found" }, 404);
352 }
353 
354 const membership = await requireMembership(c, db, user.id, page.workspace_id, true);
355 if (membership instanceof Response) return membership;
356 
357 if (membership.role === "guest") {
358 return c.json({ error: "forbidden", message: "Guests cannot manage shares" }, 403);
359 }
360 
361 const share = await db
362 .select()
363 .from(pageShares)
364 .where(and(eq(pageShares.id, shareId), eq(pageShares.page_id, pageId)))
365 .get();
366 if (!share) {
367 return c.json({ error: "not_found", message: "Share not found" }, 404);
368 }
369 
370 const granteeInWorkspace = share.grantee_id ? await checkMembership(db, share.grantee_id, page.workspace_id) : null;
371 if (
372 !canRevokeShare({
373 workspaceRole: membership.role,
374 granteeType: share.grantee_type,
375 shareCreatedByViewer: share.created_by === user.id,
376 granteeIsWorkspaceMember: !!granteeInWorkspace,
377 })
378 ) {
379 const message =
380 share.grantee_type === "link"
381 ? "Only admins and owners can revoke link shares"
382 : share.created_by !== user.id
383 ? "You can only revoke shares you created"
384 : "Members can only revoke workspace-member user shares";
385 return c.json({ error: "forbidden", message }, 403);
386 }
387 
388 await db.delete(pageShares).where(eq(pageShares.id, shareId));
389 
390 log.info("share_revoked", { shareId, pageId });
391 
392 return c.json({ ok: true });
393});
394 
395// Share link resolution โ€” mounted under /api/v1
396export const shareLinkRouter = new Hono<AppContext>();
397 
398// GET /share/:token - Resolve share link
399shareLinkRouter.get("/share/:token", optionalAuth, rateLimit("RL_API"), async (c) => {
400 const token = c.req.param("token");
401 const db = c.get("db");
402 const user = c.get("user");
403 
404 const share = await db
405 .select()
406 .from(pageShares)
407 .where(and(eq(pageShares.link_token, token), eq(pageShares.grantee_type, "link")))
408 .get();
409 
410 if (!share) {
411 return c.json({ error: "not_found", message: "Share link not found or expired" }, 404);
412 }
413 
414 const page = await getPage(db, share.page_id);
415 if (!page) {
416 return c.json({ error: "not_found", message: "Page not found" }, 404);
417 }
418 
419 const workspace = await db
420 .select({ slug: workspaces.slug })
421 .from(workspaces)
422 .where(eq(workspaces.id, page.workspace_id))
423 .get();
424 if (!workspace) {
425 return c.json({ error: "not_found", message: "Workspace not found" }, 404);
426 }
427 
428 const resolved = await resolvePrincipal(db, user, page.workspace_id, {
429 surface: "shared",
430 shareToken: token,
431 });
432 if (!resolved) {
433 return c.json({ error: "unauthorized", message: "Authentication required" }, 401);
434 }
435 
436 return c.json({
437 page_id: page.id,
438 workspace_id: page.workspace_id,
439 kind: page.kind,
440 title: page.title,
441 icon: page.icon,
442 cover_url: page.cover_url,
443 permission: share.permission,
444 token,
445 viewer: toResolvedViewerContext(resolved, workspace.slug, "shared"),
446 });
447});