@samitouri / QOSami-HFS / commits / 288ad20e

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