Step-1 Modified - [install] [backup] [database] [recovery] Added periodic database backups and integrated them into the installer recovery flow.
release-nightly / nightly-binary (push) Has been cancelled
release-nightly / nightly-container (push) Has been cancelled

- 1 - Add: Gitea now creates timestamped database backup bundles under `[backup].PATH`, with `manifest.json`, SQL dump output, optional gzip compression, optional `app.ini` snapshotting, and retention-based cleanup.
- 2 - Mod: the installer now includes a `Database Backup` section under `Database Settings`, reads and writes the `[backup]` and `[cron.database_backup]` settings, and starts from an enabled-by-default daily backup schedule for installs or updates that do not already define backup cron values.
- 3 - Mod: the existing installer recovery modal now works as a recovery-source chooser for existing databases, compatible database backup bundles, or repository-filesystem recovery, with source-specific warnings, confirmations, and preserved modal state across rerenders.
- 4 - Add: installer-side database backup restore now detects compatible bundles from the configured backup path, lets the user choose a bundle, and imports the selected `database.sql` or `database.sql.gz` into the target database during reinstall recovery.
- 5 - Mod: the admin maintenance dashboard and cron monitor now expose the `database_backup` task directly, including the configured schedule even before the task is manually enabled.
- 6 - Fix: backup retention pruning no longer panics when `RETENTION_COUNT` is greater than the number of available backup directories.
This commit is contained in:
2026-05-25 00:17:53 +03:00
parent 406e6d0697
commit 3093e56b88
19 changed files with 1326 additions and 35 deletions
+8
View File
@@ -893,3 +893,11 @@ History search guidance:
- 1 - Mod: `/-/admin/badges` now shows a delete button in each row that opens the badge delete modal directly from the table.
- 2 - Mod: `/-/admin/orgs` now shows a delete button in each row that opens an organization delete modal directly from the table.
- 3 - Mod: the new table actions reuse the original delete modal layouts already used in badge edit and organization settings, and deleting an organization from `/-/admin/orgs` now returns to the admin organizations list instead of `/`.
186 - [2026-05-24 19:46:12] - v1.27.0-dev-197-g406e6d0697 - Type: Step-1 Modified - [install] [backup] [database] [recovery] Added periodic database backups and integrated them into the installer recovery flow.
- 1 - Add: Gitea now creates timestamped database backup bundles under `[backup].PATH`, with `manifest.json`, SQL dump output, optional gzip compression, optional `app.ini` snapshotting, and retention-based cleanup.
- 2 - Mod: the installer now includes a `Database Backup` section under `Database Settings`, reads and writes the `[backup]` and `[cron.database_backup]` settings, and starts from an enabled-by-default daily backup schedule for installs or updates that do not already define backup cron values.
- 3 - Mod: the existing installer recovery modal now works as a recovery-source chooser for existing databases, compatible database backup bundles, or repository-filesystem recovery, with source-specific warnings, confirmations, and preserved modal state across rerenders.
- 4 - Add: installer-side database backup restore now detects compatible bundles from the configured backup path, lets the user choose a bundle, and imports the selected `database.sql` or `database.sql.gz` into the target database during reinstall recovery.
- 5 - Mod: the admin maintenance dashboard and cron monitor now expose the `database_backup` task directly, including the configured schedule even before the task is manually enabled.
- 6 - Fix: backup retention pruning no longer panics when `RETENTION_COUNT` is greater than the number of available backup directories.
+34
View File
@@ -2108,6 +2108,22 @@ LEVEL = Info
;; Empty means server's location setting
;DEFAULT_UI_LOCATION =
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;[backup]
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; start edit/add - by petru @ codex
;; Directory where periodic database backups are written.
;PATH = data/backups/db
;; Number of backups to keep. Set to 0 to disable pruning.
;RETENTION_COUNT = 7
;; Compress the SQL dump as `database.sql.gz`.
;COMPRESS = true
;; Save a copy of the current app.ini alongside the backup manifest.
;INCLUDE_APP_INI_SNAPSHOT = true
;; end edit/add - by petru @ codex
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;[cron]
@@ -2130,6 +2146,24 @@ LEVEL = Info
;; Basic cron tasks - enabled by default
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Create database backups
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;[cron.database_backup]
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; start edit/add - by petru @ codex
;; Enable running the periodic database backup task.
;ENABLED = true
;; Run the database backup task when Gitea starts.
;RUN_AT_START = false
;; Whether to emit notice on successful execution too.
;NOTICE_ON_SUCCESS = false
;; Time interval for job to run.
;SCHEDULE = @daily
;; end edit/add - by petru @ codex
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Clean up old repository archives
+9
View File
@@ -31,3 +31,12 @@ func DumpDatabase(filePath, dbType string) error {
}
return xormEngine.DumpTablesToFile(tbs, filePath)
}
// start edit/add - by petru @ codex
// ImportDatabase imports SQL statements from a file into the current default engine.
func ImportDatabase(filePath string) error {
_, err := xormEngine.ImportFile(filePath)
return err
}
// end edit/add - by petru @ codex
+37
View File
@@ -0,0 +1,37 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
import "path/filepath"
// start edit/add - by petru @ codex
var Backup = struct {
Path string
RetentionCount int
Compress bool
IncludeAppINISnapshot bool
}{
RetentionCount: 7,
Compress: true,
IncludeAppINISnapshot: true,
}
func loadBackupFrom(rootCfg ConfigProvider) {
sec := rootCfg.Section("backup")
Backup.Path = sec.Key("PATH").MustString(filepath.Join(AppDataPath, "backups", "db"))
if !filepath.IsAbs(Backup.Path) {
Backup.Path = filepath.Join(AppWorkPath, Backup.Path)
}
Backup.RetentionCount = sec.Key("RETENTION_COUNT").MustInt(7)
if Backup.RetentionCount < 0 {
Backup.RetentionCount = 0
}
Backup.Compress = sec.Key("COMPRESS").MustBool(true)
Backup.IncludeAppINISnapshot = sec.Key("INCLUDE_APP_INI_SNAPSHOT").MustBool(true)
}
// end edit/add - by petru @ codex
+45
View File
@@ -0,0 +1,45 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// start edit/add - by petru @ codex
func TestLoadBackupFrom(t *testing.T) {
oldAppWorkPath := AppWorkPath
oldAppDataPath := AppDataPath
oldBackup := Backup
defer func() {
AppWorkPath = oldAppWorkPath
AppDataPath = oldAppDataPath
Backup = oldBackup
}()
AppWorkPath = "/srv/gitea"
AppDataPath = "data"
cfg, err := NewConfigProviderFromData(`
[backup]
PATH = backups/custom-db
RETENTION_COUNT = -4
COMPRESS = false
INCLUDE_APP_INI_SNAPSHOT = false
`)
require.NoError(t, err)
loadBackupFrom(cfg)
assert.Equal(t, filepath.Join(AppWorkPath, "backups", "custom-db"), Backup.Path)
assert.Equal(t, 0, Backup.RetentionCount)
assert.False(t, Backup.Compress)
assert.False(t, Backup.IncludeAppINISnapshot)
}
// end edit/add - by petru @ codex
+1
View File
@@ -135,6 +135,7 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error {
}
loadTimeFrom(cfg)
loadRepositoryFrom(cfg)
loadBackupFrom(cfg) // edit/add - by petru @ codex
if err := loadAvatarsFrom(cfg); err != nil {
return err
}
+39
View File
@@ -266,11 +266,49 @@
"install.ssl_mode": "SSL",
"install.path": "Path",
"install.sqlite_helper": "File path for the SQLite3 database.<br>Enter an absolute path if you run Gitea as a service.",
"install.database_backup_title": "Database Backup",
"install.database_backup_desc": "Configure periodic database backups that Gitea will generate for recovery and reinstall scenarios.",
"install.database_backup_enabled": "Enable periodic database backups",
"install.database_backup_enabled_helper": "Turn on the scheduled database backup cron task.",
"install.database_backup_run_at_start": "Run backup at startup",
"install.database_backup_run_at_start_helper": "Also create a backup once when Gitea starts, in addition to the scheduled runs.",
"install.database_backup_schedule": "Backup Schedule",
"install.database_backup_schedule_helper": "Cron descriptor or full cron expression, for example `@daily` or `@every 12h`.",
"install.database_backup_path": "Backup Path",
"install.database_backup_path_helper": "Directory where timestamped database backup bundles will be written.",
"install.database_backup_retention_count": "Retention Count",
"install.database_backup_retention_count_helper": "How many backups to keep. Set to `0` to disable pruning.",
"install.database_backup_compress": "Compress SQL dump",
"install.database_backup_compress_helper": "Store the database dump as `database.sql.gz` instead of a plain `.sql` file.",
"install.database_backup_include_app_ini_snapshot": "Include app.ini snapshot",
"install.database_backup_include_app_ini_snapshot_helper": "Save a copy of the current `app.ini` alongside each backup bundle.",
"install.reinstall_error": "You are trying to install into an existing Gitea database",
"install.reinstall_confirm_message": "Re-installing with an existing Gitea database can cause multiple problems. In most cases, you should use your existing \"app.ini\" to run Gitea. If you know what you are doing, confirm the following:",
"install.reinstall_confirm_check_1": "The data encrypted by the SECRET_KEY in app.ini may be lost: users may not be able to log in with 2FA/OTP and mirrors may not function correctly. By checking this box, you confirm that the current app.ini file contains the correct SECRET_KEY.",
"install.reinstall_confirm_check_2": "The repositories and settings may need to be resynchronized. By checking this box, you confirm that you will resynchronize the hooks for the repositories and authorized_keys file manually. You confirm that you will ensure that repository and mirror settings are correct.",
"install.reinstall_confirm_check_3": "You confirm that you are absolutely sure that this Gitea is running with the correct app.ini location and that you are sure that you have to re-install. You confirm that you acknowledge the above risks.",
"install.recovery_source_title": "Recovery source",
"install.recovery_source_existing_database": "Use existing database",
"install.recovery_source_existing_database_helper": "Continue with the currently configured Gitea database when it is already present and still usable.",
"install.recovery_source_database_backup": "Restore from database backup",
"install.recovery_source_database_backup_helper": "Continue with a new or empty database and restore it from a previously generated backup bundle that matches the selected database type.",
"install.recovery_source_database_backup_unavailable": "Database backup restore is not available in this installer yet.",
"install.recovery_source_repository_filesystem": "Recover from repository filesystem",
"install.recovery_source_repository_filesystem_helper": "Continue with a new database and recover repositories later from the existing repository directories on disk.",
"install.recovery_database_backup_error": "Compatible database backup bundles were detected for the selected database type",
"install.recovery_database_backup_message": "Restoring from a database backup can bring back most of the original installation state, but it will overwrite the target database contents. If you know what you are doing, confirm the following:",
"install.recovery_database_backup_select": "Database backup bundle",
"install.recovery_database_backup_select_helper": "Select the backup bundle that should be restored into the currently configured database.",
"install.recovery_database_backup_confirm_check_1": "You confirm that the selected backup belongs to this Gitea instance and matches the currently selected database type.",
"install.recovery_database_backup_confirm_check_2": "You confirm that the target database is new or empty and that its current contents may be overwritten by the restore process.",
"install.recovery_database_backup_confirm_check_3": "You confirm that you will verify the restored installation after setup and that you understand any data newer than the selected backup will not be recovered.",
"install.recovery_database_backup_discovery_failed": "Failed to inspect the configured database backup path: %v",
"install.recovery_database_backup_restore_failed": "Failed to restore the selected database backup: %v",
"install.recovery_repository_filesystem_error": "Existing repository data was detected in the configured repository root",
"install.recovery_repository_filesystem_message": "Recovering from the existing repository filesystem can restore repositories, but some account and database data may be lost. If you know what you are doing, confirm the following:",
"install.recovery_repository_filesystem_confirm_check_1": "You confirm that the current repository root points to the original repository filesystem you want to recover.",
"install.recovery_repository_filesystem_confirm_check_2": "You confirm that you understand this recovery path may lose database-only data such as accounts, sessions, issues, pull requests, comments, tokens, and notifications.",
"install.recovery_repository_filesystem_confirm_check_3": "You confirm that you will manually review and adopt the recovered repositories after installation, and that you accept the limits of filesystem-based recovery.",
"install.err_empty_db_path": "The SQLite3 database path cannot be empty.",
"install.no_admin_and_disable_registration": "You cannot disable user self-registration without creating an administrator account.",
"install.err_empty_admin_password": "The administrator password cannot be empty.",
@@ -3155,6 +3193,7 @@
"admin.dashboard.gc_times": "GC Times",
"admin.dashboard.delete_old_actions": "Delete all old activities from database",
"admin.dashboard.delete_old_actions.started": "Deletion of all old activities from database started",
"admin.dashboard.database_backup": "Create a database backup",
"admin.dashboard.update_checker": "Update checker",
"admin.dashboard.delete_old_system_notices": "Delete all old system notices from database",
"admin.dashboard.gc_lfs": "Garbage-collect LFS meta objects",
+39
View File
@@ -266,11 +266,49 @@
"install.ssl_mode": "SSL",
"install.path": "Cale Baza de Date",
"install.sqlite_helper": "Calea fișierului pentru baza de date SQLite3.<br>Introduceți o cale absolută dacă rulați Gitea ca serviciu.",
"install.database_backup_title": "Backup bază de date",
"install.database_backup_desc": "Configurează backupurile periodice ale bazei de date pe care Gitea le va genera pentru scenarii de recovery și reinstalare.",
"install.database_backup_enabled": "Activează backupurile periodice ale bazei de date",
"install.database_backup_enabled_helper": "Pornește taskul cron programat pentru backupul bazei de date.",
"install.database_backup_run_at_start": "Rulează backupul la pornire",
"install.database_backup_run_at_start_helper": "Creează și un backup la pornirea Gitea, pe lângă rulările programate.",
"install.database_backup_schedule": "Program backup",
"install.database_backup_schedule_helper": "Descriptor cron sau expresie cron completă, de exemplu `@daily` sau `@every 12h`.",
"install.database_backup_path": "Cale backup",
"install.database_backup_path_helper": "Directorul în care vor fi scrise bundle-urile versionate ale backupurilor bazei de date.",
"install.database_backup_retention_count": "Număr retenție",
"install.database_backup_retention_count_helper": "Câte backupuri să fie păstrate. Setează `0` pentru a dezactiva curățarea automată.",
"install.database_backup_compress": "Comprimă dumpul SQL",
"install.database_backup_compress_helper": "Salvează dumpul bazei de date ca `database.sql.gz` în loc de un fișier `.sql` simplu.",
"install.database_backup_include_app_ini_snapshot": "Include snapshot app.ini",
"install.database_backup_include_app_ini_snapshot_helper": "Salvează o copie a fișierului `app.ini` curent lângă fiecare bundle de backup.",
"install.reinstall_error": "Încercați să instalați într-o bază de date Gitea existentă",
"install.reinstall_confirm_message": "Reinstalarea cu o bază de date Gitea existentă poate cauza mai multe probleme. În cele mai multe cazuri, ar trebui să utilizați „app.ini” existent pentru a rula Gitea. Dacă știți ce faceți, confirmați următoarele:",
"install.reinstall_confirm_check_1": "Datele criptate de SECRET_KEY în app.ini se pot pierde: este posibil ca utilizatorii să nu se poată conecta cu 2FA/OTP și este posibil ca oglindirea să nu funcționeze corect. Bifând această casetă, confirmați că fișierul actual app.ini conține SECRET_KEY corect.",
"install.reinstall_confirm_check_2": "Arhivele și setările ar putea avea nevoie să fie resincronizate. Bifând această casetă, confirmi că vei resincroniza manual cârligele pentru proiecte și fișierul authorized_keys. Confirmi că vei asigura că setările pentru proiecte și oglindire sunt corecte.",
"install.reinstall_confirm_check_3": "Confirmă că ești absolut sigur că acest Gitea rulează cu locația corectă a app.ini și că ești sigur că trebuie să reinstalezi. Confirmă că înțelegi riscurile de mai sus.",
"install.recovery_source_title": "Sursa de recuperare",
"install.recovery_source_existing_database": "Folosește baza de date existentă",
"install.recovery_source_existing_database_helper": "Continuă cu baza de date Gitea configurată în prezent atunci când aceasta există deja și este încă utilizabilă.",
"install.recovery_source_database_backup": "Restaurează dintr-un backup al bazei de date",
"install.recovery_source_database_backup_helper": "Continuă cu o bază de date nouă sau goală și restaureaz-o dintr-un bundle de backup generat anterior, care se potrivește cu tipul de bază de date selectat.",
"install.recovery_source_database_backup_unavailable": "Restaurarea din backup-ul bazei de date nu este încă disponibilă în acest installer.",
"install.recovery_source_repository_filesystem": "Recuperează din structura de fișiere a arhivelor",
"install.recovery_source_repository_filesystem_helper": "Continuă cu o bază de date nouă și recuperează ulterior arhivele din directoarele de proiecte existente pe disc.",
"install.recovery_database_backup_error": "Au fost detectate bundle-uri de backup compatibile cu tipul de bază de date selectat",
"install.recovery_database_backup_message": "Restaurarea dintr-un backup al bazei de date poate readuce cea mai mare parte a stării originale a instalării, dar va suprascrie conținutul bazei de date țintă. Dacă știi ce faci, confirmă următoarele:",
"install.recovery_database_backup_select": "Bundle backup bază de date",
"install.recovery_database_backup_select_helper": "Selectează bundle-ul de backup care trebuie restaurat în baza de date configurată în prezent.",
"install.recovery_database_backup_confirm_check_1": "Confirmi că backupul selectat aparține acestei instanțe Gitea și corespunde tipului de bază de date selectat în prezent.",
"install.recovery_database_backup_confirm_check_2": "Confirmi că baza de date țintă este nouă sau goală și că procesul de restaurare îi poate suprascrie conținutul actual.",
"install.recovery_database_backup_confirm_check_3": "Confirmi că vei verifica instalarea restaurată după setup și că înțelegi că datele mai noi decât backupul selectat nu vor fi recuperate.",
"install.recovery_database_backup_discovery_failed": "Inspectarea căii configurate pentru backupurile bazei de date a eșuat: %v",
"install.recovery_database_backup_restore_failed": "Restaurarea backupului bazei de date selectat a eșuat: %v",
"install.recovery_repository_filesystem_error": "Au fost detectate date de proiect existente în calea configurată pentru arhive",
"install.recovery_repository_filesystem_message": "Recuperarea din structura de fișiere a arhivelor poate restaura proiectele, dar unele date de cont și din baza de date se pot pierde. Dacă știi ce faci, confirmă următoarele:",
"install.recovery_repository_filesystem_confirm_check_1": "Confirmi că calea actuală a arhivelor indică structura originală de proiecte pe care vrei să o recuperezi.",
"install.recovery_repository_filesystem_confirm_check_2": "Confirmi că înțelegi că această cale de recuperare poate pierde date existente doar în baza de date, cum ar fi conturi, sesiuni, tichete, pull request-uri, comentarii, tokenuri și notificări.",
"install.recovery_repository_filesystem_confirm_check_3": "Confirmi că vei verifica și adopta manual proiectele recuperate după instalare și că accepți limitele recuperării bazate pe structura de fișiere.",
"install.err_empty_db_path": "Calea bazei de date SQLite3 nu poate fi goală.",
"install.no_admin_and_disable_registration": "Nu poți dezactiva auto-înregistrarea utilizatorului fără a crea un cont de super administrator.",
"install.err_empty_admin_password": "Parola de super administrator nu poate fi goală.",
@@ -3155,6 +3193,7 @@
"admin.dashboard.gc_times": "Timpi GC",
"admin.dashboard.delete_old_actions": "Șterge toate activitățile vechi din baza de date",
"admin.dashboard.delete_old_actions.started": "A început ștergerea tuturor activităților vechi din baza de date",
"admin.dashboard.database_backup": "Creează un backup al bazei de date",
"admin.dashboard.update_checker": "Verificare actualizări",
"admin.dashboard.delete_old_system_notices": "Șterge toate notificările de sistem vechi, din baza de date",
"admin.dashboard.gc_lfs": "Meta-obiecte LFS de colectare a gunoiului",
+199 -12
View File
@@ -7,8 +7,8 @@ package install
import (
"bytes"
"errors"
"fmt"
"image"
_ "image/png"
"io"
"net/http"
"net/mail"
@@ -23,6 +23,8 @@ import (
"strings"
"time"
_ "image/png"
"code.gitea.io/gitea/models/db"
db_install "code.gitea.io/gitea/models/db/install"
user_model "code.gitea.io/gitea/models/user"
@@ -40,6 +42,7 @@ import (
"code.gitea.io/gitea/routers/common"
auth_service "code.gitea.io/gitea/services/auth"
"code.gitea.io/gitea/services/context"
dbbackup_service "code.gitea.io/gitea/services/dbbackup"
"code.gitea.io/gitea/services/forms"
"code.gitea.io/gitea/services/mailer"
"code.gitea.io/gitea/services/versioned_migration"
@@ -58,6 +61,10 @@ const (
installBrandingMaxFileSize = int64(1 << 20)
installBrandingMinPNGEdge = 64
installAppINIImportMaxSize = int64(1 << 20)
recoveryModeExistingDatabase = "existing_database"
recoveryModeDatabaseBackup = "database_backup"
recoveryModeRepositoryFileSystem = "repository_filesystem"
)
type installBrandingAssetSpec struct {
@@ -67,6 +74,14 @@ type installBrandingAssetSpec struct {
MimeType string
}
// start edit/add - by petru @ codex
type installBackupChoice struct {
ID string
Label string
}
// end edit/add - by petru @ codex
var installBrandingAssetSpecs = []installBrandingAssetSpec{
{FormField: "logo_svg", TargetName: "logo.svg", LabelKey: "install.branding.logo_svg", MimeType: typesniffer.MimeTypeImageSvg},
{FormField: "logo_png", TargetName: "logo.png", LabelKey: "install.branding.logo_png", MimeType: "image/png"},
@@ -367,6 +382,16 @@ func newInstallFormFromSettings() (forms.InstallForm, string) {
form.AdminManagementPolicy = setting.Admin.AdminManagementPolicy
form.ReleaseMaxFiles = setting.Repository.Release.MaxFiles
form.ReleaseFileMaxSize = setting.Repository.Release.FileMaxSize
// start edit/add - by petru @ codex
form.BackupPath = setting.Backup.Path
form.BackupRetentionCount = setting.Backup.RetentionCount
form.BackupCompress = setting.Backup.Compress
form.BackupIncludeAppINISnapshot = setting.Backup.IncludeAppINISnapshot
backupCronSec := setting.CfgProvider.Section("cron.database_backup")
form.BackupEnabled = setting.ConfigSectionKeyBool(backupCronSec, "ENABLED", true)
form.BackupRunAtStart = setting.ConfigSectionKeyBool(backupCronSec, "RUN_AT_START", false)
form.BackupSchedule = setting.ConfigSectionKeyString(backupCronSec, "SCHEDULE", "@daily")
// end edit/add - by petru @ codex
form.AllowAdoptionOfUnadoptedRepositories = true // edit/add - by petru @ codex
form.AllowDeleteOfUnadoptedRepositories = true // edit/add - by petru @ codex
normalizeInstallRegistrationOptions(&form)
@@ -385,7 +410,80 @@ func renderInstallPage(ctx *context.Context, form *forms.InstallForm, curDBType
ctx.HTML(http.StatusOK, tplInstall)
}
func importedDBType(dbType string, fallback string) string {
// start edit/add - by petru @ codex
func resolveInstallBackupPath(backupPath string) string {
backupPath = strings.TrimSpace(backupPath)
if backupPath == "" {
return ""
}
if filepath.IsAbs(backupPath) {
return backupPath
}
return filepath.Join(setting.AppWorkPath, backupPath)
}
func listInstallBackupChoices(backupPath, dbType string) ([]*installBackupChoice, error) {
backups, err := dbbackup_service.ListBackupsInPath(resolveInstallBackupPath(backupPath))
if err != nil {
return nil, err
}
choices := make([]*installBackupChoice, 0, len(backups))
for _, backup := range backups {
if dbType != "" && backup.DBType != dbType {
continue
}
choices = append(choices, &installBackupChoice{
ID: backup.ID,
Label: fmt.Sprintf("%s (%s)", backup.ID, backup.DBType),
})
}
return choices, nil
}
func hasInstallRepositoryFilesystem(repoRoot string) bool {
entries, err := os.ReadDir(repoRoot)
if err != nil {
return false
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
if strings.HasSuffix(entry.Name(), ".git") {
return true
}
ownerPath := filepath.Join(repoRoot, entry.Name())
ownerEntries, err := os.ReadDir(ownerPath)
if err != nil {
continue
}
for _, ownerEntry := range ownerEntries {
if ownerEntry.IsDir() && strings.HasSuffix(ownerEntry.Name(), ".git") {
return true
}
}
}
return false
}
func setInstallRecoverySourceAvailability(ctx *context.Context, hasExistingDatabase bool, availableBackups []*installBackupChoice, hasRepositoryFilesystem bool) {
ctx.Data["InstallRecoveryOptionExistingDBAvailable"] = hasExistingDatabase
ctx.Data["InstallRecoveryOptionDatabaseBackupAvailable"] = len(availableBackups) > 0
ctx.Data["InstallRecoveryOptionRepositoryFilesystemAvailable"] = hasRepositoryFilesystem
ctx.Data["InstallRecoveryBackups"] = availableBackups
}
func installRecoveryConfirmationsAccepted(form *forms.InstallForm) bool {
return form.ReinstallConfirmFirst && form.ReinstallConfirmSecond && form.ReinstallConfirmThird
}
// end edit/add - by petru @ codex
func importedDBType(dbType, fallback string) string {
dbType = strings.TrimSpace(strings.ToLower(dbType))
if slices.Contains(setting.SupportedDatabaseTypes, dbType) {
return dbType
@@ -409,7 +507,7 @@ func importedInt64(sec setting.ConfigSection, key string, fallback int64) int64
return cfgKey.MustInt64(fallback)
}
func importedFirstLang(value string, fallback string) string {
func importedFirstLang(value, fallback string) string {
for _, lang := range strings.Split(value, ",") {
lang = strings.TrimSpace(lang)
if lang != "" {
@@ -433,6 +531,8 @@ func populateInstallFormFromConfig(form *forms.InstallForm, cfg setting.ConfigPr
adminSec := cfg.Section("admin")
i18nSec := cfg.Section("i18n")
repoReleaseSec := cfg.Section("repository.release")
backupSec := cfg.Section("backup") // edit/add - by petru @ codex
backupCronSec := cfg.Section("cron.database_backup") // edit/add - by petru @ codex
form.AppName = setting.ConfigSectionKeyString(rootSec, "APP_NAME", form.AppName)
form.RunUser = setting.ConfigSectionKeyString(rootSec, "RUN_USER", form.RunUser)
@@ -499,6 +599,15 @@ func populateInstallFormFromConfig(form *forms.InstallForm, cfg setting.ConfigPr
form.AdminManagementPolicy = setting.ConfigSectionKeyString(adminSec, "ADMIN_MANAGEMENT_POLICY", form.AdminManagementPolicy)
form.ReleaseMaxFiles = importedInt64(repoReleaseSec, "MAX_FILES", form.ReleaseMaxFiles)
form.ReleaseFileMaxSize = importedInt64(repoReleaseSec, "FILE_MAX_SIZE", form.ReleaseFileMaxSize)
// start edit/add - by petru @ codex
form.BackupPath = setting.ConfigSectionKeyString(backupSec, "PATH", form.BackupPath)
form.BackupRetentionCount = importedInt(backupSec, "RETENTION_COUNT", form.BackupRetentionCount)
form.BackupCompress = setting.ConfigSectionKeyBool(backupSec, "COMPRESS", form.BackupCompress)
form.BackupIncludeAppINISnapshot = setting.ConfigSectionKeyBool(backupSec, "INCLUDE_APP_INI_SNAPSHOT", form.BackupIncludeAppINISnapshot)
form.BackupEnabled = setting.ConfigSectionKeyBool(backupCronSec, "ENABLED", form.BackupEnabled)
form.BackupRunAtStart = setting.ConfigSectionKeyBool(backupCronSec, "RUN_AT_START", form.BackupRunAtStart)
form.BackupSchedule = setting.ConfigSectionKeyString(backupCronSec, "SCHEDULE", form.BackupSchedule)
// end edit/add - by petru @ codex
if form.ImportSensitiveSecrets {
form.ImportedLFSJWTSecret = readImportedSecretValue(serverSec, "LFS_JWT_SECRET_URI", "LFS_JWT_SECRET")
form.ImportedInternalToken = readImportedSecretValue(securitySec, "INTERNAL_TOKEN_URI", "INTERNAL_TOKEN")
@@ -652,7 +761,7 @@ func ImportAppINI(ctx *context.Context) {
form, curDBType := newInstallFormFromSettings()
form.ImportSensitiveSecrets = true // edit/add - by petru @ codex
form.ImportedAppINI = true // edit/add - by petru @ codex
form.ImportedAppINI = true // edit/add - by petru @ codex
cfg, err := readImportedInstallConfig(ctx)
if err != nil {
ctx.Data["CurDbType"] = curDBType
@@ -734,7 +843,7 @@ func populateInstallSMTPFromFields(form *forms.InstallForm) {
// start edit/add - by petru @ codex
func applyImportedAppINIRepositoryRecoveryDefaults(form *forms.InstallForm, dbPreviouslyUsed bool) {
if form.ImportedAppINI && !dbPreviouslyUsed {
if form.ImportedAppINI && !dbPreviouslyUsed && form.RecoveryMode == recoveryModeRepositoryFileSystem {
form.AllowAdoptionOfUnadoptedRepositories = true
form.AllowDeleteOfUnadoptedRepositories = true
}
@@ -894,8 +1003,11 @@ func checkDatabase(ctx *context.Context, form *forms.InstallForm) (bool, bool) {
if hasPostInstallationUser && dbMigrationVersion > 0 {
log.Error("The database is likely to have been used by Gitea before, database migration version=%d", dbMigrationVersion)
confirmed := form.ReinstallConfirmFirst && form.ReinstallConfirmSecond && form.ReinstallConfirmThird
if !confirmed {
setInstallRecoverySourceAvailability(ctx, true, nil, false) // edit/add - by petru @ codex
if form.RecoveryMode == "" {
form.RecoveryMode = recoveryModeExistingDatabase
}
if form.RecoveryMode != recoveryModeExistingDatabase || !installRecoveryConfirmationsAccepted(form) {
ctx.Data["Err_DbInstalledBefore"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("install.reinstall_error"), tplInstall, form)
return false, dbPreviouslyUsed
@@ -999,6 +1111,54 @@ func SubmitInstall(ctx *context.Context) {
return
}
// start edit/add - by petru @ codex
hasRepositoryFilesystem := form.ImportedAppINI && hasInstallRepositoryFilesystem(form.RepoRootPath)
availableBackups, err := listInstallBackupChoices(form.BackupPath, form.DbType)
if err != nil {
ctx.Data["Err_BackupPath"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("install.recovery_database_backup_discovery_failed", err), tplInstall, &form)
return
}
hasDatabaseBackups := len(availableBackups) > 0
if form.BackupRestoreID == "" && hasDatabaseBackups {
form.BackupRestoreID = availableBackups[0].ID
}
if form.RecoveryMode == recoveryModeDatabaseBackup && !hasDatabaseBackups {
form.RecoveryMode = "" // edit/add - by petru @ codex
}
if !dbPreviouslyUsed && (hasRepositoryFilesystem || hasDatabaseBackups) {
setInstallRecoverySourceAvailability(ctx, false, availableBackups, hasRepositoryFilesystem)
if form.RecoveryMode == "" {
if hasDatabaseBackups {
form.RecoveryMode = recoveryModeDatabaseBackup
} else {
form.RecoveryMode = recoveryModeRepositoryFileSystem
}
}
recoverySelectionValid := installRecoveryConfirmationsAccepted(&form)
switch form.RecoveryMode {
case recoveryModeDatabaseBackup:
recoverySelectionValid = recoverySelectionValid && form.BackupRestoreID != ""
case recoveryModeRepositoryFileSystem:
// nothing extra
default:
recoverySelectionValid = false
}
if !recoverySelectionValid {
if form.RecoveryMode == recoveryModeDatabaseBackup && hasDatabaseBackups {
ctx.Data["Err_DatabaseBackupRecovery"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("install.recovery_database_backup_error"), tplInstall, &form)
return
}
ctx.Data["Err_RepositoryFilesystemRecovery"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("install.recovery_repository_filesystem_error"), tplInstall, &form)
return
}
}
// end edit/add - by petru @ codex
// Test LFS root path if not empty, empty meaning disable LFS
if form.LFSRootPath != "" {
form.LFSRootPath = strings.ReplaceAll(form.LFSRootPath, "\\", "/")
@@ -1070,13 +1230,31 @@ func SubmitInstall(ctx *context.Context) {
return
}
// Init the engine with migration
if err = db.InitEngineWithMigration(ctx, versioned_migration.Migrate); err != nil {
// start edit/add - by petru @ codex
if !dbPreviouslyUsed && form.RecoveryMode == recoveryModeDatabaseBackup {
if err = db.InitEngine(ctx); err != nil {
db.UnsetDefaultEngine()
ctx.Data["Err_DbSetting"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("install.invalid_db_setting", err), tplInstall, &form)
return
}
if _, err = dbbackup_service.RestoreDatabaseBackup(ctx, resolveInstallBackupPath(form.BackupPath), form.BackupRestoreID, form.DbType); err != nil {
db.UnsetDefaultEngine()
ctx.Data["Err_DatabaseBackupRecovery"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("install.recovery_database_backup_restore_failed", err), tplInstall, &form)
return
}
db.UnsetDefaultEngine()
ctx.Data["Err_DbSetting"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("install.invalid_db_setting", err), tplInstall, &form)
return
} else {
// Init the engine with migration
if err = db.InitEngineWithMigration(ctx, versioned_migration.Migrate); err != nil {
db.UnsetDefaultEngine()
ctx.Data["Err_DbSetting"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("install.invalid_db_setting", err), tplInstall, &form)
return
}
}
// end edit/add - by petru @ codex
// Save settings.
cfg, err := setting.NewConfigProviderFromFile(setting.CustomConf)
@@ -1105,6 +1283,15 @@ func SubmitInstall(ctx *context.Context) {
cfg.Section("repository").Key("ALLOW_DELETE_OF_UNADOPTED_REPOSITORIES").SetValue(strconv.FormatBool(form.AllowDeleteOfUnadoptedRepositories)) // edit/add - by petru @ codex
cfg.Section("repository.release").Key("MAX_FILES").SetValue(strconv.FormatInt(form.ReleaseMaxFiles, 10))
cfg.Section("repository.release").Key("FILE_MAX_SIZE").SetValue(strconv.FormatInt(form.ReleaseFileMaxSize, 10))
// start edit/add - by petru @ codex
cfg.Section("backup").Key("PATH").SetValue(form.BackupPath)
cfg.Section("backup").Key("RETENTION_COUNT").SetValue(strconv.Itoa(form.BackupRetentionCount))
cfg.Section("backup").Key("COMPRESS").SetValue(strconv.FormatBool(form.BackupCompress))
cfg.Section("backup").Key("INCLUDE_APP_INI_SNAPSHOT").SetValue(strconv.FormatBool(form.BackupIncludeAppINISnapshot))
cfg.Section("cron.database_backup").Key("ENABLED").SetValue(strconv.FormatBool(form.BackupEnabled))
cfg.Section("cron.database_backup").Key("RUN_AT_START").SetValue(strconv.FormatBool(form.BackupRunAtStart))
cfg.Section("cron.database_backup").Key("SCHEDULE").SetValue(form.BackupSchedule)
// end edit/add - by petru @ codex
cfg.Section("server").Key("SSH_DOMAIN").SetValue(form.Domain)
cfg.Section("server").Key("DOMAIN").SetValue(form.Domain)
cfg.Section("server").Key("HTTP_PORT").SetValue(form.HTTPPort)
+111
View File
@@ -5,6 +5,7 @@ package install
import (
"bytes"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
@@ -140,6 +141,17 @@ ADMIN_MANAGEMENT_POLICY = super_admin_only
MAX_FILES = 20
FILE_MAX_SIZE = 2048
[backup]
PATH = /srv/gitea/backups/db
RETENTION_COUNT = 9
COMPRESS = false
INCLUDE_APP_INI_SNAPSHOT = false
[cron.database_backup]
ENABLED = true
RUN_AT_START = true
SCHEDULE = @every 12h
[i18n]
LANGS = de-DE,en-US
`)
@@ -193,12 +205,35 @@ LANGS = de-DE,en-US
assert.Equal(t, "super_admin_only", form.AdminManagementPolicy)
assert.EqualValues(t, 20, form.ReleaseMaxFiles)
assert.EqualValues(t, 2048, form.ReleaseFileMaxSize)
assert.True(t, form.BackupEnabled)
assert.True(t, form.BackupRunAtStart)
assert.Equal(t, "@every 12h", form.BackupSchedule)
assert.Equal(t, "/srv/gitea/backups/db", form.BackupPath)
assert.Equal(t, 9, form.BackupRetentionCount)
assert.False(t, form.BackupCompress)
assert.False(t, form.BackupIncludeAppINISnapshot)
}
// start edit/add - by petru @ codex
func TestNewInstallFormFromSettingsBackupDefaults(t *testing.T) {
cfg, err := setting.NewConfigProviderFromData("")
require.NoError(t, err)
defer test.MockVariableValue(&setting.CfgProvider, cfg)()
form, _ := newInstallFormFromSettings()
assert.True(t, form.BackupEnabled)
assert.False(t, form.BackupRunAtStart)
assert.Equal(t, "@daily", form.BackupSchedule)
}
// end edit/add - by petru @ codex
// start edit/add - by petru @ codex
func TestApplyImportedAppINIRepositoryRecoveryDefaults(t *testing.T) {
form := forms.InstallForm{
ImportedAppINI: true,
RecoveryMode: recoveryModeRepositoryFileSystem,
AllowAdoptionOfUnadoptedRepositories: false,
AllowDeleteOfUnadoptedRepositories: false,
}
@@ -209,6 +244,7 @@ func TestApplyImportedAppINIRepositoryRecoveryDefaults(t *testing.T) {
form = forms.InstallForm{
ImportedAppINI: true,
RecoveryMode: recoveryModeRepositoryFileSystem,
AllowAdoptionOfUnadoptedRepositories: false,
AllowDeleteOfUnadoptedRepositories: false,
}
@@ -218,6 +254,81 @@ func TestApplyImportedAppINIRepositoryRecoveryDefaults(t *testing.T) {
assert.False(t, form.AllowDeleteOfUnadoptedRepositories)
}
func TestApplyImportedAppINIRepositoryRecoveryDefaultsWithoutFilesystemRecoveryMode(t *testing.T) {
form := forms.InstallForm{
ImportedAppINI: true,
RecoveryMode: recoveryModeExistingDatabase,
AllowAdoptionOfUnadoptedRepositories: false,
AllowDeleteOfUnadoptedRepositories: false,
}
applyImportedAppINIRepositoryRecoveryDefaults(&form, false)
assert.False(t, form.AllowAdoptionOfUnadoptedRepositories)
assert.False(t, form.AllowDeleteOfUnadoptedRepositories)
}
func TestHasInstallRepositoryFilesystem(t *testing.T) {
tmpDir := t.TempDir()
assert.False(t, hasInstallRepositoryFilesystem(tmpDir))
require.NoError(t, os.Mkdir(filepath.Join(tmpDir, "owner"), 0o755))
require.NoError(t, os.Mkdir(filepath.Join(tmpDir, "owner", "repo.git"), 0o755))
assert.True(t, hasInstallRepositoryFilesystem(tmpDir))
tmpDirFlat := t.TempDir()
require.NoError(t, os.Mkdir(filepath.Join(tmpDirFlat, "repo.git"), 0o755))
assert.True(t, hasInstallRepositoryFilesystem(tmpDirFlat))
}
func TestResolveInstallBackupPath(t *testing.T) {
defer test.MockVariableValue(&setting.AppWorkPath, "/srv/gitea")()
assert.Equal(t, "", resolveInstallBackupPath(""))
assert.Equal(t, "/srv/gitea/data/backups/db", resolveInstallBackupPath("data/backups/db"))
assert.Equal(t, "/var/lib/gitea/backups", resolveInstallBackupPath("/var/lib/gitea/backups"))
}
func TestListInstallBackupChoices(t *testing.T) {
tmpDir := t.TempDir()
writeInstallBackupManifestForTest(t, tmpDir, "20260524-010101.000000001", "sqlite3", 100)
writeInstallBackupManifestForTest(t, tmpDir, "20260524-010102.000000002", "mysql", 200)
writeInstallBackupManifestForTest(t, tmpDir, "20260524-010103.000000003", "sqlite3", 300)
choices, err := listInstallBackupChoices(tmpDir, "sqlite3")
require.NoError(t, err)
require.Len(t, choices, 2)
assert.Equal(t, "20260524-010103.000000003", choices[0].ID)
assert.Equal(t, "20260524-010101.000000001", choices[1].ID)
assert.Equal(t, "20260524-010103.000000003 (sqlite3)", choices[0].Label)
}
// end edit/add - by petru @ codex
// start edit/add - by petru @ codex
func writeInstallBackupManifestForTest(t *testing.T, rootPath, backupID, dbType string, createdUnix int64) {
t.Helper()
backupDir := filepath.Join(rootPath, backupID)
require.NoError(t, os.MkdirAll(backupDir, 0o755))
manifestBytes, err := json.Marshal(map[string]any{
"schema_version": 1,
"id": backupID,
"created_unix": createdUnix,
"db_type": dbType,
"app_version": "dev",
"app_name": "Test",
"migration_version": 1,
"file_name": "database.sql.gz",
"file_sha256": "abc",
"file_size": 1,
"compressed": true,
})
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(backupDir, "manifest.json"), manifestBytes, 0o644))
}
// end edit/add - by petru @ codex
func TestPopulateInstallFormFromConfigReplacesSMTPFromSplitFields(t *testing.T) {
+1 -1
View File
@@ -1071,7 +1071,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Post("", web.Bind(forms.BlockUserForm{}), org.BlockedUsersPost)
})
m.Group("/repos", func() {
m.Get("", org.Repos) // edit/add - by petru @ codex
m.Get("", org.Repos) // edit/add - by petru @ codex
m.Post("/unadopted", org.AdoptOrDeleteRepository) // edit/add - by petru @ codex
})
}, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled, "PageIsOrgSettings", true))
+1 -1
View File
@@ -100,7 +100,7 @@ func ListTasks() TaskTable {
tTable := make([]*TaskTableRow, 0, len(tasks))
for _, task := range tasks {
spec := "-"
spec := task.config.GetSchedule() // edit/add - by petru @ codex
var (
next time.Time
prev time.Time
+17
View File
@@ -15,6 +15,7 @@ import (
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/updatechecker"
asymkey_service "code.gitea.io/gitea/services/asymkey"
dbbackup_service "code.gitea.io/gitea/services/dbbackup" // edit/add - by petru @ codex
repo_service "code.gitea.io/gitea/services/repository"
archiver_service "code.gitea.io/gitea/services/repository/archiver"
user_service "code.gitea.io/gitea/services/user"
@@ -234,6 +235,21 @@ func registerRebuildIssueIndexer() {
})
}
// start edit/add - by petru @ codex
func registerDatabaseBackup() {
RegisterTaskFatal("database_backup", &BaseConfig{
Enabled: true,
RunAtStart: false,
Schedule: "@daily",
NoticeOnSuccess: false,
}, func(ctx context.Context, _ *user_model.User, _ Config) error {
_, _, err := dbbackup_service.CreateDatabaseBackup(ctx)
return err
})
}
// end edit/add - by petru @ codex
func initExtendedTasks() {
registerDeleteInactiveUsers()
registerDeleteExpiredAccountRequests()
@@ -250,4 +266,5 @@ func initExtendedTasks() {
registerDeleteOldSystemNotices()
registerGCLFS()
registerRebuildIssueIndexer()
registerDatabaseBackup() // edit/add - by petru @ codex
}
+390
View File
@@ -0,0 +1,390 @@
// 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 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
+91
View File
@@ -0,0 +1,91 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package dbbackup
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// start edit/add - by petru @ codex
func TestListBackups(t *testing.T) {
tmpDir := t.TempDir()
defer test.MockVariableValue(&setting.Backup.Path, tmpDir)()
writeBackupManifestForTest(t, tmpDir, "20260524-010101.000000001", 100)
writeBackupManifestForTest(t, tmpDir, "20260524-010102.000000002", 200)
backups, err := ListBackups()
require.NoError(t, err)
require.Len(t, backups, 2)
assert.Equal(t, "20260524-010102.000000002", backups[0].ID)
assert.Equal(t, "20260524-010101.000000001", backups[1].ID)
}
func TestPruneOldBackups(t *testing.T) {
tmpDir := t.TempDir()
defer test.MockVariableValue(&setting.Backup.Path, tmpDir)()
writeBackupManifestForTest(t, tmpDir, "20260524-010101.000000001", 100)
writeBackupManifestForTest(t, tmpDir, "20260524-010102.000000002", 200)
writeBackupManifestForTest(t, tmpDir, "20260524-010103.000000003", 300)
require.NoError(t, PruneOldBackups(2))
backups, err := ListBackups()
require.NoError(t, err)
require.Len(t, backups, 2)
assert.Equal(t, int64(300), backups[0].CreatedUnix)
assert.Equal(t, int64(200), backups[1].CreatedUnix)
assert.NoDirExists(t, filepath.Join(tmpDir, "20260524-010101.000000001"))
}
func TestPruneOldBackupsRetentionGreaterThanBackupCount(t *testing.T) {
tmpDir := t.TempDir()
defer test.MockVariableValue(&setting.Backup.Path, tmpDir)()
writeBackupManifestForTest(t, tmpDir, "20260524-010101.000000001", 100)
writeBackupManifestForTest(t, tmpDir, "20260524-010102.000000002", 200)
require.NoError(t, PruneOldBackups(7))
backups, err := ListBackups()
require.NoError(t, err)
require.Len(t, backups, 2)
assert.DirExists(t, filepath.Join(tmpDir, "20260524-010101.000000001"))
assert.DirExists(t, filepath.Join(tmpDir, "20260524-010102.000000002"))
}
func writeBackupManifestForTest(t *testing.T, rootPath, backupID string, createdUnix int64) {
t.Helper()
backupDir := filepath.Join(rootPath, backupID)
require.NoError(t, os.MkdirAll(backupDir, 0o755))
manifestBytes, err := json.Marshal(&Manifest{
SchemaVersion: backupManifestSchemaValue,
ID: backupID,
CreatedUnix: createdUnix,
DBType: "sqlite3",
AppVersion: "dev",
AppName: "Test",
MigrationVersion: 1,
FileName: databaseDumpGzipFileName,
FileSHA256: "abc",
FileSize: 1,
Compressed: true,
})
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(backupDir, manifestFileName), manifestBytes, 0o644))
}
// end edit/add - by petru @ codex
+11 -2
View File
@@ -50,6 +50,8 @@ type InstallForm struct {
ImportedLFSJWTSecret string `form:"imported_lfs_jwt_secret"`
ImportedInternalToken string `form:"imported_internal_token"`
ImportedOAuth2JWTSecret string `form:"imported_o_auth2_jwt_secret"`
RecoveryMode string `binding:"In(,existing_database,database_backup,repository_filesystem)" locale:"install.recovery_source_title"` // edit/add - by petru @ codex
BackupRestoreID string // edit/add - by petru @ codex
RegisterConfirm bool
RegisterManualConfirm bool
MailNotify bool
@@ -74,8 +76,15 @@ type InstallForm struct {
AdminManagementPolicy string `binding:"In(,super_admin_only,grantor_only,grantor_inheritance)" locale:"install.admin_management_policy"`
ReleaseMaxFiles int64
ReleaseFileMaxSize int64
AllowAdoptionOfUnadoptedRepositories bool // edit/add - by petru @ codex
AllowDeleteOfUnadoptedRepositories bool // edit/add - by petru @ codex
BackupEnabled bool // edit/add - by petru @ codex
BackupRunAtStart bool // edit/add - by petru @ codex
BackupSchedule string // edit/add - by petru @ codex
BackupPath string // edit/add - by petru @ codex
BackupRetentionCount int // edit/add - by petru @ codex
BackupCompress bool // edit/add - by petru @ codex
BackupIncludeAppINISnapshot bool // edit/add - by petru @ codex
AllowAdoptionOfUnadoptedRepositories bool // edit/add - by petru @ codex
AllowDeleteOfUnadoptedRepositories bool // edit/add - by petru @ codex
AdminName string `binding:"OmitEmpty;Username;MaxSize(30)" locale:"install.admin_name"`
AdminPasswd string `binding:"OmitEmpty;MaxSize(255)" locale:"install.admin_password"`
+6
View File
@@ -58,6 +58,12 @@
<td>{{ctx.Locale.Tr "admin.dashboard.delete_generated_repository_avatars"}}</td>
<td class="tw-text-right"><button type="submit" class="ui primary button" name="op" value="delete_generated_repository_avatars">{{svg "octicon-play"}} {{ctx.Locale.Tr "admin.dashboard.operation_run"}}</button></td>
</tr>
<!-- start edit/add - by petru @ codex -->
<tr>
<td>{{ctx.Locale.Tr "admin.dashboard.database_backup"}}</td>
<td class="tw-text-right"><button type="submit" class="ui primary button" name="op" value="database_backup">{{svg "octicon-play"}} {{ctx.Locale.Tr "admin.dashboard.operation_run"}}</button></td>
</tr>
<!-- end edit/add - by petru @ codex -->
<tr>
<td>{{ctx.Locale.Tr "admin.dashboard.sync_repo_branches"}}</td>
<td class="tw-text-right"><button type="submit" class="ui primary button" name="op" value="sync_repo_branches">{{svg "octicon-play"}} {{ctx.Locale.Tr "admin.dashboard.operation_run"}}</button></td>
+259 -19
View File
@@ -6,7 +6,7 @@
{{ctx.Locale.Tr "install.title"}}
</h3>
<div class="ui attached segment">
{{if not .Err_DbInstalledBefore}}{{template "base/alert" .}}{{end}} <!-- edit/add - by petru @ codex -->
{{if not (or .Err_DbInstalledBefore .Err_RepositoryFilesystemRecovery .Err_DatabaseBackupRecovery)}}{{template "base/alert" .}}{{end}} <!-- edit/add - by petru @ codex -->
<p>{{ctx.Locale.Tr "install.docker_helper" "https://docs.gitea.com/installation/install-with-docker"}}</p>
@@ -96,6 +96,57 @@
<span class="help">{{ctx.Locale.Tr "install.sqlite_helper"}}</span>
</div>
</div>
<!-- start edit/add - by petru @ codex -->
<details class="optional field">
<summary class="right-content tw-py-2">
{{ctx.Locale.Tr "install.database_backup_title"}}
</summary>
<p>{{ctx.Locale.Tr "install.database_backup_desc"}}</p>
<div class="inline field">
<div class="ui checkbox">
<label>{{ctx.Locale.Tr "install.database_backup_enabled"}}</label>
<input name="backup_enabled" type="checkbox" {{if .backup_enabled}}checked{{end}}>
</div>
<span class="help">{{ctx.Locale.Tr "install.database_backup_enabled_helper"}}</span>
</div>
<div class="inline field">
<div class="ui checkbox">
<label>{{ctx.Locale.Tr "install.database_backup_run_at_start"}}</label>
<input name="backup_run_at_start" type="checkbox" {{if .backup_run_at_start}}checked{{end}}>
</div>
<span class="help">{{ctx.Locale.Tr "install.database_backup_run_at_start_helper"}}</span>
</div>
<div class="inline field">
<label for="backup_schedule">{{ctx.Locale.Tr "install.database_backup_schedule"}}</label>
<input id="backup_schedule" name="backup_schedule" value="{{.backup_schedule}}" placeholder="@daily">
<span class="help">{{ctx.Locale.Tr "install.database_backup_schedule_helper"}}</span>
</div>
<div class="inline field">
<label for="backup_path">{{ctx.Locale.Tr "install.database_backup_path"}}</label>
<input id="backup_path" name="backup_path" value="{{.backup_path}}">
<span class="help">{{ctx.Locale.Tr "install.database_backup_path_helper"}}</span>
</div>
<div class="inline field">
<label for="backup_retention_count">{{ctx.Locale.Tr "install.database_backup_retention_count"}}</label>
<input id="backup_retention_count" name="backup_retention_count" type="number" min="0" value="{{.backup_retention_count}}">
<span class="help">{{ctx.Locale.Tr "install.database_backup_retention_count_helper"}}</span>
</div>
<div class="inline field">
<div class="ui checkbox">
<label>{{ctx.Locale.Tr "install.database_backup_compress"}}</label>
<input name="backup_compress" type="checkbox" {{if .backup_compress}}checked{{end}}>
</div>
<span class="help">{{ctx.Locale.Tr "install.database_backup_compress_helper"}}</span>
</div>
<div class="inline field">
<div class="ui checkbox">
<label>{{ctx.Locale.Tr "install.database_backup_include_app_ini_snapshot"}}</label>
<input name="backup_include_app_ini_snapshot" type="checkbox" {{if .backup_include_app_ini_snapshot}}checked{{end}}>
</div>
<span class="help">{{ctx.Locale.Tr "install.database_backup_include_app_ini_snapshot_helper"}}</span>
</div>
</details>
<!-- end edit/add - by petru @ codex -->
<!-- General Settings -->
<h4 class="ui dividing header">{{ctx.Locale.Tr "install.general_title"}}</h4>
@@ -505,7 +556,7 @@
{{$filePath := HTMLFormat `<span class="ui label">%s</span> <button class="btn interact-fg" data-clipboard-text="%s">%s</button>` .CustomConfFile .CustomConfFile $copyBtn}}
{{ctx.Locale.Tr "install.config_write_file_prompt" $filePath}}
</div>
{{if not .Err_DbInstalledBefore}} <!-- edit/add - by petru @ codex -->
{{if not (or .Err_DbInstalledBefore .Err_RepositoryFilesystemRecovery .Err_DatabaseBackupRecovery)}} <!-- edit/add - by petru @ codex -->
<div class="tw-mt-4 tw-mb-2 tw-text-center">
<button
class="ui primary button js-install-confirm-button"
@@ -519,28 +570,141 @@
</div>
</div>
</div>
{{if .Err_DbInstalledBefore}}
{{if or .Err_DbInstalledBefore .Err_RepositoryFilesystemRecovery .Err_DatabaseBackupRecovery}}
<!-- start edit/add - by petru @ codex -->
<div class="ui small modal install-reinstall-confirm-modal js-install-reinstall-modal">
<div class="ui negative message install-reinstall-alert">{{ctx.Locale.Tr "install.reinstall_error"}}</div>
<div
class="ui negative message install-reinstall-alert js-install-recovery-alert"
data-existing-database-text="{{ctx.Locale.Tr "install.reinstall_error"}}"
data-database-backup-text="{{ctx.Locale.Tr "install.recovery_database_backup_error"}}"
data-repository-filesystem-text="{{ctx.Locale.Tr "install.recovery_repository_filesystem_error"}}"
>{{ctx.Locale.Tr "install.reinstall_error"}}</div>
<div class="content">
<p class="reinstall-message">{{ctx.Locale.Tr "install.reinstall_confirm_message"}}</p>
<div class="reinstall-confirm">
<div class="ui checkbox">
<input id="reinstall_confirm_first" name="reinstall_confirm_first" type="checkbox" form="install-form">
<label for="reinstall_confirm_first">{{ctx.Locale.Tr "install.reinstall_confirm_check_1"}}</label>
<div class="recovery-source-block">
<h5 class="ui dividing header">{{ctx.Locale.Tr "install.recovery_source_title"}}</h5>
<div class="recovery-source-option{{if .InstallRecoveryOptionExistingDBAvailable}} is-available{{end}}">
<div class="ui radio checkbox">
<input
id="recovery_mode_existing_database"
name="recovery_mode"
type="radio"
value="existing_database"
form="install-form"
{{if not .InstallRecoveryOptionExistingDBAvailable}}disabled{{end}}
{{if eq .recovery_mode "existing_database"}}checked{{end}}
>
<label for="recovery_mode_existing_database">{{ctx.Locale.Tr "install.recovery_source_existing_database"}}</label>
</div>
<p class="help">{{ctx.Locale.Tr "install.recovery_source_existing_database_helper"}}</p>
</div>
<div class="recovery-source-option{{if .InstallRecoveryOptionDatabaseBackupAvailable}} is-available{{end}}">
<div class="ui radio checkbox{{if not .InstallRecoveryOptionDatabaseBackupAvailable}} disabled{{end}}">
<input
id="recovery_mode_database_backup"
name="recovery_mode"
type="radio"
value="database_backup"
form="install-form"
{{if not .InstallRecoveryOptionDatabaseBackupAvailable}}disabled{{end}}
{{if eq .recovery_mode "database_backup"}}checked{{end}}
>
<label for="recovery_mode_database_backup">{{ctx.Locale.Tr "install.recovery_source_database_backup"}}</label>
</div>
{{if .InstallRecoveryOptionDatabaseBackupAvailable}}
<p class="help">{{ctx.Locale.Tr "install.recovery_source_database_backup_helper"}}</p>
{{else}}
<p class="help">{{ctx.Locale.Tr "install.recovery_source_database_backup_unavailable"}}</p>
{{end}}
</div>
<div class="recovery-source-option{{if .InstallRecoveryOptionRepositoryFilesystemAvailable}} is-available{{end}}">
<div class="ui radio checkbox">
<input
id="recovery_mode_repository_filesystem"
name="recovery_mode"
type="radio"
value="repository_filesystem"
form="install-form"
{{if not .InstallRecoveryOptionRepositoryFilesystemAvailable}}disabled{{end}}
{{if eq .recovery_mode "repository_filesystem"}}checked{{end}}
>
<label for="recovery_mode_repository_filesystem">{{ctx.Locale.Tr "install.recovery_source_repository_filesystem"}}</label>
</div>
<p class="help">{{ctx.Locale.Tr "install.recovery_source_repository_filesystem_helper"}}</p>
</div>
</div>
<div class="reinstall-confirm">
<div class="ui checkbox">
<input id="reinstall_confirm_second" name="reinstall_confirm_second" type="checkbox" form="install-form">
<label for="reinstall_confirm_second">{{ctx.Locale.Tr "install.reinstall_confirm_check_2"}}</label>
<p
class="reinstall-message js-install-recovery-message"
data-existing-database-text="{{ctx.Locale.Tr "install.reinstall_confirm_message"}}"
data-database-backup-text="{{ctx.Locale.Tr "install.recovery_database_backup_message"}}"
data-repository-filesystem-text="{{ctx.Locale.Tr "install.recovery_repository_filesystem_message"}}"
>{{ctx.Locale.Tr "install.reinstall_confirm_message"}}</p>
<div class="js-install-recovery-confirm-panel" data-recovery-mode="existing_database">
<div class="reinstall-confirm">
<div class="ui checkbox">
<input id="reinstall_confirm_first" name="reinstall_confirm_first" type="checkbox" form="install-form" {{if .reinstall_confirm_first}}checked{{end}}>
<label for="reinstall_confirm_first">{{ctx.Locale.Tr "install.reinstall_confirm_check_1"}}</label>
</div>
</div>
<div class="reinstall-confirm">
<div class="ui checkbox">
<input id="reinstall_confirm_second" name="reinstall_confirm_second" type="checkbox" form="install-form" {{if .reinstall_confirm_second}}checked{{end}}>
<label for="reinstall_confirm_second">{{ctx.Locale.Tr "install.reinstall_confirm_check_2"}}</label>
</div>
</div>
<div class="reinstall-confirm">
<div class="ui checkbox">
<input id="reinstall_confirm_third" name="reinstall_confirm_third" type="checkbox" form="install-form" {{if .reinstall_confirm_third}}checked{{end}}>
<label for="reinstall_confirm_third">{{ctx.Locale.Tr "install.reinstall_confirm_check_3"}}</label>
</div>
</div>
</div>
<div class="reinstall-confirm">
<div class="ui checkbox">
<input id="reinstall_confirm_third" name="reinstall_confirm_third" type="checkbox" form="install-form">
<label for="reinstall_confirm_third">{{ctx.Locale.Tr "install.reinstall_confirm_check_3"}}</label>
<div class="js-install-recovery-confirm-panel" data-recovery-mode="database_backup">
<div class="inline field">
<label for="backup_restore_id">{{ctx.Locale.Tr "install.recovery_database_backup_select"}}</label>
<select id="backup_restore_id" name="backup_restore_id" form="install-form">
{{range .InstallRecoveryBackups}}
<option value="{{.ID}}" {{if eq $.backup_restore_id .ID}}selected{{end}}>{{.Label}}</option>
{{end}}
</select>
<span class="help">{{ctx.Locale.Tr "install.recovery_database_backup_select_helper"}}</span>
</div>
<div class="reinstall-confirm">
<div class="ui checkbox">
<input id="database_backup_recovery_confirm_first" name="reinstall_confirm_first" type="checkbox" form="install-form" {{if .reinstall_confirm_first}}checked{{end}}>
<label for="database_backup_recovery_confirm_first">{{ctx.Locale.Tr "install.recovery_database_backup_confirm_check_1"}}</label>
</div>
</div>
<div class="reinstall-confirm">
<div class="ui checkbox">
<input id="database_backup_recovery_confirm_second" name="reinstall_confirm_second" type="checkbox" form="install-form" {{if .reinstall_confirm_second}}checked{{end}}>
<label for="database_backup_recovery_confirm_second">{{ctx.Locale.Tr "install.recovery_database_backup_confirm_check_2"}}</label>
</div>
</div>
<div class="reinstall-confirm">
<div class="ui checkbox">
<input id="database_backup_recovery_confirm_third" name="reinstall_confirm_third" type="checkbox" form="install-form" {{if .reinstall_confirm_third}}checked{{end}}>
<label for="database_backup_recovery_confirm_third">{{ctx.Locale.Tr "install.recovery_database_backup_confirm_check_3"}}</label>
</div>
</div>
</div>
<div class="js-install-recovery-confirm-panel" data-recovery-mode="repository_filesystem">
<div class="reinstall-confirm">
<div class="ui checkbox">
<input id="filesystem_recovery_confirm_first" name="reinstall_confirm_first" type="checkbox" form="install-form" {{if .reinstall_confirm_first}}checked{{end}}>
<label for="filesystem_recovery_confirm_first">{{ctx.Locale.Tr "install.recovery_repository_filesystem_confirm_check_1"}}</label>
</div>
</div>
<div class="reinstall-confirm">
<div class="ui checkbox">
<input id="filesystem_recovery_confirm_second" name="reinstall_confirm_second" type="checkbox" form="install-form" {{if .reinstall_confirm_second}}checked{{end}}>
<label for="filesystem_recovery_confirm_second">{{ctx.Locale.Tr "install.recovery_repository_filesystem_confirm_check_2"}}</label>
</div>
</div>
<div class="reinstall-confirm">
<div class="ui checkbox">
<input id="filesystem_recovery_confirm_third" name="reinstall_confirm_third" type="checkbox" form="install-form" {{if .reinstall_confirm_third}}checked{{end}}>
<label for="filesystem_recovery_confirm_third">{{ctx.Locale.Tr "install.recovery_repository_filesystem_confirm_check_3"}}</label>
</div>
</div>
</div>
</div>
@@ -762,19 +926,95 @@
document.addEventListener('DOMContentLoaded', () => {
dismissInstallSuccessFlash();
{{if .Err_DbInstalledBefore}}
{{if or .Err_DbInstalledBefore .Err_RepositoryFilesystemRecovery .Err_DatabaseBackupRecovery}}
// start edit/add - by petru @ codex
const reinstallModal = document.querySelector('.js-install-reinstall-modal');
if (reinstallModal) {
const recoveryModeInputs = reinstallModal.querySelectorAll('input[name="recovery_mode"]');
const reinstallConfirmInputs = reinstallModal.querySelectorAll('input[name^="reinstall_confirm_"]');
const reinstallConfirmButton = reinstallModal.querySelector('.js-install-confirm-button');
const reinstallAlert = reinstallModal.querySelector('.js-install-recovery-alert');
const reinstallMessage = reinstallModal.querySelector('.js-install-recovery-message');
const reinstallConfirmPanels = reinstallModal.querySelectorAll('.js-install-recovery-confirm-panel');
const getSelectedRecoveryMode = () => {
for (const input of recoveryModeInputs) {
if (input instanceof HTMLInputElement && input.checked) return input.value;
}
return '';
};
const syncReinstallRecoveryPanel = () => {
const selectedRecoveryMode = getSelectedRecoveryMode();
for (const panel of reinstallConfirmPanels) {
if (!(panel instanceof HTMLElement)) continue;
const isActive = panel.dataset.recoveryMode === selectedRecoveryMode;
panel.classList.toggle('is-active', isActive);
for (const field of panel.querySelectorAll('select')) {
if (field instanceof HTMLSelectElement) {
field.disabled = !isActive;
}
}
for (const input of panel.querySelectorAll('input[name^="reinstall_confirm_"]')) {
if (input instanceof HTMLInputElement) {
input.disabled = !isActive;
}
}
}
if (reinstallAlert instanceof HTMLElement) {
const nextText = reinstallAlert.dataset[
selectedRecoveryMode === 'database_backup'
? 'databaseBackupText'
: selectedRecoveryMode === 'repository_filesystem'
? 'repositoryFilesystemText'
: 'existingDatabaseText'
];
if (nextText) reinstallAlert.textContent = nextText;
}
if (reinstallMessage instanceof HTMLElement) {
const nextText = reinstallMessage.dataset[
selectedRecoveryMode === 'database_backup'
? 'databaseBackupText'
: selectedRecoveryMode === 'repository_filesystem'
? 'repositoryFilesystemText'
: 'existingDatabaseText'
];
if (nextText) reinstallMessage.textContent = nextText;
}
};
const syncReinstallConfirmButton = () => {
if (!(reinstallConfirmButton instanceof HTMLButtonElement)) return;
reinstallConfirmButton.disabled = [...reinstallConfirmInputs].some((input) => !(input instanceof HTMLInputElement) || !input.checked);
const selectedRecoveryMode = getSelectedRecoveryMode();
if (!selectedRecoveryMode) {
reinstallConfirmButton.disabled = true;
return;
}
const activePanel = reinstallModal.querySelector(`.js-install-recovery-confirm-panel[data-recovery-mode="${selectedRecoveryMode}"]`);
if (!(activePanel instanceof HTMLElement)) {
reinstallConfirmButton.disabled = true;
return;
}
const activeInputs = activePanel.querySelectorAll('input[name^="reinstall_confirm_"]');
const confirmationsAccepted = ![...activeInputs].some((input) => !(input instanceof HTMLInputElement) || !input.checked);
if (selectedRecoveryMode === 'database_backup') {
const backupSelect = activePanel.querySelector('#backup_restore_id');
reinstallConfirmButton.disabled = !(confirmationsAccepted && backupSelect instanceof HTMLSelectElement && backupSelect.value);
return;
}
reinstallConfirmButton.disabled = !confirmationsAccepted;
};
for (const input of recoveryModeInputs) {
input.addEventListener('change', () => {
syncReinstallRecoveryPanel();
syncReinstallConfirmButton();
});
}
for (const input of reinstallConfirmInputs) {
input.addEventListener('change', syncReinstallConfirmButton);
}
const backupRestoreSelect = reinstallModal.querySelector('#backup_restore_id');
if (backupRestoreSelect instanceof HTMLSelectElement) {
backupRestoreSelect.addEventListener('change', syncReinstallConfirmButton);
}
syncReinstallRecoveryPanel();
syncReinstallConfirmButton();
window.$(reinstallModal).modal({
autofocus: false,
+28
View File
@@ -75,6 +75,34 @@
padding-top: 1.25rem;
}
.page-content.install .install-reinstall-confirm-modal .recovery-source-block {
margin-bottom: 1rem;
}
.page-content.install .install-reinstall-confirm-modal .recovery-source-option {
padding: 0.75rem 0;
border-top: 1px solid var(--color-secondary-alpha-40);
}
.page-content.install .install-reinstall-confirm-modal .recovery-source-option:first-of-type {
border-top: 0;
padding-top: 0.25rem;
}
.page-content.install .install-reinstall-confirm-modal .recovery-source-option .help {
display: block;
margin-top: 0.35rem;
margin-left: 1.5rem;
}
.page-content.install .install-reinstall-confirm-modal .js-install-recovery-confirm-panel {
display: none;
}
.page-content.install .install-reinstall-confirm-modal .js-install-recovery-confirm-panel.is-active {
display: block;
}
.ui.negative.message.install-reinstall-alert {
font-size: 19px;
}