forked from defunkt/toes
93 lines
3.0 KiB
TypeScript
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`
|