hfs 0.26.6 → 0.26.8

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 (162) hide show
  1. package/admin/.DS_Store +0 -0
  2. package/admin/.eslintrc +8 -0
  3. package/admin/.gitignore +23 -0
  4. package/admin/index.html +1 -3
  5. package/admin/package.json +67 -0
  6. package/admin/{logo.svg → public/logo.svg} +0 -0
  7. package/admin/src/AccountForm.ts +92 -0
  8. package/admin/src/AccountsPage.ts +143 -0
  9. package/admin/src/App.ts +83 -0
  10. package/admin/src/ArrayField.ts +84 -0
  11. package/admin/src/ConfigPage.ts +279 -0
  12. package/admin/src/FileField.ts +52 -0
  13. package/admin/src/FileForm.ts +148 -0
  14. package/admin/src/FilePicker.ts +166 -0
  15. package/admin/src/HomePage.ts +96 -0
  16. package/admin/src/InstalledPlugins.ts +158 -0
  17. package/admin/src/LoginRequired.ts +75 -0
  18. package/admin/src/LogoutPage.ts +27 -0
  19. package/admin/src/LogsPage.ts +75 -0
  20. package/admin/src/MainMenu.ts +74 -0
  21. package/admin/src/MenuButton.ts +38 -0
  22. package/admin/src/MonitorPage.ts +200 -0
  23. package/admin/src/OnlinePlugins.ts +101 -0
  24. package/admin/src/PermField.ts +80 -0
  25. package/admin/src/PluginsPage.ts +27 -0
  26. package/admin/src/VfsMenuBar.ts +58 -0
  27. package/admin/src/VfsPage.ts +124 -0
  28. package/admin/src/VfsTree.ts +95 -0
  29. package/admin/src/addFiles.ts +59 -0
  30. package/admin/src/api.ts +246 -0
  31. package/admin/src/dialog.ts +203 -0
  32. package/admin/src/index.css +21 -0
  33. package/admin/src/index.ts +10 -0
  34. package/admin/src/md.ts +31 -0
  35. package/admin/src/misc.ts +141 -0
  36. package/admin/src/react-app-env.d.ts +1 -0
  37. package/admin/src/reportWebVitals.ts +15 -0
  38. package/admin/src/setupTests.ts +5 -0
  39. package/admin/src/state.ts +40 -0
  40. package/admin/src/theme.ts +37 -0
  41. package/admin/tsconfig.json +26 -0
  42. package/admin/vite.config.ts +32 -0
  43. package/frontend/.DS_Store +0 -0
  44. package/frontend/.eslintrc +8 -0
  45. package/frontend/.gitignore +23 -0
  46. package/frontend/index.html +1 -3
  47. package/frontend/package.json +51 -0
  48. package/frontend/{fontello.css → public/fontello.css} +0 -0
  49. package/frontend/{fontello.woff2 → public/fontello.woff2} +0 -0
  50. package/frontend/src/App.ts +25 -0
  51. package/frontend/src/Breadcrumbs.ts +43 -0
  52. package/frontend/src/BrowseFiles.ts +141 -0
  53. package/frontend/src/Head.ts +45 -0
  54. package/frontend/src/UserPanel.ts +52 -0
  55. package/frontend/src/api.ts +78 -0
  56. package/frontend/src/components.ts +54 -0
  57. package/frontend/src/dialog.css +76 -0
  58. package/frontend/src/dialog.ts +105 -0
  59. package/frontend/src/icons.ts +46 -0
  60. package/frontend/src/index.scss +307 -0
  61. package/frontend/src/index.ts +10 -0
  62. package/frontend/src/login.ts +50 -0
  63. package/frontend/src/menu.ts +188 -0
  64. package/frontend/src/misc.ts +54 -0
  65. package/frontend/src/options.ts +52 -0
  66. package/frontend/src/react-app-env.d.ts +1 -0
  67. package/frontend/src/reportWebVitals.ts +15 -0
  68. package/frontend/src/setupTests.ts +5 -0
  69. package/frontend/src/state.ts +82 -0
  70. package/frontend/src/useAuthorized.ts +17 -0
  71. package/frontend/src/useFetchList.ts +144 -0
  72. package/frontend/src/useTheme.ts +23 -0
  73. package/frontend/tsconfig.json +26 -0
  74. package/frontend/vite.config.ts +21 -0
  75. package/package.json +2 -1
  76. package/plugins/vhosting/plugin.js +1 -1
  77. package/src/QuickZipStream.ts +279 -0
  78. package/src/ThrottledStream.ts +98 -0
  79. package/src/adminApis.ts +161 -0
  80. package/src/api.accounts.ts +78 -0
  81. package/src/api.auth.ts +131 -0
  82. package/src/api.file_list.ts +102 -0
  83. package/src/api.helpers.ts +30 -0
  84. package/src/api.monitor.ts +106 -0
  85. package/src/api.plugins.ts +139 -0
  86. package/src/api.vfs.ts +182 -0
  87. package/src/apiMiddleware.ts +124 -0
  88. package/src/block.ts +35 -0
  89. package/src/commands.ts +122 -0
  90. package/src/config.ts +166 -0
  91. package/src/connections.ts +60 -0
  92. package/src/const.ts +57 -0
  93. package/src/crypt.ts +16 -0
  94. package/src/debounceAsync.ts +51 -0
  95. package/src/events.ts +6 -0
  96. package/src/frontEndApis.ts +17 -0
  97. package/src/github.ts +102 -0
  98. package/src/index.ts +53 -0
  99. package/src/listen.ts +220 -0
  100. package/src/log.ts +128 -0
  101. package/src/middlewares.ts +176 -0
  102. package/src/misc.ts +149 -0
  103. package/src/pbkdf2.ts +83 -0
  104. package/src/perm.ts +194 -0
  105. package/src/plugins.ts +342 -0
  106. package/src/serveFile.ts +104 -0
  107. package/src/serveGuiFiles.ts +95 -0
  108. package/src/sse.ts +29 -0
  109. package/src/throttler.ts +106 -0
  110. package/src/update.ts +67 -0
  111. package/src/util-files.ts +137 -0
  112. package/src/util-generators.ts +29 -0
  113. package/src/util-http.ts +29 -0
  114. package/src/vfs.ts +258 -0
  115. package/src/watchLoad.ts +75 -0
  116. package/src/zip.ts +69 -0
  117. package/admin/assets/index.3129dad1.js +0 -282
  118. package/admin/assets/index.dcc78777.css +0 -1
  119. package/admin/assets/sha512.e9b1ee42.js +0 -8
  120. package/frontend/assets/index.1151988f.js +0 -85
  121. package/frontend/assets/index.93366732.css +0 -1
  122. package/frontend/assets/sha512.bb881250.js +0 -8
  123. package/src/QuickZipStream.js +0 -285
  124. package/src/ThrottledStream.js +0 -93
  125. package/src/adminApis.js +0 -169
  126. package/src/api.accounts.js +0 -59
  127. package/src/api.auth.js +0 -130
  128. package/src/api.file_list.js +0 -103
  129. package/src/api.helpers.js +0 -32
  130. package/src/api.monitor.js +0 -102
  131. package/src/api.plugins.js +0 -127
  132. package/src/api.vfs.js +0 -164
  133. package/src/apiMiddleware.js +0 -136
  134. package/src/block.js +0 -33
  135. package/src/commands.js +0 -124
  136. package/src/config.js +0 -168
  137. package/src/connections.js +0 -57
  138. package/src/const.js +0 -83
  139. package/src/crypt.js +0 -21
  140. package/src/debounceAsync.js +0 -48
  141. package/src/events.js +0 -9
  142. package/src/frontEndApis.js +0 -38
  143. package/src/github.js +0 -102
  144. package/src/index.js +0 -55
  145. package/src/listen.js +0 -235
  146. package/src/log.js +0 -137
  147. package/src/middlewares.js +0 -154
  148. package/src/misc.js +0 -160
  149. package/src/pbkdf2.js +0 -74
  150. package/src/perm.js +0 -176
  151. package/src/plugins.js +0 -343
  152. package/src/serveFile.js +0 -104
  153. package/src/serveGuiFiles.js +0 -113
  154. package/src/sse.js +0 -29
  155. package/src/throttler.js +0 -91
  156. package/src/update.js +0 -69
  157. package/src/util-files.js +0 -148
  158. package/src/util-generators.js +0 -30
  159. package/src/util-http.js +0 -30
  160. package/src/vfs.js +0 -226
  161. package/src/watchLoad.js +0 -73
  162. package/src/zip.js +0 -69
package/src/pbkdf2.ts ADDED
@@ -0,0 +1,83 @@
1
+ // @ts-nocheck
2
+ import { webcrypto as crypto } from "node:crypto";
3
+ export { pbkdf2, pbkdf2Verify }
4
+
5
+ // FROM https://gist.github.com/chrisveness/770ee96945ec12ac84f134bf538d89fb
6
+
7
+ /**
8
+ * Returns PBKDF2 derived key from supplied password.
9
+ *
10
+ * Stored key can subsequently be used to verify that a password matches the original password used
11
+ * to derive the key, using pbkdf2Verify().
12
+ *
13
+ * @param {String} password - Password to be hashed using key derivation function.
14
+ * @param {Number} [iterations=1e6] - Number of iterations of HMAC function to apply.
15
+ * @returns {String} Derived key as base64 string.
16
+ *
17
+ * @example
18
+ * const key = await pbkdf2('pāşšŵōřđ'); // eg 'djAxBRKXWNWPyXgpKWHld8SWJA9CQFmLyMbNet7Rle5RLKJAkBCllLfM6tPFa7bAis0lSTiB'
19
+ */
20
+ async function pbkdf2(password, iterations=1e6) {
21
+ const pwUtf8 = new TextEncoder().encode(password); // encode pw as UTF-8
22
+ const pwKey = await crypto.subtle.importKey('raw', pwUtf8, 'PBKDF2', false, ['deriveBits']); // create pw key
23
+
24
+ const saltUint8 = crypto.getRandomValues(new Uint8Array(16)); // get random salt
25
+
26
+ const params = { name: 'PBKDF2', hash: 'SHA-256', salt: saltUint8, iterations: iterations }; // pbkdf2 params
27
+ const keyBuffer = await crypto.subtle.deriveBits(params, pwKey, 256); // derive key
28
+
29
+ const keyArray = Array.from(new Uint8Array(keyBuffer)); // key as byte array
30
+
31
+ const saltArray = Array.from(new Uint8Array(saltUint8)); // salt as byte array
32
+
33
+ const iterHex = ('000000'+iterations.toString(16)).slice(-6); // iter’n count as hex
34
+ const iterArray = iterHex.match(/.{2}/g).map(byte => parseInt(byte, 16)); // iter’ns as byte array
35
+
36
+ const compositeArray = [].concat(saltArray, iterArray, keyArray); // combined array
37
+ const compositeStr = compositeArray.map(byte => String.fromCharCode(byte)).join(''); // combined as string
38
+ // encode as base64
39
+ return btoa('v01' + compositeStr); // return composite key
40
+ }
41
+
42
+
43
+ /**
44
+ * Verifies whether the supplied password matches the password previously used to generate the key.
45
+ *
46
+ * @param {String} key - Key previously generated with pbkdf2().
47
+ * @param {String} password - Password to be matched against previously derived key.
48
+ * @returns {boolean} Whether password matches key.
49
+ *
50
+ * @example
51
+ * const match = await pbkdf2Verify(key, 'pāşšŵōřđ'); // true
52
+ */
53
+ async function pbkdf2Verify(key, password) {
54
+ let compositeStr = null; // composite key is salt, iteration count, and derived key
55
+ try { compositeStr = atob(key); } catch (e) { throw new Error ('Invalid key'); } // decode from base64
56
+
57
+ const version = compositeStr.slice(0, 3); // 3 bytes
58
+ const saltStr = compositeStr.slice(3, 19); // 16 bytes (128 bits)
59
+ const iterStr = compositeStr.slice(19, 22); // 3 bytes
60
+ const keyStr = compositeStr.slice(22, 54); // 32 bytes (256 bits)
61
+
62
+ if (version !== 'v01') throw new Error('Invalid key');
63
+
64
+ // -- recover salt & iterations from stored (composite) key
65
+
66
+ const saltUint8 = new Uint8Array(saltStr.match(/./g).map(ch => ch.charCodeAt(0))); // salt as Uint8Array
67
+ // note: cannot use TextEncoder().encode(saltStr) as it generates UTF-8
68
+
69
+ const iterHex = iterStr.match(/./g).map(ch => ch.charCodeAt(0).toString(16)).join(''); // iter’n count as hex
70
+ const iterations = parseInt(iterHex, 16); // iter’ns
71
+
72
+ // -- generate new key from stored salt & iterations and supplied password
73
+
74
+ const pwUtf8 = new TextEncoder().encode(password); // encode pw as UTF-8
75
+ const pwKey = await crypto.subtle.importKey('raw', pwUtf8, 'PBKDF2', false, ['deriveBits']); // create pw key
76
+
77
+ const params = { name: 'PBKDF2', hash: 'SHA-256', salt: saltUint8, iterations: iterations }; // pbkdf params
78
+ const keyBuffer = await crypto.subtle.deriveBits(params, pwKey, 256); // derive key
79
+ const keyArray = Array.from(new Uint8Array(keyBuffer)); // key as byte array
80
+ const keyStrNew = keyArray.map(byte => String.fromCharCode(byte)).join(''); // key as string
81
+
82
+ return keyStrNew === keyStr; // test if newly generated key matches stored key
83
+ }
package/src/perm.ts ADDED
@@ -0,0 +1,194 @@
1
+ // This file is part of HFS - Copyright 2021-2022, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
+
3
+ import _ from 'lodash'
4
+ import { hashPassword } from './crypt'
5
+ import { objRenameKey, setHidden, wantArray } from './misc'
6
+ import Koa from 'koa'
7
+ import { defineConfig, saveConfigAsap } from './config'
8
+ import { createVerifierAndSalt, SRPParameters, SRPRoutines } from 'tssrp6a'
9
+ import events from './events'
10
+
11
+ export interface Account {
12
+ username: string, // we'll have username in it, so we don't need to pass it separately
13
+ password?: string
14
+ hashed_password?: string
15
+ srp?: string
16
+ belongs?: string[]
17
+ ignore_limits?: boolean
18
+ admin?: boolean
19
+ redirect?: string
20
+ }
21
+ interface Accounts { [username:string]: Account }
22
+
23
+ let accounts: Accounts = {}
24
+
25
+ export function getAccounts() {
26
+ return accounts as Readonly<typeof accounts>
27
+ }
28
+
29
+ export function getCurrentUsername(ctx: Koa.Context): string {
30
+ return ctx.state.account?.username || ctx.session?.username || ''
31
+ }
32
+
33
+ // provides the username and all other usernames it inherits based on the 'belongs' attribute. Useful to check permissions
34
+ export function getCurrentUsernameExpanded(ctx: Koa.Context) {
35
+ const who = getCurrentUsername(ctx)
36
+ if (!who)
37
+ return []
38
+ const ret = [who]
39
+ for (const u of ret) {
40
+ const a = getAccount(u)
41
+ if (a?.belongs)
42
+ ret.push(...a.belongs)
43
+ }
44
+ return ret
45
+ }
46
+
47
+ export function getAccount(username:string) : Account | undefined {
48
+ return username ? accounts[username] : undefined
49
+ }
50
+
51
+ export function saveSrpInfo(account:Account, salt:string | bigint, verifier: string | bigint) {
52
+ account.srp = String(salt) + '|' + String(verifier)
53
+ }
54
+
55
+ export const allowClearTextLogin = defineConfig('allow_clear_text_login')
56
+
57
+ const srp6aNimbusRoutines = new SRPRoutines(new SRPParameters())
58
+
59
+ type Changer = (account:Account)=> void | Promise<void>
60
+ export async function updateAccount(account: Account, changer?:Changer) {
61
+ const was = JSON.stringify(account)
62
+ await changer?.(account)
63
+ const { username } = account
64
+ if (account.password) {
65
+ console.debug('hashing password for', username)
66
+ if (allowClearTextLogin.get())
67
+ account.hashed_password = await hashPassword(account.password)
68
+ const res = await createVerifierAndSalt(srp6aNimbusRoutines, username, account.password)
69
+ saveSrpInfo(account, res.s, res.v)
70
+ delete account.password
71
+ }
72
+ else if (!account.srp && account.hashed_password) {
73
+ console.log('please reset password for account', username)
74
+ process.exit(1)
75
+ }
76
+ if (account.belongs)
77
+ account.belongs = wantArray(account.belongs).filter(b =>
78
+ b in accounts // at this stage the group record may still be null if specified later in the file
79
+ || console.error(`account ${username} belongs to non-existing ${b}`) )
80
+ if (was !== JSON.stringify(account))
81
+ saveAccountsAsap()
82
+ }
83
+
84
+ const saveAccountsAsap = saveConfigAsap
85
+
86
+ const accountsConfig = defineConfig<Accounts>('accounts', {})
87
+ accountsConfig.sub(async v => {
88
+ // we should validate content here
89
+ accounts = v // keep local reference
90
+ await Promise.all(_.map(accounts, async (rec,k) => {
91
+ const norm = normalizeUsername(k)
92
+ if (!rec) // an empty object in yaml is stored as null
93
+ rec = accounts[norm] = { username: norm }
94
+ else
95
+ objRenameKey(accounts, k, norm)
96
+ setHidden(rec, { username: norm })
97
+ await updateAccount(rec)
98
+ }))
99
+ })
100
+
101
+ function normalizeUsername(username: string) {
102
+ return username.toLocaleLowerCase()
103
+ }
104
+
105
+ export function renameAccount(from: string, to: string) {
106
+ from = normalizeUsername(from)
107
+ to = normalizeUsername(to)
108
+ if (!to || !accounts[from] || accounts[to])
109
+ return false
110
+ if (to === from)
111
+ return true
112
+ objRenameKey(accounts, from, to)
113
+ updateReferences()
114
+ saveAccountsAsap()
115
+ return true
116
+
117
+ function updateReferences() {
118
+ setHidden(accounts[to], { username: to })
119
+ for (const a of Object.values(accounts)) {
120
+ const idx = a.belongs?.indexOf(from)
121
+ if (idx !== undefined && idx >= 0)
122
+ a.belongs![idx] = to
123
+ }
124
+ events.emit('accountRenamed', from, to) // everybody, take care of your stuff
125
+ }
126
+ }
127
+
128
+ // we consider all the following fields, when falsy, as equivalent to be missing. If this changes in the future, please adjust addAccount and setAccount
129
+ const assignableProps: (keyof Account)[] = ['redirect','ignore_limits','belongs','admin']
130
+
131
+ export function addAccount(username: string, props: Partial<Account>) {
132
+ if (!username || accounts[username])
133
+ return
134
+ const copy: Account = setHidden(_.pickBy(_.pick(props, assignableProps), Boolean),
135
+ { username }) // have the field in the object but hidden so that stringification won't include it
136
+ accountsConfig.set(accounts =>
137
+ Object.assign(accounts, { [username]: copy }))
138
+ saveAccountsAsap().then()
139
+ return copy
140
+ }
141
+
142
+ export function setAccount(username: string, changes: Partial<Account>) {
143
+ const acc = getAccount(username)
144
+ if (!acc)
145
+ return false
146
+ const rest = _.pick(changes, assignableProps)
147
+ for (const [k,v] of Object.entries(rest))
148
+ if (!v)
149
+ rest[k as keyof Account] = undefined
150
+ Object.assign(acc, rest)
151
+ if (changes.username)
152
+ renameAccount(username, changes.username)
153
+ saveAccountsAsap().then()
154
+ return true
155
+ }
156
+
157
+ export function delAccount(username: string) {
158
+ if (!getAccount(username))
159
+ return false
160
+ accountsConfig.set(accounts =>
161
+ Object.assign(accounts, { [username]: undefined }))
162
+ saveAccountsAsap().then()
163
+ return true
164
+ }
165
+
166
+ // get some property from account, searching in its groups if necessary. Search is breadth-first, and this determines priority of inheritance.
167
+ export function getFromAccount<T=any>(account: Account | string, getter:(a:Account) => T) {
168
+ const search = [account]
169
+ for (const accountOrUsername of search) {
170
+ const a = typeof accountOrUsername === 'string' ? getAccount(accountOrUsername) : accountOrUsername
171
+ if (!a) continue
172
+ const res = getter(a)
173
+ if (res !== undefined)
174
+ return res
175
+ if (a.belongs)
176
+ search.push(...a.belongs)
177
+ }
178
+ }
179
+
180
+ export function accountHasPassword(account: Account) {
181
+ return Boolean(account.password || account.hashed_password || account.srp)
182
+ }
183
+
184
+ export function accountCanLogin(account: Account) {
185
+ return accountHasPassword(account)
186
+ }
187
+
188
+ export function accountCanLoginAdmin(account: Account) {
189
+ return accountCanLogin(account) && getFromAccount(account, a => a.admin)
190
+ }
191
+
192
+ export function anyAccountCanLoginAdmin() {
193
+ return Object.values(accounts).find(accountCanLoginAdmin)
194
+ }
package/src/plugins.ts ADDED
@@ -0,0 +1,342 @@
1
+ // This file is part of HFS - Copyright 2021-2022, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
+
3
+ import glob from 'fast-glob'
4
+ import { watchLoad } from './watchLoad'
5
+ import _ from 'lodash'
6
+ import pathLib from 'path'
7
+ import { API_VERSION, APP_PATH, COMPATIBLE_API_VERSION, PLUGINS_PUB_URI } from './const'
8
+ import * as Const from './const'
9
+ import Koa from 'koa'
10
+ import {
11
+ adjustStaticPathForGlob,
12
+ Callback,
13
+ debounceAsync,
14
+ Dict,
15
+ getOrSet,
16
+ onProcessExit,
17
+ same,
18
+ tryJson,
19
+ wantArray,
20
+ watchDir
21
+ } from './misc'
22
+ import { defineConfig, getConfig } from './config'
23
+ import { DirEntry } from './api.file_list'
24
+ import { VfsNode } from './vfs'
25
+ import { serveFile } from './serveFile'
26
+ import events from './events'
27
+ import { readFile } from 'fs/promises'
28
+ import { existsSync, mkdirSync } from 'fs'
29
+ import { getConnections } from './connections'
30
+
31
+ export const PATH = 'plugins'
32
+ export const DISABLING_POSTFIX = '-disabled'
33
+
34
+ const plugins: Record<string, Plugin> = {}
35
+
36
+ export function isPluginRunning(id: string) {
37
+ return plugins[id]?.started
38
+ }
39
+
40
+ export function enablePlugin(id: string, state=true) {
41
+ enablePlugins.set( arr =>
42
+ arr.includes(id) === state ? arr
43
+ : state ? [...arr, id]
44
+ : arr.filter((x: string) => x !== id)
45
+ )
46
+ }
47
+
48
+ // nullish values are equivalent to defaultValues
49
+ export function setPluginConfig(id: string, changes: Dict) {
50
+ pluginsConfig.set(allConfigs => {
51
+ const fields = getPluginConfigFields(id)
52
+ const oldConfig = allConfigs[id]
53
+ const newConfig = _.pickBy({ ...oldConfig, ...changes },
54
+ (v, k) => v != null && !same(v, fields?.[k]?.defaultValue))
55
+ return { ...allConfigs, [id]: _.isEmpty(newConfig) ? undefined : newConfig }
56
+ })
57
+ }
58
+
59
+ export function getPluginInfo(id: string) {
60
+ const running = plugins[id]?.getData()
61
+ return running && Object.assign(running, {id}) || availablePlugins[id]
62
+ }
63
+
64
+ export function mapPlugins<T>(cb:(plugin:Readonly<Plugin>, pluginName:string)=> T) {
65
+ return _.map(plugins, (pl,plName) => {
66
+ try { return cb(pl,plName) }
67
+ catch(e) {
68
+ console.log('plugin error', plName, String(e))
69
+ }
70
+ }).filter(x => x !== undefined) as Exclude<T,undefined>[]
71
+ }
72
+
73
+ export function getPluginConfigFields(id: string) {
74
+ return plugins[id]?.getData().config
75
+ }
76
+
77
+ export function pluginsMiddleware(): Koa.Middleware {
78
+ return async (ctx, next) => {
79
+ const after = []
80
+ // run middleware plugins
81
+ for (const id in plugins)
82
+ try {
83
+ const pl = plugins[id]
84
+ const res = await pl.middleware?.(ctx)
85
+ if (res === true)
86
+ ctx.pluginStopped = true
87
+ if (typeof res === 'function')
88
+ after.push(res)
89
+ }
90
+ catch(e){
91
+ console.log('error middleware plugin', id, String(e))
92
+ console.debug(e)
93
+ }
94
+ // expose public plugins' files
95
+ const { path } = ctx
96
+ if (!ctx.pluginStopped) {
97
+ if (path.startsWith(PLUGINS_PUB_URI)) {
98
+ const a = path.substring(PLUGINS_PUB_URI.length).split('/')
99
+ if (plugins.hasOwnProperty(a[0])) { // do it only if the plugin is loaded
100
+ a.splice(1, 0, 'public')
101
+ await serveFile(PATH + '/' + a.join('/'), 'auto')(ctx, next)
102
+ }
103
+ }
104
+ await next()
105
+ }
106
+ for (const f of after)
107
+ await f()
108
+ }
109
+ }
110
+
111
+ // return false to ask to exclude this entry from results
112
+ interface OnDirEntryParams { entry:DirEntry, ctx:Koa.Context, node:VfsNode }
113
+ type OnDirEntry = (params:OnDirEntryParams) => void | false
114
+
115
+ export class Plugin {
116
+ started = new Date()
117
+
118
+ constructor(readonly id:string, private readonly data:any, private unwatch:()=>void){
119
+ if (!data) throw 'invalid data'
120
+ if (plugins[id])
121
+ throw "unload first: " + id
122
+ plugins[id] = this
123
+
124
+ this.data = data = { ...data } // clone to make object modifiable. Objects coming from import are not.
125
+ // some validation
126
+ for (const k of ['frontend_css', 'frontend_js']) {
127
+ const v = data[k]
128
+ if (typeof v === 'string')
129
+ data[k] = [v]
130
+ else if (v && !Array.isArray(v)) {
131
+ delete data[k]
132
+ console.warn('invalid', k)
133
+ }
134
+ }
135
+ }
136
+ get middleware(): undefined | PluginMiddleware {
137
+ return this.data?.middleware
138
+ }
139
+ get frontend_css(): undefined | string[] {
140
+ return this.data?.frontend_css
141
+ }
142
+ get frontend_js(): undefined | string[] {
143
+ return this.data?.frontend_js
144
+ }
145
+ get onDirEntry(): undefined | OnDirEntry {
146
+ return this.data?.onDirEntry
147
+ }
148
+
149
+ getData(): any {
150
+ return { ...this.data }
151
+ }
152
+
153
+ async unload(reloading=false) {
154
+ const { id } = this
155
+ try {
156
+ await this.data?.unload?.()
157
+ console.log('unloaded plugin', id)
158
+ }
159
+ catch(e) {
160
+ console.log('error unloading plugin', id, String(e))
161
+ }
162
+ delete plugins[id]
163
+ if (reloading) return
164
+ this.unwatch()
165
+ if (availablePlugins[id])
166
+ events.emit('pluginStopped', availablePlugins[id])
167
+ else
168
+ events.emit('pluginUninstalled', id)
169
+ }
170
+ }
171
+
172
+ type PluginMiddleware = (ctx:Koa.Context) => void | Stop | CallMeAfter
173
+ type Stop = true
174
+ type CallMeAfter = ()=>void
175
+
176
+ export interface AvailablePlugin {
177
+ id: string
178
+ description?: string
179
+ version?: number
180
+ apiRequired?: number | [number,number]
181
+ repo?: string
182
+ branch?: string
183
+ badApi?: string
184
+ }
185
+
186
+ let availablePlugins: Record<string, AvailablePlugin> = {}
187
+
188
+ export function getAvailablePlugins() {
189
+ return Object.values(availablePlugins)
190
+ }
191
+
192
+ const rescanAsap = debounceAsync(rescan, 1000)
193
+ if (!existsSync(PATH))
194
+ try { mkdirSync(PATH) }
195
+ catch {}
196
+ export const pluginsWatcher = watchDir(PATH, rescanAsap)
197
+
198
+ export const enablePlugins = defineConfig('enable_plugins', ['antibrute'])
199
+ enablePlugins.sub(rescanAsap)
200
+
201
+ export const pluginsConfig = defineConfig('plugins_config', {} as Record<string,any>)
202
+
203
+ export async function rescan() {
204
+ console.debug('scanning plugins')
205
+ const found = []
206
+ const foundDisabled: typeof availablePlugins = {}
207
+ const MASK = PATH + '/*/plugin.js' // be sure to not use path.join as fast-glob doesn't work with \
208
+ for (const f of await glob([adjustStaticPathForGlob(APP_PATH) + '/' + MASK, MASK])) {
209
+ const id = f.split('/').slice(-2)[0]
210
+ if (id.endsWith(DISABLING_POSTFIX)) continue
211
+ if (!enablePlugins.get().includes(id)) {
212
+ try {
213
+ const source = await readFile(f, 'utf8')
214
+ foundDisabled[id] = parsePluginSource(id, source)
215
+ }
216
+ catch {}
217
+ continue
218
+ }
219
+ found.push(id)
220
+ if (plugins[id]) // already loaded
221
+ continue
222
+ const module = pathLib.resolve(f)
223
+ const { unwatch } = watchLoad(f, async () => {
224
+ try {
225
+ const alreadyRunning = plugins[id]
226
+ console.log(alreadyRunning ? "reloading plugin" : "loading plugin", id)
227
+ const { init, ...data } = await import(module)
228
+ delete data.default
229
+ deleteModule(require.resolve(module)) // avoid caching at next import
230
+ calculateBadApi(data)
231
+ if (data.badApi)
232
+ console.log("plugin", id, data.badApi)
233
+
234
+ await alreadyRunning?.unload(true)
235
+ console.debug("starting plugin", id)
236
+ const res = await init?.call(null, {
237
+ srcDir: __dirname,
238
+ const: Const,
239
+ require,
240
+ getConnections,
241
+ events,
242
+ log(...args: any[]) {
243
+ console.log('plugin', id, ':', ...args)
244
+ },
245
+ getConfig: (cfgKey: string) =>
246
+ pluginsConfig.get()?.[id]?.[cfgKey] ?? data.config?.[cfgKey]?.defaultValue,
247
+ setConfig: (cfgKey: string, value: any) =>
248
+ setPluginConfig(id, { [cfgKey]: value }),
249
+ subscribeConfig(cfgKey: string, cb: Callback<any>) {
250
+ let last = this.getConfig(cfgKey)
251
+ cb(last)
252
+ return pluginsConfig.sub(() => {
253
+ const now = this.getConfig(cfgKey)
254
+ if (same(now, last)) return
255
+ try { cb(last = now) }
256
+ catch(e){
257
+ console.log('plugin', id, String(e))
258
+ }
259
+ })
260
+ },
261
+ getHfsConfig: getConfig,
262
+ })
263
+ Object.assign(data, res)
264
+ const plugin = new Plugin(id, data, unwatch)
265
+ if (alreadyRunning)
266
+ events.emit('pluginUpdated', Object.assign(_.pick(plugin, 'started'), getPluginInfo(id)))
267
+ else {
268
+ const wasInstalled = availablePlugins[id]
269
+ if (wasInstalled)
270
+ delete availablePlugins[id]
271
+ events.emit(wasInstalled ? 'pluginStarted' : 'pluginInstalled', plugin)
272
+ }
273
+
274
+ } catch (e) {
275
+ console.log("plugin error:", e)
276
+ }
277
+ })
278
+ }
279
+ for (const id in foundDisabled) {
280
+ const p = foundDisabled[id]
281
+ const a = availablePlugins[id]
282
+ if (same(a, p)) continue
283
+ availablePlugins[id] = p
284
+ if (a)
285
+ events.emit('pluginUpdated', p)
286
+ else if (!plugins[id])
287
+ events.emit('pluginInstalled', p)
288
+ }
289
+ for (const id in availablePlugins)
290
+ if (!foundDisabled[id] && !found.includes(id) && !plugins[id]) {
291
+ delete availablePlugins[id]
292
+ events.emit('pluginUninstalled', id)
293
+ }
294
+ for (const id in plugins)
295
+ if (!found.includes(id))
296
+ await plugins[id].unload()
297
+ }
298
+
299
+ function deleteModule(id: string) {
300
+ const { cache } = require
301
+ // build reversed map of dependencies
302
+ const requiredBy: Record<string,string[]> = { '.':['.'] } // don't touch main entry
303
+ for (const k in cache)
304
+ if (k !== id)
305
+ for (const child of wantArray(cache[k]?.children))
306
+ getOrSet(requiredBy, child.id, ()=> [] as string[]).push(k)
307
+ const deleted: string[] = []
308
+ recur(id)
309
+
310
+ function recur(id: string) {
311
+ let mod = cache[id]
312
+ if (!mod) return
313
+ delete cache[id]
314
+ deleted.push(id)
315
+ for (const child of mod.children)
316
+ if (! _.difference(requiredBy[child.id], deleted).length)
317
+ recur(child.id)
318
+ }
319
+ }
320
+
321
+ onProcessExit(() =>
322
+ Promise.allSettled(mapPlugins(pl => pl.unload())))
323
+
324
+ export function parsePluginSource(id: string, source: string) {
325
+ const pl: AvailablePlugin = { id }
326
+ pl.description = tryJson(/exports.description *= *(".*")/.exec(source)?.[1])
327
+ pl.repo = /exports.repo *= *"(.*)"/.exec(source)?.[1]
328
+ pl.version = Number(/exports.version *= *(\d*\.?\d+)/.exec(source)?.[1]) ?? undefined
329
+ pl.apiRequired = tryJson(/exports.apiRequired *= *([ \d.,[\]]+)/.exec(source)?.[1]) ?? undefined
330
+ if (Array.isArray(pl.apiRequired) && (pl.apiRequired.length !== 2 || !pl.apiRequired.every(_.isFinite))) // validate [from,to] form
331
+ pl.apiRequired = undefined
332
+ calculateBadApi(pl)
333
+ return pl
334
+ }
335
+
336
+ function calculateBadApi(data: AvailablePlugin) {
337
+ const r = data.apiRequired
338
+ const [min, max] = Array.isArray(r) ? r : [r, r] // normalize data type
339
+ data.badApi = min! > API_VERSION ? "may not work correctly as it is designed for a newer version of HFS - check for updates"
340
+ : max! < COMPATIBLE_API_VERSION ? "may not work correctly as it is designed for an older version of HFS - check for updates"
341
+ : undefined
342
+ }