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 再用应用侧读查询真打一遍, 迁移块排到依赖表之前这种错只有新库会炸,老库毫无症状。
395 lines
14 KiB
Go
395 lines
14 KiB
Go
package web
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"mime/multipart"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"net/url"
|
||
"os"
|
||
"path"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// webpBytes 造一段带正确 RIFF/WEBP 魔数的假数据。服务端只校验文件头
|
||
// (转码在浏览器做),所以这里不需要真的 WebP 编码器。
|
||
func webpBytes(n int) []byte {
|
||
b := make([]byte, 12+n)
|
||
copy(b[0:4], "RIFF")
|
||
copy(b[8:12], "WEBP")
|
||
for i := 12; i < len(b); i++ {
|
||
b[i] = byte(i)
|
||
}
|
||
return b
|
||
}
|
||
|
||
// postMultipart 按浏览器 FormData 的形态发一个 multipart 请求。
|
||
func postMultipart(t *testing.T, f *movementFixture, handler http.HandlerFunc, fields map[string]string, files map[string][]byte) *httptest.ResponseRecorder {
|
||
t.Helper()
|
||
var body bytes.Buffer
|
||
w := multipart.NewWriter(&body)
|
||
for k, v := range fields {
|
||
if err := w.WriteField(k, v); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
for field, data := range files {
|
||
part, err := w.CreateFormFile(field, "image.webp")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := part.Write(data); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
if err := w.Close(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
req := httptest.NewRequest("POST", "/x", &body)
|
||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||
req = req.WithContext(f.ctx)
|
||
rec := httptest.NewRecorder()
|
||
handler(rec, req)
|
||
return rec
|
||
}
|
||
|
||
// setMediaBackend 把「图片存哪边」临时改成 backend,测试结束再恢复。
|
||
// 多个测试共用一个库,不还原就会互相干扰(前一个设了 s3,后一个的本地上传就失败了)。
|
||
func setMediaBackend(t *testing.T, f *movementFixture, backend string) {
|
||
t.Helper()
|
||
var prev string
|
||
if err := f.pg.QueryRowContext(f.ctx, `SELECT COALESCE(value->>'media_backend','local')
|
||
FROM settings WHERE key='site'`).Scan(&prev); err != nil {
|
||
t.Fatalf("读图片存储设置失败: %v", err)
|
||
}
|
||
write := func(v string) {
|
||
t.Helper()
|
||
if _, err := f.pg.ExecContext(f.ctx, `UPDATE settings
|
||
SET value = jsonb_set(value, '{media_backend}', to_jsonb($1::text)) WHERE key='site'`, v); err != nil {
|
||
t.Fatalf("写图片存储设置失败: %v", err)
|
||
}
|
||
}
|
||
write(backend)
|
||
t.Cleanup(func() { write(prev) })
|
||
}
|
||
|
||
// TestPlaceImageUploadDelete 端到端跑一遍浏览器那条路:
|
||
// multipart 上传 → 落库 + 落盘 → /media 取回同样的字节 → multipart 删除 → 两处都没了。
|
||
//
|
||
// 这里刻意用 multipart 而不是 urlencoded:前端是用 FormData 发的,
|
||
// 而「先 ParseForm 再读 multipart」会把字段读成空——这个坑真踩过(删除按钮点了没反应)。
|
||
func TestPlaceImageUploadDelete(t *testing.T) {
|
||
f := newMovementFixture(t)
|
||
f.cfg.DataDir = t.TempDir() // 图片写进临时目录,别脏了 storage/
|
||
setMediaBackend(t, f, "local")
|
||
|
||
// 借一个现成的地点
|
||
var src int
|
||
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM places
|
||
WHERE dataset_id=$1 ORDER BY src_pk LIMIT 1`, f.dsID).Scan(&src); err != nil {
|
||
t.Fatalf("找不到地点: %v", err)
|
||
}
|
||
|
||
raw := webpBytes(64)
|
||
rec := postMultipart(t, f, f.srv.adminPlaceImageUpload,
|
||
map[string]string{"src_pk": fmt.Sprint(src), "w": "1200", "h": "900"},
|
||
map[string][]byte{"file": raw})
|
||
var up struct {
|
||
OK bool `json:"ok"`
|
||
ID int64 `json:"id"`
|
||
URL string `json:"url"`
|
||
Bytes int `json:"bytes"`
|
||
Error string `json:"error"`
|
||
}
|
||
if err := json.Unmarshal(rec.Body.Bytes(), &up); err != nil {
|
||
t.Fatalf("上传响应不是 JSON: %s", rec.Body.String())
|
||
}
|
||
if !up.OK || up.Error != "" {
|
||
t.Fatalf("上传失败: %s", rec.Body.String())
|
||
}
|
||
if !strings.HasPrefix(up.URL, "/media/") {
|
||
t.Fatalf("本地存储的地址应以 /media/ 开头,实际 %q", up.URL)
|
||
}
|
||
|
||
// 落盘 + 落库
|
||
full, err := f.srv.media.LocalPath(strings.TrimPrefix(up.URL, "/media/"))
|
||
if err != nil {
|
||
t.Fatalf("取路径失败: %v", err)
|
||
}
|
||
if got, err := os.ReadFile(full); err != nil || !bytes.Equal(got, raw) {
|
||
t.Fatalf("落盘的字节与上传的不一致: %v", err)
|
||
}
|
||
|
||
// /media 直出同样的字节
|
||
rec = httptest.NewRecorder()
|
||
req := httptest.NewRequest("GET", up.URL, nil).WithContext(f.ctx)
|
||
f.srv.serveMedia(rec, req)
|
||
if rec.Code != 200 {
|
||
t.Fatalf("取图返回 %d", rec.Code)
|
||
}
|
||
if !bytes.Equal(rec.Body.Bytes(), raw) {
|
||
t.Fatal("取回的字节与上传的不一致")
|
||
}
|
||
if ct := rec.Header().Get("Content-Type"); ct != "image/webp" {
|
||
t.Errorf("Content-Type 不对: %q", ct)
|
||
}
|
||
|
||
// 非 WebP 必须被挡住(前端转码失败时会发来 PNG)
|
||
rec = postMultipart(t, f, f.srv.adminPlaceImageUpload,
|
||
map[string]string{"src_pk": fmt.Sprint(src)},
|
||
map[string][]byte{"file": append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 32)...)})
|
||
if !strings.Contains(rec.Body.String(), "只接受 WebP") {
|
||
t.Errorf("非 WebP 没被拒绝: %s", rec.Body.String())
|
||
}
|
||
|
||
// 删除:同样走 multipart
|
||
rec = postMultipart(t, f, f.srv.adminPlaceImageDelete,
|
||
map[string]string{"id": fmt.Sprint(up.ID)}, nil)
|
||
if !strings.Contains(rec.Body.String(), `"ok":true`) {
|
||
t.Fatalf("删除失败: %s", rec.Body.String())
|
||
}
|
||
if _, err := os.Stat(full); !os.IsNotExist(err) {
|
||
t.Errorf("文件没删掉: %v", err)
|
||
}
|
||
var n int
|
||
if err := f.pg.QueryRowContext(f.ctx, `SELECT count(*) FROM place_images WHERE id=$1`, up.ID).Scan(&n); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if n != 0 {
|
||
t.Errorf("库里的记录没删掉")
|
||
}
|
||
}
|
||
|
||
// TestPlaceImageUploadRejectsOversizedBody 上传接口必须先卡住请求体再解析 multipart。
|
||
//
|
||
// ParseMultipartForm 的 maxMemory 只管「内存里留多少」,超出的部分会无上限地落到
|
||
// 临时文件;不先套 MaxBytesReader,一个大请求就能把磁盘写满。
|
||
//
|
||
// 判据用响应文案而不是「文件大小」:只有 ParseMultipartForm 失败那一条分支会回
|
||
// 「可能太大」。把 MaxBytesReader 那行删掉,请求会一路解析成功、最后被 6MB 的单张
|
||
// 上限拦成「图片超过 6MB」,这条用例就会红。
|
||
func TestPlaceImageUploadRejectsOversizedBody(t *testing.T) {
|
||
f := newMovementFixture(t)
|
||
f.cfg.DataDir = t.TempDir()
|
||
setMediaBackend(t, f, "local")
|
||
|
||
var src int
|
||
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM places
|
||
WHERE dataset_id=$1 ORDER BY src_pk LIMIT 1`, f.dsID).Scan(&src); err != nil {
|
||
t.Fatalf("找不到地点: %v", err)
|
||
}
|
||
|
||
// 超出门限(imageMaxBytes + 1MB)一大截
|
||
raw := webpBytes(imageMaxBytes + 3<<20)
|
||
rec := postMultipart(t, f, f.srv.adminPlaceImageUpload,
|
||
map[string]string{"src_pk": fmt.Sprint(src)},
|
||
map[string][]byte{"file": raw})
|
||
|
||
if !strings.Contains(rec.Body.String(), "可能太大") {
|
||
t.Errorf("超大请求体没在解析阶段被挡下: %s", rec.Body.String())
|
||
}
|
||
// 没落库(解析阶段就失败,后面取 src_pk / 存图那几步都到不了)
|
||
var n int
|
||
if err := f.pg.QueryRowContext(f.ctx, `SELECT count(*) FROM place_images
|
||
WHERE dataset_id=$1 AND src_pk=$2`, f.dsID, src).Scan(&n); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if n != 0 {
|
||
t.Errorf("被拒的上传落了库: %d 行", n)
|
||
}
|
||
}
|
||
|
||
// TestPhotoImportWithImage 照片导入「存图」:地点与该行图片要在一次导入里都建好,
|
||
// 并且图片挂在正确的 src_pk 上。
|
||
func TestPhotoImportWithImage(t *testing.T) {
|
||
f := newMovementFixture(t)
|
||
f.cfg.DataDir = t.TempDir()
|
||
setMediaBackend(t, f, "local")
|
||
|
||
raw := webpBytes(48)
|
||
rec := postMultipart(t, f, f.srv.adminPhotosImport, map[string]string{
|
||
"n": "1", "coord": "wgs84", "keep_0": "on",
|
||
"lat_0": "30.123456", "lon_0": "120.123456",
|
||
"arrival_0": "2026-04-18T18:14", "name_0": "带图的导入点",
|
||
"country_0": "CN", "timezone_0": "Asia/Shanghai",
|
||
"image_w_0": "1200", "image_h_0": "900",
|
||
}, map[string][]byte{"image_0": raw})
|
||
if rec.Code != http.StatusSeeOther {
|
||
t.Fatalf("导入没返回重定向: %d %s", rec.Code, rec.Body.String())
|
||
}
|
||
loc, err := url.QueryUnescape(rec.Header().Get("Location"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if strings.Contains(loc, "err=") {
|
||
t.Fatalf("导入报错: %s", loc)
|
||
}
|
||
if !strings.Contains(loc, "配了 1 张图") {
|
||
t.Errorf("结果消息里应提到配了图: %s", loc)
|
||
}
|
||
|
||
var src int
|
||
var w, h int
|
||
if err := f.pg.QueryRowContext(f.ctx, `SELECT p.src_pk, pi.width, pi.height FROM places p
|
||
JOIN place_images pi ON pi.dataset_id=p.dataset_id AND pi.src_pk=p.src_pk
|
||
WHERE p.dataset_id=$1 AND p.name='带图的导入点'`, f.dsID).Scan(&src, &w, &h); err != nil {
|
||
t.Fatalf("配图没挂到地点上: %v", err)
|
||
}
|
||
if w != 1200 || h != 900 {
|
||
t.Errorf("宽高没记对: %dx%d", w, h)
|
||
}
|
||
// 字节确实落盘了:目录是 <数据集>/<上传月份>/,文件名是 <地点号>-<哈希>.webp
|
||
dir := filepath.Join(f.cfg.MediaDir(), fmt.Sprint(f.dsID), time.Now().Format("200601"))
|
||
entries, err := os.ReadDir(dir)
|
||
if err != nil {
|
||
t.Fatalf("月份目录不存在: %v", err)
|
||
}
|
||
if len(entries) != 1 {
|
||
t.Fatalf("期望落盘 1 个文件,实际 %d", len(entries))
|
||
}
|
||
if name := entries[0].Name(); !strings.HasPrefix(name, fmt.Sprint(src)+"-") ||
|
||
!strings.HasSuffix(name, ".webp") {
|
||
t.Errorf("文件名应是 <地点号>-<哈希>.webp,实际 %q", name)
|
||
}
|
||
}
|
||
|
||
// TestPlaceImageS3Backend 走一遍对象存储那条路:用 httptest 假装 S3 端点,
|
||
// 断言请求打到「路径式 + 桶名 + 对象名」上,并带上了 SigV4 的 Authorization。
|
||
// 真去校验签名的是 media 包里的官方向量测试,这里盯的是接线对不对。
|
||
func TestPlaceImageS3Backend(t *testing.T) {
|
||
f := newMovementFixture(t)
|
||
f.cfg.DataDir = t.TempDir()
|
||
|
||
var gotPath, gotAuth, gotCT string
|
||
var gotBody []byte
|
||
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
gotPath = r.URL.Path
|
||
gotAuth = r.Header.Get("Authorization")
|
||
gotCT = r.Header.Get("Content-Type")
|
||
gotBody, _ = io.ReadAll(r.Body)
|
||
w.WriteHeader(http.StatusOK)
|
||
}))
|
||
defer fake.Close()
|
||
|
||
f.cfg.S3Endpoint = fake.URL
|
||
f.cfg.S3Region = "auto"
|
||
f.cfg.S3Bucket = "pics"
|
||
f.cfg.S3AccessKey = "AKIATEST"
|
||
f.cfg.S3SecretKey = "secret"
|
||
f.cfg.S3PublicBase = "https://img.example.com"
|
||
|
||
setMediaBackend(t, f, "s3")
|
||
|
||
var src int
|
||
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM places
|
||
WHERE dataset_id=$1 ORDER BY src_pk LIMIT 1`, f.dsID).Scan(&src); err != nil {
|
||
t.Fatalf("找不到地点: %v", err)
|
||
}
|
||
|
||
raw := webpBytes(32)
|
||
rec := postMultipart(t, f, f.srv.adminPlaceImageUpload,
|
||
map[string]string{"src_pk": fmt.Sprint(src)}, map[string][]byte{"file": raw})
|
||
|
||
var up struct {
|
||
OK bool `json:"ok"`
|
||
URL string `json:"url"`
|
||
Error string `json:"error"`
|
||
}
|
||
if err := json.Unmarshal(rec.Body.Bytes(), &up); err != nil {
|
||
t.Fatalf("上传响应不是 JSON: %s", rec.Body.String())
|
||
}
|
||
if !up.OK || up.Error != "" {
|
||
t.Fatalf("对象存储上传失败: %s", rec.Body.String())
|
||
}
|
||
if !strings.HasPrefix(up.URL, "https://img.example.com/") {
|
||
t.Errorf("对象存储的地址应当用 s3_public_base 拼,实际 %q", up.URL)
|
||
}
|
||
// 对象名 = <数据集>/<上传月份>/<地点号>-<内容哈希>.webp
|
||
parts := strings.Split(strings.TrimPrefix(gotPath, "/pics/"), "/")
|
||
if len(parts) != 3 {
|
||
t.Fatalf("对象名层级不对(应为 数据集/月份/文件名): %q", gotPath)
|
||
}
|
||
if parts[0] != fmt.Sprint(f.dsID) {
|
||
t.Errorf("第一层应是数据集号,实际 %q", parts[0])
|
||
}
|
||
if len(parts[1]) != 6 || !strings.HasPrefix(parts[1], time.Now().Format("2006")) {
|
||
t.Errorf("第二层应是上传月份(YYYYMM),实际 %q", parts[1])
|
||
}
|
||
if !strings.HasPrefix(parts[2], fmt.Sprint(src)+"-") || !strings.HasSuffix(parts[2], ".webp") {
|
||
t.Errorf("文件名应是 <地点号>-<哈希>.webp,实际 %q", parts[2])
|
||
}
|
||
if !strings.Contains(gotAuth, "AWS4-HMAC-SHA256 Credential=AKIATEST/") ||
|
||
!strings.Contains(gotAuth, "/auto/s3/aws4_request") {
|
||
t.Errorf("没带上 SigV4 签名: %q", gotAuth)
|
||
}
|
||
if gotCT != "image/webp" {
|
||
t.Errorf("Content-Type 不对: %q", gotCT)
|
||
}
|
||
if !bytes.Equal(gotBody, raw) {
|
||
t.Error("发出去的字节与上传的不一致")
|
||
}
|
||
|
||
// 存储被切到对象存储后,本地不应再落盘
|
||
entries, _ := os.ReadDir(path.Join(f.cfg.MediaDir(), fmt.Sprint(f.dsID)))
|
||
if len(entries) != 0 {
|
||
t.Errorf("切到对象存储后不该再写本地磁盘,却发现 %d 个条目", len(entries))
|
||
}
|
||
}
|
||
|
||
// TestVisitorPlacePageCarriesImageURLs 盯住「访客在详情页也能看到图片入口」。
|
||
//
|
||
// 画廊的地址以前只从 #imgs-grid 反推,而那个缩略图墙是站长专属的 DOM,访客页里
|
||
// 根本没有——于是 syncGallery() 数到 0 张,把服务端已经渲染好的「图片 N」按钮
|
||
// remove 掉(表现成刷新后按钮闪一下就没了)。站长自己打开一切正常,所以只有按访客
|
||
// 身份把页面渲出来才验得到:**访客页必须自带地址列表,不能依赖那一块 DOM**。
|
||
func TestVisitorPlacePageCarriesImageURLs(t *testing.T) {
|
||
f := newMovementFixture(t)
|
||
|
||
var placeID int64
|
||
var srcPK int
|
||
if err := f.pg.QueryRowContext(f.ctx, `SELECT id, src_pk FROM places
|
||
WHERE dataset_id=$1 AND lat IS NOT NULL ORDER BY id LIMIT 1`, f.dsID).Scan(&placeID, &srcPK); err != nil {
|
||
t.Fatalf("找不到地点: %v", err)
|
||
}
|
||
const key = "9/visitor-page-test.webp"
|
||
// 这里只验发下去的地址,不验能否取回字节,所以不用真的写文件。
|
||
// place_key 得照真实写入路径一并算好——读取是认坐标键的,不认 (dataset_id, src_pk)。
|
||
if _, err := f.pg.ExecContext(f.ctx, `INSERT INTO place_images
|
||
(dataset_id, src_pk, place_key, storage, object_key, width, height, bytes, position)
|
||
SELECT $1, $2, `+placeKeyExpr+`, 'local', $3, 1200, 900, 1024, 0
|
||
FROM places p WHERE p.dataset_id=$1 AND p.src_pk=$2
|
||
ON CONFLICT (dataset_id, src_pk, object_key) DO NOTHING`,
|
||
f.dsID, srcPK, key); err != nil {
|
||
t.Fatalf("插入图片记录失败: %v", err)
|
||
}
|
||
t.Cleanup(func() {
|
||
f.pg.ExecContext(f.ctx,
|
||
`DELETE FROM place_images WHERE dataset_id=$1 AND object_key=$2`, f.dsID, key)
|
||
})
|
||
|
||
req := httptest.NewRequest("GET", "/places/x", nil)
|
||
req = req.WithContext(context.Background()) // 访客 = 上下文里没有登录态
|
||
req.SetPathValue("id", strconv.FormatInt(placeID, 10))
|
||
rec := httptest.NewRecorder()
|
||
f.srv.placePage(rec, req)
|
||
body := rec.Body.String()
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("访客访问详情页返回 %d:%s", rec.Code, firstLine(body))
|
||
}
|
||
if !strings.Contains(body, `id="imgs-open"`) {
|
||
t.Error("访客页面里没有「图片」按钮")
|
||
}
|
||
want := f.srv.media.PublicURL("local", key)
|
||
if !strings.Contains(body, want) {
|
||
t.Errorf("访客页面没有把图片地址发给浏览器(缺 %s)", want)
|
||
}
|
||
}
|