admin/config: min. available disk space
Massimo Melina committed
Jan 25, 2023 at 00:06 UTC
c89e0d1471ca0b62a5c98fbc6c3c559d0a7816ff
10 files changed
+189
-91
admin/src/ConfigPage.ts
+5
-3
@@ -110,11 +110,13 @@ export default function ConfigPage() {
110
},
111
{ k: 'allowed_referer', placeholder: "any",
112
helperText: values.allowed_referer ? "Leave empty to allow any" : "Use this to avoid direct links from other websites", },
113
- { k: 'zip_calculate_size_for_seconds', comp: NumberField, sm: 6, md: 3, label: "Calculate ZIP size for", unit: "seconds",
114
- helperText: "If time is not enough, the browser will not show download percentage" },
113
{ k: 'delete_unfinished_uploads_after', comp: NumberField, sm: 6, md: 3, min : 0, unit: "seconds", placeholder: "Never",
114
helperText: "Leave empty to never delete" },
117
- { k: 'custom_header', multiline: true, sm: 12, md: 6, sx: { '& textarea': { fontFamily: 'monospace' } },
115
+ { k: 'min_available_mb', comp: NumberField, sm: 6, md: 3, min : 0, unit: "MBytes", placeholder: "None",
116
+ label: "Min. available disk space", helperText: "Reject uploads that don't comply" },
117
+ { k: 'zip_calculate_size_for_seconds', comp: NumberField, sm: 12, md: 6, label: "Calculate ZIP size for", unit: "seconds",
118
+ helperText: "If time is not enough, the browser will not show download percentage" },
119
+ { k: 'custom_header', multiline: true, sm: 12, sx: { '& textarea': { fontFamily: 'monospace' } },
120
helperText: "Any HTML code here will be used as header for the Frontend"
121
},
122
{ k: 'mime', comp: StringStringField,
frontend/src/upload.ts
+8
-4
@@ -224,7 +224,7 @@ async function startUpload(f: File, to: string, resume=0) {
224
const status = overrideStatus || req.status
225
if (status) // 0 = user-aborted
226
if (status >= 400)
227
- error()
227
+ error(status)
228
else
229
done()
230
if (!resuming)
@@ -271,9 +271,13 @@ async function startUpload(f: File, to: string, resume=0) {
271
}
272
}
273
274
- function error() {
275
- if (!uploadState.errors++)
276
- alertDialog("Upload error", 'error').then()
274
+ function error(status: number) {
275
+ if (uploadState.errors++) return
276
+ const ERRORS = {
277
+ 413: "file too large",
278
+ }
279
+ const specifier = (ERRORS as any)[status]
280
+ alertDialog("Upload error" + prefix(': ', specifier), 'error').then()
281
}
282
283
function done() {
package-lock.json
+42
@@ -20,6 +20,7 @@
20
"basic-auth": "^2.0.1",
21
"buffer-crc32": "^0.2.13",
22
"cidr-tools": "^4.3.0",
23
+ "diskusage": "^1.1.3",
24
"fast-glob": "^3.2.7",
25
"find-process": "^1.4.7",
26
"formidable": "^2.1.1",
@@ -3116,6 +3117,21 @@
3117
"node": ">=8"
3118
}
3119
},
3120
+ "node_modules/diskusage": {
3121
+ "version": "1.1.3",
3122
+ "resolved": "https://registry.npmjs.org/diskusage/-/diskusage-1.1.3.tgz",
3123
+ "integrity": "sha512-EAyaxl8hy4Ph07kzlzGTfpbZMNAAAHXSZtNEMwdlnSd1noHzvA6HsgKt4fEMSvaEXQYLSphe5rPMxN4WOj0hcQ==",
3124
+ "hasInstallScript": true,
3125
+ "dependencies": {
3126
+ "es6-promise": "^4.2.5",
3127
+ "nan": "^2.14.0"
3128
+ }
3129
+ },
3130
+ "node_modules/diskusage/node_modules/es6-promise": {
3131
+ "version": "4.2.8",
3132
+ "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
3133
+ "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w=="
3134
+ },
3135
"node_modules/dom-helpers": {
3136
"version": "5.2.1",
3137
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
@@ -4741,6 +4757,11 @@
4757
"node": ">= 6"
4758
}
4759
},
4760
+ "node_modules/nan": {
4761
+ "version": "2.17.0",
4762
+ "resolved": "https://registry.npmjs.org/nan/-/nan-2.17.0.tgz",
4763
+ "integrity": "sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ=="
4764
+ },
4765
"node_modules/nanoid": {
4766
"version": "3.3.1",
4767
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.1.tgz",
@@ -9302,6 +9323,22 @@
9323
"path-type": "^4.0.0"
9324
}
9325
},
9326
+ "diskusage": {
9327
+ "version": "1.1.3",
9328
+ "resolved": "https://registry.npmjs.org/diskusage/-/diskusage-1.1.3.tgz",
9329
+ "integrity": "sha512-EAyaxl8hy4Ph07kzlzGTfpbZMNAAAHXSZtNEMwdlnSd1noHzvA6HsgKt4fEMSvaEXQYLSphe5rPMxN4WOj0hcQ==",
9330
+ "requires": {
9331
+ "es6-promise": "^4.2.5",
9332
+ "nan": "^2.14.0"
9333
+ },
9334
+ "dependencies": {
9335
+ "es6-promise": {
9336
+ "version": "4.2.8",
9337
+ "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
9338
+ "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w=="
9339
+ }
9340
+ }
9341
+ },
9342
"dom-helpers": {
9343
"version": "5.2.1",
9344
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
@@ -10532,6 +10569,11 @@
10569
}
10570
}
10571
},
10572
+ "nan": {
10573
+ "version": "2.17.0",
10574
+ "resolved": "https://registry.npmjs.org/nan/-/nan-2.17.0.tgz",
10575
+ "integrity": "sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ=="
10576
+ },
10577
"nanoid": {
10578
"version": "3.3.1",
10579
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.1.tgz",
package.json
+4
-4
@@ -74,8 +74,8 @@
74
"minimist": "^1.2.6",
75
"open": "^8.4.0",
76
"tssrp6a": "^3.0.0",
77
- "yaml": "^2.0.0-10",
78
- "unzip-stream": "^0.3.1"
77
+ "unzip-stream": "^0.3.1",
78
+ "yaml": "^2.0.0-10"
79
},
80
"devDependencies": {
81
"@types/archiver": "^5.1.1",
@@ -92,16 +92,16 @@
92
"@types/minimist": "^1.2.2",
93
"@types/mocha": "^9.0.0",
94
"@types/node": "^16.11.12",
95
+ "@types/tough-cookie": "^4.0.2",
96
"@types/unzipper": "^0.10.5",
97
"axios": "^0.24.0",
98
"axios-cookiejar-support": "^4.0.1",
98
- "tough-cookie": "^4.0.0",
99
- "@types/tough-cookie": "^4.0.2",
99
"koa-better-http-proxy": "^0.2.9",
100
"mocha": "^9.1.3",
101
"nm-prune": "^5.0.0",
102
"nodemon": "^2.0.15",
103
"pkg": "^5.7.0",
104
+ "tough-cookie": "^4.0.0",
105
"ts-node": "^10.4.0"
106
}
107
}
shared/index.ts
+2
-2
@@ -31,8 +31,8 @@ export function prefix(pre:string, v:string|number|undefined|null|false, post:st
31
return v ? pre+v+post : ''
32
}
33
34
-export function wait(ms: number) {
35
- return new Promise(res=> setTimeout(res,ms))
34
+export function wait<T=undefined>(ms: number, val?: T): Promise<T> {
35
+ return new Promise(res=> setTimeout(res,ms,val))
36
}
37
38
export function getCookie(name: string) {
src/const.ts
+1
@@ -39,6 +39,7 @@ export const HTTP_NOT_FOUND = 404
39
export const HTTP_METHOD_NOT_ALLOWED = 405
40
export const HTTP_NOT_ACCEPTABLE = 406
41
export const HTTP_CONFLICT = 409
42
+export const HTTP_PAYLOAD_TOO_LARGE = 413
43
export const HTTP_RANGE_NOT_SATISFIABLE = 416
44
export const HTTP_FOOL = 418
45
export const HTTP_SERVER_ERROR = 500
src/middlewares.ts
+5
-77
@@ -8,11 +8,11 @@ import {
8
BUILD_TIMESTAMP,
9
DEV,
10
SESSION_DURATION,
11
- HTTP_FORBIDDEN, HTTP_UNAUTHORIZED, HTTP_NOT_FOUND, HTTP_RANGE_NOT_SATISFIABLE, HTTP_SERVER_ERROR,
11
+ HTTP_FORBIDDEN, HTTP_NOT_FOUND,
12
} from './const'
13
import { FRONTEND_URI } from './const'
14
-import { cantReadStatusCode, hasPermission, nodeIsDirectory, urlToNode, vfs, VfsNode } from './vfs'
15
-import { Callback, dirTraversal, try_ } from './misc'
14
+import { cantReadStatusCode, hasPermission, nodeIsDirectory, urlToNode, vfs } from './vfs'
15
+import { dirTraversal } from './misc'
16
import { zipStreamFromFolder } from './zip'
17
import { serveFileNode } from './serveFile'
18
import { serveGuiFiles } from './serveGuiFiles'
@@ -24,12 +24,10 @@ import { socket2connection, updateConnection, normalizeIp } from './connections'
24
import basicAuth from 'basic-auth'
25
import { SRPClientSession, SRPParameters, SRPRoutines } from 'tssrp6a'
26
import { srpStep1 } from './api.auth'
27
-import { basename, dirname, join } from 'path'
28
-import fs from 'fs'
27
+import { basename, dirname } from 'path'
28
import { pipeline } from 'stream/promises'
29
import formidable from 'formidable'
31
-import { notifyClient } from './frontEndApis'
32
-import { defineConfig } from './config'
30
+import { uploadWriter } from './upload'
31
32
export const gzipper = compress({
33
threshold: 2048,
@@ -131,78 +129,8 @@ export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => {
129
: ctx.status = cantReadStatusCode(def)
130
}
131
return serveFrontendFiles(ctx, next)
134
-
135
- function uploadWriter(base: VfsNode, path: string, ctx: Koa.Context) {
136
- if (!base.source || !hasPermission(base, 'can_upload', ctx))
137
- return fail(base.can_upload === false ? HTTP_FORBIDDEN : HTTP_UNAUTHORIZED)
138
- const fullPath = join(base.source, path)
139
- const dir = dirname(fullPath)
140
- fs.mkdirSync(dir, { recursive: true })
141
- const keepName = basename(fullPath).slice(-200)
142
- let tempName = join(dir, 'hfs$upload-' + keepName)
143
- const resumable = fs.existsSync(tempName) && tempName
144
- if (resumable)
145
- tempName = join(dir, 'hfs$upload2-' + keepName)
146
- const resume = Number(ctx.query.resume)
147
- const size = resumable && try_(() => fs.statSync(resumable).size)
148
- if (size === undefined) // stat failed
149
- return fail(HTTP_SERVER_ERROR)
150
- if (resume > size)
151
- return fail(HTTP_RANGE_NOT_SATISFIABLE)
152
- if (!resume && resumable) {
153
- const timeout = 30
154
- notifyClient(ctx, 'upload.resumable', { [path]: size, expires: Date.now() + timeout * 1000 })
155
- delayedDelete(resumable, timeout, () =>
156
- fs.rename(tempName, resumable, err => {
157
- if (!err)
158
- tempName = resumable
159
- }) )
160
- }
161
- const resuming = resume && resumable
162
- const ret = resuming ? fs.createWriteStream(resumable, { flags: 'r+', start: resume })
163
- : fs.createWriteStream(tempName)
164
- if (resuming) {
165
- fs.rm(tempName, () => {})
166
- tempName = resumable
167
- }
168
- cancelDeletion(tempName)
169
- ret.on('close', () => {
170
- if (!ctx.req.aborted)
171
- return fs.rename(tempName, fullPath, err => {
172
- err && console.error("couldn't rename temp to", fullPath, String(err))
173
- if (resumable)
174
- delayedDelete(resumable, 0)
175
- })
176
- const sec = deleteUnfinishedUploadsAfter.get()
177
- if (typeof sec !== 'number') return
178
- delayedDelete(tempName, sec)
179
- })
180
- return ret
181
-
182
- function delayedDelete(path: string, secs: number, cb?: Callback) {
183
- clearTimeout(waitingToBeDeleted[path])
184
- waitingToBeDeleted[path] = setTimeout(() => {
185
- delete waitingToBeDeleted[path]
186
- fs.rm(path, () => cb?.())
187
- }, secs * 1000)
188
- }
189
-
190
- function cancelDeletion(path: string) {
191
- clearTimeout(waitingToBeDeleted[path])
192
- delete waitingToBeDeleted[path]
193
- }
194
-
195
- function fail(status: number) {
196
- ctx.status = status
197
- notifyClient(ctx, 'upload.status', { [path]: ctx.status }) // allow browsers to detect failure while still sending body
198
- }
199
- }
200
-
132
}
133
203
-const deleteUnfinishedUploadsAfter = defineConfig('delete_unfinished_uploads_after')
204
-const waitingToBeDeleted: Record<string, ReturnType<typeof setTimeout>> = {}
205
-
134
let proxyDetected = false
135
export const someSecurity: Koa.Middleware = async (ctx, next) => {
136
ctx.request.ip = normalizeIp(ctx.ip)
src/upload.ts
new
+96
@@ -0,0 +1,96 @@
1
+import { hasPermission, VfsNode } from './vfs'
2
+import Koa from 'koa'
3
+import {
4
+ HTTP_FORBIDDEN,
5
+ HTTP_PAYLOAD_TOO_LARGE,
6
+ HTTP_RANGE_NOT_SATISFIABLE,
7
+ HTTP_SERVER_ERROR,
8
+ HTTP_UNAUTHORIZED
9
+} from './const'
10
+import { basename, dirname, join } from 'path'
11
+import fs from 'fs'
12
+import { Callback, try_ } from './misc'
13
+import { notifyClient } from './frontEndApis'
14
+import { defineConfig } from './config'
15
+import { getFreeDiskSync } from './util-os'
16
+
17
+export const deleteUnfinishedUploadsAfter = defineConfig('delete_unfinished_uploads_after')
18
+export const minAvailableMb = defineConfig('min_available_mb', 100)
19
+
20
+const waitingToBeDeleted: Record<string, ReturnType<typeof setTimeout>> = {}
21
+
22
+export function uploadWriter(base: VfsNode, path: string, ctx: Koa.Context) {
23
+ if (!base.source || !hasPermission(base, 'can_upload', ctx))
24
+ return fail(base.can_upload === false ? HTTP_FORBIDDEN : HTTP_UNAUTHORIZED)
25
+ const fullPath = join(base.source, path)
26
+ const dir = dirname(fullPath)
27
+ const min = minAvailableMb.get() * (1 << 20)
28
+ const reqSize = Number(ctx.headers["content-length"])
29
+ if (min && reqSize)
30
+ try {
31
+ if (reqSize > getFreeDiskSync(dir) - min)
32
+ return fail(HTTP_PAYLOAD_TOO_LARGE)
33
+ }
34
+ catch(e) {
35
+ console.warn("can't check disk size", String(e))
36
+ }
37
+ fs.mkdirSync(dir, { recursive: true })
38
+ const keepName = basename(fullPath).slice(-200)
39
+ let tempName = join(dir, 'hfs$upload-' + keepName)
40
+ const resumable = fs.existsSync(tempName) && tempName
41
+ if (resumable)
42
+ tempName = join(dir, 'hfs$upload2-' + keepName)
43
+ const resume = Number(ctx.query.resume)
44
+ const size = resumable && try_(() => fs.statSync(resumable).size)
45
+ if (size === undefined) // stat failed
46
+ return fail(HTTP_SERVER_ERROR)
47
+ if (resume > size)
48
+ return fail(HTTP_RANGE_NOT_SATISFIABLE)
49
+ if (!resume && resumable) {
50
+ const timeout = 30
51
+ notifyClient(ctx, 'upload.resumable', { [path]: size, expires: Date.now() + timeout * 1000 })
52
+ delayedDelete(resumable, timeout, () =>
53
+ fs.rename(tempName, resumable, err => {
54
+ if (!err)
55
+ tempName = resumable
56
+ }) )
57
+ }
58
+ const resuming = resume && resumable
59
+ const ret = resuming ? fs.createWriteStream(resumable, { flags: 'r+', start: resume })
60
+ : fs.createWriteStream(tempName)
61
+ if (resuming) {
62
+ fs.rm(tempName, () => {})
63
+ tempName = resumable
64
+ }
65
+ cancelDeletion(tempName)
66
+ ret.on('close', () => {
67
+ if (!ctx.req.aborted)
68
+ return fs.rename(tempName, fullPath, err => {
69
+ err && console.error("couldn't rename temp to", fullPath, String(err))
70
+ if (resumable)
71
+ delayedDelete(resumable, 0)
72
+ })
73
+ const sec = deleteUnfinishedUploadsAfter.get()
74
+ if (typeof sec !== 'number') return
75
+ delayedDelete(tempName, sec)
76
+ })
77
+ return ret
78
+
79
+ function delayedDelete(path: string, secs: number, cb?: Callback) {
80
+ clearTimeout(waitingToBeDeleted[path])
81
+ waitingToBeDeleted[path] = setTimeout(() => {
82
+ delete waitingToBeDeleted[path]
83
+ fs.rm(path, () => cb?.())
84
+ }, secs * 1000)
85
+ }
86
+
87
+ function cancelDeletion(path: string) {
88
+ clearTimeout(waitingToBeDeleted[path])
89
+ delete waitingToBeDeleted[path]
90
+ }
91
+
92
+ function fail(status: number) {
93
+ ctx.status = status
94
+ notifyClient(ctx, 'upload.status', { [path]: ctx.status }) // allow browsers to detect failure while still sending body
95
+ }
96
+}
src/util-os.ts
new
+26
@@ -0,0 +1,26 @@
1
+import os from 'os'
2
+import { resolve } from 'path'
3
+import { execSync } from 'child_process'
4
+import { try_ } from './misc'
5
+
6
+export function getFreeDiskSync(path: string) {
7
+ if (os.platform() === 'win32') {
8
+ const drive = resolve(path).slice(0, 2).toUpperCase()
9
+ const out = execSync('wmic logicaldisk get FreeSpace,name /format:list').toString().replace(/\r/g, '')
10
+ const one = out.split(/\n\n+/).find(x => x.includes('Name=' + drive))
11
+ if (!one)
12
+ throw Error('miss')
13
+ return Number(/FreeSpace=(\d+)/.exec(one)?.[1])
14
+ }
15
+ const out = try_(() => execSync(`df -k ${path}`).toString(),
16
+ err => {
17
+ throw err.status === 1 ? Error('miss')
18
+ : err.status === 127 ? Error('unsupported')
19
+ : err
20
+ })
21
+ if (!out?.startsWith('Filesystem'))
22
+ throw Error('unsupported')
23
+ const one = out.split('\n')[1]
24
+ const free = Number(one.split(/\s+/)[3])
25
+ return free * 1024
26
+}
\ No newline at end of file
todo.md
-1
@@ -1,5 +1,4 @@
1
# To do
2
-- upload: minimum available disk
2
- admin/fs: check if source exist when set
3
- plugins: after installing, switch to installed (and perhaps highlight new one)
4
- plugins' log, accessible in admin