// 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" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "os" "os/exec" "path/filepath" "runtime" "strings" "time" "github.com/aws/aws-sdk-go-v2/aws" "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" "github.com/tamakyi/TamaBox/internal/storage" ) const ( imageObjectAttemptTimeout = 60 * time.Second imageObjectMaxAttempts = 3 imageObjectRetryBackoff = 100 * time.Millisecond ) // Backup 全量备份数据库与对象存储(Cloudflare R2)图片到本地目录。 // // 为什么必须同时备份图片:站点的所有图片(头像 / 背景 / 二维码 logo / 提问图 / 回答图) // 都以根日期 key、picture/ 或 tamabox/ 等形式存在 R2 上,数据库里只存 key 或 CDN URL。 // 只备份数据库的话,恢复后这些引用指向的图片若已不在 R2,页面就全是裂图。 // // 输出目录结构: // // / // db-<20060102-150405>.sql # pg_dump 全量导出(schema + data,可用 psql 恢复) // images/ # R2 对象,保持原 key 路径 // images-manifest.json # key → 实际大小 / SHA-256 / GET 元数据 // // 用法: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 all R2 objects 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 ctx.Bool("db-only") && ctx.Bool("images-only") { return errors.New("--db-only and --images-only cannot be used together") } 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) var dumpErr strings.Builder if err := writeFileAtomically(outFile, func(w io.Writer) error { cmd.Stdout = w cmd.Stderr = &dumpErr if err := cmd.Run(); err != nil { return errors.Wrapf(err, "pg_dump failed: %s", strings.TrimSpace(dumpErr.String())) } return nil }); err != nil { return err } logrus.WithField("file", outFile).Info("数据库已备份") return nil } // writeFileAtomically 保留数据库、manifest 与对象下载共用的原子写修复: // 只有完整写入、Sync 并关闭成功后才替换目标,失败时清理临时 .part 文件。 func writeFileAtomically(outFile string, write func(io.Writer) error) error { f, err := os.CreateTemp(filepath.Dir(outFile), "."+filepath.Base(outFile)+"-*.part") if err != nil { return errors.Wrapf(err, "create temporary file for %q", outFile) } tmpFile := f.Name() defer os.Remove(tmpFile) defer f.Close() if err := write(f); err != nil { return err } if err := f.Sync(); err != nil { return errors.Wrapf(err, "sync temporary file for %q", outFile) } if err := f.Close(); err != nil { return errors.Wrapf(err, "close temporary file for %q", outFile) } if err := os.Rename(tmpFile, outFile); err != nil { return errors.Wrapf(err, "finalize %q", outFile) } return nil } // imageManifestEntry 记录实际下载内容的大小和 SHA-256,以及 GET 返回的原始元数据。 // ETag 只是服务商元数据,不能当作内容 MD5 或完整性校验的替代品。 type imageManifestEntry struct { Key string `json:"key"` Size int64 `json:"size"` SHA256 string `json:"sha256"` ETag string `json:"etag,omitempty"` ContentType string `json:"content_type,omitempty"` CacheControl string `json:"cache_control,omitempty"` } type backupS3API interface { ListObjectsV2(context.Context, *s3.ListObjectsV2Input, ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) GetObject(context.Context, *s3.GetObjectInput, ...func(*s3.Options)) (*s3.GetObjectOutput, error) } type backupDownloadPlan struct { key string destination string } type downloadedImageObject struct { size int64 sha256 string etag string contentType string cacheControl string } // backupImages 默认使用空前缀列举配置 bucket 的全部对象,而不是假定图片都在 tamabox/ 下。 func backupImages(ctx context.Context, outDir string) error { client, err := backupS3Client(ctx) if err != nil { return errors.Wrap(err, "get R2 client for backup") } return backupImagesWithClient(ctx, client, conf.Upload.ImageBucket, outDir, "") } // backupImagesWithClient 允许测试和隔离演练显式指定 prefix。 // 所有分页 List、key 校验和本地路径预检都在第一次 GetObject 之前完成。 func backupImagesWithClient(ctx context.Context, client backupS3API, bucket, outDir, prefix string) error { keys, err := listAllBackupKeys(ctx, client, bucket, prefix) if err != nil { return err } imageDir := filepath.Join(outDir, "images") plans, err := planBackupDownloads(imageDir, keys) if err != nil { return err } manifest := make([]imageManifestEntry, 0, len(plans)) var totalBytes int64 for _, plan := range plans { if err := ensureBackupDestinationDirectory(imageDir, plan.destination); err != nil { return err } downloaded, err := downloadObject(ctx, client, bucket, plan.key, plan.destination) if err != nil { return err } manifest = append(manifest, imageManifestEntry{ Key: plan.key, Size: downloaded.size, SHA256: downloaded.sha256, ETag: downloaded.etag, ContentType: downloaded.contentType, CacheControl: downloaded.cacheControl, }) totalBytes += downloaded.size } manifestFile := filepath.Join(outDir, "images-manifest.json") if err := writeFileAtomically(manifestFile, func(w io.Writer) error { enc := json.NewEncoder(w) enc.SetIndent("", " ") if err := enc.Encode(manifest); err != nil { return errors.Wrap(err, "write manifest") } return nil }); err != nil { return err } logrus.WithFields(logrus.Fields{ "objects": len(manifest), "bytes": totalBytes, "dir": imageDir, "prefix": prefix, }).Info("图片已备份") return nil } func listAllBackupKeys(ctx context.Context, client backupS3API, bucket, prefix string) ([]string, error) { keys := make([]string, 0) var continuationToken *string seenTokens := make(map[string]struct{}) for { page, err := client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ Bucket: aws.String(bucket), Prefix: aws.String(prefix), ContinuationToken: continuationToken, }) if err != nil { return nil, errors.Wrapf(err, "list R2 objects prefix %q", prefix) } if page == nil { return nil, errors.New("list R2 objects returned a nil page") } for _, object := range page.Contents { // nil key 与空 key 一样是无效输入;统一转成空串,由完整 List 后的预检拒绝。 keys = append(keys, aws.ToString(object.Key)) } if !aws.ToBool(page.IsTruncated) { return keys, nil } next := aws.ToString(page.NextContinuationToken) if next == "" { return nil, errors.New("truncated R2 list response has no continuation token") } if _, exists := seenTokens[next]; exists { return nil, errors.Errorf("R2 list continuation token %q repeated", next) } seenTokens[next] = struct{}{} continuationToken = aws.String(next) } } func planBackupDownloads(imageDir string, keys []string) ([]backupDownloadPlan, error) { plans := make([]backupDownloadPlan, 0, len(keys)) files := make(map[string]string, len(keys)) directories := make(map[string]string) root, err := filepath.Abs(imageDir) if err != nil { return nil, errors.Wrap(err, "resolve images directory") } directories[canonicalLocalPath(root)] = "" for _, key := range keys { destination, err := localImagePath(root, key) if err != nil { return nil, err } canonicalDestination := canonicalLocalPath(destination) if previous, exists := files[canonicalDestination]; exists { return nil, errors.Errorf("objects %q and %q map to the same local file", previous, key) } if previous, exists := directories[canonicalDestination]; exists { return nil, errors.Errorf("file/directory collision between objects %q and %q", previous, key) } for parent := filepath.Dir(destination); pathWithinDirectory(root, parent); parent = filepath.Dir(parent) { canonicalParent := canonicalLocalPath(parent) if previous, exists := files[canonicalParent]; exists { return nil, errors.Errorf("file/directory collision between objects %q and %q", previous, key) } if _, exists := directories[canonicalParent]; !exists { directories[canonicalParent] = key } if parent == root { break } } files[canonicalDestination] = key plans = append(plans, backupDownloadPlan{key: key, destination: destination}) } if err := prepareBackupImageRoot(root); err != nil { return nil, err } for _, plan := range plans { if err := preflightBackupLocalPath(root, plan.destination); err != nil { return nil, err } } return plans, nil } func validateObjectKey(key string) error { if key == "" { return errors.New("R2 object key must not be empty") } if strings.ContainsRune(key, 0) { return errors.Errorf("R2 object key %q contains NUL", key) } if strings.Contains(key, `\`) { return errors.Errorf("R2 object key %q contains a backslash", key) } if strings.HasSuffix(key, "/") { return errors.Errorf("R2 object key %q is a directory key", key) } if strings.HasPrefix(key, "/") || filepath.IsAbs(filepath.FromSlash(key)) || filepath.VolumeName(filepath.FromSlash(key)) != "" || (len(key) >= 2 && ((key[0] >= 'A' && key[0] <= 'Z') || (key[0] >= 'a' && key[0] <= 'z')) && key[1] == ':') { return errors.Errorf("R2 object key %q is an absolute or volume-qualified path", key) } for _, segment := range strings.Split(key, "/") { if segment == "" || segment == "." || segment == ".." { return errors.Errorf("R2 object key %q contains an empty or dot path segment", key) } } return nil } func localImagePath(imageDir, key string) (string, error) { if err := validateObjectKey(key); err != nil { return "", err } root, err := filepath.Abs(imageDir) if err != nil { return "", errors.Wrap(err, "resolve images directory") } destination := filepath.Join(root, filepath.FromSlash(key)) if !pathWithinDirectory(root, destination) || destination == root { return "", errors.Errorf("R2 object key %q escapes the images directory", key) } return destination, nil } func pathWithinDirectory(root, candidate string) bool { rel, err := filepath.Rel(root, candidate) if err != nil || filepath.IsAbs(rel) { return false } return rel == "." || rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) } func canonicalLocalPath(path string) string { cleaned := filepath.Clean(path) if runtime.GOOS == "windows" { return strings.ToLower(cleaned) } return cleaned } func prepareBackupImageRoot(imageDir string) error { info, err := os.Lstat(imageDir) if os.IsNotExist(err) { if err := os.MkdirAll(imageDir, 0o700); err != nil { return errors.Wrap(err, "create images directory") } info, err = os.Lstat(imageDir) } if err != nil { return errors.Wrap(err, "inspect images directory") } if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { return errors.Errorf("images path %q must be a real directory, not a symlink or file", imageDir) } return nil } func preflightBackupLocalPath(imageDir, destination string) error { info, err := os.Lstat(destination) if err == nil && (info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular()) { return errors.Errorf("backup destination %q is not a regular file", destination) } if err != nil && !os.IsNotExist(err) { return errors.Wrapf(err, "inspect backup destination %q", destination) } for parent := filepath.Dir(destination); parent != imageDir; parent = filepath.Dir(parent) { if !pathWithinDirectory(imageDir, parent) { return errors.Errorf("backup destination %q escapes images directory", destination) } info, err := os.Lstat(parent) if os.IsNotExist(err) { continue } if err != nil { return errors.Wrapf(err, "inspect backup parent %q", parent) } if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { return errors.Errorf("backup parent %q is not a real directory", parent) } } return nil } func ensureBackupDestinationDirectory(imageDir, destination string) error { root, err := filepath.Abs(imageDir) if err != nil { return err } parent := filepath.Dir(destination) rel, err := filepath.Rel(root, parent) if err != nil || !pathWithinDirectory(root, parent) { return errors.Errorf("invalid backup destination %q", destination) } if rel == "." { return nil } current := root for _, segment := range strings.Split(rel, string(filepath.Separator)) { current = filepath.Join(current, segment) info, err := os.Lstat(current) if os.IsNotExist(err) { if err := os.Mkdir(current, 0o700); err != nil && !os.IsExist(err) { return errors.Wrapf(err, "create backup directory %q", current) } info, err = os.Lstat(current) } if err != nil { return errors.Wrapf(err, "inspect backup directory %q", current) } if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { return errors.Errorf("backup parent %q is not a real directory", current) } } return nil } // backupS3Client 与上传、恢复共用 storage.NewS3Client 的 endpoint 语义: // endpoint 可不带路径或只带 /,请求始终以 Bucket 参数和原始完整 key 寻址。 // List 返回什么 key 就下载什么 key,不额外添加或剥离 bucket 前缀。 func backupS3Client(ctx context.Context) (*s3.Client, error) { return storage.NewS3Client( ctx, conf.Upload.ImageEndpoint, conf.Upload.ImageBucket, conf.Upload.ImageAccessID, conf.Upload.ImageAccessSecret, ) } // downloadObject 原子下载单个对象,并返回实际落盘内容的 SHA-256 和 GET 元数据。 // 每次尝试都有独立 60 秒 context;流中断会从头重试整个对象,最多 3 次。 func downloadObject(ctx context.Context, client backupS3API, bucket, key, destination string) (downloadedImageObject, error) { var lastErr error for attempt := 0; attempt < imageObjectMaxAttempts; attempt++ { if err := ctx.Err(); err != nil { return downloadedImageObject{}, err } attemptCtx, cancel := context.WithTimeout(ctx, imageObjectAttemptTimeout) downloaded, err := downloadObjectOnce(attemptCtx, client, bucket, key, destination) cancel() if err == nil { return downloaded, nil } lastErr = err if ctx.Err() != nil { return downloadedImageObject{}, ctx.Err() } if attempt == imageObjectMaxAttempts-1 { break } if err := waitForImageObjectRetry(ctx, imageObjectRetryBackoff<