// 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(``) 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 }