// 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" "regexp" "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/security/ipinfo" "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" ) // indexNowKeyPattern 限制 IndexNow 密钥文件名的合法形态:32 位小写十六进制 + ".txt"。 // 用于防止把路由参数直接拼进 embed FS 路径时发生路径穿越(如 "../robots.txt")。 var indexNowKeyPattern = regexp.MustCompile(`^[0-9a-f]{32}\.txt$`) // CSRFUserSessionKey 是 CSRF token 绑定的会话键,必须与登录时写入的键一致 // (见 route/auth/login.go 的 ctx.Session.Set("uid", ...))。flamego/csrf 默认用 // "userID",不对齐则 token id 恒为 "0",匿名访客从公开页取到的 token 对所有已登录 // 用户均有效,CSRF 退化为全站公开常量。 // 本常量被 csrf_binding_test.go 引用作为单一事实来源,误改会让回归测试失败。 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,仅敏感扫描路径计数)。 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, // 告警发送前先离线识别来源 IP 的网络归属(CDN/云厂商/WAF,零外部请求), // 识别失败不影响告警发送(ipinfo.Lookup 内部已折叠错误)。 SendAlert: func(ip string, paths []string, durationMinutes int) error { provider, proxied := ipinfo.Lookup(ip) return mail.SendSecurityAlertMail(ip, provider, proxied, paths, durationMinutes) }, }) 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 防 XSS 窃取会话;SameSite=Lax 阻断跨站表单 // POST;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) }) // IndexNow 密钥文件:static/index-now/<32 位十六进制>.txt,供 Bing / Yandex 等 // 搜索引擎校验站点所有权。 // ⚠️ flamego Static 的 Prefix 是 "/static",不会接管本路径,必须显式注册, // 否则会落到 f.NotFound 被 302 到首页,校验必然失败。 // 文件名经 indexNowKeyPattern 白名单校验,避免路径穿越读 embed FS 内其它文件。 f.Get("/index-now/{key}", func(c context.Context) { key := c.Param("key") if !indexNowKeyPattern.MatchString(key) { c.ResponseWriter().WriteHeader(http.StatusNotFound) return } fs, err := static.FS.Open("index-now/" + key) if err != nil { c.ResponseWriter().WriteHeader(http.StatusNotFound) 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) // 账号停用成功页(公开):停用后会话已清空,不能再要求登录, // 故为独立公开页,让用户明确看到「已停用」而非被丢回登录页。 f.Get("/deactivated", user.DeactivatedProfile) // 处置停用页(L4):必须注册在公开区,被处置箱主自己也只能看到这一页。 f.Get("/restricted", user.RestrictedProfile) // 匿名投票参与端(公开):token 链接入口 f.Group("/vote/{token}", func() { f.Get("", vote.Page) // 提交限流优先按登录用户/签名 vote_fp 计数,IP 仅作高阈值兜底,避免共享出口误伤。 f.Post("/submit", voteSubmitRateLimitMiddleware(20, 1*time.Minute), vote.Submit) }) // 公开页语言切换:语言偏好写入 session 后跳回原页面。此前写独立 lang cookie // 会被 session 中间件的 Set-Cookie 整体覆盖而失效,故托管在 session 中。 f.Get("/i18n/{lang}", func(c context.Context) { lang := i18n.Resolve(c.Param("lang")) 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) }) }) // 防骚扰与全局屏蔽词拆为两个独立入口,避免同表单一次写两列时相互清空。 // 保存后经 ?tab= 参数回跳到来源面板。 f.Post("/harassment/update", form.Bind(form.UpdateHarassment{}), user.UpdateHarassment) f.Post("/blockwords/update", form.Bind(form.UpdateBlockWords{}), user.UpdateBlockWords) f.Post("/moderation-strictness/update", form.Bind(form.UpdateModerationStrictness{}), user.UpdateModerationStrictness) 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", // ⚠️ 必须与 login.go 的 ctx.Session.Set("uid", ...) 一致。flamego/csrf 默认 // SessionKey 为 "userID",不显式指定则 s.Get("userID") 恒为 nil、token 内 id // 恒为 "0";校验是无状态 HMAC 重算(不与 session 内 token 比对),匿名访客从 // 公开页取到的 token 会对所有已登录用户有效,CSRF 退化为全站公开常量。 // 回归测试见 route/csrf_binding_test.go。 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 前记录。 // 键必须与 autobanMiddleware 的 IsBanned/Record 一致(同样经 LimitKey 聚合), // 否则计数落在另一个键上,封禁永不生效。 // ⚠️ 计数受 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 }