1) /healthz 与 /readyz
改动前站内唯一的「探活」手段是 curl /,而首页只渲染模板、不查数据库:
DB 挂了首页照样 200(漏报),Redis 挂了首页又可能 500(误报)。
监控、CDN 源站健康检查、滚动重启的摘流判定都没有可靠依据。
→ /healthz 永远 200,刻意不碰任何依赖:进程活着 ≠ 能对外服务,
把 DB 检查塞进存活探针会导致数据库抖动时进程被反复重启,对恢复毫无帮助;
→ /readyz 查 DB(2s 超时),失败 503;响应体只回组件状态,
绝不回错误详情 —— 这是公开端点,DB 报错常带库名/主机名/账号。
实测:停掉 PostgreSQL 后 /healthz 仍 200、/readyz 503,恢复后 200。
2) ReadTimeout 30s → 120s
站内允许单图 8MB、请求体上限 12MB。30s 传完 8MB 需要 ≥273 KB/s 上行,
而手机 4G 上行常在 1–2 Mbps(125–250 KB/s)—— 一张 8MB 图必然超时,
且用户已写好的正文会一起丢(POST 失败不回填)。120s 对应约 68 KB/s,
偏远地区 3G 也能过。
放宽它不会让 slowloris 重新可行:那类攻击卡在「发不完 header」,
由 10s 的 ReadHeaderTimeout 拦住,ReadTimeout 只影响读完 header 之后传 body 的时长。
632 lines
30 KiB
Go
632 lines
30 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 (
|
||
stdctx "context"
|
||
"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")
|
||
// X-XSS-Protection 已是废弃的响应头:Chrome 移除、新 Firefox 不实现,
|
||
// 而旧版 IE/Edge 的反射型 XSS 过滤器本身有信息泄露缺陷(CVE-2018-10016 一类)。
|
||
// 现代建议是显式关掉,把防护交给 CSP 与输出编码。
|
||
w.Header().Set("X-XSS-Protection", "0")
|
||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||
// 站点不需要这些能力,显式全禁,避免被嵌入页 / 第三方脚本索要权限。
|
||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()")
|
||
// HSTS:仅当站点以 https 对外提供服务时下发,避免 http 部署下把用户锁死在 https。
|
||
if strings.HasPrefix(conf.App.ExternalURL, "https") {
|
||
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||
}
|
||
|
||
// CSP:off 时完全不出现;report 只下发 Report-Only(页面行为不变);
|
||
// enforce 才真正拦截。见 csp.go 与 [security] csp 的注释。
|
||
switch cspMode() {
|
||
case "enforce":
|
||
w.Header().Set("Content-Security-Policy", cspPolicy())
|
||
case "report":
|
||
w.Header().Set("Content-Security-Policy-Report-Only", cspPolicy())
|
||
}
|
||
|
||
// 敏感页面禁止缓存:
|
||
// /user/** 箱主后台(资料、会话、私信全在里面)
|
||
// /_/** 箱主页与单题页(单题页带 t= token,等于一把钥匙)
|
||
// 不管的话,共享设备上「退出登录 → 后退键」还能翻出完整后台(bfcache / 磁盘缓存);
|
||
// 将来若给 /user 配了 CDN 的 Cache Everything,还会把个人数据跨用户回放。
|
||
// 静态资源不在此列(/static 前缀不匹配)。
|
||
if p := ctx.Request().URL.Path; isNoStorePath(p) {
|
||
w.Header().Set("Cache-Control", "no-store, private")
|
||
w.Header().Set("Pragma", "no-cache")
|
||
}
|
||
}
|
||
}
|
||
|
||
// isNoStorePath 判断该路径是否属于「不允许任何缓存」的敏感面。
|
||
func isNoStorePath(p string) bool {
|
||
// 分享卡例外:它是给社交平台抓取的图片,本来就以公开分享为目的,而且渲染成本高
|
||
// (要画图 + 上传/落盘)。给它 no-store 会让每次 OG 预览都回源重画一张。
|
||
if strings.HasSuffix(p, "/share-card") {
|
||
return false
|
||
}
|
||
return p == "/user" || strings.HasPrefix(p, "/user/") ||
|
||
p == "/_" || strings.HasPrefix(p, "/_/")
|
||
}
|
||
|
||
// 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,
|
||
|
||
// 建封日志:封禁表是纯内存、外部完全看不见,而线上出现过
|
||
// 「邮件说封了、实际还能访问」—— 当时没有任何记录能说明封的是哪个键、
|
||
// 到什么时候解除,排查只能靠猜。键与 IsBanned 的判断口径完全一致
|
||
// (shared.LimitKey,IPv6 折成 /64),日志与判定不会各说一套。
|
||
OnBan: func(key string, duration time.Duration, reason string) {
|
||
reasonText := "敏感路径探测"
|
||
if reason == autoban.ReasonHoneypot {
|
||
reasonText = "蜜罐提交凭据"
|
||
}
|
||
expiresAt := time.Now().Add(duration)
|
||
logrus.WithFields(logrus.Fields{
|
||
"ban_key": key,
|
||
"reason": reasonText,
|
||
"duration": duration.String(),
|
||
"expires_at": expiresAt.Format("2006-01-02 15:04:05"),
|
||
}).Warn("已自动封禁来源(内存态,重启进程即解除;键为聚合口径,IPv6 为 /64)")
|
||
|
||
// 落库:内存态封禁重启即失效,事后没人能回答「封了哪个键、因为什么、封多久」。
|
||
// 异步 + 独立超时 + 失败只告警 —— 取证记录不能反过来拖慢或影响封禁本身。
|
||
// BanRecords 为 nil 表示未经 db.Init(单测里自建 Banner 就是这种情形)。
|
||
if db.BanRecords == nil {
|
||
return
|
||
}
|
||
record := &db.BanRecord{
|
||
BanKey: key,
|
||
Reason: reasonText,
|
||
DurationMinutes: int(duration.Minutes()),
|
||
ExpiresAt: expiresAt,
|
||
}
|
||
go func() {
|
||
ctx, cancel := stdctx.WithTimeout(stdctx.Background(), 3*time.Second)
|
||
defer cancel()
|
||
if err := db.BanRecords.Create(ctx, record); err != nil {
|
||
logrus.WithError(err).WithField("ban_key", record.BanKey).Warn("封禁记录落库失败")
|
||
}
|
||
}()
|
||
},
|
||
})
|
||
}
|
||
|
||
func New() *flamego.Flame {
|
||
// 不用 flamego.Classic():它内部注册的 Logger() 记录 RequestURI(含 query),
|
||
// 会把 ?t=<私密题钥匙> 与 ?code=<改密凭证> 明文写进日志(见 accesslog.go)。
|
||
// Classic 提供的三件套在这里逐一显式装配:
|
||
// accessLogMiddleware() 替代 Logger(),只记 URL.Path
|
||
// flamego.Recovery() 行为不变
|
||
// flamego.Static() 已在下方按 /static 前缀显式注册(原来就重复注册过)
|
||
f := flamego.New()
|
||
// 访问日志放在 Recovery 外层:panic 被兜成 500 后仍能记到状态码。
|
||
f.Use(accessLogMiddleware())
|
||
f.Use(flamego.Recovery())
|
||
|
||
if conf.App.Production {
|
||
flamego.SetEnv(flamego.EnvTypeProd)
|
||
}
|
||
|
||
// 自动封禁实例:整个请求链路共享同一个,供中间件与 f.NotFound 复用,
|
||
// 避免对业务性 302→/(语言切换、登出等)误判为路径扫描。
|
||
// 装配细节见 newBanner(集中一处,便于回归测试断言配置真的传进去了)。
|
||
ab := newBanner()
|
||
|
||
// 封禁响应图:启动时读一次,之后每个被封请求只读内存。
|
||
// 留空 / 读不到 / 不是图片 → 回退纯文字 403(见 loadBanImage),
|
||
// 绝不因为一张图把站点拦在启动阶段。
|
||
loadBanImage(conf.Security.BanImagePath)
|
||
|
||
// 蜜罐(虚假后台登录界面):默认关闭,需在 [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"),
|
||
},
|
||
})
|
||
|
||
// 安全响应头必须注册在静态资源之前:flamego.Static 命中后会终结请求链,
|
||
// 放在它后面等于静态资源拿不到 nosniff / HSTS。
|
||
f.Use(securityHeaders())
|
||
|
||
f.Use(flamego.Static(flamego.StaticOptions{
|
||
FileSystem: http.FS(static.FS),
|
||
Prefix: "/static",
|
||
// 静态资源随二进制发布,内容变更需重新部署;1 天缓存是安全折中
|
||
CacheControl: func() string {
|
||
return "public, max-age=86400"
|
||
},
|
||
}))
|
||
|
||
f.Use(ipBlacklistMiddleware())
|
||
f.Use(autobanMiddleware(ab, hp))
|
||
// 全局请求体上限:必须在任何会读取 body 的中间件(尤其 Contexter 内的 CSRF 校验)
|
||
// 之前执行,否则超大 multipart 会在 Bind 的路由级限制生效前落盘临时文件耗尽磁盘。
|
||
f.Use(requestBodyLimitMiddleware())
|
||
|
||
// 缓存层:配了 Redis 用 Redis,没配就用进程内内存缓存。
|
||
//
|
||
// 原来无条件走 cacheRedis,而 go-redis 对空 Addr 会回退到 localhost:6379
|
||
// (go-redis/v9/options.go)。于是「不配 Redis」这种被配置允许的部署形态下,
|
||
// 每个依赖缓存的功能都在连一个不存在(或不属于本站)的本地实例:
|
||
// - route/auth/recover.go 读不回 code → 找回密码整体不可用,只看到「内部错误」
|
||
// - chat_handler 的邮件每小时节流失效 → 每条私信都发一封邮件
|
||
// 而这一切在启动期没有任何提示,排查时也看不出跟 Redis 有关。
|
||
var cacheHandler flamego.Handler
|
||
if conf.Redis.Addr != "" {
|
||
cacheHandler = cache.Cacher(cache.Options{
|
||
Initer: cacheRedis.Initer(),
|
||
Config: cacheRedis.Config{
|
||
Options: &cacheRedis.Options{
|
||
Addr: conf.Redis.Addr,
|
||
Password: conf.Redis.Password,
|
||
DB: 0,
|
||
},
|
||
},
|
||
})
|
||
} else {
|
||
cacheHandler = cache.Cacher(cache.Options{Initer: cache.MemoryIniter()})
|
||
}
|
||
|
||
reqUserSignOut := context.Toggle(&context.ToggleOptions{UserSignOutRequired: true})
|
||
reqUserSignIn := context.Toggle(&context.ToggleOptions{UserSignInRequired: true})
|
||
|
||
f.Group("", func() {
|
||
// 健康检查:存活(永远 200)与就绪(DB 可用才 200,否则 503)。
|
||
// 免鉴权、不限流、不查业务数据;响应体不含任何内部错误详情。
|
||
// 注意:这两个端点仍会经过全局中间件(安全头、IP 黑名单、自动封禁),
|
||
// 这是有意保留的 —— 异常来源不该因为「探针路径」就绕过封禁。
|
||
f.Get("/healthz", route.Healthz)
|
||
f.Get("/readyz", route.Readyz)
|
||
|
||
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("/stats", route.Stats)
|
||
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)
|
||
},
|
||
cacheHandler,
|
||
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
|
||
}
|