hfs 3.1.0-beta4 → 3.1.0-beta6
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/README.md +7 -11
- package/admin/assets/{index-BL84dr-c.js → index-14Q6fQhw.js} +1 -1
- package/admin/assets/{index-aI7IEKyS.js → index-VnuTY7OR.js} +98 -98
- package/admin/assets/{sha512-BUo7IPyY.js → sha512-DDIK-e99.js} +1 -1
- package/admin/index.html +1 -1
- package/frontend/assets/{index-legacy-CVyhaUXB.js → index-legacy-C8oPp-Hh.js} +1 -1
- package/frontend/assets/index-legacy-l9a4YlnQ.js +9 -0
- package/frontend/assets/{sha512-legacy-CXmh1M3g.js → sha512-legacy-DOR1zo9J.js} +1 -1
- package/frontend/index.html +1 -1
- package/npm-shrinkwrap.json +28 -28
- package/package.json +1 -1
- package/src/cross-const.js +2 -1
- package/src/cross.js +1 -0
- package/src/github.js +2 -1
- package/src/index.js +6 -5
- package/src/langs/hfs-lang-ar.json +1 -1
- package/src/langs/hfs-lang-bg.json +1 -1
- package/src/langs/hfs-lang-de.json +1 -1
- package/src/langs/hfs-lang-el.json +1 -1
- package/src/langs/hfs-lang-es.json +1 -1
- package/src/langs/hfs-lang-fi.json +1 -1
- package/src/langs/hfs-lang-fr.json +1 -1
- package/src/langs/hfs-lang-hu.json +1 -1
- package/src/langs/hfs-lang-it.json +1 -1
- package/src/langs/hfs-lang-ja.json +1 -1
- package/src/langs/hfs-lang-ko.json +1 -1
- package/src/langs/hfs-lang-lt.json +1 -1
- package/src/langs/hfs-lang-ms.json +1 -1
- package/src/langs/hfs-lang-nl.json +1 -1
- package/src/langs/hfs-lang-pt-br.json +1 -1
- package/src/langs/hfs-lang-ro.json +1 -1
- package/src/langs/hfs-lang-ru.json +1 -1
- package/src/langs/hfs-lang-sr.json +1 -1
- package/src/langs/hfs-lang-th.json +1 -1
- package/src/langs/hfs-lang-tr.json +1 -1
- package/src/langs/hfs-lang-uk.json +1 -1
- package/src/langs/hfs-lang-vi.json +1 -1
- package/src/langs/hfs-lang-zh-tw.json +1 -1
- package/src/langs/hfs-lang-zh.json +1 -1
- package/src/listen.js +3 -0
- package/src/log.js +12 -10
- package/src/makeQ.js +10 -4
- package/src/multipartUpload.js +7 -1
- package/src/serveGuiAndSharedFiles.js +10 -8
- package/src/serveGuiFiles.js +2 -1
- package/src/throttler.js +2 -1
- package/src/update.js +5 -2
- package/src/upload.js +4 -3
- package/src/util-os.js +26 -2
- package/src/vfs.js +5 -13
- package/src/webdav.js +87 -47
- package/frontend/assets/index-legacy-f8_Sicwh.js +0 -9
package/src/log.js
CHANGED
|
@@ -101,7 +101,7 @@ const logUA = (0, config_1.defineConfig)(misc_1.CFG.log_ua, false);
|
|
|
101
101
|
const logSpam = (0, config_1.defineConfig)(misc_1.CFG.log_spam, false);
|
|
102
102
|
const debounce = lodash_1.default.debounce(cb => cb(), 1000); // with this technique, i'll be able to debounce some code respecting the references in its closure
|
|
103
103
|
const logMw = async (ctx, next) => {
|
|
104
|
-
const
|
|
104
|
+
const reqStart = new Date(); // request start
|
|
105
105
|
const userAtStart = (0, auth_1.getCurrentUsername)(ctx);
|
|
106
106
|
// do it now so it's available for returning plugins
|
|
107
107
|
ctx.state.completed = Promise.race([(0, events_1.once)(ctx.res, 'finish'), (0, events_1.once)(ctx.res, 'close')]);
|
|
@@ -130,13 +130,14 @@ const logMw = async (ctx, next) => {
|
|
|
130
130
|
let { stream, last, path } = logger;
|
|
131
131
|
if (!stream)
|
|
132
132
|
return;
|
|
133
|
-
logger.last =
|
|
133
|
+
logger.last = reqStart;
|
|
134
134
|
if (rotate && last) { // rotation enabled and a file exists?
|
|
135
|
-
const passed = Number(
|
|
135
|
+
const passed = Number(reqStart) - Number(last)
|
|
136
136
|
- 3600_000; // be pessimistic and count a possible DST change
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|| rotate === '
|
|
137
|
+
const t = reqStart;
|
|
138
|
+
if (rotate === 'm' && (passed >= 31 * misc_1.DAY || t.getMonth() !== last.getMonth())
|
|
139
|
+
|| rotate === 'd' && (passed >= misc_1.DAY || t.getDate() !== last.getDate()) // checking passed will solve the case when the day of the month is the same but a month has passed
|
|
140
|
+
|| rotate === 'w' && (passed >= 7 * misc_1.DAY || t.getDay() < last.getDay())) {
|
|
140
141
|
stream.end();
|
|
141
142
|
const suffix = '-' + last.getFullYear() + '-' + doubleDigit(last.getMonth() + 1) + '-' + doubleDigit(last.getDate());
|
|
142
143
|
const newPath = (0, misc_1.strinsert)(path, path.length - (0, path_1.extname)(path).length, suffix);
|
|
@@ -152,12 +153,13 @@ const logMw = async (ctx, next) => {
|
|
|
152
153
|
}
|
|
153
154
|
}
|
|
154
155
|
const format = '%s - %s [%s] "%s %s HTTP/%s" %d %s %s\n'; // Apache's Common Log Format
|
|
155
|
-
|
|
156
|
-
const
|
|
156
|
+
// keep reqStart for duration/rotation, but log time should reflect when the line is actually written
|
|
157
|
+
const a = new Date().toString().split(' '); // like nginx, our default log contains the time of log writing
|
|
158
|
+
const date = `${a[2]}/${a[1]}/${a[3]}:${a[4]} ${a[5]?.slice(3)}`;
|
|
157
159
|
const user = (0, auth_1.getCurrentUsername)(ctx) || userAtStart;
|
|
158
160
|
const length = ctx.state.length ?? ctx.length;
|
|
159
161
|
const uri = ctx.originalUrl;
|
|
160
|
-
const duration = (Date.now() - Number(
|
|
162
|
+
const duration = (Date.now() - Number(reqStart)) / 1000;
|
|
161
163
|
ctx.logExtra(ctx.vfsNode && {
|
|
162
164
|
speed: Math.round(length / duration),
|
|
163
165
|
...ctx.state.includesLastByte && ctx.res.finished && { dl: 1 }
|
|
@@ -172,7 +174,7 @@ const logMw = async (ctx, next) => {
|
|
|
172
174
|
ctx.logExtra({ ua: ctx.get('user-agent') || undefined });
|
|
173
175
|
const extra = ctx.state.logExtra;
|
|
174
176
|
if (events_2.default.anyListener(logger.name)) // small optimization: this event can happen often, while most times there's no listener, and the parameters object is constructed pointlessly. A benchmark measured it 20% faster (just the line), while maybe it was not necessary.
|
|
175
|
-
events_2.default.emit(logger.name, { ctx, length, user, ts:
|
|
177
|
+
events_2.default.emit(logger.name, { ctx, length, user, ts: reqStart, uri, extra });
|
|
176
178
|
debounce(() => // once in a while we check if the file is still good (not deleted, etc), or we'll reopen it
|
|
177
179
|
(0, util_files_1.statWithTimeout)(logger.path).catch(() => logger.reopen())); // async = smoother but we may lose some entries
|
|
178
180
|
stream.write(util.format(format, ctx.ip, user || '-', date, ctx.method, uri, ctx.req.httpVersion, ctx.status, length?.toString() ?? '-', lodash_1.default.isEmpty(extra) ? '' : JSON.stringify(JSON.stringify(extra))));
|
package/src/makeQ.js
CHANGED
|
@@ -1,27 +1,33 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.makeQ = makeQ;
|
|
4
|
-
|
|
4
|
+
const asap = globalThis.setImmediate || setTimeout;
|
|
5
|
+
function makeQ(parallelization = 1, max = Infinity) {
|
|
5
6
|
const running = new Set();
|
|
6
7
|
const queued = [];
|
|
7
8
|
return {
|
|
8
9
|
add(toAdd) {
|
|
10
|
+
if (queued.length >= max + parallelization - running.size) // we may have some free slots that will be used at the next tick
|
|
11
|
+
return false;
|
|
9
12
|
queued.push(toAdd);
|
|
10
|
-
|
|
13
|
+
asap(startNextIfPossible); // avoid calling now, as it would cause nesting/stacking of jobs
|
|
14
|
+
return true;
|
|
11
15
|
},
|
|
12
16
|
isWorking() { return running.size > 0; },
|
|
13
17
|
isFree() { return running.size < parallelization; },
|
|
18
|
+
setMax(newMax) { max = newMax; },
|
|
19
|
+
queueSize() { return queued.length; },
|
|
14
20
|
};
|
|
15
21
|
function startNextIfPossible() {
|
|
16
22
|
while (running.size < parallelization) {
|
|
17
|
-
const job = queued.
|
|
23
|
+
const job = queued.shift();
|
|
18
24
|
if (!job)
|
|
19
25
|
break; // finished
|
|
20
26
|
const working = job(); // start the job
|
|
21
27
|
if (!working)
|
|
22
28
|
continue; // it was canceled
|
|
23
29
|
running.add(working);
|
|
24
|
-
working.
|
|
30
|
+
working.finally(() => {
|
|
25
31
|
running.delete(working);
|
|
26
32
|
startNextIfPossible();
|
|
27
33
|
});
|
package/src/multipartUpload.js
CHANGED
|
@@ -11,6 +11,7 @@ const path_1 = require("path");
|
|
|
11
11
|
const upload_1 = require("./upload");
|
|
12
12
|
const cross_const_1 = require("./cross-const");
|
|
13
13
|
const first_1 = require("./first");
|
|
14
|
+
const cross_1 = require("./cross");
|
|
14
15
|
async function handleMultipartUpload(ctx, node) {
|
|
15
16
|
if (ctx.request.type !== 'multipart/form-data')
|
|
16
17
|
return ctx.status = cross_const_1.HTTP_BAD_REQUEST;
|
|
@@ -18,7 +19,12 @@ async function handleMultipartUpload(ctx, node) {
|
|
|
18
19
|
const locks = [];
|
|
19
20
|
const fileJobs = [];
|
|
20
21
|
const errors = [];
|
|
21
|
-
const bb = (0, busboy_1.default)({ headers: ctx.req.headers, preservePath: true })
|
|
22
|
+
const bb = (0, cross_1.try_)(() => (0, busboy_1.default)({ headers: ctx.req.headers, preservePath: true }), e => {
|
|
23
|
+
ctx.body = String(e); // busboy validates multipart headers at construction time, so malformed requests must stop here as 400
|
|
24
|
+
ctx.status = cross_const_1.HTTP_BAD_REQUEST;
|
|
25
|
+
});
|
|
26
|
+
if (!bb)
|
|
27
|
+
return;
|
|
22
28
|
bb.on('field', (name) => {
|
|
23
29
|
if (name === 'upload')
|
|
24
30
|
errors.push('empty filename');
|
|
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.
|
|
6
|
+
exports.serveSharedFiles = exports.guiFilesMiddleware = void 0;
|
|
7
7
|
const path_1 = require("path");
|
|
8
8
|
const vfs_1 = require("./vfs");
|
|
9
9
|
const errorPages_1 = require("./errorPages");
|
|
@@ -28,12 +28,11 @@ const comments_1 = require("./comments");
|
|
|
28
28
|
const basicWeb_1 = require("./basicWeb");
|
|
29
29
|
const icons_1 = require("./icons");
|
|
30
30
|
const plugins_1 = require("./plugins");
|
|
31
|
-
const webdav_1 = require("./webdav");
|
|
32
31
|
const serveFrontendFiles = (0, serveGuiFiles_1.serveGuiFiles)(process.env.FRONTEND_PROXY, cross_const_1.FRONTEND_URI);
|
|
33
32
|
const serveFrontendPrefixed = (0, koa_mount_1.default)(cross_const_1.FRONTEND_URI.slice(0, -1), serveFrontendFiles);
|
|
34
33
|
const serveAdminFiles = (0, serveGuiFiles_1.serveGuiFiles)(process.env.ADMIN_PROXY, cross_const_1.ADMIN_URI);
|
|
35
34
|
const serveAdminPrefixed = (0, koa_mount_1.default)(cross_const_1.ADMIN_URI.slice(0, -1), serveAdminFiles);
|
|
36
|
-
const
|
|
35
|
+
const guiFilesMiddleware = async (ctx, next) => {
|
|
37
36
|
const { path } = ctx;
|
|
38
37
|
// dynamic import on frontend|admin (used for non-https login) while developing (vite4) is not producing a relative path
|
|
39
38
|
if (const_1.DEV && path.startsWith('/node_modules/')) {
|
|
@@ -59,15 +58,18 @@ const serveGuiAndSharedFiles = async (ctx, next) => {
|
|
|
59
58
|
ctx.state.considerAsGui = true;
|
|
60
59
|
return (0, serveFile_1.serveFile)(ctx, (0, path_1.join)(plugin?.folder || '', icons_1.ICONS_FOLDER, file), const_1.MIME_AUTO);
|
|
61
60
|
}
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
return next();
|
|
62
|
+
};
|
|
63
|
+
exports.guiFilesMiddleware = guiFilesMiddleware;
|
|
64
|
+
const serveSharedFiles = async (ctx, next) => {
|
|
65
|
+
const { path } = ctx;
|
|
64
66
|
const { get } = ctx.query;
|
|
65
67
|
const getUploadTempHash = get === cross_const_1.UPLOAD_TEMP_HASH;
|
|
66
68
|
if (ctx.method === 'PUT' || getUploadTempHash) { // PUT is what you get with `curl -T file url/`
|
|
67
69
|
const decPath = (0, misc_1.safeDecodeURIComponent)(path, '');
|
|
68
70
|
const fn = (0, path_1.basename)(decPath);
|
|
69
71
|
const folderUri = (0, misc_1.pathEncode)((0, path_1.dirname)(decPath)); // re-encode to get readable urls
|
|
70
|
-
const folder = await (0, vfs_1.urlToNode)(folderUri, ctx, vfs_1.vfs, true);
|
|
72
|
+
const folder = await (0, vfs_1.urlToNode)(folderUri, ctx, vfs_1.vfs, true); // we don't require the folder to already exist, but to be mapped on disk AND to have proper permissions
|
|
71
73
|
if (!folder)
|
|
72
74
|
return (0, errorPages_1.sendErrorPage)(ctx, cross_const_1.HTTP_NOT_FOUND);
|
|
73
75
|
ctx.state.uploadPath = decPath;
|
|
@@ -149,7 +151,7 @@ const serveGuiAndSharedFiles = async (ctx, next) => {
|
|
|
149
151
|
: get === 'list' ? sendFolderList(node, ctx)
|
|
150
152
|
: ((0, basicWeb_1.basicWeb)(ctx, node) || serveFrontendFiles(ctx, next));
|
|
151
153
|
};
|
|
152
|
-
exports.
|
|
154
|
+
exports.serveSharedFiles = serveSharedFiles;
|
|
153
155
|
async function sendFolderList(node, ctx) {
|
|
154
156
|
if ((await events_1.default.emitAsync('getList', { node, ctx }))?.isDefaultPrevented())
|
|
155
157
|
return;
|
|
@@ -161,7 +163,7 @@ async function sendFolderList(node, ctx) {
|
|
|
161
163
|
|| URL.protocol + '//' + URL.host + ctx.state.revProxyPath;
|
|
162
164
|
prepend = base + (0, misc_1.pathEncode)(decodeURI(ctx.path)); // redo the encoding our way, keeping unicode chars unchanged
|
|
163
165
|
}
|
|
164
|
-
const walker = (0, vfs_1.walkNode)(node, { ctx, depth: depth === '*' ? Infinity : Number(depth), parallelizeRecursion: false });
|
|
166
|
+
const walker = (0, vfs_1.walkNode)(node, { ctx, depth: depth === '*' ? Infinity : Number(depth), parallelizeRecursion: false }); // parallelization produces out-of-order results, and we don't want it like that here
|
|
165
167
|
ctx.body = (0, misc_1.asyncGeneratorToReadable)((0, misc_1.filterMapGenerator)(walker, async (el) => {
|
|
166
168
|
const isFolder = (0, vfs_1.nodeIsFolder)(el);
|
|
167
169
|
return !folders && isFolder ? undefined
|
package/src/serveGuiFiles.js
CHANGED
|
@@ -90,6 +90,7 @@ async function treatIndex(ctx, filesUri, body) {
|
|
|
90
90
|
VERSION: const_1.VERSION,
|
|
91
91
|
API_VERSION: const_1.API_VERSION,
|
|
92
92
|
SPECIAL_URI: const_1.SPECIAL_URI, PLUGINS_PUB_URI: const_1.PLUGINS_PUB_URI, FRONTEND_URI: const_1.FRONTEND_URI,
|
|
93
|
+
pathSeparator: path_1.sep,
|
|
93
94
|
session: session instanceof apiMiddleware_1.ApiError ? null : session,
|
|
94
95
|
plugins,
|
|
95
96
|
loadScripts: Object.fromEntries((0, plugins_1.mapPlugins)((p, id) => [id, p.frontend_js?.map(f => f.includes('//') ? f : pub + id + '/' + f)])),
|
|
@@ -174,7 +175,7 @@ function serveProxied(port, uri) {
|
|
|
174
175
|
return function (ctx, next) {
|
|
175
176
|
if (!exports.logGui.get())
|
|
176
177
|
ctx.state.dontLog = true;
|
|
177
|
-
return proxy(ctx,
|
|
178
|
+
return proxy(ctx, async () => { });
|
|
178
179
|
};
|
|
179
180
|
}
|
|
180
181
|
function serveGuiFiles(proxyPort, uri) {
|
package/src/throttler.js
CHANGED
|
@@ -57,7 +57,8 @@ const throttler = async (ctx, next) => {
|
|
|
57
57
|
if (outSpeed || !closed)
|
|
58
58
|
conn[SymTimeout] = setTimeout(update, DELAY);
|
|
59
59
|
}, DELAY, { leading: true, maxWait: DELAY });
|
|
60
|
-
ts.on('sent', (n) => {
|
|
60
|
+
ts.on('sent', async (n) => {
|
|
61
|
+
await exports.totalSent.ready();
|
|
61
62
|
exports.totalSent.set(x => x + n);
|
|
62
63
|
update();
|
|
63
64
|
});
|
package/src/update.js
CHANGED
|
@@ -134,13 +134,16 @@ async function update(tagOrUrl = '') {
|
|
|
134
134
|
}
|
|
135
135
|
if (url) {
|
|
136
136
|
console.log("downloading", url);
|
|
137
|
+
const temp = LOCAL_UPDATE + '-temp';
|
|
138
|
+
await (0, promises_1.rm)(temp, { force: true });
|
|
137
139
|
try {
|
|
138
|
-
await (0, promises_1.writeFile)(
|
|
140
|
+
await (0, promises_1.writeFile)(temp, await (0, misc_1.httpStream)(url));
|
|
139
141
|
}
|
|
140
142
|
catch (e) {
|
|
141
|
-
await (0, promises_1.rm)(
|
|
143
|
+
await (0, promises_1.rm)(temp).catch(() => { }); // no leftovers
|
|
142
144
|
throw "Download failed for " + url + (0, misc_1.prefix)(' – ', e?.message);
|
|
143
145
|
}
|
|
146
|
+
await (0, promises_1.rename)(temp, LOCAL_UPDATE);
|
|
144
147
|
console.debug("download finished");
|
|
145
148
|
}
|
|
146
149
|
const bin = process.execPath;
|
package/src/upload.js
CHANGED
|
@@ -66,12 +66,13 @@ function uploadWriter(base, baseUri, filename, ctx) {
|
|
|
66
66
|
// enforce minAvailableMb
|
|
67
67
|
const min = exports.minAvailableMb.get() * (1 << 20);
|
|
68
68
|
const { simulate } = ctx.query; // `simulate` is used to get the same error but with an empty body, so that the request is processed quickly
|
|
69
|
-
const contentLength = Number(simulate
|
|
70
|
-
|
|
69
|
+
const contentLength = Number(simulate ?? ctx.headers["content-length"]
|
|
70
|
+
?? 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
|
|
71
|
+
const isPartial = ctx.query.partial !== undefined; // while the presence of "partial" conveys that the upload is split...
|
|
71
72
|
const stillToWrite = Math.max(contentLength, Number(ctx.query.partial) || 0); // ...the number is used to tell how much space we need (fullSize - offset)
|
|
72
73
|
if (isNaN(stillToWrite)) {
|
|
73
74
|
if (min)
|
|
74
|
-
return fail(const_1.
|
|
75
|
+
return fail(const_1.HTTP_LENGTH_REQUIRED);
|
|
75
76
|
}
|
|
76
77
|
else
|
|
77
78
|
try {
|
package/src/util-os.js
CHANGED
|
@@ -84,15 +84,39 @@ async function getWindowsServicePids() {
|
|
|
84
84
|
const no = parsed?.[1]?.[2];
|
|
85
85
|
return Object.fromEntries(parsed.slice(2).filter(x => x[2] !== no).map(x => [x[1], x[2]]));
|
|
86
86
|
}
|
|
87
|
-
exports.RUNNING_AS_SERVICE =
|
|
88
|
-
const ret = x[node_process_1.pid] || x[node_process_1.ppid];
|
|
87
|
+
exports.RUNNING_AS_SERVICE = detectRunningAsService().then(ret => {
|
|
89
88
|
if (ret)
|
|
90
89
|
console.log("running as service", ret);
|
|
91
90
|
return ret;
|
|
92
91
|
}, e => {
|
|
93
92
|
console.log("couldn't determine if we are running as a service");
|
|
94
93
|
console.debug(e);
|
|
94
|
+
return false;
|
|
95
95
|
});
|
|
96
|
+
async function detectRunningAsService() {
|
|
97
|
+
if (const_1.IS_WINDOWS)
|
|
98
|
+
return getWindowsServicePids().then(x => x[node_process_1.pid] || x[node_process_1.ppid]);
|
|
99
|
+
if (process.platform === 'linux') {
|
|
100
|
+
// interactive shells live in session-*.scope; only a leaf *.service means this process is actually inside a service unit
|
|
101
|
+
const systemdFromCgroup = await (0, promises_1.readFile)('/proc/self/cgroup', 'utf8').then(cgroup => cgroup.split('\n')
|
|
102
|
+
// cgroup v1 uses single-colon fields while v2 uses double-colon; taking the last colon-separated field covers both
|
|
103
|
+
.map(line => line.split(':').pop()?.split('/').pop() || '')
|
|
104
|
+
.find(unit => unit.endsWith('.service')));
|
|
105
|
+
if (systemdFromCgroup)
|
|
106
|
+
return systemdFromCgroup;
|
|
107
|
+
// keep env markers as a last fallback only for non-interactive runs, to avoid false positives in normal terminal sessions
|
|
108
|
+
if (!process.stdin.isTTY && !process.stdout.isTTY && (process.env.INVOCATION_ID || process.env.JOURNAL_STREAM || process.env.NOTIFY_SOCKET || process.env.SYSTEMD_EXEC_PID))
|
|
109
|
+
return process.env.INVOCATION_ID || process.env.SYSTEMD_EXEC_PID || 'systemd';
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
// launchd services expose LAUNCH_JOB_NAME and run detached from interactive ttys
|
|
113
|
+
if (!const_1.IS_MAC || process.stdin.isTTY || process.stdout.isTTY)
|
|
114
|
+
return false;
|
|
115
|
+
const launchdService = process.env.LAUNCH_JOB_NAME || false;
|
|
116
|
+
if (launchdService)
|
|
117
|
+
return launchdService;
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
96
120
|
function reg(...pars) {
|
|
97
121
|
return (0, util_1.promisify)(child_process_1.execFile)('reg', pars).then(x => x.stdout);
|
|
98
122
|
}
|
package/src/vfs.js
CHANGED
|
@@ -92,7 +92,8 @@ async function applyParentToChild(child, parent, name) {
|
|
|
92
92
|
inheritFromParent(ret);
|
|
93
93
|
return ret;
|
|
94
94
|
}
|
|
95
|
-
async function urlToNode(url, ctx, parent = exports.vfs,
|
|
95
|
+
async function urlToNode(url, ctx, parent = exports.vfs, allowMissing // true means missing path segments still resolve to temporary nodes with a computed source path
|
|
96
|
+
) {
|
|
96
97
|
let initialSlashes = 0;
|
|
97
98
|
while (url[initialSlashes] === '/')
|
|
98
99
|
initialSlashes++;
|
|
@@ -105,25 +106,16 @@ async function urlToNode(url, ctx, parent = exports.vfs, resolveMissing) {
|
|
|
105
106
|
return;
|
|
106
107
|
const hasTrailingSlash = url.endsWith('/');
|
|
107
108
|
const rest = nextSlash < 0 ? '' : url.slice(nextSlash + 1, hasTrailingSlash ? -1 : undefined);
|
|
108
|
-
const allowMissing = resolveMissing === true;
|
|
109
109
|
const assumeFolder = allowMissing && (rest > '' || hasTrailingSlash);
|
|
110
110
|
const ret = await getNodeByName(name, parent, assumeFolder);
|
|
111
111
|
if (!ret)
|
|
112
112
|
return;
|
|
113
113
|
if (rest || ret?.original)
|
|
114
|
-
return urlToNode(rest, ctx, ret,
|
|
114
|
+
return urlToNode(rest, ctx, ret, allowMissing);
|
|
115
115
|
if (ret.source)
|
|
116
|
-
if (!showHiddenFiles.get() && await isHiddenFile(ret.source)
|
|
116
|
+
if (!showHiddenFiles.get() && await isHiddenFile(ret.source)
|
|
117
|
+
|| !allowMissing && await setIsFolder(ret) === undefined) // undefined = not found on disk
|
|
117
118
|
return;
|
|
118
|
-
else if (await setIsFolder(ret) === undefined) { // undefined = not found on disk
|
|
119
|
-
if (!resolveMissing)
|
|
120
|
-
return;
|
|
121
|
-
if (allowMissing)
|
|
122
|
-
return ret;
|
|
123
|
-
const rest = ret.source.slice(parent.source.length); // we know parent has .source, otherwise !ret.source || ret.original
|
|
124
|
-
resolveMissing((0, misc_1.removeStarting)('/', rest));
|
|
125
|
-
return parent;
|
|
126
|
-
}
|
|
127
119
|
return ret;
|
|
128
120
|
}
|
|
129
121
|
async function nodeStats(node) {
|
package/src/webdav.js
CHANGED
|
@@ -3,7 +3,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.
|
|
6
|
+
exports.webdav = void 0;
|
|
7
|
+
exports.releaseWebdavLock = releaseWebdavLock;
|
|
7
8
|
const consumers_1 = require("node:stream/consumers");
|
|
8
9
|
const vfs_1 = require("./vfs");
|
|
9
10
|
const cross_1 = require("./cross");
|
|
@@ -33,13 +34,26 @@ const LOCK_MAX_SECONDS = cross_1.DAY / 1000;
|
|
|
33
34
|
const xmlParser = new fast_xml_parser_1.XMLParser({ ignoreAttributes: false, removeNSPrefix: true, trimValues: true });
|
|
34
35
|
const canOverwrite = new Set();
|
|
35
36
|
const locks = new Map();
|
|
36
|
-
function
|
|
37
|
+
function releaseWebdavLock(path) {
|
|
37
38
|
const lock = locks.get(path);
|
|
38
39
|
if (!lock)
|
|
39
40
|
return false;
|
|
41
|
+
clearTimeout(lock.timeout);
|
|
42
|
+
locks.delete(path);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
async function isLocked(path, ctx) {
|
|
46
|
+
const lock = locks.get(path);
|
|
47
|
+
if (!lock)
|
|
48
|
+
return false;
|
|
49
|
+
// if the resource is gone, keeping the lock only creates fake 423 responses
|
|
50
|
+
if (!await (0, vfs_1.urlToNode)(path, ctx)) {
|
|
51
|
+
releaseWebdavLock(path);
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
40
54
|
const ifHeader = ctx.get('If');
|
|
41
55
|
const tokenHeader = ctx.get(TOKEN_HEADER);
|
|
42
|
-
if (hasToken(ifHeader, lock.token) || hasToken(tokenHeader, lock.token))
|
|
56
|
+
if (isSameLockPrincipal(lock, ctx) && (hasToken(ifHeader, lock.token) || hasToken(tokenHeader, lock.token)))
|
|
43
57
|
return false;
|
|
44
58
|
ctx.status = cross_1.HTTP_LOCKED;
|
|
45
59
|
return true;
|
|
@@ -49,7 +63,13 @@ function hasToken(header, token) {
|
|
|
49
63
|
return false;
|
|
50
64
|
return header.includes(`<${token}>`) || header.split(/[,;\s]+/).includes(token);
|
|
51
65
|
}
|
|
52
|
-
|
|
66
|
+
function getWebdavPrincipal(ctx) {
|
|
67
|
+
return (0, auth_1.getCurrentUsername)(ctx) || '';
|
|
68
|
+
}
|
|
69
|
+
function isSameLockPrincipal(lock, ctx) {
|
|
70
|
+
return lock.principal === getWebdavPrincipal(ctx);
|
|
71
|
+
}
|
|
72
|
+
const webdav = async (ctx, next) => {
|
|
53
73
|
let { path } = ctx;
|
|
54
74
|
path = path.replace(/^\/+/, '/'); // double-slash is causing empty listing in filezilla-pro
|
|
55
75
|
const ua = ctx.get('user-agent');
|
|
@@ -61,25 +81,26 @@ async function handledWebdav(ctx) {
|
|
|
61
81
|
if (isWebdavAuthRequest && ua && (0, auth_1.getCurrentUsername)(ctx))
|
|
62
82
|
webdavDetectedAgents.try(webdavAgentKey(ctx, ua), () => true);
|
|
63
83
|
if (ctx.method === 'OPTIONS') {
|
|
64
|
-
if (ctx.get('Access-Control-Request-Method'))
|
|
65
|
-
return;
|
|
84
|
+
if (ctx.get('Access-Control-Request-Method')) // it's a preflight cors request, not webdav
|
|
85
|
+
return next();
|
|
66
86
|
setWebdavHeaders();
|
|
67
87
|
ctx.body = '';
|
|
68
|
-
return
|
|
88
|
+
return;
|
|
69
89
|
}
|
|
70
90
|
if (isWebdavAuthRequest && shouldChallengeWebdav())
|
|
71
|
-
return
|
|
91
|
+
return;
|
|
72
92
|
if (ctx.method === 'PUT') {
|
|
73
|
-
if (isLocked(path, ctx))
|
|
74
|
-
return
|
|
93
|
+
if (await isLocked(path, ctx))
|
|
94
|
+
return;
|
|
95
|
+
const overwriteGraceKey = path + (0, cross_1.prefix)('|', (0, auth_1.getCurrentUsername)(ctx)); // bind temporary overwrite grace to the authenticated user so accounts cannot reuse each other's grace window
|
|
75
96
|
// Finder first creates an empty file (a test?) then wants to overwrite it, which requires deletion permission, but the user may not have it, causing a renamed upload. To solve, so we give it special permission for a few seconds.
|
|
76
97
|
const x = ctx.get('x-expected-entity-length'); // field used by Finder's webdav on actual upload, after
|
|
77
98
|
if (!x && !ctx.length) {
|
|
78
|
-
canOverwrite.add(
|
|
79
|
-
setTimeout(() => canOverwrite.delete(
|
|
99
|
+
canOverwrite.add(overwriteGraceKey);
|
|
100
|
+
setTimeout(() => canOverwrite.delete(overwriteGraceKey), 10_000); // grace period
|
|
80
101
|
}
|
|
81
|
-
else if (canOverwrite.has(
|
|
82
|
-
canOverwrite.delete(
|
|
102
|
+
else if (canOverwrite.has(overwriteGraceKey)) {
|
|
103
|
+
canOverwrite.delete(overwriteGraceKey);
|
|
83
104
|
const node = await (0, vfs_1.urlToNode)(path, ctx);
|
|
84
105
|
if (node?.source)
|
|
85
106
|
await (0, promises_1.rm)(node.source).catch(() => { });
|
|
@@ -88,25 +109,27 @@ async function handledWebdav(ctx) {
|
|
|
88
109
|
ctx.req.headers['content-length'] = x;
|
|
89
110
|
if (KNOWN_UA.test(ua) || webdavDetectedAgents.has(webdavAgentKey(ctx, ua)))
|
|
90
111
|
ctx.query.existing ??= 'overwrite'; // with webdav this is our default
|
|
91
|
-
return;
|
|
112
|
+
return next();
|
|
92
113
|
}
|
|
93
114
|
if (ctx.method === 'MKCOL') {
|
|
94
115
|
setWebdavHeaders();
|
|
95
|
-
if (isLocked(path, ctx))
|
|
96
|
-
return
|
|
116
|
+
if (await isLocked(path, ctx))
|
|
117
|
+
return;
|
|
97
118
|
const node = await (0, vfs_1.urlToNode)(path, ctx);
|
|
98
|
-
if (node)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
119
|
+
if (node) {
|
|
120
|
+
ctx.status = cross_1.HTTP_METHOD_NOT_ALLOWED;
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const parentNode = await (0, vfs_1.urlToNode)((0, path_1.dirname)(path), ctx);
|
|
124
|
+
if (!parentNode) // this is a bit incoherent with the way we handle PUT, which doesn't stop in this case, but it's by RFC 4918 section 9.3
|
|
125
|
+
return ctx.status = cross_1.HTTP_CONFLICT;
|
|
126
|
+
const name = (0, cross_1.safeDecodeURIComponent)((0, path_1.basename)(path), '');
|
|
104
127
|
if (!(0, misc_1.isValidFileName)(name))
|
|
105
128
|
return ctx.status = cross_1.HTTP_BAD_REQUEST;
|
|
106
129
|
if ((0, vfs_1.statusCodeForMissingPerm)(parentNode, 'can_upload', ctx)) {
|
|
107
130
|
if (ctx.status === cross_1.HTTP_UNAUTHORIZED)
|
|
108
131
|
setWebdavHeaders(true);
|
|
109
|
-
return
|
|
132
|
+
return;
|
|
110
133
|
}
|
|
111
134
|
try {
|
|
112
135
|
await (0, promises_1.mkdir)((0, path_1.join)(parentNode.source, name));
|
|
@@ -118,21 +141,26 @@ async function handledWebdav(ctx) {
|
|
|
118
141
|
}
|
|
119
142
|
if (ctx.method === 'MOVE') {
|
|
120
143
|
setWebdavHeaders();
|
|
121
|
-
if (isLocked(path, ctx))
|
|
122
|
-
return
|
|
144
|
+
if (await isLocked(path, ctx))
|
|
145
|
+
return;
|
|
123
146
|
const node = await (0, vfs_1.urlToNode)(path, ctx);
|
|
124
147
|
if (!node)
|
|
125
|
-
return;
|
|
148
|
+
return next();
|
|
126
149
|
let dest = ctx.get('destination');
|
|
127
150
|
const i = dest.indexOf('//');
|
|
128
151
|
if (i >= 0)
|
|
129
152
|
dest = dest.slice(dest.indexOf('/', i + 2));
|
|
130
153
|
dest = (0, cross_1.join)(ctx.state.root || '', dest); // on Windows, we must use / as the delimiter to be able to compare with `path` below
|
|
131
|
-
if (isLocked(dest, ctx))
|
|
132
|
-
return
|
|
154
|
+
if (await isLocked(dest, ctx))
|
|
155
|
+
return;
|
|
133
156
|
if ((0, path_1.dirname)(path) === (0, path_1.dirname)(dest)) // rename case. `path` is is encoded, so we test before decoding `dest`
|
|
134
157
|
try {
|
|
135
|
-
|
|
158
|
+
// decode the single path segment so reserved chars like %2C become their real name on rename
|
|
159
|
+
const newName = (0, cross_1.safeDecodeURIComponent)((0, path_1.basename)(dest), '');
|
|
160
|
+
if (!newName)
|
|
161
|
+
return ctx.status = cross_1.HTTP_BAD_REQUEST;
|
|
162
|
+
await (0, frontEndApis_1.requestedRename)(node, newName, ctx);
|
|
163
|
+
releaseWebdavLock(path); // RFC 4918 says MOVE must not carry locks to destination, so clear source lock on success
|
|
136
164
|
return ctx.status = cross_1.HTTP_CREATED;
|
|
137
165
|
}
|
|
138
166
|
catch (e) {
|
|
@@ -142,13 +170,18 @@ async function handledWebdav(ctx) {
|
|
|
142
170
|
if (moveRes instanceof Error)
|
|
143
171
|
return ctx.status = moveRes.status || cross_1.HTTP_SERVER_ERROR;
|
|
144
172
|
const err = moveRes?.errors?.[0];
|
|
173
|
+
if (!err)
|
|
174
|
+
releaseWebdavLock(path); // successful MOVE leaves the old path invalid, therefore its lock must be dropped
|
|
145
175
|
return ctx.status = !err ? cross_1.HTTP_CREATED : typeof err === 'number' ? err : cross_1.HTTP_SERVER_ERROR;
|
|
146
176
|
}
|
|
147
177
|
if (ctx.method === 'DELETE') {
|
|
148
178
|
setWebdavHeaders();
|
|
149
|
-
if (isLocked(path, ctx))
|
|
150
|
-
return
|
|
151
|
-
|
|
179
|
+
if (await isLocked(path, ctx))
|
|
180
|
+
return;
|
|
181
|
+
await next();
|
|
182
|
+
if (ctx.status === cross_1.HTTP_OK)
|
|
183
|
+
releaseWebdavLock(path); // webdav clients may forget UNLOCK; successful delete must clear any lock
|
|
184
|
+
return;
|
|
152
185
|
}
|
|
153
186
|
if (ctx.method === 'UNLOCK') {
|
|
154
187
|
setWebdavHeaders();
|
|
@@ -156,8 +189,10 @@ async function handledWebdav(ctx) {
|
|
|
156
189
|
const lock = locks.get(path);
|
|
157
190
|
if (x !== lock?.token)
|
|
158
191
|
return ctx.status = cross_1.HTTP_BAD_REQUEST;
|
|
159
|
-
|
|
160
|
-
|
|
192
|
+
// with force_webdav_login disabled a client may silently fall back to anonymous; keep lock ownership on the original principal
|
|
193
|
+
if (!isSameLockPrincipal(lock, ctx))
|
|
194
|
+
return ctx.status = cross_1.HTTP_PRECONDITION_FAILED;
|
|
195
|
+
releaseWebdavLock(path);
|
|
161
196
|
ctx.set(TOKEN_HEADER, x);
|
|
162
197
|
if (const_1.IS_MAC)
|
|
163
198
|
(0, vfs_1.urlToNode)(path, ctx).then(x => x?.source && dotClean((0, path_1.dirname)(x.source)));
|
|
@@ -176,14 +211,17 @@ async function handledWebdav(ctx) {
|
|
|
176
211
|
const lock = locks.get(path);
|
|
177
212
|
if (token !== lock?.token)
|
|
178
213
|
return ctx.status = cross_1.HTTP_PRECONDITION_FAILED;
|
|
214
|
+
// same-token refresh from another principal would make abandoned locks effectively persistent
|
|
215
|
+
if (!isSameLockPrincipal(lock, ctx))
|
|
216
|
+
return ctx.status = cross_1.HTTP_PRECONDITION_FAILED;
|
|
179
217
|
// refresh lock – keep the same token on refresh so clients can continue using the lock they already hold
|
|
180
218
|
clearTimeout(lock.timeout);
|
|
181
|
-
lock.timeout = setTimeout(() =>
|
|
219
|
+
lock.timeout = setTimeout(() => releaseWebdavLock(path), seconds * 1000);
|
|
182
220
|
lock.seconds = seconds;
|
|
183
221
|
locks.set(path, lock);
|
|
184
222
|
ctx.set(TOKEN_HEADER, lock.token);
|
|
185
223
|
ctx.body = renderLockResponse(lock.token, lock.seconds);
|
|
186
|
-
return
|
|
224
|
+
return;
|
|
187
225
|
}
|
|
188
226
|
const lockinfo = (0, cross_1.try_)(() => xmlParser.parse(body).lockinfo);
|
|
189
227
|
const scope = lodash_1.default.keys(lockinfo?.lockscope)[0];
|
|
@@ -197,11 +235,11 @@ async function handledWebdav(ctx) {
|
|
|
197
235
|
if (locks.has(path))
|
|
198
236
|
return ctx.status = cross_1.HTTP_LOCKED;
|
|
199
237
|
const newToken = 'urn:uuid:' + (0, node_crypto_1.randomUUID)();
|
|
200
|
-
const timeout = setTimeout(() =>
|
|
201
|
-
locks.set(path, { token: newToken, timeout, seconds });
|
|
238
|
+
const timeout = setTimeout(() => releaseWebdavLock(path), seconds * 1000);
|
|
239
|
+
locks.set(path, { token: newToken, timeout, seconds, principal: getWebdavPrincipal(ctx) });
|
|
202
240
|
ctx.set(TOKEN_HEADER, newToken);
|
|
203
241
|
ctx.body = renderLockResponse(newToken, seconds);
|
|
204
|
-
return
|
|
242
|
+
return;
|
|
205
243
|
function getProvidedLockToken(ctx) {
|
|
206
244
|
const direct = ctx.get(TOKEN_HEADER).replace(/[<>]/g, '');
|
|
207
245
|
if (direct)
|
|
@@ -213,8 +251,8 @@ async function handledWebdav(ctx) {
|
|
|
213
251
|
return `<?xml version="1.0" encoding="utf-8"?><prop xmlns="DAV:"><lockdiscovery><activelock>
|
|
214
252
|
<locktype><write/></locktype>
|
|
215
253
|
<lockscope><exclusive/></lockscope>
|
|
216
|
-
<locktoken><href>${token}</href></locktoken>
|
|
217
|
-
<lockroot><href>${path}</href></lockroot>
|
|
254
|
+
<locktoken><href>${lodash_1.default.escape(token)}</href></locktoken>
|
|
255
|
+
<lockroot><href>${lodash_1.default.escape(path)}</href></lockroot>
|
|
218
256
|
<depth>0</depth>
|
|
219
257
|
<timeout>Second-${seconds}</timeout>
|
|
220
258
|
</activelock></lockdiscovery></prop>`;
|
|
@@ -224,14 +262,14 @@ async function handledWebdav(ctx) {
|
|
|
224
262
|
setWebdavHeaders();
|
|
225
263
|
const node = await (0, vfs_1.urlToNode)(path, ctx);
|
|
226
264
|
if (!node)
|
|
227
|
-
return;
|
|
265
|
+
return next();
|
|
228
266
|
let depth = Number(ctx.get('depth'));
|
|
229
267
|
depth = isNaN(depth) ? Infinity : depth;
|
|
230
268
|
const isList = depth !== 0;
|
|
231
269
|
if ((0, vfs_1.statusCodeForMissingPerm)(node, isList ? 'can_list' : 'can_see', ctx)) {
|
|
232
270
|
if (ctx.status === cross_1.HTTP_UNAUTHORIZED)
|
|
233
271
|
setWebdavHeaders(true);
|
|
234
|
-
return
|
|
272
|
+
return;
|
|
235
273
|
}
|
|
236
274
|
ctx.type = 'xml';
|
|
237
275
|
ctx.status = 207;
|
|
@@ -246,7 +284,7 @@ async function handledWebdav(ctx) {
|
|
|
246
284
|
}
|
|
247
285
|
res.write(`</multistatus>`);
|
|
248
286
|
res.end();
|
|
249
|
-
return
|
|
287
|
+
return;
|
|
250
288
|
async function sendEntry(node, append = false) {
|
|
251
289
|
if ((0, vfs_1.nodeIsLink)(node))
|
|
252
290
|
return;
|
|
@@ -254,7 +292,7 @@ async function handledWebdav(ctx) {
|
|
|
254
292
|
const isDir = await (0, vfs_1.nodeIsFolder)(node);
|
|
255
293
|
const st = await (0, vfs_1.nodeStats)(node);
|
|
256
294
|
res.write(`<response>
|
|
257
|
-
<href>${outPath + (append ? (0, cross_1.pathEncode)(name, true) + (isDir ? '/' : '') : '')}</href>
|
|
295
|
+
<href>${lodash_1.default.escape(outPath + (append ? (0, cross_1.pathEncode)(name, true) + (isDir ? '/' : '') : ''))}</href>
|
|
258
296
|
<propstat>
|
|
259
297
|
<status>HTTP/1.1 200 OK</status>
|
|
260
298
|
<prop>
|
|
@@ -272,6 +310,7 @@ async function handledWebdav(ctx) {
|
|
|
272
310
|
setWebdavHeaders();
|
|
273
311
|
return ctx.status = cross_1.HTTP_METHOD_NOT_ALLOWED;
|
|
274
312
|
}
|
|
313
|
+
return next();
|
|
275
314
|
function setWebdavHeaders(authenticate = false) {
|
|
276
315
|
ctx.set('DAV', '1,2');
|
|
277
316
|
ctx.set('MS-Author-Via', 'DAV');
|
|
@@ -300,7 +339,8 @@ async function handledWebdav(ctx) {
|
|
|
300
339
|
return true;
|
|
301
340
|
}
|
|
302
341
|
}
|
|
303
|
-
}
|
|
342
|
+
};
|
|
343
|
+
exports.webdav = webdav;
|
|
304
344
|
function compileWebdavAgentRegex(v) {
|
|
305
345
|
return !v ? null : v === true ? /.*/ : new RegExp(v.trim(), 'i');
|
|
306
346
|
}
|