diff --git a/src/index.tsx b/src/index.tsx index 1039cd1..1fb92d1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -176,14 +176,18 @@ export class Hype< }) // file based routing - this.on('GET', ['/', '/:page'], async c => { - const pageName = (c.req.param('page') ?? 'index').replace('.', '') - if (pageName.startsWith('_')) return render404(c) + this.on('GET', ['/', '/:page{.+}'], async c => { + const pageName = (c.req.param('page') ?? 'index').replace(/\.\./g, '') + if (pageName.split('/').some(s => s.startsWith('_'))) return render404(c) - const path = join(process.cwd(), `./src/pages/${pageName}.tsx`) - - if (!(await Bun.file(path).exists())) - return render404(c) + // 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())) + return render404(c) + } let Layout = defaultLayout const layoutPath = join(process.cwd(), `./src/pages/_layout.tsx`) diff --git a/src/tests/pages-routing.test.ts b/src/tests/pages-routing.test.ts new file mode 100644 index 0000000..0cc2f7f --- /dev/null +++ b/src/tests/pages-routing.test.ts @@ -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

Home

`) + writeFileSync(join(pagesDir, "about.tsx"), `export default

About

`) + writeFileSync(join(pagesDir, "editor/spell.tsx"), `export default

Spell Editor

`) + writeFileSync(join(pagesDir, "deep/nested/page.tsx"), `export default

Deep Page

`) + writeFileSync(join(pagesDir, "editor/index.tsx"), `export default

Editor Index

`) + writeFileSync(join(pagesDir, "_private.tsx"), `export default

Private

`) + writeFileSync(join(pagesDir, "editor/_secret.tsx"), `export default

Secret

`) +}) + +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 +})