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 再用应用侧读查询真打一遍, 迁移块排到依赖表之前这种错只有新库会炸,老库毫无症状。
308 lines
11 KiB
Go
308 lines
11 KiB
Go
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("&", "&", "<", "<", ">", ">", `"`, """, "'", "'")
|
||
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)
|
||
}
|
||
}
|