File
Blob: src/worker/routes/uploads.ts
| 1 | import { Hono } from "hono"; |
| 2 | import { getCookie } from "hono/cookie"; |
| 3 | import { eq, and } from "drizzle-orm"; |
| 4 | import { ulid } from "ulid"; |
| 5 | |
| 6 | import { jwtVerify } from "jose"; |
| 7 | |
| 8 | import type { AppContext } from "@/worker/app-context"; |
| 9 | import { uploads, pageShares } from "@/worker/db/d1/schema"; |
| 10 | import { optionalAuth } from "@/worker/middleware/auth"; |
| 11 | import { rateLimit } from "@/worker/middleware/rate-limit"; |
| 12 | import { checkMembership } from "@/worker/lib/membership"; |
| 13 | import { canEdit, canAccessPage } from "@/worker/lib/permissions"; |
| 14 | import { getPage } from "@/worker/lib/page-access"; |
| 15 | import { REFRESH_COOKIE, getJwtSecret } from "@/worker/lib/auth"; |
| 16 | import { parseBody } from "@/worker/lib/validate"; |
| 17 | import { createLogger } from "@/worker/lib/logger"; |
| 18 | import { JWT_ALGORITHM } from "@/worker/lib/constants"; |
| 19 | import { PresignRequest } from "@/shared/types"; |
| 20 | import { getPageEditEntitlements } from "@/shared/entitlements"; |
| 21 | |
| 22 | const log = createLogger("uploads"); |
| 23 | |
| 24 | // Presign endpoint — mounted under /api/v1 |
| 25 | export const uploadsRouter = new Hono<AppContext>(); |
| 26 | |
| 27 | // POST /workspaces/:wid/uploads/presign - Create upload record + return upload URL |
| 28 | // Accepts JWT auth (workspace members) or ?share=<token> (shared-link editors) |
| 29 | uploadsRouter.post("/workspaces/:wid/uploads/presign", optionalAuth, rateLimit("RL_API"), async (c) => { |
| 30 | const workspaceId = c.req.param("wid"); |
| 31 | const user = c.get("user"); |
| 32 | const db = c.get("db"); |
| 33 | const shareToken = c.req.query("share"); |
| 34 | |
| 35 | const data = await parseBody(c, PresignRequest); |
| 36 | if (data instanceof Response) return data; |
| 37 | |
| 38 | let uploadedBy: string | null = null; |
| 39 | |
| 40 | // Shared-surface precedence: when `?share=<token>` is present, the shared principal |
| 41 | // authorizes the upload and bearer-member auth does not apply. Matches the WS |
| 42 | // "share wins" invariant and keeps `/s/:token` link-scoped end to end. |
| 43 | if (shareToken) { |
| 44 | if (!data.page_id) { |
| 45 | return c.json({ error: "bad_request", message: "page_id is required for shared-link uploads" }, 400); |
| 46 | } |
| 47 | const hasEdit = await canAccessPage(db, { type: "link", token: shareToken }, data.page_id, workspaceId, "edit"); |
| 48 | if (!hasEdit || !getPageEditEntitlements("shared", "edit").uploadImage) { |
| 49 | return c.json({ error: "forbidden", message: "Share link does not grant edit access" }, 403); |
| 50 | } |
| 51 | const share = await db |
| 52 | .select({ created_by: pageShares.created_by }) |
| 53 | .from(pageShares) |
| 54 | .where(and(eq(pageShares.link_token, shareToken), eq(pageShares.grantee_type, "link"))) |
| 55 | .get(); |
| 56 | if (!share) { |
| 57 | return c.json({ error: "forbidden", message: "Invalid share token" }, 403); |
| 58 | } |
| 59 | uploadedBy = share.created_by; |
| 60 | } else if (user) { |
| 61 | const membership = await checkMembership(db, user.id, workspaceId); |
| 62 | if (membership && canEdit(membership.role)) { |
| 63 | uploadedBy = user.id; |
| 64 | } else if (data.page_id) { |
| 65 | const hasEdit = await canAccessPage(db, { type: "user", userId: user.id }, data.page_id, workspaceId, "edit"); |
| 66 | if (hasEdit && getPageEditEntitlements("canonical", "edit").uploadImage) uploadedBy = user.id; |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | if (!uploadedBy) { |
| 71 | return c.json({ error: "unauthorized", message: "Authentication required" }, 401); |
| 72 | } |
| 73 | |
| 74 | // Validate page_id belongs to workspace if provided |
| 75 | if (data.page_id) { |
| 76 | const page = await getPage(db, data.page_id, workspaceId); |
| 77 | if (!page) { |
| 78 | return c.json({ error: "not_found", message: "Page not found in this workspace" }, 404); |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | const uploadId = ulid(); |
| 83 | const r2Key = `${workspaceId}/${uploadId}/${data.filename}`; |
| 84 | |
| 85 | await db.insert(uploads).values({ |
| 86 | id: uploadId, |
| 87 | workspace_id: workspaceId, |
| 88 | page_id: data.page_id ?? null, |
| 89 | uploaded_by: uploadedBy, |
| 90 | filename: data.filename, |
| 91 | content_type: data.content_type, |
| 92 | size_bytes: data.size_bytes, |
| 93 | r2_key: r2Key, |
| 94 | }); |
| 95 | |
| 96 | log.info("upload_presigned", { uploadId, workspaceId, filename: data.filename, sizeBytes: data.size_bytes }); |
| 97 | |
| 98 | return c.json({ |
| 99 | upload: { |
| 100 | id: uploadId, |
| 101 | upload_url: `/uploads/${uploadId}/data`, |
| 102 | url: `/uploads/${uploadId}`, |
| 103 | }, |
| 104 | }); |
| 105 | }); |
| 106 | |
| 107 | // PUT data + GET serve — mounted at /uploads |
| 108 | export const uploadServingRouter = new Hono<AppContext>(); |
| 109 | |
| 110 | // PUT /:id/data - Receive file binary and store in R2 |
| 111 | // Accepts JWT auth (workspace members) or ?share=<token> (shared-link editors) |
| 112 | uploadServingRouter.put("/:id/data", optionalAuth, rateLimit("RL_API"), async (c) => { |
| 113 | const uploadId = c.req.param("id"); |
| 114 | const user = c.get("user"); |
| 115 | const db = c.get("db"); |
| 116 | const shareToken = c.req.query("share"); |
| 117 | |
| 118 | const upload = await db.select().from(uploads).where(eq(uploads.id, uploadId)).get(); |
| 119 | if (!upload) { |
| 120 | return c.json({ error: "not_found", message: "Upload not found" }, 404); |
| 121 | } |
| 122 | |
| 123 | let putAuthorized = false; |
| 124 | |
| 125 | // Shared-surface precedence (same rule as presign): `?share=<token>` wins. The |
| 126 | // share path intentionally does not check `upload.uploaded_by === user.id` — |
| 127 | // shared-link editors write the R2 body via the share principal, and the |
| 128 | // `uploads` row's `uploaded_by` records the share author, not the writer. |
| 129 | if (shareToken && upload.page_id) { |
| 130 | putAuthorized = |
| 131 | (await canAccessPage(db, { type: "link", token: shareToken }, upload.page_id, upload.workspace_id, "edit")) && |
| 132 | getPageEditEntitlements("shared", "edit").uploadImage; |
| 133 | if (!putAuthorized) { |
| 134 | return c.json({ error: "forbidden", message: "Share link does not grant edit access" }, 403); |
| 135 | } |
| 136 | } else { |
| 137 | // Canonical path: distinguish "no valid bearer" (401, refresh-eligible) |
| 138 | // from "valid bearer without rights" (403, terminal). The client refresh |
| 139 | // gate at api.ts triggers only on 401 / 403+`unauthorized`. |
| 140 | if (!user) { |
| 141 | return c.json({ error: "unauthorized", message: "Authentication required" }, 401); |
| 142 | } |
| 143 | if (upload.uploaded_by === user.id) { |
| 144 | const membership = await checkMembership(db, user.id, upload.workspace_id); |
| 145 | if (membership && canEdit(membership.role)) { |
| 146 | putAuthorized = true; |
| 147 | } else if (upload.page_id) { |
| 148 | putAuthorized = |
| 149 | (await canAccessPage(db, { type: "user", userId: user.id }, upload.page_id, upload.workspace_id, "edit")) && |
| 150 | getPageEditEntitlements("canonical", "edit").uploadImage; |
| 151 | } |
| 152 | } |
| 153 | if (!putAuthorized) { |
| 154 | return c.json({ error: "forbidden", message: "You do not have edit access" }, 403); |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | // Prevent overwriting an already-uploaded file |
| 159 | const existing = await c.env.R2.head(upload.r2_key); |
| 160 | if (existing) { |
| 161 | return c.json({ error: "conflict", message: "File already uploaded" }, 409); |
| 162 | } |
| 163 | |
| 164 | const body = await c.req.arrayBuffer(); |
| 165 | if (body.byteLength > upload.size_bytes * 1.1) { |
| 166 | return c.json({ error: "bad_request", message: "File exceeds declared size" }, 400); |
| 167 | } |
| 168 | |
| 169 | await c.env.R2.put(upload.r2_key, body, { |
| 170 | httpMetadata: { contentType: upload.content_type }, |
| 171 | }); |
| 172 | |
| 173 | log.info("upload_completed", { uploadId, r2Key: upload.r2_key, actualSize: body.byteLength }); |
| 174 | |
| 175 | return c.json({ ok: true }); |
| 176 | }); |
| 177 | |
| 178 | // GET /:id - Serve file from R2 (auth via refresh cookie or ?share=token) |
| 179 | uploadServingRouter.get("/:id", rateLimit("RL_API"), async (c) => { |
| 180 | const uploadId = c.req.param("id"); |
| 181 | const db = c.get("db"); |
| 182 | |
| 183 | const upload = await db.select().from(uploads).where(eq(uploads.id, uploadId)).get(); |
| 184 | if (!upload) { |
| 185 | return c.json({ error: "not_found", message: "Upload not found" }, 404); |
| 186 | } |
| 187 | |
| 188 | // Page-scoped uploads: if the linked page is archived or missing, conceal the asset. |
| 189 | // This keeps all callers on the same 404 path instead of leaking through auth outcomes. |
| 190 | if (upload.page_id) { |
| 191 | const page = await getPage(db, upload.page_id, upload.workspace_id); |
| 192 | if (!page) { |
| 193 | return c.json({ error: "not_found", message: "Upload not found" }, 404); |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | const shareToken = c.req.query("share"); |
| 198 | let authorized = false; |
| 199 | |
| 200 | // Shared-surface precedence: a page-scoped asset fetched with `?share=<token>` |
| 201 | // authorizes against the share principal and does NOT fall back to cookie auth. |
| 202 | // A workspace member carrying both a refresh cookie and a share token resolves |
| 203 | // through the share, matching the WS / HTTP shared-follow-on invariant. |
| 204 | if (shareToken && upload.page_id) { |
| 205 | authorized = await canAccessPage( |
| 206 | db, |
| 207 | { type: "link", token: shareToken }, |
| 208 | upload.page_id, |
| 209 | upload.workspace_id, |
| 210 | "view", |
| 211 | ); |
| 212 | } else { |
| 213 | // Cookie-based canonical auth. Used for workspace-level assets (avatars) and |
| 214 | // for page-scoped assets when no share token is present. |
| 215 | const refreshToken = getCookie(c, REFRESH_COOKIE); |
| 216 | if (refreshToken) { |
| 217 | try { |
| 218 | const { payload } = await jwtVerify(refreshToken, getJwtSecret(c.env), { algorithms: [JWT_ALGORITHM] }); |
| 219 | if (payload.sub && payload.type === "refresh") { |
| 220 | if (!upload.page_id) { |
| 221 | authorized = true; |
| 222 | } else { |
| 223 | const membership = await checkMembership(db, payload.sub, upload.workspace_id); |
| 224 | if (membership && membership.role !== "guest") { |
| 225 | authorized = true; |
| 226 | } else { |
| 227 | authorized = await canAccessPage( |
| 228 | db, |
| 229 | { type: "user", userId: payload.sub }, |
| 230 | upload.page_id, |
| 231 | upload.workspace_id, |
| 232 | "view", |
| 233 | ); |
| 234 | } |
| 235 | } |
| 236 | } |
| 237 | } catch { |
| 238 | // Cookie auth failed — leave `authorized = false` |
| 239 | } |
| 240 | } |
| 241 | } |
| 242 | |
| 243 | if (!authorized) { |
| 244 | return c.json({ error: "unauthorized", message: "Authentication required" }, 401); |
| 245 | } |
| 246 | |
| 247 | const object = await c.env.R2.get(upload.r2_key); |
| 248 | if (!object) { |
| 249 | return c.json({ error: "not_found", message: "File not found" }, 404); |
| 250 | } |
| 251 | |
| 252 | // Page-scoped assets use a short private TTL so share revocation takes effect |
| 253 | // within minutes instead of a year. Workspace-level assets (avatars) keep the |
| 254 | // long immutable cache policy because they are not revocation-sensitive. |
| 255 | const cacheControl = upload.page_id |
| 256 | ? "private, max-age=300, must-revalidate" |
| 257 | : "private, max-age=31536000, immutable"; |
| 258 | |
| 259 | return new Response(object.body, { |
| 260 | headers: { |
| 261 | "Content-Type": upload.content_type, |
| 262 | "Content-Length": String(object.size), |
| 263 | "Cache-Control": cacheControl, |
| 264 | }, |
| 265 | }); |
| 266 | }); |