Files
tamabox/internal/route/route.go
T
2026-10-22 17:04:10 +08:00

514 lines
24 KiB
Go
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"
"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/db"
"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/honeypot"
"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")
}
}
}
// newBanner 按 conf.Security 装配自动封禁器。
//
// 抽成独立函数(而不是内联在 New 里)是有代价换来的:蜜罐的封禁通道参数
// (HoneypotEnabled / HoneypotThreshold / HoneypotDuration)曾经在这里漏传,
// 而 autoban.RecordHoneypot 第一行就是 `if !b.honeypotEnabled { return false }`
// —— 结果是蜜罐命中永不封禁、落库的 banned 恒为 false、IsBanned 永不为真。
// 更糟的是当时的测试都自建 Banner 并显式传了这些字段,所以全绿。
//
// 集中到一处后,banner_config_test.go 只调这个函数就能断言「配置真的传进去了」,
// 下次再漏传会直接测试失败。
func newBanner() *autoban.Banner {
return 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)
},
// 蜜罐独立通道:开关跟随 enable_honeypot,不受 enable_auto_ban 约束。
// 蜜罐命中意味着对方已经在往假后台里提交账号密码,证据强度远高于
// 「访问了一个可疑路径」,因此单独给阈值与时长,并默认封得比常规久。
HoneypotEnabled: conf.Security.EnableHoneypot,
HoneypotThreshold: conf.Security.HoneypotThreshold,
HoneypotDuration: time.Duration(conf.Security.HoneypotBanMinutes) * time.Minute,
})
}
func New() *flamego.Flame {
f := flamego.Classic()
if conf.App.Production {
flamego.SetEnv(flamego.EnvTypeProd)
}
// 自动封禁实例:整个请求链路共享同一个,供中间件与 f.NotFound 复用,
// 避免对业务性 302→/(语言切换、登出等)误判为路径扫描。
// 装配细节见 newBanner(集中一处,便于回归测试断言配置真的传进去了)。
ab := newBanner()
// 蜜罐(虚假后台登录界面):默认关闭,需在 [security] 段显式 enable_honeypot = true。
// 命中即返回假登录页 + 记录破解凭据 + 复用上面的 autoban 封禁/告警链路。
hp := honeypot.New(honeypot.Options{
Enabled: conf.Security.EnableHoneypot,
ExtraPaths: honeypot.SplitPaths(conf.Security.HoneypotPaths),
})
// 凭据外置:账号密码绝不入库,落到 0600 权限的按天 JSONL 文件
// (见 honeypot.CredRecord 的注释)。目录不可写时降级为「不保存凭据」,
// 绝不让取证功能把站点拖挂。
credStore, err := honeypot.NewFileCredStore(conf.Security.HoneypotCredDir)
if err != nil {
logrus.WithError(err).WithField("dir", conf.Security.HoneypotCredDir).
Warn("蜜罐凭据目录不可用,捕获到的凭据将不会被保存(其余功能不受影响)")
credStore = nil
}
hph := newHoneypotHandler(hp, ab, db.Honeypots, credStore)
// 蜜罐告警:只有真的提交了凭据才发(纯路径探测没有取证价值),且每个 IP 只发一次。
// 收件人复用 security_alert_email,不新增配置项。
hph.sendAlert = func(a mail.HoneypotAlert) error {
return mail.SendHoneypotAlertMail(a)
}
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, hp))
// 全局请求体上限:必须在任何会读取 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("/rules", route.Rules)
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)
// 「下一条未读」= 标记本条已读 + 跳到最早的一条未读(改变状态,故为 POST)
f.Post("/next-unread", question.NextUnread)
f.Post("/answer", reqUserSignIn, form.Bind(form.PublishAnswerQuestion{}), question.PublishAnswer)
// 举报入口:公开、不要求登录(主要目击者是匿名访客),限流防刷。
// 举报只写入 content_reports,不改变任何可见性;处置由箱主或 CLI 完成。
f.Post("/report", form.Bind(form.ReportQuestion{}), rateLimitMiddleware(10, 1*time.Minute), question.Report)
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.Post("/theme", form.Bind(form.UpdateProfileTheme{}), rateLimitMiddleware(10, 1*time.Minute), user.UpdateTheme)
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) {
// ── 蜜罐分流(优先)───────────────────────────────────────────────
// 命中诱饵路径(/wp-admin、/.ssh、/.env 等后台探测特征)时,返回一个
// 仿 WordPress 的假登录页,并把攻击者提交的凭据记录入库。
//
// 为什么挂在 NotFound 而不是全局中间件:NotFound 只在「路由表完全落空」
// 时触发,因此与 /login、/register、/_/{domain}、/user/**、/api/v1/** 等
// 真实业务路由天然零交集,不会劫持正常用户;且它位于全局 f.Use 链之后,
// requestBodyLimitMiddleware 已对 body 施加上限,解析 POST body 安全。
// 组级中间件(含 CSRF)只挂在具体路由上,不作用于 NotFound,故表单可解析。
//
// 短路:flamego 的 context.run() 在 ResponseWriter().Written() 为真时即
// return,故此处写完响应且不调用 ctx.Next() 即可安全终止整条链路。
if hp != nil && hp.Match(ctx.Request().URL.Path) {
hph.Serve(ctx)
return
}
// 真正的 404 探测在此记录(不把业务 302→/ 误判为扫描)。
// ⚠️ 更正:flamego 的 NotFound 处理器并不会自动写 404 状态码 ——
// ResponseWriter.Write() 在未显式 WriteHeader 时写的是 200,
// ctx.Redirect("/") 写的是 302 + Location: /。这里保持原有 302→/ 行为
// (对扫描器而言,302 到首页同样表示「此路径不存在」)。
// 键必须与 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
}