Repository Public
1// Share links. The owner manages them; anonymous visitors use them through /api/v1/public/shares/<token>/….2// The token is shown once and stored hashed. Every public request re-reads the share: revocation is immediate.3import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';4import { extOf, INLINE_MIME, previewKind } from '@spd/shared';5import { z } from 'zod';6import { actorOf, requireUser, userOf } from './auth.ts';7import { masterKey } from './blob.ts';8import { config, notFound, secureCookies, UserError } from './config.ts';9import { contentOf, previewPlan, sendPreview, sendSealed, sendZip, type ZipEntry } from './content.ts';10import { hashPassword, hmacHex, newToken, safeEqual, TOKEN_RE, tokenHash, verifyPassword } from './crypto.ts';11import { audit, type Db, sql } from './db.ts';12import { type FileRow, fileJoin, fileJson, fileSelect, type FolderRow, getFile, getFolder, isWithin, subtreeFiles } from './tree.ts';1314interface ShareRow {15 id: string; owner_id: string; file_id: string | null; folder_id: string | null; token_hash: string; label: string; can_download: boolean; password_hash: string | null;16 expires_at: Date | null; max_views: number | null; view_count: number; max_downloads: number | null; download_count: number; single_use: boolean; revoked_at: Date | null; created_at: Date; last_access_at: Date | null;17 target_name?: string | null; target_live?: boolean;18}19const status = (s: ShareRow): 'active' | 'revoked' | 'expired' | 'exhausted' | 'target_missing' =>20 s.revoked_at ? 'revoked' : s.expires_at && s.expires_at < new Date() ? 'expired'21 : (s.max_views !== null && s.view_count >= s.max_views) || (s.single_use && s.view_count > 0) || (s.max_downloads !== null && s.download_count >= s.max_downloads) ? 'exhausted'22 : s.target_live === false ? 'target_missing' : 'active';23const shareJson = (s: ShareRow) => ({24 id: s.id, type: s.file_id ? 'file' as const : 'folder' as const, targetId: s.file_id ?? s.folder_id, targetName: s.target_name ?? '', label: s.label, canDownload: s.can_download, hasPassword: !!s.password_hash,25 expiresAt: s.expires_at, maxViews: s.max_views, viewCount: s.view_count, maxDownloads: s.max_downloads, downloadCount: s.download_count, singleUse: s.single_use,26 revokedAt: s.revoked_at, createdAt: s.created_at, lastAccessAt: s.last_access_at, status: status(s),27});28const ownSelect = (t: Db) => t`s.*, coalesce(f.name, d.name) as target_name, coalesce(f.trashed_at is null, d.trashed_at is null, false) as target_live29 from shares s left join files f on f.id = s.file_id left join folders d on d.id = s.folder_id`;3031const Settings = z.object({32 label: z.string().trim().max(120).optional(), canDownload: z.boolean().optional(), password: z.string().min(4).max(200).nullable().optional(),33 expiresAt: z.iso.datetime({ offset: true }).nullable().optional(), maxViews: z.number().int().min(1).max(1_000_000).nullable().optional(),34 maxDownloads: z.number().int().min(1).max(1_000_000).nullable().optional(), singleUse: z.boolean().optional(),35});36const Id = z.uuid();3738export function shareRoutes(app: FastifyInstance): void {39 const authed = { preHandler: requireUser };4041 app.get('/api/v1/shares', authed, async (req) => {42 const q = z.object({ fileId: Id.optional(), folderId: Id.optional() }).parse(req.query);43 const rows = await sql<ShareRow[]>`select ${ownSelect(sql)} where s.owner_id = ${userOf(req).id}44 ${q.fileId ? sql`and s.file_id = ${q.fileId}` : sql``} ${q.folderId ? sql`and s.folder_id = ${q.folderId}` : sql``} order by s.created_at desc limit 1000`;45 return rows.map(shareJson);46 });47 app.post('/api/v1/shares', authed, async (req) => {48 const b = Settings.and(z.object({ fileId: Id.optional(), folderId: Id.optional() })).parse(req.body);49 if (!b.fileId === !b.folderId) throw new UserError('bad_target', 'choose one file or one folder', 400);50 const u = userOf(req);51 if (b.fileId) { const f = await getFile(sql, u.id, b.fileId); if (f.scan_status === 'infected') throw new UserError('infected', 'a file flagged by the antivirus cannot be shared', 409); }52 else { const d = await getFolder(sql, u.id, b.folderId!); if (!d.parent_id) throw new UserError('bad_target', 'the root folder cannot be shared', 400); }53 if (b.expiresAt && new Date(b.expiresAt) <= new Date()) throw new UserError('bad_expiry', 'the expiry must be in the future', 400);54 const token = newToken();55 const [s] = await sql<{ id: string }[]>`insert into shares (owner_id, file_id, folder_id, token_hash, label, can_download, password_hash, expires_at, max_views, max_downloads, single_use)56 values (${u.id}, ${b.fileId ?? null}, ${b.folderId ?? null}, ${tokenHash(token)}, ${b.label ?? ''}, ${b.canDownload ?? true}, ${b.password ? await hashPassword(b.password) : null},57 ${b.expiresAt ?? null}, ${b.maxViews ?? null}, ${b.maxDownloads ?? null}, ${b.singleUse ?? false}) returning id`;58 await audit(sql, actorOf(req), 'share.created', { targetType: 'share', targetId: s!.id, meta: { target: b.fileId ? 'file' : 'folder', password: !!b.password, expires: !!b.expiresAt } });59 const [row] = await sql<ShareRow[]>`select ${ownSelect(sql)} where s.id = ${s!.id}`;60 return { share: shareJson(row!), url: `${config.origin}/s/${token}` }; // the only time the link is available61 });62 app.patch('/api/v1/shares/:id', authed, async (req) => {63 const { id } = z.object({ id: Id }).parse(req.params);64 const b = Settings.parse(req.body);65 const u = userOf(req);66 const set = async <K extends keyof typeof b>(k: K, col: string, v: unknown) => { if (b[k] !== undefined) await sql`update shares set ${sql(col)} = ${v as never} where id = ${id} and owner_id = ${u.id}`; };67 const [own] = await sql`select 1 from shares where id = ${id} and owner_id = ${u.id}`;68 if (!own) throw notFound('share not found');69 await set('label', 'label', b.label); await set('canDownload', 'can_download', b.canDownload); await set('expiresAt', 'expires_at', b.expiresAt);70 await set('maxViews', 'max_views', b.maxViews); await set('maxDownloads', 'max_downloads', b.maxDownloads); await set('singleUse', 'single_use', b.singleUse);71 if (b.password !== undefined) await set('password', 'password_hash', b.password === null ? null : await hashPassword(b.password));72 await audit(sql, actorOf(req), 'share.updated', { targetType: 'share', targetId: id });73 const [row] = await sql<ShareRow[]>`select ${ownSelect(sql)} where s.id = ${id}`;74 return shareJson(row!);75 });76 /** Revocation is immediate: the next public request, including one in the middle of a browsing session, is refused. */77 app.delete('/api/v1/shares/:id', authed, async (req) => {78 const { id } = z.object({ id: Id }).parse(req.params);79 const q = z.object({ purge: z.string().optional() }).parse(req.query);80 const u = userOf(req);81 const r = q.purge ? await sql`delete from shares where id = ${id} and owner_id = ${u.id} returning id`82 : await sql`update shares set revoked_at = coalesce(revoked_at, now()) where id = ${id} and owner_id = ${u.id} returning id`;83 if (!r.length) throw notFound('share not found');84 await audit(sql, actorOf(req), q.purge ? 'share.deleted' : 'share.revoked', { targetType: 'share', targetId: id });85 return { ok: true };86 });87 /** New link for the same share; the old link stops working at once. */88 app.post('/api/v1/shares/:id/rotate', authed, async (req) => {89 const { id } = z.object({ id: Id }).parse(req.params);90 const token = newToken();91 const r = await sql`update shares set token_hash = ${tokenHash(token)} where id = ${id} and owner_id = ${userOf(req).id} and revoked_at is null returning id`;92 if (!r.length) throw notFound('share not found');93 await audit(sql, actorOf(req), 'share.rotated', { targetType: 'share', targetId: id });94 return { url: `${config.origin}/s/${token}` };95 });96 app.get('/api/v1/shares/:id/events', authed, async (req) => {97 const { id } = z.object({ id: Id }).parse(req.params);98 const [own] = await sql`select 1 from shares where id = ${id} and owner_id = ${userOf(req).id}`;99 if (!own) throw notFound('share not found');100 return (await sql<{ id: string; at: Date; kind: string; ok: boolean; ip: string; user_agent: string; detail: string }[]>`select id::text, at, kind, ok, ip, user_agent, detail from share_access_events where share_id = ${id} order by at desc limit 500`)101 .map((e) => ({ id: e.id, at: e.at, kind: e.kind, ok: e.ok, ip: e.ip, userAgent: e.user_agent, detail: e.detail }));102 });103104 // ============================ public side ============================105 const gone = () => new UserError('share_unavailable', 'this link is not available', 404);106 const event = (s: ShareRow, req: FastifyRequest, kind: string, ok: boolean, o: { fileId?: string; detail?: string } = {}) =>107 sql`insert into share_access_events (share_id, kind, ok, ip, user_agent, file_id, detail) values (${s.id}, ${kind}, ${ok}, ${req.ip}, ${String(req.headers['user-agent'] ?? '').slice(0, 300)}, ${o.fileId ?? null}, ${o.detail ?? ''})`;108 const grantName = (s: ShareRow) => `spd_g_${s.id.slice(0, 8)}`;109 const grantKey = () => Buffer.from(hmacHex(masterKey(), 'spd-share-grant-key-v1'), 'hex');110 const grantMac = (s: ShareRow, exp: number) => hmacHex(grantKey(), `share-grant-v1|${s.id}|${s.token_hash}|${s.password_hash ?? ''}|${exp}`);111112 /** The share behind a token, if it can still be used at all. Unknown, revoked, expired and orphaned links look the same. */113 async function liveShare(req: FastifyRequest): Promise<ShareRow> {114 const { token } = req.params as { token?: string };115 if (!token || !TOKEN_RE.test(token)) throw gone();116 const [s] = await sql<ShareRow[]>`select s.* from shares s join users u on u.id = s.owner_id and u.disabled_at is null where s.token_hash = ${tokenHash(token)}`;117 if (!s || s.revoked_at || (s.expires_at && s.expires_at < new Date())) throw gone();118 const [live] = s.file_id119 ? await sql`select 1 from files f join file_versions v on v.id = f.current_version_id join storage_objects o on o.id = v.object_id where f.id = ${s.file_id} and f.trashed_at is null and o.scan_status <> 'infected'`120 : await sql`select 1 from folders where id = ${s.folder_id} and trashed_at is null`;121 if (!live) throw gone();122 return s;123 }124 /** A visitor who opened the share (password given, view counted) holds a short-lived grant cookie. */125 async function granted(req: FastifyRequest): Promise<ShareRow> {126 const s = await liveShare(req);127 const [exp, mac] = String(req.cookies[grantName(s)] ?? '').split('.');128 if (!exp || !mac || Number(exp) < Date.now() || !safeEqual(mac, grantMac(s, Number(exp)))) throw new UserError('share_locked', 'open the link first', 401);129 return s;130 }131 /** A live file reachable through the share (the shared file itself, or a file below the shared folder). */132 async function sharedFile(s: ShareRow, fileId: string): Promise<FileRow> {133 const [f] = await sql<FileRow[]>`select ${fileSelect(sql)} from files f ${fileJoin(sql)} where f.id = ${fileId} and f.owner_id = ${s.owner_id} and f.trashed_at is null`;134 if (!f || f.scan_status === 'infected') throw notFound('file not found');135 if (s.file_id ? f.id !== s.file_id : !(await isWithin(sql, s.folder_id!, f.folder_id))) throw notFound('file not found');136 return f;137 }138 const publicFile = (f: FileRow) => { const j = fileJson(f); return { id: j.id, name: j.name, size: j.size, kind: j.kind, updatedAt: j.updatedAt, thumb: j.thumb, previews: previewPlan(j.name) }; };139 const limited = (max: number) => ({ config: { rateLimit: { max, timeWindow: '1 minute' } } });140141 app.get('/api/v1/public/shares/:token', limited(60), async (req) => {142 const s = await liveShare(req);143 return { needsPassword: !!s.password_hash };144 });145 app.post('/api/v1/public/shares/:token/open', limited(20), async (req, reply) => {146 const b = z.object({ password: z.string().max(200).optional() }).parse(req.body ?? {});147 const s = await liveShare(req);148 if (s.password_hash) {149 const [f] = await sql<{ n: number }[]>`select count(*)::int as n from share_access_events where share_id = ${s.id} and kind = 'password' and not ok and at > now() - interval '15 minutes'`;150 if (f!.n >= 20) throw new UserError('too_many_attempts', 'too many attempts, try again later', 429);151 if (!b.password) throw new UserError('password_required', 'this link is protected by a password', 401);152 if (!(await verifyPassword(b.password, s.password_hash))) { await event(s, req, 'password', false); throw new UserError('password_invalid', 'wrong password', 401); }153 }154 // counting the view and checking the limits is one atomic statement: two visitors cannot both take the last view155 const [counted] = await sql<ShareRow[]>`update shares set view_count = view_count + 1, last_access_at = now() where id = ${s.id} and revoked_at is null156 and (expires_at is null or expires_at > now()) and (max_views is null or view_count < max_views) and (not single_use or view_count = 0) returning *`;157 if (!counted) { await event(s, req, 'open', false, { detail: 'limit' }); throw gone(); }158 await event(s, req, 'open', true);159 const exp = Date.now() + (s.single_use ? 15 : 60) * 60_000;160 reply.setCookie(grantName(s), `${exp}.${grantMac(s, exp)}`, { httpOnly: true, secure: secureCookies, sameSite: 'strict', path: '/api/v1/public/shares/', expires: new Date(exp) });161 const [owner] = await sql<{ display_name: string }[]>`select display_name from users where id = ${s.owner_id}`;162 const base = { label: s.label, owner: owner!.display_name, canDownload: s.can_download, expiresAt: s.expires_at };163 if (s.file_id) return { ...base, type: 'file' as const, file: publicFile(await sharedFile(s, s.file_id)) };164 const [d] = await sql<FolderRow[]>`select * from folders where id = ${s.folder_id}`;165 return { ...base, type: 'folder' as const, root: { id: d!.id, name: d!.name } };166 });167 app.get('/api/v1/public/shares/:token/list', limited(120), async (req) => {168 const s = await granted(req);169 if (!s.folder_id) throw notFound();170 const q = z.object({ folder: Id.optional() }).parse(req.query);171 const folderId = q.folder ?? s.folder_id;172 const [d] = await sql<FolderRow[]>`select * from folders where id = ${folderId} and owner_id = ${s.owner_id} and trashed_at is null`;173 if (!d || !(await isWithin(sql, s.folder_id, d.id))) throw notFound('folder not found');174 const [folders, files, crumbs] = await Promise.all([175 sql<FolderRow[]>`select * from folders where parent_id = ${d.id} and trashed_at is null order by name_key limit 5000`,176 sql<FileRow[]>`select ${fileSelect(sql)} from files f ${fileJoin(sql)} where f.folder_id = ${d.id} and f.trashed_at is null and o.scan_status <> 'infected' order by f.name_key limit 5000`,177 // path from the shared folder down, never above it178 sql<{ id: string; name: string; depth: number }[]>`with recursive up as (select id, parent_id, name, 0 as depth from folders where id = ${d.id}179 union all select p.id, p.parent_id, p.name, up.depth + 1 from folders p join up on p.id = up.parent_id where up.id <> ${s.folder_id} and up.depth < 200) select id, name, depth from up order by depth desc`,180 ]);181 return { folder: { id: d.id, name: d.name }, breadcrumbs: crumbs.map((c) => ({ id: c.id, name: c.name })), folders: folders.map((x) => ({ id: x.id, name: x.name, updatedAt: x.updated_at })), files: files.map(publicFile) };182 });183 /** Count a download against the limit, atomically. */184 async function countDownload(s: ShareRow, req: FastifyRequest, fileId?: string): Promise<void> {185 const [ok] = await sql`update shares set download_count = download_count + 1, last_access_at = now() where id = ${s.id} and revoked_at is null and (max_downloads is null or download_count < max_downloads) returning id`;186 if (!ok) { await event(s, req, 'download', false, { fileId, detail: 'limit' }); throw new UserError('download_limit', 'the download limit of this link is reached', 403); }187 await event(s, req, 'download', true, { fileId });188 }189 app.route({ method: 'GET', url: '/api/v1/public/shares/:token/files/:fileId/content', ...limited(600), handler: async (req: FastifyRequest, reply: FastifyReply) => {190 const s = await granted(req);191 const { fileId } = z.object({ fileId: Id, token: z.string() }).parse(req.params);192 const q = z.object({ download: z.string().optional(), as: z.enum(['text']).optional() }).parse(req.query);193 const f = await sharedFile(s, fileId);194 const c = await contentOf(sql, f.id);195 if (q.download) {196 if (!s.can_download) throw new UserError('download_forbidden', 'downloading is not allowed for this link', 403);197 const firstBytes = !req.headers.range || /^bytes=0-/.test(req.headers.range);198 if (req.method === 'GET' && firstBytes) await countDownload(s, req, f.id);199 return sendSealed(req, reply, c, { name: f.name, etag: c.sha256, mode: 'download' });200 }201 // view-only: only what the viewer can display is ever sent202 const kind = previewKind(f.name);203 const viewable = q.as === 'text' ? ['text', 'markdown', 'csv', 'json'].includes(kind) : !!INLINE_MIME[extOf(f.name)];204 if (!viewable) throw new UserError('preview_unsupported', 'no preview for this format', 404);205 return sendSealed(req, reply, c, { name: f.name, etag: c.sha256, mode: q.as === 'text' ? 'text' : 'inline' });206 } });207 app.get('/api/v1/public/shares/:token/files/:fileId/preview/:kind', limited(600), async (req, reply) => {208 const s = await granted(req);209 const p = z.object({ fileId: Id, kind: z.string().max(20), token: z.string() }).parse(req.params);210 const f = await sharedFile(s, p.fileId);211 return sendPreview(sql, req, reply, f.object_id!, p.kind, f.name);212 });213 app.get('/api/v1/public/shares/:token/zip', limited(10), async (req, reply) => {214 const s = await granted(req);215 if (!s.folder_id) throw notFound();216 if (!s.can_download) throw new UserError('download_forbidden', 'downloading is not allowed for this link', 403);217 const q = z.object({ folder: Id.optional() }).parse(req.query);218 const [d] = await sql<FolderRow[]>`select * from folders where id = ${q.folder ?? s.folder_id} and owner_id = ${s.owner_id} and trashed_at is null`;219 if (!d || !(await isWithin(sql, s.folder_id, d.id))) throw notFound('folder not found');220 const entries: ZipEntry[] = [];221 for (const f of await subtreeFiles(sql, d.id)) { const c = await contentOf(sql, f.id); if (c.scan_status !== 'infected') entries.push({ path: f.path, content: c }); }222 if (!entries.length) throw new UserError('empty_selection', 'nothing to download', 400);223 await countDownload(s, req);224 return sendZip(reply, `${d.name}.zip`, entries);225 });226}227