Support nested file-based routing with index.tsx fallback
Pages can now resolve multi-segment paths (e.g. /editor/spell) and fall back to directory index.tsx files. Path traversal is stripped and underscore checks apply to all segments, not just the first.
This commit is contained in:
parent
f84786fb0e
commit
0f224f89e9
|
|
@ -176,14 +176,18 @@ export class Hype<
|
||||||
})
|
})
|
||||||
|
|
||||||
// file based routing
|
// file based routing
|
||||||
this.on('GET', ['/', '/:page'], async c => {
|
this.on('GET', ['/', '/:page{.+}'], async c => {
|
||||||
const pageName = (c.req.param('page') ?? 'index').replace('.', '')
|
const pageName = (c.req.param('page') ?? 'index').replace(/\.\./g, '')
|
||||||
if (pageName.startsWith('_')) return render404(c)
|
if (pageName.split('/').some(s => s.startsWith('_'))) return render404(c)
|
||||||
|
|
||||||
const path = join(process.cwd(), `./src/pages/${pageName}.tsx`)
|
|
||||||
|
|
||||||
|
// try pageName.tsx, then pageName/index.tsx
|
||||||
|
const base = join(process.cwd(), './src/pages')
|
||||||
|
let path = join(base, `${pageName}.tsx`)
|
||||||
|
if (!(await Bun.file(path).exists())) {
|
||||||
|
path = join(base, `${pageName}/index.tsx`)
|
||||||
if (!(await Bun.file(path).exists()))
|
if (!(await Bun.file(path).exists()))
|
||||||
return render404(c)
|
return render404(c)
|
||||||
|
}
|
||||||
|
|
||||||
let Layout = defaultLayout
|
let Layout = defaultLayout
|
||||||
const layoutPath = join(process.cwd(), `./src/pages/_layout.tsx`)
|
const layoutPath = join(process.cwd(), `./src/pages/_layout.tsx`)
|
||||||
|
|
|
||||||
137
src/tests/pages-routing.test.ts
Normal file
137
src/tests/pages-routing.test.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
import { test, expect, beforeAll, afterAll } from "bun:test"
|
||||||
|
import { Hype } from "../index.tsx"
|
||||||
|
import { join } from "path"
|
||||||
|
import { mkdirSync, writeFileSync, rmSync } from "fs"
|
||||||
|
|
||||||
|
// PWD is set to testRoot, so pages resolve at testRoot/src/pages/
|
||||||
|
const testRoot = join(process.cwd(), "src/tests/_test_root")
|
||||||
|
const pagesDir = join(testRoot, "src/pages")
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
mkdirSync(join(pagesDir, "editor"), { recursive: true })
|
||||||
|
mkdirSync(join(pagesDir, "deep/nested"), { recursive: true })
|
||||||
|
|
||||||
|
writeFileSync(join(pagesDir, "index.tsx"), `export default <h1>Home</h1>`)
|
||||||
|
writeFileSync(join(pagesDir, "about.tsx"), `export default <h1>About</h1>`)
|
||||||
|
writeFileSync(join(pagesDir, "editor/spell.tsx"), `export default <h1>Spell Editor</h1>`)
|
||||||
|
writeFileSync(join(pagesDir, "deep/nested/page.tsx"), `export default <h1>Deep Page</h1>`)
|
||||||
|
writeFileSync(join(pagesDir, "editor/index.tsx"), `export default <h1>Editor Index</h1>`)
|
||||||
|
writeFileSync(join(pagesDir, "_private.tsx"), `export default <h1>Private</h1>`)
|
||||||
|
writeFileSync(join(pagesDir, "editor/_secret.tsx"), `export default <h1>Secret</h1>`)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
rmSync(testRoot, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("single-segment page route", async () => {
|
||||||
|
const origPwd = process.env.PWD
|
||||||
|
process.env.PWD = testRoot
|
||||||
|
const app = new Hype({ layout: false, logging: false, prettyHTML: false })
|
||||||
|
const server = Bun.serve({ port: 0, fetch: app.defaults.fetch })
|
||||||
|
|
||||||
|
const res = await fetch(`http://localhost:${server.port}/about`)
|
||||||
|
const text = await res.text()
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(text).toContain("About")
|
||||||
|
|
||||||
|
server.stop(true)
|
||||||
|
process.env.PWD = origPwd
|
||||||
|
})
|
||||||
|
|
||||||
|
test("nested page route (editor/spell)", async () => {
|
||||||
|
const origPwd = process.env.PWD
|
||||||
|
process.env.PWD = testRoot
|
||||||
|
const app = new Hype({ layout: false, logging: false, prettyHTML: false })
|
||||||
|
const server = Bun.serve({ port: 0, fetch: app.defaults.fetch })
|
||||||
|
|
||||||
|
const res = await fetch(`http://localhost:${server.port}/editor/spell`)
|
||||||
|
const text = await res.text()
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(text).toContain("Spell Editor")
|
||||||
|
|
||||||
|
server.stop(true)
|
||||||
|
process.env.PWD = origPwd
|
||||||
|
})
|
||||||
|
|
||||||
|
test("deeply nested page route", async () => {
|
||||||
|
const origPwd = process.env.PWD
|
||||||
|
process.env.PWD = testRoot
|
||||||
|
const app = new Hype({ layout: false, logging: false, prettyHTML: false })
|
||||||
|
const server = Bun.serve({ port: 0, fetch: app.defaults.fetch })
|
||||||
|
|
||||||
|
const res = await fetch(`http://localhost:${server.port}/deep/nested/page`)
|
||||||
|
const text = await res.text()
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(text).toContain("Deep Page")
|
||||||
|
|
||||||
|
server.stop(true)
|
||||||
|
process.env.PWD = origPwd
|
||||||
|
})
|
||||||
|
|
||||||
|
test("subdirectory index.tsx fallback", async () => {
|
||||||
|
const origPwd = process.env.PWD
|
||||||
|
process.env.PWD = testRoot
|
||||||
|
const app = new Hype({ layout: false, logging: false, prettyHTML: false })
|
||||||
|
const server = Bun.serve({ port: 0, fetch: app.defaults.fetch })
|
||||||
|
|
||||||
|
const res = await fetch(`http://localhost:${server.port}/editor`)
|
||||||
|
const text = await res.text()
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(text).toContain("Editor Index")
|
||||||
|
|
||||||
|
server.stop(true)
|
||||||
|
process.env.PWD = origPwd
|
||||||
|
})
|
||||||
|
|
||||||
|
test("underscore-prefixed files return 404", async () => {
|
||||||
|
const origPwd = process.env.PWD
|
||||||
|
process.env.PWD = testRoot
|
||||||
|
const app = new Hype({ layout: false, logging: false, prettyHTML: false })
|
||||||
|
const server = Bun.serve({ port: 0, fetch: app.defaults.fetch })
|
||||||
|
|
||||||
|
const res = await fetch(`http://localhost:${server.port}/_private`)
|
||||||
|
expect(res.status).toBe(404)
|
||||||
|
|
||||||
|
server.stop(true)
|
||||||
|
process.env.PWD = origPwd
|
||||||
|
})
|
||||||
|
|
||||||
|
test("underscore-prefixed files in subdirectories return 404", async () => {
|
||||||
|
const origPwd = process.env.PWD
|
||||||
|
process.env.PWD = testRoot
|
||||||
|
const app = new Hype({ layout: false, logging: false, prettyHTML: false })
|
||||||
|
const server = Bun.serve({ port: 0, fetch: app.defaults.fetch })
|
||||||
|
|
||||||
|
const res = await fetch(`http://localhost:${server.port}/editor/_secret`)
|
||||||
|
expect(res.status).toBe(404)
|
||||||
|
|
||||||
|
server.stop(true)
|
||||||
|
process.env.PWD = origPwd
|
||||||
|
})
|
||||||
|
|
||||||
|
test("nonexistent page returns 404", async () => {
|
||||||
|
const origPwd = process.env.PWD
|
||||||
|
process.env.PWD = testRoot
|
||||||
|
const app = new Hype({ layout: false, logging: false, prettyHTML: false })
|
||||||
|
const server = Bun.serve({ port: 0, fetch: app.defaults.fetch })
|
||||||
|
|
||||||
|
const res = await fetch(`http://localhost:${server.port}/does/not/exist`)
|
||||||
|
expect(res.status).toBe(404)
|
||||||
|
|
||||||
|
server.stop(true)
|
||||||
|
process.env.PWD = origPwd
|
||||||
|
})
|
||||||
|
|
||||||
|
test("path traversal is blocked", async () => {
|
||||||
|
const origPwd = process.env.PWD
|
||||||
|
process.env.PWD = testRoot
|
||||||
|
const app = new Hype({ layout: false, logging: false, prettyHTML: false })
|
||||||
|
const server = Bun.serve({ port: 0, fetch: app.defaults.fetch })
|
||||||
|
|
||||||
|
const res = await fetch(`http://localhost:${server.port}/../../../etc/passwd`)
|
||||||
|
expect(res.status).toBe(404)
|
||||||
|
|
||||||
|
server.stop(true)
|
||||||
|
process.env.PWD = origPwd
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user