hfs
Version:
HTTP File Server
186 lines (185 loc) • 9.05 kB
JavaScript
// 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;
}
};
}
;