This commit is contained in:
tamakyi
2026-09-08 10:10:44 +08:00
commit 2ed27dd55e
288 changed files with 67061 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
package route
import (
"net/http"
"github.com/flamego/flamego"
"github.com/tamakyi/TamaBox/internal/security/autoban"
"github.com/tamakyi/TamaBox/internal/shared"
)
// autobanMiddleware 自动封禁中间件:挂载在静态黑名单之后、业务路由之前。
// - 已封禁 IP 直接 403,不再进入业务
// - 敏感路径命中 → 计入窗口统计(达到阈值触发临时封禁 + 邮件告警)
// - 真正的 404 探测由 f.NotFound 处理器单独计数(见 route.go),
// 不再把业务性的"302 → /"(语言切换 / 登出 / 投票不存在 / 黑名单跳转等)误判为扫描。
// - 核心检测逻辑见 internal/security/autoban(可独立测试);告警发送函数由本层注入。
func autobanMiddleware(b *autoban.Banner) flamego.Handler {
return func(ctx flamego.Context) {
// 经 shared.LimitKey 归一化后再计数与判定:IPv4 原样,IPv6 聚合到 /64。
// 否则攻击者可在单个 /64 内轮换地址,使每个键只命中一次而永不触发阈值。
// 记录与判定必须使用同一个键,否则封禁将无法命中。
ip := shared.LimitKey(shared.ResolveClientIP(ctx.Request().Request))
if ip == "" {
return
}
if b.IsBanned(ip) {
ctx.ResponseWriter().WriteHeader(http.StatusForbidden)
_, _ = ctx.ResponseWriter().Write([]byte("403 Forbidden"))
return
}
path := ctx.Request().URL.Path
kind := autoban.KindNone
if autoban.IsSensitivePath(path) {
kind = autoban.KindSensitive
}
if kind != autoban.KindNone {
defer b.Record(ip, path, kind)
}
// 让下游业务路由先执行,敏感路径的计数延后到响应返回后记录。
ctx.Next()
}
}
+54
View File
@@ -0,0 +1,54 @@
// Copyright 2026 tamakyi. 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 (
"net/http"
"github.com/flamego/flamego"
"github.com/tamakyi/TamaBox/internal/form"
)
// requestBodyLimitMiddleware 在任何业务代码读取请求体之前,全局封顶单次请求体总大小,
// 上限与 form.Bind 共用 form.MaxRequestBodySize(12 MiB),单一事实来源。
//
// 为什么需要全局层(而不是只依赖 form.Bind 内部限制):
// 1. 走 form.Bind 的路由中,Bind 内 MaxBytesReader 的执行顺序晚于 Contexter 组级
// CSRF 校验 —— flamego/csrf 的 Validate 在请求缺少 X-CSRF-Token 头时通过
// FormValue("_csrf") 惰性解析整包 body(multipart 超过 32 MiB 内存阈值后无上限
// 落盘临时文件),校验失败写出的 400 又发生在路由级限流之前。攻击者无需有效
// CSRF token、也不受限流约束,即可持续 POST 超大 multipart 打满磁盘。
// 2. chat /send、vote /submit 等不走 form.Bind 的入口此前完全没有请求体上限。
//
// 挂载于 internal/route/route.go 的 f.Use 链(autoban 之后、所有 Group 之前),
// 因此任何后续解析(CSRF FormValue、form.Bind、业务 FormValue)都发生在受限 reader 上。
// 合法最大单请求约为 8 MiB 单图(storage.MaxUploadBytes)+ 表单开销,12 MiB 不会误伤。
func requestBodyLimitMiddleware() flamego.Handler {
return func(ctx flamego.Context) {
req := ctx.Request()
// 无 body 的方法不处理(避免对静态资源等无谓开销)。
switch req.Method {
case http.MethodPost, http.MethodPut, http.MethodPatch:
default:
return
}
// Content-Length 明确超限时直接 413 快速失败,不进入解析流程。
// (chunked 或未知长度请求的 ContentLength 为 0/-1,走下方 MaxBytesReader 惰性封顶。)
if req.ContentLength > form.MaxRequestBodySize {
http.Error(ctx.ResponseWriter(), http.StatusText(http.StatusRequestEntityTooLarge), http.StatusRequestEntityTooLarge)
return
}
// 其余情况:用 MaxBytesReader 包一层。MaxBytesReader 在读取超过上限时令后续
// Read 报错,使 ParseForm / ParseMultipartForm 失败 —— 超大 body 不会落盘或读入内存。
// 注意:c.Request() 返回 flamego 的包装类型,其 Body 是方法而非字段,
// 必须取内嵌的 *http.Request 才能替换 Body(与 internal/form/form.go 同款写法)。
httpReq := req.Request
httpReq.Body = http.MaxBytesReader(ctx.ResponseWriter(), httpReq.Body, form.MaxRequestBodySize)
}
}
+81
View File
@@ -0,0 +1,81 @@
// Copyright 2026 tamakyi. 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 (
"bytes"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/flamego/flamego"
"github.com/tamakyi/TamaBox/internal/form"
)
// TestRequestBodyLimitEnforced 是 P1-1(请求体 12MiB 上限必须在任何解析之前全局生效)
// 的回归测试。
//
// 背景:此前 MaxBytesReader 只装在 form.Bind(路由级),但 Contexter 组级 CSRF 校验
// (无 X-CSRF-Token 头时经 FormValue("_csrf") 惰性解析整包 body)先于 Bind 执行,
// 超大 multipart 会在路由级限制生效前无上限落盘临时文件耗尽磁盘;chat /send、
// vote /submit 等不走 Bind 的入口更是完全没有上限。修复为全局中间件
// requestBodyLimitMiddleware(挂载于 f.Use 链,先于所有 Group/CSRF)。
//
// 本测试验证三件事(用内存 flamego 实例,无需 DB/Redis):
// 1. 合法请求(≤8MiB 单图上限 + 表单开销)不被误伤 → 200;
// 2. Content-Length 明确超限 → 中间件直接 413 快速失败,不进入 handler;
// 3. chunked/未知长度超限(绕过 Content-Length 预检)→ MaxBytesReader 在读超限时报错,
// 服务端拒绝,而非默默读入/落盘后 200。
func TestRequestBodyLimitEnforced(t *testing.T) {
f := flamego.New()
// 与 internal/route/route.go 一致:全局 Use 挂载先于任何路由/组 handler。
f.Use(requestBodyLimitMiddleware())
f.Post("/echo", func(ctx flamego.Context) {
body, err := io.ReadAll(ctx.Request().Request.Body)
if err != nil {
// MaxBytesReader 超限读报错:服务端拒绝,不得返回 200。
ctx.ResponseWriter().WriteHeader(http.StatusRequestEntityTooLarge)
return
}
ctx.ResponseWriter().WriteHeader(http.StatusOK)
_, _ = ctx.ResponseWriter().Write(body)
})
ts := httptest.NewServer(f)
defer ts.Close()
post := func(payload []byte, chunked bool) int {
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/echo", bytes.NewReader(payload))
req.ContentLength = int64(len(payload))
if chunked {
req.ContentLength = -1 // 强制 chunked,绕过 Content-Length 预检
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("POST failed: %v", err)
}
defer func() { _ = resp.Body.Close() }()
_, _ = io.Copy(io.Discard, resp.Body)
return resp.StatusCode
}
// 1. 合法:8MiB(≈单图上限 storage.MaxUploadBytes + 表单开销)→ 必须放行。
legal := bytes.Repeat([]byte{'a'}, 8<<20)
if code := post(legal, false); code != http.StatusOK {
t.Errorf("合法 8MiB 请求被拒绝:got %d, want 200", code)
}
// 2. Content-Length 明确超限 → 413(中间件预检,不进入 handler)。
huge := bytes.Repeat([]byte{'b'}, form.MaxRequestBodySize+1)
if code := post(huge, false); code != http.StatusRequestEntityTooLarge {
t.Errorf("Content-Length 超限请求未被 413:got %d, want 413", code)
}
// 3. chunked 超限(无 Content-Length,必须靠 MaxBytesReader 惰性封顶)→ 413。
if code := post(huge, true); code != http.StatusRequestEntityTooLarge {
t.Errorf("chunked 超限请求未被拒绝:got %d, want 413", code)
}
}
+412
View File
@@ -0,0 +1,412 @@
package chat
import (
stdctx "context"
"fmt"
"mime/multipart"
"net/http"
"time"
"unicode/utf8"
"github.com/flamego/cache"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/imagecheck"
"github.com/tamakyi/TamaBox/internal/mail"
"github.com/tamakyi/TamaBox/internal/security/censor"
"github.com/tamakyi/TamaBox/internal/shared"
"github.com/tamakyi/TamaBox/internal/storage"
)
const (
MaxMessageImageSize = 5 * 1024 * 1024 // 5MB
)
func ConversationPage(ctx context.Context) {
domain := ctx.Param("domain")
token := ctx.Param("token")
pageUser, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
if err != nil {
ctx.Redirect("/")
return
}
conv, err := db.Conversations.GetByToken(ctx.Request().Context(), token)
if err != nil {
ctx.Redirect("/")
return
}
if conv.IsDeletedByOwner {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errDeletedByOwner"))
ctx.Redirect("/_/" + domain)
return
}
// 纵深防御:私聊对话只能由其所属提问箱(domain 对应的真实属主)的页面访问。
// 否则攻击者可用其它 domain 构造访问,使下方拉黑判定用到错误的主体,绕过真实属主的拉黑。
if conv.OwnerID != pageUser.ID {
ctx.Redirect("/")
return
}
isOwner := ctx.IsLogged && ctx.User.ID == conv.OwnerID
isAsker := ctx.IsLogged && ctx.User.ID == conv.AskerID
if !isOwner && !isAsker {
ctx.Redirect("/")
return
}
// 提问者检查是否被对方(对话真实属主)拉黑
if isAsker {
blocked, err := db.Users.IsUserBlocked(ctx.Request().Context(), conv.OwnerID, conv.AskerID)
if err != nil {
logrus.WithError(err).Error("Failed to check block status for asker")
}
if blocked {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errBlockedView"))
ctx.Redirect("/_/" + domain)
return
}
}
// 检查是否已拉黑对方(仅对被提问者有效,以对话真实属主为准)
var isBlocked bool
if isOwner {
blocked, err := db.Users.IsUserBlocked(ctx.Request().Context(), conv.OwnerID, conv.AskerID)
if err != nil {
logrus.WithError(err).Error("Failed to check block status")
}
isBlocked = blocked
}
messages, err := db.Messages.GetByConversationID(ctx.Request().Context(), conv.ID, nil)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get messages")
ctx.SetInternalError()
return
}
// 获取所有消息的图片(被拉黑时不显示对方图片)
var messageImageMap map[uint][]*db.MessageImage
if !(isOwner && isBlocked) {
msgIDs := make([]uint, 0, len(messages))
for _, msg := range messages {
msgIDs = append(msgIDs, msg.ID)
}
messageImageMap, err = db.MessageImages.GetByMessageIDs(ctx.Request().Context(), msgIDs)
if err != nil {
logrus.WithError(err).Error("Failed to get message images")
messageImageMap = make(map[uint][]*db.MessageImage)
}
} else {
messageImageMap = make(map[uint][]*db.MessageImage)
}
ctx.Data["PageUser"] = pageUser
ctx.Data["Conversation"] = conv
ctx.Data["Messages"] = messages
ctx.Data["MessageImages"] = messageImageMap
ctx.Data["IsOwner"] = isOwner
ctx.Data["IsAsker"] = isAsker
ctx.Data["IsBlocked"] = isBlocked
// 单条消息字数上限,供发送 / 回复 textarea 的 maxlength 与后端校验同源
// (conf.Limits.ChatMessageLen,[limits] 配置段)
ctx.Data["ChatMessageLimit"] = conf.Limits.ChatMessageLen
ctx.Data["CanSend"] = !conv.IsClosed && !conv.IsDeletedByOwner &&
((isOwner && conv.MessageCount < conv.MaxMessages) || (isAsker && conv.MessageCount < conv.MaxMessages && !isBlocked))
ctx.Success("chat/conversation")
}
func SendMessage(ctx context.Context, cache cache.Cache) {
domain := ctx.Param("domain")
token := ctx.Param("token")
content := ctx.Request().FormValue("content")
// 处理图片上传
var imageFile multipart.File
var imageHeader *multipart.FileHeader
imageFile, imageHeader, err := ctx.Request().FormFile("image")
if err != nil && err != http.ErrMissingFile {
logrus.WithError(err).Error("Failed to read uploaded image")
}
if imageFile != nil {
defer imageFile.Close()
}
// 内容与图片至少有一个
if content == "" && imageFile == nil {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errEmpty"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
// 内容长度限制(上限取自 conf.Limits.ChatMessageLen,[limits] 配置段)
if utf8.RuneCountInString(content) > conf.Limits.ChatMessageLen {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errTooLong", conf.Limits.ChatMessageLen))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
pageUser, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
if err != nil {
ctx.Redirect("/")
return
}
conv, err := db.Conversations.GetByToken(ctx.Request().Context(), token)
if err != nil {
ctx.Redirect("/")
return
}
if conv.IsDeletedByOwner {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errConvDeletedSend"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
isOwner := ctx.IsLogged && ctx.User.ID == conv.OwnerID
isAsker := ctx.IsLogged && ctx.User.ID == conv.AskerID
if !isOwner && !isAsker {
ctx.Redirect("/")
return
}
// 提问者发送时检查拉黑与每日限制
if isAsker {
blocked, err := db.Users.IsUserBlocked(ctx.Request().Context(), pageUser.ID, ctx.User.ID)
if err != nil {
logrus.WithError(err).Error("Failed to check block")
}
if blocked {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errBlockedSend"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
// 箱主全局屏蔽词(提问/私聊/投票共用一份词表):对方发给我的消息命中即拦截。
// 仅拦对方 → 箱主方向:我回复对方的内容不受自己设置的屏蔽词限制。
if content != "" {
if _, hit := shared.BlockWordHit(pageUser.BlockWords, content); hit {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errBlockWords"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
}
if pageUser.DailyMessageLimit > 0 {
todayCount, err := db.Users.CountTodayMessagesFromUser(ctx.Request().Context(), pageUser.ID, ctx.User.ID)
if err != nil {
logrus.WithError(err).Error("Failed to count today messages")
}
if todayCount >= int64(pageUser.DailyMessageLimit) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errDailyLimit", pageUser.DailyMessageLimit))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
}
}
if conv.IsClosed || conv.MessageCount >= conv.MaxMessages {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errLimitReached"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
// 私聊功能开关:发送前重新校验箱主私聊功能是否仍开启(创建会话时已查过,
// 但箱主可能在会话中途关闭私聊,此时应阻止继续发消息)。
chatState := db.FeatureStateOf(ctx.Request().Context(), conv.OwnerID, db.FeatureChat)
if !chatState.Open {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errChatClosed"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
// 图片大小限制
if imageHeader != nil && imageHeader.Size > MaxMessageImageSize {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errImageTooLarge", MaxMessageImageSize/(1024*1024)))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
// 上传图片(如有)
var imageURL string
if imageFile != nil {
uploadedURL, err := storage.UploadPictureToS3(imageFile, imageHeader)
if err != nil {
if errors.Is(err, imagecheck.ErrImageTooLarge) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "image.errTooLarge", imagecheck.MaxImageSide, imagecheck.MaxImageSide))
} else {
logrus.WithError(err).Error("Failed to upload image")
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errUploadFailed"))
}
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
imageURL = uploadedURL
}
// 内容安全检测(仅文本)
if content != "" {
censorResponse, err := censor.Text(ctx.Request().Context(), content)
if err != nil {
// 审核源全部不可用:fail-closed,拒绝发送而非免审放行。
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to censor text")
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "censor.unavailable"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
if !censorResponse.Pass {
ctx.SetErrorFlash(censorResponse.ErrorMessage())
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
}
// 原子地递增消息计数:真正的上限检查点(先于消息落库,防止超额消息写入)。
// 并发场景(对话双方同时发送)下,仅当 message_count < max 时才 +1 成功。
ok, err := db.Conversations.IncrementMessageCountIfBelow(ctx.Request().Context(), conv.ID, conv.MaxMessages)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to increment message count")
ctx.SetInternalErrorFlash()
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
if !ok {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errLimitReached"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
// 创建消息
msg, err := db.Messages.Create(ctx.Request().Context(), db.CreateMessageOptions{
ConversationID: conv.ID,
SenderID: ctx.User.ID,
Content: content,
})
if err != nil {
// 计数已递增但消息落库失败:回滚计数,避免占用配额
if rerr := db.Conversations.DecrementMessageCount(ctx.Request().Context(), conv.ID); rerr != nil {
logrus.WithContext(ctx.Request().Context()).WithError(rerr).Error("Failed to rollback message count")
}
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create message")
ctx.SetInternalErrorFlash()
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
// 保存图片记录(如有)
if imageURL != "" {
if _, err := db.MessageImages.Create(ctx.Request().Context(), db.CreateMessageImageOptions{
MessageID: msg.ID,
URL: imageURL,
FileName: imageHeader.Filename,
FileSize: imageHeader.Size,
}); err != nil {
logrus.WithError(err).Error("Failed to save message image")
}
}
// 达到上限时关闭对话(幂等,多实例同时执行也安全)
if err := db.Conversations.MaybeClose(ctx.Request().Context(), conv.ID, conv.MaxMessages); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to maybe close conversation")
}
senderName := ctx.User.Name
shared.SafeGo(func() {
var recipientEmail string
if isOwner {
asker, err := db.Users.GetByID(stdctx.Background(), conv.AskerID)
if err == nil {
recipientEmail = asker.Email
}
} else {
owner, err := db.Users.GetByID(stdctx.Background(), conv.OwnerID)
if err == nil {
recipientEmail = owner.Email
}
}
if recipientEmail == "" {
return
}
// 邮件节流:同一收件人 5 分钟内最多一封通知,防止刷屏导致邮件轰炸。
notifyKey := "chat-notify:" + recipientEmail
if _, err := cache.Get(stdctx.Background(), notifyKey); err == nil {
return
}
if err := mail.SendNewMessageMail(recipientEmail, senderName, pageUser.Domain, token, content); err != nil {
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new message mail")
return
}
_ = cache.Set(stdctx.Background(), notifyKey, time.Now(), 5*time.Minute)
})
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "chat.flash.sent"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
}
func DeleteConversation(ctx context.Context) {
token := ctx.Param("token")
conv, err := db.Conversations.GetByToken(ctx.Request().Context(), token)
if err != nil {
ctx.Redirect("/user/questions")
return
}
if !ctx.IsLogged || ctx.User.ID != conv.OwnerID {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errNoPermission"))
ctx.Redirect("/user/questions")
return
}
if err := db.Conversations.MarkDeletedByOwner(ctx.Request().Context(), conv.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to mark conversation deleted")
ctx.SetInternalErrorFlash()
ctx.Redirect("/user/questions")
return
}
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "chat.flash.deleted"))
ctx.Redirect("/user/questions")
}
// ToggleBlockUser 拉黑/取消屏蔽
func ToggleBlockUser(ctx context.Context) {
token := ctx.Param("token")
conv, err := db.Conversations.GetByToken(ctx.Request().Context(), token)
if err != nil {
ctx.Redirect("/user/questions")
return
}
if !ctx.IsLogged || ctx.User.ID != conv.OwnerID {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errNoPermission"))
ctx.Redirect("/user/questions")
return
}
blocked, _ := db.Users.IsUserBlocked(ctx.Request().Context(), conv.OwnerID, conv.AskerID)
if blocked {
err = db.Users.UnblockUser(ctx.Request().Context(), conv.OwnerID, conv.AskerID)
} else {
err = db.Users.BlockUser(ctx.Request().Context(), conv.OwnerID, conv.AskerID)
}
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to toggle block")
ctx.SetInternalErrorFlash()
} else {
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "chat.flash.operated"))
}
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", ctx.Param("domain"), token))
}
+120
View File
@@ -0,0 +1,120 @@
package route
import (
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"testing"
"github.com/flamego/csrf"
"github.com/flamego/flamego"
"github.com/flamego/session"
)
// TestCSRFTokenBoundToUserSession 是 P1-1 的回归测试:CSRF token 必须绑定到登录用户的
// 会话键(CSRFUserSessionKey = "uid"),而非 flamego/csrf 默认的 "userID"。
//
// 背景:flamego/csrf 的校验是无状态 HMAC 重算(token = HMAC(secret, id:action:ts),不与
// session 内存储的 token 比对)。一旦 SessionKey 与登录时写入的会话键(login.go 中的
// Session.Set("uid", ...))不一致,token 内绑定的 id 恒为 "0",任意匿名访客从公开 /login
// 页面取到的 token 对所有已登录用户均有效 —— CSRF 退化为全站公开常量,跨站请求伪造可自由发起。
//
// 本测试用内存会话(无需数据库)重放「会话 B 携带会话 A 的 token 提交」这一跨会话复用攻击:
// - 会话 A(已登录 uid=42)携带自己的 token 提交 → 必须通过(200);
// - 会话 B(匿名)携带会话 A 的 token 跨会话提交 → 必须被拒绝(400)。
//
// 若 CSRFUserSessionKey 被误改回 "userID",第二项断言会失败,CI 即报警。
func TestCSRFTokenBoundToUserSession(t *testing.T) {
const secret = "test-secret-for-csrf-regression"
f := flamego.New()
f.Use(session.Sessioner(session.Options{
Initer: session.MemoryIniter(),
Cookie: session.CookieOptions{
HTTPOnly: true,
SameSite: http.SameSiteLaxMode,
},
}))
// 仅当请求带 X-Test-Login 头时才把 uid=42 写入会话,模拟「已登录用户」。
// 该中间件必须排在 csrf 之前,使 token 在生成时即绑定到 uid。
f.Use(func(ctx flamego.Context, s session.Session) {
if ctx.Request().Header.Get("X-Test-Login") != "" {
s.Set(CSRFUserSessionKey, uint(42))
}
})
f.Use(csrf.Csrfer(csrf.Options{
Secret: secret,
Header: "X-CSRF-Token",
SessionKey: CSRFUserSessionKey,
SetHeader: true, // 便于测试从响应头取出 token
}))
// 受保护路由:仅当 CSRF 校验通过才返回 200;失败由 csrf.Error 写 400。
f.Post("/protected", func(ctx flamego.Context, x csrf.CSRF) {
x.Validate(ctx)
// 校验失败时 x.Validate 已写入 400;此处 Write 在已写 400 时仅追加 body,
// 状态保持 400;校验通过时此处隐式返回 200。
ctx.ResponseWriter().Write([]byte("ok"))
})
// 生成 token 的 GET 路由(token 在 GET 时生成,与真实应用一致)。
f.Get("/token", func(ctx flamego.Context) {
ctx.ResponseWriter().Write([]byte("ok"))
})
ts := httptest.NewServer(f)
defer ts.Close()
newClient := func() *http.Client {
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatalf("cookiejar.New: %v", err)
}
return &http.Client{Jar: jar}
}
getToken := func(client *http.Client, login bool) string {
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/token", nil)
if login {
req.Header.Set("X-Test-Login", "1")
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("GET /token: %v", err)
}
defer resp.Body.Close()
tok := resp.Header.Get("X-CSRF-Token")
if tok == "" {
t.Fatalf("GET /token 未返回 X-CSRF-Token(Csrfer SetHeader 应已启用)")
}
return tok
}
postProtected := func(client *http.Client, token string) int {
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/protected", nil)
if token != "" {
req.Header.Set("X-CSRF-Token", token)
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("POST /protected: %v", err)
}
defer resp.Body.Close()
return resp.StatusCode
}
// 会话 A:已登录用户(uid=42)。
clientA := newClient()
tokenA := getToken(clientA, true)
// 同一会话携带自己的 token 提交 —— 必须通过(200)。
if code := postProtected(clientA, tokenA); code != http.StatusOK {
t.Fatalf("会话 A 携带自身 token 提交,期望 200,实际 %d(修复后不应被拒绝)", code)
}
// 会话 B:匿名访客,跨会话复用会话 A 的 token 提交 —— 必须被拒绝(400)。
// 这是 P1-1 的核心回归点:token 绑定了 uid=42,会话 B 的 id 为 "0",HMAC 重算不匹配。
clientB := newClient()
if code := postProtected(clientB, tokenA); code != http.StatusBadRequest {
t.Fatalf("会话 B 跨会话复用会话 A 的 token,期望 400,实际 %d(CSRF 绑定失效!)", code)
}
}
+150
View File
@@ -0,0 +1,150 @@
// Copyright 2026 tamakyi. 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 (
"bufio"
"compress/gzip"
"errors"
"net"
"net/http"
"strings"
)
// GzipHandler 返回一个压缩中间件,用于包装 http.Handler。
// 它对可压缩的响应(HTML / CSS / JS / JSON 等文本类型)做 gzip 压缩,
// 自动跳过图片、视频、字体等已经压缩过的格式。
//
// 由于 flamego 的 Context 无法替换底层 ResponseWriter,这里采用在
// http.Server 最外层包装 Handler 的方式,保证对所有响应(含静态文件)生效。
func GzipHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 客户端不支持 gzip 时直接透传
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
next.ServeHTTP(w, r)
return
}
gw := &gzipResponseWriter{
ResponseWriter: w,
gz: gzip.NewWriter(w),
}
defer func() {
// 只有真正写入过 body 才关闭 gzip 流,避免 302/204 等
// 无 body 响应被追加空的 gzip 尾块
if gw.compress && gw.wrote {
_ = gw.gz.Close()
}
}()
next.ServeHTTP(gw, r)
})
}
// gzipResponseWriter 包装 http.ResponseWriter,在 WriteHeader 时根据
// Content-Type 决定是否启用 gzip,保持接口兼容(Flusher / Hijacker / Pusher)。
type gzipResponseWriter struct {
http.ResponseWriter
gz *gzip.Writer
compress bool
wrote bool
wroteHeader bool
}
// WriteHeader 记录状态码,并根据 Content-Type 决定是否压缩。
// 由于 Content-Encoding 改变后 Content-Length 不再准确,需要删除该头。
func (w *gzipResponseWriter) WriteHeader(status int) {
if w.wroteHeader {
return
}
w.wroteHeader = true
// 无实体响应(204 No Content / 304 Not Modified)不应附加 Content-Encoding。
if status == http.StatusNoContent || status == http.StatusNotModified {
w.ResponseWriter.WriteHeader(status)
return
}
if shouldCompressContentType(w.Header().Get("Content-Type")) {
w.compress = true
w.Header().Del("Content-Length")
w.Header().Set("Content-Encoding", "gzip")
w.Header().Add("Vary", "Accept-Encoding")
}
w.ResponseWriter.WriteHeader(status)
}
// Write 写入响应体。若尚未 WriteHeader(handler 依赖 net/http 自动嗅探
// Content-Type 的情况),先用首个字节块探测类型再决策。
func (w *gzipResponseWriter) Write(b []byte) (int, error) {
if !w.wroteHeader {
ct := w.Header().Get("Content-Type")
if ct == "" {
ct = http.DetectContentType(b)
// 将嗅探到的类型写回响应头,使 WriteHeader 的压缩判定能正确生效
// (否则空 Content-Type 会被判定为"不压缩",导致本应压缩的纯文本响应未压缩)。
w.Header().Set("Content-Type", ct)
}
w.WriteHeader(http.StatusOK)
}
if w.compress {
w.wrote = true
return w.gz.Write(b)
}
return w.ResponseWriter.Write(b)
}
// Flush 转发底层 Flusher,并在压缩模式下先刷新 gzip 缓冲。
func (w *gzipResponseWriter) Flush() {
if w.compress && w.gz != nil {
_ = w.gz.Flush()
}
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// Hijack 转发底层 Hijacker,保持对 WebSocket 等升级协议兼容。
func (w *gzipResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if h, ok := w.ResponseWriter.(http.Hijacker); ok {
return h.Hijack()
}
return nil, nil, errors.New("gzipResponseWriter: hijacking not supported")
}
// Push 转发 HTTP/2 Server Push(如果底层支持)。
func (w *gzipResponseWriter) Push(target string, opts *http.PushOptions) error {
if p, ok := w.ResponseWriter.(http.Pusher); ok {
return p.Push(target, opts)
}
return http.ErrNotSupported
}
// shouldCompressContentType 判断响应 Content-Type 是否值得压缩。
// 空类型、二进制图片/视频/音频/字体等已压缩格式直接跳过。
func shouldCompressContentType(ct string) bool {
ct = strings.ToLower(strings.TrimSpace(ct))
if ct == "" {
return false
}
// SVG 是文本格式,值得压缩;其余图片格式已是压缩格式
if strings.HasPrefix(ct, "image/") {
return strings.Contains(ct, "svg")
}
// 视频 / 音频 / 字体已是压缩格式
if strings.HasPrefix(ct, "video/") || strings.HasPrefix(ct, "audio/") ||
strings.HasPrefix(ct, "font/") {
return false
}
// 已知压缩或二进制格式
switch ct {
case "application/zip", "application/gzip", "application/x-7z-compressed",
"application/x-rar-compressed", "application/pdf",
"application/vnd.ms-fontobject", "application/wasm",
"application/octet-stream", "application/x-protobuf", "application/grpc":
return false
}
return true
}
+63
View File
@@ -0,0 +1,63 @@
package route
import (
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/flamego/flamego"
"github.com/tamakyi/TamaBox/static"
)
// TestIndexNowKeyFileRoute 验证 IndexNow 密钥文件路由:能匹配含点号的路径段并返回 embed 内容,
// 同时拒绝路径穿越与非法文件名。
//
// 背景:flamego 的 Static 中间件 Prefix 为 "/static",不会接管 /index-now/,若缺少本路由,
// 请求会落到 f.NotFound 被 302 重定向到首页,搜索引擎的密钥校验必然失败。
func TestIndexNowKeyFileRoute(t *testing.T) {
f := flamego.NewWithLogger(io.Discard)
f.Get("/index-now/{key}", func(c flamego.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")
_, _ = io.Copy(c.ResponseWriter(), fs)
})
cases := []struct {
path string
want int
wantKey bool
}{
{"/index-now/66e5e0911b0941f2ba7dbfbbd961e9be.txt", 200, true},
{"/index-now/..%2frobots.txt", 404, false}, // 路径穿越(编码斜杠)
{"/index-now/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.txt", 404, false}, // 合法格式但文件不存在
{"/index-now/../../robots.txt", 404, false}, // 路径穿越(原始)
{"/index-now/66e5e0911b0941f2ba7dbfbbd961e9be.txt.exe", 404, false}, // 后缀不符
}
for _, tc := range cases {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
w := httptest.NewRecorder()
f.ServeHTTP(w, req)
body := w.Body.String()
got := w.Code
if got != tc.want {
t.Errorf("%s -> status %d, want %d (body=%q)", tc.path, got, tc.want, body)
continue
}
if tc.wantKey && body != "66e5e0911b0941f2ba7dbfbbd961e9be" {
t.Errorf("%s -> body %q, want key content", tc.path, body)
}
t.Logf("%-56s -> %d %q", tc.path, got, body)
}
}
+87
View File
@@ -0,0 +1,87 @@
package route
import (
"net"
"net/http"
"strings"
"github.com/flamego/flamego"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/shared"
)
// ipBlacklistMiddleware returns a Flamego handler that checks the client's IP
// against a configurable blacklist. When a blacklisted IP is detected, the
// request is redirected (HTTP 302) to the configured redirect URL.
//
// Supports exact IP matching and CIDR notation (e.g. "192.168.1.0/24").
// The client IP is resolved by checking X-Real-IP, X-Forwarded-For, then
// falling back to the raw RemoteAddr.
func ipBlacklistMiddleware() flamego.Handler {
raw := strings.TrimSpace(conf.Security.IPBlacklist)
if raw == "" {
return func(ctx flamego.Context) {}
}
// Pre-parse all entries into exact IPs and CIDR networks.
type cidrEntry struct{ ipNet *net.IPNet }
exact := make(map[string]bool)
cidrs := make([]cidrEntry, 0)
for _, entry := range strings.Split(raw, ",") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
// Try CIDR first.
if _, ipNet, err := net.ParseCIDR(entry); err == nil {
cidrs = append(cidrs, cidrEntry{ipNet: ipNet})
logrus.Infof("IP blacklist: CIDR %s added", entry)
continue
}
// Fall back to exact IP.
if ip := net.ParseIP(entry); ip != nil {
exact[ip.String()] = true
logrus.Infof("IP blacklist: exact IP %s added", ip.String())
continue
}
logrus.Warnf("IP blacklist: skipping invalid entry %q", entry)
}
if len(exact) == 0 && len(cidrs) == 0 {
return func(ctx flamego.Context) {}
}
redirectURL := strings.TrimSpace(conf.Security.IPBlacklistRedirectURL)
if redirectURL == "" {
logrus.Warn("IP blacklist is configured but ip_blacklist_redirect_url is empty")
}
return func(ctx flamego.Context) {
clientIP := shared.ResolveClientIP(ctx.Request().Request)
if clientIP == "" {
return
}
// Exact match first (O(1)).
if exact[clientIP] {
logrus.WithField("ip", clientIP).Info("IP blacklist: exact match, redirecting")
ctx.Redirect(redirectURL, http.StatusFound)
return
}
// CIDR match (O(n), n is typically small).
parsed := net.ParseIP(clientIP)
if parsed != nil {
for _, e := range cidrs {
if e.ipNet.Contains(parsed) {
logrus.WithField("ip", clientIP).Infof("IP blacklist: CIDR %s match, redirecting", e.ipNet)
ctx.Redirect(redirectURL, http.StatusFound)
return
}
}
}
}
}
+183
View File
@@ -0,0 +1,183 @@
package route
import (
"fmt"
"net/http"
"sync"
"time"
"github.com/flamego/flamego"
"github.com/flamego/session"
"github.com/tamakyi/TamaBox/internal/shared"
"github.com/tamakyi/TamaBox/route/vote"
)
// rateLimiter provides a simple in-memory rate limiter using a sliding window.
type rateLimiter struct {
mu sync.Mutex
visitors map[string][]time.Time
limit int
window time.Duration
}
// ---------- 全局清理 ----------
//
// 所有 rateLimiter 实例注册到全局注册表,由单个常驻 goroutine 统一清理,
// 避免每个中间件实例各自启动一个永久 goroutine(旧实现会随路由数量线性泄漏 goroutine)。
var (
rateLimitersMu sync.Mutex
rateLimiters = make(map[*rateLimiter]struct{})
rateLimiterClean sync.Once
)
func registerRateLimiter(rl *rateLimiter) {
rateLimitersMu.Lock()
rateLimiters[rl] = struct{}{}
rateLimitersMu.Unlock()
// 仅启动一次全局清理循环
rateLimiterClean.Do(func() {
go func() {
for {
time.Sleep(5 * time.Minute)
rateLimitersMu.Lock()
for rl := range rateLimiters {
rl.cleanup()
}
rateLimitersMu.Unlock()
}
}()
})
}
func newRateLimiter(limit int, window time.Duration) *rateLimiter {
rl := &rateLimiter{
visitors: make(map[string][]time.Time),
limit: limit,
window: window,
}
registerRateLimiter(rl)
return rl
}
func (rl *rateLimiter) allow(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
// 恶意伪造 X-Forwarded-For 可撑爆 visitors map,超过阈值时快速清理
// 窗口内无活动的 key(其最新时间戳已过期)
if len(rl.visitors) > 10000 {
cutoff2 := time.Now().Add(-rl.window)
for ip, ts := range rl.visitors {
if len(ts) == 0 || ts[len(ts)-1].Before(cutoff2) {
delete(rl.visitors, ip)
}
}
}
now := time.Now()
cutoff := now.Add(-rl.window)
times := rl.visitors[ip]
// Remove expired entries.
valid := times[:0]
for _, t := range times {
if t.After(cutoff) {
valid = append(valid, t)
}
}
if len(valid) >= rl.limit {
rl.visitors[ip] = valid
return false
}
valid = append(valid, now)
rl.visitors[ip] = valid
return true
}
func (rl *rateLimiter) cleanup() {
rl.mu.Lock()
defer rl.mu.Unlock()
cutoff := time.Now().Add(-rl.window)
for ip, times := range rl.visitors {
valid := times[:0]
for _, t := range times {
if t.After(cutoff) {
valid = append(valid, t)
}
}
if len(valid) == 0 {
delete(rl.visitors, ip)
} else {
rl.visitors[ip] = valid
}
}
}
// rateLimitMiddleware returns a Flamego handler that applies rate limiting.
// limit: max requests per window; window: time window duration.
//
// 匿名 / 登录分级限流:登录用户(session 中存在 uid)使用 3 倍额度,
// 未登录访客使用基础额度。分级使用独立限流器统计,互不污染。
func rateLimitMiddleware(limit int, window time.Duration) flamego.Handler {
anonymousRL := newRateLimiter(limit, window)
loggedRL := newRateLimiter(limit*3, window)
return func(ctx flamego.Context, s session.Session) {
// 统一通过 shared.ResolveClientIP 解析,trust_proxy=false 时忽略伪造的代理头。
// 再经 shared.LimitKey 归一化:IPv4 原样,IPv6 聚合到 /64,
// 防止在单个 /64 内轮换海量地址绕过阈值(详见 LimitKey 注释)。
ip := shared.LimitKey(shared.ResolveClientIP(ctx.Request().Request))
rl := anonymousRL
if _, ok := s.Get("uid").(uint); ok {
rl = loggedRL
}
if !rl.allow(ip) {
ctx.ResponseWriter().WriteHeader(http.StatusTooManyRequests)
_, _ = ctx.ResponseWriter().Write([]byte("Too many requests. Please try again later."))
return
}
}
}
// voteSubmitRateLimitMiddleware 对投票提交同时执行两层限流:
// 匿名身份层按 token 计数,IP 层使用更高阈值作为批量换 token 的风控兜底。
// 这样共享公网出口的多个匿名浏览器不会共享低阈值,同时仍能限制异常突发流量。
func voteSubmitRateLimitMiddleware(limit int, window time.Duration) flamego.Handler {
anonymousTokenRL := newRateLimiter(limit, window)
loggedUserRL := newRateLimiter(limit*3, window)
anonymousIPRL := newRateLimiter(limit*10, window)
tooMany := func(ctx flamego.Context) {
ctx.ResponseWriter().WriteHeader(http.StatusTooManyRequests)
_, _ = ctx.ResponseWriter().Write([]byte("Too many requests. Please try again later."))
}
return func(ctx flamego.Context, s session.Session) {
r := ctx.Request().Request
if uid, ok := s.Get("uid").(uint); ok && uid > 0 {
if !loggedUserRL.allow(fmt.Sprintf("user:%d", uid)) {
tooMany(ctx)
}
return
}
// 匿名请求始终经过高阈值 IP 兜底;共享出口下每个有效 token 仍独立计数。
// IP 键同样经 LimitKey 聚合,避免 IPv6 轮换绕过兜底阈值。
if !anonymousIPRL.allow("ip:" + shared.LimitKey(shared.ResolveClientIP(r))) {
tooMany(ctx)
return
}
key := vote.VoterTokenRateLimitKey(r)
if key != "" && !anonymousTokenRL.allow("token:"+key) {
tooMany(ctx)
}
}
}
+27
View File
@@ -0,0 +1,27 @@
package route
import (
"testing"
"time"
)
func TestVoteSubmitRateLimitKeysSeparateSharedIPUsers(t *testing.T) {
tokenRL := newRateLimiter(1, time.Minute)
ipRL := newRateLimiter(2, time.Minute)
// Two devices behind one public IP use different signed token keys.
if !tokenRL.allow("token:device-a") || !tokenRL.allow("token:device-b") {
t.Fatal("different voter tokens should have independent limits")
}
if tokenRL.allow("token:device-a") {
t.Fatal("the same voter token should be limited independently")
}
// The shared IP is only a high-threshold fallback, not a one-vote identity.
if !ipRL.allow("ip:203.0.113.10") || !ipRL.allow("ip:203.0.113.10") {
t.Fatal("shared IP should allow multiple normal submissions")
}
if ipRL.allow("ip:203.0.113.10") {
t.Fatal("IP fallback should still stop an abnormal burst")
}
}
+441
View File
@@ -0,0 +1,441 @@
// 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/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,
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)
})
// 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)
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
}