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
|
@@ -0,0 +1,98 @@
|
|
|
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 { Transform, TransformCallback } from 'stream'
|
|
4
|
+
import { TokenBucket } from 'limiter'
|
|
5
|
+
|
|
6
|
+
// throttled stream
|
|
7
|
+
export class ThrottledStream extends Transform {
|
|
8
|
+
|
|
9
|
+
private sent: number = 0
|
|
10
|
+
private lastSpeed: number = 0
|
|
11
|
+
private lastSpeedTime = Date.now()
|
|
12
|
+
private totalSent: number = 0 // total sent over connection, since connection can be re-used for multiple requests
|
|
13
|
+
|
|
14
|
+
constructor(private group: ThrottleGroup, copyStats?: ThrottledStream) {
|
|
15
|
+
super()
|
|
16
|
+
if (!copyStats) return
|
|
17
|
+
this.sent = copyStats.sent
|
|
18
|
+
this.totalSent = copyStats.totalSent
|
|
19
|
+
this.lastSpeedTime = copyStats.lastSpeedTime
|
|
20
|
+
this.lastSpeed = copyStats.lastSpeed
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async _transform(chunk: any, encoding: BufferEncoding, done: TransformCallback) {
|
|
24
|
+
let pos = 0
|
|
25
|
+
while (1) {
|
|
26
|
+
let n = this.group.suggestChunkSize()
|
|
27
|
+
const slice = chunk.slice(pos, pos + n)
|
|
28
|
+
n = slice.length
|
|
29
|
+
if (!n) // we're done here
|
|
30
|
+
return done()
|
|
31
|
+
try {
|
|
32
|
+
await this.group.consume(n)
|
|
33
|
+
this.push(slice)
|
|
34
|
+
this.sent += n
|
|
35
|
+
this.totalSent += n
|
|
36
|
+
pos += n
|
|
37
|
+
this.emit('sent', n)
|
|
38
|
+
} catch (e) {
|
|
39
|
+
done(e as Error)
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// @return kBs
|
|
46
|
+
getSpeed(): number {
|
|
47
|
+
const now = Date.now()
|
|
48
|
+
const past = now - this.lastSpeedTime
|
|
49
|
+
if (past >= 1000) { // recalculate?
|
|
50
|
+
this.lastSpeedTime = now
|
|
51
|
+
this.lastSpeed = this.sent / past
|
|
52
|
+
this.sent = 0
|
|
53
|
+
}
|
|
54
|
+
return this.lastSpeed
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
getBytesSent() {
|
|
58
|
+
return this.totalSent
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class ThrottleGroup {
|
|
63
|
+
|
|
64
|
+
private bucket: TokenBucket
|
|
65
|
+
|
|
66
|
+
constructor(kBs: number, private parent?: ThrottleGroup) {
|
|
67
|
+
this.bucket = this.updateLimit(kBs) // assignment is redundant and yet the best way I've found to shut up typescript
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// @return kBs
|
|
71
|
+
getLimit() {
|
|
72
|
+
return this.bucket.bucketSize / 1000
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
updateLimit(kBs: number) {
|
|
76
|
+
if (kBs < 0)
|
|
77
|
+
throw new Error('invalid bytesPerSecond')
|
|
78
|
+
kBs *= 1000
|
|
79
|
+
return this.bucket = new TokenBucket({
|
|
80
|
+
bucketSize: kBs,
|
|
81
|
+
tokensPerInterval: kBs,
|
|
82
|
+
interval: 'second',
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
suggestChunkSize() {
|
|
87
|
+
let b: TokenBucket | undefined = this.bucket
|
|
88
|
+
b.parentBucket = this.parent?.bucket
|
|
89
|
+
let min = b.bucketSize
|
|
90
|
+
while (b = b.parentBucket)
|
|
91
|
+
min = Math.min(min, b.bucketSize)
|
|
92
|
+
return min / 10
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
consume(n: number) {
|
|
96
|
+
return this.bucket.removeTokens(n)
|
|
97
|
+
}
|
|
98
|
+
}
|
package/src/adminApis.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
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 { ApiError, ApiHandlers, SendListReadable } from './apiMiddleware'
|
|
4
|
+
import { defineConfig, getWholeConfig, setConfig } from './config'
|
|
5
|
+
import { getStatus, getUrls, httpsPortCfg, portCfg } from './listen'
|
|
6
|
+
import {
|
|
7
|
+
API_VERSION,
|
|
8
|
+
BUILD_TIMESTAMP,
|
|
9
|
+
COMPATIBLE_API_VERSION,
|
|
10
|
+
FORBIDDEN,
|
|
11
|
+
HFS_STARTED,
|
|
12
|
+
IS_WINDOWS,
|
|
13
|
+
UNAUTHORIZED,
|
|
14
|
+
VERSION
|
|
15
|
+
} from './const'
|
|
16
|
+
import vfsApis from './api.vfs'
|
|
17
|
+
import accountsApis from './api.accounts'
|
|
18
|
+
import pluginsApis from './api.plugins'
|
|
19
|
+
import monitorApis from './api.monitor'
|
|
20
|
+
import { getConnections } from './connections'
|
|
21
|
+
import { debounceAsync, isLocalHost, onOff, wait } from './misc'
|
|
22
|
+
import _ from 'lodash'
|
|
23
|
+
import events from './events'
|
|
24
|
+
import { getFromAccount } from './perm'
|
|
25
|
+
import Koa from 'koa'
|
|
26
|
+
import { getProxyDetected } from './middlewares'
|
|
27
|
+
import { writeFile } from 'fs/promises'
|
|
28
|
+
import { createReadStream } from 'fs'
|
|
29
|
+
import * as readline from 'readline'
|
|
30
|
+
import { loggers } from './log'
|
|
31
|
+
import { execFile } from 'child_process'
|
|
32
|
+
import { promisify } from 'util'
|
|
33
|
+
|
|
34
|
+
export const adminApis: ApiHandlers = {
|
|
35
|
+
|
|
36
|
+
...vfsApis,
|
|
37
|
+
...accountsApis,
|
|
38
|
+
...pluginsApis,
|
|
39
|
+
...monitorApis,
|
|
40
|
+
|
|
41
|
+
async set_config({ values: v }) {
|
|
42
|
+
if (v) {
|
|
43
|
+
const st = getStatus()
|
|
44
|
+
const noHttp = (v.port ?? portCfg.get()) < 0 || !st.httpSrv.listening
|
|
45
|
+
const noHttps = (v.https_port ?? httpsPortCfg.get()) < 0 || !st.httpsSrv.listening
|
|
46
|
+
if (noHttp && noHttps)
|
|
47
|
+
return new ApiError(FORBIDDEN, "You cannot switch off both http and https ports")
|
|
48
|
+
await setConfig(v)
|
|
49
|
+
}
|
|
50
|
+
return {}
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
get_config: getWholeConfig,
|
|
54
|
+
|
|
55
|
+
async get_status() {
|
|
56
|
+
const st = getStatus()
|
|
57
|
+
return {
|
|
58
|
+
started: HFS_STARTED,
|
|
59
|
+
build: BUILD_TIMESTAMP,
|
|
60
|
+
version: VERSION,
|
|
61
|
+
apiVersion: API_VERSION,
|
|
62
|
+
compatibleApiVersion: COMPATIBLE_API_VERSION,
|
|
63
|
+
http: await serverStatus(st.httpSrv, portCfg.get()),
|
|
64
|
+
https: await serverStatus(st.httpsSrv, httpsPortCfg.get()),
|
|
65
|
+
urls: getUrls(),
|
|
66
|
+
proxyDetected: getProxyDetected(),
|
|
67
|
+
frpDetected: localhostAdmin.get() && !getProxyDetected()
|
|
68
|
+
&& getConnections().every(isLocalHost)
|
|
69
|
+
&& await frpDebounced(),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function serverStatus(h: typeof st.httpSrv, configuredPort?: number) {
|
|
73
|
+
const busy = await h.busy
|
|
74
|
+
await wait(0) // simple trick to wait for also .error to be updated. If this trickery becomes necessary elsewhere, then we should make also error a Promise.
|
|
75
|
+
return {
|
|
76
|
+
..._.pick(h, ['listening', 'error']),
|
|
77
|
+
busy,
|
|
78
|
+
port: (h?.address() as any)?.port || configuredPort,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
async save_pem({ cert, private_key, name='self' }) {
|
|
84
|
+
if (!cert || !private_key)
|
|
85
|
+
return new ApiError(400)
|
|
86
|
+
const files = { cert: name + '.cert', private_key: name + '.key' }
|
|
87
|
+
await writeFile(files.private_key, private_key)
|
|
88
|
+
await writeFile(files.cert, cert)
|
|
89
|
+
return files
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
async get_log({ file='log' }, ctx) {
|
|
93
|
+
return new SendListReadable({
|
|
94
|
+
bufferTime: 10,
|
|
95
|
+
doAtStart(list) {
|
|
96
|
+
const logger = loggers.find(l => l.name === file)
|
|
97
|
+
if (!logger)
|
|
98
|
+
return list.error(404, true)
|
|
99
|
+
const input = createReadStream(logger.path)
|
|
100
|
+
input.on('error', async (e: any) => {
|
|
101
|
+
if (e.code === 'ENOENT') // ignore ENOENT, consider it an empty log
|
|
102
|
+
return list.ready()
|
|
103
|
+
list.error(e.code || e.message)
|
|
104
|
+
})
|
|
105
|
+
input.on('end', () =>
|
|
106
|
+
list.ready())
|
|
107
|
+
input.on('ready', () => {
|
|
108
|
+
readline.createInterface({ input }).on('line', line => {
|
|
109
|
+
if (ctx.aborted)
|
|
110
|
+
return input.close()
|
|
111
|
+
const obj = parse(line)
|
|
112
|
+
if (obj)
|
|
113
|
+
list.add(obj)
|
|
114
|
+
}).on('close', () => { // file is automatically closed, so we continue by events
|
|
115
|
+
ctx.res.once('close', onOff(events, { // unsubscribe when connection is interrupted
|
|
116
|
+
[logger.name](entry) {
|
|
117
|
+
list.add(entry)
|
|
118
|
+
}
|
|
119
|
+
}))
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
function parse(line: string) {
|
|
126
|
+
const m = /^(.+?) (.+?) (.+?) \[(.{11}):(.{14})] "(\w+) ([^"]+) HTTP\/\d.\d" (\d+) (-|\d+)/.exec(line)
|
|
127
|
+
if (!m) return
|
|
128
|
+
const [, ip, , user, date, time, method, uri, status, length] = m
|
|
129
|
+
return { // keep object format same as events emitted by the log module
|
|
130
|
+
ip,
|
|
131
|
+
user: user === '-' ? undefined : user,
|
|
132
|
+
ts: new Date(date + ' ' + time),
|
|
133
|
+
method,
|
|
134
|
+
uri,
|
|
135
|
+
status: Number(status),
|
|
136
|
+
length: length === '-' ? undefined : Number(length),
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
for (const k in adminApis) {
|
|
143
|
+
const was = adminApis[k]
|
|
144
|
+
adminApis[k] = (params, ctx) =>
|
|
145
|
+
ctxAdminAccess(ctx) ? was(params, ctx)
|
|
146
|
+
: new ApiError(UNAUTHORIZED)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export const localhostAdmin = defineConfig('localhost_admin', true)
|
|
150
|
+
|
|
151
|
+
export function ctxAdminAccess(ctx: Koa.Context) {
|
|
152
|
+
return !ctx.state.proxiedFor // we consider localhost_admin only if no proxy is detected
|
|
153
|
+
&& localhostAdmin.get() && isLocalHost(ctx)
|
|
154
|
+
|| getFromAccount(ctx.state.account, a => a.admin)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const frpDebounced = debounceAsync(async () => {
|
|
158
|
+
if (!IS_WINDOWS) return false
|
|
159
|
+
const { stdout } = await promisify(execFile)('tasklist', ['/fi','imagename eq frpc.exe','/nh'])
|
|
160
|
+
return stdout.includes('frpc')
|
|
161
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
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 { changePasswordHelper, changeSrpHelper } from './api.helpers'
|
|
4
|
+
import { ApiError, ApiHandlers } from './apiMiddleware'
|
|
5
|
+
import {
|
|
6
|
+
Account,
|
|
7
|
+
accountCanLoginAdmin,
|
|
8
|
+
accountHasPassword,
|
|
9
|
+
addAccount,
|
|
10
|
+
delAccount,
|
|
11
|
+
getAccount,
|
|
12
|
+
getAccounts,
|
|
13
|
+
getCurrentUsername,
|
|
14
|
+
setAccount
|
|
15
|
+
} from './perm'
|
|
16
|
+
import _ from 'lodash'
|
|
17
|
+
import { FORBIDDEN } from './const'
|
|
18
|
+
|
|
19
|
+
function prepareAccount(ac: Account | undefined) {
|
|
20
|
+
return ac && {
|
|
21
|
+
..._.omit(ac, ['password','hashed_password','srp']),
|
|
22
|
+
username: ac.username, // omit won't copy it because it's a hidden prop
|
|
23
|
+
hasPassword: accountHasPassword(ac),
|
|
24
|
+
adminActualAccess: accountCanLoginAdmin(ac),
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const apis: ApiHandlers = {
|
|
29
|
+
|
|
30
|
+
get_usernames() {
|
|
31
|
+
return { list: Object.keys(getAccounts()) }
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
get_account({ username }, ctx) {
|
|
35
|
+
return prepareAccount(getAccount(username || getCurrentUsername(ctx)))
|
|
36
|
+
|| new ApiError(404)
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
get_accounts() {
|
|
40
|
+
return { list: Object.values(getAccounts()).map(prepareAccount) }
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
get_admins() {
|
|
44
|
+
return { list: Object.values(getAccounts()).map(prepareAccount).filter(ac => ac?.adminActualAccess).map(ac => ac!.username) }
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
set_account({ username, changes }) {
|
|
48
|
+
const { admin } = changes
|
|
49
|
+
if (admin === null)
|
|
50
|
+
changes.admin = undefined
|
|
51
|
+
else if (admin !== undefined && typeof admin !== 'boolean')
|
|
52
|
+
return new ApiError(400, "invalid admin")
|
|
53
|
+
return setAccount(username, changes) ? {} : new ApiError(400)
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
add_account({ username, ...rest }) {
|
|
57
|
+
if (getAccount(username))
|
|
58
|
+
return new ApiError(FORBIDDEN)
|
|
59
|
+
if (!addAccount(username, rest))
|
|
60
|
+
return new ApiError(400)
|
|
61
|
+
return {}
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
del_account({ username }) {
|
|
65
|
+
return delAccount(username) ? {} : new ApiError(400)
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
async change_password_others({ username, newPassword }) {
|
|
69
|
+
return changePasswordHelper(getAccount(username), newPassword)
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
async change_srp_others({ username, salt, verifier }) {
|
|
73
|
+
return changeSrpHelper(getAccount(username), salt, verifier)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export default apis
|
package/src/api.auth.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
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 { Account, getAccount, getCurrentUsername } from './perm'
|
|
4
|
+
import { verifyPassword } from './crypt'
|
|
5
|
+
import { ApiError, ApiHandler } from './apiMiddleware'
|
|
6
|
+
import { SRPParameters, SRPRoutines, SRPServerSession, SRPServerSessionStep1 } from 'tssrp6a'
|
|
7
|
+
import { ADMIN_URI, SESSION_DURATION, UNAUTHORIZED } from './const'
|
|
8
|
+
import { randomId } from './misc'
|
|
9
|
+
import Koa from 'koa'
|
|
10
|
+
import { changeSrpHelper, changePasswordHelper } from './api.helpers'
|
|
11
|
+
import { ctxAdminAccess } from './adminApis'
|
|
12
|
+
import { prepareState } from './middlewares'
|
|
13
|
+
|
|
14
|
+
const srp6aNimbusRoutines = new SRPRoutines(new SRPParameters())
|
|
15
|
+
const ongoingLogins:Record<string,SRPServerSessionStep1> = {} // store data that doesn't fit session object
|
|
16
|
+
|
|
17
|
+
// centralized log-in state
|
|
18
|
+
async function loggedIn(ctx:Koa.Context, username: string | false) {
|
|
19
|
+
const s = ctx.session
|
|
20
|
+
if (!s)
|
|
21
|
+
return ctx.throw(500,'session')
|
|
22
|
+
if (username === false) {
|
|
23
|
+
delete s.username
|
|
24
|
+
ctx.cookies.set('csrf', '')
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
s.username = username
|
|
28
|
+
await prepareState(ctx, async ()=>{}) // updating the state is necessary to send complete session data so that frontend shows admin button
|
|
29
|
+
delete s.login
|
|
30
|
+
ctx.cookies.set('csrf', randomId(), { signed:false, httpOnly: false })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeExp() {
|
|
34
|
+
return { exp: new Date(Date.now() + SESSION_DURATION) }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const login: ApiHandler = async ({ username, password }, ctx) => {
|
|
38
|
+
if (!username || !password) // some validation
|
|
39
|
+
return new ApiError(400)
|
|
40
|
+
username = username.toLocaleLowerCase() // normalize username, to be case-insensitive
|
|
41
|
+
const acc = getAccount(username)
|
|
42
|
+
if (!acc)
|
|
43
|
+
return new ApiError(UNAUTHORIZED)
|
|
44
|
+
if (!acc.hashed_password)
|
|
45
|
+
return new ApiError(406)
|
|
46
|
+
if (!await verifyPassword(acc.hashed_password, password))
|
|
47
|
+
return new ApiError(UNAUTHORIZED)
|
|
48
|
+
if (!ctx.session)
|
|
49
|
+
return new ApiError(500)
|
|
50
|
+
await loggedIn(ctx, username)
|
|
51
|
+
return { ...makeExp(), redirect: acc.redirect }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const loginSrp1: ApiHandler = async ({ username }, ctx) => {
|
|
55
|
+
if (!username)
|
|
56
|
+
return new ApiError(400)
|
|
57
|
+
username = username.toLocaleLowerCase()
|
|
58
|
+
const account = getAccount(username)
|
|
59
|
+
if (!ctx.session)
|
|
60
|
+
return new ApiError(500)
|
|
61
|
+
if (!account) // TODO simulate fake account to prevent knowing valid usernames
|
|
62
|
+
return new ApiError(UNAUTHORIZED)
|
|
63
|
+
try {
|
|
64
|
+
const { step1, ...rest } = await srpStep1(account)
|
|
65
|
+
const sid = Math.random()
|
|
66
|
+
ongoingLogins[sid] = step1
|
|
67
|
+
setTimeout(()=> delete ongoingLogins[sid], 60_000)
|
|
68
|
+
ctx.session.login = { username, sid }
|
|
69
|
+
return rest
|
|
70
|
+
}
|
|
71
|
+
catch (code: any) {
|
|
72
|
+
return new ApiError(code)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function srpStep1(account: Account) {
|
|
77
|
+
if (!account.srp)
|
|
78
|
+
throw 406 // unacceptable
|
|
79
|
+
const [salt, verifier] = account.srp.split('|')
|
|
80
|
+
const srpSession = new SRPServerSession(srp6aNimbusRoutines)
|
|
81
|
+
const step1 = await srpSession.step1(account.username, BigInt(salt), BigInt(verifier))
|
|
82
|
+
return { step1, salt, pubKey: String(step1.B) } // cast to string cause bigint can't be jsonized
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const loginSrp2: ApiHandler = async ({ pubKey, proof }, ctx) => {
|
|
86
|
+
if (!ctx.session)
|
|
87
|
+
return new ApiError(500)
|
|
88
|
+
if (!ctx.session.login)
|
|
89
|
+
return new ApiError(409)
|
|
90
|
+
const { username, sid } = ctx.session.login
|
|
91
|
+
const step1 = ongoingLogins[sid]
|
|
92
|
+
try {
|
|
93
|
+
const M2 = await step1.step2(BigInt(pubKey), BigInt(proof))
|
|
94
|
+
await loggedIn(ctx, username)
|
|
95
|
+
return {
|
|
96
|
+
proof: String(M2),
|
|
97
|
+
redirect: ctx.state.account?.redirect,
|
|
98
|
+
...await refresh_session({},ctx)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch(e) {
|
|
102
|
+
return new ApiError(UNAUTHORIZED, String(e))
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
delete ongoingLogins[sid]
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const logout: ApiHandler = async ({}, ctx) => {
|
|
110
|
+
if (!ctx.session)
|
|
111
|
+
return new ApiError(500)
|
|
112
|
+
loggedIn(ctx, false)
|
|
113
|
+
// 401 is a convenient code for OK: the browser clears a possible http authentication (hopefully), and Admin automatically triggers login dialog
|
|
114
|
+
return new ApiError(401)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const refresh_session: ApiHandler = async ({}, ctx) => {
|
|
118
|
+
return !ctx.session ? new ApiError(500) : {
|
|
119
|
+
username: getCurrentUsername(ctx),
|
|
120
|
+
adminUrl: ctxAdminAccess(ctx) ? ADMIN_URI : undefined,
|
|
121
|
+
...makeExp(),
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const change_password: ApiHandler = async ({ newPassword }, ctx) => {
|
|
126
|
+
return changePasswordHelper(ctx.state.account, newPassword)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export const change_srp: ApiHandler = async ({ salt, verifier }, ctx) => {
|
|
130
|
+
return changeSrpHelper(ctx.state.account, salt, verifier)
|
|
131
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
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 { cantReadStatusCode, getNodeName, hasPermission, nodeIsDirectory, urlToNode, VfsNode, walkNode } from './vfs'
|
|
4
|
+
import { ApiError, ApiHandler, SendListReadable } from './apiMiddleware'
|
|
5
|
+
import { stat } from 'fs/promises'
|
|
6
|
+
import { mapPlugins } from './plugins'
|
|
7
|
+
import { asyncGeneratorToArray, dirTraversal, pattern2filter } from './misc'
|
|
8
|
+
import _ from 'lodash'
|
|
9
|
+
|
|
10
|
+
export const file_list: ApiHandler = async ({ path, offset, limit, search, omit, sse }, ctx) => {
|
|
11
|
+
let node = await urlToNode(path || '/', ctx)
|
|
12
|
+
const list = new SendListReadable()
|
|
13
|
+
if (!node)
|
|
14
|
+
return fail(404)
|
|
15
|
+
if (!hasPermission(node,'can_read',ctx))
|
|
16
|
+
return fail(cantReadStatusCode(node))
|
|
17
|
+
if (dirTraversal(search))
|
|
18
|
+
return fail(418)
|
|
19
|
+
if (node.default)
|
|
20
|
+
return (sse ? list.custom : _.identity)({ redirect: path }) // sse will wrap the object in a 'custom' message, otherwise we plainly return the object
|
|
21
|
+
if (!await nodeIsDirectory(node))
|
|
22
|
+
return fail(405) // method not allowed on target
|
|
23
|
+
offset = Number(offset)
|
|
24
|
+
limit = Number(limit)
|
|
25
|
+
const filter = pattern2filter(search)
|
|
26
|
+
const walker = walkNode(node, ctx, search ? Infinity : 0)
|
|
27
|
+
const onDirEntryHandlers = mapPlugins(plug => plug.onDirEntry)
|
|
28
|
+
if (!sse)
|
|
29
|
+
return { list: await asyncGeneratorToArray(produceEntries()) }
|
|
30
|
+
setTimeout(async () => {
|
|
31
|
+
for await (const entry of produceEntries())
|
|
32
|
+
list.add(entry)
|
|
33
|
+
list.close()
|
|
34
|
+
})
|
|
35
|
+
return list
|
|
36
|
+
|
|
37
|
+
function fail(code: any) {
|
|
38
|
+
if (!sse)
|
|
39
|
+
return new ApiError(code)
|
|
40
|
+
list.error(code)
|
|
41
|
+
list.close()
|
|
42
|
+
return list
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function* produceEntries() {
|
|
46
|
+
for await (const sub of walker) {
|
|
47
|
+
if (ctx.aborted) break
|
|
48
|
+
if (!filter(getNodeName(sub)))
|
|
49
|
+
continue
|
|
50
|
+
const entry = await nodeToDirEntry(sub)
|
|
51
|
+
if (!entry)
|
|
52
|
+
continue
|
|
53
|
+
const cbParams = { entry, ctx, listPath:path, node:sub }
|
|
54
|
+
try {
|
|
55
|
+
if (onDirEntryHandlers.some(cb => cb(cbParams) === false))
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
catch(e) {
|
|
59
|
+
console.log("a plugin with onDirEntry is causing problems:", e)
|
|
60
|
+
}
|
|
61
|
+
if (offset) {
|
|
62
|
+
--offset
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
if (omit) {
|
|
66
|
+
if (omit !== 'c')
|
|
67
|
+
ctx.throw(400, 'omit')
|
|
68
|
+
if (!entry.m)
|
|
69
|
+
entry.m = entry.c
|
|
70
|
+
delete entry.c
|
|
71
|
+
}
|
|
72
|
+
yield entry
|
|
73
|
+
if (limit && !--limit)
|
|
74
|
+
break
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface DirEntry { n:string, s?:number, m?:Date, c?:Date }
|
|
80
|
+
|
|
81
|
+
async function nodeToDirEntry(node: VfsNode): Promise<DirEntry | null> {
|
|
82
|
+
let { source, default:def } = node
|
|
83
|
+
const name = getNodeName(node)
|
|
84
|
+
if (!source)
|
|
85
|
+
return name ? { n: name + '/' } : null
|
|
86
|
+
if (def)
|
|
87
|
+
return { n: name }
|
|
88
|
+
try {
|
|
89
|
+
const st = await stat(source)
|
|
90
|
+
const folder = st.isDirectory()
|
|
91
|
+
const { ctime, mtime } = st
|
|
92
|
+
return {
|
|
93
|
+
n: name + (folder ? '/' : ''),
|
|
94
|
+
c: ctime,
|
|
95
|
+
m: Math.abs(+mtime-+ctime) < 1000 ? undefined : mtime,
|
|
96
|
+
s: folder ? undefined : st.size,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
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 { Account, allowClearTextLogin, saveSrpInfo, updateAccount } from './perm'
|
|
4
|
+
import { ApiError } from './apiMiddleware'
|
|
5
|
+
import { UNAUTHORIZED } from './const'
|
|
6
|
+
|
|
7
|
+
export async function changePasswordHelper(account: Account | undefined, newPassword: string) {
|
|
8
|
+
if (!newPassword) // clear text version
|
|
9
|
+
return Error('missing parameters')
|
|
10
|
+
if (!account)
|
|
11
|
+
return new ApiError(UNAUTHORIZED)
|
|
12
|
+
await updateAccount(account, account => {
|
|
13
|
+
account.password = newPassword
|
|
14
|
+
})
|
|
15
|
+
return {}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function changeSrpHelper(account: Account | undefined, salt: string, verifier: string) {
|
|
19
|
+
if (allowClearTextLogin.get())
|
|
20
|
+
return new ApiError(406)
|
|
21
|
+
if (!salt || !verifier)
|
|
22
|
+
return Error('missing parameters')
|
|
23
|
+
if (!account)
|
|
24
|
+
return new ApiError(UNAUTHORIZED)
|
|
25
|
+
await updateAccount(account, account => {
|
|
26
|
+
saveSrpInfo(account, salt, verifier)
|
|
27
|
+
delete account.hashed_password // remove leftovers
|
|
28
|
+
})
|
|
29
|
+
return {}
|
|
30
|
+
}
|