hfs 3.3.2 → 3.4.0-alpha1

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.
Files changed (145) hide show
  1. package/admin/assets/AccountsPage-C6J2hRcY.js +4 -0
  2. package/admin/assets/ConfigFilePage-D5TZZyMR.js +4 -0
  3. package/admin/assets/CustomHtmlPage-DV2-ufWZ.js +2 -0
  4. package/admin/assets/InternetPage-DT3wSQYQ.js +6 -0
  5. package/admin/assets/LangPage-DbXCv7wR.js +2 -0
  6. package/admin/assets/LogsPage-DmAxUZpu.js +3 -0
  7. package/admin/assets/MonitorPage-D7ydOeq_.js +2 -0
  8. package/admin/assets/OptionsPage-DpN3ajRm.js +3 -0
  9. package/admin/assets/PluginsPage-BYzfwuvg.js +1 -0
  10. package/admin/assets/icons-CfBG9vvB.js +1 -0
  11. package/admin/assets/index-CVCnYMoB.js +755 -0
  12. package/admin/assets/index-mI9kZlpb.css +1 -0
  13. package/admin/assets/routing-C4l8rnk_.js +1 -0
  14. package/admin/index.html +2 -2
  15. package/central.json +100 -1
  16. package/frontend/assets/{dist-legacy-zQdkj0Z9.js → dist-legacy-CKczq_fx.js} +1 -1
  17. package/frontend/assets/index-legacy-ZYae30AY.js +1 -0
  18. package/frontend/assets/{lib-legacy-CRrFEEqm.js → lib-legacy-D2tyILlj.js} +1 -1
  19. package/frontend/assets/{sha512-legacy-BB7Mdt0y.js → sha512-legacy-CFcDeiiP.js} +1 -1
  20. package/frontend/index.html +1 -1
  21. package/npm-shrinkwrap.json +60 -60
  22. package/package.json +3 -3
  23. package/src/acme.js +143 -50
  24. package/src/acmeConfig.js +43 -0
  25. package/src/acmeDns.js +285 -0
  26. package/src/admin-langs/embedded.js +33 -0
  27. package/src/admin-langs/hfs-admin-lang-ar.json +1141 -0
  28. package/src/admin-langs/hfs-admin-lang-bg.json +1141 -0
  29. package/src/admin-langs/hfs-admin-lang-de.json +1141 -0
  30. package/src/admin-langs/hfs-admin-lang-el.json +1141 -0
  31. package/src/admin-langs/hfs-admin-lang-en.json +1141 -0
  32. package/src/admin-langs/hfs-admin-lang-es.json +1141 -0
  33. package/src/admin-langs/hfs-admin-lang-fi.json +1141 -0
  34. package/src/admin-langs/hfs-admin-lang-fr.json +1141 -0
  35. package/src/admin-langs/hfs-admin-lang-hu.json +1141 -0
  36. package/src/admin-langs/hfs-admin-lang-it.json +1141 -0
  37. package/src/admin-langs/hfs-admin-lang-ja.json +1141 -0
  38. package/src/admin-langs/hfs-admin-lang-ko.json +1141 -0
  39. package/src/admin-langs/hfs-admin-lang-lt.json +1141 -0
  40. package/src/admin-langs/hfs-admin-lang-ms.json +1141 -0
  41. package/src/admin-langs/hfs-admin-lang-nl.json +1141 -0
  42. package/src/admin-langs/hfs-admin-lang-pl.json +1141 -0
  43. package/src/admin-langs/hfs-admin-lang-pt-br.json +1141 -0
  44. package/src/admin-langs/hfs-admin-lang-ro.json +1141 -0
  45. package/src/admin-langs/hfs-admin-lang-ru.json +1141 -0
  46. package/src/admin-langs/hfs-admin-lang-sr-latn.json +1141 -0
  47. package/src/admin-langs/hfs-admin-lang-sr.json +1141 -0
  48. package/src/admin-langs/hfs-admin-lang-th.json +1141 -0
  49. package/src/admin-langs/hfs-admin-lang-tr.json +1141 -0
  50. package/src/admin-langs/hfs-admin-lang-uk.json +1141 -0
  51. package/src/admin-langs/hfs-admin-lang-vi.json +1141 -0
  52. package/src/admin-langs/hfs-admin-lang-zh-tw.json +1141 -0
  53. package/src/admin-langs/hfs-admin-lang-zh.json +1141 -0
  54. package/src/adminApis.js +2 -2
  55. package/src/adminLang.js +50 -0
  56. package/src/api.lang.js +22 -13
  57. package/src/api.log.js +5 -3
  58. package/src/api.net.js +22 -4
  59. package/src/api.plugins.js +53 -19
  60. package/src/api.vfs.js +2 -1
  61. package/src/argv.js +4 -0
  62. package/src/auth.js +0 -2
  63. package/src/basicWeb.js +6 -2
  64. package/src/block.js +1 -1
  65. package/src/config.js +1 -0
  66. package/src/connections.js +4 -7
  67. package/src/const.js +1 -1
  68. package/src/cross-const.js +7 -5
  69. package/src/cross.js +16 -3
  70. package/src/frontEndApis.js +44 -7
  71. package/src/github.js +6 -37
  72. package/src/i18n.js +16 -4
  73. package/src/index.js +4 -2
  74. package/src/lang.js +15 -8
  75. package/src/langs/embedded.js +2 -1
  76. package/src/langs/hfs-lang-ar.json +5 -1
  77. package/src/langs/hfs-lang-bg.json +5 -1
  78. package/src/langs/hfs-lang-de.json +5 -1
  79. package/src/langs/hfs-lang-el.json +5 -1
  80. package/src/langs/hfs-lang-en.json +188 -0
  81. package/src/langs/hfs-lang-es.json +7 -3
  82. package/src/langs/hfs-lang-fi.json +6 -2
  83. package/src/langs/hfs-lang-fr.json +10 -6
  84. package/src/langs/hfs-lang-hu.json +5 -1
  85. package/src/langs/hfs-lang-it.json +9 -4
  86. package/src/langs/hfs-lang-ja.json +5 -1
  87. package/src/langs/hfs-lang-ko.json +5 -1
  88. package/src/langs/hfs-lang-lt.json +5 -1
  89. package/src/langs/hfs-lang-ms.json +7 -1
  90. package/src/langs/hfs-lang-nl.json +5 -1
  91. package/src/langs/hfs-lang-pl.json +5 -1
  92. package/src/langs/hfs-lang-pt-br.json +7 -3
  93. package/src/langs/hfs-lang-ro.json +5 -1
  94. package/src/langs/hfs-lang-ru.json +7 -3
  95. package/src/langs/hfs-lang-sr-latn.json +5 -1
  96. package/src/langs/hfs-lang-sr.json +5 -1
  97. package/src/langs/hfs-lang-th.json +6 -2
  98. package/src/langs/hfs-lang-tr.json +6 -2
  99. package/src/langs/hfs-lang-uk.json +14 -10
  100. package/src/langs/hfs-lang-vi.json +5 -1
  101. package/src/langs/hfs-lang-zh-tw.json +5 -1
  102. package/src/langs/hfs-lang-zh.json +5 -1
  103. package/src/log.js +7 -1
  104. package/src/middlewares.js +85 -14
  105. package/src/multipartUpload.js +21 -0
  106. package/src/nat.js +10 -7
  107. package/src/outboundProxy.js +1 -1
  108. package/src/perm.js +38 -20
  109. package/src/pluginCatalog.js +66 -0
  110. package/src/pluginUsage.js +64 -0
  111. package/src/plugins.js +12 -0
  112. package/src/roots.js +22 -13
  113. package/src/serveFile.js +26 -5
  114. package/src/serveGuiFiles.js +5 -2
  115. package/src/update.js +51 -18
  116. package/src/upload.js +49 -8
  117. package/src/util-http.js +31 -5
  118. package/src/vfs.js +84 -4
  119. package/src/webdav.js +12 -6
  120. package/admin/assets/AccountsPage-Cvi9DSc1.js +0 -3
  121. package/admin/assets/ArrayField-ChS9pNra.js +0 -2
  122. package/admin/assets/ConfigFilePage-6jCYVkbr.js +0 -4
  123. package/admin/assets/CustomHtmlPage-DaEXsEcQ.js +0 -2
  124. package/admin/assets/DataTable-Cf5S5yWr.js +0 -119
  125. package/admin/assets/DateTimeField--mTsSB1l.js +0 -1
  126. package/admin/assets/EventManager-BJWba-fL.js +0 -1
  127. package/admin/assets/FileField-B0WhrAuk.js +0 -117
  128. package/admin/assets/InternetPage-BICQDG_K.js +0 -8
  129. package/admin/assets/LangPage-D42UPSxC.js +0 -2
  130. package/admin/assets/LogsPage-DQTrf5GE.js +0 -4
  131. package/admin/assets/MenuButton-DDfB3119.js +0 -1
  132. package/admin/assets/MonitorPage-CH06c8aW.js +0 -2
  133. package/admin/assets/OptionsPage-BvXLA_R0.js +0 -3
  134. package/admin/assets/PluginsPage-CyaVJZkM.js +0 -1
  135. package/admin/assets/Tabs-CGfV3BK0.js +0 -1
  136. package/admin/assets/TextEditor-BeS-mR2I.css +0 -1
  137. package/admin/assets/TextEditor-Df9LSzm6.js +0 -34
  138. package/admin/assets/VfsPathField-6Kyy5vuQ.js +0 -1
  139. package/admin/assets/agentIcons-DwT9U-lh.js +0 -1
  140. package/admin/assets/baseUrl-D7S0KsqZ.js +0 -2
  141. package/admin/assets/icons-DWkdo8hc.js +0 -1
  142. package/admin/assets/index-11n5z0Ez.css +0 -1
  143. package/admin/assets/index-BDdPG215.js +0 -477
  144. package/admin/assets/routing-CxxvyWME.js +0 -1
  145. package/frontend/assets/index-legacy-B8EvbIOB.js +0 -1
package/src/log.js CHANGED
@@ -48,6 +48,7 @@ const lodash_1 = __importDefault(require("lodash"));
48
48
  const util_files_1 = require("./util-files");
49
49
  const auth_1 = require("./auth");
50
50
  const misc_1 = require("./misc");
51
+ const vfs_1 = require("./vfs");
51
52
  const path_1 = require("path");
52
53
  const yazl_1 = __importDefault(require("yazl"));
53
54
  const promises_1 = require("stream/promises");
@@ -109,6 +110,7 @@ const logRotation = (0, config_1.defineConfig)(misc_1.CFG.log_rotation, 'weekly'
109
110
  const dontLogNet = (0, config_1.defineConfig)(misc_1.CFG.dont_log_net, '127.0.0.1|::1', v => (0, misc_1.makeNetMatcher)(v));
110
111
  const logUA = (0, config_1.defineConfig)(misc_1.CFG.log_ua, false);
111
112
  const logSpam = (0, config_1.defineConfig)(misc_1.CFG.log_spam, false);
113
+ const logSpamRegex = (0, config_1.defineConfig)(misc_1.CFG.log_spam_regex, /wlwmanifest.xml$|\.(php|asp)$|cgi|robots.txt$/.source, v => v ? new RegExp(v) : null);
112
114
  const logHost = (0, config_1.defineConfig)(misc_1.CFG.log_host, false);
113
115
  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
114
116
  const logMw = async (ctx, next) => {
@@ -122,7 +124,9 @@ const logMw = async (ctx, next) => {
122
124
  if (!logSpam.get()
123
125
  && (ctx.querystring.includes('{.exec|') // v2's bug
124
126
  // requests generated by security scanners, other bots, and macos' finder
125
- || ctx.status === misc_1.HTTP_NOT_FOUND && /wlwmanifest.xml$|\.(php)$|cgi|robots.txt$/.test(ctx.path))) {
127
+ || logSpamRegex.compiled()?.test(ctx.path)
128
+ && (ctx.status === misc_1.HTTP_NOT_FOUND
129
+ || ctx.status === misc_1.HTTP_TEMPORARY_REDIRECT_KEEP_METHOD && !await (0, vfs_1.urlToNode)(ctx.path, ctx)))) {
126
130
  events_2.default.emit('spam', ctx);
127
131
  return;
128
132
  }
@@ -140,6 +144,8 @@ const logMw = async (ctx, next) => {
140
144
  let { stream, last, path } = logger;
141
145
  if (!stream)
142
146
  return;
147
+ if (events_2.default.emit('beforeLog', { ctx })?.isDefaultPrevented())
148
+ return;
143
149
  const rotate = logRotation.get()?.[0];
144
150
  const reqEnd = logger.last = new Date();
145
151
  if (rotate && last) { // rotation enabled and a file exists?
@@ -10,6 +10,9 @@ exports.failAllowNet = failAllowNet;
10
10
  const koa_compress_1 = __importDefault(require("koa-compress"));
11
11
  const const_1 = require("./const");
12
12
  const misc_1 = require("./misc");
13
+ const node_crypto_1 = require("node:crypto");
14
+ const lang_1 = require("./lang");
15
+ const i18n_1 = require("./i18n");
13
16
  const stream_1 = require("stream");
14
17
  const block_1 = require("./block");
15
18
  const perm_1 = require("./perm");
@@ -64,12 +67,50 @@ const someSecurity = (ctx, next) => {
64
67
  const { URL } = ctx;
65
68
  URL.protocol = 'https';
66
69
  URL.port = (0, listen_1.getHttpsWorkingPort)();
67
- ctx.status = 307; // this ensures the client doesn't switch to a simpler GET request
70
+ ctx.status = const_1.HTTP_TEMPORARY_REDIRECT_KEEP_METHOD; // this ensures the client doesn't switch to a simpler GET request
68
71
  return ctx.redirect(URL.href);
69
72
  }
73
+ const page = ctx.state.urlLoginPage;
74
+ if (page)
75
+ return sendUrlLoginPage(page.cleanUrl, page.username);
70
76
  return next();
77
+ function sendUrlLoginPage(cleanUrl, username) {
78
+ return sendPage(ctx, username === undefined ? 'login_bad_credentials' : 'Confirm', t => {
79
+ if (username === undefined)
80
+ return `<script>history.replaceState(null,'',location.pathname)</script>
81
+ <p><a href="${(0, misc_1.escapeHTML)(cleanUrl)}">${(0, misc_1.escapeHTML)(t('Continue'))}</a></p>`;
82
+ const token = (0, node_crypto_1.randomUUID)();
83
+ (ctx.session ??= {}).urlLoginConfirmation = {
84
+ token,
85
+ username,
86
+ expires: Date.now() + 5 * 60_000,
87
+ };
88
+ return `<p>${(0, misc_1.escapeHTML)(t('confirm_url_login', { username }))}</p>
89
+ <p><strong><a class="confirm" href="${(0, misc_1.escapeHTML)(cleanUrl)}">${(0, misc_1.escapeHTML)(t('Continue'))}</a></strong>
90
+ <a href="${(0, misc_1.escapeHTML)(cleanUrl)}">${(0, misc_1.escapeHTML)(t('Cancel'))}</a></p>
91
+ <script>
92
+ const loginUrl=new URL(location.href);
93
+ history.replaceState(null,'',loginUrl.pathname);
94
+ document.querySelector('.confirm').onclick=ev=>{ev.preventDefault();loginUrl.searchParams.set('login_confirm','${token}');location.replace(loginUrl)}
95
+ </script>`;
96
+ });
97
+ }
71
98
  };
72
99
  exports.someSecurity = someSecurity;
100
+ async function sendPage(ctx, titleKey, content) {
101
+ const { t } = (0, i18n_1.i18nFromTranslations)(await (0, lang_1.getLangData)(ctx));
102
+ const title = t(titleKey);
103
+ ctx.status = 200;
104
+ ctx.type = 'html';
105
+ ctx.set('cache-control', 'no-store');
106
+ ctx.set('referrer-policy', 'no-referrer');
107
+ ctx.set('content-security-policy', "frame-ancestors 'none'");
108
+ ctx.set('x-frame-options', 'DENY');
109
+ ctx.body = `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
110
+ <title>${(0, misc_1.escapeHTML)(title)}</title><style>
111
+ body{margin-top:30vh;text-align:center;font-family:sans-serif}a{margin:1em}
112
+ </style></head><body><h1>${(0, misc_1.escapeHTML)(title)}</h1>${content(t)}</body></html>`;
113
+ }
73
114
  function enforceSessionIp(ctx) {
74
115
  const s = ctx.session;
75
116
  if (!s?.username || s[misc_1.ALLOW_SESSION_IP_CHANGE])
@@ -89,7 +130,7 @@ function getProxyDetected() {
89
130
  }
90
131
  const prepareState = async (ctx, next) => {
91
132
  // normalize once so auth, filters and logging agree on the same client address
92
- ctx.request.ip = (0, connections_1.normalizeIp)(ctx.ip);
133
+ ctx.request.ip = (0, misc_1.normalizeIp)(ctx.ip);
93
134
  // invalidate before account resolution; someSecurity calls again to bind logins made below
94
135
  enforceSessionIp(ctx);
95
136
  // rootsMiddleware consults proxy-aware admin access before someSecurity runs
@@ -115,7 +156,9 @@ const prepareState = async (ctx, next) => {
115
156
  ctx.state.connection = (0, connections_1.socket2connection)(ctx.socket);
116
157
  // explicit credentials and existing sessions must take precedence, so a matching IP cannot override a chosen account
117
158
  let via;
118
- let a = await urlLogin() || await getHttpAccount() || !s?.username && autoLogin();
159
+ const urlAccount = await urlLogin();
160
+ let a = ctx.state.urlLoginPage ? undefined
161
+ : urlAccount || await getHttpAccount() || !s?.username && autoLogin();
119
162
  const loggedInNotBySession = a;
120
163
  ctx.state.account = a ||= (0, perm_1.getAccount)(s?.username, false); // with least precedence, we consider session
121
164
  if (a)
@@ -137,14 +180,43 @@ const prepareState = async (ctx, next) => {
137
180
  ctx.state.revProxyPath = ctx.get('x-forwarded-prefix');
138
181
  (0, connections_1.updateConnectionForCtx)(ctx);
139
182
  await next();
140
- function urlLogin() {
183
+ async function urlLogin() {
141
184
  via = 'url';
142
185
  const { login } = ctx.query;
143
186
  if (!login)
144
187
  return;
145
188
  const [u, p] = (0, misc_1.splitAt)(':', String(login));
146
- ctx.redirect(ctx.originalUrl.slice(0, -ctx.querystring.length - 1)); // redirect to hide credentials
147
- return u && (0, auth_1.clearTextLogin)(ctx, u, p, 'url');
189
+ const cleanUrl = ctx.originalUrl.slice(0, -ctx.querystring.length - 1);
190
+ const token = String(ctx.query.login_confirm || '');
191
+ const pending = s?.urlLoginConfirmation;
192
+ const confirmed = Boolean(token && pending?.token === token && pending.username === u
193
+ && pending.expires > Date.now());
194
+ if (token && s)
195
+ delete s.urlLoginConfirmation; // confirmation tokens are single-use, including failed attempts
196
+ if (!confirmed && needsConfirmation(u)) {
197
+ ctx.state.urlLoginPage = { username: u, cleanUrl };
198
+ return;
199
+ }
200
+ if (s)
201
+ delete s.urlLoginConfirmation;
202
+ const account = u && await (0, auth_1.clearTextLogin)(ctx, u, p, 'url');
203
+ if (!account && confirmed)
204
+ ctx.state.urlLoginPage = { cleanUrl };
205
+ else
206
+ ctx.redirect(cleanUrl); // redirect to hide credentials
207
+ return account;
208
+ function needsConfirmation(username) {
209
+ if (ctx.get('sec-fetch-mode') !== 'navigate' && !/^Mozilla\//.test(ctx.get('user-agent')))
210
+ return false;
211
+ const current = s?.username;
212
+ if (current)
213
+ return (0, perm_1.getAccount)(username)?.username !== (0, perm_1.getAccount)(current)?.username;
214
+ const fetchSite = ctx.get('sec-fetch-site');
215
+ if (fetchSite)
216
+ return fetchSite === 'same-site' || fetchSite === 'cross-site';
217
+ const referer = ctx.get('referer');
218
+ return !referer || (0, misc_1.try_)(() => new URL(referer).host.toLowerCase() !== ctx.host.toLowerCase(), () => true);
219
+ }
148
220
  }
149
221
  function getHttpAccount() {
150
222
  via = 'header';
@@ -167,20 +239,19 @@ const prepareState = async (ctx, next) => {
167
239
  };
168
240
  exports.prepareState = prepareState;
169
241
  function failAllowNet(ctx, a) {
170
- // a cached mask is valid only for the identity that stored it
171
- const sameAccount = ctx.session?.username === (0, perm_1.normalizeUsername)(a?.username || '');
172
- const cached = sameAccount ? ctx.session?.allowNet : undefined;
173
- const mask = cached ?? (0, perm_1.getFromAccount)(a || '', a => a.allow_net);
174
- if (sameAccount && !cached && mask)
175
- ctx.session.allowNet = mask; // must be deleted on logout by setLoggedIn
242
+ const mask = (0, perm_1.getFromAccount)(a || '', a => a.allow_net);
176
243
  const ret = mask && !(0, misc_1.netMatches)(ctx.ip, mask, true);
177
244
  if (ret)
178
245
  console.debug("Login failed: allow_net");
179
246
  return ret;
180
247
  }
181
248
  const paramsDecoder = async (ctx, next) => {
182
- ctx.state.params = ctx.method === 'POST' && ctx.originalUrl.startsWith(const_1.API_URI)
183
- && ((0, misc_1.tryJson)(await (0, misc_1.stream2string)(ctx.req)) || {});
249
+ if (ctx.method !== 'POST' || !ctx.originalUrl.startsWith(const_1.API_URI))
250
+ return next();
251
+ const body = await (0, misc_1.readRequestBodyLimited)(ctx, 10 * 1024 * 1024);
252
+ if (!body)
253
+ return;
254
+ ctx.state.params = (0, misc_1.tryJson)(body.text) || {};
184
255
  await next();
185
256
  };
186
257
  exports.paramsDecoder = paramsDecoder;
@@ -11,9 +11,15 @@ const upload_1 = require("./upload");
11
11
  const cross_const_1 = require("./cross-const");
12
12
  const first_1 = require("./first");
13
13
  const cross_1 = require("./cross");
14
+ const config_1 = require("./config");
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
+ if (isCrossOriginBrowserRequest()) {
19
+ ctx.set('Connection', 'close'); // the rejected multipart body is intentionally left unread
20
+ ctx.body = "Cross-origin multipart upload blocked";
21
+ return ctx.status = cross_const_1.HTTP_FOOL;
22
+ }
17
23
  ctx.state.uploads = [];
18
24
  const locks = [];
19
25
  const fileJobs = [];
@@ -73,6 +79,20 @@ async function handleMultipartUpload(ctx, node) {
73
79
  stream.resume();
74
80
  return new Promise(res => (0, first_1.onFirstEvent)(stream, ['end', 'close', 'error'], res));
75
81
  }
82
+ function isCrossOriginBrowserRequest() {
83
+ if (ctx.get('x-hfs-anti-csrf'))
84
+ return false;
85
+ const origin = ctx.get('origin');
86
+ if (origin && origin !== 'null' && allowedUploadOrigin.compiled()(origin))
87
+ return false;
88
+ const fetchSite = ctx.get('sec-fetch-site');
89
+ if (fetchSite)
90
+ return fetchSite !== 'same-origin' && fetchSite !== 'none';
91
+ if (!origin)
92
+ return false; // missing browser metadata preserves non-browser and legacy upload clients
93
+ // compare hosts because TLS termination can make the public and internal protocols differ
94
+ return (0, cross_1.try_)(() => new URL(origin).host.toLowerCase() !== ctx.host.toLowerCase(), () => true);
95
+ }
76
96
  async function subfolderBlocksUpload(fn) {
77
97
  const prefix = (0, path_1.dirname)(fn.replaceAll('\\', '/'));
78
98
  if (prefix === '.') // no subdir
@@ -81,3 +101,4 @@ async function handleMultipartUpload(ctx, node) {
81
101
  return subfolderNode && !(0, vfs_1.hasPermission)(subfolderNode, 'can_upload', ctx);
82
102
  }
83
103
  }
104
+ const allowedUploadOrigin = (0, config_1.defineConfig)('allowed_upload_origin', '', mask => (0, cross_1.makeMatcher)(mask, false, false));
package/src/nat.js CHANGED
@@ -82,24 +82,25 @@ exports.getPublicIps = (0, debounceAsync_1.debounceAsync)(async () => {
82
82
  return ret;
83
83
  }, { retain: 10 * cross_1.MINUTE });
84
84
  exports.getNatInfo = (0, debounceAsync_1.debounceAsync)(async () => {
85
+ const MAPPINGS_TIMEOUT = 15_000; // routers enumerate rules sequentially; a few dozen mappings can take over 5 seconds
85
86
  const upnp = await exports.upnpEnabled.getWhenReady() ? getUpnpClient() : null;
86
87
  const gatewayIpPromise = findGateway().catch(() => undefined);
87
88
  const gw = upnp && await (0, cross_1.haveTimeout)(10_000, upnp.getGateway()).catch(() => null);
88
- const status = await (0, listen_1.getServerStatus)();
89
- let mappings = gw && await (0, cross_1.haveTimeout)(5_000, upnp.getMappings())?.catch(() => null);
90
- console.debug(gw ? "Mappings found:" : "Mappings not queried:", mappings?.map(x => x.description).join(', ') || (gw ? "none" : upnp ? "gateway not found" : "UPnP disabled"));
89
+ let mappings = gw && await (0, cross_1.haveTimeout)(MAPPINGS_TIMEOUT, upnp.getMappings())?.catch(() => null);
90
+ console.debug(gw ? "Mappings found:" : "Mappings not queried:", lodash_1.default.uniq(mappings?.map(x => x.description)).join(', ') || (gw ? "none" : upnp ? "gateway not found" : "UPnP disabled"));
91
91
  const localIps = await (0, listen_1.getIps)(false);
92
92
  const gatewayIp = await gatewayIpPromise;
93
93
  const localIp = gw?.address || (gatewayIp ? lodash_1.default.maxBy(localIps, x => (0, cross_1.inCommon)(x, gatewayIp)) : localIps[0]);
94
+ const status = await (0, listen_1.getServerStatus)();
94
95
  const internalPort = status?.https?.listening && status.https.port || status?.http?.listening && status.http.port || undefined;
95
96
  let mapped = lodash_1.default.find(mappings, x => x.private.host === localIp && x.private.port === internalPort);
96
97
  if (upnp && mappings && localIp && internalPort && !mapped && exports.mappedPort.get())
97
98
  // restore the HFS-created mapping after routers that forget UPnP state across reboots
98
99
  await (0, cross_1.haveTimeout)(5_000, upnp.createMapping(upnpMappingParam(internalPort, exports.mappedPort.get()))).then(async () => {
99
100
  // confirm router state after restore instead of trusting the AddPortMapping result
100
- mappings = await (0, cross_1.haveTimeout)(5_000, upnp.getMappings());
101
+ mappings = await (0, cross_1.haveTimeout)(MAPPINGS_TIMEOUT, upnp.getMappings());
101
102
  mapped = lodash_1.default.find(mappings, x => x.private.host === localIp && x.private.port === internalPort);
102
- }).catch(e => console.warn('UPnP mapping restore failed:', e?.message || String(e)));
103
+ }).catch(e => console.warn('UPnP mapping restore failed:', e?.message || e?.errorDescription || String(e)));
103
104
  const externalPort = mapped?.public.port;
104
105
  if (localIp)
105
106
  exports.defaultBaseUrl.localIp = localIp;
@@ -134,9 +135,11 @@ function getUpnpClient() {
134
135
  throw Error("UPnP disabled");
135
136
  if (!upnpClient) {
136
137
  upnpClient = new nat_upnp_1.Client({ timeout: 4_000 });
137
- const originalMethod = upnpClient.getGateway;
138
138
  // other client methods call getGateway too, so this will ensure they reuse this same result
139
- upnpClient.getGateway = (0, debounceAsync_1.debounceAsync)(() => originalMethod.apply(upnpClient), { retain: cross_1.HOUR, retainFailure: 30_000 });
139
+ (0, cross_1.patchKey)(upnpClient, 'getGateway', was => (0, debounceAsync_1.debounceAsync)(() => was.apply(upnpClient), { retain: cross_1.HOUR, retainFailure: 30_000 }));
140
+ const f = (0, cross_1.patchKey)(upnpClient, 'getMappings', was => (0, debounceAsync_1.debounceAsync)(() => was.apply(upnpClient), { retain: cross_1.MINUTE }));
141
+ for (const k of ['createMapping', 'removeMapping']) // these methods must invalidate the cache
142
+ (0, cross_1.patchKey)(upnpClient, k, (was) => (...args) => was.apply(upnpClient, args).finally(f.clearRetain));
140
143
  }
141
144
  return upnpClient;
142
145
  }
@@ -44,7 +44,7 @@ config_1.configReady.then(async (startedWithoutConfig) => {
44
44
  // it can be like "IP:PORT" or "http=IP:PORT;https=IP:PORT;ftp=IP:PORT"
45
45
  const url = (0, cross_1.prefix)('https://', /https=([\d:.]+)/.exec(out)?.[1]) // prefer https
46
46
  || (0, cross_1.prefix)('http://', /http=([\d:.]+)/.exec(out)?.[1])
47
- || !read.includes('=') && 'http://' + read; // simpler form
47
+ || !read.includes('=') && (read.includes('://') ? read : 'http://' + read); // simpler form
48
48
  if (!url)
49
49
  return;
50
50
  outboundProxy.set(url);
package/src/perm.js CHANGED
@@ -31,17 +31,7 @@ const events_1 = __importDefault(require("./events"));
31
31
  const auth_1 = require("./auth");
32
32
  // provides the username and all other usernames it inherits based on the 'belongs' attribute. Useful to check permissions
33
33
  function expandUsername(who) {
34
- const ret = new Set();
35
- const q = [who];
36
- for (const u of q) {
37
- const a = getAccount(u);
38
- if (!a || a.disabled)
39
- continue;
40
- ret.add(u);
41
- if (a.belongs)
42
- q.push(...a.belongs);
43
- }
44
- return ret;
34
+ return new Set(Array.from(walkActiveAccounts([who]), a => a.username));
45
35
  }
46
36
  // check if current username or any ancestor match the provided usernames
47
37
  function ctxBelongsTo(ctx, usernames) {
@@ -213,14 +203,29 @@ function delAccount(username) {
213
203
  }
214
204
  // get some property from account, searching in its groups if necessary. Search is breadth-first, and this determines priority of inheritance.
215
205
  function getFromAccount(account, getter) {
216
- const search = [account];
206
+ const root = typeof account === 'string' ? getAccount(account) : account;
207
+ if (!root)
208
+ return;
209
+ let res = getter(root); // always consider direct properties; only inherited groups are filtered by active status
210
+ if (res !== undefined)
211
+ return res;
212
+ for (const a of walkActiveAccounts(root.belongs || [])) {
213
+ res = getter(a);
214
+ if (res !== undefined)
215
+ return res;
216
+ }
217
+ }
218
+ function* walkActiveAccounts(roots) {
219
+ const search = [...roots];
220
+ const visited = new Set();
217
221
  for (const accountOrUsername of search) {
218
222
  const a = typeof accountOrUsername === 'string' ? getAccount(accountOrUsername) : accountOrUsername;
219
- if (!a)
223
+ if (!a || visited.has(a.username))
220
224
  continue;
221
- const res = getter(a);
222
- if (res !== undefined)
223
- return res;
225
+ visited.add(a.username);
226
+ if (accountIsDisabled(a))
227
+ continue;
228
+ yield a;
224
229
  if (a.belongs)
225
230
  search.push(...a.belongs);
226
231
  }
@@ -235,10 +240,23 @@ function accountCanLogin(account) {
235
240
  return accountHasLoginMethod(account) && !accountIsDisabled(account);
236
241
  }
237
242
  function accountIsDisabled(account) {
238
- return Boolean(account.disabled
239
- || account.expire < Date.now()
240
- || account.belongs?.length // don't every() on empty array, as it returns true
241
- && account.belongs.map(u => getAccount(u, false)).every(a => a && accountIsDisabled(a)));
243
+ return check(account, new Set());
244
+ function check(account, visiting) {
245
+ if (account.disabled || account.expire < Date.now())
246
+ return true;
247
+ if (!account.belongs?.length)
248
+ return false;
249
+ // consider invalid membership cycles disabled so they cannot grant privileges
250
+ if (visiting.has(account.username))
251
+ return true;
252
+ visiting.add(account.username);
253
+ const ret = account.belongs.every(u => {
254
+ const a = getAccount(u, false);
255
+ return Boolean(a && check(a, visiting));
256
+ });
257
+ visiting.delete(account.username);
258
+ return ret;
259
+ }
242
260
  }
243
261
  function accountCanLoginAdmin(account) {
244
262
  return accountCanLogin(account) && getFromAccount(account, a => a.admin) || false;
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getPluginCatalog = getPluginCatalog;
7
+ exports.searchPluginCatalog = searchPluginCatalog;
8
+ const github_1 = require("./github");
9
+ const persistence_1 = require("./persistence");
10
+ const const_1 = require("./const");
11
+ const lodash_1 = __importDefault(require("lodash"));
12
+ const CACHE_KEY = 'pluginCatalog';
13
+ let refreshing;
14
+ async function* getPluginCatalog() {
15
+ await persistence_1.storedMap.ready();
16
+ const saved = await persistence_1.storedMap.get(CACHE_KEY);
17
+ // compatibility is determined by this HFS version, including fallback api branches
18
+ const previous = saved?.version === const_1.VERSION ? saved : undefined;
19
+ const age = previous ? Date.now() - previous.updatedAt : Infinity;
20
+ if (previous && age < 24 * 60 * 60_000)
21
+ yield { ...previous, refreshing: age >= 5 * 60_000 };
22
+ if (age < 5 * 60_000)
23
+ return;
24
+ // all searches share the same refresh, even if the initiating client disconnects
25
+ refreshing ??= refresh().finally(() => { refreshing = undefined; });
26
+ yield { ...await refreshing, refreshing: false };
27
+ async function refresh() {
28
+ const repositories = [];
29
+ for await (const repo of (0, github_1.apiGithubPaginated)('search/repositories?q=topic:hfs-plugin', { requireComplete: true }))
30
+ repositories.push(lodash_1.default.pick(repo, ['full_name', 'description', 'pushed_at', 'default_branch', 'stargazers_count', 'license']));
31
+ const old = new Map(previous?.entries.map(entry => [entry.repository.full_name, entry]));
32
+ const entries = await Promise.all(repositories.map(async (repository) => {
33
+ const cached = old.get(repository.full_name);
34
+ let plugin = cached?.plugin;
35
+ if (!cached || cached.repository.pushed_at !== repository.pushed_at
36
+ || cached.repository.default_branch !== repository.default_branch) {
37
+ plugin = await (0, github_1.readOnlineCompatiblePlugin)(repository.full_name, repository.default_branch)
38
+ .catch((error) => {
39
+ // repositories can carry the topic without providing a plugin at the expected path
40
+ if (error.message !== '404')
41
+ throw error;
42
+ }) ?? null;
43
+ }
44
+ return { repository, plugin: plugin ?? null };
45
+ }));
46
+ const next = { version: const_1.VERSION, updatedAt: Date.now(), entries };
47
+ // never replace a complete catalog with the result of a failed refresh
48
+ await persistence_1.storedMap.put(CACHE_KEY, next);
49
+ return next;
50
+ }
51
+ }
52
+ async function searchPluginCatalog(catalog, text = '', skipRepos = []) {
53
+ const central = await (0, github_1.getProjectInfo)();
54
+ const terms = text.toLowerCase().split(/\s+/).filter(Boolean);
55
+ return catalog.entries.flatMap(({ repository, plugin }) => {
56
+ const repo = repository.full_name;
57
+ const searchable = `${repo} ${repository.description || ''} ${plugin?.description || ''}`.toLowerCase();
58
+ if (!plugin || skipRepos.includes(repo) || central?.repo_blacklist?.[repo]?.message
59
+ || !terms.every(term => searchable.includes(term)))
60
+ return [];
61
+ // API responses add live state and dependency errors; keep the persisted metadata untouched
62
+ return [{ ...plugin, id: repo, repo, pushed_at: repository.pushed_at,
63
+ stargazers_count: repository.stargazers_count, default_branch: repository.default_branch,
64
+ license: repository.license?.spdx_id }];
65
+ });
66
+ }
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const node_crypto_1 = require("node:crypto");
4
+ const misc_1 = require("./misc");
5
+ const config_1 = require("./config");
6
+ const plugins_1 = require("./plugins");
7
+ const persistence_1 = require("./persistence");
8
+ const util_http_1 = require("./util-http");
9
+ const debounceAsync_1 = require("./debounceAsync");
10
+ const shareUsageStats = (0, config_1.defineConfig)(misc_1.CFG.share_usage_stats, false);
11
+ const installationId = persistence_1.storedMap.singleSync('pluginUsageId', '');
12
+ const lastReport = persistence_1.storedMap.singleSync('lastPluginUsageReport', 0);
13
+ const ready = Promise.all([config_1.configReady, plugins_1.pluginsScanned, installationId.ready(), lastReport.ready()]);
14
+ const syncPluginUsage = (0, debounceAsync_1.debounceAsync)(async () => {
15
+ await ready;
16
+ let uuid = installationId.get();
17
+ if (!shareUsageStats.get()) {
18
+ if (!uuid)
19
+ return;
20
+ await send('DELETE', { uuid });
21
+ installationId.set('');
22
+ lastReport.set(0);
23
+ return;
24
+ }
25
+ if (Date.now() < lastReport.get() + misc_1.DAY)
26
+ return;
27
+ if (!uuid) {
28
+ uuid = (0, node_crypto_1.randomUUID)();
29
+ installationId.set(uuid);
30
+ }
31
+ const enabled = plugins_1.enablePlugins.get();
32
+ const plugins = selectReportedPlugins([
33
+ ...(0, plugins_1.mapPlugins)((plugin, id) => ({ ...plugin.getData(), id, enabled: enabled.includes(id) }), false),
34
+ ...(0, plugins_1.getInactivePlugins)().map(plugin => ({ ...plugin, enabled: enabled.includes(plugin.id) })),
35
+ ]);
36
+ await send('POST', { uuid, hfsVersion: String(config_1.currentVersion), plugins });
37
+ lastReport.set(Date.now());
38
+ function selectReportedPlugins(installed) {
39
+ const selected = new Map();
40
+ for (const plugin of installed) {
41
+ const repo = typeof plugin.repo === 'string' ? plugin.repo : plugin.repo?.main;
42
+ if (!repo)
43
+ continue;
44
+ const current = selected.get(repo);
45
+ if (!current || better(plugin, current))
46
+ selected.set(repo, plugin);
47
+ }
48
+ return Array.from(selected, ([repo, plugin]) => ({
49
+ repo,
50
+ version: plugin.version,
51
+ enabled: plugin.enabled,
52
+ })).sort((a, b) => a.repo.localeCompare(b.repo));
53
+ function better(a, b) {
54
+ return Number(a.enabled) > Number(b.enabled)
55
+ || a.enabled === b.enabled && (a.id.length < b.id.length
56
+ || a.id.length === b.id.length && a.id.localeCompare(b.id) < 0);
57
+ }
58
+ }
59
+ });
60
+ shareUsageStats.sub(() => void syncPluginUsage().catch(() => { }));
61
+ (0, misc_1.repeat)(misc_1.HOUR, syncPluginUsage);
62
+ async function send(method, body) {
63
+ await (0, util_http_1.httpWithBody)(misc_1.PLUGIN_USAGE_URL + '/report', { method, body, timeout: 5000 });
64
+ }
package/src/plugins.js CHANGED
@@ -53,6 +53,7 @@ exports.getInactivePlugins = getInactivePlugins;
53
53
  exports.rescan = rescan;
54
54
  exports.parsePluginSource = parsePluginSource;
55
55
  exports.getMissingDependencies = getMissingDependencies;
56
+ const acmeDns_1 = require("./acmeDns");
56
57
  const fast_glob_1 = __importDefault(require("fast-glob"));
57
58
  const watchLoad_1 = require("./watchLoad");
58
59
  const lodash_1 = __importDefault(require("lodash"));
@@ -232,6 +233,13 @@ async function initPlugin(pl, morePassedToInit, onInitError) {
232
233
  return result;
233
234
  }
234
235
  },
236
+ registerAcmeDnsProvider(...args) {
237
+ if (unloading)
238
+ throw Error("Plugin is unloading");
239
+ const unregister = (0, acmeDns_1.registerAcmeDnsProvider)(...args);
240
+ cleanups.push(unregister);
241
+ return unregister;
242
+ },
235
243
  misc, _: lodash_1.default,
236
244
  customApiCall, notifyClient: frontEndApis_1.notifyClient, addBlock: block_1.addBlock, ctxBelongsTo: perm_1.ctxBelongsTo, getConnections: connections_1.getConnections, normalizeFilename: vfs_1.normalizeFilename,
237
245
  getCurrentUsername: auth_1.getCurrentUsername, getAccount: perm_1.getAccount, getUsernames: perm_1.getUsernames, addAccount: perm_1.addAccount, delAccount: perm_1.delAccount, updateAccount: perm_1.updateAccount, renameAccount: perm_1.renameAccount,
@@ -734,6 +742,10 @@ function parsePluginSource(id, source) {
734
742
  pl.depend = (0, misc_1.tryJson)(/exports.depend\s*=\s*(\[[\s\S]*?])/m.exec(source)?.[1])?.filter((x) => typeof x.repo === 'string' && x.version === undefined || typeof x.version === 'number'
735
743
  || console.warn("Plugin dependency discarded", x));
736
744
  pl.changelog = (0, misc_1.tryJson)(/exports.changelog\s*=\s*(\[[\s\S]*?])/m.exec(source)?.[1]);
745
+ if (!Array.isArray(pl.changelog))
746
+ delete pl.changelog;
747
+ else if (lodash_1.default.remove(pl.changelog, entry => !Number.isFinite(entry?.version) || typeof entry?.message !== 'string').length)
748
+ console.warn("Invalid plugin changelog entries discarded", id);
737
749
  if (Array.isArray(pl.apiRequired) && (pl.apiRequired.length !== 2 || !pl.apiRequired.every(lodash_1.default.isFinite))) // validate [from,to] form
738
750
  pl.apiRequired = undefined;
739
751
  calculateBadApi(pl);
package/src/roots.js CHANGED
@@ -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.rootsMiddleware = exports.roots = void 0;
6
+ exports.rootsMiddleware = exports.rootsHostGuard = exports.roots = void 0;
7
7
  const config_1 = require("./config");
8
8
  const misc_1 = require("./misc");
9
9
  const connections_1 = require("./connections");
@@ -17,6 +17,26 @@ exports.roots = (0, config_1.defineConfig)(misc_1.CFG.roots, {}, map => {
17
17
  return (host) => values[matchers.findIndex(m => m(host))];
18
18
  });
19
19
  const forceAddress = (0, config_1.defineConfig)(misc_1.CFG.force_address, false);
20
+ const rootsHostGuard = (ctx, next) => {
21
+ if (!ctx.path || lodash_1.default.isEmpty(exports.roots.get()))
22
+ return next();
23
+ if (ctx.path.startsWith(misc_1.SPECIAL_URI)) {
24
+ if (!ctx.path.startsWith(misc_1.API_URI))
25
+ return next();
26
+ const { referer } = ctx.headers;
27
+ const fromAdmin = referer && (0, misc_1.try_)(() => new URL(referer).pathname.startsWith(ctx.state.revProxyPath + misc_1.ADMIN_URI));
28
+ // authentication must remain reachable before ctxAdminAccess can succeed
29
+ if (fromAdmin && ((0, adminApis_1.ctxAdminAccess)(ctx)
30
+ || ['login', 'loginSrp1', 'loginSrp2'].some(x => ctx.path === misc_1.API_URI + x)))
31
+ return next();
32
+ }
33
+ const root = ctx.state.root = exports.roots.compiled()(ctx.host);
34
+ return !ctx.state.skipFilters && forceAddress.get()
35
+ && root === undefined && !(0, misc_1.isLocalHost)(ctx) && ctx.host !== listen_1.baseUrl.compiled()
36
+ ? (0, connections_1.disconnect)(ctx, forceAddress.key())
37
+ : next();
38
+ };
39
+ exports.rootsHostGuard = rootsHostGuard;
20
40
  const rootsMiddleware = (ctx, next) => (() => {
21
41
  if (!ctx.path) // it was once reported "Cannot read properties of null (reading 'startsWith')" but I can't reproduce it, and it shouldn't happen anyway
22
42
  return (0, connections_1.disconnect)(ctx, 'no path'); // let's assume that such requests can't be served anyway. This will leave a trace with an ip, anyway
@@ -27,19 +47,8 @@ const rootsMiddleware = (ctx, next) => (() => {
27
47
  return; // ...unless it's an api
28
48
  params = ctx.state.params || ctx.query; // for api we'll translate params
29
49
  changeUriParams(v => (0, misc_1.removeStarting)(ctx.state.revProxyPath, v)); // this removal must be done before adding the root; this operation doesn't conceptually belong to "roots", and it may be placed in different middleware, but it's convenient to do it here
30
- const { referer } = ctx.headers;
31
- const fromAdmin = referer && (0, misc_1.try_)(() => new URL(referer).pathname.startsWith(ctx.state.revProxyPath + misc_1.ADMIN_URI));
32
- // authentication must remain reachable before ctxAdminAccess can succeed
33
- if (fromAdmin && ((0, adminApis_1.ctxAdminAccess)(ctx)
34
- || ['login', 'loginSrp1', 'loginSrp2'].some(x => ctx.path === misc_1.API_URI + x)))
35
- return;
36
50
  }
37
- if (lodash_1.default.isEmpty(exports.roots.get()))
38
- return;
39
- const root = ctx.state.root = exports.roots.compiled()(ctx.host);
40
- if (!ctx.state.skipFilters && forceAddress.get()
41
- && root === undefined && !(0, misc_1.isLocalHost)(ctx) && ctx.host !== listen_1.baseUrl.compiled())
42
- return (0, connections_1.disconnect)(ctx, forceAddress.key()); // returning truthy will not call next
51
+ const { root } = ctx.state;
43
52
  if (!root || root === '/')
44
53
  return; // no transformation is required
45
54
  changeUriParams(v => (0, misc_1.join)(root, v));