Files
tamakyi b4c2a8452f 审计修复:后端(安全 / 数据一致性 / 性能)
audit-2026-09-21.md 里的后端条目落地。每条都配了回归守卫,并逐条做过变异验证
(注入原 bug → 确认按正确理由变红 → 还原 → 变绿)。

安全
- CSRF 只加同源 Origin/Referer 守卫(csrf.go),不动模板
- 会话 cookie 的 Secure 按请求判断;两步验证两闸(loginGuard + 验证码一次性)
- 跳转收口到 safeInternalPath;clientIP 只认 XFF 最后一段
- GPX 按属主圈定且查 RowsAffected;下载名走 downloadDisposition
- 上传口统一 MaxBytesReader;落盘后的失败路径清掉半成品

数据一致性
- 区域判定两端各过一遍再 AND(apiTrack 与 movementsWhere 对齐)
- Filter.Verdict 全域一致(下推到 visitsWhere);聚合刷新必须返回 error
- 附属数据改挂坐标键,Go 与 SQL 两套表达式逐位一致
- 脏备份的伪时间/越界坐标不再进领域模型;rows 循环统一 drainRows
- 合并与导出:writeRawRow 上抛错误、悬空引用清理、原生列补全

性能
- placeCols 相关子查询带 dataset_id 前导列;findSourceArchive 先按大小筛
- 批量勾选改 ANY($3::bigint[]);fillupCache 加上限;同一查询不再查两遍

另:新增「全新库」冒烟测试 —— 空库跑完 Migrate 再用应用侧读查询真打一遍,
迁移块排到依赖表之前这种错只有新库会炸,老库毫无症状。
2026-09-24 20:37:16 +08:00

283 lines
9.9 KiB
Go

package web
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"net"
"net/http"
"net/netip"
"strconv"
"strings"
"rond-with-you/internal/stats"
"rond-with-you/internal/votes"
)
// voterCookie 给匿名访客分配稳定身份,用于「一人一票」去重。
const voterCookie = "rond_voter"
// clientIP 取访客 IP,匿名投票身份用它叠一层「同一来源」的识别。
//
// X-Forwarded-For 客户端自己也能发,所以这里有两个讲究:
// 1. 取**最后一段**而不是第一段。nginx 的 `$proxy_add_x_forwarded_for` 是把客户端发来的值
// 原样接在**前面**、再把真实对端补在后面,所以第一段恰恰是最好伪造的那个;最后一段是
// 反代自己写的,客户端改不动。
// 2. 只有最后一段确实能解析成 IP 才采用,否则整个头作废、退回 RemoteAddr——
// 免得一句垃圾字符串被当成身份。
//
// 遗留 1:若本站直接暴露在公网(前面没有反代),客户端仍能自造这个头。要彻底堵上得在最外层
// 覆盖或剥掉它(`proxy_set_header X-Forwarded-For $remote_addr`)。
//
// 遗留 2:反代前面再叠一层 CDN(而反代没配 `real_ip`)时,最后一段会变成 CDN 自己的 IP,
// 于是所有访客被叠成同一个来源。cookie 仍各不相同,只是这层「同源」识别退化成全员共用,
// 不会把谁认成谁。真有多层代理,判据要改成「按已知跳数从右往左取」,不能写死最后一段。
func clientIP(r *http.Request) string {
if ip, ok := lastForwardedIP(r.Header.Values("X-Forwarded-For")); ok {
return ip
}
return hostOnly(r.RemoteAddr)
}
// lastForwardedIP 取 X-Forwarded-For 的最后一段并校验成 IP。多行头按逗号拼起来看,
// 与实际代理的拼装方式一致;IPv4-mapped IPv6 归一成 IPv4,同一客户端不该因为写法不同而换身份。
func lastForwardedIP(header []string) (string, bool) {
raw := strings.Join(header, ",")
if i := strings.LastIndexByte(raw, ','); i >= 0 {
raw = raw[i+1:]
}
last := strings.TrimSpace(raw)
if addr, err := netip.ParseAddr(last); err == nil {
return addr.Unmap().String(), true
}
if host, _, err := net.SplitHostPort(last); err == nil {
if addr, err := netip.ParseAddr(host); err == nil {
return addr.Unmap().String(), true
}
}
return "", false
}
// hostOnly 去掉 RemoteAddr 里的端口;没有端口就原样返回。
func hostOnly(remoteAddr string) string {
if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
return host
}
return remoteAddr
}
// voterKeyFromRequest 从既有 cookie 推导匿名身份;没有 cookie 返回空串。
// GET 页面据此显示「你自己的票」,但不主动种 cookie。
func (s *Server) voterKeyFromRequest(r *http.Request) string {
c, err := r.Cookie(voterCookie)
if err != nil || len(c.Value) < 16 {
return ""
}
return hashVoter(c.Value, clientIP(r))
}
// ensureVoterKey 在投票时调用:没有 cookie 就种一个,再返回匿名身份。
// 叠加 IP 后即便访客清 cookie 重来,也会被识别为同一来源,起轻度防刷作用。
func (s *Server) ensureVoterKey(w http.ResponseWriter, r *http.Request) string {
c, err := r.Cookie(voterCookie)
var uid string
if err == nil && len(c.Value) >= 16 {
uid = c.Value
} else {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return ""
}
uid = hex.EncodeToString(b)
http.SetCookie(w, &http.Cookie{
Name: voterCookie, Value: uid, Path: "/",
MaxAge: 3600 * 24 * 365, HttpOnly: true, SameSite: http.SameSiteLaxMode,
})
}
return hashVoter(uid, clientIP(r))
}
func hashVoter(uid, ip string) string {
sum := sha256.Sum256([]byte(uid + "|" + ip))
return hex.EncodeToString(sum[:16])
}
// votePayload 是投票数据的对外结构(含访客票数、站长结论与「我投的」)。
// Admin / Public 让前端自己决定气泡里该显示「我的结论」还是「大家怎么说」。
type votePayload struct {
Verdicts map[int]int `json:"verdicts"`
Tallies map[int]votes.Tally `json:"tallies"`
Mine map[int]int `json:"mine"`
Admin bool `json:"admin"`
Public bool `json:"public"`
}
// apiVotes 返回全部地点的结论、票数与当前访客自己的票,供地点库 / 地图一次性取回。
// 标记不对访客公开时,访客拿到空集。
func (s *Server) apiVotes(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
isAdmin := userFrom(ctx) != nil
public := true
if sets, err := s.sets.Load(ctx); err == nil {
public = sets.MarksPublic
}
if !isAdmin && !public {
s.json(w, votePayload{
Verdicts: map[int]int{}, Tallies: map[int]votes.Tally{}, Mine: map[int]int{},
Public: false,
})
return
}
var votesDatasetID int64
if ds, err := s.datasetFor(ctx); err == nil && ds != nil {
votesDatasetID = ds.ID
}
verdicts, err := s.votes.Verdicts(ctx, votesDatasetID)
if err != nil {
s.json(w, map[string]string{"error": err.Error()})
return
}
tallies, err := s.votes.Tallies(ctx, votesDatasetID)
if err != nil {
s.json(w, map[string]string{"error": err.Error()})
return
}
mine, err := s.votes.VoterAll(ctx, votesDatasetID, s.voterKeyFromRequest(r))
if err != nil {
s.json(w, map[string]string{"error": err.Error()})
return
}
s.json(w, votePayload{Verdicts: verdicts, Tallies: tallies, Mine: mine, Admin: isAdmin, Public: public})
}
// apiVerdict 供地图气泡以 AJAX 设置站长结论(不刷新页面)。
func (s *Server) apiVerdict(w http.ResponseWriter, r *http.Request) {
u := userFrom(r.Context())
if err := r.ParseForm(); err != nil {
s.json(w, map[string]string{"error": "请求解析失败"})
return
}
ds, err := s.datasetFor(r.Context())
if err != nil || ds == nil {
s.json(w, map[string]string{"error": "还没有可编辑的数据集"})
return
}
srcPK, err := strconv.Atoi(r.PostFormValue("src_pk"))
verdict, _ := strconv.Atoi(r.PostFormValue("verdict"))
if err != nil {
s.json(w, map[string]string{"error": "地点标识无效"})
return
}
if verdict != votes.VerdictRecommend && verdict != votes.VerdictAvoid {
verdict = 0
}
if err := s.votes.SaveVerdict(r.Context(), u.ID, ds.ID, srcPK, verdict); err != nil {
s.json(w, map[string]string{"error": err.Error()})
return
}
s.json(w, map[string]any{"verdict": verdict})
}
// apiVote 记录访客对某地点的点赞 / 点踩,返回最新票数与该访客的选择。
func (s *Server) apiVote(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := r.ParseForm(); err != nil {
s.json(w, map[string]string{"error": "请求解析失败"})
return
}
ds, err := s.datasetFor(ctx)
if err != nil || ds == nil {
s.json(w, map[string]string{"error": "还没有可投票的数据集"})
return
}
srcPK, err := strconv.Atoi(r.PostFormValue("src_pk"))
vote, _ := strconv.Atoi(r.PostFormValue("vote"))
if err != nil || (vote != 1 && vote != -1) {
s.json(w, map[string]string{"error": "参数无效"})
return
}
key := s.ensureVoterKey(w, r)
if key == "" {
s.json(w, map[string]string{"error": "无法识别访客"})
return
}
t, mine, err := s.votes.Cast(ctx, ds.ID, srcPK, key, vote)
if err != nil {
s.json(w, map[string]string{"error": err.Error()})
return
}
s.json(w, map[string]any{"up": t.Up, "down": t.Down, "mine": mine})
}
// voteTotals 汇总站长结论数量与访客票数,供统计页展示。
func (s *Server) voteTotals(ctx context.Context, datasetID int64) (rec, avoid, up, down int, err error) {
// 两张票表都挂坐标键,统计要按「本数据集的地点」圈定范围,
// 不能再按它们自己的 dataset_id 过滤——那个只是最后写入时留下的线索。
err = s.db.QueryRowContext(ctx, `SELECT
(SELECT count(*) FROM place_votes v JOIN places p ON v.place_key = `+placeKeyExpr+`
WHERE p.dataset_id=$1 AND v.verdict=1),
(SELECT count(*) FROM place_votes v JOIN places p ON v.place_key = `+placeKeyExpr+`
WHERE p.dataset_id=$1 AND v.verdict=-1),
(SELECT count(*) FROM place_visitor_votes vv JOIN places p ON vv.place_key = `+placeKeyExpr+`
WHERE p.dataset_id=$1 AND vv.vote=1),
(SELECT count(*) FROM place_visitor_votes vv JOIN places p ON vv.place_key = `+placeKeyExpr+`
WHERE p.dataset_id=$1 AND vv.vote=-1)`, datasetID).
Scan(&rec, &avoid, &up, &down)
return
}
// verdictPlaces 返回站长标为某结论的地点,按到访次数排序。
func (s *Server) verdictPlaces(ctx context.Context, datasetID int64, verdict, limit int) ([]stats.Place, error) {
q := `SELECT ` + stats.PlaceCols + ` FROM places p
WHERE p.dataset_id=$1 AND EXISTS (SELECT 1 FROM place_votes pv
WHERE pv.place_key = ` + placeKeyExpr + ` AND pv.verdict=$2)
ORDER BY p.visit_count DESC LIMIT ` + strconv.Itoa(limit)
rows, err := s.db.QueryContext(ctx, q, datasetID, verdict)
if err != nil {
return nil, err
}
defer rows.Close()
var out []stats.Place
for rows.Next() {
var pl stats.Place
if err := stats.ScanPlace(rows, &pl); err != nil {
return nil, err
}
out = append(out, pl)
}
return out, rows.Err()
}
// adminPlaceVote 设置站长对地点的官方结论(1 推荐 / -1 踩雷 / 0 取消)。
func (s *Server) adminPlaceVote(w http.ResponseWriter, r *http.Request) {
u := userFrom(r.Context())
if err := r.ParseForm(); err != nil {
s.serverError(w, r, err)
return
}
ds, err := s.datasetFor(r.Context())
if err != nil || ds == nil {
s.adminFlash(w, r, "err", "还没有可编辑的数据集")
return
}
srcPK, err := strconv.Atoi(r.PostFormValue("src_pk"))
verdict, _ := strconv.Atoi(r.PostFormValue("verdict"))
if err != nil {
s.adminFlash(w, r, "err", "地点标识无效")
return
}
if verdict != votes.VerdictRecommend && verdict != votes.VerdictAvoid {
verdict = 0
}
if err := s.votes.SaveVerdict(r.Context(), u.ID, ds.ID, srcPK, verdict); err != nil {
s.serverError(w, r, err)
return
}
next := safeInternalPath(r.PostFormValue("next"))
if next == "" {
next = "/admin"
}
http.Redirect(w, r, next, http.StatusSeeOther)
}