R2 上的图怎么救:库里的行没了就只剩桶里有那些键,不列桶无从得知它们叫什么。
给 internal/media 加了 ListObjects(ListObjectsV2 + SigV4 + 自动翻页 + XML 解析):
- 路径式访问、请求只到桶;查询串必须**先百分号编码再签名**(canonicalQuery 按原样取
RawQuery,两边得是同一份字节,prefix 里的 / 要写成 %2F)。
- ⚠️ R2 的 API Token 默认只给对象读写,没有 s3:ListBucket 就 403。列桶失败**不让整个
恢复失败**:本地那半照救,原因进 Warnings、页面用红色提示条显示(否则站长会以为恢复完了)。
- 候选对象 = 本地目录 ∪ 桶里 .webp(同键以本地为准);行的 storage 按「对象实际在哪」写,
bytes 用桶里报的 Size。另加一条 warning:挂回了却没配 s3_public_base ⇒ 行有了页面空白,
这比「没恢复」更难懂。
专题(用户拍板「一并做」):topics.dataset_id 同样带 CASCADE,删数据集带走专题,而专题
只存在这张表里、没有恢复来源。外键摘掉,所有查询 dataset_id → user_id(loadTopics、
topicPage、保存/删除/开关、pageBase.HasTopics);**内容仍照当前生效数据集算**
(topicContent 不动)——变的是「这份定义归谁」。Dataset 补 UserID 供前台按属主定位
用户级数据;管理专题不再要求有生效数据集。
导出链路未改动:place_images / topics / Dataset.UserID 都不参与 .rondbackup 导出,
全量验收(含导出保真、ZRAW 夹取、编号对齐那批)PASS=331 FAIL=0 SKIP=0。
135 lines
4.6 KiB
Go
135 lines
4.6 KiB
Go
package media
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
|
||
"rond-with-you/internal/config"
|
||
)
|
||
|
||
// ---------- 列桶(ListObjectsV2)----------
|
||
//
|
||
// 为什么要它:「图片挂载恢复」要找回**库里已经没有记录**的图片,而那些对象的键只剩下
|
||
// 桶里有。列不出来就只救得回本地磁盘那一半。
|
||
// 这几条用例守的是请求形状、翻页、以及「没权限时要说清楚该怎么办」。
|
||
|
||
func testStore(t *testing.T, endpoint string) *Store {
|
||
t.Helper()
|
||
return New(&config.Config{
|
||
S3Endpoint: endpoint, S3Region: "auto", S3Bucket: "rond",
|
||
S3AccessKey: "ak", S3SecretKey: "sk",
|
||
})
|
||
}
|
||
|
||
// listXML 拼一份最小的 ListObjectsV2 响应。
|
||
func listXML(keys []string, truncated bool, token string) string {
|
||
var b strings.Builder
|
||
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?><ListBucketResult><Name>rond</Name>`)
|
||
for _, k := range keys {
|
||
b.WriteString("<Contents><Key>" + k + "</Key><Size>1234</Size></Contents>")
|
||
}
|
||
fmt.Fprintf(&b, "<IsTruncated>%v</IsTruncated><KeyCount>%d</KeyCount>", truncated, len(keys))
|
||
if token != "" {
|
||
b.WriteString("<NextContinuationToken>" + token + "</NextContinuationToken>")
|
||
}
|
||
b.WriteString(`</ListBucketResult>`)
|
||
return b.String()
|
||
}
|
||
|
||
func TestListObjectsSignsAndParses(t *testing.T) {
|
||
var gotPath, gotQuery, gotAuth string
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
gotPath, gotQuery, gotAuth = r.URL.Path, r.URL.RawQuery, r.Header.Get("Authorization")
|
||
w.Header().Set("Content-Type", "application/xml")
|
||
fmt.Fprint(w, listXML([]string{"9/202609/309-abc.webp"}, false, ""))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
s := testStore(t, srv.URL)
|
||
objs, err := s.ListObjects(context.Background(), "9/")
|
||
if err != nil {
|
||
t.Fatalf("列出对象失败: %v", err)
|
||
}
|
||
if len(objs) != 1 || objs[0].Key != "9/202609/309-abc.webp" || objs[0].Size != 1234 {
|
||
t.Fatalf("解析结果不对: %+v", objs)
|
||
}
|
||
// 路径式访问:只到桶,不带对象键
|
||
if gotPath != "/rond" {
|
||
t.Errorf("请求路径应为 /rond,实际 %q", gotPath)
|
||
}
|
||
// 查询串必须**先编码再签**:prefix 里的 / 要写成 %2F,签名与发送必须是同一份字节
|
||
if gotQuery != "list-type=2&prefix=9%2F" {
|
||
t.Errorf("查询串不对: %q", gotQuery)
|
||
}
|
||
if !strings.HasPrefix(gotAuth, "AWS4-HMAC-SHA256 Credential=ak/") || !strings.Contains(gotAuth, "/auto/s3/aws4_request") {
|
||
t.Errorf("签名头不对: %q", gotAuth)
|
||
}
|
||
}
|
||
|
||
func TestListObjectsFollowsContinuationToken(t *testing.T) {
|
||
var pages int
|
||
var seen []string
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
pages++
|
||
q := r.URL.Query()
|
||
seen = append(seen, q.Get("continuation-token"))
|
||
w.Header().Set("Content-Type", "application/xml")
|
||
if pages == 1 {
|
||
fmt.Fprint(w, listXML([]string{"9/a.webp"}, true, "tok-1"))
|
||
return
|
||
}
|
||
fmt.Fprint(w, listXML([]string{"9/b.webp"}, false, ""))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
s := testStore(t, srv.URL)
|
||
objs, err := s.ListObjects(context.Background(), "9/")
|
||
if err != nil {
|
||
t.Fatalf("列出对象失败: %v", err)
|
||
}
|
||
if pages != 2 {
|
||
t.Errorf("应当翻两页,实际 %d", pages)
|
||
}
|
||
if len(objs) != 2 || objs[0].Key != "9/a.webp" || objs[1].Key != "9/b.webp" {
|
||
t.Errorf("两页结果没合并: %+v", objs)
|
||
}
|
||
// 第二页要把 token 带上,且同样要编码
|
||
if seen[0] != "" || seen[1] != "tok-1" {
|
||
t.Errorf("continuation-token 没带上(或顺序不对): %v", seen)
|
||
}
|
||
}
|
||
|
||
// TestListObjectsReportsPermission 没权限(403)时要把「需要 s3:ListBucket」说清楚——
|
||
// R2 的 API Token 默认只给对象读写,第一次点恢复多半就撞在这里。
|
||
func TestListObjectsReportsPermission(t *testing.T) {
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.WriteHeader(http.StatusForbidden)
|
||
fmt.Fprint(w, `<Error><Code>AccessDenied</Code></Error>`)
|
||
}))
|
||
defer srv.Close()
|
||
|
||
s := testStore(t, srv.URL)
|
||
_, err := s.ListObjects(context.Background(), "")
|
||
if err == nil {
|
||
t.Fatal("403 应当报错")
|
||
}
|
||
if !strings.Contains(err.Error(), "403") || !strings.Contains(err.Error(), "s3:ListBucket") {
|
||
t.Errorf("错误里没说清该怎么办: %v", err)
|
||
}
|
||
if !strings.Contains(err.Error(), "AccessDenied") {
|
||
t.Errorf("错误里没带服务端的响应体(不好排查): %v", err)
|
||
}
|
||
}
|
||
|
||
// TestListObjectsRejectsUnconfigured 没配齐凭据时不该发出请求。
|
||
func TestListObjectsRejectsUnconfigured(t *testing.T) {
|
||
s := New(&config.Config{})
|
||
if _, err := s.ListObjects(context.Background(), ""); err == nil {
|
||
t.Fatal("没配齐应当报错")
|
||
}
|
||
}
|