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)
178 lines
4.9 KiB
Go
178 lines
4.9 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package setting
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"code.gitea.io/gitea/modules/generate"
|
|
)
|
|
|
|
// start edit/add - by petru @ codex
|
|
const (
|
|
recoveryConfigFileName = ".gitea-recovery.ini"
|
|
recoveryUsedTokensFileName = ".gitea-recovery.used"
|
|
defaultRecoveryTokenTTLMinutes = 15
|
|
)
|
|
|
|
type RecoveryConfig struct {
|
|
Enabled bool
|
|
AllowedEmails string
|
|
TokenSecret string
|
|
TokenTTLMin int
|
|
BackupPath string
|
|
RepoRootPath string
|
|
SMTPAddr string
|
|
SMTPPort string
|
|
SMTPFrom string
|
|
SMTPUser string
|
|
SMTPPasswd string
|
|
}
|
|
|
|
func RecoveryConfigPath() string {
|
|
if CustomConf == "" {
|
|
return ""
|
|
}
|
|
return filepath.Join(filepath.Dir(CustomConf), recoveryConfigFileName)
|
|
}
|
|
|
|
func RecoveryUsedTokensPath() string {
|
|
if CustomConf == "" {
|
|
return ""
|
|
}
|
|
return filepath.Join(filepath.Dir(CustomConf), recoveryUsedTokensFileName)
|
|
}
|
|
|
|
func LoadRecoveryConfig() (*RecoveryConfig, error) {
|
|
recoveryPath := RecoveryConfigPath()
|
|
if recoveryPath == "" {
|
|
return nil, errors.New("setting.CustomConf is not set")
|
|
}
|
|
|
|
cfg, err := NewConfigProviderFromFile(recoveryPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if cfg.IsLoadedFromEmpty() {
|
|
return nil, nil
|
|
}
|
|
|
|
recoverySec := cfg.Section("recovery")
|
|
backupSec := cfg.Section("backup")
|
|
mailerSec := cfg.Section("mailer")
|
|
recoveryCfg := &RecoveryConfig{
|
|
Enabled: ConfigSectionKeyBool(recoverySec, "ENABLED"),
|
|
AllowedEmails: ConfigSectionKeyString(recoverySec, "ALLOWED_EMAILS"),
|
|
TokenSecret: ConfigSectionKeyString(recoverySec, "TOKEN_SECRET"),
|
|
TokenTTLMin: recoverySec.Key("TOKEN_TTL_MINUTES").MustInt(defaultRecoveryTokenTTLMinutes),
|
|
BackupPath: ConfigSectionKeyString(backupSec, "PATH"),
|
|
RepoRootPath: ConfigSectionKeyString(cfg.Section("repository"), "ROOT"),
|
|
SMTPAddr: ConfigSectionKeyString(mailerSec, "SMTP_ADDR"),
|
|
SMTPPort: ConfigSectionKeyString(mailerSec, "SMTP_PORT"),
|
|
SMTPFrom: ConfigSectionKeyString(mailerSec, "FROM"),
|
|
SMTPUser: ConfigSectionKeyString(mailerSec, "USER"),
|
|
SMTPPasswd: ConfigSectionKeyString(mailerSec, "PASSWD"),
|
|
}
|
|
if recoveryCfg.TokenTTLMin <= 0 {
|
|
recoveryCfg.TokenTTLMin = defaultRecoveryTokenTTLMinutes
|
|
}
|
|
return recoveryCfg, nil
|
|
}
|
|
|
|
func (data *RecoveryConfig) AllowedEmailSlice() []string {
|
|
if data == nil {
|
|
return nil
|
|
}
|
|
|
|
parts := strings.Split(data.AllowedEmails, ",")
|
|
emails := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
part = strings.ToLower(strings.TrimSpace(part))
|
|
if part != "" {
|
|
emails = append(emails, part)
|
|
}
|
|
}
|
|
return emails
|
|
}
|
|
|
|
func (data *RecoveryConfig) AllowsEmail(email string) bool {
|
|
email = strings.ToLower(strings.TrimSpace(email))
|
|
if email == "" {
|
|
return false
|
|
}
|
|
for _, allowed := range data.AllowedEmailSlice() {
|
|
if allowed == email {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (data *RecoveryConfig) EffectiveTokenTTL() time.Duration {
|
|
if data == nil || data.TokenTTLMin <= 0 {
|
|
return time.Duration(defaultRecoveryTokenTTLMinutes) * time.Minute
|
|
}
|
|
return time.Duration(data.TokenTTLMin) * time.Minute
|
|
}
|
|
|
|
func SaveRecoveryConfig(data *RecoveryConfig) error {
|
|
recoveryPath := RecoveryConfigPath()
|
|
if recoveryPath == "" {
|
|
return errors.New("setting.CustomConf is not set")
|
|
}
|
|
if data == nil || !data.Enabled {
|
|
if err := os.Remove(recoveryPath); err != nil && !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
cfg, err := NewConfigProviderFromFile(recoveryPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(recoveryPath), 0o755); err != nil {
|
|
return err // edit/add - by petru @ codex
|
|
}
|
|
if strings.TrimSpace(data.TokenSecret) == "" {
|
|
tokenSecret, err := generate.NewSecretKey()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data.TokenSecret = tokenSecret
|
|
}
|
|
if data.TokenTTLMin <= 0 {
|
|
data.TokenTTLMin = defaultRecoveryTokenTTLMinutes
|
|
}
|
|
|
|
recoverySec := cfg.Section("recovery")
|
|
recoverySec.Key("ENABLED").SetValue(strconv.FormatBool(data.Enabled))
|
|
recoverySec.Key("ALLOWED_EMAILS").SetValue(strings.TrimSpace(data.AllowedEmails))
|
|
recoverySec.Key("TOKEN_SECRET").SetValue(strings.TrimSpace(data.TokenSecret))
|
|
recoverySec.Key("TOKEN_TTL_MINUTES").SetValue(strconv.Itoa(data.TokenTTLMin))
|
|
|
|
backupSec := cfg.Section("backup")
|
|
backupSec.Key("PATH").SetValue(strings.TrimSpace(data.BackupPath))
|
|
|
|
repoSec := cfg.Section("repository")
|
|
repoSec.Key("ROOT").SetValue(strings.TrimSpace(data.RepoRootPath))
|
|
|
|
mailerSec := cfg.Section("mailer")
|
|
mailerSec.Key("ENABLED").SetValue(strconv.FormatBool(strings.TrimSpace(data.SMTPAddr) != ""))
|
|
mailerSec.Key("SMTP_ADDR").SetValue(strings.TrimSpace(data.SMTPAddr))
|
|
mailerSec.Key("SMTP_PORT").SetValue(strings.TrimSpace(data.SMTPPort))
|
|
mailerSec.Key("FROM").SetValue(strings.TrimSpace(data.SMTPFrom))
|
|
mailerSec.Key("USER").SetValue(strings.TrimSpace(data.SMTPUser))
|
|
mailerSec.Key("PASSWD").SetValue(data.SMTPPasswd)
|
|
|
|
return cfg.Save()
|
|
}
|
|
|
|
// end edit/add - by petru @ codex
|