storage.ts244 lines · main
1import { Hono } from 'hono';
2import { z } from 'zod';
3
4import { ValidationError } from '@briven/shared';
5
6import { requireAuth } from '../middleware/session.js';
7import { assertProjectRole } from '../services/access.js';
8import { isImageTransformConfigured, signedTransformUrl } from '../services/image-transform.js';
9import { audit, hashIp } from '../services/audit.js';
10import {
11 deleteFile,
12 isStorageConfigured,
13 listDeletedFiles,
14 listFiles,
15 listPublicFileIds,
16 presignDownload,
17 presignUpload,
18 restoreFile,
19 setFilePublic,
20} from '../services/storage.js';
21import { assertWithinByteLimit } from '../services/storage-admin.js';
22import type { AppEnv } from '../types/app-env.js';
23
24export const storageRouter = new Hono<AppEnv>();
25
26storageRouter.use('/v1/projects/:id/files', requireAuth());
27storageRouter.use('/v1/projects/:id/files/*', requireAuth());
28
29const 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
35function 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
45storageRouter.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
53storageRouter.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
111storageRouter.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
124storageRouter.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
141storageRouter.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
148storageRouter.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
155storageRouter.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
185storageRouter.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.
213storageRouter.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});