0904
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())})
|
||||
}
|
||||
Reference in New Issue
Block a user