SPB Git

Repository Public

files.ts 20.7 KB 299 lines typescript

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
Files main
Menu

Theme