Files
ghproxy/proxy/error.go
2025-07-29 23:27:42 +08:00

370 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package proxy
import (
"bytes"
"crypto/sha256"
"encoding/gob"
"encoding/hex"
"sync"
"fmt"
"html/template"
"io/fs"
lru "github.com/hashicorp/golang-lru/v2"
"github.com/infinite-iroha/touka"
)
func HandleError(c *touka.Context, message string) {
ErrorPage(c, NewErrorWithStatusLookup(500, message))
c.Errorf("%s %s %s %s %s Error: %v", c.ClientIP(), c.Request.Method, c.Request.URL.Path, c.UserAgent(), c.Request.Proto, message)
}
func UnifiedToukaErrorHandler(c *touka.Context, code int, err error) {
errMsg := ""
if err != nil {
errMsg = err.Error()
}
c.Errorf("%s %s %s %s %s Error: %v", c.ClientIP(), c.Request.Method, c.Request.URL.Path, c.UserAgent(), c.Request.Proto, errMsg)
constructedGHErr := NewErrorWithStatusLookup(code, errMsg)
ErrorPage(c, constructedGHErr)
}
type GHProxyErrors struct {
StatusCode int
StatusDesc string
StatusText string
HelpInfo string
ErrorMessage string
}
var (
ErrInvalidURL = &GHProxyErrors{
StatusCode: 400,
StatusDesc: "Bad Request",
StatusText: "无效请求",
HelpInfo: "请求的URL格式不正确请检查后重试。",
}
ErrAuthHeaderUnavailable = &GHProxyErrors{
StatusCode: 401,
StatusDesc: "Unauthorized",
StatusText: "认证失败",
HelpInfo: "缺少或无效的鉴权信息。",
}
ErrForbidden = &GHProxyErrors{
StatusCode: 403,
StatusDesc: "Forbidden",
StatusText: "权限不足",
HelpInfo: "您没有权限访问此资源。",
}
ErrNotFound = &GHProxyErrors{
StatusCode: 404,
StatusDesc: "Not Found",
StatusText: "页面未找到",
HelpInfo: "抱歉,您访问的页面不存在。",
}
ErrTooManyRequests = &GHProxyErrors{
StatusCode: 429,
StatusDesc: "Too Many Requests",
StatusText: "请求过于频繁",
HelpInfo: "您的请求过于频繁,请稍后再试。",
}
ErrInternalServerError = &GHProxyErrors{
StatusCode: 500,
StatusDesc: "Internal Server Error",
StatusText: "服务器内部错误",
HelpInfo: "服务器处理您的请求时发生错误,请稍后重试或联系管理员。",
}
// 502
ErrBadGateway = &GHProxyErrors{
StatusCode: 502,
StatusDesc: "Bad Gateway",
StatusText: "网关错误",
HelpInfo: "代理服务器从上游服务器接收到无效响应。",
}
ErrServiceUnavailable = &GHProxyErrors{
StatusCode: 503,
StatusDesc: "Service Unavailable",
StatusText: "服务不可用",
HelpInfo: "服务器目前无法处理请求,通常是由于服务器过载或停机维护。",
}
ErrGatewayTimeout = &GHProxyErrors{
StatusCode: 504,
StatusDesc: "Gateway Timeout",
StatusText: "网关超时",
HelpInfo: "代理服务器未能及时从上游服务器接收到响应。",
}
)
var statusErrorMap map[int]*GHProxyErrors
func init() {
statusErrorMap = map[int]*GHProxyErrors{
ErrInvalidURL.StatusCode: ErrInvalidURL,
ErrAuthHeaderUnavailable.StatusCode: ErrAuthHeaderUnavailable,
ErrForbidden.StatusCode: ErrForbidden,
ErrNotFound.StatusCode: ErrNotFound,
ErrTooManyRequests.StatusCode: ErrTooManyRequests,
ErrInternalServerError.StatusCode: ErrInternalServerError,
ErrBadGateway.StatusCode: ErrBadGateway,
ErrServiceUnavailable.StatusCode: ErrServiceUnavailable,
ErrGatewayTimeout.StatusCode: ErrGatewayTimeout,
}
}
func NewErrorWithStatusLookup(statusCode int, errMsg string) *GHProxyErrors {
baseErr, found := statusErrorMap[statusCode]
if found {
return &GHProxyErrors{
StatusCode: baseErr.StatusCode,
StatusDesc: baseErr.StatusDesc,
StatusText: baseErr.StatusText,
HelpInfo: baseErr.HelpInfo,
ErrorMessage: errMsg,
}
} else {
return &GHProxyErrors{
StatusCode: statusCode,
ErrorMessage: errMsg,
}
}
}
var errPagesFs fs.FS
func InitErrPagesFS(pages fs.FS) error {
var err error
errPagesFs, err = fs.Sub(pages, "pages/err")
if err != nil {
return err
}
return nil
}
type ErrorPageData struct {
StatusCode int
StatusDesc string
StatusText string
HelpInfo string
ErrorMessage string
}
// ToCacheKey 为 ErrorPageData 生成一个唯一的 SHA256 字符串键。
// 使用 gob 序列化来确保结构体内容到字节序列的顺序一致性,然后计算哈希。
func (d ErrorPageData) ToCacheKey() (string, error) {
var buf bytes.Buffer
enc := gob.NewEncoder(&buf)
err := enc.Encode(d)
if err != nil {
//logError("Failed to gob encode ErrorPageData for cache key: %v", err)
return "", fmt.Errorf("failed to gob encode ErrorPageData for cache key: %w", err)
}
hasher := sha256.New()
hasher.Write(buf.Bytes())
return hex.EncodeToString(hasher.Sum(nil)), nil
}
func ErrPageUnwarper(errInfo *GHProxyErrors) ErrorPageData {
return ErrorPageData{
StatusCode: errInfo.StatusCode,
StatusDesc: errInfo.StatusDesc,
StatusText: errInfo.StatusText,
HelpInfo: errInfo.HelpInfo,
ErrorMessage: errInfo.ErrorMessage,
}
}
// SizedLRUCache 实现了基于字节大小限制的 LRU 缓存。
// 它包装了 hashicorp/golang-lru/v2.Cache并额外管理缓存的总字节大小。
type SizedLRUCache struct {
cache *lru.Cache[string, []byte]
mu sync.Mutex // 保护 currentBytes 字段
maxBytes int64 // 缓存的最大字节容量
currentBytes int64 // 缓存当前占用的字节数
}
// NewSizedLRUCache 创建一个新的 SizedLRUCache 实例。
// 内部的 lru.Cache 的条目容量被设置为一个较大的值 (例如 10000)
// 因为主要的逐出逻辑将由字节大小限制来控制。
func NewSizedLRUCache(maxBytes int64) (*SizedLRUCache, error) {
if maxBytes <= 0 {
return nil, fmt.Errorf("maxBytes must be positive")
}
c := &SizedLRUCache{
maxBytes: maxBytes,
}
// 创建内部 LRU 缓存,并提供一个 OnEvictedFunc 回调函数。
// 当内部 LRU 缓存因其自身的条目容量限制或 RemoveOldest 方法被调用而逐出条目时,
// 此回调函数会被执行,从而更新 currentBytes。
var err error
//c.cache, err = lru.NewWithEvict[string, []byte](10000, func(key string, value []byte) {
c.cache, err = lru.NewWithEvict(10000, func(key string, value []byte) {
c.mu.Lock()
defer c.mu.Unlock()
c.currentBytes -= int64(len(value))
})
if err != nil {
return nil, err
}
return c, nil
}
// Get 从缓存中检索值。
func (c *SizedLRUCache) Get(key string) ([]byte, bool) {
return c.cache.Get(key)
}
// Add 向缓存中添加或更新一个键值对,并在必要时执行逐出以满足字节限制。
func (c *SizedLRUCache) Add(key string, value []byte) {
c.mu.Lock() // 保护 currentBytes 和逐出逻辑
defer c.mu.Unlock()
itemSize := int64(len(value))
// 如果待添加的条目本身就大于缓存的最大容量,则不进行缓存。
if itemSize > c.maxBytes {
return
}
// 如果键已存在,则首先从 currentBytes 中减去旧值的大小,并从内部 LRU 中移除旧条目。
if oldVal, ok := c.cache.Get(key); ok {
c.currentBytes -= int64(len(oldVal))
c.cache.Remove(key)
}
// 主动逐出最旧的条目,直到有足够的空间容纳新条目。
for c.currentBytes+itemSize > c.maxBytes && c.cache.Len() > 0 {
_, _, existed := c.cache.RemoveOldest()
if !existed {
break
}
}
// 添加新条目到内部 LRU 缓存。
c.cache.Add(key, value)
c.currentBytes += itemSize // 手动增加新条目的大小到 currentBytes。
}
const maxErrorPageCacheBytes = 512 * 1024 // 错误页面缓存的最大容量512KB
var errorPageCache *SizedLRUCache
func init() {
// 初始化 SizedLRUCache。
var err error
errorPageCache, err = NewSizedLRUCache(maxErrorPageCacheBytes)
if err != nil {
panic(err)
}
}
// parsedTemplateOnce 用于确保 HTML 模板只被解析一次。
var (
parsedTemplateOnce sync.Once
parsedTemplate *template.Template
parsedTemplateErr error
)
// getParsedTemplate 用于获取缓存的解析后的 HTML 模板。
func getParsedTemplate() (*template.Template, error) {
parsedTemplateOnce.Do(func() {
tmplPath := "page.tmpl"
// 确保 errPagesFs 已初始化。这要求在任何 ErrorPage 调用之前调用 InitErrPagesFS。
if errPagesFs == nil {
parsedTemplateErr = fmt.Errorf("errPagesFs not initialized. Call InitErrPagesFS first")
return
}
parsedTemplate, parsedTemplateErr = template.ParseFS(errPagesFs, tmplPath)
if parsedTemplateErr != nil {
parsedTemplate = nil
}
})
return parsedTemplate, parsedTemplateErr
}
// htmlTemplateRender 修改为使用缓存的模板。
func htmlTemplateRender(data interface{}) ([]byte, error) {
tmpl, err := getParsedTemplate()
if err != nil {
return nil, fmt.Errorf("failed to get parsed template: %w", err)
}
if tmpl == nil {
return nil, fmt.Errorf("template is nil after parsing")
}
// 创建一个 bytes.Buffer 用于存储渲染结果
var buf bytes.Buffer
err = tmpl.Execute(&buf, data)
if err != nil {
return nil, fmt.Errorf("failed to execute template: %w", err)
}
// 返回 buffer 的内容作为 []byte
return buf.Bytes(), nil
}
func ErrorPage(c *touka.Context, errInfo *GHProxyErrors) {
select {
case <-c.Request.Context().Done():
return
default:
if c.Writer.Written() {
return
}
}
// 将 errInfo 转换为 ErrorPageData 结构体
var err error
var cacheKey string
pageDataStruct := ErrPageUnwarper(errInfo)
// 使用 ErrorPageData 生成一个唯一的 SHA256 缓存键
cacheKey, err = pageDataStruct.ToCacheKey()
if err != nil {
c.Warnf("Failed to generate cache key for error page: %v", err)
fallbackErrorJson(c, errInfo)
return
}
// 检查生成的缓存键是否为空,这可能表示序列化或哈希计算失败
if cacheKey == "" {
c.JSON(errInfo.StatusCode, map[string]string{"error": errInfo.ErrorMessage})
c.Warnf("Failed to generate cache key for error page: %v", errInfo)
return
}
var pageData []byte
// 尝试从缓存中获取页面数据
if cachedPage, found := errorPageCache.Get(cacheKey); found {
pageData = cachedPage
c.Debugf("Serving error page from cache (Key: %s)", cacheKey)
} else {
// 如果不在缓存中,则渲染页面
pageData, err = htmlTemplateRender(pageDataStruct)
if err != nil {
c.JSON(errInfo.StatusCode, map[string]string{"error": errInfo.ErrorMessage})
c.Warnf("Failed to render error page for status %d (Key: %s): %v", errInfo.StatusCode, cacheKey, err)
return
}
// 将渲染结果存入缓存
errorPageCache.Add(cacheKey, pageData)
c.Debugf("Cached error page (Key: %s, Size: %d bytes)", cacheKey, len(pageData))
}
c.Raw(errInfo.StatusCode, "text/html; charset=utf-8", pageData)
}
func fallbackErrorJson(c *touka.Context, errInfo *GHProxyErrors) {
c.JSON(errInfo.StatusCode, map[string]string{"error": errInfo.ErrorMessage})
}