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