Skip to content
File

Blob: src/worker/routes/uploads.ts

typescript267 lines
1import { Hono } from "hono";
2import { getCookie } from "hono/cookie";
3import { eq, and } from "drizzle-orm";
4import { ulid } from "ulid";
5 
6import { jwtVerify } from "jose";
7 
8import type { AppContext } from "@/worker/app-context";
9import { uploads, pageShares } from "@/worker/db/d1/schema";
10import { optionalAuth } from "@/worker/middleware/auth";
11import { rateLimit } from "@/worker/middleware/rate-limit";
12import { checkMembership } from "@/worker/lib/membership";
13import { canEdit, canAccessPage } from "@/worker/lib/permissions";
14import { getPage } from "@/worker/lib/page-access";
15import { REFRESH_COOKIE, getJwtSecret } from "@/worker/lib/auth";
16import { parseBody } from "@/worker/lib/validate";
17import { createLogger } from "@/worker/lib/logger";
18import { JWT_ALGORITHM } from "@/worker/lib/constants";
19import { PresignRequest } from "@/shared/types";
20import { getPageEditEntitlements } from "@/shared/entitlements";
21 
22const log = createLogger("uploads");
23 
24// Presign endpoint — mounted under /api/v1
25export 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)
29uploadsRouter.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
108export 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)
112uploadServingRouter.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)
179uploadServingRouter.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});