审计修复:后端(安全 / 数据一致性 / 性能)
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 再用应用侧读查询真打一遍, 迁移块排到依赖表之前这种错只有新库会炸,老库毫无症状。
This commit is contained in:
@@ -4,10 +4,12 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -82,7 +84,7 @@ func (s *Server) exportGeoJSON(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
b.WriteString(`]}`)
|
||||
w.Header().Set("Content-Type", "application/geo+json; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="places.geojson"`)
|
||||
w.Header().Set("Content-Disposition", downloadDisposition("places.geojson"))
|
||||
w.Write([]byte(b.String()))
|
||||
}
|
||||
|
||||
@@ -120,10 +122,74 @@ func (s *Server) exportGPX(w http.ResponseWriter, r *http.Request) {
|
||||
b.WriteString("</gpx>\n")
|
||||
w.Header().Set("Content-Type", "application/gpx+xml; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition",
|
||||
fmt.Sprintf(`attachment; filename="places-%s.gpx"`, time.Now().Format("20060102")))
|
||||
downloadDisposition("places-"+time.Now().Format("20060102")+".gpx"))
|
||||
w.Write([]byte(b.String()))
|
||||
}
|
||||
|
||||
// downloadDisposition 拼 Content-Disposition。
|
||||
//
|
||||
// 导出包的名字里带着 `datasets.original_name`,那是**上传时的文件名**——含引号、斜杠、
|
||||
// 控制字符都正常,甚至可能整段 `"; filename*=…` 之类的内容。原样塞进 `filename="…"`
|
||||
// 就能提前闭合引号、往这个头里塞参数(net/http 只把 CR/LF 换成空格,管不了引号)。
|
||||
// 所以给两个值:
|
||||
// - `filename=` 只留 ASCII 安全字符,老浏览器兜底;
|
||||
// - `filename*=`(RFC 5987)按 UTF-8 百分号编码带真实名字,现代浏览器优先取它,
|
||||
// 中文名不会像以前那样被当成 Latin-1 乱码。
|
||||
func downloadDisposition(name string) string {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
name = "download"
|
||||
}
|
||||
ext := filepath.Ext(name)
|
||||
stem := asciiSafe(strings.TrimSuffix(name, ext))
|
||||
// 纯中文名的词干映射完就是一串下划线,给个能读的兜底名;但不能把扩展名一起丢掉,
|
||||
// 否则用户下到手里是个没法双击打开的「download」。
|
||||
if !hasAlnum(stem) {
|
||||
stem = "download"
|
||||
}
|
||||
return fmt.Sprintf("attachment; filename=%q; filename*=UTF-8''%s", stem+asciiSafe(ext), rfc5987Escape(name))
|
||||
}
|
||||
|
||||
// asciiSafe 把不能安全写进 `filename="…"` 的字符换成下划线:
|
||||
// 引号能提前闭合这个参数,斜杠/反斜杠会在落盘时被当成路径,`;` `=` 能伪造后续参数。
|
||||
func asciiSafe(s string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r < 0x20 || r > 0x7e {
|
||||
return '_'
|
||||
}
|
||||
switch r {
|
||||
case '"', '\\', '/', ';', ':', '=', '*', '?', '<', '>', '|':
|
||||
return '_'
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
}
|
||||
|
||||
func hasAlnum(s string) bool {
|
||||
return strings.ContainsFunc(s, func(r rune) bool {
|
||||
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
|
||||
})
|
||||
}
|
||||
|
||||
// rfc5987Escape 按 RFC 5987 的 attr-char 编码:ASCII 字母数字与 `. _ - ~` 原样,
|
||||
// 其余**逐字节**百分号编码(UTF-8 的每个字节各编一次,这正是规范要求的)。
|
||||
// 故意只放行这个窄集合:`;` `=` 这类在 attr-char 之外,漏掉就能截断参数。
|
||||
func rfc5987Escape(s string) string {
|
||||
const hexDigits = "0123456789ABCDEF"
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
|
||||
c == '.' || c == '_' || c == '-' || c == '~' {
|
||||
b.WriteByte(c)
|
||||
continue
|
||||
}
|
||||
b.WriteByte('%')
|
||||
b.WriteByte(hexDigits[c>>4])
|
||||
b.WriteByte(hexDigits[c&0x0f])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// baselinePath 返回某数据集的基线库路径。基线是导入时从原始备份包留下的单文件副本,
|
||||
// 导出时以它为底做覆盖,从而保住 Core Data 的元数据表、索引、持久化历史,
|
||||
// 以及我们并不落库的实体(原始 GPS 采样点 ZRAWVISIT、ZKEYWORD、ZTRIP* 等)。
|
||||
@@ -150,7 +216,8 @@ func (s *Server) ensureBaseline(ctx context.Context, datasetID int64) (string, e
|
||||
// findSourceArchive 在 uploads 目录里按 datasets.sha256 找回当初上传的备份包。
|
||||
func (s *Server) findSourceArchive(ctx context.Context, datasetID int64) (string, error) {
|
||||
var sha string
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(sha256,'') FROM datasets WHERE id=$1`, datasetID).Scan(&sha); err != nil {
|
||||
var size int64
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(sha256,''), COALESCE(size_bytes,0) FROM datasets WHERE id=$1`, datasetID).Scan(&sha, &size); err != nil {
|
||||
return "", err
|
||||
}
|
||||
files, _ := filepath.Glob(filepath.Join(s.cfg.UploadDir, "*"))
|
||||
@@ -163,19 +230,41 @@ func (s *Server) findSourceArchive(ctx context.Context, datasetID int64) (string
|
||||
}
|
||||
return fi.ModTime().After(fj.ModTime())
|
||||
})
|
||||
if f := pickArchiveBySha(files, sha, size, func(f string) (string, error) {
|
||||
got, _, err := ingest.File(f)
|
||||
return got, err
|
||||
}); f != "" {
|
||||
return f, nil
|
||||
}
|
||||
return "", fmt.Errorf("找不到当初上传的原始备份包(可能已清理),请到后台重新上传一次该 .rondbackup")
|
||||
}
|
||||
|
||||
// pickArchiveBySha 从候选文件里挑出摘要匹配的那一份(没有则返回空串)。
|
||||
//
|
||||
// 先按字节数筛一道再算摘要:uploads 里可能堆着几十份几百 MB 的备份,每份都得完整读一遍
|
||||
// 才比得出 sha256,而 sha256 相同就一定字节数相同——大小对不上的根本不必读。
|
||||
// 没有 sha 可比时(老库或退化数据)不做筛选,保持「取最新的那份」的旧行为。
|
||||
//
|
||||
// hash 做成参数是为了让用例能数「到底算了几次摘要」:只看最终返回哪个文件,
|
||||
// 带不带这道筛选完全一样,区分不出来。
|
||||
func pickArchiveBySha(files []string, sha string, size int64, hash func(string) (string, error)) string {
|
||||
for _, f := range files {
|
||||
if fi, err := os.Stat(f); err != nil || fi.IsDir() {
|
||||
fi, err := os.Stat(f)
|
||||
if err != nil || fi.IsDir() {
|
||||
continue
|
||||
}
|
||||
got, _, err := ingest.File(f)
|
||||
if sha != "" && size > 0 && fi.Size() != size {
|
||||
continue
|
||||
}
|
||||
got, err := hash(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if sha == "" || got == sha {
|
||||
return f, nil
|
||||
return f
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("找不到当初上传的原始备份包(可能已清理),请到后台重新上传一次该 .rondbackup")
|
||||
return ""
|
||||
}
|
||||
|
||||
// exportRondbackup 以基线库为底,把当前数据集写回为 rond 原始格式的 .rondbackup 并下载。
|
||||
@@ -204,6 +293,15 @@ func (s *Server) exportRondbackup(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition",
|
||||
fmt.Sprintf(`attachment; filename="%s-export-%s.rondbackup"`, stem, time.Now().Format("20060102")))
|
||||
w.Write(buf.Bytes())
|
||||
downloadDisposition(stem+"-export-"+time.Now().Format("20060102")+".rondbackup"))
|
||||
w.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
|
||||
// 整包先落内存是为了绝不写出半截文件,代价是整个下载都得在写死线内完成。
|
||||
// 全站 120s 是按页面响应定的,慢链路下拉几十 MB 不够——只放宽这一条路由。
|
||||
if err := http.NewResponseController(w).SetWriteDeadline(time.Now().Add(10 * time.Minute)); err != nil {
|
||||
log.Printf("导出:无法延长写超时,仍按全站限制走: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := buf.WriteTo(w); err != nil {
|
||||
log.Printf("导出:下载写出中断: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user