diff --git a/.gitignore b/.gitignore
index 0c0ca63..f616390 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,4 @@ lib
dist
.DS_Store
+test_static_files/
\ No newline at end of file
diff --git a/biome.json b/biome.json
index fa1a3ec..8df4b20 100644
--- a/biome.json
+++ b/biome.json
@@ -20,7 +20,8 @@
"noExplicitAny": "off"
},
"style": {
- "useTemplate": "off"
+ "useTemplate": "off",
+ "useNodejsImportProtocol": "error"
}
}
},
diff --git a/packages/wobe-documentation/doc/ecosystem/hooks/html.md b/packages/wobe-documentation/doc/ecosystem/hooks/html.md
new file mode 100644
index 0000000..4d9c3c7
--- /dev/null
+++ b/packages/wobe-documentation/doc/ecosystem/hooks/html.md
@@ -0,0 +1,44 @@
+# html (static files)
+
+`html` serves static files (HTML, JS, CSS, images, etc.) from a directory with guardrails similar to `express.static`: traversal blocking, dotfiles ignored by default, symlink checks, and SPA fallback support.
+
+## Quick usage
+
+```ts
+import { Wobe, html } from 'wobe'
+import { join } from 'node:path'
+
+const staticRoot = join(__dirname, '../fixtures')
+
+new Wobe()
+ // Serve under /tata while stripping that prefix for file resolution
+ .get(
+ '/tata/*',
+ html({
+ rootPath: staticRoot,
+ fallbackFile: 'index.html', // optional for SPA
+ stripPrefix: '/tata',
+ })
+ )
+ .listen(3000)
+
+// http://localhost:3000/tata/test.html -> serves fixtures/test.html
+// http://localhost:3000/tata/unknown -> serves fixtures/index.html (fallback)
+```
+
+## Options
+
+- `rootPath` (string, required): root directory of static files.
+- `fallbackFile` (string, optional): file served on 404, useful for SPAs (`text/html`).
+- `stripPrefix` (string, optional): prefix to strip from the pathname when mounted under a sub-path (e.g. `/static` or `/tata`).
+- `allowDotfiles` (boolean, default `false`): serve dotfiles; otherwise they return 404.
+- `allowSymlinks` (boolean, default `false`): allow symlinks pointing outside `rootPath`; otherwise realpath must stay inside root or 403.
+
+## Behavior and safety
+
+- **Methods**: only `GET` and `HEAD` are accepted (`405` otherwise). HEAD returns headers only.
+- **Dotfiles**: ignored by default (404).
+- **Traversal / symlinks**: path is normalized + realpathed and must stay under `rootPath` unless `allowSymlinks` is true; otherwise 403.
+- **Content-Type**: inferred from extension (built-in MIME table); text read via `.text()`, binary via `arrayBuffer()`.
+- **SPA fallback**: served only if it resolves inside root (same symlink rule); otherwise 403.
+- **Mounted prefixes**: use `stripPrefix` to exclude the mount prefix from file resolution.
diff --git a/packages/wobe-documentation/doc/ecosystem/hooks/index.md b/packages/wobe-documentation/doc/ecosystem/hooks/index.md
index c810587..cbb184b 100644
--- a/packages/wobe-documentation/doc/ecosystem/hooks/index.md
+++ b/packages/wobe-documentation/doc/ecosystem/hooks/index.md
@@ -54,8 +54,12 @@ export const authorizationHook = (schema: TSchema): WobeHandler => {
throw new HttpException(
new Response('You are not authorized to access to this route', {
status: 403,
- }),
+ })
)
}
}
```
+
+## Available hooks
+
+- [html](./html.md) — serve static files with SPA fallback, traversal/dotfile guards, GET/HEAD only.
diff --git a/packages/wobe/dev/index.ts b/packages/wobe/dev/index.ts
index aef0d52..cf5ef40 100644
--- a/packages/wobe/dev/index.ts
+++ b/packages/wobe/dev/index.ts
@@ -1,11 +1,21 @@
process.env.NODE_TEST = 'test'
import { join } from 'node:path'
-import { Wobe, uploadDirectory } from '../src'
+import { Wobe, uploadDirectory, html } from '../src'
+
+const staticRoot = join(__dirname, '../fixtures')
new Wobe()
- .get('/', (ctx) => ctx.res.send('Hi'))
+ // Sample API route
+ .get('/api', (ctx) => ctx.res.send('Hi'))
+ // Example of using the uploadDirectory hook
+ .get('/bucket/:filename', uploadDirectory({ directory: staticRoot }))
+ // Serve static assets and fallback to testFile.html for SPA-style routing
.get(
- '/bucket/:filename',
- uploadDirectory({ directory: join(__dirname, '../fixtures') }),
+ '/tata/*',
+ html({
+ rootPath: staticRoot,
+ fallbackFile: 'testFile.html',
+ stripPrefix: '/tata',
+ }),
)
.listen(3000)
diff --git a/packages/wobe/fixtures/test.html b/packages/wobe/fixtures/test.html
new file mode 100644
index 0000000..55f4b99
--- /dev/null
+++ b/packages/wobe/fixtures/test.html
@@ -0,0 +1,5 @@
+
+
+ Hello World
+
+
diff --git a/packages/wobe/src/hooks/html.test.ts b/packages/wobe/src/hooks/html.test.ts
new file mode 100644
index 0000000..5007a84
--- /dev/null
+++ b/packages/wobe/src/hooks/html.test.ts
@@ -0,0 +1,374 @@
+import { describe, expect, it, beforeEach, afterEach } from 'bun:test'
+import { html } from './html'
+import { WobeResponse } from '../WobeResponse'
+import { join } from 'node:path'
+import { rm, mkdir, writeFile, symlink } from 'node:fs/promises'
+
+// Mock context object
+class MockContext {
+ request: Request
+ res: WobeResponse
+
+ constructor(request: Request) {
+ this.request = request
+ this.res = new WobeResponse(request)
+ }
+}
+
+describe('html', () => {
+ const TEST_DIR = './test_static_files'
+ const rootPath = join(process.cwd(), TEST_DIR)
+ const OUTSIDE_FILE = join(process.cwd(), 'outside-leak.txt')
+
+ // Setup test directory and files
+ beforeEach(async () => {
+ await rm(TEST_DIR, { recursive: true, force: true })
+ await mkdir(TEST_DIR, { recursive: true })
+
+ // Create test files
+ await writeFile(join(TEST_DIR, 'index.html'), 'Index')
+ await writeFile(join(TEST_DIR, 'test.html'), 'Test')
+ await writeFile(join(TEST_DIR, 'styles.css'), 'body { color: red; }')
+ await writeFile(join(TEST_DIR, 'script.js'), 'console.log("test")')
+ await writeFile(join(TEST_DIR, 'data.json'), '{"test": true}')
+
+ // Create a subdirectory with index.html
+ await mkdir(join(TEST_DIR, 'subdir'), { recursive: true })
+ await writeFile(
+ join(TEST_DIR, 'subdir', 'index.html'),
+ 'Subdir',
+ )
+ })
+
+ afterEach(async () => {
+ await rm(rootPath, { recursive: true, force: true })
+ await rm(OUTSIDE_FILE, { force: true })
+ })
+
+ it('should serve HTML files with correct Content-Type', async () => {
+ const request = new Request('http://localhost:3000/test.html')
+ const ctx = new MockContext(request)
+ const middleware = html({ rootPath })
+
+ const response = await middleware(ctx)
+ expect(response?.status).toBe(200)
+ expect(response?.headers.get('Content-Type')).toBe('text/html')
+ expect(await response?.text()).toContain('Test')
+ })
+
+ it('should serve CSS files with correct Content-Type', async () => {
+ const request = new Request('http://localhost:3000/styles.css')
+ const ctx = new MockContext(request)
+ const middleware = html({ rootPath })
+
+ const response = await middleware(ctx)
+ expect(response?.status).toBe(200)
+ expect(response?.headers.get('Content-Type')).toBe('text/css')
+ expect(await response?.text()).toContain('body { color: red; }')
+ })
+
+ it('should serve JS files with correct Content-Type', async () => {
+ const request = new Request('http://localhost:3000/script.js')
+ const ctx = new MockContext(request)
+ const middleware = html({ rootPath })
+
+ const response = await middleware(ctx)
+ expect(response?.status).toBe(200)
+ expect(response?.headers.get('Content-Type')).toBe(
+ 'application/javascript',
+ )
+ expect(await response?.text()).toContain('console.log("test")')
+ })
+
+ it('should serve JSON files with correct Content-Type', async () => {
+ const request = new Request('http://localhost:3000/data.json')
+ const ctx = new MockContext(request)
+ const middleware = html({ rootPath })
+
+ const response = await middleware(ctx)
+ expect(response?.status).toBe(200)
+ expect(response?.headers.get('Content-Type')).toBe('application/json')
+ expect(await response?.text()).toContain('"test": true')
+ })
+
+ it('should ignore dotfiles by default', async () => {
+ await writeFile(join(TEST_DIR, '.env'), 'SECRET=1')
+
+ const request = new Request('http://localhost:3000/.env')
+ const ctx = new MockContext(request)
+ const middleware = html({ rootPath })
+
+ const response = await middleware(ctx)
+ expect(response?.status).toBe(404)
+ })
+
+ it('should serve dotfiles when allowed', async () => {
+ await writeFile(join(TEST_DIR, '.env'), 'SECRET=1')
+
+ const request = new Request('http://localhost:3000/.env')
+ const ctx = new MockContext(request)
+ const middleware = html({ rootPath, allowDotfiles: true })
+
+ const response = await middleware(ctx)
+ expect(response?.status).toBe(200)
+ expect(await response?.text()).toContain('SECRET=1')
+ })
+
+ it('should serve index.html when directory is requested', async () => {
+ const request = new Request('http://localhost:3000/subdir/')
+ const ctx = new MockContext(request)
+ const middleware = html({ rootPath })
+
+ const response = await middleware(ctx)
+ expect(response?.status).toBe(200)
+ expect(response?.headers.get('Content-Type')).toBe('text/html')
+ expect(await response?.text()).toContain('Subdir')
+ })
+
+ it('should serve index.html when directory without trailing slash is requested', async () => {
+ const request = new Request('http://localhost:3000/subdir')
+ const ctx = new MockContext(request)
+ const middleware = html({ rootPath })
+
+ const response = await middleware(ctx)
+ expect(response?.status).toBe(200)
+ expect(response?.headers.get('Content-Type')).toBe('text/html')
+ expect(await response?.text()).toContain('Subdir')
+ })
+
+ it('should return 404 for non-existent files', async () => {
+ const request = new Request('http://localhost:3000/nonexistent.html')
+ const ctx = new MockContext(request)
+ const middleware = html({ rootPath })
+
+ const response = await middleware(ctx)
+ expect(response?.status).toBe(404)
+ expect(await response?.text()).toBe('Not Found')
+ })
+
+ it('should serve fallback file for SPA when file not found', async () => {
+ const request = new Request('http://localhost:3000/nonexistent')
+ const ctx = new MockContext(request)
+ const middleware = html({ rootPath, fallbackFile: 'index.html' })
+
+ const response = await middleware(ctx)
+ expect(response?.status).toBe(200)
+ expect(response?.headers.get('Content-Type')).toBe('text/html')
+ expect(await response?.text()).toContain('Index')
+ })
+
+ it('should return 404 when fallback file does not exist', async () => {
+ const request = new Request('http://localhost:3000/nonexistent')
+ const ctx = new MockContext(request)
+ const middleware = html({
+ rootPath,
+ fallbackFile: 'nonexistent-fallback.html',
+ })
+
+ const response = await middleware(ctx)
+ expect(response?.status).toBe(404)
+ expect(await response?.text()).toBe('Not Found')
+ })
+
+ it('should handle URL-encoded paths', async () => {
+ // Create a file with spaces in the name
+ await writeFile(
+ join(TEST_DIR, 'file with spaces.html'),
+ 'Spaces',
+ )
+
+ const request = new Request(
+ 'http://localhost:3000/file%20with%20spaces.html',
+ )
+ const ctx = new MockContext(request)
+ const middleware = html({ rootPath })
+
+ const response = await middleware(ctx)
+ expect(response?.status).toBe(200)
+ expect(response?.headers.get('Content-Type')).toBe('text/html')
+ expect(await response?.text()).toContain('Spaces')
+ })
+
+ it('should handle files with no extension', async () => {
+ await writeFile(join(TEST_DIR, 'noextension'), 'No extension content')
+
+ const request = new Request('http://localhost:3000/noextension')
+ const ctx = new MockContext(request)
+ const middleware = html({ rootPath })
+
+ const response = await middleware(ctx)
+ expect(response?.status).toBe(200)
+ expect(response?.headers.get('Content-Type')).toBe(
+ 'application/octet-stream',
+ )
+ expect(await response?.text()).toContain('No extension content')
+ })
+
+ it('should handle query parameters in URL', async () => {
+ const request = new Request(
+ 'http://localhost:3000/test.html?param=value',
+ )
+ const ctx = new MockContext(request)
+ const middleware = html({ rootPath })
+
+ const response = await middleware(ctx)
+ expect(response?.status).toBe(200)
+ expect(response?.headers.get('Content-Type')).toBe('text/html')
+ expect(await response?.text()).toContain('Test')
+ })
+
+ it('should handle root path request', async () => {
+ const request = new Request('http://localhost:3000/')
+ const ctx = new MockContext(request)
+ const middleware = html({ rootPath })
+
+ const response = await middleware(ctx)
+ expect(response?.status).toBe(200)
+ expect(response?.headers.get('Content-Type')).toBe('text/html')
+ expect(await response?.text()).toContain('Index')
+ })
+
+ it('should respond to HEAD without body', async () => {
+ const request = new Request('http://localhost:3000/test.html', {
+ method: 'HEAD',
+ })
+ const ctx = new MockContext(request)
+ const middleware = html({ rootPath })
+
+ const response = await middleware(ctx)
+ expect(response?.status).toBe(200)
+ expect(response?.headers.get('Content-Type')).toBe('text/html')
+ expect(await response?.text()).toBe('')
+ })
+
+ it('should serve files when mounted under a prefix with stripPrefix', async () => {
+ const request = new Request('http://localhost:3000/tata/test.html')
+ const ctx = new MockContext(request)
+ const middleware = html({
+ rootPath,
+ stripPrefix: '/tata',
+ })
+
+ const response = await middleware(ctx)
+ expect(response?.status).toBe(200)
+ expect(response?.headers.get('Content-Type')).toBe('text/html')
+ expect(await response?.text()).toContain('Test')
+ })
+
+ it('should serve fallback under a prefix when file is missing', async () => {
+ const request = new Request('http://localhost:3000/tata/missing')
+ const ctx = new MockContext(request)
+ const middleware = html({
+ rootPath,
+ fallbackFile: 'index.html',
+ stripPrefix: '/tata',
+ })
+
+ const response = await middleware(ctx)
+ expect(response?.status).toBe(200)
+ expect(response?.headers.get('Content-Type')).toBe('text/html')
+ expect(await response?.text()).toContain('Index')
+ })
+
+ it('should serve symlink that stays inside root', async () => {
+ // relative target from within the same directory
+ await symlink('test.html', join(TEST_DIR, 'alias.html'))
+
+ const request = new Request('http://localhost:3000/alias.html')
+ const ctx = new MockContext(request)
+ const middleware = html({ rootPath })
+
+ const response = await middleware(ctx)
+ expect(response?.status).toBe(200)
+ expect(await response?.text()).toContain('Test')
+ })
+
+ it('should block symlink escaping root by default', async () => {
+ await writeFile(OUTSIDE_FILE, 'leak')
+ await symlink(OUTSIDE_FILE, join(TEST_DIR, 'leak.html'))
+
+ const request = new Request('http://localhost:3000/leak.html')
+ const ctx = new MockContext(request)
+ const middleware = html({ rootPath })
+
+ const response = await middleware(ctx)
+ expect(response?.status).toBe(403)
+ })
+
+ it('should allow symlink outside root when explicitly enabled', async () => {
+ await writeFile(OUTSIDE_FILE, 'leak-ok')
+ await symlink(OUTSIDE_FILE, join(TEST_DIR, 'leak-allowed.html'))
+
+ const request = new Request('http://localhost:3000/leak-allowed.html')
+ const ctx = new MockContext(request)
+ const middleware = html({ rootPath, allowSymlinks: true })
+
+ const response = await middleware(ctx)
+ expect(response?.status).toBe(200)
+ expect(await response?.text()).toContain('leak-ok')
+ })
+
+ it('should return 405 for non-GET/HEAD methods', async () => {
+ const request = new Request('http://localhost:3000/test.html', {
+ method: 'POST',
+ })
+ const ctx = new MockContext(request)
+ const middleware = html({ rootPath })
+
+ const response = await middleware(ctx)
+ expect(response?.status).toBe(405)
+ expect(response?.headers.get('Allow')).toBe('GET, HEAD')
+ })
+
+ it('should block SPA fallback if it resolves outside root when symlinks disallowed', async () => {
+ await writeFile(OUTSIDE_FILE, 'fallback leak')
+ await symlink(OUTSIDE_FILE, join(TEST_DIR, 'outside-fallback.html'))
+
+ const request = new Request('http://localhost:3000/unknown')
+ const ctx = new MockContext(request)
+ const middleware = html({
+ rootPath,
+ fallbackFile: 'outside-fallback.html',
+ })
+
+ const response = await middleware(ctx)
+ expect(response?.status).toBe(403)
+ })
+
+ it('should not leak files outside the root (normalized URL)', async () => {
+ const request = new Request('http://localhost:3000/../package.json')
+ const ctx = new MockContext(request)
+ const middleware = html({ rootPath })
+
+ const response = await middleware(ctx)
+ // WHATWG URL normalizes '/../package.json' to '/package.json', so we get a 404.
+ expect(response?.status).toBe(404)
+ expect(await response?.text()).toBe('Not Found')
+ })
+
+ it('should return 400 on malformed URI', async () => {
+ const request = new Request('http://localhost:3000/%E0%A4%A')
+ const ctx = new MockContext(request)
+ const middleware = html({ rootPath })
+
+ const response = await middleware(ctx)
+ expect(response?.status).toBe(400)
+ expect(await response?.text()).toBe('Bad Request')
+ })
+
+ it('should serve binary files without mangling', async () => {
+ const binary = new Uint8Array([0, 1, 2, 3])
+ await writeFile(join(TEST_DIR, 'image.png'), binary)
+
+ const request = new Request('http://localhost:3000/image.png')
+ const ctx = new MockContext(request)
+ const middleware = html({ rootPath })
+
+ const response = await middleware(ctx)
+ expect(response?.status).toBe(200)
+ expect(response?.headers.get('Content-Type')).toBe('image/png')
+ if (!response) throw new Error('Expected response')
+ const buffer = new Uint8Array(await response.arrayBuffer())
+ expect(Array.from(buffer)).toEqual(Array.from(binary))
+ })
+})
diff --git a/packages/wobe/src/hooks/html.ts b/packages/wobe/src/hooks/html.ts
new file mode 100644
index 0000000..5864afe
--- /dev/null
+++ b/packages/wobe/src/hooks/html.ts
@@ -0,0 +1,231 @@
+import { join, normalize, resolve, sep } from 'node:path'
+import { stat, realpath } from 'node:fs/promises'
+import type { WobeHandler } from '../Wobe'
+
+export type HtmlOptions = {
+ rootPath: string
+ fallbackFile?: string
+ stripPrefix?: string
+ allowDotfiles?: boolean
+ allowSymlinks?: boolean
+}
+
+const getContentType = (filePath: string): string => {
+ const ext = filePath.split('.').pop()?.toLowerCase()
+ switch (ext) {
+ case 'html':
+ return 'text/html'
+ case 'htm':
+ return 'text/html'
+ case 'css':
+ return 'text/css'
+ case 'js':
+ return 'application/javascript'
+ case 'mjs':
+ case 'cjs':
+ return 'text/javascript'
+ case 'json':
+ return 'application/json'
+ case 'png':
+ return 'image/png'
+ case 'jpg':
+ case 'jpeg':
+ return 'image/jpeg'
+ case 'gif':
+ return 'image/gif'
+ case 'svg':
+ return 'image/svg+xml'
+ case 'txt':
+ return 'text/plain'
+ case 'ico':
+ return 'image/x-icon'
+ case 'webp':
+ return 'image/webp'
+ case 'avif':
+ return 'image/avif'
+ case 'wasm':
+ return 'application/wasm'
+ case 'woff':
+ return 'font/woff'
+ case 'woff2':
+ return 'font/woff2'
+ case 'map':
+ return 'application/json'
+ case 'pdf':
+ return 'application/pdf'
+ case 'zip':
+ return 'application/zip'
+ case 'mp4':
+ return 'video/mp4'
+ case 'webm':
+ return 'video/webm'
+ case 'ogg':
+ return 'audio/ogg'
+ case 'mp3':
+ return 'audio/mpeg'
+ case 'wav':
+ return 'audio/wav'
+ default:
+ return 'application/octet-stream'
+ }
+}
+
+const isTextType = (contentType: string) =>
+ contentType.startsWith('text/') ||
+ contentType.includes('json') ||
+ contentType.includes('javascript') ||
+ contentType.includes('xml') ||
+ contentType.includes('svg')
+
+const resolveFilePath = async (requestedPath: string) => {
+ try {
+ const stats = await stat(requestedPath)
+ if (stats.isDirectory()) return join(requestedPath, 'index.html')
+ } catch {
+ // ignore missing stats; fallback to requested path
+ }
+
+ if (requestedPath.endsWith(sep)) return join(requestedPath, 'index.html')
+
+ return requestedPath
+}
+
+const trySendFile = async (
+ ctx: any,
+ filePath: string,
+ options: {
+ forceHtml?: boolean
+ resolvedRoot: string
+ allowSymlinks: boolean
+ method: string
+ },
+): Promise => {
+ try {
+ const stats = await stat(filePath)
+ if (!stats.isFile()) return
+ const { forceHtml, resolvedRoot, allowSymlinks, method } = options
+
+ const real = await realpath(filePath)
+ if (!allowSymlinks) {
+ const insideRoot =
+ real === resolvedRoot || real.startsWith(resolvedRoot + sep)
+ if (!insideRoot) return ctx.res.send('Forbidden', { status: 403 })
+ }
+
+ const file = Bun.file(real)
+ const contentType = forceHtml ? 'text/html' : getContentType(real)
+
+ // For HEAD, do not read file content; return headers only
+ if (method === 'HEAD')
+ return ctx.res.send('', {
+ headers: { 'Content-Type': contentType },
+ })
+
+ const body = isTextType(contentType)
+ ? await file.text()
+ : await file.arrayBuffer()
+
+ return ctx.res.send(body, {
+ headers: { 'Content-Type': contentType },
+ })
+ } catch {
+ return
+ }
+}
+
+const isDotPath = (relativePath: string) =>
+ relativePath
+ .split(/[\\/]/)
+ .filter((segment) => segment !== '' && segment !== '.')
+ .some((segment) => segment.startsWith('.'))
+
+/**
+ * Middleware to serve static files (HTML, JS, CSS, etc.)
+ * @param options.rootPath Path to the root directory of static files (e.g., 'dist')
+ * @param options.fallbackFile File to return in case of 404 (e.g., 'index.html' for a SPA)
+ * @param options.stripPrefix Optional prefix to strip when mounted under a sub-route
+ * @param options.allowDotfiles Allow serving dotfiles (default: false)
+ * @param options.allowSymlinks Allow serving symlinks (default: false)
+ */
+export const html = (options: HtmlOptions): WobeHandler => {
+ const {
+ rootPath,
+ fallbackFile,
+ stripPrefix,
+ allowDotfiles = false,
+ allowSymlinks = false,
+ } = options
+ const resolvedRoot = resolve(rootPath)
+ const normalizedPrefix = stripPrefix
+ ? stripPrefix.replace(/\/+$/, '')
+ : undefined
+
+ return async (ctx) => {
+ const method = ctx.request.method?.toUpperCase?.() || 'GET'
+ if (method !== 'GET' && method !== 'HEAD') {
+ return ctx.res.send('Method Not Allowed', {
+ status: 405,
+ headers: { Allow: 'GET, HEAD' },
+ })
+ }
+
+ const { pathname } = new URL(ctx.request.url)
+ let decodedPathname: string
+
+ try {
+ decodedPathname = decodeURIComponent(pathname)
+ } catch {
+ return ctx.res.send('Bad Request', { status: 400 })
+ }
+
+ // Strip mounting prefix if configured (for mounted routes like /static/*)
+ const withoutPrefix =
+ normalizedPrefix && decodedPathname.startsWith(normalizedPrefix)
+ ? decodedPathname.slice(normalizedPrefix.length) || '/'
+ : decodedPathname
+
+ const normalizedPathRaw = normalize(
+ withoutPrefix.replace(/^\/+/, '') || '',
+ )
+ const normalizedPath =
+ normalizedPathRaw === '.' ? '' : normalizedPathRaw
+
+ if (!allowDotfiles && isDotPath(normalizedPath))
+ return ctx.res.send('Not Found', { status: 404 })
+
+ const requestedPath = resolve(resolvedRoot, normalizedPath)
+ const isInsideRoot =
+ requestedPath === resolvedRoot ||
+ requestedPath.startsWith(resolvedRoot + sep)
+
+ if (!isInsideRoot) return ctx.res.send('Forbidden', { status: 403 })
+
+ const filePath = await resolveFilePath(requestedPath)
+
+ const served = await trySendFile(ctx, filePath, {
+ resolvedRoot,
+ allowSymlinks,
+ method,
+ })
+ if (served) return served
+
+ if (fallbackFile) {
+ const fallbackPath = resolve(resolvedRoot, fallbackFile)
+ const isFallbackInsideRoot =
+ fallbackPath === resolvedRoot ||
+ fallbackPath.startsWith(resolvedRoot + sep)
+
+ if (isFallbackInsideRoot) {
+ const fallbackServed = await trySendFile(ctx, fallbackPath, {
+ forceHtml: true,
+ resolvedRoot,
+ allowSymlinks,
+ method,
+ })
+ if (fallbackServed) return fallbackServed
+ }
+ }
+
+ return ctx.res.send('Not Found', { status: 404 })
+ }
+}
diff --git a/packages/wobe/src/hooks/index.ts b/packages/wobe/src/hooks/index.ts
index 0b79636..aed3c25 100644
--- a/packages/wobe/src/hooks/index.ts
+++ b/packages/wobe/src/hooks/index.ts
@@ -6,3 +6,4 @@ export * from './bearerAuth'
export * from './logger'
export * from './rateLimit'
export * from './uploadDirectory'
+export * from './html'