Repository Public
1// Serving stored bytes: originals (with Range), derived previews and ZIP exports. Authorisation happens in the2// callers; this module only knows how to answer safely once access is granted.3import type { FastifyReply, FastifyRequest } from 'fastify';4import { extOf, INLINE_MIME, previewKind } from '@spd/shared';5import yazl from 'yazl';6import { readSealed, type Sealed } from './blob.ts';7import { notFound, UserError } from './config.ts';8import type { Db } from './db.ts';910export interface ContentRow extends Sealed { object_id: string; name: string; sha256: string; scan_status: string }11/** The stored object behind a file (current version, or a given version of that file). */12export async function contentOf(t: Db, fileId: string, versionId?: string): Promise<ContentRow> {13 const [r] = await t<ContentRow[]>`select o.id as object_id, o.storage_key, o.wrapped_key, o.size::text as size, o.sha256, o.scan_status, f.name14 from files f join file_versions v on v.file_id = f.id and v.id = ${versionId ?? t`f.current_version_id`} join storage_objects o on o.id = v.object_id where f.id = ${fileId}`;15 if (!r) throw notFound('file not found');16 return { ...r, size: Number(r.size) };17}1819/** RFC 6266 / 5987 Content-Disposition with an ASCII fallback. */20export function disposition(kind: 'inline' | 'attachment', name: string): string {21 const ascii = name.normalize('NFKD').replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');22 return `${kind}; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)}`;23}24/** Headers that make user content inert in the browser, whatever its type. */25export function inertHeaders(reply: FastifyReply): void {26 reply.header('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; sandbox");27 reply.header('X-Content-Type-Options', 'nosniff');28 reply.header('Cross-Origin-Resource-Policy', 'same-origin');29}3031function parseRange(header: string | undefined, size: number): { start: number; end: number } | 'invalid' | null {32 if (!header) return null;33 const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim());34 if (!m || (m[1] === '' && m[2] === '')) return null; // multi-range or other units: answer with the whole body35 let start: number, end: number;36 if (m[1] === '') { const n = Number(m[2]); if (n === 0) return 'invalid'; start = Math.max(0, size - n); end = size - 1; }37 else { start = Number(m[1]); end = m[2] === '' ? size - 1 : Math.min(Number(m[2]), size - 1); }38 return start > end || start >= size ? 'invalid' : { start, end };39}4041export interface SendOptions { name: string; etag: string; mode: 'download' | 'inline' | 'text'; contentType?: string; cache?: string }42/** Stream a sealed object, honouring Range and conditional requests. */43export function sendSealed(req: FastifyRequest, reply: FastifyReply, o: Sealed, opt: SendOptions): FastifyReply {44 const size = Number(o.size);45 const etag = `"${opt.etag}"`;46 inertHeaders(reply);47 reply.header('ETag', etag).header('Accept-Ranges', 'bytes').header('Cache-Control', opt.cache ?? 'private, no-cache');48 if (req.headers['if-none-match'] === etag) return reply.code(304).send();49 const inlineType = INLINE_MIME[extOf(opt.name)];50 if (opt.contentType) reply.type(opt.contentType).header('Content-Disposition', disposition('inline', opt.name));51 else if (opt.mode === 'text') reply.type('text/plain; charset=utf-8').header('Content-Disposition', disposition('inline', opt.name));52 else if (opt.mode === 'inline' && inlineType) reply.type(inlineType).header('Content-Disposition', disposition('inline', opt.name));53 else reply.type('application/octet-stream').header('Content-Disposition', disposition('attachment', opt.name));54 const ifRange = req.headers['if-range'];55 const range = ifRange && ifRange !== etag ? null : parseRange(req.headers.range, size);56 if (range === 'invalid') return reply.code(416).header('Content-Range', `bytes */${size}`).send();57 if (req.method === 'HEAD') return reply.header('Content-Length', size).send();58 if (range) {59 reply.code(206).header('Content-Range', `bytes ${range.start}-${range.end}/${size}`).header('Content-Length', range.end - range.start + 1);60 return reply.send(readSealed(o, range.start, range.end));61 }62 return reply.header('Content-Length', size).send(readSealed(o));63}6465/** Answer a derived preview (thumbnail, PDF rendition, JSON inventory…) of an object. */66const PREVIEW_TYPE: Record<string, string> = { thumb: 'image/jpeg', image: 'image/jpeg', pdf: 'application/pdf', sheet: 'application/json; charset=utf-8', archive: 'application/json; charset=utf-8' };67export async function sendPreview(t: Db, req: FastifyRequest, reply: FastifyReply, objectId: string, kind: string, name: string): Promise<FastifyReply> {68 if (!(kind in PREVIEW_TYPE)) throw notFound('unknown preview');69 const [p] = await t<{ status: string; storage_key: string | null; wrapped_key: Buffer | null; size: string | null; error: string | null }[]>`70 select status, storage_key, wrapped_key, size::text, error from previews where object_id = ${objectId} and kind = ${kind}`;71 if (!p && !workerPlan(name).includes(kind)) throw new UserError('preview_unsupported', 'no preview for this format', 404); // never planned: do not let clients poll72 if (!p || p.status === 'pending') return reply.code(202).send({ status: 'pending' });73 if (p.status === 'unsupported') throw new UserError('preview_unsupported', 'no preview for this format', 404);74 if (p.status === 'failed' || !p.storage_key || !p.wrapped_key) throw new UserError('preview_failed', p.error ?? 'the preview could not be produced', 422);75 return sendSealed(req, reply, { storage_key: p.storage_key, wrapped_key: p.wrapped_key, size: Number(p.size) },76 { name: `${name}.${kind}`, etag: `${objectId}-${kind}`, mode: 'inline', contentType: PREVIEW_TYPE[kind], cache: 'private, max-age=3600' });77}78/** Every derived preview the worker produces for a name (see worker.ts `preview`). */79export const workerPlan = (name: string): string[] => {80 switch (previewKind(name)) {81 case 'image': case 'pdf': case 'video': return ['thumb'];82 case 'heic': return ['thumb', 'image'];83 case 'office': return ['pdf', 'thumb'];84 case 'sheet': return ['sheet'];85 case 'archive': return ['archive'];86 default: return [];87 }88};89/** Which derived previews the client should ask for, by file kind. */90export const previewPlan = (name: string): string[] => {91 switch (previewKind(name)) {92 case 'image': return ['thumb'];93 case 'heic': return ['thumb', 'image'];94 case 'pdf': return ['thumb'];95 case 'office': return ['pdf'];96 case 'sheet': return ['sheet'];97 case 'archive': return ['archive'];98 default: return [];99 }100};101102export interface ZipEntry { path: string; content: Sealed }103const ZIP_MAX_FILES = 20000;104/** Stream a ZIP of the given entries (stored, not compressed: most documents are already compressed). */105export function sendZip(reply: FastifyReply, name: string, entries: ZipEntry[]): FastifyReply {106 if (entries.length > ZIP_MAX_FILES) throw new UserError('too_many_items', 'too many files for one archive', 413);107 const zip = new yazl.ZipFile();108 const seen = new Set<string>();109 for (const e of entries) {110 let path = e.path.replace(/^\/+/, '');111 for (let i = 2; seen.has(path.toLowerCase()); i++) path = e.path.replace(/(\.[^./]*)?$/, ` (${i})$1`);112 seen.add(path.toLowerCase());113 zip.addReadStreamLazy(path, { compress: false, size: Number(e.content.size) }, (cb) => cb(null, readSealed(e.content)));114 }115 zip.end();116 inertHeaders(reply);117 reply.type('application/zip').header('Content-Disposition', disposition('attachment', name)).header('Cache-Control', 'private, no-store');118 return reply.send(zip.outputStream);119}120