Files
tamabox/.workbuddy/backup/domain-shiroko-20260906/route.go.bak
T
tamakyi e3e59e7c69 chore: baseline snapshot before moderation-strictness feature
Repo .git was damaged (refs/ missing, index truncated, object store near-empty).
Repaired via git init (rebuilt refs), stale index preserved as .git/index.corrupt-20260910.bak,
then full worktree committed as a rollback baseline.
2026-09-10 10:56:27 +08:00

415 lines
18 KiB
Plaintext
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 route
import (
"encoding/gob"
"io"
"net/http"
"strings"
"time"
"github.com/flamego/cache"
cacheRedis "github.com/flamego/cache/redis"
"github.com/flamego/csrf"
"github.com/flamego/flamego"
"github.com/flamego/recaptcha"
"github.com/flamego/session"
"github.com/flamego/session/mysql"
sessionRedis "github.com/flamego/session/redis"
"github.com/flamego/template"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/captcha"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/form"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/mail"
"github.com/tamakyi/TamaBox/internal/route/chat" // 私聊路由
"github.com/tamakyi/TamaBox/internal/security/autoban"
"github.com/tamakyi/TamaBox/internal/shared"
templatepkg "github.com/tamakyi/TamaBox/internal/template"
"github.com/tamakyi/TamaBox/route"
"github.com/tamakyi/TamaBox/route/auth"
"github.com/tamakyi/TamaBox/route/pixel"
"github.com/tamakyi/TamaBox/route/question"
"github.com/tamakyi/TamaBox/route/service"
"github.com/tamakyi/TamaBox/route/user"
"github.com/tamakyi/TamaBox/route/vote"
"github.com/tamakyi/TamaBox/static"
"github.com/tamakyi/TamaBox/templates"
)
// CSRFUserSessionKey 是 CSRF token 绑定的会话键,必须与登录时写入的会话键一致
// (见 route/auth/login.go 中 ctx.Session.Set("uid", ...))。flamego/csrf 默认使用
// "userID",若不与之对齐,token 的 id 恒为 "0",任意匿名访客从公开页面取到的 token
// 对全部已登录用户均有效,CSRF 退化为全站公开常量。
//
// 本常量同时被 csrf_binding_test.go 引用,作为单一事实来源:一旦被误改,回归测试会在
// CI 失败,避免修复悄悄倒退。
const CSRFUserSessionKey = "uid"
// securityHeaders adds security-related HTTP headers to every response.
func securityHeaders() flamego.Handler {
return func(ctx flamego.Context) {
w := ctx.ResponseWriter()
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
// HSTS:仅当站点以 https 对外提供服务时下发,避免 http 部署下把用户锁死在 https。
if strings.HasPrefix(conf.App.ExternalURL, "https") {
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
}
}
func New() *flamego.Flame {
f := flamego.Classic()
if conf.App.Production {
flamego.SetEnv(flamego.EnvTypeProd)
}
// 自动封禁实例:整个请求链路共享同一个,供中间件与 f.NotFound 复用,
// 避免对业务性 302→/(语言切换、登出、投票不存在等)误判为路径扫描。
// 404 是否参与统计由 ban_track_notfound 控制(默认 false:正常业务一律放行,
// 仅敏感扫描路径计数封禁,见 conf.Security.BanTrackNotFound)。
ab := autoban.New(autoban.Options{
Enabled: conf.Security.EnableAutoBan,
TrackNotFound: conf.Security.BanTrackNotFound,
ThresholdSensitive: conf.Security.BanThresholdSensitive,
Threshold404: conf.Security.BanThreshold404,
Window: time.Duration(conf.Security.BanWindowSeconds) * time.Second,
Duration: time.Duration(conf.Security.BanDurationMinutes) * time.Minute,
AlertEmail: conf.Security.SecurityAlertEmail,
SendAlert: mail.SendSecurityAlertMail,
})
// 需要输出详细日志时配置如下
// if conf.App.Production {
// flamego.SetEnv(flamego.EnvTypeProd)
// }
templateFS, err := template.EmbedFS(templates.FS, ".", []string{".html"})
if err != nil {
logrus.WithError(err).Fatal("Failed to embed templates file system")
}
gob.Register(time.Time{})
gob.Register(context.Flash{})
gob.Register(captcha.StoredAnswer{})
var sessionStorage interface{}
initer := session.MemoryIniter()
if conf.Database.DSN != "" {
initer = mysql.Initer()
sessionStorage = mysql.Config{
DSN: conf.Database.DSN,
Lifetime: 7 * 24 * time.Hour,
}
}
if conf.Redis.Addr != "" {
initer = sessionRedis.Initer()
sessionStorage = sessionRedis.Config{
Options: &sessionRedis.Options{
Addr: conf.Redis.Addr,
Password: conf.Redis.Password,
DB: 1,
},
Lifetime: 7 * 24 * time.Hour,
}
}
sessioner := session.Sessioner(session.Options{
Initer: initer,
Config: sessionStorage,
// 显式声明 cookie 安全属性,不依赖库默认值:
// - HTTPOnly:禁止 JS 读取,降低 XSS 窃取会话的风险
// - SameSite=Lax:阻断经典跨站表单 POST(CSRF 的主要防线之一)
// - Secure:仅在站点以 https 对外服务时下发,避免 http 本地开发下 cookie 不生效。
// 缺失 Secure 时,HSTS 尚未落地的首次访问可被主动 MITM 诱导明文请求窃取会话。
Cookie: session.CookieOptions{
HTTPOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: strings.HasPrefix(conf.App.ExternalURL, "https"),
},
})
f.Use(flamego.Static(flamego.StaticOptions{
FileSystem: http.FS(static.FS),
Prefix: "/static",
// 静态资源随二进制发布,内容变更需重新部署;1 天缓存是安全折中
CacheControl: func() string {
return "public, max-age=86400"
},
}))
f.Use(securityHeaders())
f.Use(ipBlacklistMiddleware())
f.Use(autobanMiddleware(ab))
// 全局请求体上限:必须在任何会读取 body 的中间件(尤其 Contexter 内的 CSRF 校验)
// 之前执行,否则超大 multipart 会在 Bind 的路由级限制生效前落盘临时文件耗尽磁盘。
f.Use(requestBodyLimitMiddleware())
reqUserSignOut := context.Toggle(&context.ToggleOptions{UserSignOutRequired: true})
reqUserSignIn := context.Toggle(&context.ToggleOptions{UserSignInRequired: true})
f.Group("", func() {
f.Get("/", route.Home)
f.Get("/pixel", reqUserSignIn, pixel.Index)
f.Get("/sponsor", route.Sponsor)
f.Get("/change-logs", route.ChangeLogs)
f.Get("/about-me", route.AboutMe)
f.Get("/robots.txt", func(c context.Context) {
fs, err := static.FS.Open("robots.txt")
if err != nil {
c.ResponseWriter().WriteHeader(404)
return
}
defer func() { _ = fs.Close() }()
c.ResponseWriter().Header().Set("Content-Type", "text/plain; charset=utf-8")
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
_, _ = io.Copy(c.ResponseWriter(), fs)
})
f.Get("/favicon.ico", func(c context.Context) {
fs, _ := static.FS.Open("favicon.ico")
defer func() { _ = fs.Close() }()
c.ResponseWriter().Header().Set("Content-Type", "image/x-icon")
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
_, _ = io.Copy(c.ResponseWriter(), fs)
})
f.Get("/sitemap.xml", func(c context.Context) {
fs, err := static.FS.Open("sitemap.xml")
if err != nil {
c.ResponseWriter().WriteHeader(404)
return
}
defer func() { _ = fs.Close() }()
c.ResponseWriter().Header().Set("Content-Type", "application/xml")
c.ResponseWriter().Header().Set("Content-Type", "text/xml; charset=utf-8")
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
_, _ = io.Copy(c.ResponseWriter(), fs)
})
f.Get("/llms.txt", func(c context.Context) {
fs, err := static.FS.Open("llms.txt")
if err != nil {
c.ResponseWriter().WriteHeader(404)
return
}
defer func() { _ = fs.Close() }()
c.ResponseWriter().Header().Set("Content-Type", "text/plain; charset=utf-8")
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
_, _ = io.Copy(c.ResponseWriter(), fs)
})
f.Get("/llms-full.txt", func(c context.Context) {
fs, err := static.FS.Open("llms-full.txt")
if err != nil {
c.ResponseWriter().WriteHeader(404)
return
}
defer func() { _ = fs.Close() }()
c.ResponseWriter().Header().Set("Content-Type", "text/plain; charset=utf-8")
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
_, _ = io.Copy(c.ResponseWriter(), fs)
})
f.Group("", func() {
f.Combo("/register").Get(auth.Register).Post(form.Bind(form.Register{}), rateLimitMiddleware(10, 1*time.Minute), auth.RegisterAction)
f.Combo("/login").Get(auth.Login).Post(form.Bind(form.Login{}), rateLimitMiddleware(20, 1*time.Minute), auth.LoginAction)
f.Combo("/forgot-password").Get(auth.ForgotPassword).Post(form.Bind(form.ForgotPassword{}), rateLimitMiddleware(5, 5*time.Minute), auth.ForgotPasswordAction)
f.Combo("/recover-password").Get(auth.RecoverPassword).Post(form.Bind(form.RecoverPassword{}), auth.RecoverPasswordAction)
}, reqUserSignOut)
// 页面密码验证路由(独立于密码保护中间件);限流防爆破
f.Combo("/_/{domain}/password").Get(question.PasswordPage).Post(rateLimitMiddleware(10, 1*time.Minute), question.VerifyPagePassword)
// 独立的分享图路由(不经过 Pager 中间件,直接返回图片)
// 注意:仍挂载 question.Pager,页面设置了密码时必须先通过密码验证,
// 否则分享图会绕过页面密码保护(与单题分享图行为一致)。
f.Group("/_/{domain}", func() {
f.Get("/share-card", question.ShareCard)
}, question.Pager)
f.Group("/_/{domain}", func() {
f.Combo("").Get(question.List).Post(form.Bind(form.NewQuestion{}), rateLimitMiddleware(30, 1*time.Minute), question.New)
f.Group("/{questionID}", func() {
f.Get("", question.Item)
f.Get("/share-card", question.QuestionShareCard)
f.Post("/delete", question.Delete)
f.Post("/set-private", question.SetPrivate)
f.Post("/set-public", question.SetPublic)
f.Post("/pin", question.TogglePin)
f.Post("/read", question.SetRead)
f.Post("/unread", question.SetUnread)
f.Post("/answer", reqUserSignIn, form.Bind(form.PublishAnswerQuestion{}), question.PublishAnswer)
f.Post("/follow-up", form.Bind(form.PublishFollowUpQuestion{}), question.PublishFollowUp)
f.Post("/follow-up/{followUpID}/answer", reqUserSignIn, form.Bind(form.AnswerFollowUpQuestion{}), question.AnswerFollowUp)
}, question.Questioner)
// 新增:私聊路由(需要登录,使用 Token 标识对话)
f.Group("/chat/{token}", func() {
f.Get("", chat.ConversationPage)
f.Post("/send", rateLimitMiddleware(20, 1*time.Minute), chat.SendMessage)
f.Post("/delete", chat.DeleteConversation)
f.Post("/block", chat.ToggleBlockUser)
}, reqUserSignIn)
}, question.Pager) // Pager 中间件已内置页面密码检查
f.Get("/explore", user.Explore)
// 匿名投票参与端(公开):token 链接入口
f.Group("/vote/{token}", func() {
f.Get("", vote.Page)
// 提交限流优先按登录用户/签名 vote_fp 计数,IP 仅作高阈值兜底,避免共享出口误伤。
f.Post("/submit", voteSubmitRateLimitMiddleware(20, 1*time.Minute), vote.Submit)
})
// 公开页语言切换:把语言偏好写入 session(由 session 中间件随其 cookie 一并持久化)
// 后跳回原页面。此前写独立 lang cookie 会被 session 中间件的 Set-Cookie 整体覆盖而失效,
// 故改为托管在 session 中。仅允许站内相对路径,防开放重定向。
f.Get("/i18n/{lang}", func(c context.Context) {
lang := i18n.Resolve(c.Param("lang"))
// 写入 session,避免被 session 中间件的 Set-Cookie 覆盖(匿名用户也走 session 中间件)。
c.Session.Set("lang", lang)
to := c.Query("to")
// 仅允许站内相对路径,防开放重定向:
// 拒绝空、非 "/" 开头、以 "//" 开头的协议相对路径,以及含反斜杠/控制字符的变体。
if to == "" || !strings.HasPrefix(to, "/") ||
strings.HasPrefix(to, "//") || strings.ContainsAny(to, "\\\x00\r\n\t") {
to = "/"
}
c.Redirect(to)
})
// 算数验证码 API(仅在 captcha_type = "math" 时生效,但路由本身不依赖中间件)
// 加限流:防止匿名脚本无限生成/轮询爆破(答案本身对客户端可见,限流是主要节流手段)。
f.Get("/api/captcha/generate", rateLimitMiddleware(20, 1*time.Minute), captcha.GenerateHandler)
f.Post("/api/captcha/verify", rateLimitMiddleware(20, 1*time.Minute), captcha.VerifyHandler)
f.Group("/user", func() {
f.Get("/questions", user.QuestionList)
// 匿名投票管理(箱主侧)
f.Group("/votes", func() {
f.Get("", user.VoteList)
f.Get("/new", user.VoteNew)
f.Post("", rateLimitMiddleware(10, 1*time.Minute), user.VoteCreate)
f.Get("/{voteID}/edit", user.VoteEdit)
f.Post("/{voteID}", rateLimitMiddleware(10, 1*time.Minute), user.VoteUpdate)
f.Post("/{voteID}/publish", user.VotePublish)
f.Post("/{voteID}/close", user.VoteClose)
f.Post("/{voteID}/delete", user.VoteDelete)
f.Get("/{voteID}/results", user.VoteResults)
f.Get("/{voteID}/preview", user.VotePreview)
// 导出为只读操作(幂等、无副作用),用 GET 链接下载,避免表单提交
f.Get("/{voteID}/export", user.VoteExportCSV)
})
f.Group("/profile", func() {
f.Get("", user.Profile)
f.Post("/update", form.Bind(form.UpdateProfile{}), rateLimitMiddleware(10, 1*time.Minute), user.UpdateProfile)
f.Post("/export", user.ExportProfile)
f.Post("/export-json", user.ExportProfileJSON)
f.Combo("/deactivate").Get(user.DeactivateProfile).Post(user.DeactivateProfileAction)
f.Post("/update-page-password", form.Bind(form.UpdatePagePassword{}), rateLimitMiddleware(10, 1*time.Minute), user.UpdatePagePassword)
f.Post("/update-ask-password", form.Bind(form.UpdateAskPassword{}), rateLimitMiddleware(10, 1*time.Minute), user.UpdateAskPassword)
f.Post("/update-chat-settings", user.UpdateChatSettings)
// 功能管理(提问 / 私聊 / 投票 的开关、定时开关与定量收集):
// 不单独开页面,作为账号设置页的三个面板,保存后回跳到对应面板。
f.Group("/features", func() {
f.Post("/update", rateLimitMiddleware(20, 1*time.Minute), user.FeaturesUpdate)
f.Post("/reset-quota", rateLimitMiddleware(20, 1*time.Minute), user.FeatureResetQuota)
})
})
// 防骚扰设置:注册限制(harassment/update)与全局屏蔽词(blockwords/update)
// 拆为两个独立入口。屏蔽词在提问/私聊/投票三个设置面板共用一份,保存后经
// ?tab= 参数回跳到来源面板。
f.Post("/harassment/update", form.Bind(form.UpdateHarassment{}), user.UpdateHarassment)
f.Post("/blockwords/update", form.Bind(form.UpdateBlockWords{}), user.UpdateBlockWords)
f.Post("/tags/update", form.Bind(form.UpdateTags{}), user.UpdateTags)
// Logout 必须用 POST:GET 会留下 CSRF 风险(第三方图片/链接可触发退出登录)
f.Post("/logout", auth.Logout)
}, reqUserSignIn)
f.Group("/api/v1", func() {
f.Group("/user", func() {
f.Get("", reqUserSignIn, user.ProfileAPI)
f.Group("/{domain}", func() {
f.Group("/questions", func() {
f.Get("", rateLimitMiddleware(60, 1*time.Minute), question.ListAPI)
})
})
})
// 像素追踪代理:仅暴露只读 GET(f.Any 会放行写方法,曾有 CSRF 豁免 + 写方法放行的风险),
// 并加限流防止被用作高频打点通道。
f.Get("/pixel/{**}", reqUserSignIn, rateLimitMiddleware(30, 1*time.Minute), pixel.Proxy)
// 服务反代仅暴露只读 GET:本网关是把已登录用户的请求转发到配置好的内部服务,
// 不应允许任意写方法,避免被用作对内网后端的越权操作入口。
f.Get("/service/{**}", reqUserSignIn, service.Proxy)
f.Get("/explore/random", rateLimitMiddleware(60, 1*time.Minute), user.RandomRecommend)
// 首页"提问箱信息统计"公开接口(服务端 30 分钟缓存),供前端定时刷新
f.Get("/site-stats", rateLimitMiddleware(60, 1*time.Minute), route.SiteStatsAPI)
}, context.APIEndpoint)
},
cache.Cacher(cache.Options{
Initer: cacheRedis.Initer(),
Config: cacheRedis.Config{
Options: &cacheRedis.Options{
Addr: conf.Redis.Addr,
Password: conf.Redis.Password,
DB: 0,
},
},
}),
recaptcha.V3(
recaptcha.Options{
Secret: conf.Recaptcha.ServerKey,
VerifyURL: func() recaptcha.VerifyURL {
if conf.Recaptcha.TurnstileStyle {
return "https://challenges.cloudflare.com/turnstile/v0/siteverify"
}
return recaptcha.VerifyURLGlobal
}(),
},
),
sessioner,
csrf.Csrfer(csrf.Options{
Secret: conf.Server.XSRFKey,
Header: "X-CSRF-Token",
// ⚠️ 必须与 route/auth/login.go 中 ctx.Session.Set("uid", ...) 的 key 一致。
// flamego/csrf 默认 SessionKey 为 "userID",若不显式指定,s.Get("userID")
// 恒为 nil,token 内绑定的 id 恒为 "0" —— 由于校验是无状态 HMAC 重算
// (不与 session 中存储的 token 比对),这会导致任意匿名访客从公开的
// /login 页面取到的 token 对所有已登录用户均有效,CSRF 退化为全站公开常量。
// 回归测试见 route/csrf_binding_test.go(引用 CSRFUserSessionKey)。
SessionKey: CSRFUserSessionKey,
}),
template.Templater(template.Options{
FileSystem: templateFS,
FuncMaps: templatepkg.FuncMap(),
}),
context.Contexter(),
)
f.NotFound(func(ctx flamego.Context) {
// 真正的 404 探测在此记录(仅此处,不把业务 302→/ 误判为扫描)。
// 注意:flamego 的 NotFound 会先写入 404 状态再 Redirect 到 "/",
// 因此这里在 Redirect 之前记录,确保状态为 404。
// 键必须与 autobanMiddleware 中的 IsBanned/Record 保持一致(同样经 LimitKey 聚合),
// 否则 404 计数会记在另一个键上,导致封禁永不生效。
// ⚠️ 该 404 计数受 ban_track_notfound 配置控制,默认关闭(正常业务一律放行,
// 仅敏感扫描路径计数封禁);Record 内部对未开启的 KindNotFound 直接忽略。
if ip := shared.LimitKey(shared.ResolveClientIP(ctx.Request().Request)); ip != "" {
ab.Record(ip, ctx.Request().URL.Path, autoban.KindNotFound)
}
ctx.Redirect("/")
})
return f
}