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
@@ -0,0 +1,122 @@
1
+ import { addAccount, getAccount, updateAccount } from './perm'
2
+ import { getConfig, getConfigDefinition, setConfig } from './config'
3
+ import _ from 'lodash'
4
+ import { getUpdate, update } from './update'
5
+ import { openAdmin } from './listen'
6
+ import yaml from 'yaml'
7
+ import { BUILD_TIMESTAMP, VERSION } from './const'
8
+ import { createInterface } from 'readline'
9
+
10
+ try {
11
+ /*
12
+ is this try-block useful in case the stdin is unavailable?
13
+ Not sure, but someone reported a problem using nohup https://github.com/rejetto/hfs/issues/74
14
+ and I've found this example try-catching https://github.com/DefinitelyTyped/DefinitelyTyped/blob/dda83a906914489e09ca28afea12948529015d4a/types/node/readline.d.ts#L489
15
+ */
16
+ createInterface({ input: process.stdin }).on('line', parseCommandLine)
17
+ console.log(`HINT: type "help" for help`)
18
+ }
19
+ catch {
20
+ console.log("console commands not available")
21
+ }
22
+
23
+ function parseCommandLine(line: string) {
24
+ if (!line) return
25
+ const [name, ...params] = line.trim().split(/ +/)
26
+ const cmd = (commands as any)[name]
27
+ if (!cmd)
28
+ return console.error("cannot understand entered command, try 'help'")
29
+ if (cmd.cb.length > params.length)
30
+ return console.error("insufficient parameters, expected: " + cmd.params)
31
+ cmd.cb(...params).then(() =>console.log("+++ command executed"),
32
+ (err: any) => {
33
+ if (typeof err === 'string')
34
+ console.error("command failed:", err)
35
+ else
36
+ throw err
37
+ })
38
+ }
39
+
40
+ const commands = {
41
+ help: {
42
+ params: '',
43
+ async cb() {
44
+ console.log("supported commands:",
45
+ ..._.map(commands, ({ params }, name) =>
46
+ '\n - ' + name + ' ' + params))
47
+ }
48
+ },
49
+ 'show-admin': {
50
+ params: '',
51
+ cb(){
52
+ openAdmin()
53
+ }
54
+ },
55
+ 'create-admin': {
56
+ params: '<password> [<username>=admin]',
57
+ async cb(password: string, username='admin') {
58
+ if (getAccount(username))
59
+ throw `user ${username} already exists`
60
+ const acc = addAccount(username, { admin: true })
61
+ await updateAccount(acc!, acc => {
62
+ acc.password = password
63
+ })
64
+ }
65
+ },
66
+ 'change-password': {
67
+ params: '<user> <password>',
68
+ async cb(user: string, password: string) {
69
+ const acc = getAccount(user)
70
+ if (!acc)
71
+ throw "user doesn't exist"
72
+ await updateAccount(acc!, acc => {
73
+ acc.password = password
74
+ })
75
+ }
76
+ },
77
+ config: {
78
+ params: '<key> <value>',
79
+ async cb(key: string, value: string) {
80
+ const conf = getConfigDefinition(key)
81
+ if (!conf)
82
+ throw "specified key doesn't exist"
83
+ let v: any = value
84
+ try { v = JSON.parse(v) }
85
+ catch {}
86
+ setConfig({ [key]: v })
87
+ }
88
+ },
89
+ 'show-config': {
90
+ params: '<key>',
91
+ async cb(key: string) {
92
+ const conf = getConfigDefinition(key)
93
+ if (!conf)
94
+ throw "specified key doesn't exist"
95
+ console.log(yaml.stringify(getConfig(key), { lineWidth:1000 }).trim())
96
+ }
97
+ },
98
+ quit: {
99
+ params: '',
100
+ async cb() {
101
+ process.exit(0)
102
+ }
103
+ },
104
+ update: {
105
+ params: '',
106
+ cb: update
107
+ },
108
+ 'check-update': {
109
+ params: '',
110
+ async cb() {
111
+ const update = await getUpdate()
112
+ console.log("new version available", update.name)
113
+ }
114
+ },
115
+ version: {
116
+ params: '',
117
+ async cb() {
118
+ console.log(VERSION)
119
+ console.log(BUILD_TIMESTAMP)
120
+ }
121
+ },
122
+ }
package/src/config.ts ADDED
@@ -0,0 +1,166 @@
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 EventEmitter from 'events'
4
+ import { APP_PATH, argv, ORIGINAL_CWD } from './const'
5
+ import { watchLoad } from './watchLoad'
6
+ import yaml from 'yaml'
7
+ import _ from 'lodash'
8
+ import { debounceAsync, same, objSameKeys, onOff, wait, with_ } from './misc'
9
+ import { copyFileSync, existsSync, renameSync, statSync } from 'fs'
10
+ import { join, resolve } from 'path'
11
+ import events from './events'
12
+
13
+ const FILE = 'config.yaml'
14
+
15
+ const configProps: Record<string, ConfigProps<any>> = {}
16
+
17
+ let started = false // this will tell the difference for subscribeConfig()s that are called before or after config is loaded
18
+ let state: Record<string, any> = {}
19
+ const cfgEvents = new EventEmitter()
20
+ cfgEvents.setMaxListeners(10_000)
21
+ const path = with_(argv.config || process.env.HFS_CONFIG, p => {
22
+ if (!p)
23
+ return FILE
24
+ p = resolve(ORIGINAL_CWD, p)
25
+ try {
26
+ if (statSync(p).isDirectory()) // try to detect if path points to a folder, in which case we add the standard filename
27
+ return join(p, FILE)
28
+ }
29
+ catch {}
30
+ return p
31
+ })
32
+ console.log("config", path)
33
+ const legacyPosition = join(APP_PATH, FILE)
34
+ if (!existsSync(path) && existsSync(legacyPosition))
35
+ try {
36
+ renameSync(legacyPosition, path)
37
+ console.log("moved from legacy position", legacyPosition)
38
+ }
39
+ catch {
40
+ try { // attempt copying, in case moving the source file proves to be impractical
41
+ copyFileSync(legacyPosition, path)
42
+ console.log("copied from legacy position", legacyPosition)
43
+ }
44
+ catch {}
45
+ }
46
+ const { save } = watchLoad(path, values => setConfig(values||{}, false), {
47
+ failedOnFirstAttempt(){
48
+ console.log("No config file, using defaults")
49
+ setConfig({}, false)
50
+ }
51
+ })
52
+
53
+ interface ConfigProps<T> {
54
+ defaultValue?: T,
55
+ }
56
+ export function defineConfig<T>(k: string, defaultValue?: T) {
57
+ configProps[k] = { defaultValue }
58
+ type Updater = (currentValue:T) => T
59
+ return {
60
+ key() {
61
+ return k
62
+ },
63
+ get(): T {
64
+ return getConfig(k)
65
+ },
66
+ sub(cb: (v:T, was?:T)=>void) {
67
+ return subscribeConfig(k, cb)
68
+ },
69
+ set(v: T | Updater) {
70
+ if (typeof v === 'function')
71
+ this.set((v as Updater)(this.get()))
72
+ else
73
+ setConfig1(k, v)
74
+ }
75
+ }
76
+ }
77
+
78
+ export function getConfigDefinition(k: string) {
79
+ return configProps[k]
80
+ }
81
+
82
+ const stack: any[] = []
83
+ function subscribeConfig<T>(k:string, cb: (v:T, was?:T)=>void) {
84
+ if (started) // initial event already passed, we'll make the first call
85
+ cb(getConfig(k))
86
+ const eventName = 'new.'+k
87
+ return onOff(cfgEvents, {
88
+ [eventName]() {
89
+ if (stack.includes(cb)) return // avoid infinite loop in case a subscriber changes the value
90
+ stack.push(cb) // @ts-ignore arguments
91
+ try { return cb.apply(this,arguments) }
92
+ finally { stack.pop() }
93
+ }
94
+ })
95
+ }
96
+
97
+ export function getConfig(k:string) {
98
+ return state[k] ?? _.cloneDeep(configProps[k]?.defaultValue) // clone to avoid changing
99
+ }
100
+
101
+ export function getWholeConfig({ omit, only }: { omit?:string[], only?:string[] }) {
102
+ const defs = objSameKeys(configProps, x => x.defaultValue)
103
+ let copy = _.defaults({}, state, defs)
104
+ if (omit?.length)
105
+ copy = _.omit(copy, omit)
106
+ if (only)
107
+ copy = _.pick(copy, only)
108
+ return _.cloneDeep(copy)
109
+ }
110
+
111
+ // pass a value to `save` to force saving decision, or leave undefined for auto. Passing false will also reset previously loaded configs.
112
+ export function setConfig(newCfg: Record<string,any>, save?: boolean) {
113
+ if (!started) { // first time we consider also CLI args
114
+ const argCfg = _.pickBy(objSameKeys(configProps, (x,k) => argv[k]), x => x !== undefined)
115
+ if (! _.isEmpty(argCfg)) {
116
+ saveConfigAsap().then() // don't set `save` argument, as it would interfere below at check `save===false`
117
+ Object.assign(newCfg, argCfg)
118
+ }
119
+ }
120
+ for (const k in newCfg)
121
+ apply(k, newCfg[k])
122
+ if (save) {
123
+ saveConfigAsap().then()
124
+ return
125
+ }
126
+ if (started) {
127
+ if (save === false) // false is used when loading whole config, and in such case we should not leave previous values untreated. Also, we need this only after we already `started`.
128
+ for (const k of Object.keys(state))
129
+ if (!newCfg.hasOwnProperty(k))
130
+ apply(k, newCfg[k])
131
+ return
132
+ }
133
+ // first time we emit also for the default values
134
+ for (const k of Object.keys(configProps))
135
+ if (!newCfg.hasOwnProperty(k))
136
+ apply(k, newCfg[k])
137
+ started = true
138
+ events.emit('config ready')
139
+
140
+ function apply(k: string, newV: any) {
141
+ return setConfig1(k, newV, save === undefined)
142
+ }
143
+ }
144
+
145
+ function setConfig1(k: string, newV: any, saveChanges=true) {
146
+ if (_.isPlainObject(newV))
147
+ newV = _.pickBy(newV, x => x !== undefined)
148
+ if (same(newV, configProps[k]?.defaultValue))
149
+ newV = undefined
150
+ if (started && same(newV, state[k])) return // no change
151
+ const was = getConfig(k) // include cloned default, if necessary
152
+ state[k] = newV
153
+ cfgEvents.emit('new.'+k, getConfig(k), was)
154
+ if (saveChanges)
155
+ saveConfigAsap().then()
156
+ }
157
+
158
+ export const saveConfigAsap = debounceAsync(async () => {
159
+ while (!started)
160
+ await wait(100)
161
+ let txt = yaml.stringify(state, { lineWidth:1000 })
162
+ if (txt.trim() === '{}') // most users wouldn't understand
163
+ txt = ''
164
+ save(path, txt)
165
+ .catch(err => console.error('Failed at saving config file, please ensure it is writable.', String(err)))
166
+ })
@@ -0,0 +1,60 @@
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 { Socket } from 'net'
4
+ import events from './events'
5
+ import Koa from 'koa'
6
+ import _ from 'lodash'
7
+
8
+ export class Connection {
9
+ readonly started = new Date()
10
+ sent = 0
11
+ outSpeed?: number
12
+ ctx?: Koa.Context
13
+ private _cachedIp?: string
14
+ [rest:symbol]: any // let other modules add extra data, but using symbols to avoid name collision
15
+
16
+ constructor(readonly socket: Socket) {
17
+ all.push(this)
18
+ socket.on('close', () => {
19
+ all.splice(all.indexOf(this), 1)
20
+ events.emit('connectionClosed', this)
21
+ })
22
+ events.emit('connection', this)
23
+ }
24
+
25
+ get ip() { // prioritize ctx.ip as it supports proxies, but fallback for when ctx is not yet available
26
+ return this.ctx?.ip || (this._cachedIp ??= normalizeIp(this.socket.remoteAddress||''))
27
+ }
28
+
29
+ get secure() {
30
+ return (this.socket as any).server.cert > ''
31
+ }
32
+ }
33
+
34
+ export function normalizeIp(ip: string) {
35
+ return ip.replace(/^::ffff:/,'') // simplify ipv6-mapped addresses
36
+ }
37
+
38
+ const all: Connection[] = []
39
+
40
+ export function newConnection(socket: Socket) {
41
+ new Connection(socket)
42
+ }
43
+
44
+ export function getConnections(): Readonly<typeof all> {
45
+ return all
46
+ }
47
+
48
+ export function socket2connection(socket: Socket) {
49
+ return all.find(x => // socket exposed by Koa is TLSSocket which encapsulates simple Socket, and I've found no way to access it for simple comparison
50
+ x.socket.remotePort === socket.remotePort // but we can still match them because IP:PORT is key
51
+ && x.socket.remoteAddress === socket.remoteAddress )
52
+ }
53
+
54
+ export function updateConnection(conn: Connection, change: Partial<Connection>) {
55
+ // if no change is detected, skip update. ctx is a special case
56
+ if (!change.ctx && Object.entries(change).every(([k,v]) => _.isEqual(v, conn[k as keyof Connection]) ))
57
+ return
58
+ Object.assign(conn, change)
59
+ events.emit('connectionUpdated', conn, change)
60
+ }
package/src/const.ts ADDED
@@ -0,0 +1,57 @@
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 minimist from 'minimist'
4
+ import * as fs from 'fs'
5
+ import { homedir } from 'os'
6
+ import { mkdirSync } from 'fs'
7
+ import { basename, dirname, join } from 'path'
8
+
9
+ export const argv = minimist(process.argv.slice(2))
10
+ export const DEV = process.env.DEV || argv.dev ? 'DEV' : ''
11
+ export const ORIGINAL_CWD = process.cwd()
12
+ export const HFS_STARTED = new Date()
13
+ const PKG_PATH = join(__dirname, '..', 'package.json')
14
+ export const BUILD_TIMESTAMP = fs.statSync(PKG_PATH).mtime.toISOString()
15
+ const pkg = JSON.parse(fs.readFileSync(PKG_PATH,'utf8'))
16
+ export const VERSION = pkg.version
17
+ export const DAY = 86_400_000
18
+ export const SESSION_DURATION = DAY
19
+
20
+ export const API_VERSION = 4.1 // array.$width
21
+ export const COMPATIBLE_API_VERSION = 1 // while changes in the api are not breaking, this number stays the same, otherwise is made equal to API_VERSION
22
+
23
+ export const SPECIAL_URI = '/~/'
24
+ export const FRONTEND_URI = SPECIAL_URI + 'frontend/'
25
+ export const ADMIN_URI = SPECIAL_URI + 'admin/'
26
+ export const API_URI = SPECIAL_URI + 'api/'
27
+ export const PLUGINS_PUB_URI = SPECIAL_URI + 'plugins/'
28
+
29
+ export const METHOD_NOT_ALLOWED = 405
30
+ export const NO_CONTENT = 204
31
+ export const FORBIDDEN = 403
32
+ export const UNAUTHORIZED = 401
33
+
34
+ export const IS_WINDOWS = process.platform === 'win32'
35
+ const IS_BINARY = !basename(process.argv0).includes('node') // this won't be node if pkg was used
36
+ export const APP_PATH = dirname(IS_BINARY ? process.argv0 : __dirname)
37
+
38
+ // we want this to be the first stuff to be printed, then we print it in this module, that is executed at the beginning
39
+ if (DEV) console.clear()
40
+ else console.debug = ()=>{}
41
+ console.log(`HFS ~ HTTP File Server - Copyright 2021-2022, Massimo Melina <a@rejetto.com>`)
42
+ console.log(`License https://www.gnu.org/licenses/gpl-3.0.txt`)
43
+ console.log('started', HFS_STARTED.toLocaleString(), DEV)
44
+ console.log('version', VERSION||'-')
45
+ console.log('build', BUILD_TIMESTAMP||'-')
46
+ if (argv.cwd)
47
+ process.chdir(argv.cwd)
48
+ else if (!process.argv0.endsWith('.exe')) { // still considering whether to use this behavior with Windows users, who may be less accustomed to it
49
+ const dir = join(homedir(), '.hfs')
50
+ try { mkdirSync(dir) }
51
+ catch(e: any) {
52
+ if (e.code !== 'EEXIST')
53
+ console.error(e)
54
+ }
55
+ process.chdir(dir)
56
+ }
57
+ console.log('cwd', process.cwd())
package/src/crypt.ts ADDED
@@ -0,0 +1,16 @@
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
+ // simple wrapper
4
+ // @ts-ignore
5
+ import { pbkdf2, pbkdf2Verify } from "./pbkdf2"
6
+ import assert from 'assert'
7
+
8
+ export async function hashPassword(s: string) {
9
+ return 'p2:' + await pbkdf2(s)
10
+ }
11
+
12
+ export async function verifyPassword(hashed: string, given: string) {
13
+ const i = hashed.indexOf(':')
14
+ assert(i>0, 'bad hashed')
15
+ return await pbkdf2Verify(hashed.slice(i+1), given) // for the time being we totally ignore the "method" part
16
+ }
@@ -0,0 +1,51 @@
1
+ // like lodash.debounce, but also avoids async invocations to overlap
2
+ export default function debounceAsync<CB extends (...args: any[]) => Promise<R>, R>(
3
+ callback: CB,
4
+ wait: number=100,
5
+ { leading=false, maxWait=Infinity }={}
6
+ ) {
7
+ let started = 0 // latest callback invocation
8
+ let runningCallback: Promise<R> | undefined // latest callback invocation result
9
+ let runningDebouncer: Promise<R | undefined> // latest wrapper invocation
10
+ let waitingSince = 0 // we are delaying invocation since
11
+ let whoIsWaiting: undefined | any[] // args' array object identifies the pending instance, and incidentally stores args
12
+ const interceptingWrapper = (...args:any[]) => runningDebouncer = debouncer.apply(null, args)
13
+ return Object.assign(interceptingWrapper, {
14
+ cancel: () => {
15
+ waitingSince = 0
16
+ whoIsWaiting = undefined
17
+ },
18
+ flush: () => runningCallback ?? exec(),
19
+ })
20
+
21
+ async function debouncer(...args:any[]) {
22
+ if (runningCallback)
23
+ return await runningCallback
24
+ whoIsWaiting = args
25
+ waitingSince ||= Date.now()
26
+ const waitingCap = maxWait - (Date.now() - (waitingSince || started))
27
+ const waitFor = Math.min(waitingCap, leading ? wait - (Date.now() - started) : wait)
28
+ if (waitFor > 0)
29
+ await new Promise(resolve => setTimeout(resolve, waitFor))
30
+ if (!whoIsWaiting) // canceled
31
+ return void(waitingSince = 0)
32
+ if (whoIsWaiting !== args) // another fresher call is waiting
33
+ return runningDebouncer
34
+ return await exec()
35
+ }
36
+
37
+ async function exec() {
38
+ if (!whoIsWaiting) return
39
+ waitingSince = 0
40
+ started = Date.now()
41
+ try {
42
+ runningCallback = callback.apply(null, whoIsWaiting)
43
+ return await runningCallback
44
+ }
45
+ finally {
46
+ whoIsWaiting = undefined
47
+ runningCallback = undefined
48
+ }
49
+ }
50
+ }
51
+
package/src/events.ts ADDED
@@ -0,0 +1,6 @@
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 EventEmitter from 'events'
4
+
5
+ // app-wide events
6
+ export default new EventEmitter()
@@ -0,0 +1,17 @@
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 { ApiHandlers } from './apiMiddleware'
4
+ import { file_list } from './api.file_list'
5
+ import * as api_auth from './api.auth'
6
+ import { defineConfig } from './config'
7
+
8
+ const customHeader = defineConfig('custom_header')
9
+
10
+ export const frontEndApis: ApiHandlers = {
11
+ file_list,
12
+ ...api_auth,
13
+
14
+ config() {
15
+ return Object.fromEntries([customHeader].map(x => [x.key(), x.get()]))
16
+ }
17
+ }
package/src/github.ts ADDED
@@ -0,0 +1,102 @@
1
+ import events from './events'
2
+ import { httpsString, httpsStream, unzip } from './misc'
3
+ import { getAvailablePlugins, mapPlugins, parsePluginSource, PATH as PLUGINS_PATH, rescan } from './plugins'
4
+ // @ts-ignore
5
+ import unzipper from 'unzip-stream'
6
+ import { ApiError } from './apiMiddleware'
7
+ import _ from 'lodash'
8
+
9
+ const DIST_ROOT = 'dist/'
10
+
11
+ type DownloadStatus = true | undefined
12
+ const downloading: Record<string, DownloadStatus> = {}
13
+
14
+ function downloadProgress(id: string, status: DownloadStatus) {
15
+ if (status === undefined)
16
+ delete downloading[id]
17
+ else
18
+ downloading[id] = status
19
+ events.emit('pluginDownload_'+id, status)
20
+ }
21
+
22
+ export async function downloadPlugin(repo: string, branch='', overwrite?: boolean) {
23
+ if (downloading[repo])
24
+ return new ApiError(409, "already downloading")
25
+ downloadProgress(repo, true)
26
+ const rec = await getRepoInfo(repo)
27
+ if (!branch)
28
+ branch = rec.default_branch
29
+ const short = repo.split('/')[1] // second part, repo without the owner
30
+ const folder2repo = getFolder2repo()
31
+ const folder = overwrite ? _.findKey(folder2repo, x => x===repo)! // use existing folder
32
+ : short in folder2repo ? repo.replace('/','-') // longer form only if another plugin is using short form
33
+ : short
34
+ const installPath = PLUGINS_PATH + '/' + folder
35
+ const GITHUB_ZIP_ROOT = short + '-' + branch // GitHub puts everything within this folder
36
+ const rootWithinZip = GITHUB_ZIP_ROOT + '/' + DIST_ROOT
37
+ const stream = await httpsStream(`https://github.com/${repo}/archive/refs/heads/${branch}.zip`)
38
+ await unzip(stream, path =>
39
+ path.startsWith(rootWithinZip) && installPath + '/' + path.slice(rootWithinZip.length) )
40
+ downloadProgress(repo, undefined)
41
+ await rescan() // workaround: for some reason, operations are not triggering the rescan of the watched folder. Let's invoke it.
42
+ return folder
43
+ }
44
+
45
+ export function getRepoInfo(id: string) {
46
+ return apiGithub('repos/'+id)
47
+ }
48
+
49
+ export async function readOnlinePlugin(repoInfo: { full_name: string, default_branch: string }, branch='') {
50
+ const url = `https://raw.githubusercontent.com/${repoInfo.full_name}/${branch || repoInfo.default_branch}/${DIST_ROOT}plugin.js`
51
+ const res = await httpsString(url)
52
+ if (!res.ok)
53
+ throw res.statusCode
54
+ const pl = parsePluginSource(repoInfo.full_name, res.body) // use 'repo' as 'id' client-side
55
+ pl.branch = branch || undefined
56
+ return pl
57
+ }
58
+
59
+ export function getFolder2repo() {
60
+ const ret = Object.fromEntries(getAvailablePlugins().map(x => [x.id, x.repo]))
61
+ Object.assign(ret, Object.fromEntries(mapPlugins(x => [x.id, x.getData().repo])))
62
+ return ret
63
+ }
64
+
65
+ async function apiGithub(uri: string) {
66
+ const res = await httpsString('https://api.github.com/'+uri, {
67
+ headers: {
68
+ 'User-Agent': 'HFS',
69
+ Accept: 'application/vnd.github.v3+json',
70
+ }
71
+ })
72
+ if (!res.ok)
73
+ throw res.statusCode
74
+ return JSON.parse(res.body)
75
+ }
76
+
77
+ export async function* searchPlugins(text: string) {
78
+ const res = await apiGithub('search/repositories?q=topic:hfs-plugin+' + encodeURI(text))
79
+ for (const it of res.items) {
80
+ const repo = it.full_name
81
+ let pl = await readOnlinePlugin(it)
82
+ if (!pl.apiRequired) continue // mandatory field
83
+ if (pl.badApi) { // we try other branches (starting with 'api')
84
+ const branches: string[] = (await apiGithub('repos/' + it.full_name + '/branches'))
85
+ .map((x: any) => x.name).filter((x: string) => x.startsWith('api')).sort()
86
+ for (const branch of branches) {
87
+ pl = await readOnlinePlugin(it, branch)
88
+ if (!pl.apiRequired)
89
+ pl.badApi = '-'
90
+ if (!pl.badApi)
91
+ break
92
+ }
93
+ }
94
+ if (pl.badApi)
95
+ continue
96
+ Object.assign(pl, { // inject some extra useful fields
97
+ downloading: downloading[repo],
98
+ license: it.license?.spdx_id,
99
+ }, _.pick(it, ['pushed_at', 'stargazers_count']))
100
+ yield pl
101
+ }
102
+ }
package/src/index.ts ADDED
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ // This file is part of HFS - Copyright 2021-2022, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
3
+
4
+ import Koa from 'koa'
5
+ import mount from 'koa-mount'
6
+ import { apiMiddleware } from './apiMiddleware'
7
+ import { API_URI, DEV } from './const'
8
+ import { frontEndApis } from './frontEndApis'
9
+ import { log } from './log'
10
+ import { pluginsMiddleware } from './plugins'
11
+ import { throttler } from './throttler'
12
+ import { headRequests, gzipper, sessions, serveGuiAndSharedFiles, someSecurity, prepareState, paramsDecoder } from './middlewares'
13
+ import './listen'
14
+ import './commands'
15
+ import { adminApis } from './adminApis'
16
+ import { defineConfig } from './config'
17
+ import { ok } from 'assert'
18
+ import _ from 'lodash'
19
+ import { randomId } from './misc'
20
+
21
+ ok(_.intersection(Object.keys(frontEndApis), Object.keys(adminApis)).length === 0) // they share same endpoints
22
+
23
+ const keys = process.env.COOKIE_SIGN_KEYS?.split(',') || [randomId(30)]
24
+ export const app = new Koa({ keys })
25
+ app.use(someSecurity)
26
+ .use(sessions(app))
27
+ .use(prepareState)
28
+ .use(headRequests)
29
+ .use(log())
30
+ .use(throttler)
31
+ .use(gzipper)
32
+ .use(paramsDecoder)
33
+ .use(pluginsMiddleware())
34
+ .use(mount(API_URI, apiMiddleware({ ...frontEndApis, ...adminApis })))
35
+ .use(serveGuiAndSharedFiles)
36
+ .on('error', errorHandler)
37
+
38
+ function errorHandler(err:Error & { code:string, path:string }) {
39
+ const { code } = err
40
+ if (DEV && code === 'ENOENT' && err.path.endsWith('sockjs-node')) return // spam out dev stuff
41
+ if (code === 'ECANCELED' || code === 'ECONNRESET' || code === 'ECONNABORTED' || code === 'EPIPE') return // someone interrupted, don't care
42
+ console.error('server error', err)
43
+ }
44
+
45
+ process.on('uncaughtException', err => {
46
+ if ((err as any).syscall !== 'watch')
47
+ console.error(err)
48
+ })
49
+
50
+ defineConfig('proxies', 0).sub(n => {
51
+ app.proxy = n > 0
52
+ app.maxIpsCount = n
53
+ })