"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.saveVfs = exports.vfs = void 0;
exports.permsFromParent = permsFromParent;
exports.isSameFilenameAs = isSameFilenameAs;
exports.normalizeFilename = normalizeFilename;
exports.applyParentToChild = applyParentToChild;
exports.urlToNode = urlToNode;
exports.nodeStats = nodeStats;
exports.getNodeByName = getNodeByName;
exports.isRoot = isRoot;
exports.getNodeName = getNodeName;
exports.nodeIsFolder = nodeIsFolder;
exports.getDefaultFile = getDefaultFile;
exports.nodeIsLink = nodeIsLink;
exports.hasPermission = hasPermission;
exports.statusCodeForMissingPerm = statusCodeForMissingPerm;
exports.simpleWhoToError = simpleWhoToError;
exports.walkNode = walkNode;
exports.masksCouldGivePermission = masksCouldGivePermission;
exports.parentMaskApplier = parentMaskApplier;
const promises_1 = __importDefault(require("fs/promises"));
const path_1 = require("path");
const misc_1 = require("./misc");
const lodash_1 = __importDefault(require("lodash"));
const config_1 = require("./config");
const const_1 = require("./const");
const events_1 = __importDefault(require("./events"));
const perm_1 = require("./perm");
const auth_1 = require("./auth");
const fswin_1 = __importDefault(require("fswin"));
const comments_1 = require("./comments");
const walkDir_1 = require("./walkDir");
const node_stream_1 = require("node:stream");
const adminApis_1 = require("./adminApis");
const showHiddenFiles = (0, config_1.defineConfig)('show_hidden_files', false);
function permsFromParent(parent, child) {
    const ret = {};
    for (const k of misc_1.PERM_KEYS) {
        let p = parent;
        let inheritedPerm;
        while (p) {
            inheritedPerm = p[k];
            // in case of object without children, parent is skipped in favor of the parent's parent
            if (!(0, misc_1.isWhoObject)(inheritedPerm))
                break;
            inheritedPerm = inheritedPerm.children;
            if (inheritedPerm !== undefined)
                break;
            p = p.parent;
        }
        if (inheritedPerm !== undefined && child[k] === undefined) // small optimization: don't expand the object
            ret[k] = inheritedPerm;
    }
    return lodash_1.default.isEmpty(ret) ? undefined : ret;
}
function inheritFromParent(child) {
    const { parent } = child;
    if (!parent)
        return;
    Object.assign(child, permsFromParent(parent, child));
    if (typeof parent.mime === 'object' && typeof child.mime === 'object')
        lodash_1.default.defaults(child.mime, parent.mime);
    else if (parent.mime)
        child.mime ??= parent.mime;
    if (parent.accept)
        child.accept ??= parent.accept;
    if (parent.default)
        child.default ??= parent.default;
    return child;
}
function isSameFilenameAs(name) {
    const normalized = normalizeFilename(name);
    return (other) => normalized === normalizeFilename(typeof other === 'string' ? other : getNodeName(other));
}
function normalizeFilename(x) {
    return (const_1.IS_WINDOWS || const_1.IS_MAC ? x.toLocaleLowerCase() : x).normalize();
}
async function applyParentToChild(child, parent, name) {
    const ret = {
        original: child, // this can be overridden by passing an 'original' in `child`
        ...child,
        isFolder: child?.isFolder ?? (child?.children?.length > 0 || undefined), // isFolder is hidden in original node, so we must copy it explicitly
        isTemp: true,
        parent,
    };
    name ||= child ? getNodeName(child) : '';
    inheritMasks(ret, parent, name);
    await parentMaskApplier(parent)(ret, name);
    inheritFromParent(ret);
    return ret;
}
async function urlToNode(url, ctx, parent = exports.vfs, allowMissing // true means missing path segments still resolve to temporary nodes with a computed source path
) {
    let initialSlashes = 0;
    while (url[initialSlashes] === '/')
        initialSlashes++;
    let nextSlash = url.indexOf('/', initialSlashes);
    const slice = url.slice(initialSlashes, nextSlash < 0 ? undefined : nextSlash);
    if (!slice)
        return parent;
    const name = (0, misc_1.safeDecodeURIComponent)(slice, '');
    if (!name) // failed decoding
        return;
    const hasTrailingSlash = url.endsWith('/');
    const rest = nextSlash < 0 ? '' : url.slice(nextSlash + 1, hasTrailingSlash ? -1 : undefined);
    const assumeFolder = allowMissing && (rest > '' || hasTrailingSlash);
    const ret = await getNodeByName(name, parent, assumeFolder);
    if (!ret)
        return;
    if (rest || ret?.original)
        return urlToNode(rest, ctx, ret, allowMissing);
    if (ret.source)
        if (!showHiddenFiles.get() && await isHiddenFile(ret.source)
            || !allowMissing && await setIsFolder(ret) === undefined) // undefined = not found on disk
            return;
    return ret;
}
async function nodeStats(node) {
    if (node.stats || !node.source)
        return node.stats;
    const stats = (0, misc_1.statWithTimeout)(node.source).catch(() => {
        (0, misc_1.setHidden)(node, { stats: null }); // don't cache rejected promises
    });
    (0, misc_1.setHidden)(node, { stats });
    return stats;
}
async function isHiddenFile(path) {
    return const_1.IS_WINDOWS ? new Promise(res => fswin_1.default.getAttributes(path, x => res(x?.IS_HIDDEN)))
        : path[path.lastIndexOf('/') + 1] === '.';
}
async function getNodeByName(name, parent, assumeMissingToBeFolder = false) {
    // does the tree node have a child that goes by this name, otherwise attempt disk
    let child = parent.children?.find(isSameFilenameAs(name));
    if (child) // found as vfs node
        await setIsFolder(child); // in case it's pointing to a folder that didn't exist at loading time
    else
        child = await childFromDisk();
    return child && applyParentToChild(child, parent, name);
    async function childFromDisk() {
        if (!parent.source)
            return;
        const ret = {};
        let onDisk = name;
        if (parent.rename) { // reverse the mapping
            for (const [from, to] of Object.entries(parent.rename))
                if (name === to) {
                    onDisk = from;
                    break; // found, search no more
                }
            ret.rename = renameUnderPath(parent.rename, name);
        }
        if (!(0, misc_1.isValidFileName)(onDisk))
            return;
        ret.source = (0, path_1.join)(parent.source, onDisk);
        ret.original = undefined; // this will overwrite the 'original' set in applyParentToChild, so we know this is not part of the vfs
        await setIsFolder(ret);
        if (assumeMissingToBeFolder)
            ret.isFolder ??= true;
        return ret;
    }
}
const smartUncFolderDetection = (0, config_1.defineConfig)('smart_unc_folder_detection', false);
async function setIsFolder(node) {
    if (!node.source)
        return;
    const isFolder = /[\\/]$/.test(node.source)
        || smartUncFolderDetection.get() && (0, misc_1.getUncHost)(node.source) && !(0, path_1.basename)(node.source).includes('.') // no dot = folder; not very reliable but fast for unreachable unc hosts, and it's an opt-in
        || await nodeStats(node).then(x => x?.isDirectory(), () => undefined);
    (0, misc_1.setHidden)(node, { isFolder });
    return isFolder;
}
exports.vfs = {};
(0, config_1.defineConfig)('vfs', exports.vfs).sub(async (x) => {
    await reviewVfs(x);
    console.log('VFS ready');
});
async function reviewVfs(data = exports.vfs) {
    await (async function recur(node) {
        if (node.source && !node.children?.length && node.isFolder === undefined)
            await setIsFolder(node);
        if (node.children)
            await Promise.allSettled(node.children.map(recur));
    })(data);
    exports.vfs = data;
}
exports.saveVfs = (0, misc_1.debounceAsync)(async () => {
    await reviewVfs();
    await (0, config_1.setConfig)({ vfs: exports.vfs }, true);
});
function isRoot(node) {
    return node === exports.vfs;
}
function getNodeName(node) {
    if (isRoot(node))
        return '';
    if (node.name)
        return node.name;
    const { source } = node;
    if (!source)
        return ''; // shoulnd't happen
    if (source === '/')
        return 'root'; // better name than
    if (/^[a-zA-Z]:\\?$/.test(source))
        return source.slice(0, 2); // exclude trailing slash
    const base = (0, path_1.basename)(source);
    if (/^[./\\]*$/.test(base)) // if empty or special-chars-only
        return (0, path_1.basename)((0, path_1.resolve)(source)); // resolve to try to get more
    if (base.includes('\\') && !source.includes('/')) // source was Windows but now we are running posix. This probably happens only debugging, so it's DX
        return source.slice(source.lastIndexOf('\\') + 1);
    return base;
}
// this is sync
function nodeIsFolder(node) {
    return node.isFolder ?? node.original?.isFolder
        ?? (nodeIsLink(node) ? false : (node.children?.length > 0 || !node.source || reconsider()));
    function reconsider() {
        // a networked source may be offline at startup, and become online later: recalculate in the background
        nodeStats(node).then(s => {
            if (s)
                (0, misc_1.setHidden)(node.original || node, { isFolder: s.isDirectory() });
        }, () => { });
        return undefined;
    }
}
async function getDefaultFile(node, ctx) {
    return node.default && nodeIsFolder(node) && await urlToNode(node.default, ctx, node) || undefined;
}
function nodeIsLink(node) {
    return node.url;
}
function hasPermission(node, perm, ctx) {
    return !statusCodeForMissingPerm(node, perm, ctx, false);
}
function statusCodeForMissingPerm(node, perm, ctx, assign = true) {
    const ret = getCode();
    if (ret && assign) {
        ctx.status = ret;
        ctx.body = ret === const_1.HTTP_UNAUTHORIZED ? "Unauthorized" : "Forbidden";
    }
    return ret;
    function getCode() {
        if ((isRoot(node) || node.original) && perm === 'can_delete' // we currently don't allow deleting of vfs nodes from frontend
            || !node.source && perm === 'can_upload') // Upload possible only if we know where to store. First check node.source because is supposedly faster.
            return const_1.HTTP_FORBIDDEN;
        // calculate value of permission resolving references to other permissions, avoiding infinite loop
        let who;
        let max = misc_1.PERM_KEYS.length;
        let cur = perm;
        do {
            who = node[cur];
            if ((0, misc_1.isWhoObject)(who))
                who = who.this;
            who ??= misc_1.defaultPerms[cur];
            if (typeof who !== 'string' || who === misc_1.WHO_ANY_ACCOUNT || who === misc_1.WHO_ADMIN)
                break;
            if (!max--) {
                console.error(`Endless loop in permission ${perm}=${node[perm] ?? misc_1.defaultPerms[perm]} for ${node.url || getNodeName(node)}`);
                return misc_1.HTTP_SERVER_ERROR;
            }
            cur = who;
        } while (1);
        if ((0, misc_1.isWhoObject)(who) || isWhoVfsPerms(who))
            throw Error(`permission type-guard: ${JSON.stringify(who)}`);
        const eventName = 'checkVfsPermission';
        if (events_1.default.anyListener(eventName)) {
            const first = lodash_1.default.max(events_1.default.emit(eventName, { who, node, perm, ctx }));
            if (first !== undefined)
                return first;
        }
        return simpleWhoToError(who, ctx)
            ?? (0, misc_1.throw_)(Error(`invalid permission: ${perm}=${(0, misc_1.try_)(() => JSON.stringify(who))}`));
    }
}
function simpleWhoToError(who, ctx) {
    if (Array.isArray(who))
        return (0, perm_1.ctxBelongsTo)(ctx, who) ? 0 : const_1.HTTP_UNAUTHORIZED;
    return typeof who === 'boolean' ? (who ? 0 : const_1.HTTP_FORBIDDEN)
        : who === misc_1.WHO_ANY_ACCOUNT ? ((0, auth_1.getCurrentUsername)(ctx) ? 0 : const_1.HTTP_UNAUTHORIZED)
            : who === misc_1.WHO_ADMIN ? ((0, adminApis_1.ctxAdminAccess)(ctx) ? 0 : const_1.HTTP_UNAUTHORIZED)
                : undefined;
}
function isWhoVfsPerms(who) {
    return typeof who === 'string' && misc_1.PERM_KEYS.includes(who);
}
// it's the responsibility of the caller to verify you have list permission on parent, as callers have different needs.
async function* walkNode(parent, { ctx, depth = Infinity, prefixPath = '', requiredPerm, onlyFolders = false, onlyFiles = false, parallelizeRecursion = true, } = {}) {
    let started = false;
    const stream = new node_stream_1.Readable({
        objectMode: true,
        async read() {
            if (started)
                return; // for simplicity, we care about starting, and never suspend
            started = true;
            const { source } = parent;
            const taken = new Set();
            const maskApplier = parentMaskApplier(parent);
            const visitLater = [];
            const childrenWorking = parent.children?.length && Promise.all(parent.children.map(async (child) => {
                if (ctx?.isAborted())
                    return;
                const nodeName = getNodeName(child);
                const name = prefixPath + nodeName;
                taken?.add(normalizeFilename(name));
                const item = { ...child, original: child, name, parent };
                if (await cantSee(item))
                    return;
                if (item.source && !item.children?.length) // real items must be accessible, unless there's more to it
                    try {
                        await promises_1.default.access(item.source);
                    }
                    catch {
                        return;
                    }
                const isFolder = nodeIsFolder(child);
                if (onlyFiles ? !isFolder : (!onlyFolders || isFolder))
                    stream.push(item);
                if (!depth || !isFolder || cantRecur(item))
                    return;
                inheritMasks(item, parent);
                visitLater.push([item, name]); // prioritize siblings
            }));
            try {
                if (!source)
                    return;
                if (requiredPerm && ctx // no permission, no reason to continue (at least for dynamic elements)
                    && !hasPermission(parent, requiredPerm, ctx)
                    && !masksCouldGivePermission(parent.masks, requiredPerm))
                    return;
                const pathMaskApplier = parentMaskApplier(parent, true);
                try {
                    await (0, walkDir_1.walkDir)(source, { depth, ctx, hidden: showHiddenFiles.get(), parallelizeRecursion }, async (entry) => {
                        if (ctx?.isAborted())
                            return null;
                        if ((0, comments_1.usingDescriptIon)() && (entry.name === comments_1.DESCRIPT_ION || entry.name === comments_1.DESCRIPT_ION_ALT))
                            return;
                        const { path } = entry; // this path is not the original deprecated property: we are overwriting/reusing it
                        const isFolder = entry.isDirectory();
                        let renamed = parent.rename?.[path];
                        if (renamed) {
                            const dir = (0, path_1.dirname)(path); // if `path` isn't just the name, copy its dir in renamed
                            if (dir !== '.')
                                renamed = dir + '/' + renamed;
                        }
                        const name = prefixPath + (renamed || path);
                        if (taken?.has(normalizeFilename(name))) // taken by vfs node above
                            return false; // false just in case it's a folder
                        const item = { name, isFolder, source: (0, path_1.join)(source, path), parent, stats: entry.stats };
                        // masks containing '/' must be matched against the relative path while keeping walkDir recursion enabled
                        await pathMaskApplier(item, renamed || path);
                        if (await cantSee(item)) // can't see: don't produce and don't recur
                            return false;
                        if (onlyFiles ? !isFolder : (!onlyFolders || isFolder))
                            stream.push(item);
                        if (cantRecur(item))
                            return false;
                    });
                }
                catch (e) {
                    console.debug('walkNode', source, String(e)); // ENOTDIR, or lacking permissions
                }
            }
            finally {
                await childrenWorking;
                for (const [item, name] of visitLater)
                    for await (const x of walkNode(item, { depth: depth - 1, prefixPath: name + '/', ctx, requiredPerm, onlyFolders, onlyFiles, parallelizeRecursion })) {
                        if (ctx?.isAborted())
                            return stream.push(null);
                        stream.push(x);
                    }
                stream.push(null);
            }
            function cantRecur(item) {
                return ctx && !hasPermission(item, 'can_list', ctx);
            }
            // item will be changed, so be sure to pass a temp node
            async function cantSee(item) {
                await maskApplier(item);
                inheritFromParent(item);
                if (ctx && !hasPermission(item, 'can_see', ctx))
                    return true;
                item.isTemp = true;
            }
        }
    });
    // must use a stream to be able to work with the callback-based mechanism of walkDir, but Readable is not typed so we wrap it with a generator
    for await (const item of stream) {
        if (ctx?.isAborted())
            return;
        yield item;
    }
}
function masksCouldGivePermission(masks, perm) {
    return masks !== undefined && Object.values(masks).some(props => props[perm] || masksCouldGivePermission(props.masks, perm));
}
function parentMaskApplier(parent, pathBased = false) {
    // rules are met in the parent.masks object from nearest to farthest, but since we finally apply with _.defaults, the nearest has precedence in the final result
    const matchers = (0, misc_1.onlyTruthy)(lodash_1.default.map(parent.masks, (mods, mask) => {
        if (!mods)
            return;
        const mustBeFolder = (() => {
            if (mask.at(-1) !== '|')
                return; // parse special flag syntax as suffix |FLAG| inside the key. This allows specifying different flags with the same mask using separate keys. To avoid syntax conflicts with the rest of the file-mask, we look for an ending pipe, as it has no practical use. Ending-pipe was preferred over starting-pipe to leave the rest of the logic (inheritMasks) untouched.
            const i = mask.lastIndexOf('|', mask.length - 2);
            if (i < 0)
                return;
            const type = mask.slice(i + 1, -1);
            mask = mask.slice(0, i); // remove
            return type === 'folders';
        })();
        if (pathBased) {
            if (!mask.includes('/'))
                return;
            // avoid evaluating twice masks like **/*.png because parentMaskApplier already handles them by basename
            const m = /^(!?)\*\*\//.exec(mask);
            // this keeps the fast basename path as source-of-truth for patterns that collapse to a filename after **/
            if (m && !mask.slice(m[0].length).includes('/'))
                return;
        }
        else {
            const m = /^(!?)\*\*\//.exec(mask); // ** globstar matches also zero subfolders, so this mask must be applied here too
            mask = m ? m[1] + mask.slice(m[0].length) : !mask.includes('/') ? mask : '';
            if (!mask)
                return;
        }
        return mask && { matcher: (0, misc_1.makeMatcher)(mask), mods, mustBeFolder };
    }));
    return async (item, virtualName = (pathBased ? lodash_1.default.identity : path_1.basename)(getNodeName(item))) => {
        // depth traversal passes full relative paths, while node traversal still matches only basenames
        let isFolder = undefined;
        for (const { matcher, mods, mustBeFolder } of matchers) {
            if (mustBeFolder !== undefined) {
                isFolder ??= nodeIsFolder(item);
                if (mustBeFolder !== isFolder)
                    continue;
            }
            if (!matcher(virtualName))
                continue;
            item.masks &&= lodash_1.default.merge(lodash_1.default.cloneDeep(mods.masks), item.masks); // item.masks must take precedence
            lodash_1.default.defaults(item, mods);
        }
    };
}
// propagates masks, don't apply
function inheritMasks(item, parent, virtualBasename = getNodeName(item)) {
    const { masks } = parent;
    if (!masks)
        return;
    const o = {};
    for (const [k, v] of Object.entries(masks)) {
        if (k.startsWith('**')) {
            o[k] = v;
            continue;
        }
        const i = k.indexOf('/');
        if (i < 0)
            continue;
        if (!(0, misc_1.matches)(virtualBasename, k.slice(0, i)))
            continue;
        o[k.slice(i + 1)] = v;
    }
    if (Object.keys(o).length)
        item.masks = Object.assign(o, item.masks); // don't change item.masks object as it is the same object of item.original
}
function renameUnderPath(rename, path) {
    if (!rename)
        return rename;
    const match = path + '/';
    rename = Object.fromEntries(Object.entries(rename).map(([k, v]) => [k.startsWith(match) ? k.slice(match.length) : '', v]));
    delete rename[''];
    return lodash_1.default.isEmpty(rename) ? undefined : rename;
}
events_1.default.on('accountRenamed', ({ from, to }) => {
    ;
    (function renameInNode(n) {
        for (const k of misc_1.PERM_KEYS)
            renameInPerm(n[k]);
        if (n.masks)
            Object.values(n.masks).forEach(renameInNode);
        n.children?.forEach(renameInNode);
    })(exports.vfs);
    (0, exports.saveVfs)();
    function renameInPerm(a) {
        if (!Array.isArray(a))
            return;
        for (let i = 0; i < a.length; i++)
            if (a[i] === from)
                a[i] = to;
    }
});
