Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -256,8 +255,6 @@

"esbuild-fix-imports-plugin": ["[email protected]", "", {}, "sha512-8Q8FDsnZgDwa+dHu0/bpU6gOmNrxmqgsIG1s7p1xtv6CQccRKc3Ja8o09pLNwjFgkOWtmwjS0bZmSWN7ATgdJQ=="],

"esbuild-plugin-file-path-extensions": ["[email protected]", "", {}, "sha512-lNjylaAsJMprYg28zjUyBivP3y0ms9b7RJZ5tdhDUFLa3sCbqZw4wDnbFUSmnyZYWhCYDPxxp7KkXM2TXGw3PQ=="],

"escape-string-regexp": ["[email protected]", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],

"eslint": ["[email protected]", "", { "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=="],
Expand Down
188 changes: 117 additions & 71 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { Elysia, NotFoundError } from 'elysia'

import type { Stats } from 'fs'

import fastDecodeURI from 'fast-decode-uri-component'

import {
Expand Down Expand Up @@ -31,7 +29,8 @@ export async function staticPlugin<const Prefix extends string = '/prefix'>({
extension = true,
indexHTML = true,
decodeURI,
silent
silent,
enableFallback = false
}: StaticOptions<Prefix> = {}): Promise<Elysia> {
if (
typeof process === 'undefined' ||
Expand Down Expand Up @@ -239,90 +238,137 @@ export async function staticPlugin<const Prefix extends string = '/prefix'>({
}
}

app.onError(() => {}).get(
`${prefix}/*`,
async ({ params, headers: requestHeaders }) => {
const pathName = path.join(
assets,
decodeURI
? (fastDecodeURI(params['*']) ?? params['*'])
: params['*']
)
const serveStaticFile = async (pathName: string, requestHeaders?: Record<string, string | undefined>) => {
if (shouldIgnore(pathName)) return null

const cache = fileCache.get(pathName)
if (cache) return cache.clone()

const fileStat = await fs.stat(pathName).catch(() => null)
if (!fileStat) return null

if (!indexHTML && fileStat.isDirectory()) return null

if (shouldIgnore(pathName)) throw new NotFoundError()
let file: NonNullable<Awaited<ReturnType<typeof getFile>>> | undefined

const cache = fileCache.get(pathName)
if (!isBun && indexHTML) {
const htmlPath = path.join(pathName, '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)
}

if (!indexHTML && fileStat.isDirectory())
throw new NotFoundError()
if (!file && !fileStat.isDirectory() && (await fileExists(pathName)))
file = await getFile(pathName)

// @ts-ignore
let file:
| NonNullable<Awaited<ReturnType<typeof getFile>>>
| undefined
if (!file) return null

if (!isBun && indexHTML) {
const htmlPath = path.join(pathName, 'index.html')
const cache = fileCache.get(htmlPath)
if (cache) return cache.clone()
if (!useETag)
return new Response(
file,
isNotEmpty(initialHeaders)
? { headers: initialHeaders }
: undefined
)

if (await fileExists(htmlPath))
file = await getFile(htmlPath)
const etag = await generateETag(file)
if (requestHeaders && etag && (await isCached(requestHeaders, etag, pathName)))
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)
return response.clone()
}

if (enableFallback) {
app.onError({ as: 'global' }, async ({ code, request }) => {
if (code !== 'NOT_FOUND') return

// Only serve static files for GET/HEAD
if (request.method !== 'GET' && request.method !== 'HEAD') return

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

const etag = await generateETag(file)
if (shouldIgnore(resolvedPath.replace(assetsDir, ''))) return

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
Expand Down
11 changes: 11 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,15 @@ export interface StaticOptions<Prefix extends string> {
* 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
}
1 change: 0 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
@@ -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')
Expand Down
97 changes: 97 additions & 0 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
})