This commit is contained in:
tamakyi
2026-09-08 10:10:44 +08:00
commit 2ed27dd55e
288 changed files with 67061 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
// 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 (
"github.com/flamego/flamego"
"github.com/flamego/session"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/i18n"
)
// authenticatedUser returns the user object of the authenticated user.
func authenticatedUser(ctx flamego.Context, sess session.Session) *db.User {
uid, ok := sess.Get("uid").(uint)
if !ok {
return nil
}
user, err := db.Users.GetByID(ctx.Request().Context(), uid)
if err != nil || user == nil {
return nil
}
// 密码变更后,旧会话保存的版本号不匹配,强制重新登录。
ver, _ := sess.Get("pwd_ver").(uint)
if user.PasswordVersion != ver {
return nil
}
return user
}
type ToggleOptions struct {
UserSignInRequired bool
UserSignOutRequired bool
}
func Toggle(options *ToggleOptions) flamego.Handler {
return func(ctx Context, endpoint EndpointType) error {
if options.UserSignOutRequired && ctx.IsLogged {
ctx.Redirect("/")
return nil
}
if options.UserSignInRequired && !ctx.IsLogged {
if endpoint.IsAPI() {
return ctx.JSONError(40100, i18n.T(ctx.Lang(), "auth.errLoginRequired"))
}
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "auth.errLoginRequired"))
ctx.Redirect("/login")
return nil
}
return nil
}
}
+327
View File
@@ -0,0 +1,327 @@
// 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
}
+52
View File
@@ -0,0 +1,52 @@
// 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 (
"fmt"
"strings"
"go.opentelemetry.io/otel/trace"
"github.com/tamakyi/TamaBox/internal/i18n"
)
type FlashType string
const (
Info = "info"
Success = "success"
Warning = "warning"
Error = "error"
)
type Flash struct {
Type FlashType
Message string
FlashTip string
}
func (c Context) SetInfoFlash(message string, tip ...string) {
c.Session.SetFlash(Flash{Type: Info, Message: message, FlashTip: strings.Join(tip, "")})
}
func (c Context) SetSuccessFlash(message string, tip ...string) {
c.Session.SetFlash(Flash{Type: Success, Message: message, FlashTip: strings.Join(tip, "")})
}
func (c Context) SetWarningFlash(message string, tip ...string) {
c.Session.SetFlash(Flash{Type: Warning, Message: message, FlashTip: strings.Join(tip, "")})
}
func (c Context) SetErrorFlash(message string, tip ...string) {
c.Session.SetFlash(Flash{Type: Error, Message: message, FlashTip: strings.Join(tip, "")})
}
func (c Context) SetInternalErrorFlash() {
span := trace.SpanFromContext(c.Request().Context())
traceID := span.SpanContext().TraceID()
c.Session.SetFlash(Flash{Type: Error, Message: i18n.T(c.Lang(), "internal.errFlash"), FlashTip: fmt.Sprintf("若问题一直出现,请带上该段字符 %s 提交反馈。", traceID.String())})
}