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.
- package/admin/.DS_Store +0 -0
- package/admin/.eslintrc +8 -0
- package/admin/.gitignore +23 -0
- package/admin/index.html +1 -3
- package/admin/package.json +67 -0
- package/admin/{logo.svg → public/logo.svg} +0 -0
- package/admin/src/AccountForm.ts +92 -0
- package/admin/src/AccountsPage.ts +143 -0
- package/admin/src/App.ts +83 -0
- package/admin/src/ArrayField.ts +84 -0
- package/admin/src/ConfigPage.ts +279 -0
- package/admin/src/FileField.ts +52 -0
- package/admin/src/FileForm.ts +148 -0
- package/admin/src/FilePicker.ts +166 -0
- package/admin/src/HomePage.ts +96 -0
- package/admin/src/InstalledPlugins.ts +158 -0
- package/admin/src/LoginRequired.ts +75 -0
- package/admin/src/LogoutPage.ts +27 -0
- package/admin/src/LogsPage.ts +75 -0
- package/admin/src/MainMenu.ts +74 -0
- package/admin/src/MenuButton.ts +38 -0
- package/admin/src/MonitorPage.ts +200 -0
- package/admin/src/OnlinePlugins.ts +101 -0
- package/admin/src/PermField.ts +80 -0
- package/admin/src/PluginsPage.ts +27 -0
- package/admin/src/VfsMenuBar.ts +58 -0
- package/admin/src/VfsPage.ts +124 -0
- package/admin/src/VfsTree.ts +95 -0
- package/admin/src/addFiles.ts +59 -0
- package/admin/src/api.ts +246 -0
- package/admin/src/dialog.ts +203 -0
- package/admin/src/index.css +21 -0
- package/admin/src/index.ts +10 -0
- package/admin/src/md.ts +31 -0
- package/admin/src/misc.ts +141 -0
- package/admin/src/react-app-env.d.ts +1 -0
- package/admin/src/reportWebVitals.ts +15 -0
- package/admin/src/setupTests.ts +5 -0
- package/admin/src/state.ts +40 -0
- package/admin/src/theme.ts +37 -0
- package/admin/tsconfig.json +26 -0
- package/admin/vite.config.ts +32 -0
- package/frontend/.DS_Store +0 -0
- package/frontend/.eslintrc +8 -0
- package/frontend/.gitignore +23 -0
- package/frontend/index.html +1 -3
- package/frontend/package.json +51 -0
- package/frontend/{fontello.css → public/fontello.css} +0 -0
- package/frontend/{fontello.woff2 → public/fontello.woff2} +0 -0
- package/frontend/src/App.ts +25 -0
- package/frontend/src/Breadcrumbs.ts +43 -0
- package/frontend/src/BrowseFiles.ts +141 -0
- package/frontend/src/Head.ts +45 -0
- package/frontend/src/UserPanel.ts +52 -0
- package/frontend/src/api.ts +78 -0
- package/frontend/src/components.ts +54 -0
- package/frontend/src/dialog.css +76 -0
- package/frontend/src/dialog.ts +105 -0
- package/frontend/src/icons.ts +46 -0
- package/frontend/src/index.scss +307 -0
- package/frontend/src/index.ts +10 -0
- package/frontend/src/login.ts +50 -0
- package/frontend/src/menu.ts +188 -0
- package/frontend/src/misc.ts +54 -0
- package/frontend/src/options.ts +52 -0
- package/frontend/src/react-app-env.d.ts +1 -0
- package/frontend/src/reportWebVitals.ts +15 -0
- package/frontend/src/setupTests.ts +5 -0
- package/frontend/src/state.ts +82 -0
- package/frontend/src/useAuthorized.ts +17 -0
- package/frontend/src/useFetchList.ts +144 -0
- package/frontend/src/useTheme.ts +23 -0
- package/frontend/tsconfig.json +26 -0
- package/frontend/vite.config.ts +21 -0
- package/package.json +2 -1
- package/plugins/vhosting/plugin.js +1 -1
- package/src/QuickZipStream.ts +279 -0
- package/src/ThrottledStream.ts +98 -0
- package/src/adminApis.ts +161 -0
- package/src/api.accounts.ts +78 -0
- package/src/api.auth.ts +131 -0
- package/src/api.file_list.ts +102 -0
- package/src/api.helpers.ts +30 -0
- package/src/api.monitor.ts +106 -0
- package/src/api.plugins.ts +139 -0
- package/src/api.vfs.ts +182 -0
- package/src/apiMiddleware.ts +124 -0
- package/src/block.ts +35 -0
- package/src/commands.ts +122 -0
- package/src/config.ts +166 -0
- package/src/connections.ts +60 -0
- package/src/const.ts +57 -0
- package/src/crypt.ts +16 -0
- package/src/debounceAsync.ts +51 -0
- package/src/events.ts +6 -0
- package/src/frontEndApis.ts +17 -0
- package/src/github.ts +102 -0
- package/src/index.ts +53 -0
- package/src/listen.ts +220 -0
- package/src/log.ts +128 -0
- package/src/middlewares.ts +176 -0
- package/src/misc.ts +149 -0
- package/src/pbkdf2.ts +83 -0
- package/src/perm.ts +194 -0
- package/src/plugins.ts +342 -0
- package/src/serveFile.ts +104 -0
- package/src/serveGuiFiles.ts +95 -0
- package/src/sse.ts +29 -0
- package/src/throttler.ts +106 -0
- package/src/update.ts +67 -0
- package/src/util-files.ts +137 -0
- package/src/util-generators.ts +29 -0
- package/src/util-http.ts +29 -0
- package/src/vfs.ts +258 -0
- package/src/watchLoad.ts +75 -0
- package/src/zip.ts +69 -0
- package/admin/assets/index.3129dad1.js +0 -282
- package/admin/assets/index.dcc78777.css +0 -1
- package/admin/assets/sha512.e9b1ee42.js +0 -8
- package/frontend/assets/index.1151988f.js +0 -85
- package/frontend/assets/index.93366732.css +0 -1
- package/frontend/assets/sha512.bb881250.js +0 -8
- package/src/QuickZipStream.js +0 -285
- package/src/ThrottledStream.js +0 -93
- package/src/adminApis.js +0 -169
- package/src/api.accounts.js +0 -59
- package/src/api.auth.js +0 -130
- package/src/api.file_list.js +0 -103
- package/src/api.helpers.js +0 -32
- package/src/api.monitor.js +0 -102
- package/src/api.plugins.js +0 -127
- package/src/api.vfs.js +0 -164
- package/src/apiMiddleware.js +0 -136
- package/src/block.js +0 -33
- package/src/commands.js +0 -124
- package/src/config.js +0 -168
- package/src/connections.js +0 -57
- package/src/const.js +0 -83
- package/src/crypt.js +0 -21
- package/src/debounceAsync.js +0 -48
- package/src/events.js +0 -9
- package/src/frontEndApis.js +0 -38
- package/src/github.js +0 -102
- package/src/index.js +0 -55
- package/src/listen.js +0 -235
- package/src/log.js +0 -137
- package/src/middlewares.js +0 -154
- package/src/misc.js +0 -160
- package/src/pbkdf2.js +0 -74
- package/src/perm.js +0 -176
- package/src/plugins.js +0 -343
- package/src/serveFile.js +0 -104
- package/src/serveGuiFiles.js +0 -113
- package/src/sse.js +0 -29
- package/src/throttler.js +0 -91
- package/src/update.js +0 -69
- package/src/util-files.js +0 -148
- package/src/util-generators.js +0 -30
- package/src/util-http.js +0 -30
- package/src/vfs.js +0 -226
- package/src/watchLoad.js +0 -73
- 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
|
+
}
|