// Copyright 2026 tamakyi. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. package route import ( "fmt" "net/http" "runtime/debug" "strings" "github.com/flamego/flamego" "github.com/sirupsen/logrus" "go.opentelemetry.io/otel/trace" "github.com/tamakyi/TamaBox/internal/i18n" "github.com/tamakyi/TamaBox/internal/shared" ) // recoveryMiddleware 替代 flamego.Recovery(),把 panic 渲染成 HTML 500 页。 // // 为什么不用框架自带的:flamego.Recovery() 直接写纯文本 "Internal Server Error", // 用户看到的是白底黑字的两行英文,地址栏还停在出错的 URL 上 —— // 既不知道发生了什么,也没有回去的入口,更没法把这次错误的编号报给我们。 func recoveryMiddleware() flamego.Handler { return func(ctx flamego.Context) { defer func() { if r := recover(); r != nil { // 带栈:没有栈的 panic 日志几乎无法定位。 logrus.WithContext(ctx.Request().Context()). Errorf("panic recovered: %v\n%s", r, debug.Stack()) if ctx.ResponseWriter().Written() { // 头已经发出去了,没法再改状态码,只能让连接断在这里。 return } writeErrorPage(ctx, http.StatusInternalServerError, "error.internalTitle", "error.internalDesc") } }() ctx.Next() } } // writeErrorPage 输出不依赖模板系统的完整错误页。 // // 为什么不用模板:这两个页面出现在两处「拿不到模板」的地方 —— // NotFound 处理器不在组级中间件链里(template.Templater 与 Contexter 都挂在组级, // 包括 session 与 CSRF),而 recovery 中间件挂在最外层,那时模板还没装配。 // 错误页恰恰是最不该依赖出错链路的页面,所以这里用内联 HTML + i18n.T。 func writeErrorPage(ctx flamego.Context, status int, titleKey, descKey string) { lang := pickErrorPageLang(ctx.Request().Request) title := i18n.T(lang, titleKey) desc := i18n.T(lang, descKey) home := i18n.T(lang, "deactivated.backHome") hint := i18n.T(lang, "error.traceHint") traceID := "" if span := trace.SpanFromContext(ctx.Request().Context()); span.SpanContext().TraceID().IsValid() { traceID = span.SpanContext().TraceID().String() } body := fmt.Sprintf(` %s

%s

%s

%s
%s
`, htmlLang(lang), esc(title), esc(title), esc(desc), func() string { if traceID == "" { return "" } return `

` + esc(hint) + ` ` + esc(traceID) + `

` }(), esc(home), ) ctx.ResponseWriter().Header().Set("Content-Type", "text/html; charset=utf-8") ctx.ResponseWriter().Header().Set("X-Content-Type-Options", "nosniff") // 错误页不缓存:修好之后用户刷新应该立刻拿到正常页面。 ctx.ResponseWriter().Header().Set("Cache-Control", "no-store") ctx.ResponseWriter().WriteHeader(status) _, _ = ctx.ResponseWriter().Write([]byte(body)) } // pickErrorPageLang 选错误页语言。 // // 这里拿不到 session(sessioner 是组级中间件,NotFound 不经过它), // 所以只能从 Accept-Language 猜一个,猜不到就用站点默认语言。 func pickErrorPageLang(r *http.Request) string { def := i18n.Resolve(shared.App.Lang()) header := strings.ToLower(r.Header.Get("Accept-Language")) if header == "" { return def } // 按声明顺序取第一个能识别的语言偏好(q 值排序这里不精确处理: // 错误页的语言不需要那么讲究,按出现顺序取第一个命中的即可)。 for _, part := range strings.Split(header, ",") { tag := strings.TrimSpace(strings.SplitN(part, ";", 2)[0]) switch { case strings.HasPrefix(tag, "zh-tw"), strings.HasPrefix(tag, "zh-hk"), strings.HasPrefix(tag, "zh-hant"): return i18n.Resolve("zh-TW") case strings.HasPrefix(tag, "zh"): return i18n.Resolve("zh-CN") case strings.HasPrefix(tag, "ja"): return i18n.Resolve("ja") case strings.HasPrefix(tag, "en"): return i18n.Resolve("en") } } return def } // htmlLang 把内部语言代码转成 该用的 BCP 47 值。 func htmlLang(lang string) string { switch lang { case "zh-CN": return "zh-CN" case "zh-TW": return "zh-Hant" case "ja": return "ja" default: return "en" } } // esc 对极少数字符做转义。这里的内容全部来自字典(不是用户输入), // 但仍然转义一次 —— 字典里出现引号或 & 时不能把页面结构弄坏。 func esc(s string) string { r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """, "'", "'") return r.Replace(s) }