345 lines
11 KiB
Go
345 lines
11 KiB
Go
// 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"
|
||
"github.com/tamakyi/TamaBox/internal/theme"
|
||
)
|
||
|
||
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
|
||
|
||
// 主题:/user 后台是箱主本人的页面,跟随本人配置;
|
||
// /_/{domain} 的箱主主题由 question.Pager 覆盖(访客看到的是箱主的选择)。
|
||
// 空串(未选且站点无默认主题)= 不加载主题 css。
|
||
if isThemeScope(path) && c.IsLogged {
|
||
c.Data["ThemeSlug"] = theme.Resolve(c.User.Theme)
|
||
}
|
||
|
||
// 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
|
||
// 静态资源版本号:base.css 与主题 css 共用同一个值,改样式只需改 conf.StaticVersion。
|
||
c.Data["StaticVersion"] = conf.StaticVersion
|
||
|
||
// 默认头像/背景图兜底:未设置自定义头像的箱主,其二维码 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
|
||
}
|
||
|
||
// isThemeScope 判断该路径的主题是否由「当前登录用户自己的配置」决定。
|
||
// 只覆盖 /user 后台(本人页面);提问箱相关页面由 question.Pager 按箱主注入,
|
||
// 公开页(首页/探索/登录)保持站点默认外观,不跟随登录状态变化。
|
||
func isThemeScope(path string) bool {
|
||
return path == "/user" || strings.HasPrefix(path, "/user/")
|
||
}
|