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}) }