Repository Public
1// Explorer API for the signed-in owner. Every query is scoped by owner_id; an id that belongs to someone else is 404.2import type { FastifyInstance } from 'fastify';3import { nameKey, sanitizeName } from '@spd/shared';4import { z } from 'zod';5import { actorOf, requireUser, userOf } from './auth.ts';6import { notFound, UserError } from './config.ts';7import { contentOf, previewPlan, sendPreview, sendSealed, sendZip, type ZipEntry } from './content.ts';8import { audit, type Db, lockOwner, sql, tx } from './db.ts';9import {10 breadcrumbs, copyItems, createFolder, type FileRow, fileJoin, fileJson, fileSelect, type FolderRow, folderJson, getFile, getFolder, moveItems,11 purgeItems, renameFile, renameFolder, restoreItems, subtreeFiles, trashItems, addVersion,12} from './tree.ts';13import type { ObjectRow } from './blob.ts';1415const Id = z.uuid();16const Sel = z.object({ fileIds: z.array(Id).max(2000).default([]), folderIds: z.array(Id).max(2000).default([]) }).refine((s) => s.fileIds.length + s.folderIds.length > 0, 'empty selection');17const SORTS = { name: 'name_key', updated: 'updated_at', size: 'size', created: 'created_at' } as const;18const Sort = z.object({ sort: z.enum(['name', 'updated', 'size', 'created']).default('name'), dir: z.enum(['asc', 'desc']).default('asc') });19const order = (t: Db, s: z.infer<typeof Sort>, alias: string, hasSize: boolean) =>20 t`${t(alias)}.${t(s.sort === 'size' && !hasSize ? 'name_key' : SORTS[s.sort])} ${s.dir === 'desc' ? t`desc` : t`asc`}, ${t(alias)}.id`;2122export function fileRoutes(app: FastifyInstance): void {23 const authed = { preHandler: requireUser };2425 // --- browse ---26 app.get('/api/v1/folders/:id', authed, async (req) => {27 const { id } = z.object({ id: z.union([Id, z.literal('root')]) }).parse(req.params);28 const s = Sort.parse(req.query);29 const u = userOf(req);30 const folder = await getFolder(sql, u.id, id);31 const [folders, files, crumbs] = await Promise.all([32 sql<FolderRow[]>`select d.* from folders d where d.parent_id = ${folder.id} and d.trashed_at is null order by ${order(sql, s, 'd', false)} limit 5000`,33 sql<FileRow[]>`select ${fileSelect(sql)} from files f ${fileJoin(sql)} where f.folder_id = ${folder.id} and f.trashed_at is null order by ${order(sql, s, 'f', true)} limit 5000`,34 breadcrumbs(sql, folder),35 ]);36 return { folder: folderJson(folder), breadcrumbs: crumbs, folders: folders.map(folderJson), files: files.map(fileJson) };37 });38 /** Folder tree for the move/copy picker: direct children only. */39 app.get('/api/v1/folders/:id/children', authed, async (req) => {40 const { id } = z.object({ id: z.union([Id, z.literal('root')]) }).parse(req.params);41 const folder = await getFolder(sql, userOf(req).id, id);42 const rows = await sql<(FolderRow & { has_children: boolean })[]>`select d.*, exists(select 1 from folders c where c.parent_id = d.id and c.trashed_at is null) as has_children43 from folders d where d.parent_id = ${folder.id} and d.trashed_at is null order by d.name_key limit 5000`;44 return { folder: folderJson(folder), children: rows.map((r) => ({ ...folderJson(r), hasChildren: r.has_children })) };45 });46 app.post('/api/v1/folders', authed, async (req) => {47 const b = z.object({ parentId: z.union([Id, z.literal('root')]), name: z.string().min(1).max(1000), onConflict: z.enum(['fail', 'rename']).default('fail') }).parse(req.body);48 const u = userOf(req);49 return tx(async (t) => {50 await lockOwner(t, u.id);51 const parent = await getFolder(t, u.id, b.parentId);52 const f = await createFolder(t, u.id, parent.id, b.name, b.onConflict);53 await audit(t, actorOf(req), 'folder.created', { targetType: 'folder', targetId: f.id });54 return folderJson(f);55 });56 });57 app.patch('/api/v1/folders/:id', authed, async (req) => {58 const { id } = z.object({ id: Id }).parse(req.params);59 const b = z.object({ name: z.string().min(1).max(1000).optional(), starred: z.boolean().optional() }).parse(req.body);60 const u = userOf(req);61 return tx(async (t) => {62 await lockOwner(t, u.id);63 if (b.name !== undefined) await renameFolder(t, u.id, id, b.name);64 if (b.starred !== undefined) { await getFolder(t, u.id, id); await t`update folders set starred_at = ${b.starred ? new Date() : null} where id = ${id}`; }65 return folderJson(await getFolder(t, u.id, id));66 });67 });6869 app.get('/api/v1/files/:id', authed, async (req) => {70 const { id } = z.object({ id: Id }).parse(req.params);71 const u = userOf(req);72 const f = await getFile(sql, u.id, id, { trashed: true });73 const [versions, folder, shares, previews] = await Promise.all([74 sql<{ id: string; seq: number; size: string; sha256: string; created_at: Date; note: string }[]>`select id, seq, size::text, sha256, created_at, note from file_versions where file_id = ${id} order by seq desc`,75 sql<FolderRow[]>`select * from folders where id = ${f.folder_id}`,76 sql<{ n: number }[]>`select count(*)::int as n from shares where file_id = ${id} and revoked_at is null`,77 sql<{ kind: string; status: string; error: string | null }[]>`select kind, status, error from previews where object_id = ${f.object_id!}`,78 ]);79 return {80 file: fileJson(f), path: folder[0] ? await breadcrumbs(sql, folder[0]) : [], shares: shares[0]!.n,81 versions: versions.map((v) => ({ id: v.id, seq: v.seq, size: Number(v.size), sha256: v.sha256, createdAt: v.created_at, note: v.note, current: v.id === f.current_version_id })),82 previews: Object.fromEntries(previewPlan(f.name).map((k) => [k, previews.find((p) => p.kind === k)?.status ?? 'pending'])),83 };84 });85 app.patch('/api/v1/files/:id', authed, async (req) => {86 const { id } = z.object({ id: Id }).parse(req.params);87 const b = z.object({ name: z.string().min(1).max(1000).optional(), starred: z.boolean().optional(), tagIds: z.array(Id).max(50).optional() }).parse(req.body);88 const u = userOf(req);89 return tx(async (t) => {90 await lockOwner(t, u.id);91 await getFile(t, u.id, id);92 if (b.name !== undefined) await renameFile(t, u.id, id, b.name);93 if (b.starred !== undefined) await t`update files set starred_at = ${b.starred ? new Date() : null} where id = ${id}`;94 if (b.tagIds) {95 const own = await t<{ id: string }[]>`select id from tags where owner_id = ${u.id} and id = any(${b.tagIds})`;96 if (own.length !== new Set(b.tagIds).size) throw notFound('tag not found');97 await t`delete from file_tags where file_id = ${id}`;98 for (const tag of own) await t`insert into file_tags (file_id, tag_id) values (${id}, ${tag.id})`;99 }100 return fileJson(await getFile(t, u.id, id));101 });102 });103104 // --- content ---105 const Content = z.object({ v: Id.optional(), download: z.string().optional(), as: z.enum(['text']).optional(), infected: z.string().optional() });106 app.route({ method: 'GET', url: '/api/v1/files/:id/content', ...authed, handler: async (req, reply) => {107 const { id } = z.object({ id: Id }).parse(req.params);108 const q = Content.parse(req.query);109 const f = await getFile(sql, userOf(req).id, id, { trashed: true });110 const c = await contentOf(sql, f.id, q.v);111 // the owner may still take an infected file out, but only as an explicit download112 if (c.scan_status === 'infected' && !(q.download && q.infected === 'ack')) throw new UserError('infected', 'this file was flagged by the antivirus', 451);113 return sendSealed(req, reply, c, { name: f.name, etag: c.sha256, mode: q.download ? 'download' : q.as === 'text' ? 'text' : 'inline' });114 } });115 app.get('/api/v1/files/:id/preview/:kind', authed, async (req, reply) => {116 const { id, kind } = z.object({ id: Id, kind: z.string().max(20) }).parse(req.params);117 const q = z.object({ v: Id.optional() }).parse(req.query);118 const f = await getFile(sql, userOf(req).id, id, { trashed: true });119 const c = await contentOf(sql, f.id, q.v);120 if (c.scan_status === 'infected') throw new UserError('infected', 'this file was flagged by the antivirus', 451);121 return sendPreview(sql, req, reply, c.object_id, kind, f.name);122 });123 app.get('/api/v1/zip', authed, async (req, reply) => {124 const csv = z.string().max(80000).optional().transform((s) => (s ? s.split(',') : [])).pipe(z.array(Id).max(2000));125 const q = z.object({ files: csv, folders: csv }).parse(req.query);126 const u = userOf(req);127 const entries: ZipEntry[] = [];128 let zipName = 'sp-drive.zip';129 const push = async (fileId: string, path: string) => { const c = await contentOf(sql, fileId); if (c.scan_status !== 'infected') entries.push({ path, content: c }); };130 for (const id of q.folders) {131 const d = await getFolder(sql, u.id, id);132 if (q.folders.length === 1 && !q.files.length) zipName = `${d.name || 'sp-drive'}.zip`;133 for (const f of await subtreeFiles(sql, d.id)) await push(f.id, `${d.name}/${f.path}`);134 }135 for (const id of q.files) { const f = await getFile(sql, u.id, id); await push(f.id, f.name); }136 if (!entries.length) throw new UserError('empty_selection', 'nothing to download', 400);137 await audit(sql, actorOf(req), 'zip.downloaded', { meta: { files: entries.length } });138 return sendZip(reply, zipName, entries);139 });140141 // --- versions ---142 app.post('/api/v1/files/:id/versions/:versionId/restore', authed, async (req) => {143 const p = z.object({ id: Id, versionId: Id }).parse(req.params);144 const u = userOf(req);145 return tx(async (t) => {146 await lockOwner(t, u.id);147 await getFile(t, u.id, p.id);148 const [v] = await t<(ObjectRow & { seq: number })[]>`select o.*, v.seq from file_versions v join storage_objects o on o.id = v.object_id where v.id = ${p.versionId} and v.file_id = ${p.id}`;149 if (!v) throw notFound('version not found');150 const f = await addVersion(t, u.id, p.id, v, u.id, `restored from version ${v.seq}`);151 await audit(t, actorOf(req), 'file.version_restored', { targetType: 'file', targetId: p.id, meta: { from: v.seq } });152 return fileJson(f);153 });154 });155 app.delete('/api/v1/files/:id/versions/:versionId', authed, async (req) => {156 const p = z.object({ id: Id, versionId: Id }).parse(req.params);157 const u = userOf(req);158 await tx(async (t) => {159 await lockOwner(t, u.id);160 const f = await getFile(t, u.id, p.id);161 if (f.current_version_id === p.versionId) throw new UserError('current_version', 'the current version cannot be deleted', 409);162 const r = await t`delete from file_versions where id = ${p.versionId} and file_id = ${p.id} returning id`;163 if (!r.length) throw notFound('version not found');164 await t`insert into jobs (kind, payload, dedupe_key, run_at) values ('gc_objects', ${t.json({ ownerId: u.id })}, ${`gc:${u.id}`}, now() + interval '2 seconds')165 on conflict (dedupe_key) where status in ('queued', 'running') and dedupe_key is not null do nothing`;166 await audit(t, actorOf(req), 'file.version_deleted', { targetType: 'file', targetId: p.id });167 });168 return { ok: true };169 });170171 // --- structure changes ---172 app.post('/api/v1/move', authed, async (req) => {173 const b = Sel.and(z.object({ destinationId: z.union([Id, z.literal('root')]), onConflict: z.enum(['fail', 'rename']).default('fail') })).parse(req.body);174 const u = userOf(req);175 await tx(async (t) => { await lockOwner(t, u.id); await moveItems(t, u.id, b, b.destinationId, b.onConflict); await audit(t, actorOf(req), 'items.moved', { meta: { files: b.fileIds.length, folders: b.folderIds.length } }); });176 return { ok: true };177 });178 app.post('/api/v1/copy', authed, async (req) => {179 const b = Sel.and(z.object({ destinationId: z.union([Id, z.literal('root')]) })).parse(req.body);180 const u = userOf(req);181 return tx(async (t) => { await lockOwner(t, u.id); const n = await copyItems(t, u.id, b, b.destinationId); await audit(t, actorOf(req), 'items.copied', { meta: n }); return n; });182 });183 app.post('/api/v1/trash', authed, async (req) => {184 const b = Sel.parse(req.body);185 const u = userOf(req);186 await tx(async (t) => { await lockOwner(t, u.id); await trashItems(t, u.id, b); await audit(t, actorOf(req), 'items.trashed', { meta: { files: b.fileIds.length, folders: b.folderIds.length } }); });187 return { ok: true };188 });189 app.post('/api/v1/restore', authed, async (req) => {190 const b = Sel.parse(req.body);191 const u = userOf(req);192 await tx(async (t) => { await lockOwner(t, u.id); await restoreItems(t, u.id, b); await audit(t, actorOf(req), 'items.restored', { meta: { files: b.fileIds.length, folders: b.folderIds.length } }); });193 return { ok: true };194 });195 app.post('/api/v1/purge', authed, async (req) => {196 const b = z.union([z.object({ all: z.literal(true) }), Sel]).parse(req.body);197 const u = userOf(req);198 await tx(async (t) => { await lockOwner(t, u.id); await purgeItems(t, u.id, 'all' in b ? 'all' : b); await audit(t, actorOf(req), 'items.purged', { meta: 'all' in b ? { all: true } : { files: b.fileIds.length, folders: b.folderIds.length } }); });199 return { ok: true };200 });201202 // --- views ---203 app.get('/api/v1/trash', authed, async (req) => {204 const u = userOf(req);205 const [folders, files] = await Promise.all([206 sql<FolderRow[]>`select * from folders where owner_id = ${u.id} and trash_root = id order by trashed_at desc limit 2000`,207 sql<FileRow[]>`select ${fileSelect(sql)} from files f ${fileJoin(sql)} where f.owner_id = ${u.id} and f.trash_root = f.id order by f.trashed_at desc limit 2000`,208 ]);209 return { folders: folders.map(folderJson), files: files.map(fileJson) };210 });211 app.get('/api/v1/recent', authed, async (req) => {212 const files = await sql<FileRow[]>`select ${fileSelect(sql)} from files f ${fileJoin(sql)} where f.owner_id = ${userOf(req).id} and f.trashed_at is null order by f.updated_at desc limit 100`;213 return { folders: [], files: files.map(fileJson) };214 });215 app.get('/api/v1/starred', authed, async (req) => {216 const u = userOf(req);217 const [folders, files] = await Promise.all([218 sql<FolderRow[]>`select * from folders where owner_id = ${u.id} and trashed_at is null and starred_at is not null order by name_key limit 2000`,219 sql<FileRow[]>`select ${fileSelect(sql)} from files f ${fileJoin(sql)} where f.owner_id = ${u.id} and f.trashed_at is null and f.starred_at is not null order by f.name_key limit 2000`,220 ]);221 return { folders: folders.map(folderJson), files: files.map(fileJson) };222 });223 /** Name search with filters. Accent- and case-insensitive; scoped to the owner's live items. */224 app.get('/api/v1/search', authed, async (req) => {225 const q = z.object({226 q: z.string().trim().max(200).default(''), kind: z.enum(['any', 'folder', 'image', 'pdf', 'document', 'sheet', 'video', 'audio', 'archive', 'text']).default('any'),227 tag: Id.optional(), starred: z.enum(['1']).optional(), from: z.iso.date().optional(), to: z.iso.date().optional(), minSize: z.coerce.number().int().min(0).optional(), maxSize: z.coerce.number().int().min(0).optional(),228 }).and(Sort).parse(req.query);229 const u = userOf(req);230 const pattern = `%${nameKey(q.q).replace(/[\\%_]/g, (c) => `\\${c}`)}%`;231 const EXT: Record<string, string[]> = {232 image: ['jpg', 'jpeg', 'png', 'webp', 'gif', 'heic', 'heif', 'svg'], pdf: ['pdf'], document: ['doc', 'docx', 'odt', 'ppt', 'pptx', 'odp', 'rtf', 'pages', 'key'], sheet: ['xlsx', 'xls', 'ods', 'csv', 'numbers'],233 video: ['mp4', 'm4v', 'mov', 'webm', 'mkv', 'avi'], audio: ['mp3', 'm4a', 'aac', 'wav', 'ogg', 'flac'], archive: ['zip', 'tar', 'gz', 'tgz', '7z', 'rar'], text: ['txt', 'md', 'json', 'xml', 'log', 'yaml', 'yml', 'tex'],234 };235 const exts = EXT[q.kind];236 const fileOnly = q.kind !== 'any' && q.kind !== 'folder' || q.tag || q.minSize !== undefined || q.maxSize !== undefined;237 const match = (t: Db, a: string) => (q.q ? t`and unaccent(${t(a)}.name_key) like unaccent(${pattern})` : t``);238 const dates = (t: Db, a: string) => t`${q.from ? t`and ${t(a)}.updated_at >= ${q.from}::date` : t``} ${q.to ? t`and ${t(a)}.updated_at < ${q.to}::date + 1` : t``}`;239 const [folders, files] = await Promise.all([240 fileOnly ? [] : sql<FolderRow[]>`select d.* from folders d where d.owner_id = ${u.id} and d.trashed_at is null and d.parent_id is not null ${match(sql, 'd')} ${dates(sql, 'd')}241 ${q.starred ? sql`and d.starred_at is not null` : sql``} order by ${order(sql, q, 'd', false)} limit 300`,242 q.kind === 'folder' ? [] : sql<FileRow[]>`select ${fileSelect(sql)} from files f ${fileJoin(sql)} where f.owner_id = ${u.id} and f.trashed_at is null ${match(sql, 'f')} ${dates(sql, 'f')}243 ${exts ? sql`and lower(substring(f.name from '\\.([^.]+)$')) = any(${exts})` : sql``}244 ${q.tag ? sql`and exists (select 1 from file_tags ft join tags tg on tg.id = ft.tag_id where ft.file_id = f.id and tg.id = ${q.tag} and tg.owner_id = ${u.id})` : sql``}245 ${q.starred ? sql`and f.starred_at is not null` : sql``} ${q.minSize !== undefined ? sql`and f.size >= ${q.minSize}` : sql``} ${q.maxSize !== undefined ? sql`and f.size <= ${q.maxSize}` : sql``}246 order by ${order(sql, q, 'f', true)} limit 500`,247 ]);248 return { folders: folders.map(folderJson), files: files.map(fileJson) };249 });250 /** Groups of live files with identical content, within this account only. */251 app.get('/api/v1/duplicates', authed, async (req) => {252 const u = userOf(req);253 const files = await sql<FileRow[]>`select ${fileSelect(sql)} from files f ${fileJoin(sql)} where f.owner_id = ${u.id} and f.trashed_at is null and f.size > 0254 and (f.sha256, f.size) in (select sha256, size from files where owner_id = ${u.id} and trashed_at is null and size > 0 group by sha256, size having count(*) > 1)255 order by f.size desc, f.sha256, f.created_at limit 2000`;256 const groups = new Map<string, ReturnType<typeof fileJson>[]>();257 for (const f of files) { const k = `${f.sha256}:${f.size}`; groups.set(k, [...(groups.get(k) ?? []), fileJson(f)]); }258 return { groups: [...groups.values()].map((g) => ({ sha256: g[0]!.sha256, size: g[0]!.size, files: g })) };259 });260261 // --- tags ---262 app.get('/api/v1/tags', authed, async (req) =>263 (await sql<{ id: string; name: string; color: string; n: number }[]>`select t.id, t.name, t.color, (select count(*)::int from file_tags ft join files f on f.id = ft.file_id where ft.tag_id = t.id and f.trashed_at is null) as n264 from tags t where t.owner_id = ${userOf(req).id} order by t.name_key`).map((t) => ({ id: t.id, name: t.name, color: t.color, files: t.n })));265 const TagBody = z.object({ name: z.string().trim().min(1).max(40), color: z.enum(['slate', 'red', 'orange', 'amber', 'green', 'teal', 'blue', 'violet', 'pink']).default('slate') });266 app.post('/api/v1/tags', authed, async (req) => {267 const b = TagBody.parse(req.body);268 const name = sanitizeName(b.name);269 const [t] = await sql<{ id: string }[]>`insert into tags (owner_id, name, name_key, color) values (${userOf(req).id}, ${name}, ${nameKey(name)}, ${b.color}) on conflict (owner_id, name_key) do nothing returning id`;270 if (!t) throw new UserError('name_conflict', 'a tag with this name already exists', 409, { name });271 return { id: t.id, name, color: b.color, files: 0 };272 });273 app.patch('/api/v1/tags/:id', authed, async (req) => {274 const { id } = z.object({ id: Id }).parse(req.params);275 const b = TagBody.partial().parse(req.body);276 const name = b.name ? sanitizeName(b.name) : null;277 const r = await sql`update tags set name = coalesce(${name}, name), name_key = coalesce(${name ? nameKey(name) : null}, name_key), color = coalesce(${b.color ?? null}, color)278 where id = ${id} and owner_id = ${userOf(req).id} returning id`.catch((e: { code?: string }) => { if (e.code === '23505') throw new UserError('name_conflict', 'a tag with this name already exists', 409); throw e; });279 if (!r.length) throw notFound('tag not found');280 return { ok: true };281 });282 app.delete('/api/v1/tags/:id', authed, async (req) => {283 const { id } = z.object({ id: Id }).parse(req.params);284 const r = await sql`delete from tags where id = ${id} and owner_id = ${userOf(req).id} returning id`;285 if (!r.length) throw notFound('tag not found');286 return { ok: true };287 });288289 // --- notifications ---290 app.get('/api/v1/notifications', authed, async (req) =>291 (await sql<{ id: string; kind: string; data: unknown; read_at: Date | null; created_at: Date }[]>`select id, kind, data, read_at, created_at from notifications where user_id = ${userOf(req).id} order by created_at desc limit 100`)292 .map((n) => ({ id: n.id, kind: n.kind, data: n.data, read: !!n.read_at, createdAt: n.created_at })));293 app.post('/api/v1/notifications/read', authed, async (req) => {294 const b = z.object({ ids: z.array(Id).max(200).optional() }).parse(req.body ?? {});295 await sql`update notifications set read_at = now() where user_id = ${userOf(req).id} and read_at is null ${b.ids ? sql`and id = any(${b.ids})` : sql``}`;296 return { ok: true };297 });298}299