hfs 3.1.0-alpha3 → 3.1.0-beta4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/admin/assets/{index-CDiYkO8j.css → index-B66w-a0v.css} +1 -1
- package/admin/assets/{index-CNnmxYpa.js → index-BL84dr-c.js} +1 -1
- package/admin/assets/{index-BjCiaHEB.js → index-aI7IEKyS.js} +11 -11
- package/admin/assets/{sha512-CbkTEGOo.js → sha512-BUo7IPyY.js} +1 -1
- package/admin/index.html +2 -2
- package/frontend/assets/{index-legacy-CG_og8xL.js → index-legacy-CVyhaUXB.js} +1 -1
- package/frontend/assets/index-legacy-f8_Sicwh.js +9 -0
- package/frontend/assets/{sha512-legacy-D1zEZzDe.js → sha512-legacy-CXmh1M3g.js} +1 -1
- package/frontend/index.html +1 -1
- package/npm-shrinkwrap.json +1235 -15734
- package/package.json +7 -26
- package/src/commands.js +15 -3
- package/src/const.js +19 -4
- package/src/frontEndApis.js +10 -5
- package/src/plugins.js +5 -7
- package/src/roots.js +1 -1
- package/src/update.js +19 -5
- package/src/vfs.js +30 -12
- package/src/webdav.js +77 -49
- package/frontend/assets/index-legacy-DDLKWGcm.js +0 -9
package/package.json
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hfs",
|
|
3
|
-
"version": "3.1.0-
|
|
3
|
+
"version": "3.1.0-beta4",
|
|
4
4
|
"description": "HTTP File Server",
|
|
5
|
-
"keywords": [
|
|
5
|
+
"keywords": [
|
|
6
|
+
"file server",
|
|
7
|
+
"http server"
|
|
8
|
+
],
|
|
6
9
|
"homepage": "https://rejetto.com/hfs",
|
|
7
10
|
"license": "GPL-3.0",
|
|
8
11
|
"author": "Massimo Melina <a@rejetto.com>",
|
|
9
|
-
"workspaces": [ "admin", "frontend", "shared", "mui-grid-form" ],
|
|
10
12
|
"scripts": {
|
|
11
13
|
"watch-server": "cross-env DEV=1 nodemon --ignore tests/ --watch src -e ts,tsx --exec tsx src",
|
|
12
14
|
"watch-server-proxied": "cross-env FRONTEND_PROXY=3005 ADMIN_PROXY=3006 npm run watch-server",
|
|
@@ -28,7 +30,7 @@
|
|
|
28
30
|
"dist": "STASHED=; if ! git diff-index --quiet HEAD --; then git stash push -m 'dist' && STASHED=1; fi; CI=1 FORCE_COLOR=1 npm run dist-uncommitted || (EXIT_CODE=$?; [ -n \"$STASHED\" ] && git stash pop; exit $EXIT_CODE); [ -n \"$STASHED\" ] && git stash pop",
|
|
29
31
|
"dist-uncommitted": "npm audit --omit=dev --audit-level=moderate && rm -rf dist && npm run build-server && npm run test-with-server && (npm run build-frontend & npm run build-admin) && npm run test-ui && npm run dist-bin",
|
|
30
32
|
"dist-bin": "npm run dist-modules && npm run dist-bin-win && npm run dist-bin-linux && npm run dist-bin-linux-arm && npm run dist-bin-mac && npm run dist-bin-mac-arm",
|
|
31
|
-
"dist-modules": "cp package
|
|
33
|
+
"dist-modules": "cp package.json central.json README.md dist && cd dist && npm pkg delete devDependencies workspaces && rm -rf node_modules && npm install --omit=dev && npm shrinkwrap && cd .. && node scripts/prune_modules.js",
|
|
32
34
|
"dist-bin-win": "cd dist && pkg . --public -C gzip -t node20-win-x64 && npx resedit-cli --in hfs.exe --icon 1,../hfs.ico --out hfs.exe && zip hfs-windows-x64-$(jq -r .version ../package.json).zip hfs.exe -r plugins && cd ..",
|
|
33
35
|
"dist-bin-mac-arm": "cd dist && pkg . --public -C gzip -t node20-macos-arm64 && zip hfs-mac-arm64-$(jq -r .version ../package.json).zip hfs -r plugins && cd ..",
|
|
34
36
|
"dist-bin-mac": "cd dist && pkg . --public -C gzip -t node20-macos-x64 && zip hfs-mac-x64-$(jq -r .version ../package.json).zip hfs -r plugins && cd ..",
|
|
@@ -85,6 +87,7 @@
|
|
|
85
87
|
"busboy": "^1.6.0",
|
|
86
88
|
"crc-32": "^1.2.2",
|
|
87
89
|
"fast-glob": "^3.3.3",
|
|
90
|
+
"fast-xml-parser": "^5.4.2",
|
|
88
91
|
"find-process": "^2.0.0",
|
|
89
92
|
"fs-x-attributes": "^1.0.2",
|
|
90
93
|
"fswin": "^3.24.829",
|
|
@@ -108,27 +111,5 @@
|
|
|
108
111
|
"valtio": "^1.13.2",
|
|
109
112
|
"xxhashjs": "^0.2.2",
|
|
110
113
|
"yaml": "^2.8.1"
|
|
111
|
-
},
|
|
112
|
-
"devDependencies": {
|
|
113
|
-
"@playwright/test": "^1.55.1",
|
|
114
|
-
"@types/busboy": "^1.5.4",
|
|
115
|
-
"@types/koa": "^3.0.0",
|
|
116
|
-
"@types/koa__router": "^12.0.4",
|
|
117
|
-
"@types/koa-compress": "^4.0.6",
|
|
118
|
-
"@types/koa-mount": "^4.0.5",
|
|
119
|
-
"@types/koa-session": "^6.4.5",
|
|
120
|
-
"@types/lodash": "^4.17.20",
|
|
121
|
-
"@types/mime-types": "^3.0.1",
|
|
122
|
-
"@types/minimist": "^1.2.5",
|
|
123
|
-
"@types/node": "^20.17.30",
|
|
124
|
-
"@types/node-forge": "^1.3.14",
|
|
125
|
-
"@types/picomatch": "^4.0.2",
|
|
126
|
-
"@yao-pkg/pkg": "6.14.1",
|
|
127
|
-
"cross-env": "^10.0.0",
|
|
128
|
-
"koa-better-http-proxy": "^0.2.10",
|
|
129
|
-
"nm-prune": "^5.0.0",
|
|
130
|
-
"nodemon": "^3.1.10",
|
|
131
|
-
"tsx": "^4.20.5",
|
|
132
|
-
"typescript": "^5.9.2"
|
|
133
114
|
}
|
|
134
115
|
}
|
package/src/commands.js
CHANGED
|
@@ -20,6 +20,7 @@ const cross_1 = require("./cross");
|
|
|
20
20
|
const api_monitor_1 = __importDefault(require("./api.monitor"));
|
|
21
21
|
const argv_1 = require("./argv");
|
|
22
22
|
const listen_2 = require("./listen");
|
|
23
|
+
let debugEnabled = argv_1.argv.debug || process.env.HFS_DEBUG;
|
|
23
24
|
if (!argv_1.argv.updating && !config_1.showHelp) {
|
|
24
25
|
try {
|
|
25
26
|
// Not sure if the try is necessary for when stdin is unavailable, but someone reported a problem using nohup https://github.com/rejetto/hfs/issues/74 and I've found this example try-catching https://github.com/DefinitelyTyped/DefinitelyTyped/blob/dda83a906914489e09ca28afea12948529015d4a/types/node/readline.d.ts#L489
|
|
@@ -29,7 +30,7 @@ if (!argv_1.argv.updating && !config_1.showHelp) {
|
|
|
29
30
|
.on('SIGINT', () => process.emit('SIGINT')); // readline swallows the first ctrl+c unless we forward it to process-level handlers
|
|
30
31
|
let isClean = true;
|
|
31
32
|
const showPrompt = tty && lodash_1.default.debounce(() => {
|
|
32
|
-
if (first_1.quitting || !tty)
|
|
33
|
+
if (first_1.quitting || !tty || !isClean)
|
|
33
34
|
return;
|
|
34
35
|
prompter.prompt(true);
|
|
35
36
|
isClean = false;
|
|
@@ -43,12 +44,14 @@ if (!argv_1.argv.updating && !config_1.showHelp) {
|
|
|
43
44
|
}
|
|
44
45
|
showPrompt?.();
|
|
45
46
|
for (const k of ['log', 'warn', 'error', 'debug']) {
|
|
46
|
-
const
|
|
47
|
+
const original = console[k];
|
|
47
48
|
console[k] = (...args) => {
|
|
49
|
+
if (k === 'debug' && !debugEnabled)
|
|
50
|
+
return;
|
|
48
51
|
if (!first_1.quitting && tty)
|
|
49
52
|
clean();
|
|
50
53
|
try {
|
|
51
|
-
|
|
54
|
+
original(...args);
|
|
52
55
|
}
|
|
53
56
|
finally {
|
|
54
57
|
showPrompt?.();
|
|
@@ -58,6 +61,8 @@ if (!argv_1.argv.updating && !config_1.showHelp) {
|
|
|
58
61
|
}
|
|
59
62
|
catch {
|
|
60
63
|
console.log("console commands not available");
|
|
64
|
+
const original = console.debug;
|
|
65
|
+
console.debug = (...args) => debugEnabled && original(...args);
|
|
61
66
|
}
|
|
62
67
|
}
|
|
63
68
|
async function parseCommandLine(line) {
|
|
@@ -156,6 +161,13 @@ const commands = {
|
|
|
156
161
|
console.log(const_1.VERSION, 'build', const_1.BUILD_TIMESTAMP);
|
|
157
162
|
}
|
|
158
163
|
},
|
|
164
|
+
debug: {
|
|
165
|
+
params: '',
|
|
166
|
+
cb() {
|
|
167
|
+
debugEnabled = !debugEnabled;
|
|
168
|
+
console.log(`debug messages ${debugEnabled ? "on" : "off"}`);
|
|
169
|
+
}
|
|
170
|
+
},
|
|
159
171
|
'start-plugin': {
|
|
160
172
|
params: '<name>',
|
|
161
173
|
cb: plugins_1.startPlugin,
|
package/src/const.js
CHANGED
|
@@ -36,16 +36,34 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
36
36
|
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
37
37
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
38
38
|
};
|
|
39
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
40
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
41
|
+
};
|
|
39
42
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
40
|
-
exports.CONFIG_FILE = exports.MIME_AUTO = exports.APP_PATH = exports.IS_BINARY = exports.IS_MAC = exports.IS_WINDOWS = exports.RUNNING_BETA = exports.VERSION = exports.BUILD_TIMESTAMP = exports.HFS_STARTED = exports.ORIGINAL_CWD = exports.DEV = exports.COMPATIBLE_API_VERSION = exports.API_VERSION = void 0;
|
|
43
|
+
exports.CONFIG_FILE = exports.MIME_AUTO = exports.APP_PATH = exports.IS_BINARY = exports.IS_MAC = exports.IS_WINDOWS = exports.RUNNING_BETA = exports.VERSION = exports.BUILD_TIMESTAMP = exports.HFS_STARTED = exports.ORIGINAL_CWD = exports.DEV = exports.ARGS_FILE = exports.COMPATIBLE_API_VERSION = exports.API_VERSION = void 0;
|
|
44
|
+
const minimist_1 = __importDefault(require("minimist"));
|
|
41
45
|
const fs = __importStar(require("fs"));
|
|
42
46
|
const os_1 = require("os");
|
|
47
|
+
const lodash_1 = __importDefault(require("lodash"));
|
|
43
48
|
const path_1 = require("path");
|
|
44
49
|
const cross_1 = require("./cross");
|
|
45
50
|
const argv_1 = require("./argv");
|
|
46
51
|
__exportStar(require("./cross-const"), exports);
|
|
47
52
|
exports.API_VERSION = 13;
|
|
48
53
|
exports.COMPATIBLE_API_VERSION = 1; // the day we break with the past, we'll update this
|
|
54
|
+
// you can add arguments with this file, currently used for the update process on mac/linux.
|
|
55
|
+
// we are using homedir because it's the only stable path both the old and new process can agree on (open() doesn't preserve cwd)
|
|
56
|
+
exports.ARGS_FILE = (0, path_1.join)((0, os_1.homedir)(), 'hfs-args');
|
|
57
|
+
try {
|
|
58
|
+
const s = fs.readFileSync(exports.ARGS_FILE, 'utf-8');
|
|
59
|
+
console.log('additional arguments', s);
|
|
60
|
+
lodash_1.default.defaults(argv_1.argv, (0, minimist_1.default)(JSON.parse(s)));
|
|
61
|
+
fs.unlinkSync(exports.ARGS_FILE);
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
if (e?.code !== 'ENOENT')
|
|
65
|
+
console.error(exports.ARGS_FILE, String(e));
|
|
66
|
+
}
|
|
49
67
|
exports.DEV = process.env.DEV ? 'DEV' : '';
|
|
50
68
|
exports.ORIGINAL_CWD = process.cwd();
|
|
51
69
|
exports.HFS_STARTED = new Date();
|
|
@@ -65,14 +83,11 @@ if (exports.DEV) {
|
|
|
65
83
|
console.clear();
|
|
66
84
|
process.env.DEBUG = 'acme-client';
|
|
67
85
|
}
|
|
68
|
-
else if (!argv_1.argv.debug && !process.env.HFS_DEBUG)
|
|
69
|
-
console.debug = () => { };
|
|
70
86
|
console.log(`HFS ~ HTTP File Server`);
|
|
71
87
|
console.log(`© Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt`);
|
|
72
88
|
console.log('started', (0, cross_1.formatTimestamp)(exports.HFS_STARTED), exports.DEV);
|
|
73
89
|
console.log('version', exports.VERSION || '-');
|
|
74
90
|
console.log('build', exports.BUILD_TIMESTAMP || '-');
|
|
75
|
-
console.debug('arguments', argv_1.argv);
|
|
76
91
|
// still considering whether to use ".hfs" with Windows users, who may be less accustomed to it
|
|
77
92
|
const dir = argv_1.argv.cwd || useHomeDir() && (0, path_1.join)((0, os_1.homedir)(), '.hfs');
|
|
78
93
|
if (dir) {
|
package/src/frontEndApis.js
CHANGED
|
@@ -141,12 +141,17 @@ exports.frontEndApis = {
|
|
|
141
141
|
return new apiMiddleware_1.ApiError(ctx.status);
|
|
142
142
|
let bytes = 0;
|
|
143
143
|
let files = 0;
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
144
|
+
let folders = 0;
|
|
145
|
+
for await (const n of (0, vfs_1.walkNode)(folder, { ctx, depth: Infinity })) {
|
|
146
|
+
if (n.isFolder)
|
|
147
|
+
folders++;
|
|
148
|
+
else {
|
|
149
|
+
bytes += await (0, vfs_1.nodeStats)(n).then(x => x?.size || 0, () => 0);
|
|
150
|
+
files++;
|
|
151
|
+
}
|
|
152
|
+
partialFolderSize[id] = { bytes, files, folders };
|
|
148
153
|
}
|
|
149
|
-
return (0, misc_1.popKey)(partialFolderSize, id) || { bytes, files };
|
|
154
|
+
return (0, misc_1.popKey)(partialFolderSize, id) || { bytes, files, folders };
|
|
150
155
|
},
|
|
151
156
|
};
|
|
152
157
|
function notifyClient(channel, name, data) {
|
package/src/plugins.js
CHANGED
|
@@ -204,18 +204,16 @@ async function initPlugin(pl, morePassedToInit) {
|
|
|
204
204
|
events_1.default.emit('pluginInitialized', pl);
|
|
205
205
|
return pl;
|
|
206
206
|
}
|
|
207
|
-
const already = new Set();
|
|
208
|
-
function warnOnce(msg) {
|
|
209
|
-
if (already.has(msg))
|
|
210
|
-
return;
|
|
211
|
-
already.add(msg);
|
|
212
|
-
console.log('Warning: ' + msg);
|
|
213
|
-
}
|
|
214
207
|
const pluginsMiddleware = async (ctx, next) => {
|
|
215
208
|
const after = {};
|
|
216
209
|
// run middleware plugins
|
|
217
210
|
let lastStatus = ctx.status;
|
|
218
211
|
let lastBody = ctx.body;
|
|
212
|
+
const res = await events_1.default.emitAsync('request', { ctx });
|
|
213
|
+
if (ctx.isAborted() || res?.isDefaultPrevented())
|
|
214
|
+
return;
|
|
215
|
+
if (res?.length)
|
|
216
|
+
after['/event'] = () => res.forEach(misc_1.callable);
|
|
219
217
|
await Promise.all(mapPlugins(async (pl, id) => {
|
|
220
218
|
try {
|
|
221
219
|
const res = await pl.middleware?.(ctx);
|
package/src/roots.js
CHANGED
|
@@ -12,7 +12,7 @@ const lodash_1 = __importDefault(require("lodash"));
|
|
|
12
12
|
exports.roots = (0, config_1.defineConfig)(misc_1.CFG.roots, {}, map => {
|
|
13
13
|
const list = Object.keys(map);
|
|
14
14
|
const matchers = list.map(hostMask => (0, misc_1.makeMatcher)(hostMask));
|
|
15
|
-
const values = Object.values(map).map(x => (0, misc_1.enforceFinal)('/', (0, misc_1.enforceStarting)('/', x)));
|
|
15
|
+
const values = Object.values(map).map(x => (0, misc_1.enforceFinal)('/', (0, misc_1.enforceStarting)('/', x.replace(/\/{2,}/g, '/'))));
|
|
16
16
|
return (host) => values[matchers.findIndex(m => m(host))];
|
|
17
17
|
});
|
|
18
18
|
const forceAddress = (0, config_1.defineConfig)(misc_1.CFG.force_address, false);
|
package/src/update.js
CHANGED
|
@@ -19,6 +19,7 @@ const misc_1 = require("./misc");
|
|
|
19
19
|
const fs_1 = require("fs");
|
|
20
20
|
const plugins_1 = require("./plugins");
|
|
21
21
|
const promises_1 = require("fs/promises");
|
|
22
|
+
const open_1 = __importDefault(require("open"));
|
|
22
23
|
const config_1 = require("./config");
|
|
23
24
|
const util_os_1 = require("./util-os");
|
|
24
25
|
const first_1 = require("./first");
|
|
@@ -190,11 +191,24 @@ if (argv_1.argv.updating) { // we were launched with a temporary name, restore o
|
|
|
190
191
|
// have to relaunch with the new name, or otherwise the next update will fail with EBUSY on hfs.exe
|
|
191
192
|
console.log(`renamed binary file to "${argv_1.argv.updating}" and now restarting`);
|
|
192
193
|
// if you change anything, be sure to test launching both double-clicking and in a terminal
|
|
193
|
-
if (const_1.IS_WINDOWS) // windows-only; this method on
|
|
194
|
+
if (const_1.IS_WINDOWS) // windows-only; this method on mac+linux works only once, and without the console
|
|
194
195
|
(0, first_1.onProcessExit)(() => (0, child_process_1.spawn)((0, util_os_1.cmdEscape)(dest), ['--updated', '--cwd .'], { detached: true, shell: true, stdio: [0, 1, 2] })); // launch+sync here would cause the old process to stay open, locking ports
|
|
195
|
-
else if (
|
|
196
|
-
(
|
|
197
|
-
|
|
198
|
-
|
|
196
|
+
else if (const_1.IS_MAC) {
|
|
197
|
+
// open() is the only consistent way that I could find working on macos preserving console input/output over relaunching,
|
|
198
|
+
// and it doesn't let us pass cli arguments, so we pass them through a temp file consumed at the next startup.
|
|
199
|
+
// For the record, on mac you can: write "./hfs arg1 arg2" to /tmp/tmp.sh with 0o700, and then spawn "open -a Terminal /tmp/tmp.sh"
|
|
200
|
+
try {
|
|
201
|
+
(0, fs_1.writeFileSync)(const_1.ARGS_FILE, JSON.stringify(['--updated', '--cwd', process.cwd()]));
|
|
202
|
+
}
|
|
203
|
+
catch { }
|
|
204
|
+
console.log('open-ing');
|
|
205
|
+
void (0, open_1.default)(dest);
|
|
206
|
+
}
|
|
207
|
+
else { // linux and other *nix
|
|
208
|
+
if (process.stdin.isTTY && process.stdout.isTTY) // in interactive terminals, block this bridge process on the restarted hfs so the terminal session stays attached
|
|
209
|
+
(0, child_process_1.spawnSync)(dest, ['--updated', '--cwd', process.cwd()], { stdio: [0, 1, 2] });
|
|
210
|
+
else
|
|
211
|
+
(0, child_process_1.spawn)(dest, ['--updated', '--cwd', process.cwd()], { detached: true, stdio: 'ignore' }).unref();
|
|
212
|
+
}
|
|
199
213
|
process.exit();
|
|
200
214
|
}
|
package/src/vfs.js
CHANGED
|
@@ -324,6 +324,7 @@ async function* walkNode(parent, { ctx, depth = Infinity, prefixPath = '', requi
|
|
|
324
324
|
&& !hasPermission(parent, requiredPerm, ctx)
|
|
325
325
|
&& !masksCouldGivePermission(parent.masks, requiredPerm))
|
|
326
326
|
return;
|
|
327
|
+
const pathMaskApplier = parentMaskApplier(parent, true);
|
|
327
328
|
try {
|
|
328
329
|
await (0, walkDir_1.walkDir)(source, { depth, ctx, hidden: showHiddenFiles.get(), parallelizeRecursion }, async (entry) => {
|
|
329
330
|
if (ctx?.isAborted()) {
|
|
@@ -344,6 +345,8 @@ async function* walkNode(parent, { ctx, depth = Infinity, prefixPath = '', requi
|
|
|
344
345
|
if (taken?.has(normalizeFilename(name))) // taken by vfs node above
|
|
345
346
|
return false; // false just in case it's a folder
|
|
346
347
|
const item = { name, isFolder, source: (0, path_1.join)(source, path), parent };
|
|
348
|
+
// masks containing '/' must be matched against the relative path while keeping walkDir recursion enabled
|
|
349
|
+
await pathMaskApplier(item, renamed || path);
|
|
347
350
|
if (await cantSee(item)) // can't see: don't produce and don't recur
|
|
348
351
|
return false;
|
|
349
352
|
if (onlyFiles ? !isFolder : (!onlyFolders || isFolder))
|
|
@@ -359,7 +362,7 @@ async function* walkNode(parent, { ctx, depth = Infinity, prefixPath = '', requi
|
|
|
359
362
|
finally {
|
|
360
363
|
await childrenWorking;
|
|
361
364
|
for (const [item, name] of visitLater)
|
|
362
|
-
for await (const x of walkNode(item, { depth: depth - 1, prefixPath: name + '/', ctx, requiredPerm, onlyFolders, parallelizeRecursion }))
|
|
365
|
+
for await (const x of walkNode(item, { depth: depth - 1, prefixPath: name + '/', ctx, requiredPerm, onlyFolders, onlyFiles, parallelizeRecursion }))
|
|
363
366
|
stream.push(x);
|
|
364
367
|
stream.push(null);
|
|
365
368
|
}
|
|
@@ -383,26 +386,40 @@ async function* walkNode(parent, { ctx, depth = Infinity, prefixPath = '', requi
|
|
|
383
386
|
function masksCouldGivePermission(masks, perm) {
|
|
384
387
|
return masks !== undefined && Object.values(masks).some(props => props[perm] || masksCouldGivePermission(props.masks, perm));
|
|
385
388
|
}
|
|
386
|
-
function parentMaskApplier(parent) {
|
|
389
|
+
function parentMaskApplier(parent, pathBased = false) {
|
|
387
390
|
// 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
|
|
388
|
-
const matchers = (0, misc_1.onlyTruthy)(lodash_1.default.map(parent.masks, (mods,
|
|
391
|
+
const matchers = (0, misc_1.onlyTruthy)(lodash_1.default.map(parent.masks, (mods, mask) => {
|
|
389
392
|
if (!mods)
|
|
390
393
|
return;
|
|
391
394
|
const mustBeFolder = (() => {
|
|
392
|
-
if (
|
|
395
|
+
if (mask.at(-1) !== '|')
|
|
393
396
|
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.
|
|
394
|
-
const i =
|
|
397
|
+
const i = mask.lastIndexOf('|', mask.length - 2);
|
|
395
398
|
if (i < 0)
|
|
396
399
|
return;
|
|
397
|
-
const type =
|
|
398
|
-
|
|
400
|
+
const type = mask.slice(i + 1, -1);
|
|
401
|
+
mask = mask.slice(0, i); // remove
|
|
399
402
|
return type === 'folders';
|
|
400
403
|
})();
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
+
if (pathBased) {
|
|
405
|
+
if (!mask.includes('/'))
|
|
406
|
+
return;
|
|
407
|
+
// avoid evaluating twice masks like **/*.png because parentMaskApplier already handles them by basename
|
|
408
|
+
const m = /^(!?)\*\*\//.exec(mask);
|
|
409
|
+
// this keeps the fast basename path as source-of-truth for patterns that collapse to a filename after **/
|
|
410
|
+
if (m && !mask.slice(m[0].length).includes('/'))
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
const m = /^(!?)\*\*\//.exec(mask); // ** globstar matches also zero subfolders, so this mask must be applied here too
|
|
415
|
+
mask = m ? m[1] + mask.slice(m[0].length) : !mask.includes('/') ? mask : '';
|
|
416
|
+
if (!mask)
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
return mask && { matcher: (0, misc_1.makeMatcher)(mask), mods, mustBeFolder };
|
|
404
420
|
}));
|
|
405
|
-
return async (item,
|
|
421
|
+
return async (item, virtualName = (pathBased ? lodash_1.default.identity : path_1.basename)(getNodeName(item))) => {
|
|
422
|
+
// depth traversal passes full relative paths, while node traversal still matches only basenames
|
|
406
423
|
let isFolder = undefined;
|
|
407
424
|
for (const { matcher, mods, mustBeFolder } of matchers) {
|
|
408
425
|
if (mustBeFolder !== undefined) {
|
|
@@ -410,13 +427,14 @@ function parentMaskApplier(parent) {
|
|
|
410
427
|
if (mustBeFolder !== isFolder)
|
|
411
428
|
continue;
|
|
412
429
|
}
|
|
413
|
-
if (!matcher(
|
|
430
|
+
if (!matcher(virtualName))
|
|
414
431
|
continue;
|
|
415
432
|
item.masks &&= lodash_1.default.merge(lodash_1.default.cloneDeep(mods.masks), item.masks); // item.masks must take precedence
|
|
416
433
|
lodash_1.default.defaults(item, mods);
|
|
417
434
|
}
|
|
418
435
|
};
|
|
419
436
|
}
|
|
437
|
+
// propagates masks, don't apply
|
|
420
438
|
function inheritMasks(item, parent, virtualBasename = getNodeName(item)) {
|
|
421
439
|
const { masks } = parent;
|
|
422
440
|
if (!masks)
|
package/src/webdav.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
6
|
exports.handledWebdav = handledWebdav;
|
|
7
|
+
const consumers_1 = require("node:stream/consumers");
|
|
4
8
|
const vfs_1 = require("./vfs");
|
|
5
9
|
const cross_1 = require("./cross");
|
|
6
10
|
const stream_1 = require("stream");
|
|
@@ -14,14 +18,19 @@ const child_process_1 = require("child_process");
|
|
|
14
18
|
const auth_1 = require("./auth");
|
|
15
19
|
const config_1 = require("./config");
|
|
16
20
|
const expiringCache_1 = require("./expiringCache");
|
|
21
|
+
const fast_xml_parser_1 = require("fast-xml-parser");
|
|
22
|
+
const lodash_1 = __importDefault(require("lodash"));
|
|
17
23
|
const forceWebdavLogin = (0, config_1.defineConfig)(cross_1.CFG.force_webdav_login, true, compileWebdavAgentRegex);
|
|
18
24
|
const webdavInitialAuth = (0, config_1.defineConfig)(cross_1.CFG.webdav_initial_auth, 'WebDAVFS', compileWebdavAgentRegex);
|
|
19
25
|
const webdavPrompted = (0, expiringCache_1.expiringCache)(cross_1.DAY);
|
|
20
|
-
const webdavDetectedAgents =
|
|
26
|
+
const webdavDetectedAgents = (0, expiringCache_1.expiringCache)(cross_1.DAY);
|
|
21
27
|
const TOKEN_HEADER = 'lock-token';
|
|
22
|
-
const WEBDAV_METHODS = new Set(['PROPFIND', '
|
|
28
|
+
const WEBDAV_METHODS = new Set(['PROPFIND', 'MKCOL', 'MOVE', 'LOCK', 'UNLOCK']);
|
|
23
29
|
const WEBDAV_HINT_HEADERS = ['depth', 'destination', 'overwrite', 'translate', 'if', TOKEN_HEADER, 'x-expected-entity-length'];
|
|
24
30
|
const KNOWN_UA = /webdav|miniredir|davclnt/i;
|
|
31
|
+
const LOCK_DEFAULT_SECONDS = 3600;
|
|
32
|
+
const LOCK_MAX_SECONDS = cross_1.DAY / 1000;
|
|
33
|
+
const xmlParser = new fast_xml_parser_1.XMLParser({ ignoreAttributes: false, removeNSPrefix: true, trimValues: true });
|
|
25
34
|
const canOverwrite = new Set();
|
|
26
35
|
const locks = new Map();
|
|
27
36
|
function isLocked(path, ctx) {
|
|
@@ -41,17 +50,16 @@ function hasToken(header, token) {
|
|
|
41
50
|
return header.includes(`<${token}>`) || header.split(/[,;\s]+/).includes(token);
|
|
42
51
|
}
|
|
43
52
|
async function handledWebdav(ctx) {
|
|
44
|
-
|
|
45
|
-
|
|
53
|
+
let { path } = ctx;
|
|
54
|
+
path = path.replace(/^\/+/, '/'); // double-slash is causing empty listing in filezilla-pro
|
|
46
55
|
const ua = ctx.get('user-agent');
|
|
47
|
-
if (
|
|
48
|
-
if (ua)
|
|
49
|
-
webdavDetectedAgents.add(ua);
|
|
50
|
-
}
|
|
51
|
-
if (ctx.path.includes('/._') && ua?.startsWith('WebDAVFS')) { // too much spam from Finder for these files that can contain metas
|
|
56
|
+
if (path.includes('/._') && ua?.startsWith('WebDAVFS')) { // too much spam from Finder for these files that can contain metas
|
|
52
57
|
ctx.state.dontLog = true;
|
|
53
58
|
return ctx.status = cross_1.HTTP_FORBIDDEN;
|
|
54
59
|
}
|
|
60
|
+
const isWebdavAuthRequest = WEBDAV_METHODS.has(ctx.method) || WEBDAV_HINT_HEADERS.some(h => ctx.get(h));
|
|
61
|
+
if (isWebdavAuthRequest && ua && (0, auth_1.getCurrentUsername)(ctx))
|
|
62
|
+
webdavDetectedAgents.try(webdavAgentKey(ctx, ua), () => true);
|
|
55
63
|
if (ctx.method === 'OPTIONS') {
|
|
56
64
|
if (ctx.get('Access-Control-Request-Method'))
|
|
57
65
|
return; // it's a preflight cors request, not webdav
|
|
@@ -78,7 +86,7 @@ async function handledWebdav(ctx) {
|
|
|
78
86
|
}
|
|
79
87
|
if (x && ctx.length === undefined) // missing length can make PUT fail
|
|
80
88
|
ctx.req.headers['content-length'] = x;
|
|
81
|
-
if (KNOWN_UA.test(ua) || webdavDetectedAgents.has(ua))
|
|
89
|
+
if (KNOWN_UA.test(ua) || webdavDetectedAgents.has(webdavAgentKey(ctx, ua)))
|
|
82
90
|
ctx.query.existing ??= 'overwrite'; // with webdav this is our default
|
|
83
91
|
return; // default handling
|
|
84
92
|
}
|
|
@@ -157,22 +165,60 @@ async function handledWebdav(ctx) {
|
|
|
157
165
|
}
|
|
158
166
|
if (ctx.method === 'LOCK') {
|
|
159
167
|
setWebdavHeaders();
|
|
168
|
+
const body = ctx.length || ctx.get('content-length') || ctx.get('transfer-encoding') ? await (0, consumers_1.text)(ctx.req) : '';
|
|
169
|
+
const token = getProvidedLockToken(ctx);
|
|
170
|
+
let seconds = Number(ctx.get('timeout').split(',').find(x => /^Second-\d+$/i.test(x.trim()))?.trim().split('-', 2)[1]);
|
|
171
|
+
seconds = lodash_1.default.clamp(seconds || LOCK_DEFAULT_SECONDS, 1, LOCK_MAX_SECONDS);
|
|
172
|
+
if (!body) {
|
|
173
|
+
// Finder and similar clients refresh an existing lock by sending LOCK without a body
|
|
174
|
+
if (!token)
|
|
175
|
+
return ctx.status = cross_1.HTTP_BAD_REQUEST;
|
|
176
|
+
const lock = locks.get(path);
|
|
177
|
+
if (token !== lock?.token)
|
|
178
|
+
return ctx.status = cross_1.HTTP_PRECONDITION_FAILED;
|
|
179
|
+
// refresh lock – keep the same token on refresh so clients can continue using the lock they already hold
|
|
180
|
+
clearTimeout(lock.timeout);
|
|
181
|
+
lock.timeout = setTimeout(() => locks.delete(path), seconds * 1000);
|
|
182
|
+
lock.seconds = seconds;
|
|
183
|
+
locks.set(path, lock);
|
|
184
|
+
ctx.set(TOKEN_HEADER, lock.token);
|
|
185
|
+
ctx.body = renderLockResponse(lock.token, lock.seconds);
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
const lockinfo = (0, cross_1.try_)(() => xmlParser.parse(body).lockinfo);
|
|
189
|
+
const scope = lodash_1.default.keys(lockinfo?.lockscope)[0];
|
|
190
|
+
const type = lodash_1.default.keys(lockinfo?.locktype)[0];
|
|
191
|
+
if (!scope || !type)
|
|
192
|
+
return ctx.status = cross_1.HTTP_BAD_REQUEST;
|
|
193
|
+
if (ctx.get('depth') && ctx.get('depth') !== '0')
|
|
194
|
+
return ctx.status = cross_1.HTTP_CONFLICT;
|
|
195
|
+
if (scope !== 'exclusive' || type !== 'write')
|
|
196
|
+
return ctx.status = cross_1.HTTP_CONFLICT;
|
|
160
197
|
if (locks.has(path))
|
|
161
|
-
return ctx.status =
|
|
162
|
-
const
|
|
163
|
-
ctx.set(TOKEN_HEADER, token);
|
|
164
|
-
const seconds = 3600;
|
|
198
|
+
return ctx.status = cross_1.HTTP_LOCKED;
|
|
199
|
+
const newToken = 'urn:uuid:' + (0, node_crypto_1.randomUUID)();
|
|
165
200
|
const timeout = setTimeout(() => locks.delete(path), seconds * 1000);
|
|
166
|
-
locks.set(path, { token, timeout });
|
|
167
|
-
ctx.
|
|
168
|
-
|
|
169
|
-
<lockscope><exclusive/></lockscope>
|
|
170
|
-
<locktoken><href>${token}</href></locktoken>
|
|
171
|
-
<lockroot><href>${path}</href></lockroot>
|
|
172
|
-
<depth>0</depth>
|
|
173
|
-
<timeout>Second-${seconds}</timeout>
|
|
174
|
-
</activelock></lockdiscovery></prop>`;
|
|
201
|
+
locks.set(path, { token: newToken, timeout, seconds });
|
|
202
|
+
ctx.set(TOKEN_HEADER, newToken);
|
|
203
|
+
ctx.body = renderLockResponse(newToken, seconds);
|
|
175
204
|
return true;
|
|
205
|
+
function getProvidedLockToken(ctx) {
|
|
206
|
+
const direct = ctx.get(TOKEN_HEADER).replace(/[<>]/g, '');
|
|
207
|
+
if (direct)
|
|
208
|
+
return direct;
|
|
209
|
+
const ifHeader = ctx.get('If');
|
|
210
|
+
return /<([^>]+)>/.exec(ifHeader)?.[1] || '';
|
|
211
|
+
}
|
|
212
|
+
function renderLockResponse(token, seconds) {
|
|
213
|
+
return `<?xml version="1.0" encoding="utf-8"?><prop xmlns="DAV:"><lockdiscovery><activelock>
|
|
214
|
+
<locktype><write/></locktype>
|
|
215
|
+
<lockscope><exclusive/></lockscope>
|
|
216
|
+
<locktoken><href>${token}</href></locktoken>
|
|
217
|
+
<lockroot><href>${path}</href></lockroot>
|
|
218
|
+
<depth>0</depth>
|
|
219
|
+
<timeout>Second-${seconds}</timeout>
|
|
220
|
+
</activelock></lockdiscovery></prop>`;
|
|
221
|
+
}
|
|
176
222
|
}
|
|
177
223
|
if (ctx.method === 'PROPFIND') {
|
|
178
224
|
setWebdavHeaders();
|
|
@@ -189,7 +235,7 @@ async function handledWebdav(ctx) {
|
|
|
189
235
|
}
|
|
190
236
|
ctx.type = 'xml';
|
|
191
237
|
ctx.status = 207;
|
|
192
|
-
const
|
|
238
|
+
const outPath = (0, cross_1.enforceFinal)('/', path.slice(Math.max(0, (ctx.state.root?.length ?? 0) - 1)), true);
|
|
193
239
|
const res = ctx.body = new stream_1.PassThrough({ encoding: 'utf8' });
|
|
194
240
|
res.write(`<?xml version="1.0" encoding="utf-8" ?><multistatus xmlns="DAV:">`);
|
|
195
241
|
await sendEntry(node);
|
|
@@ -208,7 +254,7 @@ async function handledWebdav(ctx) {
|
|
|
208
254
|
const isDir = await (0, vfs_1.nodeIsFolder)(node);
|
|
209
255
|
const st = await (0, vfs_1.nodeStats)(node);
|
|
210
256
|
res.write(`<response>
|
|
211
|
-
<href>${
|
|
257
|
+
<href>${outPath + (append ? (0, cross_1.pathEncode)(name, true) + (isDir ? '/' : '') : '')}</href>
|
|
212
258
|
<propstat>
|
|
213
259
|
<status>HTTP/1.1 200 OK</status>
|
|
214
260
|
<prop>
|
|
@@ -224,34 +270,12 @@ async function handledWebdav(ctx) {
|
|
|
224
270
|
}
|
|
225
271
|
if (ctx.method === 'PROPPATCH') {
|
|
226
272
|
setWebdavHeaders();
|
|
227
|
-
|
|
228
|
-
return true;
|
|
229
|
-
const node = await (0, vfs_1.urlToNode)(path, ctx);
|
|
230
|
-
if (!node)
|
|
231
|
-
return;
|
|
232
|
-
if ((0, vfs_1.statusCodeForMissingPerm)(node, 'can_see', ctx)) {
|
|
233
|
-
if (ctx.status === cross_1.HTTP_UNAUTHORIZED)
|
|
234
|
-
setWebdavHeaders(true);
|
|
235
|
-
return true;
|
|
236
|
-
}
|
|
237
|
-
ctx.type = 'xml';
|
|
238
|
-
ctx.status = 207;
|
|
239
|
-
ctx.body = `<?xml version="1.0" encoding="utf-8"?>
|
|
240
|
-
<multistatus xmlns="DAV:">
|
|
241
|
-
<response>
|
|
242
|
-
<href>${path}</href>
|
|
243
|
-
<propstat>
|
|
244
|
-
<status>HTTP/1.1 200 OK</status>
|
|
245
|
-
<prop/>
|
|
246
|
-
</propstat>
|
|
247
|
-
</response>
|
|
248
|
-
</multistatus>`;
|
|
249
|
-
return true;
|
|
273
|
+
return ctx.status = cross_1.HTTP_METHOD_NOT_ALLOWED;
|
|
250
274
|
}
|
|
251
275
|
function setWebdavHeaders(authenticate = false) {
|
|
252
276
|
ctx.set('DAV', '1,2');
|
|
253
277
|
ctx.set('MS-Author-Via', 'DAV');
|
|
254
|
-
ctx.set('Allow', 'PROPFIND,
|
|
278
|
+
ctx.set('Allow', 'PROPFIND,OPTIONS,DELETE,MOVE,LOCK,UNLOCK,MKCOL,PUT');
|
|
255
279
|
if (authenticate)
|
|
256
280
|
ctx.set('WWW-Authenticate', `Basic realm="HFS WebDAV"`); // keep a dedicated realm for WebDAV so Windows credential cache is isolated from other basic-auth flows
|
|
257
281
|
}
|
|
@@ -280,6 +304,10 @@ async function handledWebdav(ctx) {
|
|
|
280
304
|
function compileWebdavAgentRegex(v) {
|
|
281
305
|
return !v ? null : v === true ? /.*/ : new RegExp(v.trim(), 'i');
|
|
282
306
|
}
|
|
307
|
+
function webdavAgentKey(ctx, ua) {
|
|
308
|
+
// tying detection to source IP avoids promoting one spoofed UA to global WebDAV behavior
|
|
309
|
+
return `${ctx.ip}|${ua}`;
|
|
310
|
+
}
|
|
283
311
|
// Finder will upload special attributes as files with name ._* that can be merged using system utility "dot_clean"
|
|
284
312
|
const cleaners = {};
|
|
285
313
|
function dotClean(path) {
|