new default not-found error page

Massimo Melina committed Apr 28, 2026 at 00:16 UTC 1db58efaaa5aa19362ae2e78a5f64aff6adfd769
2 files changed +40 -9
src/errorPages.ts
+24 -9
@@ -1,7 +1,9 @@
1 import Koa from 'koa'
2 import { getLangData } from './lang'
3 import { getSection } from './customHtml'
4 -import { HTTP_FORBIDDEN, HTTP_MESSAGES, HTTP_NOT_FOUND, HTTP_TOO_MANY_REQUESTS, HTTP_UNAUTHORIZED } from './cross'
4 +import {
5 + HTTP_FORBIDDEN, HTTP_MESSAGES, HTTP_NOT_FOUND, HTTP_TOO_MANY_REQUESTS, HTTP_UNAUTHORIZED, replace
6 +} from './cross'
7
8 const declaredErrorPages = [HTTP_NOT_FOUND, HTTP_FORBIDDEN, HTTP_TOO_MANY_REQUESTS].map(String)
9
@@ -14,15 +16,28 @@ export async function sendErrorPage(ctx: Koa.Context, code=ctx.status) {
16 ctx.type = 'text'
17 ctx.set('content-disposition', '') // reset ctx.attachment (or forceDownload)
18 ctx.status = code
17 - const msg = HTTP_MESSAGES[ctx.status]
19 + let msg = HTTP_MESSAGES[ctx.status] || ''
20 if (!msg) return
21 const lang = await getLangData(ctx)
20 - if (!lang) return
21 - const trans = (Object.values(lang)[0] as any)?.translate
22 - ctx.body = trans?.[msg] ?? msg
23 - const errorPage = getSection(ctx.status === HTTP_UNAUTHORIZED ? 'unauthorized' : String(ctx.status))
24 - if (!errorPage) return
25 - if (errorPage.includes('<'))
22 + const trans = lang ? (Object.values(lang)[0] as any)?.translate : undefined
23 + msg = trans?.[msg] ?? msg
24 + const page = getSection(ctx.status === HTTP_UNAUTHORIZED ? 'unauthorized' : String(ctx.status))
25 + || SIMPLE_PAGE || ''
26 + if (page.includes('<'))
27 ctx.type = 'html'
27 - ctx.body = errorPage.replace('$MESSAGE', String(ctx.body))
28 + ctx.body = replace(page, { MESSAGE: msg, HOME: trans?.home ?? 'home', URL: ctx.state.revProxyPath || '/' }, '$')
29 }
30 +
31 +const SIMPLE_PAGE = `<!DOCTYPE html>
32 +<html><head>
33 +<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
34 +<title>$MESSAGE</title>
35 +<style>
36 +body{margin-top:30vh; text-align:center; font-family:sans-serif;}
37 +a{color:#68a}
38 +@media (prefers-color-scheme:dark){body{background:#111; color:#999;}}
39 +</style>
40 +</head><body>
41 +<h1>$MESSAGE</h1>
42 +<h2><a href="$URL">$HOME</a></h2>
43 +</body></html>`
tests/test.ts
+16
@@ -144,6 +144,11 @@ describe('basics', () => {
144 test('traversal.mixed-dots', req('/f1/page/.%2e/%2e./README.md', 404))
145 test('traversal.overlong-utf8', req('/f1/page/%c0%ae%c0%ae/%c0%ae%c0%ae/README.md', 404))
146 test('bad url encoding', req('/f1/%E0%A4%A', 404))
147 + test('not-found.default page', req('/missing-default-404', /found<\/h1>/))
148 + test('not-found.custom page overrides default', () =>
149 + withCustomHtml({ 404: '<strong>custom 404 $MESSAGE</strong>' }, () =>
150 + req('/missing-custom-404', /^<strong>custom 404 Not found<\/strong>$/)()) )
151 + test('not-found.default page reverse proxy root', req('/missing-proxy-404', /href="\/prefix/, { headers: { 'x-forwarded-prefix': '/prefix' } }))
152 test('custom mime from above', req('/tests/page/index.html', { status: 200, mime:'text/plain' }))
153 test('name encoding', req(FUNNY_NAME_ENCODED, 200))
154 test('name encoding list', reqList('/', { inList: [FUNNY_NAME] }))
@@ -1578,6 +1583,17 @@ async function withPluginConfig(id: string, config: object, cb: () => Promise<vo
1583 }
1584 }
1585
1586 +async function withCustomHtml(sections: Record<string, string>, cb: () => Promise<void>) {
1587 + const prev = await reqApi('get_custom_html', {}, res => res?.sections, { auth, jar: {} })()
1588 + await reqApi('set_custom_html', { sections: { ...prev.sections, ...sections } }, 200, { auth, jar: {} })()
1589 + try {
1590 + await cb()
1591 + }
1592 + finally {
1593 + await reqApi('set_custom_html', { sections: prev.sections }, 200, { auth, jar: {} })()
1594 + }
1595 +}
1596 +
1597 async function reqBasicAuth(url: string, credentials: string) {
1598 const authorization = 'Basic ' + Buffer.from(credentials).toString('base64')
1599 const response = await httpStream(defaultBaseUrl + url, {