hfs 3.1.4 → 3.2.0-alpha1.1

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 (219) hide show
  1. package/admin/assets/af-RjyQ-neT.js +1 -0
  2. package/admin/assets/am-PptHpglb.js +1 -0
  3. package/admin/assets/ar-Du3syDxa.js +1 -0
  4. package/admin/assets/ar-dz-CiziRh1x.js +1 -0
  5. package/admin/assets/ar-iq-hCKg0CRP.js +1 -0
  6. package/admin/assets/ar-kw-COYePt-p.js +1 -0
  7. package/admin/assets/ar-ly-CY5yETqb.js +1 -0
  8. package/admin/assets/ar-ma-DczXi97r.js +1 -0
  9. package/admin/assets/ar-sa-Bu7sTKzl.js +1 -0
  10. package/admin/assets/ar-tn-BrG0r6qZ.js +1 -0
  11. package/admin/assets/az-DYRCrdk0.js +1 -0
  12. package/admin/assets/be-DObSs5HX.js +1 -0
  13. package/admin/assets/bg-BDBkMe50.js +1 -0
  14. package/admin/assets/bi-CApXFwc6.js +1 -0
  15. package/admin/assets/bm-CjxCV8Lr.js +1 -0
  16. package/admin/assets/bn-DzWZbDi3.js +1 -0
  17. package/admin/assets/bn-bd-ijdPRJbb.js +1 -0
  18. package/admin/assets/bo-BDDfj5XH.js +1 -0
  19. package/admin/assets/br-DRHEjait.js +1 -0
  20. package/admin/assets/bs-B0Zv2Agm.js +1 -0
  21. package/admin/assets/ca-DuZAUqU_.js +1 -0
  22. package/admin/assets/cs-DVkylosR.js +1 -0
  23. package/admin/assets/cv-8Is1Fns9.js +1 -0
  24. package/admin/assets/cy-Bk30Or_y.js +1 -0
  25. package/admin/assets/da-C41DIdZK.js +1 -0
  26. package/admin/assets/de--qvLJgIE.js +1 -0
  27. package/admin/assets/de-at-D5vCBlsw.js +1 -0
  28. package/admin/assets/de-ch-Oh2cO23c.js +1 -0
  29. package/admin/assets/dv-4ZO2KNbw.js +1 -0
  30. package/admin/assets/el-CobOUqyu.js +1 -0
  31. package/admin/assets/en-C9afClpS.js +1 -0
  32. package/admin/assets/en-au-C_QMghEl.js +1 -0
  33. package/admin/assets/en-ca-C80KILwc.js +1 -0
  34. package/admin/assets/en-gb-BwQ0asAI.js +1 -0
  35. package/admin/assets/en-ie-ConCcQkq.js +1 -0
  36. package/admin/assets/en-il-CAOzD4DD.js +1 -0
  37. package/admin/assets/en-in-87gardaO.js +1 -0
  38. package/admin/assets/en-nz-uMTjgkDP.js +1 -0
  39. package/admin/assets/en-sg-BlbiLv4L.js +1 -0
  40. package/admin/assets/en-tt-BXXmBax2.js +1 -0
  41. package/admin/assets/eo-BBL7o-0W.js +1 -0
  42. package/admin/assets/es-DuNej-79.js +1 -0
  43. package/admin/assets/es-do-bZWKFwTE.js +1 -0
  44. package/admin/assets/es-mx-C2Lfb86F.js +1 -0
  45. package/admin/assets/es-pr-wU3Du8m-.js +1 -0
  46. package/admin/assets/es-us-qcU-zRiw.js +1 -0
  47. package/admin/assets/et-B-4PjN_n.js +1 -0
  48. package/admin/assets/eu-D0pNDZ9i.js +1 -0
  49. package/admin/assets/fa-R_zmYBGc.js +1 -0
  50. package/admin/assets/fi-CWYS_0Hg.js +1 -0
  51. package/admin/assets/fo-aJYpcnWS.js +1 -0
  52. package/admin/assets/fr-BHFrWfwB.js +1 -0
  53. package/admin/assets/fr-ca-ihwmO47n.js +1 -0
  54. package/admin/assets/fr-ch-Cb8XbRfE.js +1 -0
  55. package/admin/assets/fy-DT9BUc6t.js +1 -0
  56. package/admin/assets/ga-B8p4naXJ.js +1 -0
  57. package/admin/assets/gd-Dzrl6rPc.js +1 -0
  58. package/admin/assets/gl-bN2tXl9d.js +1 -0
  59. package/admin/assets/gom-latn-DUK8PgIJ.js +1 -0
  60. package/admin/assets/gu-CxAIP8fw.js +1 -0
  61. package/admin/assets/he-Bm9XJVAj.js +1 -0
  62. package/admin/assets/hi-CHik9Qxx.js +1 -0
  63. package/admin/assets/hr-Dn57_dqN.js +1 -0
  64. package/admin/assets/ht-BRS-FrJq.js +1 -0
  65. package/admin/assets/hu-hVaGcZ-U.js +1 -0
  66. package/admin/assets/hy-am-heLxXhpz.js +1 -0
  67. package/admin/assets/id-B6AkO4h8.js +1 -0
  68. package/admin/assets/{index-DTxjaflW.js → index-Buyl8rI8.js} +1 -1
  69. package/admin/assets/index-CFWd-FDo.css +1 -0
  70. package/admin/assets/index-WXxQwyJV.js +889 -0
  71. package/admin/assets/is-BUADPoni.js +1 -0
  72. package/admin/assets/it-Ce3gssOP.js +1 -0
  73. package/admin/assets/it-ch-CJzj2czr.js +1 -0
  74. package/admin/assets/ja-DykMIu-9.js +1 -0
  75. package/admin/assets/jv-Cum6DLwa.js +1 -0
  76. package/admin/assets/ka-foCPVFxw.js +1 -0
  77. package/admin/assets/kk-CMNu1ysC.js +1 -0
  78. package/admin/assets/km-BfMisGaD.js +1 -0
  79. package/admin/assets/kn-R_9S5KJz.js +1 -0
  80. package/admin/assets/ko-DYRND-mM.js +1 -0
  81. package/admin/assets/ku-CNZ_weGy.js +1 -0
  82. package/admin/assets/ky-BhTAWL4I.js +1 -0
  83. package/admin/assets/lb-CxWhQxwF.js +1 -0
  84. package/admin/assets/lo-DBL_XLkE.js +1 -0
  85. package/admin/assets/lt-BakZVm4p.js +1 -0
  86. package/admin/assets/lv-CCn-slXG.js +1 -0
  87. package/admin/assets/me-xPEA4qjE.js +1 -0
  88. package/admin/assets/mi-6cCs2Mzx.js +1 -0
  89. package/admin/assets/mk-CwWbHJPS.js +1 -0
  90. package/admin/assets/ml-Dp7Ab1SV.js +1 -0
  91. package/admin/assets/mn-ClotGWAe.js +1 -0
  92. package/admin/assets/mr-GiM-paTF.js +1 -0
  93. package/admin/assets/ms-BiZ1_eVR.js +1 -0
  94. package/admin/assets/ms-my-oevI0fOH.js +1 -0
  95. package/admin/assets/mt-BaIHeOl1.js +1 -0
  96. package/admin/assets/my-Cg5Fc_1j.js +1 -0
  97. package/admin/assets/nb-ZPODjT6R.js +1 -0
  98. package/admin/assets/ne-C0ti-ZX2.js +1 -0
  99. package/admin/assets/nl-DP2PkkGx.js +1 -0
  100. package/admin/assets/nl-be-VPC0QyaW.js +1 -0
  101. package/admin/assets/nn-NLidKyJd.js +1 -0
  102. package/admin/assets/oc-lnc-Ddj9_PnD.js +1 -0
  103. package/admin/assets/pa-in-DSAWffhb.js +1 -0
  104. package/admin/assets/pl-Yojex5aS.js +1 -0
  105. package/admin/assets/pt-BQMs2la2.js +1 -0
  106. package/admin/assets/pt-br-DVeeNZu8.js +1 -0
  107. package/admin/assets/rn-CKkTrK3f.js +1 -0
  108. package/admin/assets/ro-BbgGULSZ.js +1 -0
  109. package/admin/assets/ru-mAZX3DTa.js +1 -0
  110. package/admin/assets/rw-CYErOxKd.js +1 -0
  111. package/admin/assets/sd-kfPTqkSy.js +1 -0
  112. package/admin/assets/se-D0bN3rDS.js +1 -0
  113. package/admin/assets/{sha512-D936QW8l.js → sha512-99nhg44S.js} +1 -1
  114. package/admin/assets/si-COjb_4Hy.js +1 -0
  115. package/admin/assets/sk-Co95XNOo.js +1 -0
  116. package/admin/assets/sl-DUXa5wTF.js +1 -0
  117. package/admin/assets/sq-B-KU0nWK.js +1 -0
  118. package/admin/assets/sr-Cb_b-zPG.js +1 -0
  119. package/admin/assets/sr-cyrl-csuTDIB6.js +1 -0
  120. package/admin/assets/ss-BDnE-cG6.js +1 -0
  121. package/admin/assets/sv-CzWdOHcE.js +1 -0
  122. package/admin/assets/sv-fi-IK8oi5nB.js +1 -0
  123. package/admin/assets/sw-fPHo5hof.js +1 -0
  124. package/admin/assets/ta-TteN0nyU.js +1 -0
  125. package/admin/assets/te-2gsQb1fF.js +1 -0
  126. package/admin/assets/tet-ClTpDYJv.js +1 -0
  127. package/admin/assets/tg-Bel7Uc6z.js +1 -0
  128. package/admin/assets/th-pDQttM9V.js +1 -0
  129. package/admin/assets/tk-CxoKYZH6.js +1 -0
  130. package/admin/assets/tl-ph-D9htRcOQ.js +1 -0
  131. package/admin/assets/tlh-7UqvDBxU.js +1 -0
  132. package/admin/assets/tr-NjJrq1iC.js +1 -0
  133. package/admin/assets/tzl-CzGaXn8M.js +1 -0
  134. package/admin/assets/tzm-BiuscZFr.js +1 -0
  135. package/admin/assets/tzm-latn-IeWkCgWf.js +1 -0
  136. package/admin/assets/ug-cn-5LK64x5v.js +1 -0
  137. package/admin/assets/uk-CzacUaQe.js +1 -0
  138. package/admin/assets/ur-BRV7z4Gu.js +1 -0
  139. package/admin/assets/uz-C4G77QOI.js +1 -0
  140. package/admin/assets/uz-latn-CQFrzZ6F.js +1 -0
  141. package/admin/assets/vi-DEFXdlJi.js +1 -0
  142. package/admin/assets/x-pseudo-sWj5RKxR.js +1 -0
  143. package/admin/assets/yo-CBDjk96e.js +1 -0
  144. package/admin/assets/zh-CQtX_fkD.js +1 -0
  145. package/admin/assets/zh-cn-D6dF0jKM.js +1 -0
  146. package/admin/assets/zh-hk-DY-Jaqdg.js +1 -0
  147. package/admin/assets/zh-tw-BdmVby0R.js +1 -0
  148. package/admin/index.html +2 -2
  149. package/frontend/assets/index-legacy-BtoTkZho.js +9 -0
  150. package/frontend/assets/{index-legacy-CQ3_LTGh.js → index-legacy-CQovmh_0.js} +1 -1
  151. package/frontend/assets/{sha512-legacy-DLqdsV8R.js → sha512-legacy-CXU3efCO.js} +1 -1
  152. package/frontend/index.html +1 -1
  153. package/npm-shrinkwrap.json +170 -81
  154. package/package.json +9 -9
  155. package/plugins/antibrute/plugin.js +150 -19
  156. package/plugins/list-uploader/public/main.js +1 -1
  157. package/src/acme.js +11 -7
  158. package/src/api.accounts.js +3 -3
  159. package/src/api.auth.js +3 -1
  160. package/src/api.get_file_list.js +16 -12
  161. package/src/api.monitor.js +47 -43
  162. package/src/api.net.js +4 -3
  163. package/src/api.vfs.js +1 -1
  164. package/src/commands.js +54 -1
  165. package/src/config.js +9 -5
  166. package/src/consoleLog.js +39 -1
  167. package/src/const.js +5 -1
  168. package/src/cross.js +22 -1
  169. package/src/errorPages.js +20 -10
  170. package/src/events.js +1 -0
  171. package/src/fileAttr.js +73 -15
  172. package/src/frontEndApis.js +3 -1
  173. package/src/index.js +2 -1
  174. package/src/langs/hfs-lang-ar.json +2 -1
  175. package/src/langs/hfs-lang-bg.json +2 -1
  176. package/src/langs/hfs-lang-de.json +2 -1
  177. package/src/langs/hfs-lang-el.json +2 -1
  178. package/src/langs/hfs-lang-es.json +2 -1
  179. package/src/langs/hfs-lang-fi.json +2 -1
  180. package/src/langs/hfs-lang-fr.json +2 -1
  181. package/src/langs/hfs-lang-hu.json +2 -1
  182. package/src/langs/hfs-lang-it.json +2 -1
  183. package/src/langs/hfs-lang-ja.json +2 -1
  184. package/src/langs/hfs-lang-ko.json +2 -1
  185. package/src/langs/hfs-lang-lt.json +2 -1
  186. package/src/langs/hfs-lang-ms.json +2 -0
  187. package/src/langs/hfs-lang-nl.json +2 -1
  188. package/src/langs/hfs-lang-pt-br.json +2 -1
  189. package/src/langs/hfs-lang-ro.json +2 -1
  190. package/src/langs/hfs-lang-ru.json +2 -1
  191. package/src/langs/hfs-lang-sr-latn.json +2 -1
  192. package/src/langs/hfs-lang-sr.json +2 -1
  193. package/src/langs/hfs-lang-th.json +2 -1
  194. package/src/langs/hfs-lang-tr.json +2 -1
  195. package/src/langs/hfs-lang-uk.json +2 -1
  196. package/src/langs/hfs-lang-vi.json +2 -1
  197. package/src/langs/hfs-lang-zh-tw.json +2 -1
  198. package/src/langs/hfs-lang-zh.json +2 -1
  199. package/src/listen.js +2 -1
  200. package/src/log.js +27 -2
  201. package/src/middlewares.js +8 -2
  202. package/src/misc.js +14 -0
  203. package/src/nat.js +57 -20
  204. package/src/outboundProxy.js +1 -1
  205. package/src/perm.js +5 -1
  206. package/src/plugins.js +34 -5
  207. package/src/serveGuiAndSharedFiles.js +1 -0
  208. package/src/serveGuiFiles.js +1 -0
  209. package/src/update.js +9 -15
  210. package/src/urlList.js +32 -0
  211. package/src/util-files.js +15 -5
  212. package/src/util-http.js +2 -0
  213. package/src/vfs.js +1 -1
  214. package/src/walkDir.js +7 -1
  215. package/src/webdav.js +4 -1
  216. package/src/zip.js +2 -1
  217. package/admin/assets/index-B66w-a0v.css +0 -1
  218. package/admin/assets/index-Df6vYR7s.js +0 -822
  219. package/frontend/assets/index-legacy-D6nPw49_.js +0 -9
@@ -1,4 +1,4 @@
1
- exports.version = 3.1
1
+ exports.version = 3.2
2
2
  exports.description = "Introduce increasing delays between login attempts."
3
3
  exports.apiRequired = 9.6 // addBlock
4
4
 
@@ -8,42 +8,173 @@ exports.config = {
8
8
  blockAfter: { type: 'number', xs: 6, min: 1, max: 9999, defaultValue: 100, label: "Block IP after", unit: "attempts", helperText: "localhost excluded" },
9
9
  blockForHours: { type: 'number', xs: 6, min: 0, defaultValue: 24, label: "Block for", unit: "hours" },
10
10
  exclude: { type: 'string', defaultValue: '', label: "Exclude IPs", helperText: "Net mask syntax" },
11
+ maxQueuePerIp: { type: 'number', min: 1, max: 9999, defaultValue: 32, label: "Max queued per IP" },
12
+ maxQueuePerAccount: { type: 'number', min: 1, max: 9999, defaultValue: 16, label: "Max queued per account" },
13
+ maxQueueGlobal: { type: 'number', min: 1, max: 999999, defaultValue: 512, label: "Max queued globally" },
11
14
  }
12
15
  exports.configDialog = {
13
16
  maxWidth: 'xs',
14
17
  }
15
18
 
16
19
  const byIp = {}
20
+ const byAccount = {}
21
+ const laneByIp = {}
22
+ const laneByAccount = {}
23
+ const UNKNOWN_ACCOUNT = 'unknown\t'
17
24
 
18
25
  exports.init = api => {
19
26
  const { isLocalHost, HOUR, netMatches } = api.misc
27
+ const { makeQ } = api.require('./makeQ')
28
+ const gateQ = makeQ(1)
29
+ let waitingGlobal = 0
30
+ const QUEUE_FULL = Symbol('queue_full')
20
31
  api.events.multi({
21
- async attemptingLogin({ ctx }) {
32
+ async attemptingLogin({ ctx, username }) {
22
33
  const { ip } = ctx
23
- const now = new Date
24
- const rec = byIp[ip] ||= { attempts: 0, next: now }
25
- const max = api.getConfig('max') * 1000
26
- const delay = Math.min(max, 1000 * api.getConfig('increment') * ++rec.attempts)
27
- const wait = rec.next - now
28
- rec.next = new Date(+rec.next + delay)
29
- if (rec.attempts > api.getConfig('blockAfter') && !isLocalHost(ctx) && !isExcluded(ip)) {
30
- const hours = api.getConfig('blockForHours')
31
- api.addBlock({ ip, comment: "From antibrute plugin", expire: hours ? new Date(now.getTime() + hours * HOUR) : undefined })
34
+ const account = getAccountKey(username)
35
+ const ipRec = getRecord(byIp, ip)
36
+ const accountRec = getRecord(byAccount, account)
37
+ let admitted = false
38
+ try {
39
+ await runGate(() => {
40
+ if (ipRec.waiting >= api.getConfig('maxQueuePerIp')
41
+ || accountRec.waiting >= api.getConfig('maxQueuePerAccount')
42
+ || waitingGlobal >= api.getConfig('maxQueueGlobal'))
43
+ throw QUEUE_FULL
44
+ // reserve all buckets atomically so we never exceed limits due to parallel requests
45
+ ipRec.waiting++
46
+ accountRec.waiting++
47
+ waitingGlobal++
48
+ admitted = true
49
+ })
50
+ // serialize waits per ip and per account so parallel bursts can't consume the same penalty window
51
+ await runInLane(getLane(laneByIp, ip), () =>
52
+ runInLane(getLane(laneByAccount, account), async () => {
53
+ const now = Date.now()
54
+ const wait = Math.max(0, ipRec.next - now, accountRec.next - now)
55
+ if (wait <= 0) return
56
+ api.log('delaying', ip, 'for', Math.round(wait / 1000))
57
+ ctx.set('x-anti-brute-force', wait)
58
+ await new Promise(resolve => setTimeout(resolve, wait))
59
+ }))
60
+ }
61
+ catch (e) {
62
+ if (e === QUEUE_FULL) {
63
+ ctx.status = 429
64
+ return api.events.stop
65
+ }
66
+ throw e
32
67
  }
33
- clearTimeout(rec.timer)
34
- if (wait > 0) {
35
- api.log('delaying', ip, 'for', Math.round(wait / 1000))
36
- ctx.set('x-anti-brute-force', wait)
37
- await new Promise(resolve => setTimeout(resolve, wait))
68
+ finally {
69
+ if (admitted) {
70
+ ipRec.waiting--
71
+ accountRec.waiting--
72
+ waitingGlobal--
73
+ }
74
+ armCleanup(byIp, ip, ipRec)
75
+ armCleanup(byAccount, account, accountRec)
76
+ dropLaneIfIdle(laneByIp, ip)
77
+ dropLaneIfIdle(laneByAccount, account)
38
78
  }
39
- rec.timer = setTimeout(() => delete byIp[ip], 24 * HOUR) // no memory leak
79
+ },
80
+ failedLogin({ ctx, username }) {
81
+ const { ip } = ctx
82
+ const account = getAccountKey(username)
83
+ const now = Date.now()
84
+ const ipRec = getRecord(byIp, ip)
85
+ const accountRec = getRecord(byAccount, account)
86
+ const ipAttempts = increasePenalty(ipRec, now)
87
+ increasePenalty(accountRec, now)
88
+ if (ipAttempts > api.getConfig('blockAfter') && !isLocalHost(ctx) && !isExcluded(ip)) {
89
+ const hours = api.getConfig('blockForHours')
90
+ api.addBlock({ ip, comment: "From antibrute plugin", expire: hours ? new Date(now + hours * HOUR) : undefined })
91
+ }
92
+ armCleanup(byIp, ip, ipRec)
93
+ armCleanup(byAccount, account, accountRec)
94
+ dropLaneIfIdle(laneByIp, ip)
95
+ dropLaneIfIdle(laneByAccount, account)
40
96
  },
41
97
  login(ctx) {
42
- if (ctx.state.account)
43
- delete byIp[ctx.ip] // reset if login was successful
98
+ if (ctx.state.account) {
99
+ const { ip } = ctx
100
+ const account = getAccountKey(ctx.state.account.username)
101
+ resetRecord(byIp, ip)
102
+ resetRecord(byAccount, account)
103
+ dropLaneIfIdle(laneByIp, ip)
104
+ dropLaneIfIdle(laneByAccount, account)
105
+ }
44
106
  }
45
107
  })
46
108
 
109
+ function getRecord(container, key) {
110
+ return container[key] ||= { failures: 0, next: 0, waiting: 0 }
111
+ }
112
+
113
+ function increasePenalty(rec, now) {
114
+ const attempts = ++rec.failures
115
+ const max = api.getConfig('max') * 1000
116
+ const delay = Math.min(max, attempts * api.getConfig('increment') * 1000)
117
+ rec.next = Math.max(now, rec.next) + delay
118
+ return attempts
119
+ }
120
+
121
+ function armCleanup(records, key, rec) {
122
+ clearTimeout(rec.timer)
123
+ rec.timer = setTimeout(() => {
124
+ // keep records while there are in-flight admissions, otherwise later releases may touch deleted state
125
+ if (rec.waiting)
126
+ return armCleanup(records, key, rec)
127
+ delete records[key]
128
+ }, 24 * HOUR) // no memory leak
129
+ }
130
+
131
+ function runGate(job) {
132
+ return new Promise((resolve, reject) => {
133
+ gateQ.add(async () => {
134
+ try { resolve(await job()) }
135
+ catch (e) { reject(e) }
136
+ })
137
+ })
138
+ }
139
+
140
+ function getLane(container, key) {
141
+ return container[key] ||= makeQ(1)
142
+ }
143
+
144
+ function runInLane(q, job) {
145
+ return new Promise((resolve, reject) => {
146
+ q.add(async () => {
147
+ try { resolve(await job()) }
148
+ catch (e) { reject(e) }
149
+ })
150
+ })
151
+ }
152
+
153
+ function dropLaneIfIdle(container, key) {
154
+ const q = container[key]
155
+ if (q?.isWorking() || q?.queueSize()) return
156
+ delete container[key]
157
+ }
158
+
159
+ function resetRecord(container, key) {
160
+ const rec = container[key]
161
+ if (!rec) return
162
+ if (rec.waiting) {
163
+ // successful login must clear penalties without dropping admission counters still needed by concurrent requests
164
+ rec.failures = 0
165
+ rec.next = 0
166
+ return
167
+ }
168
+ delete container[key]
169
+ }
170
+
171
+ function getAccountKey(username) {
172
+ // fold unknown usernames together to avoid unbounded memory growth from random names
173
+ if (!username || !api.getAccount(String(username)))
174
+ return UNKNOWN_ACCOUNT
175
+ return String(username).toLowerCase()
176
+ }
177
+
47
178
  function isExcluded(ip) {
48
179
  const mask = api.getConfig('exclude')
49
180
  if (!mask) return false
@@ -6,7 +6,7 @@
6
6
  HFS.h(Uploader, entry))
7
7
 
8
8
  function Uploader({ uri }) {
9
- const { data } = HFS.useBatch(getDetails, uri)
9
+ const { data } = HFS.useBatch(getDetails, uri, { depend: HFS.state.isAdmin })
10
10
  const text = React.useMemo(() => {
11
11
  if (!data || data === true) return ''
12
12
  const { upload: x } = data
package/src/acme.js CHANGED
@@ -32,17 +32,21 @@ const acmeMiddleware = (ctx, next) => {
32
32
  return next();
33
33
  };
34
34
  exports.acmeMiddleware = acmeMiddleware;
35
- const TEMP_MAP = { private: 80, public: { host: '', port: 80 }, description: 'hfs temporary', ttl: 5000 }; // from my tests (zyxel VMG8825), lower values won't make a working mapping
35
+ const TEMP_MAP = (0, nat_1.upnpMappingParam)(80, 80, 'hfs temporary', 5000); // from my tests (zyxel VMG8825), lower values won't make a working mapping
36
+ // remove temporary port mapping, if any is left from previous execution
36
37
  (0, misc_1.repeat)(misc_1.MINUTE, async (stop) => {
37
- await nat_1.upnpClient.getGateway(); // without this, the next call will break upnp support
38
- const res = await nat_1.upnpClient.getMappings();
38
+ if (!await nat_1.upnpEnabled.getWhenReady())
39
+ return stop();
40
+ const client = (0, nat_1.getUpnpClient)();
41
+ await client.getGateway(); // without this, the next call will break upnp support
42
+ const res = await client.getMappings();
39
43
  const leftover = res.find(x => x.description === TEMP_MAP.description); // in case the process is interrupted
40
44
  if (!leftover)
41
45
  return void stop(); // we are good
42
46
  if (acmeOngoing)
43
47
  return; // it doesn't count, as we are in the middle of something. Retry later
44
48
  stop();
45
- return nat_1.upnpClient.removeMapping(TEMP_MAP);
49
+ return client.removeMapping(TEMP_MAP);
46
50
  });
47
51
  async function generateSSLCert(domain, email, altNames) {
48
52
  // will answer the challenge through our koa app (if on port 80) or must we spawn a dedicated server?
@@ -63,7 +67,7 @@ async function generateSSLCert(domain, email, altNames) {
63
67
  let check = await (0, selfCheck_1.selfCheck)(checkUrl); // some check services may not consider the domain, but we already verified that
64
68
  if (check?.success === false && nat.upnp && !nat.mapped80) {
65
69
  console.debug("Setting temporary port forward");
66
- tempMap = await (0, misc_1.haveTimeout)(10_000, nat_1.upnpClient.createMapping(TEMP_MAP).catch(() => { })).catch(() => { });
70
+ tempMap = await (0, misc_1.haveTimeout)(10_000, (0, nat_1.getUpnpClient)().createMapping(TEMP_MAP)).catch(() => { });
67
71
  check = await (0, selfCheck_1.selfCheck)(checkUrl); // repeat test
68
72
  }
69
73
  //if (!check) throw new ApiError(HTTP_FAILED_DEPENDENCY, "couldn't test port 80")
@@ -88,9 +92,9 @@ async function generateSSLCert(domain, email, altNames) {
88
92
  return { key, cert };
89
93
  }
90
94
  finally {
91
- if (tempMap) {
95
+ if (tempMap && nat_1.upnpEnabled.get()) {
92
96
  console.debug("Removing temporary port forward");
93
- nat_1.upnpClient.removeMapping(TEMP_MAP).catch(() => { }); // clean after ourselves
97
+ (0, nat_1.getUpnpClient)().removeMapping(TEMP_MAP).catch(() => { }); // clean after ourselves
94
98
  }
95
99
  acmeOngoing = false;
96
100
  if (tempSrv)
@@ -16,9 +16,9 @@ function prepareAccount(ac) {
16
16
  ...lodash_1.default.omit(ac, ['password', 'hashed_password', 'srp']),
17
17
  username: ac.username, // omit won't copy it because it's a hidden prop
18
18
  hasPassword: (0, perm_1.accountHasPassword)(ac),
19
- isGroup: !ac.plugin?.auth && !(0, perm_1.accountHasPassword)(ac),
19
+ isGroup: !(0, perm_1.accountHasLoginMethod)(ac),
20
20
  adminActualAccess: (0, perm_1.accountCanLoginAdmin)(ac),
21
- canLogin: (0, perm_1.accountHasPassword)(ac) ? (0, perm_1.accountCanLogin)(ac) : undefined,
21
+ canLogin: (0, perm_1.accountHasLoginMethod)(ac) ? (0, perm_1.accountCanLogin)(ac) : undefined,
22
22
  canChangePassword: (0, perm_1.accountCanChangePassword)(ac),
23
23
  invalidated: auth_1.invalidateSessionBefore.get(ac.username),
24
24
  directMembers: Object.values(perm_1.accounts.get()).filter(a => a.belongs?.includes(ac.username)).map(x => x.username),
@@ -33,7 +33,7 @@ function prepareAccount(ac) {
33
33
  })
34
34
  };
35
35
  }
36
- const ALLOWED_KEYS = ['admin', 'allow_net', 'belongs', 'days_to_live', 'disable_password_change',
36
+ const ALLOWED_KEYS = ['admin', 'allow_net', 'auto_login_net', 'belongs', 'days_to_live', 'disable_password_change',
37
37
  'disabled', 'expire', 'ignore_limits', 'notes', 'password', 'redirect', 'require_password_change', 'username'];
38
38
  exports.default = {
39
39
  get_usernames() {
package/src/api.auth.js CHANGED
@@ -18,10 +18,12 @@ const ongoingLogins = {}; // store data that doesn't fit session object
18
18
  const keepSessionAlive = (0, config_1.defineConfig)('keep_session_alive', true);
19
19
  const refresh_session = async ({}, ctx) => {
20
20
  const username = (0, auth_1.getCurrentUsername)(ctx);
21
+ const isAdmin = (0, adminApis_1.ctxAdminAccess)(ctx) || undefined;
21
22
  return !ctx.session ? new apiMiddleware_1.ApiError(const_1.HTTP_SERVER_ERROR) : {
22
23
  username,
23
24
  expandedUsername: Array.from((0, perm_1.expandUsername)(username)),
24
- adminUrl: (0, adminApis_1.ctxAdminAccess)(ctx) ? ctx.state.revProxyPath + const_1.ADMIN_URI : undefined,
25
+ isAdmin,
26
+ adminUrl: isAdmin && ctx.state.revProxyPath + const_1.ADMIN_URI,
25
27
  canChangePassword: (0, perm_1.accountCanChangePassword)(ctx.state.account),
26
28
  requireChangePassword: ctx.state.account?.require_password_change,
27
29
  exp: username && keepSessionAlive.get() ? new Date(Date.now() + middlewares_1.sessionDuration.compiled()) : undefined,
@@ -44,7 +44,7 @@ const get_file_list = async ({ uri = '/', offset, limit, c, onlyFolders, onlyFil
44
44
  limit = Number(limit);
45
45
  const { filterName, filterComment, fileMask, depth } = paramsToFilter(rest);
46
46
  const walker = (0, vfs_1.walkNode)(node, { ctx: admin ? undefined : ctx, onlyFolders, onlyFiles, depth });
47
- const onDirEntryHandlers = (0, plugins_1.mapPlugins)(plug => plug.onDirEntry);
47
+ const onDirEntryHandlers = (0, plugins_1.mapPlugins)((plug, id) => plug.onDirEntry && { id, cb: plug.onDirEntry });
48
48
  const can_upload = admin || (0, vfs_1.hasPermission)(node, 'can_upload', ctx);
49
49
  const can_delete = admin || (0, vfs_1.hasPermission)(node, 'can_delete', ctx);
50
50
  const fakeChild = await (0, vfs_1.applyParentToChild)({ source: 'dummy-file', original: undefined }, node); // used to check permission; simple but can produce false results; 'original' to simulate a non-vfs node
@@ -74,7 +74,7 @@ const get_file_list = async ({ uri = '/', offset, limit, c, onlyFolders, onlyFil
74
74
  async function* produceEntries() {
75
75
  for await (const sub of walker) {
76
76
  let name = (0, vfs_1.getNodeName)(sub);
77
- name = (0, path_1.basename)(name) || name; // on windows, basename('C:') === ''
77
+ name = (0, path_1.basename)(name) || name; // on Windows, basename('C:') === ''
78
78
  if (filterName && !filterName(name) || fileMask && !(0, vfs_1.nodeIsFolder)(sub) && !fileMask(name)
79
79
  || filterComment && !filterComment(await (0, comments_1.getCommentFor)(sub.source) || ''))
80
80
  continue;
@@ -83,15 +83,15 @@ const get_file_list = async ({ uri = '/', offset, limit, c, onlyFolders, onlyFil
83
83
  continue;
84
84
  const cbParams = { entry, ctx, listUri: uri, node: sub };
85
85
  try {
86
- const res = await Promise.all(onDirEntryHandlers.map(cb => cb(cbParams)));
86
+ const res = await Promise.all(onDirEntryHandlers.map(({ id, cb }) => Promise.resolve().then(() => cb(cbParams)).catch(error => { throw { id, error }; })));
87
87
  if (res.some(x => x === false))
88
88
  continue;
89
- if ((await events_1.default.emitAsync('dirEntry', cbParams))?.isDefaultPrevented())
90
- continue;
91
89
  }
92
90
  catch (e) {
93
- console.warn("A plugin is causing problems on dirEntry:", e);
91
+ console.warn(`Plugin ${e?.id || '?'} is causing problems on onDirEntry:`, e?.error ?? e);
94
92
  }
93
+ if ((await events_1.default.emitAsync('dirEntry', cbParams))?.isDefaultPrevented())
94
+ continue;
95
95
  if (offset) {
96
96
  --offset;
97
97
  continue;
@@ -111,10 +111,14 @@ const get_file_list = async ({ uri = '/', offset, limit, c, onlyFolders, onlyFil
111
111
  const name = (0, vfs_1.getNodeName)(node);
112
112
  const isFolder = (0, vfs_1.nodeIsFolder)(node);
113
113
  try {
114
- const st = source ? await (node.stats || (0, misc_1.statWithTimeout)(source).catch(e => {
115
- if (!isFolder || !node.children?.length) // folders with virtual children, keep them
116
- throw e;
117
- })) : undefined;
114
+ const [web, comment, st] = await Promise.all([
115
+ (0, vfs_1.hasDefaultFile)(node, ctx).then(x => x ? true : undefined),
116
+ node.comment ?? (0, comments_1.getCommentFor)(source),
117
+ (0, vfs_1.nodeStats)(node).catch(e => {
118
+ if (!isFolder || !node.children?.length) // folders with virtual children, keep them
119
+ throw e;
120
+ })
121
+ ]);
118
122
  // permissions of entries are sent as a difference with permissions of parent
119
123
  const pl = node.can_list === misc_1.WHO_NO_ONE ? 'l'
120
124
  : !(0, vfs_1.hasPermission)(node, 'can_list', ctx) ? 'L'
@@ -136,9 +140,9 @@ const get_file_list = async ({ uri = '/', offset, limit, c, onlyFolders, onlyFil
136
140
  url,
137
141
  target: node.target,
138
142
  order: node.order,
139
- comment: node.comment ?? await (0, comments_1.getCommentFor)(source),
143
+ comment,
140
144
  icon: getNodeIcon(node),
141
- web: await (0, vfs_1.hasDefaultFile)(node, ctx) ? true : undefined,
145
+ web,
142
146
  };
143
147
  }
144
148
  catch {
@@ -4,6 +4,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  return (mod && mod.__esModule) ? mod : { "default": mod };
5
5
  };
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.serializeConnection = serializeConnection;
8
+ exports.inferOperation = inferOperation;
7
9
  const lodash_1 = __importDefault(require("lodash"));
8
10
  const connections_1 = require("./connections");
9
11
  const misc_1 = require("./misc");
@@ -11,6 +13,7 @@ const throttler_1 = require("./throttler");
11
13
  const auth_1 = require("./auth");
12
14
  const SendList_1 = require("./SendList");
13
15
  const persistence_1 = require("./persistence");
16
+ const cross_1 = require("./cross");
14
17
  exports.default = {
15
18
  async disconnect({ ip, port, allButLocalhost }) {
16
19
  (0, misc_1.apiAssertTypes)({
@@ -28,25 +31,21 @@ exports.default = {
28
31
  get_connections({}, ctx) {
29
32
  const list = new SendList_1.SendListReadable({
30
33
  diff: true,
31
- addAtStart: (0, connections_1.getConnections)().map(c => !ignore(c) && serializeConnection(c)).filter(Boolean),
34
+ addAtStart: (0, connections_1.getConnections)().map(serializeConnection),
32
35
  });
33
36
  list.props({ you: ctx.ip });
34
37
  return list.events(ctx, {
35
38
  connection(conn) {
36
- if (ignore(conn))
37
- return;
38
39
  list.add(serializeConnection(conn));
39
40
  },
40
41
  connectionClosed(conn) {
41
- if (ignore(conn))
42
- return;
43
42
  list.remove(getConnAddress(conn));
44
43
  },
45
44
  connectionNewIp(conn, oldIp, newIp) {
46
45
  list.update(getConnAddress(conn, oldIp), { ip: newIp });
47
46
  },
48
47
  connectionUpdated(conn, change) {
49
- if (conn.socket.closed || ignore(conn) || ignore(change) || lodash_1.default.isEmpty(change))
48
+ if (conn.socket.closed || lodash_1.default.isEmpty(change))
50
49
  return;
51
50
  if (change.ctx) {
52
51
  Object.assign(change, fromCtx(change.ctx));
@@ -55,48 +54,16 @@ exports.default = {
55
54
  list.update(getConnAddress(conn), change);
56
55
  },
57
56
  });
58
- function serializeConnection(conn) {
59
- const { socket, started, secure } = conn;
60
- return {
61
- ...getConnAddress(conn),
62
- v: (socket.remoteFamily?.endsWith('6') ? 6 : 4),
63
- got: socket.bytesRead,
64
- sent: socket.bytesWritten,
65
- country: conn.country,
66
- started,
67
- secure: (secure || undefined), // undefined will save some space once json-ed
68
- ...fromCtx(conn.ctx),
69
- };
70
- }
71
- function fromCtx(ctx) {
72
- if (!ctx)
73
- return;
74
- const s = ctx.state; // short alias
75
- return {
76
- user: (0, auth_1.getCurrentUsername)(ctx),
77
- agent: (0, misc_1.shortenAgent)(ctx.get('user-agent')),
78
- archive: s.archive,
79
- ...s.browsing ? { op: 'browsing', path: (0, misc_1.safeDecodeURIComponent)(s.browsing) }
80
- : s.uploadPath ? { op: 'upload', path: (0, misc_1.safeDecodeURIComponent)(s.uploadPath) }
81
- : {
82
- op: !s.considerAsGui && (ctx.state.archive || ctx.state.vfsNode) ? 'download' : undefined,
83
- path: (0, misc_1.safeDecodeURIComponent)(ctx.originalUrl),
84
- },
85
- opProgress: lodash_1.default.isNumber(s.opProgress) ? lodash_1.default.round(s.opProgress, 3) : undefined,
86
- opTotal: s.opTotal,
87
- opOffset: s.opOffset,
88
- };
89
- }
90
57
  },
91
58
  async *get_connection_stats() {
92
59
  while (1) {
93
- const filtered = (0, connections_1.getConnections)().filter(x => !ignore(x));
60
+ const connections = (0, connections_1.getConnections)();
94
61
  yield {
95
62
  outSpeedKb: throttler_1.totalOutSpeedKb,
96
63
  inSpeedKb: throttler_1.totalInSpeedKb,
97
64
  sent_got: [throttler_1.totalSent.get(), throttler_1.totalGot.get(), totalGotSentResetTime.get()],
98
- connections: filtered.length,
99
- ips: lodash_1.default.uniqBy(filtered, x => x.ip).length,
65
+ connections: connections.length,
66
+ ips: (0, cross_1.countUniqueBy)(connections, conn => conn.ip),
100
67
  };
101
68
  await (0, misc_1.wait)(1000);
102
69
  }
@@ -108,8 +75,45 @@ exports.default = {
108
75
  void persistence_1.storedMap.del(x);
109
76
  },
110
77
  };
111
- function ignore(conn) {
112
- return false; //conn.socket && isLocalHost(conn)
78
+ function serializeConnection(conn) {
79
+ const { socket, started, secure } = conn;
80
+ return {
81
+ ...getConnAddress(conn),
82
+ v: (socket.remoteFamily?.endsWith('6') ? 6 : 4),
83
+ // connection fields are request-scoped once transfer tracking starts; socket counters cover earlier snapshots
84
+ got: conn.got || socket.bytesRead,
85
+ sent: conn.sent || socket.bytesWritten,
86
+ outSpeedKb: conn.outSpeedKb,
87
+ inSpeedKb: conn.inSpeedKb,
88
+ country: conn.country,
89
+ started,
90
+ secure: (secure || undefined), // undefined will save some space once json-ed
91
+ ...fromCtx(conn.ctx),
92
+ };
93
+ }
94
+ function fromCtx(ctx) {
95
+ if (!ctx)
96
+ return;
97
+ return {
98
+ user: (0, auth_1.getCurrentUsername)(ctx),
99
+ agent: (0, misc_1.shortenAgent)(ctx.get('user-agent')),
100
+ ...inferOperation(ctx)
101
+ };
102
+ }
103
+ function inferOperation(ctx) {
104
+ const s = ctx.state; // short alias
105
+ return {
106
+ archive: s.archive,
107
+ ...s.browsing ? { op: 'browsing', path: (0, misc_1.safeDecodeURIComponent)(s.browsing) }
108
+ : s.uploadPath ? { op: 'upload', path: (0, misc_1.safeDecodeURIComponent)(s.uploadPath) }
109
+ : {
110
+ op: !s.considerAsGui && (ctx.state.archive || ctx.state.vfsNode) ? 'download' : undefined,
111
+ path: (0, misc_1.safeDecodeURIComponent)(ctx.originalUrl),
112
+ },
113
+ opProgress: lodash_1.default.isNumber(s.opProgress) ? lodash_1.default.round(s.opProgress, 3) : undefined,
114
+ opTotal: s.opTotal,
115
+ opOffset: s.opOffset,
116
+ };
113
117
  }
114
118
  function getConnAddress(conn, overrideIp) {
115
119
  return {
package/src/api.net.js CHANGED
@@ -52,16 +52,17 @@ exports.default = {
52
52
  return new apiMiddleware_1.ApiError(const_1.HTTP_FAILED_DEPENDENCY, "no internal port");
53
53
  if (externalPort)
54
54
  try {
55
- await nat_1.upnpClient.removeMapping({ public: { host: '', port: externalPort } });
55
+ await (0, nat_1.getUpnpClient)().removeMapping({ public: { host: '', port: externalPort } });
56
56
  }
57
57
  catch (e) {
58
58
  return new apiMiddleware_1.ApiError(const_1.HTTP_SERVER_ERROR, "removeMapping failed: " + String(e));
59
59
  }
60
- if (external) // must use the object form of 'public' to work around a bug of the library
61
- await nat_1.upnpClient.createMapping({ private: internal || internalPort, public: { host: '', port: external }, description: 'hfs', ttl: 0 })
60
+ if (external)
61
+ await (0, nat_1.getUpnpClient)().createMapping((0, nat_1.upnpMappingParam)(internal || internalPort, external))
62
62
  .catch(res => {
63
63
  throw new apiMiddleware_1.ApiError(res.errorCode || const_1.HTTP_SERVER_ERROR, res.errorCode === 718 ? "Port not available" : res.errorDescription || res.message || "unknown error");
64
64
  });
65
+ nat_1.mappedPort.set(external || 0); // remember only successful HFS mappings
65
66
  return {};
66
67
  },
67
68
  async self_check({ url }) {
package/src/api.vfs.js CHANGED
@@ -225,7 +225,7 @@ exports.default = {
225
225
  for (const k of ['*', 'Directory']) {
226
226
  await (0, util_os_1.reg)('add', WINDOWS_REG_KEY.replace('*', k), '/ve', '/f', '/d', 'Add to HFS (new)');
227
227
  await (0, util_os_1.reg)('add', WINDOWS_REG_KEY.replace('*', k), '/v', 'icon', '/f', '/d', const_1.IS_BINARY ? process.execPath : const_1.APP_PATH + '\\hfs.ico');
228
- await (0, util_os_1.reg)('add', WINDOWS_REG_KEY.replace('*', k) + '\\command', '/ve', '/f', '/d', `powershell -WindowStyle Hidden -Command "
228
+ await (0, util_os_1.reg)('add', WINDOWS_REG_KEY.replace('*', k) + '\\command', '/ve', '/f', '/d', `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -Command "
229
229
  [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12;
230
230
  $wsh = New-Object -ComObject Wscript.Shell;
231
231
  $j = @{parent=@'\n${parent}\n'@; source=@'\n%1\n'@} | ConvertTo-Json -Compress
package/src/commands.js CHANGED
@@ -1,5 +1,38 @@
1
1
  "use strict";
2
2
  // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
3
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
4
+ if (k2 === undefined) k2 = k;
5
+ var desc = Object.getOwnPropertyDescriptor(m, k);
6
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
7
+ desc = { enumerable: true, get: function() { return m[k]; } };
8
+ }
9
+ Object.defineProperty(o, k2, desc);
10
+ }) : (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ o[k2] = m[k];
13
+ }));
14
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
15
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
16
+ }) : function(o, v) {
17
+ o["default"] = v;
18
+ });
19
+ var __importStar = (this && this.__importStar) || (function () {
20
+ var ownKeys = function(o) {
21
+ ownKeys = Object.getOwnPropertyNames || function (o) {
22
+ var ar = [];
23
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
24
+ return ar;
25
+ };
26
+ return ownKeys(o);
27
+ };
28
+ return function (mod) {
29
+ if (mod && mod.__esModule) return mod;
30
+ var result = {};
31
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
32
+ __setModuleDefault(result, mod);
33
+ return result;
34
+ };
35
+ })();
3
36
  var __importDefault = (this && this.__importDefault) || function (mod) {
4
37
  return (mod && mod.__esModule) ? mod : { "default": mod };
5
38
  };
@@ -17,7 +50,8 @@ const plugins_1 = require("./plugins");
17
50
  const fileAttr_1 = require("./fileAttr");
18
51
  const github_1 = require("./github");
19
52
  const cross_1 = require("./cross");
20
- const api_monitor_1 = __importDefault(require("./api.monitor"));
53
+ const api_monitor_1 = __importStar(require("./api.monitor"));
54
+ const connections_1 = require("./connections");
21
55
  const argv_1 = require("./argv");
22
56
  const listen_2 = require("./listen");
23
57
  let debugEnabled = argv_1.argv.debug || process.env.HFS_DEBUG || const_1.DEV;
@@ -191,11 +225,30 @@ const commands = {
191
225
  params: '',
192
226
  cb: fileAttr_1.purgeFileAttr,
193
227
  },
228
+ transfers: {
229
+ params: '',
230
+ cb() {
231
+ const transfers = (0, connections_1.getConnections)().map(api_monitor_1.serializeConnection).filter(x => x.op === 'upload' || x.op === 'download');
232
+ if (!transfers.length)
233
+ return console.log("No ongoing uploads/downloads");
234
+ console.table(transfers.map(x => ({
235
+ type: x.op,
236
+ progress: (0, cross_1.with_)(x.opProgress ?? x.opOffset, v => v == null ? '' : (0, cross_1.formatPerc)(v)),
237
+ transferred: (0, cross_1.formatBytes)(Math.max(x.sent || 0, x.got || 0)),
238
+ total: x.opTotal == null ? '' : (0, cross_1.formatBytes)(x.opTotal),
239
+ speed: (0, cross_1.formatSpeed)(Math.max(x.outSpeedKb || 0, x.inSpeedKb || 0) * 1000),
240
+ user: x.user,
241
+ path: x.path,
242
+ })));
243
+ }
244
+ },
194
245
  status: {
195
246
  params: '',
196
247
  async cb() {
197
248
  const ports = await (0, listen_2.getServerStatus)(false);
198
249
  console.log(lodash_1.default.map(ports, (x, k) => `${k.toUpperCase()} ${x.configuredPort < 0 ? "disabled" : x.listening ? `on port ${x.port}` : (x.error || "not working")}`).join(" – "));
250
+ const operations = lodash_1.default.countBy((0, connections_1.getConnections)(), x => x.ctx && (0, api_monitor_1.inferOperation)(x.ctx).op);
251
+ console.log(`Active downloads ↑ ${operations.download || 0} – uploads ↓ ${operations.upload || 0}`);
199
252
  const conn = (await api_monitor_1.default.get_connection_stats().next()).value;
200
253
  if (conn) {
201
254
  const { sent_got: sg } = conn;