hfs 0.26.7 → 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.0f549e00.js +0 -281
- package/admin/assets/index.dcc78777.css +0 -1
- package/admin/assets/sha512.ea1121b3.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 -227
- package/src/watchLoad.js +0 -73
- package/src/zip.js +0 -69
package/src/listen.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
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 * as http from 'http'
|
|
4
|
+
import { defineConfig } from './config'
|
|
5
|
+
import { app } from './index'
|
|
6
|
+
import * as https from 'https'
|
|
7
|
+
import { watchLoad } from './watchLoad'
|
|
8
|
+
import { networkInterfaces } from 'os';
|
|
9
|
+
import { newConnection } from './connections'
|
|
10
|
+
import open from 'open'
|
|
11
|
+
import { debounceAsync, onlyTruthy, wait } from './misc'
|
|
12
|
+
import { ADMIN_URI, DEV } from './const'
|
|
13
|
+
import findProcess from 'find-process'
|
|
14
|
+
import { anyAccountCanLoginAdmin } from './perm'
|
|
15
|
+
import _ from 'lodash'
|
|
16
|
+
|
|
17
|
+
interface ServerExtra { name: string, error?: string, busy?: Promise<string> }
|
|
18
|
+
let httpSrv: http.Server & ServerExtra
|
|
19
|
+
let httpsSrv: http.Server & ServerExtra
|
|
20
|
+
|
|
21
|
+
const openBrowserAtStart = defineConfig('open_browser_at_start', !DEV)
|
|
22
|
+
|
|
23
|
+
export const portCfg = defineConfig<number>('port', 80)
|
|
24
|
+
portCfg.sub(async port => {
|
|
25
|
+
while (!app)
|
|
26
|
+
await wait(100)
|
|
27
|
+
stopServer(httpSrv).then()
|
|
28
|
+
httpSrv = Object.assign(http.createServer(app.callback()), { name: 'http' })
|
|
29
|
+
port = await startServer(httpSrv, { port })
|
|
30
|
+
if (!port) return
|
|
31
|
+
httpSrv.on('connection', newConnection)
|
|
32
|
+
printUrls(port, 'http')
|
|
33
|
+
if (openBrowserAtStart.get())
|
|
34
|
+
openAdmin()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
export function openAdmin() {
|
|
38
|
+
for (const srv of [httpSrv, httpsSrv]) {
|
|
39
|
+
const a = srv.address()
|
|
40
|
+
if (!a || typeof a === 'string') continue
|
|
41
|
+
const baseUrl = srv.name + '://localhost:' + a.port
|
|
42
|
+
open(baseUrl + ADMIN_URI, { wait: true}).catch(e => {
|
|
43
|
+
console.debug(String(e))
|
|
44
|
+
console.warn("cannot launch browser on this machine >PLEASE< open your browser and reach one of these (you may need a different address)",
|
|
45
|
+
...Object.values(getUrls()).flat().map(x => '\n - ' + x + ADMIN_URI))
|
|
46
|
+
if (! anyAccountCanLoginAdmin())
|
|
47
|
+
console.log(`HINT: you can enter command: create-admin YOUR_PASSWORD`)
|
|
48
|
+
})
|
|
49
|
+
return true
|
|
50
|
+
}
|
|
51
|
+
console.log("openAdmin failed")
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const considerHttps = debounceAsync(async () => {
|
|
55
|
+
stopServer(httpsSrv).then()
|
|
56
|
+
let port = httpsPortCfg.get()
|
|
57
|
+
try {
|
|
58
|
+
while (!app)
|
|
59
|
+
await wait(100)
|
|
60
|
+
httpsSrv = Object.assign(
|
|
61
|
+
https.createServer(port < 0 ? {} : { key: httpsOptions.private_key, cert: httpsOptions.cert }, app.callback()),
|
|
62
|
+
{ name: 'https', error: undefined }
|
|
63
|
+
)
|
|
64
|
+
if (port >= 0) {
|
|
65
|
+
const namesForOutput: any = { cert: 'certificate', private_key: 'private key' }
|
|
66
|
+
const missing = httpsNeeds.find(x => !x.get())?.key()
|
|
67
|
+
if (missing)
|
|
68
|
+
return httpsSrv.error = "missing " + namesForOutput[missing]
|
|
69
|
+
const cantRead = httpsNeeds.find(x => !httpsOptions[x.key() as HttpsKeys])?.key()
|
|
70
|
+
if (cantRead)
|
|
71
|
+
return httpsSrv.error = "cannot read " + namesForOutput[cantRead]
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch(e) {
|
|
75
|
+
httpsSrv.error = "bad private key or certificate"
|
|
76
|
+
console.log("failed to create https server: check your private key and certificate", String(e))
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
port = await startServer(httpsSrv, { port })
|
|
80
|
+
if (!port) return
|
|
81
|
+
httpsSrv.on('connection', newConnection)
|
|
82
|
+
printUrls(port, 'https')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
const cert = defineConfig<string>('cert')
|
|
87
|
+
const privateKey = defineConfig<string>('private_key')
|
|
88
|
+
const httpsNeeds = [cert, privateKey]
|
|
89
|
+
const httpsOptions = { cert: '', private_key: '' }
|
|
90
|
+
type HttpsKeys = keyof typeof httpsOptions
|
|
91
|
+
for (const cfg of httpsNeeds) {
|
|
92
|
+
let unwatch: ReturnType<typeof watchLoad>['unwatch']
|
|
93
|
+
cfg.sub(async v => {
|
|
94
|
+
unwatch?.()
|
|
95
|
+
const k = cfg.key() as HttpsKeys
|
|
96
|
+
httpsOptions[k] = v
|
|
97
|
+
if (!v || v.includes('\n'))
|
|
98
|
+
return considerHttps()
|
|
99
|
+
// v is a path
|
|
100
|
+
httpsOptions[k] = ''
|
|
101
|
+
unwatch = watchLoad(v, data => {
|
|
102
|
+
httpsOptions[k] = data
|
|
103
|
+
considerHttps()
|
|
104
|
+
}).unwatch
|
|
105
|
+
await considerHttps()
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const httpsPortCfg = defineConfig('https_port', -1)
|
|
110
|
+
httpsPortCfg.sub(considerHttps)
|
|
111
|
+
|
|
112
|
+
interface StartServer { port: number, host?:string }
|
|
113
|
+
function startServer(srv: typeof httpSrv, { port, host }: StartServer) {
|
|
114
|
+
return new Promise<number>(async resolve => {
|
|
115
|
+
try {
|
|
116
|
+
if (port < 0 || !host && !await testIpV4()) // !host means ipV4+6, and if v4 port alone is busy we won't be notified of the failure, so we'll first test it on its own
|
|
117
|
+
return resolve(0)
|
|
118
|
+
port = await listen(host)
|
|
119
|
+
if (port)
|
|
120
|
+
console.log(srv.name, "serving on", host||"any network", ':', port)
|
|
121
|
+
resolve(port)
|
|
122
|
+
}
|
|
123
|
+
catch(e) {
|
|
124
|
+
srv.error = String(e)
|
|
125
|
+
console.error(srv.name, "couldn't listen on port", port, srv.error)
|
|
126
|
+
resolve(0)
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
async function testIpV4() {
|
|
131
|
+
const res = await listen('0.0.0.0')
|
|
132
|
+
await new Promise(res => srv.close(res))
|
|
133
|
+
return res > 0
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function listen(host?: string) {
|
|
137
|
+
return new Promise<number>(async (resolve, reject) => {
|
|
138
|
+
srv.listen({ port, host }, () => {
|
|
139
|
+
const ad = srv.address()
|
|
140
|
+
if (!ad)
|
|
141
|
+
return reject('no address')
|
|
142
|
+
if (typeof ad === 'string') {
|
|
143
|
+
srv.close()
|
|
144
|
+
return reject('type of socket not supported')
|
|
145
|
+
}
|
|
146
|
+
resolve(ad.port)
|
|
147
|
+
}).on('error', async e => {
|
|
148
|
+
srv.error = String(e)
|
|
149
|
+
srv.busy = undefined
|
|
150
|
+
const { code } = e as any
|
|
151
|
+
if (code === 'EADDRINUSE') {
|
|
152
|
+
srv.busy = findProcess('port', port).then(res => res?.[0]?.name || '', () => '')
|
|
153
|
+
srv.error = `port ${port} busy: ${await srv.busy || "unknown process"}`
|
|
154
|
+
}
|
|
155
|
+
console.error(srv.name, srv.error)
|
|
156
|
+
const k = (srv === httpSrv? portCfg : httpsPortCfg).key()
|
|
157
|
+
console.log(` >> try specifying a different port, enter this command: config ${k} 8011`)
|
|
158
|
+
resolve(0)
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function stopServer(srv: http.Server) {
|
|
165
|
+
return new Promise(resolve => {
|
|
166
|
+
if (!srv?.listening)
|
|
167
|
+
return resolve(null)
|
|
168
|
+
const ad = srv.address()
|
|
169
|
+
if (ad && typeof ad !== 'string')
|
|
170
|
+
console.log("stopped port", ad.port)
|
|
171
|
+
srv.close(err => {
|
|
172
|
+
if (err && (err as any).code !== 'ERR_SERVER_NOT_RUNNING')
|
|
173
|
+
console.debug("failed to stop server", String(err))
|
|
174
|
+
resolve(err)
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function getStatus() {
|
|
180
|
+
return {
|
|
181
|
+
httpSrv,
|
|
182
|
+
httpsSrv,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const ignore = /^(lo|.*loopback.*|virtualbox.*|.*\(wsl\).*|llw\d|awdl\d|utun\d|anpi\d)$/i // avoid giving too much information
|
|
187
|
+
|
|
188
|
+
export function getUrls() {
|
|
189
|
+
return Object.fromEntries(onlyTruthy([httpSrv, httpsSrv].map(srv => {
|
|
190
|
+
if (!srv?.listening)
|
|
191
|
+
return false
|
|
192
|
+
const port = (srv?.address() as any)?.port
|
|
193
|
+
const appendPort = port === (srv.name === 'https' ? 443 : 80) ? '' : ':' + port
|
|
194
|
+
const urls = onlyTruthy(Object.entries(networkInterfaces()).map(([name, nets]) =>
|
|
195
|
+
nets && !ignore.test(name) && nets.map(net => {
|
|
196
|
+
if (net.internal) return
|
|
197
|
+
let { address } = net
|
|
198
|
+
if (address.includes(':'))
|
|
199
|
+
address = '[' + address + ']'
|
|
200
|
+
return srv.name + '://' + address + appendPort
|
|
201
|
+
})
|
|
202
|
+
).flat())
|
|
203
|
+
return urls.length && [srv.name, urls]
|
|
204
|
+
})))
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function printUrls(port: number, proto: string) {
|
|
208
|
+
if (!port) return
|
|
209
|
+
for (const [name, nets] of Object.entries(networkInterfaces())) {
|
|
210
|
+
if (!nets || ignore.test(name)) continue
|
|
211
|
+
_.remove(nets, 'internal')
|
|
212
|
+
if (!nets.length) continue
|
|
213
|
+
const best = _.find(nets, { family: 'IPv4' }) || nets[0]
|
|
214
|
+
const appendPort = port === (proto==='https' ? 443 : 80) ? '' : ':' + port
|
|
215
|
+
let { address } = best
|
|
216
|
+
if (address.includes(':'))
|
|
217
|
+
address = '['+address+']'
|
|
218
|
+
console.log('network', name, proto + '://' + address + appendPort)
|
|
219
|
+
}
|
|
220
|
+
}
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
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 Koa from 'koa'
|
|
4
|
+
import { Writable } from 'stream'
|
|
5
|
+
import { defineConfig } from './config'
|
|
6
|
+
import { createWriteStream, renameSync } from 'fs'
|
|
7
|
+
import * as util from 'util'
|
|
8
|
+
import { mkdir, stat } from 'fs/promises'
|
|
9
|
+
import { DAY } from './const'
|
|
10
|
+
import events from './events'
|
|
11
|
+
import _ from 'lodash'
|
|
12
|
+
import { dirname } from 'path'
|
|
13
|
+
import { getCurrentUsername } from './perm'
|
|
14
|
+
|
|
15
|
+
class Logger {
|
|
16
|
+
stream?: Writable
|
|
17
|
+
last?: Date
|
|
18
|
+
path: string = ''
|
|
19
|
+
|
|
20
|
+
constructor(readonly name: string){
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async setPath(path: string) {
|
|
24
|
+
this.path = path
|
|
25
|
+
this.stream?.end()
|
|
26
|
+
this.last = undefined
|
|
27
|
+
if (!path)
|
|
28
|
+
return this.stream = undefined
|
|
29
|
+
try {
|
|
30
|
+
const stats = await stat(path)
|
|
31
|
+
this.last = stats.mtime || stats.ctime
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
await mkdir(dirname(path), { recursive: true })
|
|
35
|
+
.catch(() => console.log("cannot create folder for", path))
|
|
36
|
+
}
|
|
37
|
+
this.reopen()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
reopen() {
|
|
41
|
+
return this.stream = createWriteStream(this.path, { flags: 'a' })
|
|
42
|
+
.on('error', () => this.stream = undefined)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// we'll have names same as config keys. These are used also by the get_log api.
|
|
47
|
+
const accessLogger = new Logger('log')
|
|
48
|
+
const accessErrorLog = new Logger('error_log')
|
|
49
|
+
export const loggers = [accessLogger, accessErrorLog]
|
|
50
|
+
|
|
51
|
+
defineConfig('log', 'logs/access.log').sub(path => {
|
|
52
|
+
console.debug('access log file: ' + (path || 'disabled'))
|
|
53
|
+
accessLogger.setPath(path)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const errorLogFile = defineConfig(accessErrorLog.name, 'logs/access-error.log')
|
|
57
|
+
errorLogFile.sub(path => {
|
|
58
|
+
console.debug('access error log: ' + (path || 'disabled'))
|
|
59
|
+
accessErrorLog.setPath(path)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const logRotation = defineConfig('log_rotation', 'weekly')
|
|
63
|
+
|
|
64
|
+
export function log(): Koa.Middleware {
|
|
65
|
+
const debounce = _.debounce(cb => cb(), 1000)
|
|
66
|
+
return async (ctx, next) => { // wrapping in a function will make it use current 'mw' value
|
|
67
|
+
await next()
|
|
68
|
+
const isError = ctx.status >= 400
|
|
69
|
+
const logger = isError && accessErrorLog || accessLogger
|
|
70
|
+
const rotate = logRotation.get()?.[0]
|
|
71
|
+
let { stream, last, path } = logger
|
|
72
|
+
if (!stream) return
|
|
73
|
+
const now = new Date()
|
|
74
|
+
const a = now.toString().split(' ')
|
|
75
|
+
logger.last = now
|
|
76
|
+
if (rotate && last) { // rotation enabled and a file exists?
|
|
77
|
+
const passed = Number(now) - Number(last)
|
|
78
|
+
- 3600_000 // be pessimistic and count a possible DST change
|
|
79
|
+
if (rotate === 'm' && (passed >= 31*DAY || now.getMonth() !== last.getMonth())
|
|
80
|
+
|| rotate === 'd' && (passed >= DAY || now.getDate() !== last.getDate()) // checking passed will solve the case when the day of the month is the same but a month has passed
|
|
81
|
+
|| rotate === 'w' && (passed >= 7*DAY || now.getDay() < last.getDay())) {
|
|
82
|
+
stream.end()
|
|
83
|
+
const postfix = last.getFullYear() + '-' + doubleDigit(last.getMonth() + 1) + '-' + doubleDigit(last.getDate())
|
|
84
|
+
try { // other logging requests shouldn't happen while we are renaming. Since this is very infrequent we can tolerate solving this by making it sync.
|
|
85
|
+
renameSync(path, path + '-' + postfix)
|
|
86
|
+
}
|
|
87
|
+
catch(e) { // ok, rename failed, but this doesn't mean we ain't gonna log
|
|
88
|
+
console.error(e)
|
|
89
|
+
}
|
|
90
|
+
stream = logger.reopen() // keep variable updated
|
|
91
|
+
if (!stream) return
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const format = '%s - %s [%s] "%s %s HTTP/%s" %d %s\n' // Apache's Common Log Format
|
|
95
|
+
const date = a[2]+'/'+a[1]+'/'+a[3]+':'+a[4]+' '+a[5].slice(3)
|
|
96
|
+
const user = getCurrentUsername(ctx)
|
|
97
|
+
events.emit(logger.name, Object.assign(_.pick(ctx, ['ip', 'method','status','length']), { user, ts: now, uri: ctx.path }))
|
|
98
|
+
console.debug(ctx.status, ctx.method, ctx.path)
|
|
99
|
+
debounce(() => // once in a while we check if the file is still good (not deleted, etc), or we'll reopen it
|
|
100
|
+
stat(logger.path).catch(() => logger.reopen())) // async = smoother but we may lose some entries
|
|
101
|
+
stream.write(util.format( format,
|
|
102
|
+
ctx.ip,
|
|
103
|
+
user || '-',
|
|
104
|
+
date,
|
|
105
|
+
ctx.method,
|
|
106
|
+
ctx.path,
|
|
107
|
+
ctx.req.httpVersion,
|
|
108
|
+
ctx.status,
|
|
109
|
+
ctx.length ? ctx.length.toString() : '-',
|
|
110
|
+
))
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function doubleDigit(n: number) {
|
|
115
|
+
return n > 9 ? n : '0'+n
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// dump console.error to file
|
|
119
|
+
const debugLogFile = createWriteStream('debug.log', { flags: 'a' })
|
|
120
|
+
debugLogFile.on('open', () => {
|
|
121
|
+
const was = console.error
|
|
122
|
+
console.error = function(...args: any[]) {
|
|
123
|
+
was.apply(this, args)
|
|
124
|
+
const params = args.map(x =>
|
|
125
|
+
typeof x === 'string' ? x : JSON.stringify(x)).join(' ')
|
|
126
|
+
debugLogFile.write(new Date().toLocaleString() + ': ' + params + '\n')
|
|
127
|
+
}
|
|
128
|
+
}).on('error', () => console.log("cannot create debug.log"))
|
|
@@ -0,0 +1,176 @@
|
|
|
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 compress from 'koa-compress'
|
|
4
|
+
import Koa from 'koa'
|
|
5
|
+
import session from 'koa-session'
|
|
6
|
+
import { ADMIN_URI, BUILD_TIMESTAMP, DEV, FORBIDDEN, SESSION_DURATION } from './const'
|
|
7
|
+
import Application from 'koa'
|
|
8
|
+
import { FRONTEND_URI } from './const'
|
|
9
|
+
import { cantReadStatusCode, hasPermission, nodeIsDirectory, urlToNode } from './vfs'
|
|
10
|
+
import { dirTraversal, objSameKeys, tryJson } from './misc'
|
|
11
|
+
import { zipStreamFromFolder } from './zip'
|
|
12
|
+
import { serveFileNode } from './serveFile'
|
|
13
|
+
import { serveGuiFiles } from './serveGuiFiles'
|
|
14
|
+
import mount from 'koa-mount'
|
|
15
|
+
import { Readable } from 'stream'
|
|
16
|
+
import { applyBlock } from './block'
|
|
17
|
+
import { getAccount, getCurrentUsername } from './perm'
|
|
18
|
+
import { socket2connection, updateConnection, normalizeIp } from './connections'
|
|
19
|
+
import basicAuth from 'basic-auth'
|
|
20
|
+
import { SRPClientSession, SRPParameters, SRPRoutines } from 'tssrp6a'
|
|
21
|
+
import { srpStep1 } from './api.auth'
|
|
22
|
+
import { IncomingMessage } from 'http'
|
|
23
|
+
|
|
24
|
+
export const gzipper = compress({
|
|
25
|
+
threshold: 2048,
|
|
26
|
+
gzip: { flush: require('zlib').constants.Z_SYNC_FLUSH },
|
|
27
|
+
deflate: { flush: require('zlib').constants.Z_SYNC_FLUSH },
|
|
28
|
+
br: false, // disable brotli
|
|
29
|
+
filter(type) {
|
|
30
|
+
return /text|javascript|style/i.test(type)
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
export const headRequests: Koa.Middleware = async (ctx, next) => {
|
|
35
|
+
const head = ctx.method === 'HEAD'
|
|
36
|
+
if (head)
|
|
37
|
+
ctx.method = 'GET' // let other middlewares work, so we can collect the size at the end
|
|
38
|
+
await next()
|
|
39
|
+
if (!head || ctx.body === undefined) return
|
|
40
|
+
const { length, status } = ctx.response
|
|
41
|
+
if (ctx.body)
|
|
42
|
+
ctx.body = Readable.from('') // empty the body for this is a HEAD request. Using Readable avoids koa from trying to set length to 0
|
|
43
|
+
ctx.status = status
|
|
44
|
+
if (length)
|
|
45
|
+
ctx.response.length = length
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const sessions = (app: Application) => session({
|
|
49
|
+
key: 'hfs_$id',
|
|
50
|
+
signed: true,
|
|
51
|
+
rolling: true,
|
|
52
|
+
maxAge: SESSION_DURATION,
|
|
53
|
+
}, app)
|
|
54
|
+
|
|
55
|
+
const serveFrontendFiles = serveGuiFiles(process.env.FRONTEND_PROXY, FRONTEND_URI)
|
|
56
|
+
const serveFrontendPrefixed = mount(FRONTEND_URI.slice(0,-1), serveFrontendFiles)
|
|
57
|
+
const serveAdminPrefixed = mount(ADMIN_URI.slice(0,-1), serveGuiFiles(process.env.ADMIN_PROXY, ADMIN_URI))
|
|
58
|
+
|
|
59
|
+
export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => {
|
|
60
|
+
const { path } = ctx
|
|
61
|
+
if (ctx.body)
|
|
62
|
+
return next()
|
|
63
|
+
if (path.startsWith(FRONTEND_URI))
|
|
64
|
+
return serveFrontendPrefixed(ctx,next)
|
|
65
|
+
if (path+'/' === ADMIN_URI)
|
|
66
|
+
return ctx.redirect(ADMIN_URI)
|
|
67
|
+
if (path.startsWith(ADMIN_URI))
|
|
68
|
+
return serveAdminPrefixed(ctx,next)
|
|
69
|
+
const node = await urlToNode(path, ctx)
|
|
70
|
+
if (!node)
|
|
71
|
+
return ctx.status = 404
|
|
72
|
+
const canRead = hasPermission(node, 'can_read', ctx)
|
|
73
|
+
const isFolder = await nodeIsDirectory(node)
|
|
74
|
+
if (isFolder && !path.endsWith('/'))
|
|
75
|
+
return ctx.redirect(path + '/')
|
|
76
|
+
if (canRead && !isFolder)
|
|
77
|
+
return node.source ? serveFileNode(node)(ctx,next)
|
|
78
|
+
: next()
|
|
79
|
+
if (!canRead) {
|
|
80
|
+
ctx.status = cantReadStatusCode(node)
|
|
81
|
+
if (ctx.status === FORBIDDEN)
|
|
82
|
+
return
|
|
83
|
+
const browserDetected = ctx.get('Upgrade-Insecure-Requests') || ctx.get('Sec-Fetch-Mode') // ugh, heuristics
|
|
84
|
+
if (!browserDetected) // we don't want to trigger basic authentication on browsers, it's meant for download managers only
|
|
85
|
+
ctx.set('WWW-Authenticate', 'Basic') // we support basic authentication
|
|
86
|
+
ctx.state.serveApp = true
|
|
87
|
+
return serveFrontendFiles(ctx, next)
|
|
88
|
+
}
|
|
89
|
+
ctx.set({ server:'HFS '+BUILD_TIMESTAMP })
|
|
90
|
+
const { get } = ctx.query
|
|
91
|
+
if (get === 'zip')
|
|
92
|
+
return await zipStreamFromFolder(node, ctx)
|
|
93
|
+
if (node.default) {
|
|
94
|
+
const def = await urlToNode(path + node.default, ctx)
|
|
95
|
+
return !def ? next()
|
|
96
|
+
: hasPermission(def, 'can_read', ctx) ? serveFileNode(def)(ctx, next)
|
|
97
|
+
: ctx.status = cantReadStatusCode(def)
|
|
98
|
+
}
|
|
99
|
+
return serveFrontendFiles(ctx, next)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let proxyDetected = false
|
|
103
|
+
export const someSecurity: Koa.Middleware = async (ctx, next) => {
|
|
104
|
+
ctx.request.ip = normalizeIp(ctx.ip)
|
|
105
|
+
try {
|
|
106
|
+
let proxy = ctx.get('X-Forwarded-For')
|
|
107
|
+
// we have some dev-proxies to ignore
|
|
108
|
+
if (DEV && proxy && [process.env.FRONTEND_PROXY, process.env.ADMIN_PROXY].includes(ctx.get('X-Forwarded-port')))
|
|
109
|
+
proxy = ''
|
|
110
|
+
if (dirTraversal(decodeURI(ctx.path)))
|
|
111
|
+
return ctx.status = 418
|
|
112
|
+
if (applyBlock(ctx.socket, ctx.ip))
|
|
113
|
+
return
|
|
114
|
+
proxyDetected ||= proxy > ''
|
|
115
|
+
ctx.state.proxiedFor = proxy
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return ctx.status = 418
|
|
119
|
+
}
|
|
120
|
+
return next()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// this is only about http proxies
|
|
124
|
+
export function getProxyDetected() {
|
|
125
|
+
return proxyDetected
|
|
126
|
+
}
|
|
127
|
+
export const prepareState: Koa.Middleware = async (ctx, next) => {
|
|
128
|
+
// calculate these once and for all
|
|
129
|
+
ctx.state.account = await getHttpAccount(ctx) ?? getAccount(getCurrentUsername(ctx))
|
|
130
|
+
const conn = ctx.state.connection = socket2connection(ctx.socket)
|
|
131
|
+
await next()
|
|
132
|
+
if (conn)
|
|
133
|
+
updateConnection(conn, { ctx })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function getHttpAccount(ctx: Koa.Context) {
|
|
137
|
+
const credentials = basicAuth(ctx.req)
|
|
138
|
+
const account = getAccount(credentials?.name||'')
|
|
139
|
+
if (account && await srpCheck(account.username, credentials!.pass))
|
|
140
|
+
return account
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function srpCheck(username: string, password: string) {
|
|
144
|
+
username = username.toLocaleLowerCase()
|
|
145
|
+
const account = getAccount(username)
|
|
146
|
+
if (!account?.srp || !password) return false
|
|
147
|
+
const { step1, salt, pubKey } = await srpStep1(account)
|
|
148
|
+
const client = new SRPClientSession(new SRPRoutines(new SRPParameters()))
|
|
149
|
+
const clientRes1 = await client.step1(username, password)
|
|
150
|
+
const clientRes2 = await clientRes1.step2(BigInt(salt), BigInt(pubKey))
|
|
151
|
+
return await step1.step2(clientRes2.A, clientRes2.M1).then(() => true, () => false)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// unify get/post parameters, with JSON decoding to not be limited to strings
|
|
155
|
+
export const paramsDecoder: Koa.Middleware = async (ctx, next) => {
|
|
156
|
+
ctx.params = ctx.method === 'POST' ? tryJson(await getReqData(ctx.req))
|
|
157
|
+
: objSameKeys(ctx.query, x => Array.isArray(x) ? x : tryJson(x))
|
|
158
|
+
await next()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function getReqData(req: IncomingMessage): Promise<any> {
|
|
162
|
+
return new Promise((resolve, reject) => {
|
|
163
|
+
let data = ''
|
|
164
|
+
req.on('data', chunk =>
|
|
165
|
+
data += chunk)
|
|
166
|
+
req.on('error', reject)
|
|
167
|
+
req.on('end', () => {
|
|
168
|
+
try {
|
|
169
|
+
resolve(data)
|
|
170
|
+
}
|
|
171
|
+
catch(e) {
|
|
172
|
+
reject(e)
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
}
|
package/src/misc.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
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 { basename } from 'path'
|
|
5
|
+
import _ from 'lodash'
|
|
6
|
+
import Koa from 'koa'
|
|
7
|
+
import { Connection } from './connections'
|
|
8
|
+
import assert from 'assert'
|
|
9
|
+
export * from './util-http'
|
|
10
|
+
export * from './util-generators'
|
|
11
|
+
export * from './util-files'
|
|
12
|
+
import debounceAsync from './debounceAsync'
|
|
13
|
+
export { debounceAsync }
|
|
14
|
+
|
|
15
|
+
export type Callback<IN=void, OUT=void> = (x:IN) => OUT
|
|
16
|
+
export type Dict<T = any> = Record<string, T>
|
|
17
|
+
|
|
18
|
+
export function enforceFinal(sub:string, s:string) {
|
|
19
|
+
return s.endsWith(sub) ? s : s+sub
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function prefix(pre:string, v:string|number, post:string='') {
|
|
23
|
+
return v ? pre+v+post : ''
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function setHidden<T, ADD>(dest: T, src: ADD) {
|
|
27
|
+
return Object.defineProperties(dest, objSameKeys(src as any, value => ({
|
|
28
|
+
enumerable: false,
|
|
29
|
+
writable: true,
|
|
30
|
+
value,
|
|
31
|
+
}))) as T & ADD
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function objSameKeys<S extends object,VR=any>(src: S, newValue:(value:Truthy<S[keyof S]>, key:keyof S)=>any) {
|
|
35
|
+
return Object.fromEntries(Object.entries(src).map(([k,v]) => [k, newValue(v,k as keyof S)])) as { [K in keyof S]:VR }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function wait(ms: number) {
|
|
39
|
+
return new Promise(res=> setTimeout(res,ms))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function wantArray<T>(x?: void | T | T[]) {
|
|
43
|
+
return x == null ? [] : Array.isArray(x) ? x : [x]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getOrSet<T>(o: Record<string,T>, k:string, creator:()=>T): T {
|
|
47
|
+
return k in o ? o[k]
|
|
48
|
+
: (o[k] = creator())
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 10 chars is 51+bits, 8 is 41+bits
|
|
52
|
+
export function randomId(len = 10): string {
|
|
53
|
+
if (len > 10)
|
|
54
|
+
return randomId(10) + randomId(len - 10)
|
|
55
|
+
return Math.random()
|
|
56
|
+
.toString(36)
|
|
57
|
+
.substring(2, 2+len)
|
|
58
|
+
.replace(/l/g, 'L'); // avoid confusion reading l1
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type ProcessExitHandler = (signal:string) => any
|
|
62
|
+
const cbs = new Set<ProcessExitHandler>()
|
|
63
|
+
export function onProcessExit(cb: ProcessExitHandler) {
|
|
64
|
+
cbs.add(cb)
|
|
65
|
+
return () => cbs.delete(cb)
|
|
66
|
+
}
|
|
67
|
+
onFirstEvent(process, ['exit', 'SIGQUIT', 'SIGTERM', 'SIGINT', 'SIGHUP'], signal =>
|
|
68
|
+
Promise.allSettled(Array.from(cbs).map(cb => cb(signal))).then(() =>
|
|
69
|
+
process.exit(0)))
|
|
70
|
+
|
|
71
|
+
export function onFirstEvent(emitter:EventEmitter, events: string[], cb: (...args:any[])=> void) {
|
|
72
|
+
let already = false
|
|
73
|
+
for (const e of events)
|
|
74
|
+
emitter.on(e, (...args) => {
|
|
75
|
+
if (already) return
|
|
76
|
+
already = true
|
|
77
|
+
cb(...args)
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function pattern2filter(pattern: string){
|
|
82
|
+
const re = new RegExp(_.escapeRegExp(pattern), 'i')
|
|
83
|
+
return (s?:string) =>
|
|
84
|
+
!s || !pattern || re.test(basename(s))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
type Truthy<T> = T extends false | '' | 0 | null | undefined ? never : T
|
|
88
|
+
|
|
89
|
+
export function truthy<T>(value: T): value is Truthy<T> {
|
|
90
|
+
return Boolean(value)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function onlyTruthy<T>(arr: T[]) {
|
|
94
|
+
return arr.filter(truthy)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
type PendingPromise<T> = Promise<T> & { resolve: (value: T) => void, reject: (reason?: any) => void }
|
|
98
|
+
export function pendingPromise<T>() {
|
|
99
|
+
let takeOut
|
|
100
|
+
const ret = new Promise<T>((resolve, reject) =>
|
|
101
|
+
takeOut = { resolve, reject })
|
|
102
|
+
return Object.assign(ret, takeOut) as PendingPromise<T>
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// install multiple handlers and returns a handy 'uninstall' function which requires no parameter. Pass a map {event:handler}
|
|
106
|
+
export function onOff(em: EventEmitter, events: { [eventName:string]: (...args: any[]) => void }) {
|
|
107
|
+
events = { ...events } // avoid later modifications, as we need this later for uninstallation
|
|
108
|
+
for (const [k,cb] of Object.entries(events))
|
|
109
|
+
for (const e of k.split(' '))
|
|
110
|
+
em.on(e, cb)
|
|
111
|
+
return () => {
|
|
112
|
+
for (const [k,cb] of Object.entries(events))
|
|
113
|
+
for (const e of k.split(' '))
|
|
114
|
+
em.off(e, cb)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function objRenameKey(o: Dict | undefined, from: string, to: string) {
|
|
119
|
+
if (!o || !o.hasOwnProperty(from) || from === to) return
|
|
120
|
+
o[to] = o[from]
|
|
121
|
+
delete o[from]
|
|
122
|
+
return true
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function typedKeys<T extends {}>(o: T) {
|
|
126
|
+
return Object.keys(o) as (keyof T)[]
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function with_<T,RT>(par:T, cb: (par:T) => RT) {
|
|
130
|
+
return cb(par)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function isLocalHost(c: Connection | Koa.Context) {
|
|
134
|
+
const ip = c.socket.remoteAddress // don't use Context.ip as it is subject to proxied ips, and that's no use for localhost detection
|
|
135
|
+
return ip && (ip === '::1' || ip.endsWith('127.0.0.1'))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function same(a: any, b: any) {
|
|
139
|
+
try {
|
|
140
|
+
assert.deepStrictEqual(a, b)
|
|
141
|
+
return true
|
|
142
|
+
}
|
|
143
|
+
catch { return false }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function tryJson(s?: string) {
|
|
147
|
+
try { return s && JSON.parse(s) }
|
|
148
|
+
catch {}
|
|
149
|
+
}
|