451 lines
20 KiB
Go
451 lines
20 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 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 引用,作为单一事实来源:一旦被误改,回归测试会在
|
||
// 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,
|
||
// 告警发送前先离线识别来源 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)
|
||
},
|
||
})
|
||
// 需要输出详细日志时配置如下
|
||
// 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)
|
||
})
|
||
|
||
// IndexNow 密钥文件:static/index-now/<32 位十六进制>.txt,供 Bing / Yandex / Seznam
|
||
// 等支持 IndexNow 的搜索引擎校验站点所有权(提交 URL 时的 keyLocation 指向此处)。
|
||
// ⚠️ 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)
|
||
|
||
// 匿名投票参与端(公开):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)
|
||
// 内容审核严格度:与屏蔽词一样在提问/私聊/投票三个面板共用一份,
|
||
// 保存后经 ?tab= 参数回跳到来源面板。
|
||
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",
|
||
// ⚠️ 必须与 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
|
||
}
|