"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.dontOverwriteUploading = exports.minAvailableMb = exports.deleteUnfinishedUploadsAfter = void 0;
exports.getUploadMeta = getUploadMeta;
exports.getUploadTempFor = getUploadTempFor;
exports.uploadWriter = uploadWriter;
const vfs_1 = require("./vfs");
const const_1 = require("./const");
const path_1 = require("path");
const fs_1 = __importDefault(require("fs"));
const misc_1 = require("./misc");
const config_1 = require("./config");
const util_os_1 = require("./util-os");
const connections_1 = require("./connections");
const throttler_1 = require("./throttler");
const auth_1 = require("./auth");
const comments_1 = require("./comments");
const lodash_1 = __importDefault(require("lodash"));
const events_1 = __importDefault(require("./events"));
const promises_1 = require("fs/promises");
const expiringCache_1 = require("./expiringCache");
const first_1 = require("./first");
exports.deleteUnfinishedUploadsAfter = (0, config_1.defineConfig)('delete_unfinished_uploads_after', 86_400);
exports.minAvailableMb = (0, config_1.defineConfig)('min_available_mb', 100);
exports.dontOverwriteUploading = (0, config_1.defineConfig)('dont_overwrite_uploading', true);
const waitingToBeDeleted = {};
(0, first_1.onProcessExit)(() => {
    if (!Object.keys(waitingToBeDeleted).length)
        return;
    console.log("Removing unfinished uploads");
    for (const path in waitingToBeDeleted)
        try {
            fs_1.default.rmSync(path, { force: true });
        }
        catch { }
});
const ATTR_UPLOADER = 'uploader';
function getUploadMeta(path) {
    return (0, misc_1.loadFileAttr)(path, ATTR_UPLOADER);
}
function setUploadMeta(path, ctx) {
    return (0, misc_1.storeFileAttr)(path, ATTR_UPLOADER, {
        username: (0, auth_1.getCurrentUsername)(ctx) || undefined,
        ip: ctx.ip,
    });
}
function getUploadTempFor(fullPath) {
    return (0, path_1.join)((0, path_1.dirname)(fullPath), 'hfs$upload-' + (0, path_1.basename)(fullPath).slice(-200));
}
const diskSpaceCache = (0, expiringCache_1.expiringCache)(3_000); // invalidate shortly
const uploadingFiles = new Map();
// initially sync for formidable; still sync to avoid async races and PUT piping gaps
function uploadWriter(base, baseUri, filename, ctx) {
    if (!filename || !(0, misc_1.isValidFileName)(filename) || !filename)
        return fail(const_1.HTTP_FOOL);
    if ((0, vfs_1.statusCodeForMissingPerm)(base, 'can_upload', ctx))
        return fail();
    const fullPath = (0, path_1.join)(base.source, filename);
    const already = uploadingFiles.get(fullPath); // this can be checked so early because this function is sync
    if (already) // if it's the same client, we tell to retry later
        return fail(const_1.HTTP_CONFLICT, ctx.query.id && ctx.query.id === already.ctx.query.id ? 'retry' : 'already uploading');
    const dir = (0, path_1.dirname)(fullPath);
    // enforce minAvailableMb
    const min = exports.minAvailableMb.get() * (1 << 20);
    const { simulate } = ctx.query; // `simulate` is used to get the same error but with an empty body, so that the request is processed quickly
    const contentLength = Number(simulate ?? ctx.headers["content-length"]
        ?? ctx.headers['x-expected-entity-length']); // some webdav clients send this; it's not equivalent to content-length, but can get the job done in some cases
    const isPartial = ctx.query.partial !== undefined; // while the presence of "partial" conveys that the upload is split...
    const stillToWrite = Math.max(contentLength, Number(ctx.query.partial) || 0); // ...the number is used to tell how much space we need (fullSize - offset)
    if (isNaN(stillToWrite)) {
        if (min)
            return fail(const_1.HTTP_LENGTH_REQUIRED);
    }
    else
        try {
            // refer to the source of the closest node that actually belongs to the vfs, so that cache is more effective
            let closestVfsNode = base; // if base=root, there's no parent and no original
            while (closestVfsNode?.parent && !closestVfsNode.original)
                closestVfsNode = closestVfsNode.parent; // if it's not original, it surely has a parent
            const dirToCheck = closestVfsNode.source;
            const res = diskSpaceCache.try(dirToCheck, () => (0, util_os_1.getDiskSpaceSync)(dirToCheck));
            if (!res)
                throw 'miss';
            const { free } = res;
            if (typeof free !== 'number' || isNaN(free))
                throw JSON.stringify(res);
            const reservedSpace = lodash_1.default.sumBy(Array.from(uploadingFiles.values()), x => x.size - x.got);
            if (stillToWrite > free - (min || 0) - reservedSpace)
                return fail(const_1.HTTP_INSUFFICIENT_STORAGE);
        }
        catch (e) { // warn, but let it through
            console.warn("Can't check disk size:", e.message || String(e));
        }
    // optionally 'skip'
    if (ctx.query.existing === 'skip' && fs_1.default.existsSync(fullPath))
        return fail(const_1.HTTP_CONFLICT, 'exists');
    let overwriteRequestedButForbidden = false;
    const mtime = Number(ctx.query.mtime) || 0;
    try {
        // if upload creates a folder, then add meta to it too
        if (!dir.endsWith(':\\') && fs_1.default.mkdirSync(dir, { recursive: true }))
            setUploadMeta(dir, ctx);
        // use temporary name while uploading
        const tempName = getUploadTempFor(fullPath);
        // try to catch errors early (sync): this is avoiding on chrome139 when uploading a big file (1GB) to get miss the error code and have to make a `simulate`
        try {
            fs_1.default.accessSync(tempName, fs_1.default.constants.W_OK);
        }
        catch {
            fs_1.default.closeSync(fs_1.default.openSync(tempName, 'w'));
            fs_1.default.unlinkSync(tempName);
        }
        const stats = (0, misc_1.try_)(() => fs_1.default.statSync(tempName));
        const resumableSize = stats?.size || 0;
        // checks for resume feature
        const par = String(ctx.query.resume || '');
        const resume = parseInt(par) || 0;
        const strictResume = par.at(-1) === '!';
        if (resume > resumableSize || resume < 0)
            return fail(const_1.HTTP_RANGE_NOT_SATISFIABLE);
        const resumeInfo = resumableSize && waitingToBeDeleted[tempName];
        if (strictResume) // frontend asked to be notified about resumable uploads
            if (resumableSize > resume && (!resumeInfo || resumeInfo.mtime === mtime)) {
                ctx.set('x-size', String(resumableSize));
                if (!resumeInfo) // if unavailable, the client can request hashing
                    ctx.set(const_1.MTIME_CHECK, 'not-available');
                return fail(const_1.HTTP_PRECONDITION_FAILED);
            }
        // append if resuming
        if (!resume && stats)
            fs_1.default.unlinkSync(tempName);
        const writeStream = (0, misc_1.createStreamLimiter)(contentLength ?? Infinity);
        const fullSize = stillToWrite + resume;
        // allow plugins to mess with the write-stream, because the read-stream can be complicated in case of multipart
        const obj = { ctx, writeStream, fullPath, tempName, resume, fullSize, uri: '' };
        const resEvent = events_1.default.emit('uploadStart', obj);
        ctx.state.uploadDestinationPath = tempName;
        if (resEvent?.isDefaultPrevented())
            return;
        const fileStream = fs_1.default.createWriteStream(tempName, resume ? { flags: 'r+', start: resume } : undefined);
        writeStream.on('error', e => {
            releaseFile();
            console.debug(e);
        });
        writeStream.pipe(fileStream);
        Object.assign(obj, { fileStream });
        trackProgress();
        cancelDeletion(tempName);
        const tracked = { ctx, got: 0, size: stillToWrite };
        uploadingFiles.set(fullPath, tracked);
        console.debug('Upload started');
        // the file stream doesn't have an event for data being written, so we use 'data' of its feeder, which happens before, so we postpone a bit, trying to have a fresher number
        writeStream.on('data', () => setTimeout(() => tracked.got = bytesGot()));
        const lockMiddleware = (0, misc_1.pendingPromise)(); // expose outside, to let know when all operations stopped
        let errored;
        fileStream.on('error', (e) => {
            console.warn('File error while uploading', filename, ':', e.message);
            errored = e;
            fail(const_1.HTTP_SERVER_ERROR, e.code); // don't send e.message as it may contain a disk paths we don't want to leak
        });
        writeStream.once('close', async () => {
            try {
                ctx.state.uploadSize = bytesGot(); // in case content-length is not specified
                await new Promise(res => fileStream.close(res)); // this only seems necessary on Windows
                if (errored)
                    return;
                if (simulate)
                    return (0, promises_1.rm)(tempName).catch(() => { });
                if (ctx.isAborted()) { // in the very unlikely case the connection is interrupted between last-byte and here, we still consider it unfinished, as the client had no way to know, and will resume, but it would get an error if we finish the process
                    const sec = exports.deleteUnfinishedUploadsAfter.get();
                    return lodash_1.default.isNumber(sec) && delayedDelete(tempName, sec);
                }
                if (isPartial) // we are supposed to leave the unfinished upload as it is, with its temp name
                    return ctx.status = const_1.HTTP_NO_CONTENT; // lockMiddleware contains an empty string, so we must take care of the status
                let dest = fullPath; // final destination, considering numbering if necessary
                if (exports.dontOverwriteUploading.get() && fs_1.default.existsSync(dest) && !await overwriteAnyway()) {
                    if (overwriteRequestedButForbidden) {
                        await (0, promises_1.rm)(tempName).catch(e => console.warn(String(e)));
                        releaseFile();
                        return fail(); // status code set by overwriteAnyway
                    }
                    const ext = (0, path_1.extname)(dest);
                    const base = dest.slice(0, -ext.length || Infinity);
                    let i = 1;
                    do
                        dest = `${base} (${i++})${ext}`;
                    while (fs_1.default.existsSync(dest));
                }
                try {
                    const done = await (0, misc_1.waitFor)(// an antivirus may lock the temp file to scan it
                    () => (0, promises_1.rename)(tempName, dest).then(() => true, e => e?.code !== 'EBUSY' && Promise.reject(e)), { timeout: 10_000 });
                    if (!done)
                        throw 'EBUSY';
                    if (mtime) // so we use it to touch the file
                        await (0, promises_1.utimes)(dest, Date.now() / 1000, mtime / 1000);
                    cancelDeletion(tempName); // not necessary, as deletion's failure is silent, but still
                    ctx.state.uploadDestinationPath = dest;
                    void setUploadMeta(dest, ctx);
                    if (ctx.query.comment)
                        void (0, comments_1.setCommentFor)(dest, String(ctx.query.comment));
                    obj.uri = (0, misc_1.enforceFinal)('/', baseUri) + (0, misc_1.pathEncode)((0, path_1.basename)(dest));
                    events_1.default.emit('uploadFinished', obj);
                    console.debug("Upload finished", dest);
                    if (resEvent)
                        for (const cb of resEvent)
                            if (lodash_1.default.isFunction(cb))
                                cb(obj);
                }
                catch (err) {
                    void setUploadMeta(tempName, ctx);
                    console.error("Couldn't rename temp to", dest, String(err));
                }
            }
            finally {
                releaseFile();
                lockMiddleware.resolve(obj.uri);
            }
        });
        return Object.assign(obj.writeStream, { lockMiddleware });
        function trackProgress() {
            let lastGot = 0;
            let lastGotTime = 0;
            Object.assign(ctx.state, { opTotal: fullSize, opOffset: resume / fullSize, opProgress: 0 });
            const conn = (0, connections_1.updateConnectionForCtx)(ctx);
            if (!conn)
                return;
            if (writeStream.closed || writeStream.destroyed || writeStream.writableFinished)
                return;
            // tracking
            const h = setInterval(() => {
                const now = Date.now();
                const got = bytesGot();
                const inSpeedKb = (0, throttler_1.roundSpeed)((got - lastGot) / (now - lastGotTime));
                lastGot = got;
                lastGotTime = now;
                (0, connections_1.updateConnection)(conn, { inSpeedKb, got }, { opProgress: (resume + got) / fullSize });
            }, 1000);
            const stopTracking = () => clearInterval(h);
            writeStream.once('close', stopTracking);
            writeStream.once('error', stopTracking);
        }
        function bytesGot() {
            return fileStream.bytesWritten + fileStream.writableLength;
        }
    }
    catch (e) {
        releaseFile();
        return fail(const_1.HTTP_SERVER_ERROR, e.code || e.message || String(e));
    }
    async function overwriteAnyway() {
        if (ctx.query.existing !== 'overwrite')
            return false;
        const n = await (0, vfs_1.getNodeByName)(filename, base);
        if (n && !(0, vfs_1.statusCodeForMissingPerm)(n, 'can_delete', ctx))
            return true;
        overwriteRequestedButForbidden = true;
        return false;
    }
    function delayedDelete(path, secs) {
        clearTimeout(waitingToBeDeleted[path]?.timeout);
        return waitingToBeDeleted[path] = {
            mtime,
            expires: Date.now() + secs * 1000,
            timeout: setTimeout(() => {
                delete waitingToBeDeleted[path];
                (0, promises_1.rm)(path).catch(() => { });
            }, secs * 1000)
        };
    }
    function cancelDeletion(path) {
        clearTimeout(waitingToBeDeleted[path]?.timeout);
        delete waitingToBeDeleted[path];
    }
    function releaseFile() {
        uploadingFiles.delete(fullPath);
    }
    function fail(status = ctx.status, msg) {
        console.debug('Upload failed', status, msg || '');
        ctx.status = status;
        if (msg)
            ctx.body = msg;
        if (status >= 400 // with other codes Chrome will report ERR_CONNECTION_RESET
            && !ctx.get('x-hfs-wait') // you can disable the following behavior
            && !ctx.req.complete) // if request body is already complete, forcing a disconnect can interfere with follow-up requests on reused sockets.
            setTimeout(() => (0, connections_1.disconnect)(ctx), 200); // don't wait, if the upload is still in progress
    }
}
