| 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 | } |