Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
200 changes: 127 additions & 73 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 @@ -244,92 +243,147 @@ export async function staticPlugin<const Prefix extends string = '/prefix'>({
}
}

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<string, string | undefined>) => {
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<Awaited<ReturnType<typeof getFile>>> | 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<Awaited<ReturnType<typeof getFile>>>
| 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
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)
}
})
})