media.ts67 lines · main
| 1 | import { Hono } from 'hono'; |
| 2 | |
| 3 | import { openPublicObject, openOwnedObject } from '../services/storage.js'; |
| 4 | import { resolveShareLink } from '../services/storage-share-links.js'; |
| 5 | import { originsForProject, brivenOwnOrigins } from '../services/auth-origin-allowlist.js'; |
| 6 | |
| 7 | export const mediaRouter = new Hono(); |
| 8 | |
| 9 | // Public: serve a file that's been marked public, from the clean media host. |
| 10 | // No auth. Per-tenant CORS: echo the requesting Origin only if it's in the |
| 11 | // project's allowed-domains (or a briven-own origin). |
| 12 | // |
| 13 | // Bodies are streamed through `new Response(ReadableStream, ...)` — the same |
| 14 | // idiom the branding-logo + AI-stream routes use for serving object bytes — |
| 15 | // so we build every header (incl. the tenant-scoped CORS ones) manually. |
| 16 | mediaRouter.get('/media/:projectId/:fileId', async (c) => { |
| 17 | const projectId = c.req.param('projectId'); |
| 18 | const fileId = c.req.param('fileId'); |
| 19 | const obj = await openPublicObject(projectId, fileId); |
| 20 | if (!obj) return c.text('not found', 404); |
| 21 | |
| 22 | const headers: Record<string, string> = { |
| 23 | 'content-type': obj.contentType, |
| 24 | 'cache-control': 'public, max-age=300', |
| 25 | }; |
| 26 | if (obj.contentLength) headers['content-length'] = obj.contentLength; |
| 27 | |
| 28 | const origin = c.req.header('origin'); |
| 29 | if (origin) { |
| 30 | const allowed = await originsForProject(projectId).catch(() => [] as string[]); |
| 31 | if (allowed.includes(origin) || brivenOwnOrigins().includes(origin)) { |
| 32 | headers['access-control-allow-origin'] = origin; |
| 33 | headers['vary'] = 'Origin'; |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | return new Response(obj.body, { status: 200, headers }); |
| 38 | }); |
| 39 | |
| 40 | // Public: serve a file via a tokenized SHARE-LINK. No auth — the token IS the |
| 41 | // authorization. `resolveShareLink` is strict-deny: it returns the file ONLY for |
| 42 | // an active (unrevoked, unexpired) link whose token exact-matches. On any miss it |
| 43 | // returns null and we answer a plain 404 — deliberately NOT leaking whether the |
| 44 | // token, the link, or the file exists (all misses look identical). |
| 45 | // |
| 46 | // A valid link serves the file EVEN IF it is not marked public — that is the |
| 47 | // point of a private, time-limited link — so we open the OWNED object (not the |
| 48 | // public one). Same streaming idiom as /media. No CORS echo: a share-link is a |
| 49 | // direct bearer URL (opened by navigation/download), not a cross-origin fetch |
| 50 | // against a project's allow-listed app origins. |
| 51 | mediaRouter.get('/link/:token', async (c) => { |
| 52 | const token = c.req.param('token'); |
| 53 | const resolved = await resolveShareLink(token); |
| 54 | if (!resolved) return c.text('not found', 404); |
| 55 | |
| 56 | const obj = await openOwnedObject(resolved.projectId, resolved.fileId); |
| 57 | if (!obj) return c.text('not found', 404); |
| 58 | |
| 59 | const headers: Record<string, string> = { |
| 60 | 'content-type': obj.contentType, |
| 61 | // Private link → don't let shared caches fan it out. |
| 62 | 'cache-control': 'private, max-age=60', |
| 63 | }; |
| 64 | if (obj.contentLength) headers['content-length'] = obj.contentLength; |
| 65 | |
| 66 | return new Response(obj.body, { status: 200, headers }); |
| 67 | }); |