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, {