hfs
Version:
HTTP File Server
443 lines (442 loc) • 20.4 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.vfs = void 0;
exports.permsFromParent = permsFromParent;
exports.isSameFilenameAs = isSameFilenameAs;
exports.applyParentToChild = applyParentToChild;
exports.urlToNode = urlToNode;
exports.nodeStats = nodeStats;
exports.getNodeByName = getNodeByName;
exports.saveVfs = saveVfs;
exports.isRoot = isRoot;
exports.getNodeName = getNodeName;
exports.nodeIsDirectory = nodeIsDirectory;
exports.hasDefaultFile = hasDefaultFile;
exports.nodeIsLink = nodeIsLink;
exports.hasPermission = hasPermission;
exports.statusCodeForMissingPerm = statusCodeForMissingPerm;
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 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(parent, child) {
var _a, _b, _c;
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)
(_a = child.mime) !== null && _a !== void 0 ? _a : (child.mime = parent.mime);
if (parent.accept)
(_b = child.accept) !== null && _b !== void 0 ? _b : (child.accept = parent.accept);
if (parent.default)
(_c = child.default) !== null && _c !== void 0 ? _c : (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) {
var _a, _b;
const ret = {
original: child, // this can be overridden by passing an 'original' in `child`
...child,
isFolder: (_a = child === null || child === void 0 ? void 0 : child.isFolder) !== null && _a !== void 0 ? _a : (((_b = child === null || child === void 0 ? void 0 : child.children) === null || _b === void 0 ? void 0 : _b.length) > 0 || undefined), // isFolder is hidden in original node, so we must read it to copy it
isTemp: true,
parent,
};
name || (name = child ? getNodeName(child) : '');
inheritMasks(ret, parent, name);
await parentMaskApplier(parent)(ret, name);
inheritFromParent(parent, ret);
return ret;
}
async function urlToNode(url, ctx, parent = exports.vfs, getRest) {
let initialSlashes = 0;
while (url[initialSlashes] === '/')
initialSlashes++;
let nextSlash = url.indexOf('/', initialSlashes);
const name = decodeURIComponent(url.slice(initialSlashes, nextSlash < 0 ? undefined : nextSlash));
if (!name)
return parent;
const rest = nextSlash < 0 ? '' : url.slice(nextSlash + 1, url.endsWith('/') ? -1 : undefined);
const ret = await getNodeByName(name, parent);
if (!ret)
return;
if (rest || (ret === null || ret === void 0 ? void 0 : ret.original))
return urlToNode(rest, ctx, ret, getRest);
if (ret.source)
try {
if (!showHiddenFiles.get() && await isHiddenFile(ret.source))
throw 'hiddenFile';
ret.isFolder = (await nodeStats(ret)).isDirectory(); // throws if it doesn't exist on disk
}
catch (_a) {
if (!getRest)
return;
const rest = ret.source.slice(parent.source.length); // parent has source, otherwise !ret.source || ret.original
getRest((0, misc_1.removeStarting)('/', rest));
return parent;
}
return ret;
}
async function nodeStats(ret) {
if (ret.stats)
if (lodash_1.default.isPlainObject(ret.stats))
delete ret.stats; // legacy pre-55-alpha1
else
return ret.stats;
const stats = ret.source ? await promises_1.default.stat(ret.source) : undefined;
(0, misc_1.setHidden)(ret, { stats });
return stats;
}
async function isHiddenFile(path) {
return const_1.IS_WINDOWS ? new Promise(res => fswin_1.default.getAttributes(path, x => res(x === null || x === void 0 ? void 0 : x.IS_HIDDEN)))
: path[path.lastIndexOf('/') + 1] === '.';
}
async function getNodeByName(name, parent) {
var _a;
// does the tree node have a child that goes by this name, otherwise attempt disk
const child = ((_a = parent.children) === null || _a === void 0 ? void 0 : _a.find(isSameFilenameAs(name))) || childFromDisk();
return child && applyParentToChild(child, parent, name);
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
return ret;
}
}
exports.vfs = {};
(0, config_1.defineConfig)('vfs', {}).sub(data => exports.vfs = (function recur(node) {
const { masks } = node;
lodash_1.default.each(masks, (v, mask) => {
if (v.maskOnly) {
masks[`${mask}|${v.maskOnly}|`] = v = lodash_1.default.omit(v, 'maskOnly');
delete masks[mask];
}
recur(v);
});
if (node.children)
for (const c of node.children)
recur(c);
return node;
})(data));
function saveVfs() {
return (0, config_1.setConfig)({ vfs: lodash_1.default.cloneDeep(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;
}
async function nodeIsDirectory(node) {
var _a;
if (node.isFolder !== undefined)
return node.isFolder;
if (nodeIsLink(node))
return false;
if (((_a = node.children) === null || _a === void 0 ? void 0 : _a.length) || !node.source)
return true;
const isFolder = await nodeStats(node).then(x => x.isDirectory(), () => false);
(0, misc_1.setHidden)(node, { isFolder }); // don't make it to the storage (a node.isTemp doesn't need it to be hidden)
return isFolder;
}
async function hasDefaultFile(node, ctx) {
return node.default && await nodeIsDirectory(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() {
var _a;
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 !== null && who !== void 0 ? who : (who = misc_1.defaultPerms[cur]);
if (typeof who !== 'string' || who === misc_1.WHO_ANY_ACCOUNT)
break;
if (!max--) {
console.error(`endless loop in permission ${perm}=${(_a = node[perm]) !== null && _a !== void 0 ? _a : misc_1.defaultPerms[perm]} for ${node.url || getNodeName(node)}`);
return misc_1.HTTP_SERVER_ERROR;
}
cur = who;
} while (1);
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)
: (0, misc_1.throw_)(Error(`invalid permission: ${perm}=${(0, misc_1.try_)(() => JSON.stringify(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() {
var _a;
if (started)
return; // for simplicity, we care about starting, and never suspend
started = true;
const { children, source } = parent;
const taken = prefixPath ? undefined : new Set();
const maskApplier = parentMaskApplier(parent);
const visitLater = [];
if (children)
for (const child of children) {
const nodeName = getNodeName(child);
const name = prefixPath + nodeName;
taken === null || taken === void 0 ? void 0 : taken.add(normalizeFilename(name));
const item = { ...child, original: child, name };
if (await cantSee(item))
continue;
if (item.source && !((_a = item.children) === null || _a === void 0 ? void 0 : _a.length)) // real items must be accessible, unless there's more to it
try {
await promises_1.default.access(item.source);
}
catch (_b) {
continue;
}
const isFolder = await nodeIsDirectory(child);
if (onlyFiles ? !isFolder : (!onlyFolders || isFolder))
stream.push(item);
if (!depth || !isFolder || cantRecur(item))
continue;
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;
try {
await (0, walkDir_1.walkDir)(source, { depth, ctx, hidden: showHiddenFiles.get(), parallelizeRecursion }, async (entry) => {
var _a;
if (ctx === null || ctx === void 0 ? void 0 : ctx.isAborted()) {
stream.push(null);
return null;
}
if ((0, comments_1.usingDescriptIon)() && entry.name === comments_1.DESCRIPT_ION)
return;
const { path } = entry;
const isFolder = entry.isDirectory();
let renamed = (_a = parent.rename) === null || _a === void 0 ? void 0 : _a[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 === null || taken === void 0 ? void 0 : 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) };
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, e); // ENOTDIR, or lacking permissions
}
}
finally {
for (const [item, name] of visitLater)
for await (const x of walkNode(item, { depth: depth - 1, prefixPath: name + '/', ctx, requiredPerm, onlyFolders, parallelizeRecursion }))
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(parent, 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)
yield item;
}
function masksCouldGivePermission(masks, perm) {
return masks !== undefined && Object.values(masks).some(props => props[perm] || masksCouldGivePermission(props.masks, perm));
}
function parentMaskApplier(parent) {
// 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, k) => {
if (!mods)
return;
const mustBeFolder = (() => {
if (k.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 = k.lastIndexOf('|', k.length - 2);
if (i < 0)
return;
const type = k.slice(i + 1, -1);
k = k.slice(0, i); // remove
return type === 'folders';
})();
const m = /^(!?)\*\*\//.exec(k); // ** globstar matches also zero subfolders, so this mask must be applied here too
k = m ? m[1] + k.slice(m[0].length) : !k.includes('/') ? k : '';
return k && { mods, matcher: (0, misc_1.makeMatcher)(k), mustBeFolder };
}));
return async (item, virtualBasename = (0, path_1.basename)(getNodeName(item))) => {
let isFolder = undefined;
for (const { matcher, mods, mustBeFolder } of matchers) {
if (mustBeFolder !== undefined) {
isFolder !== null && isFolder !== void 0 ? isFolder : (isFolder = await nodeIsDirectory(item));
if (mustBeFolder !== isFolder)
continue;
}
if (!matcher(virtualBasename))
continue;
item.masks && (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);
}
};
}
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) {
var _a;
for (const k of misc_1.PERM_KEYS)
renameInPerm(n[k]);
if (n.masks)
Object.values(n.masks).forEach(renameInNode);
(_a = n.children) === null || _a === void 0 ? void 0 : _a.forEach(renameInNode);
})(exports.vfs);
saveVfs();
function renameInPerm(a) {
if (!Array.isArray(a))
return;
for (let i = 0; i < a.length; i++)
if (a[i] === from)
a[i] = to;
}
});
;