better code: split files
Massimo Melina committed
Jan 5, 2024 at 18:55 UTC
288ad20ed639327e5f07dfe7556c62ff1430c1e9
6 files changed
+149
-139
src/api.get_file_list.ts
+5
@@ -140,4 +140,9 @@ export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, search
140
|| n.children?.some(c => c.can_read || filesInsideCould(c)) // we count on the boolean-compliant nature of the permission type here
141
}
142
}
143
+}
144
+declare module "koa" {
145
+ interface DefaultState {
146
+ browsing?: string // for admin/monitoring
147
+ }
148
}
\ No newline at end of file
src/errorPages.ts
+1
@@ -9,6 +9,7 @@ export function getErrorSections() {
9
return declaredErrorPages
10
}
11
12
+// to be used with errors whose recipient is possibly human
13
export async function sendErrorPage(ctx: Koa.Context, code: number) {
14
ctx.type = 'text'
15
ctx.set('content-disposition', '') // reset ctx.attachment
src/index.ts
+2
-2
@@ -10,8 +10,8 @@ import { frontEndApis } from './frontEndApis'
10
import { logMw } from './log'
11
import { pluginsMiddleware } from './plugins'
12
import { throttler } from './throttler'
13
-import { headRequests, gzipper, serveGuiAndSharedFiles, someSecurity, prepareState, paramsDecoder, sessionMiddleware
14
-} from './middlewares'
13
+import { headRequests, gzipper, someSecurity, prepareState, paramsDecoder, sessionMiddleware } from './middlewares'
14
+import { serveGuiAndSharedFiles } from './serveGuiAndSharedFiles'
15
import './listen'
16
import './commands'
17
import { adminApis } from './adminApis'
src/middlewares.ts
+11
-136
@@ -2,32 +2,17 @@
2
3
import compress from 'koa-compress'
4
import Koa from 'koa'
5
-import { ADMIN_URI, API_URI, BUILD_TIMESTAMP, DEV, VERSION,
6
- HTTP_FORBIDDEN, HTTP_NOT_FOUND, HTTP_FOOL, HTTP_UNAUTHORIZED, HTTP_BAD_REQUEST, HTTP_METHOD_NOT_ALLOWED,
7
-} from './const'
8
-import { FRONTEND_URI } from './const'
9
-import { statusCodeForMissingPerm, nodeIsDirectory, urlToNode, vfs, walkNode, VfsNode, getNodeName } from './vfs'
10
-import { DAY, asyncGeneratorToReadable, dirTraversal, filterMapGenerator, isLocalHost, stream2string, tryJson, splitAt
11
-} from './misc'
12
-import { zipStreamFromFolder } from './zip'
13
-import { serveFile, serveFileNode } from './serveFile'
14
-import { serveGuiFiles } from './serveGuiFiles'
15
-import mount from 'koa-mount'
16
-import { Readable, Writable } from 'stream'
5
+import { API_URI, DEV, HTTP_FOOL } from './const'
6
+import { DAY, dirTraversal, isLocalHost, splitAt, stream2string, tryJson } from './misc'
7
+import { Readable } from 'stream'
8
import { applyBlock } from './block'
9
import { Account, accountCanLogin, getAccount } from './perm'
19
-import { socket2connection, normalizeIp, disconnect, Connection, updateConnectionForCtx } from './connections'
10
+import { Connection, disconnect, normalizeIp, socket2connection, updateConnectionForCtx } from './connections'
11
import basicAuth from 'basic-auth'
12
import { invalidSessions, srpCheck } from './auth'
22
-import { basename, dirname } from 'path'
23
-import { pipeline } from 'stream/promises'
24
-import formidable from 'formidable'
25
-import { uploadWriter } from './upload'
26
-import { allowAdmin, favicon } from './adminApis'
13
import { constants } from 'zlib'
14
import { baseUrl, getHttpsWorkingPort } from './listen'
15
import { defineConfig } from './config'
30
-import { sendErrorPage } from './errorPages'
16
import session from 'koa-session'
17
import { app } from './index'
18
import events from './events'
@@ -62,119 +47,6 @@ export const headRequests: Koa.Middleware = async (ctx, next) => {
47
ctx.response.length = length
48
}
49
65
-const serveFrontendFiles = serveGuiFiles(process.env.FRONTEND_PROXY, FRONTEND_URI)
66
-const serveFrontendPrefixed = mount(FRONTEND_URI.slice(0,-1), serveFrontendFiles)
67
-const serveAdminFiles = serveGuiFiles(process.env.ADMIN_PROXY, ADMIN_URI)
68
-const serveAdminPrefixed = mount(ADMIN_URI.slice(0,-1), serveAdminFiles)
69
-
70
-export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => {
71
- const { path } = ctx
72
- // dynamic import on frontend|admin (used for non-https login) while developing (vite4) is not producing a relative path
73
- if (DEV && path.startsWith('/node_modules/')) {
74
- let { referer } = ctx.headers
75
- referer &&= new URL(referer).pathname
76
- return referer?.startsWith(ADMIN_URI) ? serveAdminFiles(ctx, next)
77
- : serveFrontendFiles(ctx, next)
78
- }
79
- if (ctx.body)
80
- return next()
81
- if (!ctx.secure && forceHttps.get() && getHttpsWorkingPort() && !isLocalHost(ctx)) {
82
- const { URL } = ctx
83
- URL.protocol = 'https'
84
- URL.port = getHttpsWorkingPort()
85
- ctx.status = 307 // this ensures the client doesn't switch to a simpler GET request
86
- return ctx.redirect(URL.href)
87
- }
88
-
89
- if (path.startsWith(FRONTEND_URI))
90
- return serveFrontendPrefixed(ctx,next)
91
- if (path.length === ADMIN_URI.length - 1 && ADMIN_URI.startsWith(path))
92
- return ctx.redirect(ctx.state.revProxyPath + ADMIN_URI)
93
- if (path.startsWith(ADMIN_URI))
94
- return allowAdmin(ctx) ? serveAdminPrefixed(ctx,next)
95
- : sendErrorPage(ctx, HTTP_FORBIDDEN)
96
- if (ctx.method === 'PUT') { // curl -T file url/
97
- const decPath = decodeURIComponent(path)
98
- let rest = basename(decPath)
99
- const folder = await urlToNode(dirname(decPath), ctx, vfs, v => rest = v+'/'+rest)
100
- if (!folder)
101
- return sendErrorPage(ctx, HTTP_NOT_FOUND)
102
- ctx.state.uploadPath = decPath
103
- const dest = uploadWriter(folder, rest, ctx)
104
- if (dest) {
105
- await pipeline(ctx.req, dest)
106
- ctx.body = {}
107
- }
108
- return
109
- }
110
- if (ctx.originalUrl === '/favicon.ico' && favicon.get()) // originalUrl to not be subject to changes (vhosting plugin)
111
- return serveFile(ctx, favicon.get())
112
- let node = await urlToNode(path, ctx)
113
- if (!node)
114
- return sendErrorPage(ctx, HTTP_NOT_FOUND)
115
- if (ctx.method === 'POST') { // curl -F upload=@file url/
116
- if (ctx.request.type !== 'multipart/form-data')
117
- return ctx.status = HTTP_BAD_REQUEST
118
- ctx.body = {}
119
- ctx.state.uploads = []
120
- const form = formidable({
121
- maxFileSize: Infinity,
122
- allowEmptyFiles: true,
123
- fileWriteStreamHandler: f => {
124
- const fn = (f as any).originalFilename
125
- ctx.state.uploadPath = decodeURI(ctx.path) + fn
126
- ctx.state.uploads!.push(fn)
127
- return uploadWriter(node!, fn, ctx) || new Writable()
128
- }
129
- })
130
- return new Promise<void>(res => form.parse(ctx.req, err => {
131
- if (err) console.error(String(err))
132
- res()
133
- }))
134
- }
135
- if (node.default && path.endsWith('/')) // final/ needed on browser to make resource urls correctly with html pages
136
- node = await urlToNode(node.default, ctx, node) ?? node
137
- if (!await nodeIsDirectory(node))
138
- return !node.source ? sendErrorPage(ctx, HTTP_METHOD_NOT_ALLOWED)
139
- : !statusCodeForMissingPerm(node, 'can_read', ctx) ? serveFileNode(ctx, node)
140
- : ctx.status !== HTTP_UNAUTHORIZED ? null
141
- : !path.endsWith('/') ? ctx.set('WWW-Authenticate', 'Basic') // this is necessary to support standard urls with credentials. Final / means we are dealing with default file...
142
- : (ctx.state.serveApp = true) && serveFrontendFiles(ctx, next) // ...for which we still provide fancy login
143
- if (!path.endsWith('/'))
144
- return ctx.redirect(ctx.state.revProxyPath + ctx.originalUrl.replace(/(\?|$)/, '/$1')) // keep query-string, if any
145
- if (statusCodeForMissingPerm(node, 'can_list', ctx)) {
146
- if (ctx.status === HTTP_FORBIDDEN)
147
- return sendErrorPage(ctx, HTTP_FORBIDDEN)
148
- const browserDetected = ctx.get('Upgrade-Insecure-Requests') || ctx.get('Sec-Fetch-Mode') // ugh, heuristics
149
- if (!browserDetected) // we don't want to trigger basic authentication on browsers, it's meant for download managers only
150
- return ctx.set('WWW-Authenticate', 'Basic') // we support basic authentication
151
- ctx.state.serveApp = true
152
- return serveFrontendFiles(ctx, next)
153
- }
154
- ctx.set({ server: `HFS ${VERSION} ${BUILD_TIMESTAMP}` })
155
- return ctx.query.get === 'zip' ? zipStreamFromFolder(node, ctx)
156
- : ctx.query.get === 'list' ? sendFolderList(node, ctx)
157
- : serveFrontendFiles(ctx, next)
158
-}
159
-
160
-// to be used with errors whose recipient is possibly human
161
-async function sendFolderList(node: VfsNode, ctx: Koa.Context) {
162
- let { depth=0, folders, prepend } = ctx.query
163
- ctx.type = 'text'
164
- if (prepend === undefined || prepend === '*') { // * = force auto-detection even if we have baseUrl set
165
- const { URL } = ctx
166
- const base = prepend === undefined && baseUrl.get()
167
- || URL.protocol + '//' + URL.host + ctx.state.revProxyPath
168
- prepend = base + ctx.originalUrl.split('?')[0]! as string
169
- }
170
- const walker = walkNode(node, { ctx, depth: depth === '*' ? Infinity : Number(depth) })
171
- ctx.body = asyncGeneratorToReadable(filterMapGenerator(walker, async el => {
172
- const isFolder = await nodeIsDirectory(el)
173
- return !folders && isFolder ? undefined
174
- : prepend + getNodeName(el) + (isFolder ? '/' : '') + '\n'
175
- }))
176
-}
177
-
50
let proxyDetected: undefined | Koa.Context
51
export const someSecurity: Koa.Middleware = async (ctx, next) => {
52
ctx.request.ip = normalizeIp(ctx.ip)
@@ -206,6 +78,13 @@ export const someSecurity: Koa.Middleware = async (ctx, next) => {
78
}
79
if (!ctx.state.skipFilters && forceBaseUrl.get() && !isLocalHost(ctx) && ctx.host !== baseUrl.compiled())
80
return disconnect(ctx)
81
+ if (!ctx.secure && forceHttps.get() && getHttpsWorkingPort() && !isLocalHost(ctx)) {
82
+ const { URL } = ctx
83
+ URL.protocol = 'https'
84
+ URL.port = getHttpsWorkingPort()
85
+ ctx.status = 307 // this ensures the client doesn't switch to a simpler GET request
86
+ return ctx.redirect(URL.href)
87
+ }
88
return next()
89
}
90
@@ -256,10 +135,6 @@ declare module "koa" {
135
account?: Account // user logged in
136
revProxyPath: string
137
connection: Connection
259
- serveApp?: boolean // please, serve the frontend app
260
- browsing?: string // for admin/monitoring
261
- uploadPath?: string // current one
262
- uploads?: string[] // in case of request with potentially multiple uploads (POST), we register all filenames (no full path)
138
}
139
}
140
export const paramsDecoder: Koa.Middleware = async (ctx, next) => {
src/plugins.ts
+2
-1
@@ -132,7 +132,8 @@ export const pluginsMiddleware: Koa.Middleware = async (ctx, next) => {
132
await serveFile(ctx, plugins[name]!.folder + '/public/' + a.join('/'), MIME_AUTO)
133
return
134
}
135
- await next()
135
+ if (!ctx.body)
136
+ await next()
137
}
138
for (const [id,f] of Object.entries(after))
139
try { await f() }
src/serveGuiAndSharedFiles.ts
new
+128
@@ -0,0 +1,128 @@
1
+import Koa from 'koa'
2
+import { basename, dirname } from 'path'
3
+import { getNodeName, nodeIsDirectory, statusCodeForMissingPerm, urlToNode, vfs, VfsNode, walkNode } from './vfs'
4
+import { sendErrorPage } from './errorPages'
5
+import { ADMIN_URI, FRONTEND_URI, HTTP_BAD_REQUEST, HTTP_FORBIDDEN, HTTP_METHOD_NOT_ALLOWED, HTTP_NOT_FOUND,
6
+ HTTP_UNAUTHORIZED } from './cross-const'
7
+import { uploadWriter } from './upload'
8
+import { pipeline } from 'stream/promises'
9
+import formidable from 'formidable'
10
+import { Writable } from 'stream'
11
+import { serveFile, serveFileNode } from './serveFile'
12
+import { BUILD_TIMESTAMP, DEV, VERSION } from './const'
13
+import { zipStreamFromFolder } from './zip'
14
+import { allowAdmin, favicon } from './adminApis'
15
+import { serveGuiFiles } from './serveGuiFiles'
16
+import mount from 'koa-mount'
17
+import { baseUrl } from './listen'
18
+import { asyncGeneratorToReadable, filterMapGenerator } from './misc'
19
+
20
+const serveFrontendFiles = serveGuiFiles(process.env.FRONTEND_PROXY, FRONTEND_URI)
21
+const serveFrontendPrefixed = mount(FRONTEND_URI.slice(0,-1), serveFrontendFiles)
22
+const serveAdminFiles = serveGuiFiles(process.env.ADMIN_PROXY, ADMIN_URI)
23
+const serveAdminPrefixed = mount(ADMIN_URI.slice(0,-1), serveAdminFiles)
24
+
25
+export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => {
26
+ const { path } = ctx
27
+ // dynamic import on frontend|admin (used for non-https login) while developing (vite4) is not producing a relative path
28
+ if (DEV && path.startsWith('/node_modules/')) {
29
+ let { referer } = ctx.headers
30
+ referer &&= new URL(referer).pathname
31
+ return referer?.startsWith(ADMIN_URI) ? serveAdminFiles(ctx, next)
32
+ : serveFrontendFiles(ctx, next)
33
+ }
34
+ if (path.startsWith(FRONTEND_URI))
35
+ return serveFrontendPrefixed(ctx,next)
36
+ if (path.length === ADMIN_URI.length - 1 && ADMIN_URI.startsWith(path))
37
+ return ctx.redirect(ctx.state.revProxyPath + ADMIN_URI)
38
+ if (path.startsWith(ADMIN_URI))
39
+ return allowAdmin(ctx) ? serveAdminPrefixed(ctx,next)
40
+ : sendErrorPage(ctx, HTTP_FORBIDDEN)
41
+ if (ctx.method === 'PUT') { // curl -T file url/
42
+ const decPath = decodeURIComponent(path)
43
+ let rest = basename(decPath)
44
+ const folder = await urlToNode(dirname(decPath), ctx, vfs, v => rest = v+'/'+rest)
45
+ if (!folder)
46
+ return sendErrorPage(ctx, HTTP_NOT_FOUND)
47
+ ctx.state.uploadPath = decPath
48
+ const dest = uploadWriter(folder, rest, ctx)
49
+ if (dest) {
50
+ await pipeline(ctx.req, dest)
51
+ ctx.body = {}
52
+ }
53
+ return
54
+ }
55
+ if (ctx.originalUrl === '/favicon.ico' && favicon.get()) // originalUrl to not be subject to changes (vhosting plugin)
56
+ return serveFile(ctx, favicon.get())
57
+ let node = await urlToNode(path, ctx)
58
+ if (!node)
59
+ return sendErrorPage(ctx, HTTP_NOT_FOUND)
60
+ if (ctx.method === 'POST') { // curl -F upload=@file url/
61
+ if (ctx.request.type !== 'multipart/form-data')
62
+ return ctx.status = HTTP_BAD_REQUEST
63
+ ctx.body = {}
64
+ ctx.state.uploads = []
65
+ const form = formidable({
66
+ maxFileSize: Infinity,
67
+ allowEmptyFiles: true,
68
+ fileWriteStreamHandler: f => {
69
+ const fn = (f as any).originalFilename
70
+ ctx.state.uploadPath = decodeURI(ctx.path) + fn
71
+ ctx.state.uploads!.push(fn)
72
+ return uploadWriter(node!, fn, ctx) || new Writable()
73
+ }
74
+ })
75
+ return new Promise<void>(res => form.parse(ctx.req, err => {
76
+ if (err) console.error(String(err))
77
+ res()
78
+ }))
79
+ }
80
+ if (node.default && path.endsWith('/')) // final/ needed on browser to make resource urls correctly with html pages
81
+ node = await urlToNode(node.default, ctx, node) ?? node
82
+ if (!await nodeIsDirectory(node))
83
+ return !node.source ? sendErrorPage(ctx, HTTP_METHOD_NOT_ALLOWED)
84
+ : !statusCodeForMissingPerm(node, 'can_read', ctx) ? serveFileNode(ctx, node)
85
+ : ctx.status !== HTTP_UNAUTHORIZED ? null
86
+ : !path.endsWith('/') ? ctx.set('WWW-Authenticate', 'Basic') // this is necessary to support standard urls with credentials. Final / means we are dealing with default file...
87
+ : (ctx.state.serveApp = true) && serveFrontendFiles(ctx, next) // ...for which we still provide fancy login
88
+ if (!path.endsWith('/'))
89
+ return ctx.redirect(ctx.state.revProxyPath + ctx.originalUrl.replace(/(\?|$)/, '/$1')) // keep query-string, if any
90
+ if (statusCodeForMissingPerm(node, 'can_list', ctx)) {
91
+ if (ctx.status === HTTP_FORBIDDEN)
92
+ return sendErrorPage(ctx, HTTP_FORBIDDEN)
93
+ const browserDetected = ctx.get('Upgrade-Insecure-Requests') || ctx.get('Sec-Fetch-Mode') // ugh, heuristics
94
+ if (!browserDetected) // we don't want to trigger basic authentication on browsers, it's meant for download managers only
95
+ return ctx.set('WWW-Authenticate', 'Basic') // we support basic authentication
96
+ ctx.state.serveApp = true
97
+ return serveFrontendFiles(ctx, next)
98
+ }
99
+ ctx.set({ server: `HFS ${VERSION} ${BUILD_TIMESTAMP}` })
100
+ return ctx.query.get === 'zip' ? zipStreamFromFolder(node, ctx)
101
+ : ctx.query.get === 'list' ? sendFolderList(node, ctx)
102
+ : serveFrontendFiles(ctx, next)
103
+}
104
+
105
+async function sendFolderList(node: VfsNode, ctx: Koa.Context) {
106
+ let { depth=0, folders, prepend } = ctx.query
107
+ ctx.type = 'text'
108
+ if (prepend === undefined || prepend === '*') { // * = force auto-detection even if we have baseUrl set
109
+ const { URL } = ctx
110
+ const base = prepend === undefined && baseUrl.get()
111
+ || URL.protocol + '//' + URL.host + ctx.state.revProxyPath
112
+ prepend = base + ctx.originalUrl.split('?')[0]! as string
113
+ }
114
+ const walker = walkNode(node, { ctx, depth: depth === '*' ? Infinity : Number(depth) })
115
+ ctx.body = asyncGeneratorToReadable(filterMapGenerator(walker, async el => {
116
+ const isFolder = await nodeIsDirectory(el)
117
+ return !folders && isFolder ? undefined
118
+ : prepend + getNodeName(el) + (isFolder ? '/' : '') + '\n'
119
+ }))
120
+}
121
+
122
+declare module "koa" {
123
+ interface DefaultState {
124
+ serveApp?: boolean // please, serve the frontend app
125
+ uploadPath?: string // current one
126
+ uploads?: string[] // in case of request with potentially multiple uploads (POST), we register all filenames (no full path)
127
+ }
128
+}
\ No newline at end of file