storage.ts244 lines · main
| 1 | import { Hono } from 'hono'; |
| 2 | import { z } from 'zod'; |
| 3 | |
| 4 | import { ValidationError } from '@briven/shared'; |
| 5 | |
| 6 | import { requireAuth } from '../middleware/session.js'; |
| 7 | import { assertProjectRole } from '../services/access.js'; |
| 8 | import { isImageTransformConfigured, signedTransformUrl } from '../services/image-transform.js'; |
| 9 | import { audit, hashIp } from '../services/audit.js'; |
| 10 | import { |
| 11 | deleteFile, |
| 12 | isStorageConfigured, |
| 13 | listDeletedFiles, |
| 14 | listFiles, |
| 15 | listPublicFileIds, |
| 16 | presignDownload, |
| 17 | presignUpload, |
| 18 | restoreFile, |
| 19 | setFilePublic, |
| 20 | } from '../services/storage.js'; |
| 21 | import { assertWithinByteLimit } from '../services/storage-admin.js'; |
| 22 | import type { AppEnv } from '../types/app-env.js'; |
| 23 | |
| 24 | export const storageRouter = new Hono<AppEnv>(); |
| 25 | |
| 26 | storageRouter.use('/v1/projects/:id/files', requireAuth()); |
| 27 | storageRouter.use('/v1/projects/:id/files/*', requireAuth()); |
| 28 | |
| 29 | const uploadSchema = z.object({ |
| 30 | name: z.string().min(1).max(200), |
| 31 | contentType: z.string().min(1).max(100), |
| 32 | sizeBytes: z.number().int().min(0), |
| 33 | }); |
| 34 | |
| 35 | function notConfigured(c: { json: (b: unknown, s: number) => Response }) { |
| 36 | return c.json( |
| 37 | { |
| 38 | code: 'storage_not_configured', |
| 39 | message: 'object storage is not configured on this api', |
| 40 | }, |
| 41 | 503, |
| 42 | ); |
| 43 | } |
| 44 | |
| 45 | storageRouter.get('/v1/projects/:id/files', async (c) => { |
| 46 | if (!isStorageConfigured()) return notConfigured(c); |
| 47 | const user = c.get('user')!; |
| 48 | const { project } = await assertProjectRole(c.req.param('id'), user.id, 'viewer'); |
| 49 | const files = await listFiles(project.id); |
| 50 | return c.json({ files }); |
| 51 | }); |
| 52 | |
| 53 | storageRouter.post('/v1/projects/:id/files/upload-url', async (c) => { |
| 54 | if (!isStorageConfigured()) return notConfigured(c); |
| 55 | const user = c.get('user')!; |
| 56 | const { project } = await assertProjectRole(c.req.param('id'), user.id, 'developer'); |
| 57 | |
| 58 | const body = await c.req.json().catch(() => null); |
| 59 | const parsed = uploadSchema.safeParse(body); |
| 60 | if (!parsed.success) { |
| 61 | return c.json( |
| 62 | { code: 'validation_failed', message: 'invalid request body', issues: parsed.error.issues }, |
| 63 | 400, |
| 64 | ); |
| 65 | } |
| 66 | |
| 67 | try { |
| 68 | // Storage enforcement: block-mode projects over their byte cap are refused |
| 69 | // BEFORE we hand out a presigned URL (the size is already known here). |
| 70 | // flag-mode is a no-op; a lookup miss fails open. A ValidationError surfaces |
| 71 | // as a 413 over-limit below. |
| 72 | await assertWithinByteLimit(project.id, parsed.data.sizeBytes); |
| 73 | const result = await presignUpload({ |
| 74 | projectId: project.id, |
| 75 | name: parsed.data.name, |
| 76 | contentType: parsed.data.contentType, |
| 77 | sizeBytes: parsed.data.sizeBytes, |
| 78 | uploadedBy: user.id, |
| 79 | }); |
| 80 | await audit({ |
| 81 | actorId: user.id, |
| 82 | projectId: project.id, |
| 83 | action: 'file.upload-url', |
| 84 | ipHash: hashIp(c.req.raw.headers.get('x-forwarded-for')), |
| 85 | userAgent: c.req.header('user-agent') ?? null, |
| 86 | metadata: { fileId: result.file.id, name: parsed.data.name, sizeBytes: parsed.data.sizeBytes }, |
| 87 | }); |
| 88 | return c.json( |
| 89 | { |
| 90 | file: result.file, |
| 91 | uploadUrl: result.uploadUrl, |
| 92 | requiredHeaders: result.requiredHeaders, |
| 93 | expiresInSec: result.expiresInSec, |
| 94 | }, |
| 95 | 201, |
| 96 | ); |
| 97 | } catch (err) { |
| 98 | if (err instanceof ValidationError) { |
| 99 | // The storage-enforcement byte guard tags its context with field:'bytes'; |
| 100 | // surface that as a 413 over-limit. Other validation failures (presign |
| 101 | // input) stay 400. |
| 102 | if (err.context?.field === 'bytes') { |
| 103 | return c.json({ code: 'storage_limit_reached', message: err.message }, 413); |
| 104 | } |
| 105 | return c.json({ code: 'validation_failed', message: err.message }, 400); |
| 106 | } |
| 107 | throw err; |
| 108 | } |
| 109 | }); |
| 110 | |
| 111 | storageRouter.get('/v1/projects/:id/files/:fileId/download-url', async (c) => { |
| 112 | if (!isStorageConfigured()) return notConfigured(c); |
| 113 | const user = c.get('user')!; |
| 114 | const { project } = await assertProjectRole(c.req.param('id'), user.id, 'viewer'); |
| 115 | const fileId = c.req.param('fileId'); |
| 116 | const result = await presignDownload(fileId, project.id); |
| 117 | return c.json({ |
| 118 | file: result.file, |
| 119 | downloadUrl: result.downloadUrl, |
| 120 | expiresInSec: result.expiresInSec, |
| 121 | }); |
| 122 | }); |
| 123 | |
| 124 | storageRouter.delete('/v1/projects/:id/files/:fileId', async (c) => { |
| 125 | if (!isStorageConfigured()) return notConfigured(c); |
| 126 | const user = c.get('user')!; |
| 127 | const { project } = await assertProjectRole(c.req.param('id'), user.id, 'developer'); |
| 128 | const fileId = c.req.param('fileId'); |
| 129 | const file = await deleteFile(fileId, project.id); |
| 130 | await audit({ |
| 131 | actorId: user.id, |
| 132 | projectId: project.id, |
| 133 | action: 'file.delete', |
| 134 | ipHash: hashIp(c.req.raw.headers.get('x-forwarded-for')), |
| 135 | userAgent: c.req.header('user-agent') ?? null, |
| 136 | metadata: { fileId, name: file.name }, |
| 137 | }); |
| 138 | return c.json({ ok: true }); |
| 139 | }); |
| 140 | |
| 141 | storageRouter.get('/v1/projects/:id/files/deleted', async (c) => { |
| 142 | if (!isStorageConfigured()) return notConfigured(c); |
| 143 | const user = c.get('user')!; |
| 144 | const { project } = await assertProjectRole(c.req.param('id'), user.id, 'viewer'); |
| 145 | return c.json({ files: await listDeletedFiles(project.id) }); |
| 146 | }); |
| 147 | |
| 148 | storageRouter.get('/v1/projects/:id/files/public-ids', async (c) => { |
| 149 | if (!isStorageConfigured()) return notConfigured(c); |
| 150 | const user = c.get('user')!; |
| 151 | const { project } = await assertProjectRole(c.req.param('id'), user.id, 'viewer'); |
| 152 | return c.json({ ids: await listPublicFileIds(project.id) }); |
| 153 | }); |
| 154 | |
| 155 | storageRouter.post('/v1/projects/:id/files/:fileId/public', async (c) => { |
| 156 | if (!isStorageConfigured()) return notConfigured(c); |
| 157 | const user = c.get('user')!; |
| 158 | const { project } = await assertProjectRole(c.req.param('id'), user.id, 'developer'); |
| 159 | const fileId = c.req.param('fileId'); |
| 160 | |
| 161 | const body = await c.req.json().catch(() => null); |
| 162 | if (!body || typeof body.public !== 'boolean') { |
| 163 | return c.json({ code: 'validation_failed', message: 'body must be { public: boolean }' }, 400); |
| 164 | } |
| 165 | |
| 166 | try { |
| 167 | const result = await setFilePublic(fileId, project.id, body.public); |
| 168 | await audit({ |
| 169 | actorId: user.id, |
| 170 | projectId: project.id, |
| 171 | action: 'storage.file.public', |
| 172 | ipHash: hashIp(c.req.raw.headers.get('x-forwarded-for')), |
| 173 | userAgent: c.req.header('user-agent') ?? null, |
| 174 | metadata: { fileId, public: body.public }, |
| 175 | }); |
| 176 | return c.json(result); |
| 177 | } catch (err) { |
| 178 | if (err instanceof ValidationError) { |
| 179 | return c.json({ code: 'validation_failed', message: err.message }, 400); |
| 180 | } |
| 181 | throw err; |
| 182 | } |
| 183 | }); |
| 184 | |
| 185 | storageRouter.post('/v1/projects/:id/files/:fileId/restore', async (c) => { |
| 186 | if (!isStorageConfigured()) return notConfigured(c); |
| 187 | const user = c.get('user')!; |
| 188 | const { project } = await assertProjectRole(c.req.param('id'), user.id, 'developer'); |
| 189 | const fileId = c.req.param('fileId'); |
| 190 | try { |
| 191 | const result = await restoreFile(fileId, project.id); |
| 192 | await audit({ |
| 193 | actorId: user.id, |
| 194 | projectId: project.id, |
| 195 | action: 'storage.file.restore', |
| 196 | ipHash: hashIp(c.req.raw.headers.get('x-forwarded-for')), |
| 197 | userAgent: c.req.header('user-agent') ?? null, |
| 198 | metadata: { fileId, status: result.status }, |
| 199 | }); |
| 200 | return c.json(result); |
| 201 | } catch (err) { |
| 202 | if (err instanceof ValidationError) { |
| 203 | return c.json({ code: 'validation_failed', message: err.message }, 400); |
| 204 | } |
| 205 | throw err; |
| 206 | } |
| 207 | }); |
| 208 | |
| 209 | // M4 — signed imgproxy URL for on-the-fly resizing of a PUBLIC file. Stateless |
| 210 | // URL signing (no storage/DB touch), so this doesn't gate on isStorageConfigured; |
| 211 | // it gates on the imgproxy env instead. When imgproxy isn't wired we return 503 |
| 212 | // not_configured (fail-safe) exactly like storage does. |
| 213 | storageRouter.get('/v1/projects/:id/files/:fileId/transform-url', async (c) => { |
| 214 | if (!isImageTransformConfigured()) { |
| 215 | return c.json( |
| 216 | { code: 'not_configured', message: 'image transforms are not enabled' }, |
| 217 | 503, |
| 218 | ); |
| 219 | } |
| 220 | const user = c.get('user')!; |
| 221 | const { project } = await assertProjectRole(c.req.param('id'), user.id, 'viewer'); |
| 222 | const fileId = c.req.param('fileId'); |
| 223 | |
| 224 | const parseDim = (raw: string | undefined): number | undefined => { |
| 225 | if (raw == null) return undefined; |
| 226 | const n = Number.parseInt(raw, 10); |
| 227 | return Number.isFinite(n) ? n : undefined; |
| 228 | }; |
| 229 | const width = parseDim(c.req.query('w')); |
| 230 | const height = parseDim(c.req.query('h')); |
| 231 | const rawResize = c.req.query('resize'); |
| 232 | const resize = |
| 233 | rawResize === 'fit' || rawResize === 'fill' || rawResize === 'auto' ? rawResize : undefined; |
| 234 | |
| 235 | try { |
| 236 | const url = signedTransformUrl(project.id, fileId, { width, height, resize }); |
| 237 | return c.json({ url }); |
| 238 | } catch (err) { |
| 239 | if (err instanceof ValidationError) { |
| 240 | return c.json({ code: 'validation_failed', message: err.message }, 400); |
| 241 | } |
| 242 | throw err; |
| 243 | } |
| 244 | }); |