Files
tamabox/internal/context/context.go
T
2026-09-08 10:10:44 +08:00

328 lines
10 KiB
Go
Raw 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.
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package context
import (
"encoding/json"
"fmt"
"net/http"
"reflect"
"strings"
"github.com/flamego/csrf"
"github.com/flamego/flamego"
"github.com/flamego/session"
"github.com/flamego/template"
"github.com/pkg/errors"
"github.com/unknwon/com"
"github.com/wuhan005/govalid"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/shared"
templatepkg "github.com/tamakyi/TamaBox/internal/template"
)
type EndpointType string
const (
EndpointAPI EndpointType = "api"
EndpointWeb EndpointType = "web"
)
func (e EndpointType) IsAPI() bool {
return e == EndpointAPI
}
func (e EndpointType) IsWeb() bool {
return e == EndpointWeb
}
func APIEndpoint(ctx Context) {
ctx.Map(EndpointAPI)
}
// Context represents context of a request.
type Context struct {
flamego.Context
Data template.Data
Session session.Session
Template template.Template
User *db.User
IsLogged bool
}
// HasError returns true if error occurs in form validation.
func (c *Context) HasError() bool {
hasErr, ok := c.Data["HasError"]
if !ok {
return false
}
return hasErr.(bool)
}
func (c *Context) SetError(err error, f ...interface{}) {
// 防御性兜底:调用方可能传入 nil error(如从某个可能返回 nil 的函数直接透传),
// 此时下方 err.Error() 会触发 nil 指针 panic。统一兜底为「未知错误」,避免整页崩溃。
if err == nil {
err = errors.New("发生未知错误")
}
c.Data["HasError"] = true
// 表单校验(govalid)错误:其 Error() 在字段带 msg 标签时为 i18n 键,
// 在此按当前界面语言翻译;其余业务错误(errors.New(i18n.T(...)) 等)原样保留。
if ge, ok := err.(*govalid.ErrContext); ok {
c.Data["Error"] = i18n.T(c.Lang(), ge.Error())
} else {
c.Data["Error"] = err.Error()
}
// Set back the form data.
if len(f) > 0 {
form := f[0]
typ := reflect.TypeOf(form)
val := reflect.ValueOf(form)
if typ.Kind() == reflect.Ptr {
typ = typ.Elem()
val = val.Elem()
}
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
fieldName := com.ToSnakeCase(field.Name)
c.Data[fieldName] = val.Field(i).Interface()
}
}
span := trace.SpanFromContext(c.Request().Context())
if span.IsRecording() {
span.SetAttributes(
attribute.String("tamabox.error", err.Error()),
)
}
}
func (c *Context) SetInternalError(f ...interface{}) {
span := trace.SpanFromContext(c.Request().Context())
traceID := span.SpanContext().TraceID()
c.Data["FlashTip"] = fmt.Sprintf("若问题一直出现,请带上该段字符 %s 提交反馈。", traceID.String())
c.SetError(errors.New(i18n.T(c.Lang(), "internal.errFlash")), f...)
}
// Success renders HTML template with given name with 200 OK status code.
func (c *Context) Success(templateName string) {
c.Template.HTML(http.StatusOK, templateName)
}
func (c *Context) SetTitle(title string) {
c.Data["Title"] = title
}
func (c *Context) Refresh() {
c.Redirect(c.Request().URL.Path)
}
func (c *Context) JSON(data interface{}) error {
resp := map[string]interface{}{
"code": 0,
"data": data,
"message": "success",
}
return json.NewEncoder(c.ResponseWriter()).Encode(resp)
}
func (c *Context) ServerError() error {
return c.JSONError(50000, i18n.T(c.Lang(), "internal.errServer"))
}
func (c *Context) JSONError(errorCode int, message string) error {
span := trace.SpanFromContext(c.Request().Context())
resp := map[string]interface{}{
"code": errorCode,
"message": message,
"trace_id": span.SpanContext().TraceID().String(),
}
statusCode := errorCode / 100
if statusCode < 100 || statusCode > 999 {
statusCode = http.StatusInternalServerError
}
c.ResponseWriter().WriteHeader(statusCode)
return json.NewEncoder(c.ResponseWriter()).Encode(resp)
}
// Contexter initializes a classic context for a request.
func Contexter() flamego.Handler {
return func(ctx flamego.Context, data template.Data, session session.Session, x csrf.CSRF, t template.Template, flash session.Flash) {
c := Context{
Context: ctx,
Data: data,
Session: session,
Template: t,
}
// POST 类请求统一校验 CSRF(像素追踪代理已改为 GET 且仅转发只读请求,
// 不再需要 CSRF 豁免,故此处不再对 /api/v1/pixel/ 做例外)。
if ctx.Request().Method == http.MethodPost {
x.Validate(ctx)
}
// Get user from session or header when possible
c.User = authenticatedUser(c.Context, c.Session)
var userID uint
if c.User != nil {
c.IsLogged = true
c.Data["IsLogged"] = c.IsLogged
c.Data["LoggedUser"] = c.User
c.Data["LoggedUserID"] = c.User.ID
c.Data["LoggedUserName"] = c.User.Name
userID = c.User.ID
} else {
c.Data["LoggedUserID"] = 0
c.Data["LoggedUserName"] = ""
}
c.Data["IsPixel"] = ctx.Request().URL.Path == "/pixel"
span := trace.SpanFromContext(ctx.Request().Context())
if span.IsRecording() {
span.SetAttributes(
attribute.Bool("tamabox.user.is-login", c.IsLogged),
attribute.Int("tamabox.user.id", int(userID)),
)
}
c.ResponseWriter().Header().Set("Trace-ID", span.SpanContext().TraceID().String())
if flash != nil {
flash, ok := flash.(Flash)
if ok {
switch flash.Type {
case "success":
c.Data["Success"] = flash.Message
case "error":
c.Data["HasError"] = true
c.Data["Error"] = flash.Message
case "info":
c.Data["Info"] = flash.Message
case "warning":
c.Data["Warning"] = flash.Message
}
c.Data["FlashTip"] = flash.FlashTip
}
}
// ---- i18n 语言注入 ----
// 解析优先级(按页面类型):
// - 个人页(/user 后台、/_/{domain} 提问箱):永远跟随主人 profile 语言,忽略访客 cookie。
// /user 后台即登录用户本人的配置,直接采用 c.User.Language;
// /_/{domain} 提问箱页由 question.Pager 中间件按 pageUser.Language 覆盖(见 route/question/page.go)。
// - 公开页(首页/探索/登录等):访客 lang cookie 优先;未设置 cookie 时登录用户资料语言次之;
// 再回退站点默认语言(shared.App)> i18n.Default。
path := ctx.Request().URL.Path
isUserPage := strings.HasPrefix(path, "/user") || strings.HasPrefix(path, "/_/")
c.Data["IsUserPage"] = isUserPage
// SEO:仅公开页(首页/注册/登录/explore)允许被搜索引擎收录;
// 提问内容(提问箱页、单题页)、密码页、私信、用户后台等隐私页一律 noindex。
isPublicPage := path == "/" || path == "/register" || path == "/login" || strings.HasPrefix(path, "/explore")
if !isPublicPage {
c.Data["NoIndex"] = true
}
lang := ""
if isUserPage {
// 个人页:永远跟随 profile,忽略 cookie(/user 直接采用本人配置;
// /_/{domain} 由 question.Pager 覆盖,此处留空交由其处理)。
if strings.HasPrefix(path, "/user") && c.IsLogged && c.User.Language != "" {
lang = i18n.Resolve(c.User.Language)
}
} else {
// 公开页:会话内显式选择的语言优先,其次登录用户资料语言,最后回退站点默认。
// 语言偏好存于 session(由 session 中间件托管 cookie);此前独立 lang cookie 会被
// session 中间件的 Set-Cookie 整体覆盖而失效,故此处改读 session。
if v := c.Session.Get("lang"); v != nil {
if s, ok := v.(string); ok && s != "" {
lang = i18n.Resolve(s)
}
}
if lang == "" && c.IsLogged && c.User.Language != "" {
lang = i18n.Resolve(c.User.Language)
}
}
if lang == "" {
lang = shared.App.Lang()
}
c.Data["Lang"] = lang
c.Data["LangCode"] = lang
c.Data["SupportedLangs"] = i18n.Supported()
c.SetTitle(i18n.T(lang, "site.title"))
c.Data["CSRFToken"] = x.Token()
c.Data["CSRFTokenHTML"] = templatepkg.Safe(`<input type="hidden" name="_csrf" value="` + x.Token() + `">`)
c.Data["RecaptchaDomain"] = conf.Recaptcha.Domain
c.Data["RecaptchaSiteKey"] = conf.Recaptcha.SiteKey
c.Data["RecaptchaTurnstileStyle"] = conf.Recaptcha.TurnstileStyle
c.Data["CaptchaType"] = conf.Recaptcha.CaptchaType
c.Data["CurrentURI"] = ctx.Request().Request.RequestURI
c.Data["ExternalURL"] = conf.App.ExternalURL
// 默认头像/背景图兜底:未设置自定义头像的箱主,其二维码 logo 应与页面头像一致回退到 default_avatar。
// 模板按原始 URL 直连(easy.qrcode 以 crossOrigin:'anonymous' 加载,配合对象存储桶 CORS
// 返回 Access-Control-Allow-Origin,保证 canvas 不被污染、可正常导出)。
c.Data["DefaultAvatar"] = conf.Upload.DefaultAvatarURL
c.Data["DefaultBackgroundImage"] = conf.Upload.DefaultBackgroundImageURL
// [limits] 段全站上限的模板同源入口:所有页面可直接引用
// {{.Limits.QuestionContentLen}} / {{.Limits.AnswerContentLen}} /
// {{.Limits.FollowUpContentLen}} / {{.Limits.ChatMessageLen}} 等,
// 作为输入框 maxlength 等与后端校验的同一数据源,避免各处写死数字漂移。
c.Data["Limits"] = conf.Limits
// ⚠️ VConsole can only be enabled for the first user for security reasons.
c.Data["VConsole"] = ctx.Query("debug") == "on" && c.IsLogged && c.User.ID == 1
// 🚨 SECURITY: Prevent MIME type sniffing in some browsers,
c.ResponseWriter().Header().Set("X-Content-Type-Options", "nosniff")
c.ResponseWriter().Header().Set("X-Frame-Options", "DENY")
ctx.Map(c)
ctx.Map(EndpointWeb)
if conf.App.MaintenanceMode {
c.ResponseWriter().Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
c.Success("maintenance-mode")
return
}
}
}
// Lang 返回当前请求解析出的界面语言代码(已规范化,必定是字典可查的规范代码)。
// 解析优先级见上方 Data["Lang"] 注入逻辑(个人页跟随箱主语言;公开页跟随 lang cookie /
// 登录用户资料语言 / 站点默认语言)。若中间件尚未注入(极少数非标准路径),回退 i18n.Default。
// 用于后端 flash / 错误信息按访客当前语言本地化,避免硬编码中文绕过 i18n。
func (c *Context) Lang() string {
if l, ok := c.Data["Lang"].(string); ok && l != "" {
return l
}
return i18n.Default
}