diff --git a/bun.lock b/bun.lock index 90a3c53..c35d444 100644 --- a/bun.lock +++ b/bun.lock @@ -10,7 +10,6 @@ "@typescript-eslint/eslint-plugin": "^6.7.4", "elysia": "^1.4.11", "esbuild-fix-imports-plugin": "^1.0.22", - "esbuild-plugin-file-path-extensions": "^2.1.4", "eslint": "9.6.0", "fast-decode-uri-component": "^1.0.1", "tsup": "^8.1.0", @@ -256,8 +255,6 @@ "esbuild-fix-imports-plugin": ["esbuild-fix-imports-plugin@1.0.22", "", {}, "sha512-8Q8FDsnZgDwa+dHu0/bpU6gOmNrxmqgsIG1s7p1xtv6CQccRKc3Ja8o09pLNwjFgkOWtmwjS0bZmSWN7ATgdJQ=="], - "esbuild-plugin-file-path-extensions": ["esbuild-plugin-file-path-extensions@2.1.4", "", {}, "sha512-lNjylaAsJMprYg28zjUyBivP3y0ms9b7RJZ5tdhDUFLa3sCbqZw4wDnbFUSmnyZYWhCYDPxxp7KkXM2TXGw3PQ=="], - "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "eslint": ["eslint@9.6.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/config-array": "^0.17.0", "@eslint/eslintrc": "^3.1.0", "@eslint/js": "9.6.0", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.3.0", "@nodelib/fs.walk": "^1.2.8", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.0.1", "eslint-visitor-keys": "^4.0.0", "espree": "^10.1.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3", "strip-ansi": "^6.0.1", "text-table": "^0.2.0" }, "bin": { "eslint": "bin/eslint.js" } }, "sha512-ElQkdLMEEqQNM9Njff+2Y4q2afHk7JpkPvrd7Xh7xefwgQynqPxwf55J7di9+MEibWUGdNjFF9ITG9Pck5M84w=="], diff --git a/src/index.ts b/src/index.ts index 0731e6c..1d0f918 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,5 @@ import { Elysia, NotFoundError } from 'elysia' -import type { Stats } from 'fs' - import fastDecodeURI from 'fast-decode-uri-component' import { @@ -31,7 +29,8 @@ export async function staticPlugin({ extension = true, indexHTML = true, decodeURI, - silent + silent, + enableFallback = false }: StaticOptions = {}): Promise { if ( typeof process === 'undefined' || @@ -244,92 +243,147 @@ export async function staticPlugin({ } } - app.onError(() => {}).get( - `${prefix}/*`, - async ({ params, headers: requestHeaders }) => { - const pathName = normalizePath( - path.join( - assets, - decodeURI - ? (fastDecodeURI(params['*']) ?? params['*']) - : params['*'] - ) - ) + const serveStaticFile = async (pathName: string, requestHeaders?: Record) => { + const normalizedPath = normalizePath(pathName) + const rel = normalizedPath.startsWith(assetsDir) + ? normalizedPath.slice(assetsDir.length) + : normalizedPath + if (shouldIgnore(rel)) return null + + const cache = fileCache.get(normalizedPath) + if (cache) return cache.clone() - if (shouldIgnore(pathName)) throw new NotFoundError() + const fileStat = await fs.stat(normalizedPath).catch(() => null) + if (!fileStat) return null - const cache = fileCache.get(pathName) + if (!indexHTML && fileStat.isDirectory()) return null + + let file: NonNullable>> | undefined + let targetPath = normalizedPath + + if (!isBun && indexHTML) { + const htmlPath = path.join(normalizedPath, 'index.html') + const cache = fileCache.get(htmlPath) if (cache) return cache.clone() - try { - const fileStat = await fs.stat(pathName).catch(() => null) - if (!fileStat) throw new NotFoundError() + if (await fileExists(htmlPath)) { + file = await getFile(htmlPath) + targetPath = htmlPath + } + } - if (!indexHTML && fileStat.isDirectory()) - throw new NotFoundError() + if (!file && !fileStat.isDirectory() && (await fileExists(normalizedPath))) + file = await getFile(normalizedPath) + + if (!file) return null + + if (!useETag) + return new Response( + file, + isNotEmpty(initialHeaders) + ? { headers: initialHeaders } + : undefined + ) - // @ts-ignore - let file: - | NonNullable>> - | undefined + const etag = await generateETag(file) + if (requestHeaders && etag && (await isCached(requestHeaders, etag, targetPath))) + return new Response(null, { + status: 304, + headers: isNotEmpty(initialHeaders) ? initialHeaders : undefined + }) + + const response = new Response(file, { + headers: Object.assign( + { + 'Cache-Control': maxAge + ? `${directive}, max-age=${maxAge}` + : directive + }, + initialHeaders, + etag ? { Etag: etag } : {} + ) + }) + + fileCache.set(normalizedPath, response) + return response.clone() + } + + if (enableFallback) { + app.onError({ as: 'global' }, async ({ code, request }) => { + if (code !== 'NOT_FOUND') return - if (!isBun && indexHTML) { - const htmlPath = path.join(pathName, 'index.html') - const cache = fileCache.get(htmlPath) - if (cache) return cache.clone() + // Only serve static files for GET/HEAD + if (request.method !== 'GET' && request.method !== 'HEAD') return - if (await fileExists(htmlPath)) - file = await getFile(htmlPath) + const url = new URL(request.url) + let pathname = url.pathname + + if (prefix) { + if (pathname.startsWith(prefix)) { + pathname = pathname.slice(prefix.length) + } else { + return } + } - if ( - !file && - !fileStat.isDirectory() && - (await fileExists(pathName)) - ) - file = await getFile(pathName) - else throw new NotFoundError() - - if (!useETag) - return new Response( - file, - isNotEmpty(initialHeaders) - ? { headers: initialHeaders } - : undefined - ) + const rawPath = decodeURI + ? (fastDecodeURI(pathname) ?? pathname) + : pathname + const resolvedPath = path.resolve( + assetsDir, + rawPath.replace(/^\//, '') + ) + // Block path traversal: must stay under assetsDir + if ( + resolvedPath !== assetsDir && + !resolvedPath.startsWith(assetsDir + path.sep) + ) + return + + if (shouldIgnore(resolvedPath.replace(assetsDir, ''))) return - const etag = await generateETag(file) + try { + const headers = Object.fromEntries(request.headers) + return await serveStaticFile(resolvedPath, headers) + } catch { + return + } + }) + } else { + app.onError(() => {}).get( + `${prefix}/*`, + async ({ params, headers: requestHeaders }) => { + const rawPath = decodeURI + ? (fastDecodeURI(params['*']) ?? params['*']) + : params['*'] + const resolvedPath = path.resolve( + assetsDir, + rawPath.replace(/^\//, '') + ) if ( - etag && - (await isCached(requestHeaders, etag, pathName)) + resolvedPath !== assetsDir && + !resolvedPath.startsWith(assetsDir + path.sep) ) - return new Response(null, { - status: 304 - }) - - const response = new Response(file, { - headers: Object.assign( - { - 'Cache-Control': maxAge - ? `${directive}, max-age=${maxAge}` - : directive - }, - initialHeaders, - etag ? { Etag: etag } : {} - ) - }) - - fileCache.set(pathName, response) + throw new NotFoundError() - return response.clone() - } catch (error) { - if (error instanceof NotFoundError) throw error - if (!silent) console.error(`[@elysiajs/static]`, error) + if (shouldIgnore(resolvedPath.replace(assetsDir, ''))) + throw new NotFoundError() - throw new NotFoundError() + try { + const result = await serveStaticFile( + resolvedPath, + requestHeaders + ) + if (result) return result + throw new NotFoundError() + } catch (error) { + if (error instanceof NotFoundError) throw error + if (!silent) console.error(`[@elysiajs/static]`, error) + throw new NotFoundError() + } } - } - ) + ) + } } return app diff --git a/src/types.ts b/src/types.ts index fa98cef..d39955f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -116,4 +116,15 @@ export interface StaticOptions { * If set to true, suppresses all logs and warnings from the static plugin */ silent?: boolean + + /** + * enableFallback + * + * @default false + * + * If set to true, when a static file is not found, the request will fall through + * to the next route handler instead of returning a 404 error. + * This allows other routes to handle the request. + */ + enableFallback?: boolean } diff --git a/src/utils.ts b/src/utils.ts index fb0b8cb..cdc3fa1 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,4 @@ import type { BunFile } from 'bun' -import type { Stats } from 'fs' let fs: typeof import('fs/promises') let path: typeof import('path') diff --git a/test/index.test.ts b/test/index.test.ts index 6747ff2..d76ef41 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -442,4 +442,101 @@ describe('Static Plugin', () => { res = await app.handle(req('/public/html')) expect(res.status).toBe(404) }) + + it('should fallback to other routes when enableFallback is true', async () => { + const app = new Elysia() + .get('/api/test', () => ({ success: true })) + .use( + staticPlugin({ + prefix: '/', + enableFallback: true + }) + ) + + await app.modules + + const apiRes = await app.handle(req('/api/test')) + expect(apiRes.status).toBe(200) + + const staticRes = await app.handle(req('/takodachi.png')) + expect(staticRes.status).toBe(200) + }) + + it('should return 404 for non-existent files when enableFallback is false', async () => { + const app = new Elysia() + .get('/api/test', () => ({ success: true })) + .use( + staticPlugin({ + prefix: '/', + enableFallback: false + }) + ) + + await app.modules + + const res = await app.handle(req('/non-existent-file.txt')) + expect(res.status).toBe(404) + + const apiRes = await app.handle(req('/api/test')) + expect(apiRes.status).toBe(200) + }) + + it('should work with .all() method when enableFallback is true', async () => { + const app = new Elysia() + .all('/api/auth/*', () => ({ auth: 'success' })) + .use( + staticPlugin({ + prefix: '/', + enableFallback: true + }) + ) + + await app.modules + + const res = await app.handle(req('/api/auth/get-session')) + expect(res.status).toBe(200) + }) + + it('should prevent directory traversal attacks', async () => { + const app = new Elysia().use(staticPlugin()) + + await app.modules + + const traversalPaths = [ + '/public/../package.json', + '/public/../../package.json', + '/public/../../../etc/passwd', + '/public/%2e%2e/package.json', + '/public/nested/../../package.json' + ] + + for (const path of traversalPaths) { + const res = await app.handle(req(path)) + expect(res.status).toBe(404) + } + }) + + it('should prevent directory traversal attacks when enableFallback is true', async () => { + const app = new Elysia().use( + staticPlugin({ + prefix: '/', + enableFallback: true + }) + ) + + await app.modules + + const traversalPaths = [ + '/../package.json', + '/../../package.json', + '/../../../etc/passwd', + '/%2e%2e/package.json', + '/nested/../../package.json' + ] + + for (const path of traversalPaths) { + const res = await app.handle(req(path)) + expect(res.status).toBe(404) + } + }) })