set-link-transfer
Version:
Create JWT-protected download links for text or files, with zip & TTL support
204 lines (203 loc) • 8.85 kB
JavaScript
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;
}