Files
gitea/services/dbbackup/backup.go
T
petru 471cfdd161
release-nightly / nightly-binary (push) Has been cancelled
release-nightly / nightly-container (push) Has been cancelled
Modified - [install] [backup] [database] [recovery] Consolidated database backup and installer recovery support.
- 1 - Add: Gitea now creates timestamped database backup bundles under `[backup].PATH`, exposes the backup schedule in the installer, and surfaces the `database_backup` cron task in admin monitoring.
- 2 - Add: installed instances now use `.gitea-installed` and `.gitea-recovery.ini` to enter email-gated recovery instead of falling back to public install mode when configuration or database access is broken.
- 3 - Mod: the installer recovery flow now covers backup-bundle restore, bundled or manual `app.ini` reuse, uploaded SQL/GZ database restores, and repository-filesystem recovery with source-specific validation, confirmations, and preserved launcher state.
- 4 - Fix: recovery now restores bundled `app.ini` snapshots when needed, discovers backup bundles from both the active backup path and persisted `.gitea-recovery.ini` path, and preserves SMTP and other rebuilt settings correctly when `app.ini` is missing or incomplete.
- 5 - Fix: recovery validation and restore handling now accept either a selected backup bundle or an uploaded SQL/GZ dump, keep sensitive secrets and existing `LFS_JWT_SECRET` when appropriate, clear SQLite restore targets before import, and complete the post-install handoff without redirect loops.
- 6 - Mod: fresh installs now default recovery email authorization to enabled with first-admin fallback, and the install/recovery UI, styling, and EN/RO wording were refined to match the final launcher behavior.

Co-Authored-By: petru @ codex (GPT-5) <codex@openai.com>
(cherry picked from commit 9879caf2292691b0cb521d12e6fee924b066bae2)
2026-06-01 03:56:03 +03:00

413 lines
9.5 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package dbbackup
import (
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"slices"
"strings"
"time"
"code.gitea.io/gitea/models/db"
db_install "code.gitea.io/gitea/models/db/install"
"code.gitea.io/gitea/modules/setting"
)
// start edit/add - by petru @ codex
const (
manifestFileName = "manifest.json"
databaseDumpFileName = "database.sql"
databaseDumpGzipFileName = "database.sql.gz"
appINISnapshotFileName = "app.ini"
backupManifestSchemaValue = 1
)
type Manifest struct {
SchemaVersion int `json:"schema_version"`
ID string `json:"id"`
CreatedUnix int64 `json:"created_unix"`
DBType string `json:"db_type"`
AppVersion string `json:"app_version"`
AppName string `json:"app_name"`
MigrationVersion int64 `json:"migration_version"`
FileName string `json:"file_name"`
FileSHA256 string `json:"file_sha256"`
FileSize int64 `json:"file_size"`
Compressed bool `json:"compressed"`
AppINISnapshotFile string `json:"app_ini_snapshot_file,omitempty"`
dirPath string `json:"-"`
}
var (
// start edit/add - by petru @ codex
ErrBackupNotFound = errors.New("database backup not found")
ErrBackupDBTypeMismatch = errors.New("database backup type mismatch")
// end edit/add - by petru @ codex
)
func CreateDatabaseBackup(ctx context.Context) (*Manifest, string, error) {
createdAt := time.Now().UTC()
backupID := createdAt.Format("20060102-150405.000000000")
finalDir := filepath.Join(setting.Backup.Path, backupID)
tempDir := finalDir + ".tmp"
if err := os.MkdirAll(setting.Backup.Path, 0o700); err != nil {
return nil, "", err
}
if err := os.MkdirAll(tempDir, 0o700); err != nil {
return nil, "", err
}
success := false
defer func() {
if !success {
_ = os.RemoveAll(tempDir)
}
}()
dumpPath := filepath.Join(tempDir, databaseDumpFileName)
if err := db.DumpDatabase(dumpPath, setting.Database.Type.String()); err != nil {
return nil, "", err
}
backupFileName := databaseDumpFileName
finalDumpPath := dumpPath
if setting.Backup.Compress {
backupFileName = databaseDumpGzipFileName
finalDumpPath = filepath.Join(tempDir, backupFileName)
if err := gzipFile(dumpPath, finalDumpPath); err != nil {
return nil, "", err
}
if err := os.Remove(dumpPath); err != nil {
return nil, "", err
}
}
fileSHA256, fileSize, err := fileDigestAndSize(finalDumpPath)
if err != nil {
return nil, "", err
}
manifest := &Manifest{
SchemaVersion: backupManifestSchemaValue,
ID: backupID,
CreatedUnix: createdAt.Unix(),
DBType: setting.Database.Type.String(),
AppVersion: setting.AppVer,
AppName: setting.AppName,
MigrationVersion: currentMigrationVersion(ctx),
FileName: backupFileName,
FileSHA256: fileSHA256,
FileSize: fileSize,
Compressed: setting.Backup.Compress,
}
if setting.Backup.IncludeAppINISnapshot {
copiedAppINI, err := copyAppINI(filepath.Join(tempDir, appINISnapshotFileName))
if err != nil {
return nil, "", err
}
if copiedAppINI {
manifest.AppINISnapshotFile = appINISnapshotFileName
}
}
if err := writeManifest(filepath.Join(tempDir, manifestFileName), manifest); err != nil {
return nil, "", err
}
if err := os.Rename(tempDir, finalDir); err != nil {
return nil, "", err
}
success = true
manifest.dirPath = finalDir
if err := PruneOldBackups(setting.Backup.RetentionCount); err != nil {
return manifest, finalDir, err
}
return manifest, finalDir, nil
}
func ListBackups() ([]*Manifest, error) {
return ListBackupsInPath(setting.Backup.Path)
}
// start edit/add - by petru @ codex
func ListBackupsInPath(backupPath string) ([]*Manifest, error) {
backupPath = strings.TrimSpace(backupPath)
if backupPath == "" {
return nil, nil
}
entries, err := os.ReadDir(backupPath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
manifests := make([]*Manifest, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() {
continue
}
manifestPath := filepath.Join(backupPath, entry.Name(), manifestFileName)
manifest, err := readManifest(manifestPath)
if err != nil {
continue
}
manifest.dirPath = filepath.Join(backupPath, entry.Name())
manifests = append(manifests, manifest)
}
slices.SortFunc(manifests, func(a, b *Manifest) int {
if a.CreatedUnix == b.CreatedUnix {
if a.ID > b.ID {
return -1
}
if a.ID < b.ID {
return 1
}
return 0
}
if a.CreatedUnix > b.CreatedUnix {
return -1
}
return 1
})
return manifests, nil
}
// end edit/add - by petru @ codex
func PruneOldBackups(retentionCount int) error {
if retentionCount <= 0 {
return nil
}
backups, err := ListBackups()
if err != nil {
return err
}
if retentionCount >= len(backups) {
return nil // edit/add - by petru @ codex
}
for _, backup := range backups[retentionCount:] {
if backup.dirPath == "" {
continue
}
if err := os.RemoveAll(backup.dirPath); err != nil {
return err
}
}
return nil
}
func currentMigrationVersion(ctx context.Context) int64 {
version, err := db_install.GetMigrationVersion(ctx)
if err != nil {
return 0
}
return version
}
// start edit/add - by petru @ codex
func FindBackupInPath(backupPath, backupID string) (*Manifest, error) {
backups, err := ListBackupsInPath(backupPath)
if err != nil {
return nil, err
}
for _, backup := range backups {
if backup.ID == backupID {
return backup, nil
}
}
return nil, ErrBackupNotFound
}
func RestoreDatabaseBackup(ctx context.Context, backupPath, backupID, targetDBType string) (*Manifest, error) {
backup, err := FindBackupInPath(backupPath, backupID)
if err != nil {
return nil, err
}
if targetDBType != "" && backup.DBType != targetDBType {
return nil, fmt.Errorf("%w: backup=%s target=%s", ErrBackupDBTypeMismatch, backup.DBType, targetDBType)
}
importPath, cleanup, err := prepareRestoreSQLFile(backup)
if err != nil {
return nil, err
}
defer cleanup()
if err := db.ImportDatabase(importPath); err != nil {
return nil, err
}
_ = ctx
return backup, nil
}
func LoadBackupAppINIConfig(backupPath, backupID string) (setting.ConfigProvider, error) {
// start edit/add - by petru @ codex
backup, err := FindBackupInPath(backupPath, backupID)
if err != nil {
return nil, err
}
if strings.TrimSpace(backup.AppINISnapshotFile) == "" {
return nil, nil
}
snapshotPath := filepath.Join(backup.dirPath, backup.AppINISnapshotFile)
cfg, err := setting.NewConfigProviderFromFile(snapshotPath)
if err != nil {
return nil, err
}
if cfg.IsLoadedFromEmpty() {
return nil, nil
}
return cfg, nil
// end edit/add - by petru @ codex
}
func prepareRestoreSQLFile(backup *Manifest) (string, func(), error) {
sourcePath := filepath.Join(backup.dirPath, backup.FileName)
if !backup.Compressed {
return sourcePath, func() {}, nil
}
tempFile, err := os.CreateTemp("", "gitea-db-restore-*.sql")
if err != nil {
return "", nil, err
}
tempPath := tempFile.Name()
cleanup := func() {
_ = tempFile.Close()
_ = os.Remove(tempPath)
}
sourceFile, err := os.Open(sourcePath)
if err != nil {
cleanup()
return "", nil, err
}
defer sourceFile.Close()
gzipReader, err := gzip.NewReader(sourceFile)
if err != nil {
cleanup()
return "", nil, err
}
defer gzipReader.Close()
if _, err = io.Copy(tempFile, gzipReader); err != nil {
cleanup()
return "", nil, err
}
if err = tempFile.Close(); err != nil {
cleanup()
return "", nil, err
}
return tempPath, cleanup, nil
}
// end edit/add - by petru @ codex
func gzipFile(sourcePath, targetPath string) error {
sourceFile, err := os.Open(sourcePath)
if err != nil {
return err
}
defer sourceFile.Close()
targetFile, err := os.Create(targetPath)
if err != nil {
return err
}
defer targetFile.Close()
gzipWriter := gzip.NewWriter(targetFile)
if _, err := io.Copy(gzipWriter, sourceFile); err != nil {
_ = gzipWriter.Close()
return err
}
return gzipWriter.Close()
}
func fileDigestAndSize(filePath string) (string, int64, error) {
file, err := os.Open(filePath)
if err != nil {
return "", 0, err
}
defer file.Close()
hasher := sha256.New()
size, err := io.Copy(hasher, file)
if err != nil {
return "", 0, err
}
return hex.EncodeToString(hasher.Sum(nil)), size, nil
}
func copyAppINI(targetPath string) (bool, error) {
sourceFile, err := os.Open(setting.CustomConf)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
defer sourceFile.Close()
targetFile, err := os.Create(targetPath)
if err != nil {
return false, err
}
defer targetFile.Close()
if _, err = io.Copy(targetFile, sourceFile); err != nil {
return false, err
}
return true, nil
}
func writeManifest(manifestPath string, manifest *Manifest) error {
manifestBytes, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return err
}
manifestBytes = append(manifestBytes, '\n')
return os.WriteFile(manifestPath, manifestBytes, 0o600)
}
func readManifest(manifestPath string) (*Manifest, error) {
data, err := os.ReadFile(manifestPath)
if err != nil {
return nil, err
}
manifest := &Manifest{}
if err := json.Unmarshal(data, manifest); err != nil {
return nil, err
}
if manifest.ID == "" || manifest.FileName == "" {
return nil, fmt.Errorf("invalid backup manifest: %s", manifestPath)
}
return manifest, nil
}
// end edit/add - by petru @ codex