0904
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user