471cfdd161
- 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)
413 lines
9.5 KiB
Go
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
|