main
ts 207 lines 10.9 KB
Raw
1 import Koa from 'koa'
2 import { basename, dirname, join } from 'path'
3 import { getDefaultFile, getNodeName, nodeIsFolder, statusCodeForMissingPerm, urlToNode, vfs, VfsNode, walkNode } from './vfs'
4 import { sendErrorPage } from './errorPages'
5 import events from './events'
6 import {
7 ADMIN_URI, FRONTEND_URI, HTTP_FORBIDDEN, HTTP_METHOD_NOT_ALLOWED, HTTP_NOT_FOUND,
8 HTTP_UNAUTHORIZED, HTTP_SERVER_ERROR, HTTP_OK, ICONS_URI, HTTP_FAILED_DEPENDENCY, UPLOAD_TEMP_HASH,
9 BASIC_AUTHENTICATE_HEADER
10 } from './cross-const'
11 import { getUploadTempFor, uploadWriter } from './upload'
12 import { handleMultipartUpload } from './multipartUpload'
13 import { once } from 'events'
14 import { Transform } from 'stream'
15 import { serveFile, serveFileNode } from './serveFile'
16 import { BUILD_TIMESTAMP, DEV, MIME_AUTO, VERSION } from './const'
17 import { zipStreamFromFolder } from './zip'
18 import { preventAdminAccess, favicon } from './adminApis'
19 import { serveGuiFiles } from './serveGuiFiles'
20 import mount from 'koa-mount'
21 import { baseUrl } from './listen'
22 import {
23 asyncGeneratorToReadable, deleteStoredFileAttrs, filterMapGenerator, isValidFileName, loadFileCached, pathEncode,
24 pathDecodeSegments, safeDecodeURIComponent, try_,
25 } from './misc'
26 import { roots } from './roots'
27 import XXH from 'xxhashjs'
28 import fs from 'fs'
29 import { rm } from 'fs/promises'
30 import { setCommentFor } from './comments'
31 import { basicWeb, detectBasicAgent } from './basicWeb'
32 import { customizedIcons, ICONS_FOLDER } from './icons'
33 import { getPluginInfo } from './plugins'
34
35 const serveFrontendFiles = serveGuiFiles(process.env.FRONTEND_PROXY, FRONTEND_URI)
36 const serveFrontendPrefixed = mount(FRONTEND_URI.slice(0,-1), serveFrontendFiles)
37 const serveAdminFiles = serveGuiFiles(process.env.ADMIN_PROXY, ADMIN_URI)
38 const serveAdminPrefixed = mount(ADMIN_URI.slice(0,-1), serveAdminFiles)
39
40 export const guiFilesMiddleware: Koa.Middleware = async (ctx, next) => {
41 const { path } = ctx
42 // dynamic import on frontend|admin (used for non-https login) while developing (vite4) is not producing a relative path
43 if (DEV && path.startsWith('/node_modules/')) {
44 const { referer: r } = ctx.headers
45 return try_(() => r && new URL(r).pathname?.startsWith(ADMIN_URI)) ? serveAdminFiles(ctx, next)
46 : serveFrontendFiles(ctx, next)
47 }
48 if (path.startsWith(FRONTEND_URI) && !path.endsWith('/'))
49 return serveFrontendPrefixed(ctx, next)
50 if (path.length === ADMIN_URI.length - 1 && ADMIN_URI.startsWith(path))
51 return ctx.redirect(ctx.state.revProxyPath + ADMIN_URI)
52 if (path.startsWith(ADMIN_URI))
53 return preventAdminAccess(ctx) ? sendErrorPage(ctx, HTTP_FORBIDDEN) : serveAdminPrefixed(ctx, next)
54 if (path.startsWith(ICONS_URI)) {
55 const a = path.substring(ICONS_URI.length).split('/')
56 const iconName = a.at(-1)
57 if (!iconName) return
58 const plugin = a.length > 1 && getPluginInfo(a[0]!) // an extra level in the path indicates a plugin
59 const file = plugin ? plugin.icons?.[iconName] : customizedIcons?.[iconName]
60 if (!file) return
61 ctx.state.considerAsGui = true
62 return serveFile(ctx, join(plugin?.folder || '', ICONS_FOLDER, file), MIME_AUTO)
63 }
64 return next()
65 }
66
67 export const serveSharedFiles: Koa.Middleware = async (ctx, next) => {
68 const { path } = ctx
69 const { get } = ctx.query
70 const getUploadTempHash = get === UPLOAD_TEMP_HASH
71 if (ctx.method === 'PUT' || getUploadTempHash) { // PUT is what you get with `curl -T file url/`
72 const decPath = safeDecodeURIComponent(path, '')
73 const fn = basename(decPath)
74 const folderUri = pathEncode(dirname(decPath)) // re-encode to get readable urls
75 const folder = await urlToNode(folderUri, ctx, vfs, true) // we don't require the folder to already exist, but to be mapped on disk AND to have proper permissions
76 if (!folder)
77 return sendErrorPage(ctx, HTTP_NOT_FOUND)
78 ctx.state.uploadPath = decPath
79 if (getUploadTempHash)
80 return !folder.source || !isValidFileName(fn) ? sendErrorPage(ctx, HTTP_NOT_FOUND)
81 : statusCodeForMissingPerm(folder, 'can_upload', ctx) ? null
82 : loadFileCached(getUploadTempFor(join(folder.source, fn)), calcHash) // negligible memory leak
83 .then(x => ctx.body = x.content, e => ctx.status = e?.code === 'ENOENT' ? HTTP_NOT_FOUND : HTTP_SERVER_ERROR)
84 const dest = uploadWriter(folder, folderUri, fn, ctx)
85 if (dest) {
86 ctx.req.pipe(dest).on('error', err => {
87 ctx.status = HTTP_SERVER_ERROR
88 ctx.body = err.message || String(err)
89 })
90 ctx.req.on('close', () => dest.end())
91 const uri = await dest.lockMiddleware // we need to wait more than just the stream
92 if (uri) // falsy = aborted
93 ctx.body = { uri }
94 else if (ctx.status === 404) // nodejs already sent 400, but koa ignores it (ctx.headersSent is false and ctx.status is 404), so we adjust koa state to have correct data in the log
95 ctx.status = 400
96 }
97 return
98 }
99 if (/^\/favicon.ico(\??.*)/.test(ctx.originalUrl) && favicon.get() && ctx.method === 'GET') // originalUrl to not be subject to changes (vhosting plugin)
100 return serveFile(ctx, favicon.get())
101 let node = await urlToNode(path, ctx)
102 if (!node)
103 return sendErrorPage(ctx, HTTP_NOT_FOUND)
104 if (ctx.method === 'POST') // curl -F upload=@file url/
105 return handleMultipartUpload(ctx, node)
106 if (ctx.method === 'DELETE') {
107 const { source } = node
108 if (!source)
109 return ctx.status = HTTP_METHOD_NOT_ALLOWED
110 if (statusCodeForMissingPerm(node, 'can_delete', ctx))
111 return
112 try {
113 if ((await events.emitAsync('deleting', { node, ctx }))?.isDefaultPrevented())
114 return ctx.status = HTTP_FAILED_DEPENDENCY
115 await rm(source, { recursive: true })
116 await deleteStoredFileAttrs(source)
117 void setCommentFor(source, '') // necessary only to clean a possible descript.ion or kvstorage
118 return ctx.status = HTTP_OK
119 } catch (e: any) {
120 ctx.body = String(e)
121 return ctx.status = HTTP_SERVER_ERROR
122 }
123 }
124 if (path.endsWith('/') && !get) { // final slash needed on browsers to make resource urls working with html pages
125 const found = await getDefaultFile(node, ctx)
126 if (found && /\.html?/i.test(getNodeName(node = found)))
127 ctx.state.considerAsGui = true
128 }
129 if (get === 'icon')
130 return serveFile(ctx, node.icon || '|') // pipe to cause not-found
131 if (!nodeIsFolder(node))
132 return node.url ? ctx.redirect(node.url)
133 : !node.source ? sendErrorPage(ctx, HTTP_METHOD_NOT_ALLOWED) // !dir && !source is not supported at this moment
134 : !statusCodeForMissingPerm(node, 'can_read', ctx) ? serveFileNode(ctx, node) // all good
135 : ctx.status !== HTTP_UNAUTHORIZED ? null // all errors don't need extra handling, except unauthorized
136 : detectBasicAgent(ctx) ? (ctx.set('WWW-Authenticate', BASIC_AUTHENTICATE_HEADER), sendErrorPage(ctx))
137 : ctx.query.dl === undefined && (ctx.state.serveApp = true) && serveFrontendFiles(ctx, next)
138 if (!path.endsWith('/'))
139 return ctx.redirect(ctx.state.revProxyPath + ctx.originalUrl.replace(/(\?|$)/, '/$1')) // keep query-string, if any
140 if (statusCodeForMissingPerm(node, 'can_list', ctx)) {
141 if (ctx.status === HTTP_FORBIDDEN)
142 return sendErrorPage(ctx, HTTP_FORBIDDEN)
143 // detect if we are dealing with a download-manager, as it may need basic authentication, while we don't want it on browsers
144 const { authenticate } = ctx.query
145 const downloadManagerDetected = /DAP|FDM|[Mm]anager/.test(ctx.get('user-agent'))
146 if (downloadManagerDetected || authenticate || detectBasicAgent(ctx))
147 return ctx.set('WWW-Authenticate', authenticate || BASIC_AUTHENTICATE_HEADER) // basic authentication for DMs getting the folder as a zip
148 ctx.state.serveApp = true
149 return serveFrontendFiles(ctx, next)
150 }
151 ctx.set({ server: `HFS ${VERSION} ${BUILD_TIMESTAMP}` })
152 return get === 'zip' ? zipStreamFromFolder(node, ctx)
153 : get === 'list' ? sendFolderList(node, ctx)
154 : (basicWeb(ctx, node) || serveFrontendFiles(ctx, next))
155 }
156
157 async function sendFolderList(node: VfsNode, ctx: Koa.Context) {
158 if ((await events.emitAsync('getList', { node, ctx }))?.isDefaultPrevented())
159 return
160 let { depth=0, folders, prepend } = ctx.query
161 ctx.type = 'text'
162 if (prepend === undefined || prepend === '*') { // * = force auto-detection even if we have baseUrl set
163 const { URL } = ctx
164 const requestBase = URL.protocol + '//' + URL.host + ctx.state.revProxyPath
165 const configuredBaseUrl = prepend === undefined && baseUrl.get()
166 const configuredRoot = configuredBaseUrl && roots.compiled()(baseUrl.compiled() || '')
167 const pathInConfiguredRoot = configuredRoot && (configuredRoot === '/' ? ctx.path
168 : ctx.path.startsWith(configuredRoot) ? ctx.path.slice(configuredRoot.length - 1)
169 : ctx.path === configuredRoot.slice(0, -1) ? '/'
170 : false)
171 // base_url may expose a host-rooted home; use it only for VFS paths inside that host root
172 const [base, path] = !configuredBaseUrl ? [requestBase, ctx.path]
173 : pathInConfiguredRoot === false ? [requestBase, ctx.path]
174 : [configuredBaseUrl, pathInConfiguredRoot || ctx.path]
175 // redo the encoding our way, keeping unicode chars unchanged. decode each segment separately because decodeURI preserves reserved escapes like %3A, which pathEncode would double-encode
176 prepend = base + pathDecodeSegments(path, pathEncode)
177 }
178 const walker = walkNode(node, { ctx, depth: depth === '*' ? Infinity : Number(depth), parallelizeRecursion: false }) // parallelization produces out-of-order results, and we don't want it like that here
179 ctx.body = asyncGeneratorToReadable(filterMapGenerator(walker, async el => {
180 const isFolder = nodeIsFolder(el)
181 return !folders && isFolder ? undefined
182 : prepend + pathEncode(getNodeName(el)) + (isFolder ? '/' : '') + '\n'
183 }))
184 }
185
186 async function calcHash(fn: string, limit=Infinity) {
187 const hash = XXH.h32()
188 const stream = new Transform({
189 transform(chunk, enc, done) {
190 hash.update(chunk)
191 done()
192 }
193 })
194 fs.createReadStream(fn, { end: limit - 1 }).pipe(stream)
195 console.debug('Hashing', fn)
196 await once(stream, 'finish')
197 console.debug('Hashed', fn)
198 return hash.digest().toString(16)
199 }
200
201 declare module "koa" {
202 interface DefaultState {
203 serveApp?: boolean // please, serve the frontend app
204 uploadPath?: string // current one
205 uploads?: string[] // in case of request with potentially multiple uploads (POST), we register all filenames (no full path)
206 }
207 }