From 642f814b5d29c0b630a97bf3d8b306aaf576a4d5 Mon Sep 17 00:00:00 2001 From: Lucas Coratger <73360179+coratgerl@users.noreply.github.com> Date: Thu, 4 Dec 2025 21:05:02 +0100 Subject: [PATCH 1/4] feat(wobe): add html hook --- packages/wobe/src/hooks/html.test.ts | 187 ++++++++++++++++++ packages/wobe/src/hooks/html.ts | 76 +++++++ packages/wobe/test_static_files/data.json | 1 + .../test_static_files/file with spaces.html | 1 + packages/wobe/test_static_files/index.html | 1 + packages/wobe/test_static_files/script.js | 1 + packages/wobe/test_static_files/styles.css | 1 + .../wobe/test_static_files/subdir/index.html | 1 + packages/wobe/test_static_files/test.html | 1 + 9 files changed, 270 insertions(+) create mode 100644 packages/wobe/src/hooks/html.test.ts create mode 100644 packages/wobe/src/hooks/html.ts create mode 100644 packages/wobe/test_static_files/data.json create mode 100644 packages/wobe/test_static_files/file with spaces.html create mode 100644 packages/wobe/test_static_files/index.html create mode 100644 packages/wobe/test_static_files/script.js create mode 100644 packages/wobe/test_static_files/styles.css create mode 100644 packages/wobe/test_static_files/subdir/index.html create mode 100644 packages/wobe/test_static_files/test.html diff --git a/packages/wobe/src/hooks/html.test.ts b/packages/wobe/src/hooks/html.test.ts new file mode 100644 index 0000000..2d89468 --- /dev/null +++ b/packages/wobe/src/hooks/html.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it, beforeEach } from 'bun:test' +import { html } from './html' +import { WobeResponse } from '../WobeResponse' +import { join } from 'path' +import { rm, mkdir, writeFile } 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) + + // 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', + ) + }) + + 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 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 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, '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, 'nonexistent-fallback.html') + + const response = await middleware(ctx) + expect(response?.status).toBe(404) + expect(await response?.text()).toBe('Not Found') + }) + + it.only('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') + }) +}) diff --git a/packages/wobe/src/hooks/html.ts b/packages/wobe/src/hooks/html.ts new file mode 100644 index 0000000..d9e100a --- /dev/null +++ b/packages/wobe/src/hooks/html.ts @@ -0,0 +1,76 @@ +import { join } from 'path' +import type { WobeHandler } from '../Wobe' + +/** + * Middleware to serve static files (HTML, JS, CSS, etc.) + * @param rootPath Path to the root directory of static files (e.g., 'dist') + * @param fallbackFile File to return in case of 404 (e.g., 'index.html' for a SPA) + */ +export const html = ( + rootPath: string, + fallbackFile: string = 'index.html', +): WobeHandler => { + return async (ctx) => { + const { pathname } = new URL(ctx.request.url) + const filePath = join(rootPath, pathname) + + // Try to read the file + try { + const file = Bun.file(filePath) + if (await file.exists()) { + // Determine Content-Type based on file extension + const contentType = getContentType(filePath) + + return ctx.res.send(await file.text(), { + headers: { 'Content-Type': contentType }, + }) + } + } catch { + // File not found, proceed to SPA fallback if defined + } + + // If the file does not exist and SPA fallback is defined, serve it + if (fallbackFile) { + const fallbackPath = join(rootPath, fallbackFile) + try { + const fallbackFile = Bun.file(fallbackPath) + if (await fallbackFile.exists()) { + return ctx.res.send(fallbackFile.text(), { + headers: { 'Content-Type': 'text/html' }, + }) + } + } catch {} + } + + // Return 404 if no file was found + return ctx.res.send('Not Found', { status: 404 }) + } +} + +// Function to determine Content-Type based on file extension +function getContentType(filePath: string): string { + const ext = filePath.split('.').pop()?.toLowerCase() + switch (ext) { + case 'html': + return 'text/html' + case 'css': + return 'text/css' + case 'js': + return 'application/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' + default: + return 'application/octet-stream' + } +} diff --git a/packages/wobe/test_static_files/data.json b/packages/wobe/test_static_files/data.json new file mode 100644 index 0000000..fb25aa1 --- /dev/null +++ b/packages/wobe/test_static_files/data.json @@ -0,0 +1 @@ +{"test": true} \ No newline at end of file diff --git a/packages/wobe/test_static_files/file with spaces.html b/packages/wobe/test_static_files/file with spaces.html new file mode 100644 index 0000000..e51a45d --- /dev/null +++ b/packages/wobe/test_static_files/file with spaces.html @@ -0,0 +1 @@ +Spaces \ No newline at end of file diff --git a/packages/wobe/test_static_files/index.html b/packages/wobe/test_static_files/index.html new file mode 100644 index 0000000..a463610 --- /dev/null +++ b/packages/wobe/test_static_files/index.html @@ -0,0 +1 @@ +Index \ No newline at end of file diff --git a/packages/wobe/test_static_files/script.js b/packages/wobe/test_static_files/script.js new file mode 100644 index 0000000..4b2e663 --- /dev/null +++ b/packages/wobe/test_static_files/script.js @@ -0,0 +1 @@ +console.log('test') diff --git a/packages/wobe/test_static_files/styles.css b/packages/wobe/test_static_files/styles.css new file mode 100644 index 0000000..307d246 --- /dev/null +++ b/packages/wobe/test_static_files/styles.css @@ -0,0 +1 @@ +body { color: red; } \ No newline at end of file diff --git a/packages/wobe/test_static_files/subdir/index.html b/packages/wobe/test_static_files/subdir/index.html new file mode 100644 index 0000000..21d6403 --- /dev/null +++ b/packages/wobe/test_static_files/subdir/index.html @@ -0,0 +1 @@ +Subdir \ No newline at end of file diff --git a/packages/wobe/test_static_files/test.html b/packages/wobe/test_static_files/test.html new file mode 100644 index 0000000..349665e --- /dev/null +++ b/packages/wobe/test_static_files/test.html @@ -0,0 +1 @@ +Test \ No newline at end of file From 7e90e198f1c4f5d5effd92b2c207b3643f3abdda Mon Sep 17 00:00:00 2001 From: coratgerl <73360179+coratgerl@users.noreply.github.com> Date: Sat, 6 Dec 2025 13:00:14 +0100 Subject: [PATCH 2/4] fix: tests --- .gitignore | 1 + packages/wobe/src/hooks/html.test.ts | 8 ++++++-- packages/wobe/src/hooks/html.ts | 13 ++++++++----- packages/wobe/test_static_files/data.json | 1 - .../wobe/test_static_files/file with spaces.html | 1 - packages/wobe/test_static_files/index.html | 1 - packages/wobe/test_static_files/script.js | 1 - packages/wobe/test_static_files/styles.css | 1 - packages/wobe/test_static_files/subdir/index.html | 1 - packages/wobe/test_static_files/test.html | 1 - 10 files changed, 15 insertions(+), 14 deletions(-) delete mode 100644 packages/wobe/test_static_files/data.json delete mode 100644 packages/wobe/test_static_files/file with spaces.html delete mode 100644 packages/wobe/test_static_files/index.html delete mode 100644 packages/wobe/test_static_files/script.js delete mode 100644 packages/wobe/test_static_files/styles.css delete mode 100644 packages/wobe/test_static_files/subdir/index.html delete mode 100644 packages/wobe/test_static_files/test.html 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/packages/wobe/src/hooks/html.test.ts b/packages/wobe/src/hooks/html.test.ts index 2d89468..10daf90 100644 --- a/packages/wobe/src/hooks/html.test.ts +++ b/packages/wobe/src/hooks/html.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, beforeEach } from 'bun:test' +import { describe, expect, it, beforeEach, afterEach } from 'bun:test' import { html } from './html' import { WobeResponse } from '../WobeResponse' import { join } from 'path' @@ -39,6 +39,10 @@ describe('html', () => { ) }) + afterEach(async () => { + await rm(rootPath, { recursive: true, 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) @@ -127,7 +131,7 @@ describe('html', () => { expect(await response?.text()).toBe('Not Found') }) - it.only('should handle URL-encoded paths', async () => { + it('should handle URL-encoded paths', async () => { // Create a file with spaces in the name await writeFile( join(TEST_DIR, 'file with spaces.html'), diff --git a/packages/wobe/src/hooks/html.ts b/packages/wobe/src/hooks/html.ts index d9e100a..5590369 100644 --- a/packages/wobe/src/hooks/html.ts +++ b/packages/wobe/src/hooks/html.ts @@ -8,11 +8,14 @@ import type { WobeHandler } from '../Wobe' */ export const html = ( rootPath: string, - fallbackFile: string = 'index.html', + fallbackFile?: string, ): WobeHandler => { return async (ctx) => { const { pathname } = new URL(ctx.request.url) - const filePath = join(rootPath, pathname) + const decodedPathname = decodeURI(pathname).endsWith('/') + ? `${decodeURI(pathname)}index.html` + : decodeURI(pathname) + const filePath = join(rootPath, decodedPathname) // Try to read the file try { @@ -33,9 +36,9 @@ export const html = ( if (fallbackFile) { const fallbackPath = join(rootPath, fallbackFile) try { - const fallbackFile = Bun.file(fallbackPath) - if (await fallbackFile.exists()) { - return ctx.res.send(fallbackFile.text(), { + const fallbackBunFile = Bun.file(fallbackPath) + if (await fallbackBunFile.exists()) { + return ctx.res.send(await fallbackBunFile.text(), { headers: { 'Content-Type': 'text/html' }, }) } diff --git a/packages/wobe/test_static_files/data.json b/packages/wobe/test_static_files/data.json deleted file mode 100644 index fb25aa1..0000000 --- a/packages/wobe/test_static_files/data.json +++ /dev/null @@ -1 +0,0 @@ -{"test": true} \ No newline at end of file diff --git a/packages/wobe/test_static_files/file with spaces.html b/packages/wobe/test_static_files/file with spaces.html deleted file mode 100644 index e51a45d..0000000 --- a/packages/wobe/test_static_files/file with spaces.html +++ /dev/null @@ -1 +0,0 @@ -Spaces \ No newline at end of file diff --git a/packages/wobe/test_static_files/index.html b/packages/wobe/test_static_files/index.html deleted file mode 100644 index a463610..0000000 --- a/packages/wobe/test_static_files/index.html +++ /dev/null @@ -1 +0,0 @@ -Index \ No newline at end of file diff --git a/packages/wobe/test_static_files/script.js b/packages/wobe/test_static_files/script.js deleted file mode 100644 index 4b2e663..0000000 --- a/packages/wobe/test_static_files/script.js +++ /dev/null @@ -1 +0,0 @@ -console.log('test') diff --git a/packages/wobe/test_static_files/styles.css b/packages/wobe/test_static_files/styles.css deleted file mode 100644 index 307d246..0000000 --- a/packages/wobe/test_static_files/styles.css +++ /dev/null @@ -1 +0,0 @@ -body { color: red; } \ No newline at end of file diff --git a/packages/wobe/test_static_files/subdir/index.html b/packages/wobe/test_static_files/subdir/index.html deleted file mode 100644 index 21d6403..0000000 --- a/packages/wobe/test_static_files/subdir/index.html +++ /dev/null @@ -1 +0,0 @@ -Subdir \ No newline at end of file diff --git a/packages/wobe/test_static_files/test.html b/packages/wobe/test_static_files/test.html deleted file mode 100644 index 349665e..0000000 --- a/packages/wobe/test_static_files/test.html +++ /dev/null @@ -1 +0,0 @@ -Test \ No newline at end of file From be8c7c70789a69440aaa81fd417cae576ca5aae5 Mon Sep 17 00:00:00 2001 From: Lucas Coratger <73360179+coratgerl@users.noreply.github.com> Date: Sat, 6 Dec 2025 15:48:16 +0100 Subject: [PATCH 3/4] fix: tests and improve security --- .../doc/ecosystem/hooks/html.md | 44 +++ .../doc/ecosystem/hooks/index.md | 6 +- packages/wobe/dev/index.ts | 18 +- packages/wobe/fixtures/test.html | 5 + packages/wobe/src/hooks/html.test.ts | 209 ++++++++++++++- packages/wobe/src/hooks/html.ts | 252 ++++++++++++++---- packages/wobe/src/hooks/index.ts | 1 + 7 files changed, 467 insertions(+), 68 deletions(-) create mode 100644 packages/wobe-documentation/doc/ecosystem/hooks/html.md create mode 100644 packages/wobe/fixtures/test.html 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 index 10daf90..fbd00cc 100644 --- a/packages/wobe/src/hooks/html.test.ts +++ b/packages/wobe/src/hooks/html.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, beforeEach, afterEach } from 'bun:test' import { html } from './html' import { WobeResponse } from '../WobeResponse' import { join } from 'path' -import { rm, mkdir, writeFile } from 'node:fs/promises' +import { rm, mkdir, writeFile, symlink } from 'node:fs/promises' // Mock context object class MockContext { @@ -18,6 +18,7 @@ class MockContext { 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 () => { @@ -41,12 +42,13 @@ describe('html', () => { 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 middleware = html({ rootPath }) const response = await middleware(ctx) expect(response?.status).toBe(200) @@ -57,7 +59,7 @@ describe('html', () => { 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 middleware = html({ rootPath }) const response = await middleware(ctx) expect(response?.status).toBe(200) @@ -68,7 +70,7 @@ describe('html', () => { 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 middleware = html({ rootPath }) const response = await middleware(ctx) expect(response?.status).toBe(200) @@ -81,7 +83,7 @@ describe('html', () => { 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 middleware = html({ rootPath }) const response = await middleware(ctx) expect(response?.status).toBe(200) @@ -89,10 +91,44 @@ describe('html', () => { 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 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) @@ -103,7 +139,7 @@ describe('html', () => { 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 middleware = html({ rootPath }) const response = await middleware(ctx) expect(response?.status).toBe(404) @@ -113,7 +149,7 @@ describe('html', () => { 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, 'index.html') + const middleware = html({ rootPath, fallbackFile: 'index.html' }) const response = await middleware(ctx) expect(response?.status).toBe(200) @@ -124,7 +160,10 @@ describe('html', () => { 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, 'nonexistent-fallback.html') + const middleware = html({ + rootPath, + fallbackFile: 'nonexistent-fallback.html', + }) const response = await middleware(ctx) expect(response?.status).toBe(404) @@ -142,7 +181,7 @@ describe('html', () => { 'http://localhost:3000/file%20with%20spaces.html', ) const ctx = new MockContext(request) - const middleware = html(rootPath) + const middleware = html({ rootPath }) const response = await middleware(ctx) expect(response?.status).toBe(200) @@ -155,7 +194,7 @@ describe('html', () => { const request = new Request('http://localhost:3000/noextension') const ctx = new MockContext(request) - const middleware = html(rootPath) + const middleware = html({ rootPath }) const response = await middleware(ctx) expect(response?.status).toBe(200) @@ -170,7 +209,7 @@ describe('html', () => { 'http://localhost:3000/test.html?param=value', ) const ctx = new MockContext(request) - const middleware = html(rootPath) + const middleware = html({ rootPath }) const response = await middleware(ctx) expect(response?.status).toBe(200) @@ -181,11 +220,155 @@ describe('html', () => { it('should handle root path request', async () => { const request = new Request('http://localhost:3000/') const ctx = new MockContext(request) - const middleware = html(rootPath) + 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 index 5590369..53a4f75 100644 --- a/packages/wobe/src/hooks/html.ts +++ b/packages/wobe/src/hooks/html.ts @@ -1,65 +1,29 @@ -import { join } from 'path' +import { join, normalize, resolve, sep } from 'path' +import { stat, realpath } from 'node:fs/promises' import type { WobeHandler } from '../Wobe' -/** - * Middleware to serve static files (HTML, JS, CSS, etc.) - * @param rootPath Path to the root directory of static files (e.g., 'dist') - * @param fallbackFile File to return in case of 404 (e.g., 'index.html' for a SPA) - */ -export const html = ( - rootPath: string, - fallbackFile?: string, -): WobeHandler => { - return async (ctx) => { - const { pathname } = new URL(ctx.request.url) - const decodedPathname = decodeURI(pathname).endsWith('/') - ? `${decodeURI(pathname)}index.html` - : decodeURI(pathname) - const filePath = join(rootPath, decodedPathname) - - // Try to read the file - try { - const file = Bun.file(filePath) - if (await file.exists()) { - // Determine Content-Type based on file extension - const contentType = getContentType(filePath) - - return ctx.res.send(await file.text(), { - headers: { 'Content-Type': contentType }, - }) - } - } catch { - // File not found, proceed to SPA fallback if defined - } - - // If the file does not exist and SPA fallback is defined, serve it - if (fallbackFile) { - const fallbackPath = join(rootPath, fallbackFile) - try { - const fallbackBunFile = Bun.file(fallbackPath) - if (await fallbackBunFile.exists()) { - return ctx.res.send(await fallbackBunFile.text(), { - headers: { 'Content-Type': 'text/html' }, - }) - } - } catch {} - } - - // Return 404 if no file was found - return ctx.res.send('Not Found', { status: 404 }) - } +export type HtmlOptions = { + rootPath: string + fallbackFile?: string + stripPrefix?: string + allowDotfiles?: boolean + allowSymlinks?: boolean } -// Function to determine Content-Type based on file extension -function getContentType(filePath: string): string { +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': @@ -73,7 +37,195 @@ function getContentType(filePath: string): string { 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' From debda8cf1216995ba8db2f853cb46b69ffa9627d Mon Sep 17 00:00:00 2001 From: Lucas Coratger <73360179+coratgerl@users.noreply.github.com> Date: Sat, 6 Dec 2025 15:49:50 +0100 Subject: [PATCH 4/4] fix: lint --- biome.json | 3 ++- packages/wobe/src/hooks/html.test.ts | 2 +- packages/wobe/src/hooks/html.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) 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/src/hooks/html.test.ts b/packages/wobe/src/hooks/html.test.ts index fbd00cc..5007a84 100644 --- a/packages/wobe/src/hooks/html.test.ts +++ b/packages/wobe/src/hooks/html.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, beforeEach, afterEach } from 'bun:test' import { html } from './html' import { WobeResponse } from '../WobeResponse' -import { join } from 'path' +import { join } from 'node:path' import { rm, mkdir, writeFile, symlink } from 'node:fs/promises' // Mock context object diff --git a/packages/wobe/src/hooks/html.ts b/packages/wobe/src/hooks/html.ts index 53a4f75..5864afe 100644 --- a/packages/wobe/src/hooks/html.ts +++ b/packages/wobe/src/hooks/html.ts @@ -1,4 +1,4 @@ -import { join, normalize, resolve, sep } from 'path' +import { join, normalize, resolve, sep } from 'node:path' import { stat, realpath } from 'node:fs/promises' import type { WobeHandler } from '../Wobe'