diff --git a/cmd/tamabox.go b/cmd/tamabox.go index 97ad9b8..7e55ae2 100644 --- a/cmd/tamabox.go +++ b/cmd/tamabox.go @@ -29,6 +29,7 @@ func main() { cmd.Unrestrict, cmd.HidePublic, cmd.Honeypot, + cmd.Backup, } if err := app.Run(os.Args); err != nil { logrus.WithError(err).Fatal("Failed to start application") diff --git a/internal/cmd/backup.go b/internal/cmd/backup.go new file mode 100644 index 0000000..96008ca --- /dev/null +++ b/internal/cmd/backup.go @@ -0,0 +1,316 @@ +// 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/(新)或 tamabox/YYYY/MM/.(历史)的形式存在 +// R2 上,数据库里只存 key(upload_images.key)或 URL(users.avatar/background 里的完整 +// CDN 地址)。只备份数据库的话,恢复后这些引用指向的图片若已不在 R2(bucket 误删 / +// 账号丢失 / 图片被清理),页面就全是裂图。因此备份必须把 R2 上的图片一并落盘。 +// +// 输出目录结构: +// +// / +// db-<20060102-150405>.sql # pg_dump 全量导出(schema + data,可用 psql 恢复) +// images/ # 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/.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://.r2.cloudflarestorage.com/), +// 它对 GetObject/PutObject 恰好可用(R2 容错),但 ListObjectsV2 会把 URL 拼成 +// //?list-type=2,R2 找不到这个 key 前缀而返回 404 NoSuchKey。 +// +// 这里剥离 endpoint 的 bucket 后缀,得到纯 account endpoint,并启用 path-style: +// List/Get 都用 Bucket 参数 + 完整 key(List 返回的 key 自带 bucket 名作前缀, +// 如 tamabox/2025/12/.jpg),这是 R2 上唯一稳定可用的姿势。 +func backupS3Client() (*s3.Client, error) { + endpoint := strings.TrimRight(conf.Upload.ImageEndpoint, "/") + // 剥离最后一个路径段(bucket 名)。endpoint 形如 + // https://.r2.cloudflarestorage.com/,去掉 /。 + 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 +} diff --git a/tamabox.go b/tamabox.go index 97ad9b8..7e55ae2 100644 --- a/tamabox.go +++ b/tamabox.go @@ -29,6 +29,7 @@ func main() { cmd.Unrestrict, cmd.HidePublic, cmd.Honeypot, + cmd.Backup, } if err := app.Run(os.Args); err != nil { logrus.WithError(err).Fatal("Failed to start application")