此前没有任何备份手段,而站点的图片全在 Cloudflare R2、数据库只存 key/URL —— 只备份 DB 的话,恢复后 key 指向的图片若已不在 R2(bucket 误删 / 账号丢失 / 图片被清理),页面就全是裂图。 新增 TamaBox backup --output <dir>: - 数据库:pg_dump 全量导出(schema + data,标准恢复格式),密码经 PGPASSWORD 环境变量传递避免出现在 ps 进程参数里 - 图片:从 R2 下载 tamabox 站全部图片,保持原 key 路径,并写 images-manifest.json (key → size → md5)用于恢复后完整性校验 - --db-only / --images-only 分开关 R2 访问方式的坑:storage.GetS3Client 的 endpoint 带 /<bucket> 后缀,对 GetObject/PutObject 恰好可用,但 ListObjectsV2 会把 URL 拼成 /<bucket>/<bucket> 而 404 NoSuchKey。备份脚本独立构造客户端(剥离 endpoint 的 bucket 后缀 + UsePathStyle),List prefix="tamabox/" 后用完整 key 下载 —— 这是 R2 上 List 唯一稳定可用的姿势。 实测:db-only 导出 46KB(21 表全含 schema_migrations);images-only 下载的文件 大小与 R2 List 返回完全一致,路径保持 tamabox/ 结构。
317 lines
10 KiB
Go
317 lines
10 KiB
Go
// Copyright 2026 tamakyi. All rights reserved.
|
||
// Use of this source code is governed by a MIT-style
|
||
// license that can be found in the LICENSE file.
|
||
|
||
package cmd
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/aws/aws-sdk-go-v2/aws"
|
||
"github.com/aws/aws-sdk-go-v2/config"
|
||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||
"github.com/pkg/errors"
|
||
"github.com/sirupsen/logrus"
|
||
"github.com/urfave/cli/v2"
|
||
|
||
"github.com/tamakyi/TamaBox/internal/conf"
|
||
)
|
||
|
||
// Backup 全量备份数据库与对象存储(Cloudflare R2)图片到本地目录。
|
||
//
|
||
// 为什么必须同时备份图片:站点的所有图片(头像 / 背景 / 二维码 logo / 提问图 / 回答图)
|
||
// 都以 picture/YYYY/MM/DD/<hex>(新)或 tamabox/YYYY/MM/<hex>.<ext>(历史)的形式存在
|
||
// R2 上,数据库里只存 key(upload_images.key)或 URL(users.avatar/background 里的完整
|
||
// CDN 地址)。只备份数据库的话,恢复后这些引用指向的图片若已不在 R2(bucket 误删 /
|
||
// 账号丢失 / 图片被清理),页面就全是裂图。因此备份必须把 R2 上的图片一并落盘。
|
||
//
|
||
// 输出目录结构:
|
||
//
|
||
// <output>/
|
||
// db-<20060102-150405>.sql # pg_dump 全量导出(schema + data,可用 psql 恢复)
|
||
// images/<key> # R2 对象,保持原 key 路径
|
||
// images-manifest.json # key → md5 / size 清单,用于恢复后完整性校验
|
||
//
|
||
// 用法:TamaBox backup --output /var/backups/tamabox
|
||
//
|
||
// TamaBox backup --output . --db-only # 只备份数据库
|
||
// TamaBox backup --output . --images-only # 只备份图片
|
||
var Backup = &cli.Command{
|
||
Name: "backup",
|
||
Usage: "Back up the database and R2 images to a local directory",
|
||
Action: runBackup,
|
||
Flags: []cli.Flag{
|
||
&cli.StringFlag{
|
||
Name: "output",
|
||
Value: ".",
|
||
Usage: "Output directory for the backup files",
|
||
},
|
||
&cli.BoolFlag{
|
||
Name: "db-only",
|
||
Usage: "Only back up the database (skip R2 images)",
|
||
},
|
||
&cli.BoolFlag{
|
||
Name: "images-only",
|
||
Usage: "Only back up R2 images (skip the database)",
|
||
},
|
||
},
|
||
}
|
||
|
||
func runBackup(ctx *cli.Context) error {
|
||
if err := conf.Init(); err != nil {
|
||
return errors.Wrap(err, "load configuration")
|
||
}
|
||
|
||
outDir := ctx.String("output")
|
||
if outDir == "" {
|
||
return errors.New("--output must not be empty")
|
||
}
|
||
if err := os.MkdirAll(outDir, 0o700); err != nil {
|
||
return errors.Wrapf(err, "create output directory %q", outDir)
|
||
}
|
||
|
||
dbOnly := ctx.Bool("db-only")
|
||
imagesOnly := ctx.Bool("images-only")
|
||
|
||
if !imagesOnly {
|
||
if err := backupDatabase(ctx.Context, outDir); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
if !dbOnly {
|
||
if err := backupImages(ctx.Context, outDir); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
logrus.WithField("dir", outDir).Info("备份完成")
|
||
return nil
|
||
}
|
||
|
||
// backupDatabase 用 pg_dump 导出 PostgreSQL 全量数据。
|
||
//
|
||
// 为什么用外部 pg_dump 而不是在进程内逐表 SELECT:pg_dump 输出的 .sql 是标准恢复格式,
|
||
// 直接用 psql 恢复,且能正确处理序列、外键、软删除行、大对象等进程内手写导出极易漏掉的
|
||
// 细节。项目实际使用 PostgreSQL(见 code-review-2026-08-28.md P1-4)。
|
||
func backupDatabase(ctx context.Context, outDir string) error {
|
||
if conf.Database.Type != "" && conf.Database.Type != "postgres" {
|
||
return errors.Errorf("备份当前仅支持 PostgreSQL,配置为 %q", conf.Database.Type)
|
||
}
|
||
|
||
pgDump, err := exec.LookPath("pg_dump")
|
||
if err != nil {
|
||
return errors.Wrap(err, "未找到 pg_dump(PostgreSQL 客户端工具),请安装 postgresql-client")
|
||
}
|
||
|
||
ts := time.Now().Format("20060102-150405")
|
||
outFile := filepath.Join(outDir, fmt.Sprintf("db-%s.sql", ts))
|
||
|
||
// 密码经环境变量传递,避免出现在进程参数里(ps 可见)。
|
||
cmd := exec.CommandContext(ctx, pgDump,
|
||
"--host", conf.Database.Host,
|
||
"--port", fmt.Sprintf("%d", conf.Database.Port),
|
||
"--username", conf.Database.User,
|
||
"--dbname", conf.Database.Name,
|
||
"--no-owner", "--no-privileges",
|
||
"--format", "plain",
|
||
)
|
||
cmd.Env = append(os.Environ(), "PGPASSWORD="+conf.Database.Password)
|
||
|
||
f, err := os.OpenFile(outFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||
if err != nil {
|
||
return errors.Wrapf(err, "create backup file %q", outFile)
|
||
}
|
||
defer f.Close()
|
||
|
||
var dumpErr strings.Builder
|
||
cmd.Stdout = f
|
||
cmd.Stderr = &dumpErr
|
||
if err := cmd.Run(); err != nil {
|
||
return errors.Wrapf(err, "pg_dump failed: %s", strings.TrimSpace(dumpErr.String()))
|
||
}
|
||
|
||
logrus.WithField("file", outFile).Info("数据库已备份")
|
||
return nil
|
||
}
|
||
|
||
// imageManifestEntry 图片备份清单里的一条:key → 大小 → md5(用于恢复后校验完整性)。
|
||
type imageManifestEntry struct {
|
||
Key string `json:"key"`
|
||
Size int64 `json:"size"`
|
||
MD5 string `json:"md5,omitempty"`
|
||
}
|
||
|
||
// backupImages 从 R2 下载 tamabox 站的全部图片对象到本地,并生成 manifest。
|
||
//
|
||
// R2 里 tamabox 站图片的 key 都带 `tamabox/` 前缀(如 tamabox/2025/12/<ts>.jpg),
|
||
// 这是「bucket 用 path-style 访问时 List 返回的 key 带 bucket 名作前缀」这一 R2 行为。
|
||
// 因此只需列出 `tamabox/` 前缀即可覆盖全部图片。blog/ 是同一 R2 account 下另一个站点
|
||
// 的内容,不在 tamabox 的备份范围。
|
||
func backupImages(ctx context.Context, outDir string) error {
|
||
client, err := backupS3Client()
|
||
if err != nil {
|
||
return errors.Wrap(err, "get R2 client for backup")
|
||
}
|
||
|
||
imageDir := filepath.Join(outDir, "images")
|
||
if err := os.MkdirAll(imageDir, 0o700); err != nil {
|
||
return errors.Wrap(err, "create images directory")
|
||
}
|
||
|
||
var manifest []imageManifestEntry
|
||
var count int64
|
||
var totalBytes int64
|
||
|
||
// 见函数注释:tamabox/ 前缀即覆盖全部图片(含新旧 key 格式)。
|
||
if err := backupPrefix(ctx, client, imageDir, "tamabox/", &manifest, &count, &totalBytes); err != nil {
|
||
return err
|
||
}
|
||
|
||
// 写 manifest:记录 key → 大小 → md5,恢复后可用于校验完整性。
|
||
mf, err := os.Create(filepath.Join(outDir, "images-manifest.json"))
|
||
if err != nil {
|
||
return errors.Wrap(err, "create manifest file")
|
||
}
|
||
defer mf.Close()
|
||
enc := json.NewEncoder(mf)
|
||
enc.SetIndent("", " ")
|
||
if err := enc.Encode(manifest); err != nil {
|
||
return errors.Wrap(err, "write manifest")
|
||
}
|
||
|
||
logrus.WithFields(logrus.Fields{
|
||
"objects": count,
|
||
"bytes": totalBytes,
|
||
"dir": imageDir,
|
||
}).Info("图片已备份")
|
||
return nil
|
||
}
|
||
|
||
// backupPrefix 分页列出并下载指定前缀下的全部对象。
|
||
func backupPrefix(ctx context.Context, client *s3.Client, imageDir, prefix string, manifest *[]imageManifestEntry, count, totalBytes *int64) error {
|
||
paginator := s3.NewListObjectsV2Paginator(client, &s3.ListObjectsV2Input{
|
||
Bucket: aws.String(conf.Upload.ImageBucket),
|
||
Prefix: aws.String(prefix),
|
||
})
|
||
for paginator.HasMorePages() {
|
||
page, err := paginator.NextPage(ctx)
|
||
if err != nil {
|
||
return errors.Wrapf(err, "list R2 objects prefix %q", prefix)
|
||
}
|
||
for _, obj := range page.Contents {
|
||
if obj.Key == nil {
|
||
continue
|
||
}
|
||
key := *obj.Key
|
||
|
||
// 落盘路径 = imageDir + "/" + key,保持对象 key 结构(含 tamabox/ 前缀),
|
||
// 恢复时可直接用同一 key 回传 R2。
|
||
dst := filepath.Join(imageDir, filepath.FromSlash(key))
|
||
if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
|
||
return errors.Wrapf(err, "create dir for %q", key)
|
||
}
|
||
if err := downloadObject(ctx, client, conf.Upload.ImageBucket, key, dst); err != nil {
|
||
return err
|
||
}
|
||
|
||
size := int64(0)
|
||
if obj.Size != nil {
|
||
size = *obj.Size
|
||
}
|
||
md5 := ""
|
||
if obj.ETag != nil {
|
||
md5 = strings.Trim(*obj.ETag, `"`)
|
||
}
|
||
*manifest = append(*manifest, imageManifestEntry{Key: key, Size: size, MD5: md5})
|
||
|
||
*count++
|
||
*totalBytes += size
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// backupS3Client 构造用于备份的 R2 客户端。
|
||
//
|
||
// 不能直接复用 storage.GetS3Client 做 List:那个客户端把 bucket 路径拼进了 endpoint
|
||
// (image_endpoint 形如 https://<account>.r2.cloudflarestorage.com/<bucket>),
|
||
// 它对 GetObject/PutObject 恰好可用(R2 容错),但 ListObjectsV2 会把 URL 拼成
|
||
// /<bucket>/<bucket>?list-type=2,R2 找不到这个 key 前缀而返回 404 NoSuchKey。
|
||
//
|
||
// 这里剥离 endpoint 的 bucket 后缀,得到纯 account endpoint,并启用 path-style:
|
||
// List/Get 都用 Bucket 参数 + 完整 key(List 返回的 key 自带 bucket 名作前缀,
|
||
// 如 tamabox/2025/12/<ts>.jpg),这是 R2 上唯一稳定可用的姿势。
|
||
func backupS3Client() (*s3.Client, error) {
|
||
endpoint := strings.TrimRight(conf.Upload.ImageEndpoint, "/")
|
||
// 剥离最后一个路径段(bucket 名)。endpoint 形如
|
||
// https://<account>.r2.cloudflarestorage.com/<bucket>,去掉 /<bucket>。
|
||
if i := strings.LastIndex(endpoint, "/"); i > len("https://") {
|
||
endpoint = endpoint[:i]
|
||
}
|
||
|
||
resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) {
|
||
return aws.Endpoint{
|
||
URL: endpoint,
|
||
HostnameImmutable: true,
|
||
Source: aws.EndpointSourceCustom,
|
||
}, nil
|
||
})
|
||
cfg, err := config.LoadDefaultConfig(context.Background(),
|
||
config.WithEndpointResolverWithOptions(resolver),
|
||
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
|
||
conf.Upload.ImageAccessID,
|
||
conf.Upload.ImageAccessSecret,
|
||
"",
|
||
)),
|
||
config.WithRegion("auto"),
|
||
)
|
||
if err != nil {
|
||
return nil, errors.Wrap(err, "load R2 config")
|
||
}
|
||
return s3.NewFromConfig(cfg, func(o *s3.Options) {
|
||
o.UsePathStyle = true
|
||
}), nil
|
||
}
|
||
|
||
// downloadObject 下载单个对象到本地文件,失败时删除半成品避免留下损坏文件。
|
||
func downloadObject(ctx context.Context, client *s3.Client, bucket, key, dst string) error {
|
||
out, err := client.GetObject(ctx, &s3.GetObjectInput{
|
||
Bucket: aws.String(bucket),
|
||
Key: aws.String(key),
|
||
})
|
||
if err != nil {
|
||
return errors.Wrapf(err, "get R2 object %q", key)
|
||
}
|
||
defer out.Body.Close()
|
||
|
||
tmp := dst + ".part"
|
||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||
if err != nil {
|
||
return errors.Wrapf(err, "create %q", tmp)
|
||
}
|
||
if _, err := io.Copy(f, out.Body); err != nil {
|
||
f.Close()
|
||
os.Remove(tmp)
|
||
return errors.Wrapf(err, "download %q", key)
|
||
}
|
||
if err := f.Close(); err != nil {
|
||
os.Remove(tmp)
|
||
return errors.Wrapf(err, "close %q", tmp)
|
||
}
|
||
if err := os.Rename(tmp, dst); err != nil {
|
||
os.Remove(tmp)
|
||
return errors.Wrapf(err, "finalize %q", dst)
|
||
}
|
||
return nil
|
||
}
|