UNPKG

set-link-transfer

Version:

Create JWT-protected download links for text or files, with zip & TTL support

204 lines (203 loc) 8.85 kB
import { Router } from 'express'; import jwt from 'jsonwebtoken'; import { randomUUID } from 'node:crypto'; import { createReadStream } from 'node:fs'; import { stat } from 'node:fs/promises'; import { normalize, isAbsolute, relative } from 'node:path'; import archiver from 'archiver'; import { MemoryRepo } from './MemoryRepo.js'; import pino from 'pino-http'; function assertSafePath(p) { const abs = normalize(p); if (!isAbsolute(abs)) throw Object.assign(new Error('Relative paths disallowed'), { code: 'EPATH' }); const rel = relative(process.cwd(), abs); if (rel.startsWith('..')) throw Object.assign(new Error('Path traversal detected'), { code: 'EPATH' }); return abs; } const downloading = new Map(); const Joi = (await import('joi')).default; const JWT_ALGOS = ['HS256', 'HS384', 'HS512', 'RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'PS256', 'PS384', 'PS512', 'EdDSA']; const optsSchema = Joi.object({ jwtSecret: Joi.string().min(32).required(), defaultTTL: Joi.number().integer().min(1).default(3600), uploadPath: Joi.string().default('/upload'), downloadPath: Joi.string().default('/download'), jwtAlgo: Joi.string().valid(...JWT_ALGOS.filter(a => a !== 'none')).default('HS256'), }); export function createTransferRouter(rawOpts) { const opts = Joi.attempt(rawOpts, optsSchema); const { jwtSecret, repo = new MemoryRepo(), defaultTTL, uploadPath, downloadPath, jwtAlgo } = opts; const router = Router(); router.use(pino()); const normalizePath = (p) => p.replace(/\/+$/, ''); const up = normalizePath(uploadPath); const down = normalizePath(downloadPath); const downloadUrl = (req, token) => { const proto = req.get('x-forwarded-proto') || req.protocol; const host = req.get('host'); return `${proto}://${host}${down}/${token}`; }; router.post(up, async (req, res, next) => { try { const { type, payload, ttl = defaultTTL, zip = false } = req.body; if (!['text', 'file'].includes(type) || !payload) { return res.status(400).json({ error: 'type must be "text" or "file", and payload is required' }); } if (typeof ttl !== 'number' || ttl <= 0) { return res.status(400).json({ error: 'TTL must be a positive number' }); } const ids = []; const responses = []; if (type === 'text') { const id = randomUUID(); repo.putText(id, payload, { ttl }); const token = jwt.sign({ id }, jwtSecret, { algorithm: jwtAlgo, expiresIn: ttl }); responses.push({ token, url: downloadUrl(req, token), ttl }); ids.push(id); } else { const files = Array.isArray(payload) ? payload : [payload]; for (const fpRaw of files) { let absolute; try { absolute = assertSafePath(fpRaw); } catch (e) { return res.status(400).json({ error: e.message }); } const s = await stat(absolute).catch(() => null); if (!s) return res.status(400).json({ error: `File not found: ${fpRaw}` }); const id = randomUUID(); await repo.putFile(id, absolute, { ttl, isDir: s.isDirectory() }); const token = jwt.sign({ id }, jwtSecret, { algorithm: jwtAlgo, expiresIn: ttl }); responses.push({ token, url: downloadUrl(req, token), ttl }); ids.push(id); } if (zip && files.length > 0) { const zipToken = jwt.sign({ ids }, jwtSecret, { algorithm: jwtAlgo, expiresIn: ttl }); return res.json({ token: zipToken, url: downloadUrl(req, zipToken), ttl, count: files.length }); } } return res.json(responses.length === 1 ? responses[0] : responses); } catch (e) { next(e); } }); router.get(`${down}/:token`, async (req, res, next) => { try { let decoded; try { decoded = jwt.verify(req.params.token, jwtSecret, { algorithms: [jwtAlgo] }); } catch (e) { if (e.name === 'TokenExpiredError') return res.status(410).send('Link is expired or invalid'); return res.status(401).send('Unauthorized'); } const { id, ids } = decoded; if (id && downloading.has(id)) return res.status(409).send('Download in progress'); if (ids && ids.some((i) => downloading.has(i))) return res.status(409).send('Download in progress'); if (ids && Array.isArray(ids)) { const promise = handleMultiZip(ids, req, res).finally(() => ids.forEach(i => downloading.delete(i))); ids.forEach(i => downloading.set(i, promise)); return await promise; } const promise = handleSingle(id, req, res, next).finally(() => downloading.delete(id)); downloading.set(id, promise); return await promise; } catch (e) { next(e); } }); async function handleMultiZip(ids, req, res) { const archive = archiver('zip', { zlib: { level: 9 } }); res.set('Content-Disposition', `attachment; filename="${ids[0]}.zip"`); archive.pipe(res); req.on('close', () => archive.destroy()); for (const itemId of ids) { const item = repo.get(itemId); if (!item) continue; const { kind, data, meta } = item; const name = itemId; try { if (kind === 'text') archive.append(data, { name }); else if (kind === 'file') { const s = await stat(data).catch(() => null); if (!s) { req.log.warn({ itemId }, 'file vanished during zip'); continue; } if (s.isFile()) archive.file(data, { name }); else if (s.isDirectory() && meta.isDir) archive.directory(data, name); } } catch (e) { req.log.warn({ itemId, err: e.message }, 'archive item error'); } } archive.on('error', err => { req.log.error({ err }, 'archive finalize error'); if (!res.headersSent) res.status(500).send('Archive creation failed'); archive.destroy(); }); await archive.finalize(); ids.forEach(i => repo.delete(i)); } async function handleSingle(id, req, res, next) { const item = repo.get(id); if (!item) return res.status(410).send('Link is expired or invalid'); const { kind, data, meta } = item; const filename = id; if (kind === 'text') { res.set('Content-Disposition', `attachment; filename="${filename}"`); res.type('application/octet-stream').send(data); res.on('finish', () => repo.delete(id)); return; } const s = await stat(data).catch(() => null); if (!s) { repo.delete(id); return res.status(422).send('File not found'); } if (s.isFile()) { res.set('Content-Disposition', `attachment; filename="${filename}"`); const stream = createReadStream(data); stream.on('error', err => { req.log.error({ err }, 'file stream error'); if (!res.headersSent) res.status(500).send('File read error'); }); stream.pipe(res); stream.on('close', () => repo.delete(id)); } else if (s.isDirectory() && meta.isDir) { const archive = archiver('zip', { zlib: { level: 9 } }); res.set('Content-Disposition', `attachment; filename="${filename}.zip"`); archive.pipe(res); archive.directory(data, false); archive.on('end', () => repo.delete(id)); archive.on('error', err => { req.log.error({ err }, 'dir zip error'); if (!res.headersSent) res.status(500).send('Archive creation failed'); archive.destroy(); }); await archive.finalize(); repo.delete(id); } } return router; }