// 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" "strings" "time" "github.com/flamego/flamego" "github.com/sirupsen/logrus" "github.com/tamakyi/TamaBox/internal/shared" ) // accessLogMiddleware 是 flamego.Classic() 内置 Logger() 的替代实现。 // // 为什么不能用内置的:它记录的是 ctx.Request().RequestURI —— **含 query string** // (flamego@v1.9.4/logger.go:36,45)。本站有几个参数绝对不能进日志: // // ?t= 私密提问的访问钥匙(route/question/question.go) // ?code= 一次性改密凭证(route/auth/recover.go) // ?q= 探索页搜索词 // // 拿到 ?code= 可以直接重置该用户密码;拿到 ?t= 可以读别人发给箱主的私密提问。 // 而日志通常落 journald / out.log / 容器 json-file,凡是能看日志的人 // (外包运维、日志采集平台、Uptime 面板)都能直接利用。 // // internal/tracing/flamego.go:36-40 早就为同一原因只取 URL.Path 并留了注释, // 这里是同一个判断,之前只是漏在了框架自带的那层中间件上。 // // 因此这里**只记 URL.Path**。日后若要按 query 排查,请显式加白名单键 // (tab/sort/page 这类无隐私含义的),不要把 RawQuery 整体加回来。 func accessLogMiddleware() flamego.Handler { return func(ctx flamego.Context) { started := time.Now() // 放在 Recovery 外层:panic 被 Recovery 兜成 500 后,这里仍能记录状态码。 ctx.Next() status := ctx.ResponseWriter().Status() if !shouldAccessLog(ctx.Request().URL.Path, status) { return } logrus.WithFields(accessLogFields(ctx.Request().Request, status, time.Since(started))).Info("access") } } // shouldAccessLog 判断这条请求要不要进访问日志。 // // 静态资源一页几十个请求,成功的不记,否则日志会被淹掉; // 4xx/5xx 仍然要记 —— 排查「主题图 / 字体 404」靠的就是这些。 func shouldAccessLog(path string, status int) bool { return !(strings.HasPrefix(path, "/static/") && status < http.StatusBadRequest) } // accessLogFields 构造访问日志字段。 // // 抽成独立函数而不是内联,是为了让「无论请求带什么 query,path 字段都只有 path」 // 这件事能被单测直接断言 —— 这正是之前出问题的地方(框架默认 Logger 记的是 RequestURI)。 func accessLogFields(req *http.Request, status int, elapsed time.Duration) logrus.Fields { return logrus.Fields{ "method": req.Method, // 只取 URL.Path。绝不要换成 RequestURI / RawQuery,见文件头注释。 "path": req.URL.Path, "status": status, "duration": elapsed.Round(time.Millisecond).String(), // 客户端 IP 走站内唯一口径 shared.ResolveClientIP(反代下取真实来源, // 且只在 TCP 对端可信时才采信代理头),与封禁记录用同一套值, // 出事时能把「日志里这个人」和「ban_records 里这条」对上。 "remote_ip": shared.ResolveClientIP(req), } }