File
Blob: src/worker/routes/shares.ts
| 1 | import { Hono } from "hono"; |
| 2 | import { eq, and, isNull, desc, sql } from "drizzle-orm"; |
| 3 | import { ulid } from "ulid"; |
| 4 | |
| 5 | import type { AppContext } from "@/worker/app-context"; |
| 6 | import { pageShares, pages, users, workspaces, memberships } from "@/worker/db/d1/schema"; |
| 7 | import { optionalAuth, requireAuth } from "@/worker/middleware/auth"; |
| 8 | import { rateLimit } from "@/worker/middleware/rate-limit"; |
| 9 | import { checkMembership, requireMembership } from "@/worker/lib/membership"; |
| 10 | import { canAccessPage, resolvePrincipal, toResolvedViewerContext } from "@/worker/lib/permissions"; |
| 11 | import { generateSecureToken } from "@/worker/lib/auth"; |
| 12 | import { parseBody } from "@/worker/lib/validate"; |
| 13 | import { createLogger } from "@/worker/lib/logger"; |
| 14 | import { getPage } from "@/worker/lib/page-access"; |
| 15 | import { CreateShareRequest } from "@/shared/types"; |
| 16 | import { |
| 17 | canCreateLinkShare, |
| 18 | canCreateUserShare, |
| 19 | canCreateUserShareByEmail, |
| 20 | canRevealLinkTokens, |
| 21 | canRevealShareGranteeEmails, |
| 22 | canRevokeShare, |
| 23 | } from "@/shared/entitlements"; |
| 24 | |
| 25 | const log = createLogger("shares"); |
| 26 | |
| 27 | // Share CRUD โ mounted under /api/v1 |
| 28 | export 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. |
| 39 | sharesRouter.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 |
| 132 | sharesRouter.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 |
| 285 | sharesRouter.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 |
| 343 | sharesRouter.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 |
| 396 | export const shareLinkRouter = new Hono<AppContext>(); |
| 397 | |
| 398 | // GET /share/:token - Resolve share link |
| 399 | shareLinkRouter.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 | }); |