"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);
const GUI_ASSET_MIME = /^(image\/|audio\/|video\/|font\/|text\/css$|(?:text|application)\/(?:javascript|ecmascript)$|application\/x-javascript$)/i;
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 the file "èÖ.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''${encodeURIComponent(name)}`);
}
async function serveFileNode(ctx, node) {
    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 = (0, misc_1.try_)(() => new URL(ctx.get('referer') || '').host);
        if (ref && ref !== ctx.host // automatically accept if the referer is basically the hosting domain
            && !(0, misc_1.matches)(ref, allowedReferer.get()))
            return ctx.status = const_1.HTTP_FORBIDDEN;
    }
    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);
    const fetchDest = ctx.get('sec-fetch-dest');
    ctx.state.considerAsGui ??= !download && ctx.get('referer')?.endsWith('/')
        && (fetchDest ? fetchDest !== 'document' && fetchDest !== 'empty' // modern clients
            // legacy clients often send Accept: */* for archive downloads, so the served mime is a safer signal than request headers here
            : GUI_ASSET_MIME.test(mimeString || mime_types_1.default.lookup(source || '') || ''));
    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 ??= 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);
        if (ctx.status >= 400)
            return; // applyRange may have set an error
        ctx.body = (0, fs_1.createReadStream)(source, range || undefined);
        if (ctx.state.vfsNode)
            monitorAsDownload(ctx, size, range?.start);
    }
    catch (e) {
        const status = {
            ENOENT: const_1.HTTP_NOT_FOUND,
            ENOTDIR: const_1.HTTP_NOT_FOUND,
            EACCES: const_1.HTTP_FORBIDDEN,
            EPERM: const_1.HTTP_FORBIDDEN,
        }[String(e?.code)];
        if (!status)
            throw e;
        ctx.status = status;
    }
}
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 badRequest('bad range unit');
    if (ranges?.includes(','))
        return badRequest('multi-range not supported');
    const bytes = ranges?.split('-');
    if (bytes?.length !== 2)
        return badRequest('bad range');
    const max = totalSize - 1;
    const [startTxt, endTxt] = bytes;
    const start = startTxt ? Number(startTxt) : Math.max(0, totalSize - Number(endTxt)); // a negative start is relative to the end
    const end = (startTxt && endTxt) ? Math.min(max, Number(endTxt)) : max;
    if (isNaN(start) || startTxt && endTxt && isNaN(end))
        return badRequest('bad range');
    // we don't support last-bytes without knowing max
    if (isNaN(end) && isNaN(max) || end > max || start > max || start > end) {
        ctx.set('Content-Range', `bytes */${totalSize}`);
        return badRequest('Requested Range Not Satisfiable', const_1.HTTP_RANGE_NOT_SATISFIABLE);
    }
    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 badRequest(message, status = const_1.HTTP_BAD_REQUEST) {
        ctx.status = status;
        ctx.body = message;
    }
}
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) {
            ctx.set('retry-after', '60');
            return (0, errorPages_1.sendErrorPage)(ctx, const_1.HTTP_TOO_MANY_REQUESTS)
                .then(() => true); // true = limit exceeded
        }
        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
    };
}
