Files
rond-with-you/internal/web/handlers_export.go
T
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

308 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"bytes"
"context"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"rond-with-you/internal/ingest"
"rond-with-you/internal/rond"
"rond-with-you/internal/stats"
)
// 这两个导出面向站长自己的数据迁移,因此不做「家 / 工作」过滤——
// 能进后台的人本来就拥有全部数据的查看权。
func (s *Server) exportPlaces(w http.ResponseWriter, r *http.Request) []stats.Place {
ctx := r.Context()
ds, err := s.datasetFor(ctx)
if err != nil || ds == nil {
s.adminFlash(w, r, "err", "还没有可导出的数据集")
return nil
}
// 直接按 dataset 全量取,绕过筛选与隐私开关
rows, err := s.db.QueryContext(ctx, `SELECT `+stats.PlaceCols+`
FROM places p WHERE p.dataset_id=$1 ORDER BY p.id`, ds.ID)
if err != nil {
s.serverError(w, r, err)
return nil
}
defer rows.Close()
var out []stats.Place
for rows.Next() {
var p stats.Place
if err := stats.ScanPlace(rows, &p); err != nil {
s.serverError(w, r, err)
return nil
}
out = append(out, p)
}
if err := rows.Err(); err != nil {
s.serverError(w, r, err)
return nil
}
return out
}
func xmlEscape(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;", "'", "&apos;")
return r.Replace(s)
}
func (s *Server) exportGeoJSON(w http.ResponseWriter, r *http.Request) {
places := s.exportPlaces(w, r)
if places == nil {
return
}
var b strings.Builder
b.WriteString(`{"type":"FeatureCollection","features":[`)
for i, p := range places {
if i > 0 {
b.WriteByte(',')
}
var first, last string
if p.FirstAt != nil {
first = p.FirstAt.UTC().Format("2006-01-02")
}
if p.LastAt != nil {
last = p.LastAt.UTC().Format("2006-01-02")
}
// GeoJSON 坐标是 [lon, lat];库里的坐标本身是 GCJ-02,原样导出并在说明里注明
fmt.Fprintf(&b, `{"type":"Feature","geometry":{"type":"Point","coordinates":[%v,%v]},`+
`"properties":{"name":%q,"city":%q,"province":%q,"category":%q,`+
`"visits":%d,"dwell_min":%d,"first":%q,"last":%q,"crs":"gcj02"}}`,
p.Lon, p.Lat, p.Name, p.City, p.Province, p.POICategory,
p.VisitCount, p.DwellMinutes, first, last)
}
b.WriteString(`]}`)
w.Header().Set("Content-Type", "application/geo+json; charset=utf-8")
w.Header().Set("Content-Disposition", downloadDisposition("places.geojson"))
w.Write([]byte(b.String()))
}
func (s *Server) exportGPX(w http.ResponseWriter, r *http.Request) {
places := s.exportPlaces(w, r)
if places == nil {
return
}
var b strings.Builder
catNames := s.categoryLabels(r.Context())
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?>` + "\n")
b.WriteString(`<gpx version="1.1" creator="rond-with-you" xmlns="http://www.topografix.com/GPX/1/1">` + "\n")
for _, p := range places {
var parts []string
if p.City != "" {
parts = append(parts, p.Province, p.City)
}
if p.POICategory != "" {
parts = append(parts, categoryLabel(p.POICategory, catNames))
}
desc := strings.Join(strings.Fields(strings.Join(parts, " · ")), " ")
var last string
if p.LastAt != nil {
last = p.LastAt.UTC().Format("2006-01-02T15:04:05Z")
}
fmt.Fprintf(&b, "<wpt lat=\"%v\" lon=\"%v\"><name>%s</name>", p.Lat, p.Lon, xmlEscape(p.Name))
if desc != "" {
fmt.Fprintf(&b, "<desc>%s</desc>", xmlEscape(desc))
}
if last != "" {
fmt.Fprintf(&b, "<time>%s</time>", last)
}
b.WriteString("</wpt>\n")
}
b.WriteString("</gpx>\n")
w.Header().Set("Content-Type", "application/gpx+xml; charset=utf-8")
w.Header().Set("Content-Disposition",
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* 等)。
func (s *Server) baselinePath(datasetID int64) string {
return filepath.Join(s.cfg.DataDir, "baselines", fmt.Sprintf("%d.sqlite", datasetID))
}
// ensureBaseline 确保基线库存在;缺失时从当初上传的原始备份包重建。
func (s *Server) ensureBaseline(ctx context.Context, datasetID int64) (string, error) {
p := s.baselinePath(datasetID)
if _, err := os.Stat(p); err == nil {
return p, nil
}
archive, err := s.findSourceArchive(ctx, datasetID)
if err != nil {
return "", err
}
if err := rond.WriteBaseline(archive, p); err != nil {
return "", fmt.Errorf("重建基线库失败:%w", err)
}
return p, nil
}
// findSourceArchive 在 uploads 目录里按 datasets.sha256 找回当初上传的备份包。
func (s *Server) findSourceArchive(ctx context.Context, datasetID int64) (string, error) {
var sha string
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, "*"))
// 新的在前,命中即止
sort.Slice(files, func(i, j int) bool {
fi, ei := os.Stat(files[i])
fj, ej := os.Stat(files[j])
if ei != nil || ej != nil {
return false
}
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 {
fi, err := os.Stat(f)
if err != nil || fi.IsDir() {
continue
}
if sha != "" && size > 0 && fi.Size() != size {
continue
}
got, err := hash(f)
if err != nil {
continue
}
if sha == "" || got == sha {
return f
}
}
return ""
}
// exportRondbackup 以基线库为底,把当前数据集写回为 rond 原始格式的 .rondbackup 并下载。
// 坐标是 GCJ-02,与 rond 同系,原样写回;新增/编辑的记录随数据集一并导出。
func (s *Server) exportRondbackup(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ds, err := s.datasetFor(ctx)
if err != nil || ds == nil {
s.adminFlash(w, r, "err", "还没有可导出的数据集")
return
}
base, err := s.ensureBaseline(ctx, ds.ID)
if err != nil {
s.adminFlash(w, r, "err", "导出 rondbackup 失败:"+err.Error())
return
}
// 先构建到缓冲,成功后再写响应头,避免半截输出破坏下载文件
var buf bytes.Buffer
if err := rond.ExportRondbackup(ctx, s.db, ds.ID, base, &buf); err != nil {
s.adminFlash(w, r, "err", "导出 rondbackup 失败:"+err.Error())
return
}
stem := strings.TrimSuffix(filepath.Base(ds.Name), filepath.Ext(ds.Name))
if stem == "" {
stem = "LifeEasy"
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition",
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)
}
}