admin/custom: customizable "not found" and "forbidden" error pages

Massimo Melina committed Oct 28, 2023 at 11:22 UTC f5932c8a0074ce6944ed9f6a06f862e057deba1c
8 files changed +51 -37
admin/src/CustomHtmlPage.ts
+7 -4
@@ -4,7 +4,7 @@ import { createElement as h, Fragment, useEffect, useMemo, useState } from 'reac
4 import { Field, SelectField } from '@hfs/mui-grid-form'
5 import { apiCall, useApiEx } from './api'
6 import { Alert, Box } from '@mui/material'
7 -import { Dict, IconBtn, isCtrlKey, modifiedSx, reloadBtn, wikiLink } from './misc';
7 +import { Dict, HTTP_MESSAGES, IconBtn, isCtrlKey, modifiedSx, prefix, reloadBtn, wikiLink } from './misc';
8 import { Save } from '@mui/icons-material'
9 import _ from 'lodash'
10 import { useDebounce } from 'usehooks-ts'
@@ -19,10 +19,13 @@ export default function CustomHtmlPage() {
19 useEffect(() => data && setSaved(data?.sections), [data])
20 useEffect(() => setAll(saved), [saved])
21 const options = useMemo(() => {
22 - const keys = Object.keys(all)
22 + const keys = _.sortBy(Object.keys(all), x => !isNaN(+x)) // http codes at the bottom
23 if (!keys.includes(section))
24 - setSection(keys?.[0] || '')
25 - return keys.map(x => ({ value: x, label: _.startCase(x) + (all[x]?.trim() ? ' *' : '') }))
24 + setSection(_.findKey(all, Boolean) || keys?.[0] || '') // prefer any key with content
25 + return keys.map(x => ({
26 + value: x,
27 + label: (prefix('HTTP ', HTTP_MESSAGES[x as any]) || _.startCase(x)) + (all[x]?.trim() ? ' *' : '')
28 + }))
29 }, [useDebounce(all, 500)])
30 const anyChange = useMemo(() => !_.isEqualWith(saved, all, (a,b) => !a && !b || undefined),
31 [saved, all])
frontend/src/misc.ts
+4 -11
@@ -4,12 +4,12 @@ import React, { createElement as h } from 'react'
4 import { Spinner } from './components'
5 import { newDialog } from './dialog'
6 import { Icon } from './icons'
7 -import { Dict, getHFS, useBatch } from '@hfs/shared'
7 +import { Dict, getHFS, HTTP_MESSAGES, useBatch } from '@hfs/shared'
8 +import { apiCall, useApi } from '@hfs/shared/api'
9 import { state } from './state'
10 import { t } from './i18n'
11 import * as dialogLib from './dialog'
12 import _ from 'lodash'
12 -import { apiCall, setDefaultApiCallOptions, useApi } from '@hfs/shared/api'
13 import { reloadList } from './useFetchList'
14 import { logout } from './login'
15 import { subscribeKey } from 'valtio/utils'
@@ -17,16 +17,9 @@ import { uploadState } from './upload'
17 import { fileShow } from './show'
18 export * from '@hfs/shared'
19
20 -export const ERRORS: Record<number, string> = {
21 - 401: "Unauthorized",
22 - 403: "Forbidden",
23 - 404: "Not found",
24 - 500: "Server error",
25 -}
26 -
20 export function err2msg(err: number | Error) {
28 - return typeof err === 'number' ? ERRORS[err]
29 - : (ERRORS[(err as any).code] || err.message || String(err))
21 + return typeof err === 'number' ? HTTP_MESSAGES[err]
22 + : (HTTP_MESSAGES[(err as any).code] || err.message || String(err))
23 }
24
25 export function hIcon(name: string, props?:any) {
frontend/src/useFetchList.ts
+2 -2
@@ -7,7 +7,7 @@ import _ from 'lodash'
7 import { subscribeKey } from 'valtio/utils'
8 import { useIsMounted } from 'usehooks-ts'
9 import { alertDialog } from './dialog'
10 -import { ERRORS } from './misc'
10 +import { HTTP_MESSAGES, xlate } from './misc'
11 import { t } from './i18n'
12 import { useLocation, useNavigate } from 'react-router-dom'
13
@@ -84,7 +84,7 @@ export default function useFetchList() {
84 }
85 if (error) {
86 state.stopSearch?.()
87 - state.error = (ERRORS as any)[error] || String(error)
87 + state.error = xlate(error, HTTP_MESSAGES)
88 if (error === 401 && snap.username)
89 alertDialog(t('wrong_account', { u: snap.username }, "Account {u} has no access, try another"), 'warning').then()
90 state.loginRequired = error === 401
src/adminApis.ts
+2 -1
@@ -35,6 +35,7 @@ import _ from 'lodash'
35 import { getUpdates, localUpdateAvailable, update, updateSupported } from './update'
36 import { consoleLog } from './consoleLog'
37 import { resolve } from 'path'
38 +import { getErrorSections } from './errorPages'
39
40 export const adminApis: ApiHandlers = {
41
@@ -78,7 +79,7 @@ export const adminApis: ApiHandlers = {
79 get_custom_html() {
80 return {
81 sections: Object.fromEntries([
81 - ...customHtmlSections.map(k => [k,'']),
82 + ...customHtmlSections.concat(getErrorSections()).map(k => [k,'']),
83 ...customHtmlState.sections
84 ])
85 }
src/cross-const.ts
+7
@@ -31,3 +31,10 @@ export const HTTP_FAILED_DEPENDENCY = 424
31 export const HTTP_SERVER_ERROR = 500
32 export const HTTP_SERVICE_UNAVAILABLE = 503
33
34 +export const HTTP_MESSAGES: Record<number, string> = {
35 + [HTTP_UNAUTHORIZED]: "Unauthorized",
36 + [HTTP_FORBIDDEN]: "Forbidden",
37 + [HTTP_NOT_FOUND]: "Not found",
38 + [HTTP_SERVER_ERROR]: "Server error",
39 +}
40 +
src/customHtml.ts
+1 -1
@@ -17,7 +17,7 @@ export const customHtmlState = proxy({
17 export function watchLoadCustomHtml(folder='') {
18 const state = new Map<string, string>()
19 const res = watchLoad(prefix('', folder, '/') + FILE, data => {
20 - const re = /^\[(\w+)] *$/gm
20 + const re = /^\[([^\]]+)] *$/gm
21 state.clear()
22 if (!data) return
23 let name: string | undefined = 'top'
src/errorPages.ts new
+25
@@ -0,0 +1,25 @@
1 +import Koa from 'koa'
2 +import { getLangData } from './lang'
3 +import { getSection } from './customHtml'
4 +import { HTTP_FORBIDDEN, HTTP_MESSAGES, HTTP_NOT_FOUND } from './cross'
5 +
6 +const declaredErrorPages = [HTTP_NOT_FOUND, HTTP_FORBIDDEN].map(String)
7 +
8 +export function getErrorSections() {
9 + return declaredErrorPages
10 +}
11 +
12 +export async function sendErrorPage(ctx: Koa.Context, code: number) {
13 + ctx.status = code
14 + const msg = HTTP_MESSAGES[ctx.status]
15 + if (!msg) return
16 + const lang = await getLangData(ctx)
17 + if (!lang) return
18 + const trans = (Object.values(lang)[0] as any)?.translate
19 + ctx.body = trans?.[msg] ?? msg
20 + const errorPage = getSection(String(ctx.status))
21 + if (!errorPage) return
22 + if (errorPage.includes('<'))
23 + ctx.type = 'html'
24 + ctx.body = errorPage.replace('$MESSAGE', String(ctx.body))
25 +}
src/middlewares.ts
+3 -18
@@ -15,7 +15,7 @@ import {
15 filterMapGenerator,
16 isLocalHost,
17 stream2string,
18 - tryJson
18 + tryJson, Dict
19 } from './misc'
20 import { zipStreamFromFolder } from './zip'
21 import { serveFile, serveFileNode } from './serveFile'
@@ -37,6 +37,8 @@ import { constants } from 'zlib'
37 import { baseUrl, getHttpsWorkingPort } from './listen'
38 import { defineConfig } from './config'
39 import { getLangData } from './lang'
40 +import { getSection } from './customHtml'
41 +import { sendErrorPage } from './errorPages'
42
43 const forceHttps = defineConfig('force_https', true)
44 const ignoreProxies = defineConfig('ignore_proxies', false)
@@ -157,23 +159,6 @@ export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => {
159 }
160
161 // to be used with errors whose recipient is possibly human
160 -export async function sendErrorPage(ctx: Koa.Context, code: number) {
161 - ctx.status = code
162 - const msg = (errorMessages as any)[ctx.status]
163 - if (!msg) return
164 - const lang = await getLangData(ctx)
165 - if (!lang) return
166 - const trans = (Object.values(lang)[0] as any)?.translate
167 - if (!trans) return
168 - ctx.body = trans[msg]
169 -}
170 -
171 -const errorMessages = {
172 - [HTTP_NOT_FOUND]: "Not found",
173 - [HTTP_UNAUTHORIZED]: "Unauthorized",
174 - [HTTP_FORBIDDEN]: "Forbidden",
175 -}
176 -
162 async function sendFolderList(node: VfsNode, ctx: Koa.Context) {
163 let { depth=0, folders, prepend } = ctx.query
164 ctx.type = 'text'