toes/src/server/static.ts

93 lines
3.0 KiB
TypeScript

import { APPS_DIR } from '$apps'
import { existsSync, readdirSync, statSync } from 'fs'
import { join } from 'path'
export async function serveStatic(appName: string, req: Request): Promise<Response> {
const url = new URL(req.url)
const pathname = decodeURIComponent(url.pathname)
const pubDir = join(APPS_DIR, appName, 'pub')
// Resolve the file path, preventing directory traversal
const filePath = join(pubDir, pathname)
if (!filePath.startsWith(pubDir)) {
return new Response('Forbidden', { status: 403 })
}
// Directory: try index.html, then file listing
if (existsSync(filePath) && statSync(filePath).isDirectory()) {
const indexPath = join(filePath, 'index.html')
if (existsSync(indexPath)) {
return new Response(Bun.file(indexPath))
}
return fileListing(appName, pathname, filePath)
}
// Exact file match
if (existsSync(filePath)) {
return new Response(Bun.file(filePath))
}
// Clean URLs: try .html extension
const htmlPath = filePath + '.html'
if (existsSync(htmlPath)) {
return new Response(Bun.file(htmlPath))
}
return new Response('Not Found', { status: 404 })
}
function fileListing(appName: string, pathname: string, dirPath: string): Response {
const trail = pathname.endsWith('/') ? pathname : pathname + '/'
const entries = readdirSync(dirPath, { withFileTypes: true })
.filter(e => !e.name.startsWith('.'))
.sort((a, b) => {
if (a.isDirectory() && !b.isDirectory()) return -1
if (!a.isDirectory() && b.isDirectory()) return 1
return a.name.localeCompare(b.name)
})
const rows = entries.map(e => {
const display = e.isDirectory() ? `${e.name}/` : e.name
const href = `${trail}${e.name}`
const stat = statSync(join(dirPath, e.name))
const size = e.isDirectory() ? '—' : formatSize(stat.size)
return ` <tr><td><a href="${href}">${display}</a></td><td>${size}</td></tr>`
}).join('\n')
const parent = pathname !== '/'
? ` <tr><td><a href="${trail.replace(/[^/]+\/$/, '')}">..</a></td><td></td></tr>\n`
: ''
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${appName}${pathname}</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
h1 { font-size: 1.25rem; }
table { width: 100%; border-collapse: collapse; }
td { padding: 0.4rem 0.8rem; border-bottom: 1px solid #eee; }
td:last-child { text-align: right; color: #666; }
a { color: #0366d6; text-decoration: none; }
a:hover { text-decoration: underline; }
</style>
</head>
<body>
<h1>${appName}${pathname}</h1>
<table>
${parent}${rows}
</table>
</body>
</html>`
return new Response(html, { headers: { 'content-type': 'text/html' } })
}
const formatSize = (bytes: number): string =>
bytes < 1024 ? `${bytes} B`
: bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB`
: `${(bytes / (1024 * 1024)).toFixed(1)} MB`