UNPKG

hfs

Version:
186 lines (185 loc) 9.05 kB
"use strict"; // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.forceDownload = forceDownload; exports.disposition = disposition; exports.serveFileNode = serveFileNode; exports.serveFile = serveFile; exports.monitorAsDownload = monitorAsDownload; exports.applyRange = applyRange; const fs_1 = require("fs"); const const_1 = require("./const"); const vfs_1 = require("./vfs"); const mime_types_1 = __importDefault(require("mime-types")); const config_1 = require("./config"); const misc_1 = require("./misc"); const lodash_1 = __importDefault(require("lodash")); const path_1 = require("path"); const util_1 = require("util"); const connections_1 = require("./connections"); const auth_1 = require("./auth"); const errorPages_1 = require("./errorPages"); const stream_1 = require("stream"); const crypto_1 = require("crypto"); const iconv_lite_1 = __importDefault(require("iconv-lite")); const allowedReferer = (0, config_1.defineConfig)('allowed_referer', ''); const maxDownloads = downloadLimiter((0, config_1.defineConfig)(misc_1.CFG.max_downloads, 0), () => true); const maxDownloadsPerIp = downloadLimiter((0, config_1.defineConfig)(misc_1.CFG.max_downloads_per_ip, 0), ctx => ctx.ip); const maxDownloadsPerAccount = downloadLimiter((0, config_1.defineConfig)(misc_1.CFG.max_downloads_per_account, 0), ctx => (0, auth_1.getCurrentUsername)(ctx) || undefined); function toAsciiEquivalent(s) { return iconv_lite_1.default.encode(iconv_lite_1.default.decode(Buffer.from(s), 'utf-8'), 'ascii').toString().replaceAll('?', ''); } function forceDownload(ctx, name) { disposition(ctx, name, true); } function disposition(ctx, name, forceDownload = false) { // ctx.attachment is not working well on Windows. Eg: for file "èÖ.txt" it is producing `Content-Disposition: attachment; filename="??.txt"`. Koa uses module content-disposition, that actually produces a better result anyway: `` ctx.set('Content-Disposition', (forceDownload ? 'attachment; ' : '') + `filename="${toAsciiEquivalent(name)}"; filename*=UTF-8''${encodeURI(name).replace(/#/g, '%23')}`); } async function serveFileNode(ctx, node) { var _a, _b; const { source, mime } = node; const name = (0, vfs_1.getNodeName)(node); const mimeString = typeof mime === 'string' ? mime : lodash_1.default.find(mime, (val, mask) => (0, misc_1.matches)(name, mask)); if (allowedReferer.get()) { const ref = (_a = /\/\/([^:/]+)/.exec(ctx.get('referer'))) === null || _a === void 0 ? void 0 : _a[1]; // extract host from url if (ref && ref !== (0, misc_1.normalizeHost)(ctx.host) // automatically accept if referer is basically the hosting domain && !(0, misc_1.matches)(ref, allowedReferer.get())) return ctx.status = const_1.HTTP_FORBIDDEN; } ctx.vfsNode = // legacy pre-0.51 (download-quota) ctx.state.vfsNode = node; // useful to tell service files from files shared by the user const download = 'dl' in ctx.query; disposition(ctx, name, download); if (!download && ((_b = ctx.get('referer')) === null || _b === void 0 ? void 0 : _b.endsWith('/')) && (0, misc_1.with_)(ctx.get('accept'), x => x && !x.includes('text'))) ctx.state.considerAsGui = true; await serveFile(ctx, source || '', mimeString); if (await maxDownloadsPerAccount(ctx) === undefined) // returning false will not execute other limits await maxDownloads(ctx) || await maxDownloadsPerIp(ctx); } const mimeCfg = (0, config_1.defineConfig)('mime', {}, obj => { const matchers = Object.keys(obj).map(k => (0, misc_1.makeMatcher)(k)); const values = Object.values(obj); return (name) => values[matchers.findIndex(matcher => matcher(name))]; }); // after this number of seconds, the browser should check the server to see if there's a newer version of the file const cacheControlDiskFiles = (0, config_1.defineConfig)('cache_control_disk_files', 5); async function serveFile(ctx, source, mime, content) { if (!source) return; mime !== null && mime !== void 0 ? mime : (mime = mimeCfg.compiled()((0, path_1.basename)(source))); if (mime === undefined || mime === const_1.MIME_AUTO) mime = mime_types_1.default.lookup(source) || ''; if (mime) ctx.type = mime; if (ctx.method === 'OPTIONS') { ctx.status = const_1.HTTP_NO_CONTENT; ctx.set({ Allow: 'OPTIONS, GET, HEAD' }); return; } if (ctx.method !== 'GET') return ctx.status = const_1.HTTP_METHOD_NOT_ALLOWED; try { const stats = await (0, util_1.promisify)(fs_1.stat)(source); // using fs's function instead of fs/promises, because only the former is supported by pkg if (!stats.isFile()) return ctx.status = const_1.HTTP_METHOD_NOT_ALLOWED; const t = stats.mtime.toUTCString(); ctx.set('Last-Modified', t); ctx.set('Etag', (0, crypto_1.createHash)('sha256').update(source).update(t).digest('hex')); ctx.state.fileSource = source; ctx.state.fileStats = stats; ctx.status = const_1.HTTP_OK; if (ctx.fresh) return ctx.status = const_1.HTTP_NOT_MODIFIED; if (content !== undefined) return ctx.body = content; const cc = cacheControlDiskFiles.get(); if (lodash_1.default.isNumber(cc)) ctx.set('Cache-Control', `max-age=${cc}`); const { size } = stats; const range = applyRange(ctx, size); ctx.body = (0, fs_1.createReadStream)(source, range); if (ctx.state.vfsNode) monitorAsDownload(ctx, size, range === null || range === void 0 ? void 0 : range.start); } catch (e) { return ctx.status = const_1.HTTP_NOT_FOUND; } } function monitorAsDownload(ctx, size, offset) { if (!(ctx.body instanceof stream_1.Readable)) throw 'incompatible body'; const conn = (0, connections_1.getConnection)(ctx); ctx.body.on('end', () => (0, connections_1.updateConnection)(conn, {}, { opProgress: 1 })); (0, connections_1.updateConnection)(conn, {}, { opProgress: 0, opTotal: size, opOffset: size && offset && (offset / size), }); } function applyRange(ctx, totalSize = ctx.response.length) { ctx.set('Accept-Ranges', 'bytes'); const { range } = ctx.request.header; if (!range || isNaN(totalSize)) { ctx.state.includesLastByte = true; if (!isNaN(totalSize)) ctx.response.length = totalSize; return; } const [unit, ranges] = range.split('='); if (unit !== 'bytes') return ctx.throw(const_1.HTTP_BAD_REQUEST, 'bad range unit'); if (ranges === null || ranges === void 0 ? void 0 : ranges.includes(',')) return ctx.throw(const_1.HTTP_BAD_REQUEST, 'multi-range not supported'); let bytes = ranges === null || ranges === void 0 ? void 0 : ranges.split('-'); if (!(bytes === null || bytes === void 0 ? void 0 : bytes.length)) return ctx.throw(const_1.HTTP_BAD_REQUEST, 'bad range'); const max = totalSize - 1; const start = bytes[0] ? Number(bytes[0]) : Math.max(0, totalSize - Number(bytes[1])); // a negative start is relative to the end const end = (bytes[0] && bytes[1]) ? Math.min(max, Number(bytes[1])) : max; // we don't support last-bytes without knowing max if (isNaN(end) && isNaN(max) || end > max || start > max) { ctx.status = const_1.HTTP_RANGE_NOT_SATISFIABLE; ctx.set('Content-Range', `bytes ${totalSize}`); ctx.body = 'Requested Range Not Satisfiable'; return; } ctx.state.includesLastByte = end === max; ctx.status = const_1.HTTP_PARTIAL_CONTENT; ctx.set('Content-Range', `bytes ${start}-${isNaN(end) ? '' : end}/${isNaN(totalSize) ? '*' : totalSize}`); ctx.response.length = end - start + 1; return { start, end }; } function downloadLimiter(configMax, cbKey) { const map = new Map(); return (ctx) => { if (!ctx.body || ctx.state.considerAsGui) return; // !body = no file sent, cache hit const k = cbKey(ctx); if (k === undefined) return; // undefined = skip limit const max = configMax.get(); const now = map.get(k) || 0; if (max && now >= max) return tooMany(); map.set(k, now + 1); ctx.req.on('close', () => { const n = map.get(k); if (n > 1) map.set(k, n - 1); else map.delete(k); }); return false; // limit is enforced but passed async function tooMany() { ctx.set('retry-after', '60'); await (0, errorPages_1.sendErrorPage)(ctx, const_1.HTTP_TOO_MANY_REQUESTS); return true; } }; }