diff --git a/.codex-context.md b/.codex-context.md index 58dd2cb80b..6c2ba1e1f4 100644 --- a/.codex-context.md +++ b/.codex-context.md @@ -58,7 +58,10 @@ For detailed request flow, auth flow, extension points, and task-oriented entry ## Current Working Notes +- This workstation has limited CPU and RAM resources; prefer targeted, lower-scope test runs first and avoid unnecessarily heavy concurrent verification unless the task truly requires it. - `main.go` defaults `setting.AppVer` to `development`; derive the real build/version string from the repository when writing `./.codex-history.md`. - The repository is predictably split across `cmd/`, `routers/`, `services/`, `models/`, `templates/`, and `web_src/`, so changes should normally stay localized to one or two layers plus their tests. - For custom release maintenance and repo-sync tasks, consult `./.codex-script-notes.md` before reanalyzing the large helper scripts. - New UI-visible functionality in this fork often also requires locale updates, so UI tasks and related history searches should usually consider `options/locale/`, especially `locale_en-US.json` and any actively maintained translated locale files. + +- History rule: all changes for the same `git describe` version must be recorded under the same `.codex-history.md` entry; create a new history entry only when the version string changes. diff --git a/.codex-history.md b/.codex-history.md index b37f3d55e3..6183fe1a98 100644 --- a/.codex-history.md +++ b/.codex-history.md @@ -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-06-01 00:01:11] - v1.27.0-dev-198-g78fc93ad7a - Type: Modified - [install] [backup] [database] [recovery] Consolidated database backup and installer recovery support. +- 1 - Add: Gitea now creates timestamped database backup bundles under `[backup].PATH`, exposes the backup schedule in the installer, and surfaces the `database_backup` cron task in admin monitoring. +- 2 - Add: installed instances now use `.gitea-installed` and `.gitea-recovery.ini` to enter email-gated recovery instead of falling back to public install mode when configuration or database access is broken. +- 3 - Mod: the installer recovery flow now covers backup-bundle restore, bundled or manual `app.ini` reuse, uploaded SQL/GZ database restores, and repository-filesystem recovery with source-specific validation, confirmations, and preserved launcher state. +- 4 - Fix: recovery now restores bundled `app.ini` snapshots when needed, discovers backup bundles from both the active backup path and persisted `.gitea-recovery.ini` path, and preserves SMTP and other rebuilt settings correctly when `app.ini` is missing or incomplete. +- 5 - Fix: recovery validation and restore handling now accept either a selected backup bundle or an uploaded SQL/GZ dump, keep sensitive secrets and existing `LFS_JWT_SECRET` when appropriate, clear SQLite restore targets before import, and complete the post-install handoff without redirect loops. +- 6 - Mod: fresh installs now default recovery email authorization to enabled with first-admin fallback, and the install/recovery UI, styling, and EN/RO wording were refined to match the final launcher behavior. diff --git a/cmd/recovery.go b/cmd/recovery.go new file mode 100644 index 0000000000..67357c6a79 --- /dev/null +++ b/cmd/recovery.go @@ -0,0 +1,605 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "errors" + "fmt" + "html" + "net/http" + "net/mail" + "net/url" + "os" + "strconv" + "strings" + "time" + + "code.gitea.io/gitea/models/db" + db_install "code.gitea.io/gitea/models/db/install" + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/public" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/translation" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers" + "code.gitea.io/gitea/routers/common" + install_router "code.gitea.io/gitea/routers/install" + svc_context "code.gitea.io/gitea/services/context" + sender_service "code.gitea.io/gitea/services/mailer/sender" + + "github.com/urfave/cli/v3" +) + +// start edit/add - by petru @ codex +const ( + recoverySessionEmailKey = "recovery_unlocked_email" + recoverySessionUntilKey = "recovery_unlocked_until" + recoveryUnlockCookieName = "gitea_recovery_unlock" + recoveryUnlockHostCookie = "gitea_recovery_unlock_host" + recoveryInstallQueryKey = "recovery" + recoveryInstallQuery = "recovery=1" + recoveryPageStatusKey = "recovery_status" + recoveryPageErrorKey = "recovery_error" + + recoveryPageStatusSent = "sent" + + recoveryPageErrorRequiresAddresses = "requires_addresses" + recoveryPageErrorEmailInvalid = "email_invalid" + + recoveryReasonMissingConfig = "missing_config" + recoveryReasonDatabaseUnavailable = "database_unavailable" +) + +func recoverySiteName(recoveryCfg *setting.RecoveryConfig, recoveryReason string) string { + if recoveryReason == recoveryReasonMissingConfig && recoveryCfg != nil { + if parsedFrom, err := mail.ParseAddress(strings.TrimSpace(recoveryCfg.SMTPFrom)); err == nil { + if fromName := strings.TrimSpace(parsedFrom.Name); fromName != "" { + return fromName + } + } + } + + appName := strings.TrimSpace(setting.AppName) + if appName == "" { + return "Gitea" + } + if siteName, _, found := strings.Cut(appName, ":"); found { + siteName = strings.TrimSpace(siteName) + if siteName != "" { + return siteName + } + } + if parts := strings.Fields(appName); len(parts) > 0 { + return parts[0] + } + return appName +} + +func loadEnabledRecoveryConfig() (*setting.RecoveryConfig, error) { + recoveryCfg, err := setting.LoadRecoveryConfig() + if err != nil || recoveryCfg == nil || !recoveryCfg.Enabled { + return recoveryCfg, err + } + if strings.TrimSpace(recoveryCfg.TokenSecret) == "" || recoveryCfg.TokenTTLMin <= 0 { + if err := setting.SaveRecoveryConfig(recoveryCfg); err != nil { + return nil, err + } + return setting.LoadRecoveryConfig() + } + return recoveryCfg, nil +} + +func shouldServeInstalledRecovery(ctx context.Context) (*setting.RecoveryConfig, bool, error) { + if !setting.HasInstallSentinel() { + return nil, false, nil + } + + recoveryCfg, err := loadEnabledRecoveryConfig() + if err != nil || recoveryCfg == nil { + return recoveryCfg, false, err + } + + if missingDB, err := missingInstalledSQLiteDatabase(); err != nil { + return nil, false, err + } else if missingDB { + return recoveryCfg, true, nil + } + + db.UnsetDefaultEngine() + defer db.UnsetDefaultEngine() + + if err := db.InitEngine(ctx); err != nil { + log.Warn("Recovery mode preflight: database engine init failed: %v", err) + return recoveryCfg, true, nil + } + if err := db_install.CheckDatabaseConnection(ctx); err != nil { + log.Warn("Recovery mode preflight: database connection failed: %v", err) + return recoveryCfg, true, nil + } + if _, err := db_install.HasPostInstallationUsers(ctx); err != nil { + log.Warn("Recovery mode preflight: installed user table check failed: %v", err) + return recoveryCfg, true, nil + } + if _, err := db_install.GetMigrationVersion(ctx); err != nil { + log.Warn("Recovery mode preflight: migration version check failed: %v", err) + return recoveryCfg, true, nil + } + + return nil, false, nil +} + +func serveRecovery(cmd *cli.Command, recoveryCfg *setting.RecoveryConfig, recoveryReason string) error { + showWebStartupMessage("Prepare to run recovery page") + routers.InitWebInstallPage(graceful.GetManager().HammerContext()) + + if cmd.IsSet("port") { + if err := setPort(cmd.String("port")); err != nil { + return err + } + } + if cmd.IsSet("install-port") { + if err := setPort(cmd.String("install-port")); err != nil { + return err + } + } + + err := listen(recoveryRoutes(recoveryCfg, recoveryReason), false) + if err != nil { + log.Critical("Unable to open listener for recovery. Is Gitea already running?") // edit/add - by petru @ codex + graceful.GetManager().DoGracefulShutdown() // edit/add - by petru @ codex + } + select { + case <-graceful.GetManager().IsShutdown(): + <-graceful.GetManager().Done() + log.Info("PID: %d Gitea Recovery Finished", os.Getpid()) + return err + default: + } + return nil +} + +func recoveryRoutes(recoveryCfg *setting.RecoveryConfig, recoveryReason string) *web.Router { + base := web.NewRouter() + base.BeforeRouting(common.ProtocolMiddlewares()...) + base.Methods("GET, HEAD", "/assets/*", public.FileHandlerFunc()) // edit/add - by petru @ codex + + installHandler := install_router.Routes() + + r := web.NewRouter() + r.AfterRouting(common.MustInitSessioner(), svc_context.ContexterInstallPage(map[string]any{})) + r.Get("/recovery", func(ctx *svc_context.Context) { + if recoverySessionUnlocked(ctx, recoveryCfg) { + ctx.Redirect(setting.AppSubURL + "/?" + recoveryInstallQuery) + return + } + statusMessage, errorMessage := recoveryPageMessagesFromRequest(ctx) // edit/add - by petru @ codex + renderRecoveryPage(ctx, recoveryCfg, recoveryReason, statusMessage, errorMessage) + }) + r.Post("/recovery/request", func(ctx *svc_context.Context) { + handleRecoverySendLink(ctx, recoveryCfg, recoveryReason) + }) + r.Get("/recovery/access", func(ctx *svc_context.Context) { + handleRecoveryAccess(ctx, recoveryCfg, recoveryReason) + }) + r.Any("/", func(ctx *svc_context.Context) { + serveRecoveryInstaller(ctx, installHandler, recoveryCfg, recoveryReason) + }) + r.Any("/*", func(ctx *svc_context.Context) { + serveRecoveryInstaller(ctx, installHandler, recoveryCfg, recoveryReason) + }) + + base.Mount("", r) + return base +} + +func recoverySessionUnlocked(ctx *svc_context.Context, recoveryCfg *setting.RecoveryConfig) bool { + if recoverySessionUnlockedBySession(ctx, recoveryCfg) { + return true + } + return recoverySessionUnlockedByCookie(ctx, recoveryCfg) +} + +func recoverySessionUnlockedBySession(ctx *svc_context.Context, recoveryCfg *setting.RecoveryConfig) bool { + email, ok := ctx.Session.Get(recoverySessionEmailKey).(string) + if !ok || strings.TrimSpace(email) == "" { + return false + } + + until, ok := sessionInt64(ctx.Session.Get(recoverySessionUntilKey)) + if !ok || time.Now().Unix() > until { + _ = ctx.Session.Delete(recoverySessionEmailKey) + _ = ctx.Session.Delete(recoverySessionUntilKey) + ctx.DeleteSiteCookie(recoveryUnlockCookieName) + deleteRecoveryHostCookie(ctx) // edit/add - by petru @ codex + return false + } + if recoveryCfg == nil || !recoveryCfg.AllowsEmail(email) { + _ = ctx.Session.Delete(recoverySessionEmailKey) + _ = ctx.Session.Delete(recoverySessionUntilKey) + ctx.DeleteSiteCookie(recoveryUnlockCookieName) + deleteRecoveryHostCookie(ctx) // edit/add - by petru @ codex + return false + } + return true +} + +func recoverySessionUnlockedByCookie(ctx *svc_context.Context, recoveryCfg *setting.RecoveryConfig) bool { + unlockToken := strings.TrimSpace(ctx.GetSiteCookie(recoveryUnlockCookieName)) + if unlockToken == "" { + unlockToken = strings.TrimSpace(getRecoveryHostCookie(ctx)) // edit/add - by petru @ codex + } + if unlockToken == "" || recoveryCfg == nil { + return false + } + + claims, err := setting.ValidateRecoveryUnlockToken(recoveryCfg, unlockToken, time.Now()) + if err != nil || !recoveryCfg.AllowsEmail(claims.Email) { + ctx.DeleteSiteCookie(recoveryUnlockCookieName) + deleteRecoveryHostCookie(ctx) // edit/add - by petru @ codex + return false + } + return true +} + +func sessionInt64(value any) (int64, bool) { + switch v := value.(type) { + case int64: + return v, true + case int: + return int64(v), true + case int32: + return int64(v), true + case int16: + return int64(v), true + case int8: + return int64(v), true + case uint64: + return int64(v), true + case uint: + return int64(v), true + case float64: + return int64(v), true + case string: + if v == "" { + return 0, false + } + parsed, err := strconv.ParseInt(v, 10, 64) + if err == nil { + return parsed, true + } + } + return 0, false +} + +func serveRecoveryInstaller(ctx *svc_context.Context, installHandler http.Handler, recoveryCfg *setting.RecoveryConfig, recoveryReason string) { + allowUnlockedPath := ctx.Req.URL.Path == setting.AppSubURL+"/post-install" || ctx.Req.URL.Path == "/post-install" // edit/add - by petru @ codex + if !recoverySessionUnlocked(ctx, recoveryCfg) || (ctx.Req.URL.Query().Get(recoveryInstallQueryKey) != "1" && !allowUnlockedPath) { + ctx.Redirect(setting.AppSubURL + "/recovery") + return + } + installHandler.ServeHTTP(ctx.Resp, install_router.WithRecoveryRequest(ctx.Req)) +} + +func handleRecoverySendLink(ctx *svc_context.Context, recoveryCfg *setting.RecoveryConfig, recoveryReason string) { + rawEmail := strings.TrimSpace(ctx.FormString("recovery_email")) + if rawEmail == "" { + ctx.Redirect(recoveryPageURL(ctx.Req, recoveryPageStatusKey, "", recoveryPageErrorKey, recoveryPageErrorRequiresAddresses)) + return + } + + parsedEmail, err := mail.ParseAddress(rawEmail) + if err != nil { + ctx.Redirect(recoveryPageURL(ctx.Req, recoveryPageStatusKey, "", recoveryPageErrorKey, recoveryPageErrorEmailInvalid)) + return + } + + if recoveryCfg != nil && recoveryCfg.AllowsEmail(parsedEmail.Address) { + if err := sendRecoveryAccessMail(ctx, recoveryCfg, parsedEmail.Address); err != nil { + log.Error("Failed to send recovery access mail to %s: %v", parsedEmail.Address, err) + } + } + ctx.Redirect(recoveryPageURL(ctx.Req, recoveryPageStatusKey, recoveryPageStatusSent, recoveryPageErrorKey, "")) +} + +func handleRecoveryAccess(ctx *svc_context.Context, recoveryCfg *setting.RecoveryConfig, recoveryReason string) { + token := strings.TrimSpace(ctx.FormString("token")) + if token == "" { + renderRecoveryPage(ctx, recoveryCfg, recoveryReason, "", string(ctx.Tr("install.recovery_token_invalid"))) + return + } + + claims, err := setting.ValidateRecoveryToken(recoveryCfg, token, time.Now()) + if err != nil { + renderRecoveryPage(ctx, recoveryCfg, recoveryReason, "", string(ctx.Tr("install.recovery_token_invalid"))) + return + } + if !recoveryCfg.AllowsEmail(claims.Email) { + renderRecoveryPage(ctx, recoveryCfg, recoveryReason, "", string(ctx.Tr("install.recovery_token_invalid"))) + return + } + if err := setting.ConsumeRecoveryToken(claims.ID); err != nil { + if errors.Is(err, setting.ErrRecoveryTokenUsed) { + renderRecoveryPage(ctx, recoveryCfg, recoveryReason, "", string(ctx.Tr("install.recovery_token_invalid"))) + return + } + ctx.ServerError("ConsumeRecoveryToken", err) + return + } + + if err := ctx.Session.Set(recoverySessionEmailKey, claims.Email); err != nil { + ctx.ServerError("Session.Set(recoverySessionEmailKey)", err) + return + } + if err := ctx.Session.Set(recoverySessionUntilKey, claims.ExpiresAt.Unix()); err != nil { + ctx.ServerError("Session.Set(recoverySessionUntilKey)", err) + return + } + unlockToken, err := setting.IssueRecoveryUnlockToken(recoveryCfg, claims.Email, time.Now()) + if err != nil { + ctx.ServerError("IssueRecoveryUnlockToken", err) + return + } + ctx.SetSiteCookie(recoveryUnlockCookieName, unlockToken, int(recoveryCfg.EffectiveTokenTTL().Seconds())) // edit/add - by petru @ codex + setRecoveryHostCookie(ctx, unlockToken, int(recoveryCfg.EffectiveTokenTTL().Seconds())) // edit/add - by petru @ codex + if err := ctx.Session.Release(); err != nil { + ctx.ServerError("Session.Release", err) + return + } + ctx.Redirect(setting.AppSubURL + "/?" + recoveryInstallQuery) +} + +func sendRecoveryAccessMail(ctx *svc_context.Context, recoveryCfg *setting.RecoveryConfig, email string) error { + token, err := setting.IssueRecoveryToken(recoveryCfg, email, time.Now()) + if err != nil { + return err + } + + accessURL := recoveryRequestBaseURL(ctx.Req) + "/recovery/access?token=" + url.QueryEscape(token) + siteName := recoverySiteName(recoveryCfg, recoveryReasonMissingConfig) + subject := string(ctx.Tr("install.recovery_access_mail_subject", siteName)) + log.Info("Recovery access link for %s: %s", email, accessURL) // edit/add - by petru @ codex + + mailService, err := buildRecoveryMailService(recoveryCfg) + if err != nil { + log.Info("Recovery access email for %s skipped: %v", email, err) // edit/add - by petru @ codex + return nil + } + + body := fmt.Sprintf( + "

%s

%s

%s

%s

", + html.EscapeString(string(ctx.Tr("install.recovery_access_mail_intro", siteName))), + html.EscapeString(accessURL), + html.EscapeString(string(ctx.Tr("install.recovery_access_mail_action"))), + html.EscapeString(string(ctx.Tr("install.recovery_access_mail_expiry", int(recoveryCfg.EffectiveTokenTTL().Minutes())))), + html.EscapeString(string(ctx.Tr("install.recovery_access_mail_ignore"))), + ) + + previousMailService := setting.MailService + setting.MailService = mailService + defer func() { + setting.MailService = previousMailService + }() + + msg := sender_service.NewMessageFrom(email, mailService.FromName, mailService.FromEmail, subject, body) + return sender_service.Send(&sender_service.SMTPSender{}, msg) +} + +func buildRecoveryMailService(recoveryCfg *setting.RecoveryConfig) (*setting.Mailer, error) { + if recoveryCfg == nil || strings.TrimSpace(recoveryCfg.SMTPAddr) == "" || strings.TrimSpace(recoveryCfg.SMTPFrom) == "" { + return nil, errors.New("recovery mail service is not configured") + } + + from, err := mail.ParseAddress(recoveryCfg.SMTPFrom) + if err != nil { + return nil, err + } + + smtpPort := strings.TrimSpace(recoveryCfg.SMTPPort) + protocol := inferRecoveryMailProtocol(recoveryCfg.SMTPAddr, smtpPort) + if smtpPort == "" { + switch protocol { + case "smtp": + smtpPort = "25" + case "smtp+starttls": + smtpPort = "587" + default: + smtpPort = "465" + } + } + + return &setting.Mailer{ + Name: setting.AppName, + From: recoveryCfg.SMTPFrom, + FromName: from.Name, + FromEmail: from.Address, + Protocol: protocol, + SMTPAddr: recoveryCfg.SMTPAddr, + SMTPPort: smtpPort, + User: recoveryCfg.SMTPUser, + Passwd: recoveryCfg.SMTPPasswd, + EnableHelo: true, + OverrideHeader: map[string][]string{}, + }, nil +} + +func inferRecoveryMailProtocol(smtpAddr, smtpPort string) string { + if strings.ContainsAny(smtpAddr, "/\\") { + return "smtp+unix" + } + switch smtpPort { + case "25": + return "smtp" + case "587": + return "smtp+starttls" + default: + return "smtps" + } +} + +func recoveryRequestBaseURL(req *http.Request) string { + if setting.InstallLock && strings.TrimSpace(setting.AppURL) != "" { + // start edit/add - by petru @ codex + if parsedAppURL, err := url.Parse(strings.TrimSpace(setting.AppURL)); err == nil { + parsedAppURL.RawQuery = "" + parsedAppURL.Fragment = "" + if setting.AppSubURL != "" { + parsedAppURL.Path = setting.AppSubURL + } + return strings.TrimRight(parsedAppURL.String(), "/") + } + return strings.TrimRight(strings.Split(strings.TrimSpace(setting.AppURL), "?")[0], "/") + // end edit/add - by petru @ codex + } + + scheme := "http" + if req.TLS != nil { + scheme = "https" + } else if forwardedProto := strings.TrimSpace(strings.Split(req.Header.Get("X-Forwarded-Proto"), ",")[0]); forwardedProto != "" { + scheme = forwardedProto + } + + host := req.Host + if host == "" { + host = "localhost" + } + return scheme + "://" + host + strings.TrimRight(setting.AppSubURL, "/") +} + +func getRecoveryHostCookie(ctx *svc_context.Context) string { + // start edit/add - by petru @ codex + cookie, err := ctx.Req.Cookie(recoveryUnlockHostCookie) + if err != nil { + return "" + } + value, err := url.QueryUnescape(cookie.Value) + if err != nil { + return "" + } + return value + // end edit/add - by petru @ codex +} + +func setRecoveryHostCookie(ctx *svc_context.Context, value string, maxAge int) { + // start edit/add - by petru @ codex + http.SetCookie(ctx.Resp, &http.Cookie{ + Name: recoveryUnlockHostCookie, + Value: url.QueryEscape(value), + MaxAge: maxAge, + Path: "/", + Secure: setting.SessionConfig.Secure, + HttpOnly: true, + SameSite: setting.SessionConfig.SameSite, + }) + // end edit/add - by petru @ codex +} + +func deleteRecoveryHostCookie(ctx *svc_context.Context) { + // start edit/add - by petru @ codex + http.SetCookie(ctx.Resp, &http.Cookie{ + Name: recoveryUnlockHostCookie, + Value: "", + MaxAge: -1, + Path: "/", + Secure: setting.SessionConfig.Secure, + HttpOnly: true, + SameSite: setting.SessionConfig.SameSite, + }) + // end edit/add - by petru @ codex +} + +// start edit/add - by petru @ codex +func recoveryLanguageOptions(req *http.Request, currentLang string) string { + var b strings.Builder + for _, lang := range translation.AllLangs() { + selectedAttribute := "" + if currentLang == lang.Lang { + selectedAttribute = " selected" + } + b.WriteString(fmt.Sprintf( + ``, + html.EscapeString(lang.Lang), + selectedAttribute, + html.EscapeString(lang.Name), + )) + } + return b.String() +} + +// end edit/add - by petru @ codex + +func recoveryPageURL(req *http.Request, firstKey, firstValue, secondKey, secondValue string) string { + recoveryURL := *req.URL + recoveryURL.Path = strings.TrimRight(setting.AppSubURL, "/") + "/recovery" + query := recoveryURL.Query() + query.Del(recoveryPageStatusKey) + query.Del(recoveryPageErrorKey) + if firstKey != "" && firstValue != "" { + query.Set(firstKey, firstValue) + } + if secondKey != "" && secondValue != "" { + query.Set(secondKey, secondValue) + } + recoveryURL.RawQuery = query.Encode() + return recoveryURL.RequestURI() +} + +func recoveryPageMessagesFromRequest(ctx *svc_context.Context) (statusMessage, errorMessage string) { + switch ctx.Req.URL.Query().Get(recoveryPageStatusKey) { + case recoveryPageStatusSent: + statusMessage = string(ctx.Tr("install.recovery_email_sent_notice")) + } + + switch ctx.Req.URL.Query().Get(recoveryPageErrorKey) { + case recoveryPageErrorRequiresAddresses: + errorMessage = string(ctx.Tr("install.recovery_email_requires_addresses")) + case recoveryPageErrorEmailInvalid: + errorMessage = string(ctx.Tr("form.email_invalid")) + } + return statusMessage, errorMessage +} + +func renderRecoveryPage(ctx *svc_context.Context, recoveryCfg *setting.RecoveryConfig, recoveryReason, statusMessage, errorMessage string) { + siteName := recoverySiteName(recoveryCfg, recoveryReason) + reasonMessage := string(ctx.Tr("install.recovery_reason_database_unavailable")) + if recoveryReason == recoveryReasonMissingConfig { + reasonMessage = string(ctx.Tr("install.recovery_reason_missing_config")) + } + + statusHTML := "" + if statusMessage != "" { + statusHTML = fmt.Sprintf(`
%s
`, html.EscapeString(statusMessage)) + } + errorHTML := "" + if errorMessage != "" { + errorHTML = fmt.Sprintf(`
%s
`, html.EscapeString(errorMessage)) + } + languageOptionsHTML := recoveryLanguageOptions(ctx.Req, ctx.Locale.Language()) // edit/add - by petru @ codex + + ctx.Resp.Header().Set("Content-Type", "text/html; charset=utf-8") + ctx.Resp.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate") // edit/add - by petru @ codex + ctx.Resp.Header().Set("Pragma", "no-cache") // edit/add - by petru @ codex + ctx.Resp.Header().Set("Expires", "0") // edit/add - by petru @ codex + ctx.Resp.WriteHeader(http.StatusOK) // edit/add - by petru @ codex + _, _ = fmt.Fprintf(ctx.Resp, `%s

%s

%s

%s%s

%s

`, + html.EscapeString(ctx.Locale.Language()), + html.EscapeString(string(ctx.Tr("install.recovery_identify_title"))), + html.EscapeString(setting.AppSubURL), + html.EscapeString(setting.AppSubURL), + html.EscapeString(string(ctx.Tr("install.recovery_identify_title"))), + html.EscapeString(reasonMessage), + statusHTML, + errorHTML, + html.EscapeString(setting.AppSubURL), + html.EscapeString(string(ctx.Tr("install.recovery_identify_email"))), + html.EscapeString(string(ctx.Tr("install.recovery_identify_submit"))), + html.EscapeString(string(ctx.Tr("install.recovery_identify_helper", siteName))), + html.EscapeString(setting.AppSubURL), + html.EscapeString(string(ctx.Tr("language"))), + languageOptionsHTML, + ) +} + +// end edit/add - by petru @ codex diff --git a/cmd/web.go b/cmd/web.go index 994c481fc0..15578e3e3a 100644 --- a/cmd/web.go +++ b/cmd/web.go @@ -6,6 +6,7 @@ package cmd import ( "context" "fmt" + "html" "net" "net/http" "net/http/pprof" @@ -15,6 +16,8 @@ import ( "strings" "time" + db_model "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/gtprof" @@ -23,9 +26,13 @@ import ( "code.gitea.io/gitea/modules/public" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/translation" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers" + "code.gitea.io/gitea/routers/common" "code.gitea.io/gitea/routers/install" + web_context "code.gitea.io/gitea/services/context" "github.com/felixge/fgprof" "github.com/urfave/cli/v3" @@ -163,8 +170,151 @@ func serveInstall(cmd *cli.Command) error { return nil } +// start edit/add - by petru @ codex +func missingInstalledSQLiteDatabase() (bool, error) { + if !setting.Database.Type.IsSQLite3() { + return false, nil + } + if !(setting.InstallLock || setting.HasInstallSentinel()) { + return false, nil + } + _, err := os.Stat(setting.Database.Path) + if err == nil { + return false, nil + } + if os.IsNotExist(err) { + return true, nil + } + return false, err +} + +func serviceUnavailableRoutes() *web.Router { + base := web.NewRouter() + base.BeforeRouting(common.ProtocolMiddlewares()...) + + r := web.NewRouter() + r.AfterRouting(common.MustInitSessioner(), web_context.Contexter()) + handler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + ctx := web_context.GetWebContext(req.Context()) + // The full 503 template depends on dynamic config backed by the database, + // so the bootstrap fallback must render a standalone response here. + message := html.EscapeString(ctx.Locale.TrString("error503")) // edit/add - by petru @ codex + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = fmt.Fprintf(w, `503 Service Unavailable

503 Service Unavailable

%s

`, message) + }) + r.Any("/", handler) // edit/add - by petru @ codex + r.Any("/*", handler) // edit/add - by petru @ codex + + base.Mount("", r) + return base +} + +func serveInstalledUnavailable(cmd *cli.Command) error { + showWebStartupMessage("Prepare to run service unavailable page") + translation.InitLocales(graceful.GetManager().HammerContext()) + + if cmd.IsSet("port") { + if err := setPort(cmd.String("port")); err != nil { + return err + } + } + + err := listen(serviceUnavailableRoutes(), false) + <-graceful.GetManager().Done() + log.Info("PID: %d Gitea Web Finished", os.Getpid()) + return err +} + +func buildAutoRecoveryConfigFromInstalledState(ctx context.Context) (*setting.RecoveryConfig, error) { + recoveryEmail := "" // edit/add - by petru @ codex + if setting.Admin.SuperAdminEnabled { + superAdminSettings := make([]*user_model.Setting, 0, 4) + if err := db_model.GetEngine(ctx).Where("setting_key=? AND setting_value=?", user_model.SettingsKeySuperAdminEnabled, "true").Asc("id").Find(&superAdminSettings); err != nil { + return nil, err + } + for _, superAdminSetting := range superAdminSettings { + u, err := user_model.GetUserByID(ctx, superAdminSetting.UserID) + if err != nil { + if user_model.IsErrUserNotExist(err) { + continue + } + return nil, err + } + if u.IsAdmin && u.IsActive && !u.ProhibitLogin && strings.TrimSpace(u.Email) != "" { + recoveryEmail = strings.TrimSpace(u.Email) + break + } + } + } + if recoveryEmail == "" { + adminUser, err := user_model.GetAdminUser(ctx) + if err != nil { + if user_model.IsErrUserNotExist(err) { + return nil, nil + } + return nil, err + } + if adminUser == nil || strings.TrimSpace(adminUser.Email) == "" { + return nil, nil + } + recoveryEmail = strings.TrimSpace(adminUser.Email) + } + + recoveryCfg := &setting.RecoveryConfig{ + Enabled: true, + AllowedEmails: recoveryEmail, + BackupPath: setting.Backup.Path, + RepoRootPath: setting.RepoRootPath, + } + if setting.MailService != nil { + recoveryCfg.SMTPAddr = setting.MailService.SMTPAddr + recoveryCfg.SMTPPort = setting.MailService.SMTPPort + recoveryCfg.SMTPFrom = setting.MailService.From + recoveryCfg.SMTPUser = setting.MailService.User + recoveryCfg.SMTPPasswd = setting.MailService.Passwd + } + return recoveryCfg, nil +} + +func ensureRecoveryConfigFromInstalledState(ctx context.Context) error { + recoveryCfg, err := setting.LoadRecoveryConfig() + if err != nil { + return err + } + if recoveryCfg != nil { + return nil + } + + recoveryCfg, err = buildAutoRecoveryConfigFromInstalledState(ctx) + if err != nil { + return err + } + if recoveryCfg == nil { + return nil + } + return setting.SaveRecoveryConfig(recoveryCfg) +} + +// end edit/add - by petru @ codex + func serveInstalled(c *cli.Command) error { setting.MustInstalled() + setting.LoadDBSetting() // edit/add - by petru @ codex + if recoveryCfg, shouldServeRecovery, err := shouldServeInstalledRecovery(graceful.GetManager().HammerContext()); err != nil { + return err + } else if shouldServeRecovery { + if err := serveRecovery(c, recoveryCfg, recoveryReasonDatabaseUnavailable); err != nil { + return err + } + return serveInstalled(c) + } + + if missingDB, err := missingInstalledSQLiteDatabase(); err != nil { + return err + } else if missingDB { + return serveInstalledUnavailable(c) + } showWebStartupMessage("Prepare to run web server") @@ -210,6 +360,12 @@ func serveInstalled(c *cli.Command) error { if _, err := os.Stat(setting.AppDataPath); err != nil { log.Fatal("Can not find APP_DATA_PATH %q", setting.AppDataPath) } + if err := setting.EnsureInstallSentinel(); err != nil { + log.Error("Unable to create install sentinel %q: %v", setting.InstallSentinelPath(), err) // edit/add - by petru @ codex + } + if err := ensureRecoveryConfigFromInstalledState(graceful.GetManager().HammerContext()); err != nil { + log.Error("Unable to create recovery config %q: %v", setting.RecoveryConfigPath(), err) // edit/add - by petru @ codex + } // the AppDataTempDir is fully managed by us with a safe sub-path // so it's safe to automatically remove the outdated files @@ -273,7 +429,19 @@ func runWeb(ctx context.Context, cmd *cli.Command) error { _ = templates.PageRenderer() if !setting.InstallLock { - if err := serveInstall(cmd); err != nil { + if setting.HasInstallSentinel() { + recoveryCfg, err := loadEnabledRecoveryConfig() + if err != nil { + return err + } + if recoveryCfg != nil { + if err := serveRecovery(cmd, recoveryCfg, recoveryReasonMissingConfig); err != nil { + return err + } + } else { + return fmt.Errorf("install mode is disabled because the install sentinel exists at %q; fix the configured database or use an explicit recovery flow instead", setting.InstallSentinelPath()) // edit/add - by petru @ codex + } + } else if err := serveInstall(cmd); err != nil { return err } } else { diff --git a/cmd/web_test.go b/cmd/web_test.go new file mode 100644 index 0000000000..04425599d7 --- /dev/null +++ b/cmd/web_test.go @@ -0,0 +1,146 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// start edit/add - by petru @ codex +func TestMissingInstalledSQLiteDatabase(t *testing.T) { + oldDatabase := setting.Database + oldInstallLock := setting.InstallLock + oldCustomConf := setting.CustomConf + defer func() { + setting.Database = oldDatabase + setting.InstallLock = oldInstallLock + setting.CustomConf = oldCustomConf + }() + + tmpDir := t.TempDir() + setting.CustomConf = filepath.Join(tmpDir, "custom", "conf", "app.ini") + setting.Database.Type = "sqlite3" + setting.Database.Path = filepath.Join(tmpDir, "data", "gitea.db") + setting.InstallLock = false // edit/add - by petru @ codex + + missing, err := missingInstalledSQLiteDatabase() + require.NoError(t, err) + assert.False(t, missing) + + setting.InstallLock = true + missing, err = missingInstalledSQLiteDatabase() + require.NoError(t, err) + assert.True(t, missing) + + require.NoError(t, os.MkdirAll(filepath.Dir(setting.Database.Path), 0o755)) + require.NoError(t, os.WriteFile(setting.Database.Path, []byte("db"), 0o600)) + missing, err = missingInstalledSQLiteDatabase() + require.NoError(t, err) + assert.False(t, missing) + + require.NoError(t, os.Remove(setting.Database.Path)) + setting.InstallLock = false + require.NoError(t, os.MkdirAll(filepath.Dir(setting.InstallSentinelPath()), 0o755)) + require.NoError(t, os.WriteFile(setting.InstallSentinelPath(), []byte("installed\n"), 0o600)) + missing, err = missingInstalledSQLiteDatabase() + require.NoError(t, err) + assert.True(t, missing) + + setting.Database.Type = "postgres" + missing, err = missingInstalledSQLiteDatabase() + require.NoError(t, err) + assert.False(t, missing) +} + +func TestBuildAutoRecoveryConfigFromInstalledState(t *testing.T) { + adminUser, adminErr := user_model.GetAdminUser(context.Background()) + recoveryCfg, err := buildAutoRecoveryConfigFromInstalledState(context.Background()) + require.NoError(t, err) + if adminErr != nil { + require.True(t, user_model.IsErrUserNotExist(adminErr)) + assert.Nil(t, recoveryCfg) + return + } + require.NotNil(t, adminUser) + if adminUser.Email == "" { + assert.Nil(t, recoveryCfg) + return + } + require.NotNil(t, recoveryCfg) + assert.True(t, recoveryCfg.Enabled) + assert.Equal(t, adminUser.Email, recoveryCfg.AllowedEmails) + assert.Equal(t, setting.Backup.Path, recoveryCfg.BackupPath) + assert.Equal(t, setting.RepoRootPath, recoveryCfg.RepoRootPath) + if setting.MailService != nil { + assert.Equal(t, setting.MailService.SMTPAddr, recoveryCfg.SMTPAddr) + assert.Equal(t, setting.MailService.SMTPPort, recoveryCfg.SMTPPort) + assert.Equal(t, setting.MailService.From, recoveryCfg.SMTPFrom) + assert.Equal(t, setting.MailService.User, recoveryCfg.SMTPUser) + assert.Equal(t, setting.MailService.Passwd, recoveryCfg.SMTPPasswd) + } +} + +func TestShouldServeInstalledRecoveryWithMissingSQLite(t *testing.T) { + oldDatabase := setting.Database + oldInstallLock := setting.InstallLock + oldCustomConf := setting.CustomConf + defer func() { + setting.Database = oldDatabase + setting.InstallLock = oldInstallLock + setting.CustomConf = oldCustomConf + }() + + tmpDir := t.TempDir() + setting.CustomConf = filepath.Join(tmpDir, "custom", "conf", "app.ini") + setting.Database.Type = "sqlite3" + setting.Database.Path = filepath.Join(tmpDir, "data", "gitea.db") + setting.InstallLock = true + require.NoError(t, os.MkdirAll(filepath.Dir(setting.InstallSentinelPath()), 0o755)) + require.NoError(t, os.WriteFile(setting.InstallSentinelPath(), []byte("installed\n"), 0o600)) + require.NoError(t, setting.SaveRecoveryConfig(&setting.RecoveryConfig{ + Enabled: true, + AllowedEmails: "admin@example.com", + SMTPAddr: "smtp.example.com", + SMTPPort: "587", + SMTPFrom: "Gitea ", + })) + + recoveryCfg, shouldServeRecovery, err := shouldServeInstalledRecovery(context.Background()) + require.NoError(t, err) + require.True(t, shouldServeRecovery) + require.NotNil(t, recoveryCfg) + assert.Equal(t, "admin@example.com", recoveryCfg.AllowedEmails) +} + +func TestRecoveryRequestBaseURLStripsRecoveryQueryFromAppURL(t *testing.T) { + // start edit/add - by petru @ codex + oldInstallLock := setting.InstallLock + oldAppURL := setting.AppURL + oldAppSubURL := setting.AppSubURL + defer func() { + setting.InstallLock = oldInstallLock + setting.AppURL = oldAppURL + setting.AppSubURL = oldAppSubURL + }() + + setting.InstallLock = true + setting.AppURL = "http://example.com:3000/?recovery=1" + setting.AppSubURL = "" + + req := httptest.NewRequest("GET", "http://example.com:3000/recovery", nil) + assert.Equal(t, "http://example.com:3000", recoveryRequestBaseURL(req)) + // end edit/add - by petru @ codex +} + +// end edit/add - by petru @ codex diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index c7587f5235..269554457d 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -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 diff --git a/models/db/engine_dump.go b/models/db/engine_dump.go index 63f2d4e093..427866739f 100644 --- a/models/db/engine_dump.go +++ b/models/db/engine_dump.go @@ -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 diff --git a/modules/setting/backup.go b/modules/setting/backup.go new file mode 100644 index 0000000000..4e0f647a2a --- /dev/null +++ b/modules/setting/backup.go @@ -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 diff --git a/modules/setting/backup_test.go b/modules/setting/backup_test.go new file mode 100644 index 0000000000..68c9795d39 --- /dev/null +++ b/modules/setting/backup_test.go @@ -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 diff --git a/modules/setting/database.go b/modules/setting/database.go index 1a4bf64805..9de1e987f7 100644 --- a/modules/setting/database.go +++ b/modules/setting/database.go @@ -23,6 +23,9 @@ var ( // EnableSQLite3 use SQLite3, set by build flag EnableSQLite3 bool + // AllowSQLite3CreateForInstall temporarily allows installer and recovery flows to create a SQLite database even for installed instances. // edit/add - by petru @ codex + AllowSQLite3CreateForInstall bool + // Database holds the database settings Database = struct { Type DatabaseType @@ -126,8 +129,12 @@ func DBConnStr() (string, error) { if Database.SQLiteJournalMode != "" { journalMode = "&_journal_mode=" + Database.SQLiteJournalMode } - connStr = fmt.Sprintf("file:%s?cache=shared&mode=rwc&_busy_timeout=%d&_txlock=immediate%s", - Database.Path, Database.Timeout, journalMode) + sqliteMode := "rwc" + if (InstallLock || HasInstallSentinel()) && !AllowSQLite3CreateForInstall { + sqliteMode = "rw" // edit/add - by petru @ codex + } + connStr = fmt.Sprintf("file:%s?cache=shared&mode=%s&_busy_timeout=%d&_txlock=immediate%s", + Database.Path, sqliteMode, Database.Timeout, journalMode) default: return "", fmt.Errorf("unknown database type: %s", Database.Type) } diff --git a/modules/setting/database_test.go b/modules/setting/database_test.go index a742d54f8c..ae36ba62e8 100644 --- a/modules/setting/database_test.go +++ b/modules/setting/database_test.go @@ -4,9 +4,12 @@ package setting import ( + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func Test_parsePostgreSQLHostPort(t *testing.T) { @@ -107,3 +110,52 @@ func Test_getPostgreSQLConnectionString(t *testing.T) { assert.Equal(t, test.Output, connStr) } } + +// start edit/add - by petru @ codex +func TestDBConnStrSQLiteMode(t *testing.T) { + oldDatabase := Database + oldInstallLock := InstallLock + oldEnableSQLite3 := EnableSQLite3 + oldAllowSQLite3CreateForInstall := AllowSQLite3CreateForInstall + oldCustomConf := CustomConf + defer func() { + Database = oldDatabase + InstallLock = oldInstallLock + EnableSQLite3 = oldEnableSQLite3 + AllowSQLite3CreateForInstall = oldAllowSQLite3CreateForInstall + CustomConf = oldCustomConf + }() + + tmpDir := t.TempDir() + CustomConf = filepath.Join(tmpDir, "custom", "conf", "app.ini") + Database.Type = "sqlite3" + Database.Path = filepath.Join(tmpDir, "data", "gitea.db") + Database.Timeout = 500 + EnableSQLite3 = true + + connStr, err := DBConnStr() + require.NoError(t, err) + assert.Contains(t, connStr, "mode=rwc") + + InstallLock = true + connStr, err = DBConnStr() + require.NoError(t, err) + assert.Contains(t, connStr, "mode=rw") + assert.NotContains(t, connStr, "mode=rwc") + + InstallLock = false + require.NoError(t, os.MkdirAll(filepath.Dir(InstallSentinelPath()), 0o755)) + require.NoError(t, os.WriteFile(InstallSentinelPath(), []byte("installed\n"), 0o600)) + connStr, err = DBConnStr() + require.NoError(t, err) + assert.Contains(t, connStr, "mode=rw") + assert.NotContains(t, connStr, "mode=rwc") + + AllowSQLite3CreateForInstall = true + connStr, err = DBConnStr() + require.NoError(t, err) + assert.Contains(t, connStr, "mode=rwc") + assert.NotContains(t, connStr, "mode=rw&") +} + +// end edit/add - by petru @ codex diff --git a/modules/setting/install_sentinel.go b/modules/setting/install_sentinel.go new file mode 100644 index 0000000000..ea592c3058 --- /dev/null +++ b/modules/setting/install_sentinel.go @@ -0,0 +1,50 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "errors" + "os" + "path/filepath" +) + +// start edit/add - by petru @ codex +const installSentinelFileName = ".gitea-installed" + +// InstallSentinelPath returns the filesystem path of the install sentinel file. +func InstallSentinelPath() string { + if CustomConf == "" { + return "" + } + return filepath.Join(filepath.Dir(CustomConf), installSentinelFileName) +} + +// HasInstallSentinel reports whether the install sentinel file exists. +func HasInstallSentinel() bool { + sentinelPath := InstallSentinelPath() + if sentinelPath == "" { + return false + } + _, err := os.Stat(sentinelPath) + return err == nil +} + +// CreateInstallSentinel writes the install sentinel file that disables install mode. +func CreateInstallSentinel() error { + sentinelPath := InstallSentinelPath() + if sentinelPath == "" { + return errors.New("setting.CustomConf is not set") + } + return os.WriteFile(sentinelPath, []byte("installed\n"), 0o600) +} + +// EnsureInstallSentinel creates the install sentinel file if it does not already exist. +func EnsureInstallSentinel() error { + if HasInstallSentinel() { + return nil + } + return CreateInstallSentinel() +} + +// end edit/add - by petru @ codex diff --git a/modules/setting/install_sentinel_test.go b/modules/setting/install_sentinel_test.go new file mode 100644 index 0000000000..5829b375bb --- /dev/null +++ b/modules/setting/install_sentinel_test.go @@ -0,0 +1,41 @@ +// 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 TestInstallSentinelPath(t *testing.T) { + oldCustomConf := CustomConf + defer func() { + CustomConf = oldCustomConf + }() + + CustomConf = "/srv/gitea/custom/conf/app.ini" + assert.Equal(t, filepath.Join("/srv/gitea/custom/conf", installSentinelFileName), InstallSentinelPath()) +} + +func TestEnsureInstallSentinel(t *testing.T) { + oldCustomConf := CustomConf + defer func() { + CustomConf = oldCustomConf + }() + + CustomConf = filepath.Join(t.TempDir(), "app.ini") + assert.False(t, HasInstallSentinel()) + + require.NoError(t, EnsureInstallSentinel()) + assert.True(t, HasInstallSentinel()) + + require.NoError(t, EnsureInstallSentinel()) + assert.True(t, HasInstallSentinel()) +} + +// end edit/add - by petru @ codex diff --git a/modules/setting/recovery.go b/modules/setting/recovery.go new file mode 100644 index 0000000000..39cf82066b --- /dev/null +++ b/modules/setting/recovery.go @@ -0,0 +1,177 @@ +// 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 diff --git a/modules/setting/recovery_test.go b/modules/setting/recovery_test.go new file mode 100644 index 0000000000..9509356959 --- /dev/null +++ b/modules/setting/recovery_test.go @@ -0,0 +1,135 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// start edit/add - by petru @ codex +func TestRecoveryConfigPath(t *testing.T) { + oldCustomConf := CustomConf + defer func() { + CustomConf = oldCustomConf + }() + + CustomConf = "/srv/gitea/custom/conf/app.ini" + assert.Equal(t, filepath.Join("/srv/gitea/custom/conf", recoveryConfigFileName), RecoveryConfigPath()) +} + +func TestSaveAndLoadRecoveryConfig(t *testing.T) { + oldCustomConf := CustomConf + defer func() { + CustomConf = oldCustomConf + }() + + CustomConf = filepath.Join(t.TempDir(), "custom", "conf", "app.ini") + err := SaveRecoveryConfig(&RecoveryConfig{ + Enabled: true, + AllowedEmails: "admin@example.com,ops@example.com", + BackupPath: "custom/backups/db", + RepoRootPath: "/srv/gitea/repos", + SMTPAddr: "smtp.example.com", + SMTPPort: "587", + SMTPFrom: "Gitea ", + SMTPUser: "smtp-user", + SMTPPasswd: "smtp-pass", + }) + require.NoError(t, err) + + cfg, err := LoadRecoveryConfig() + require.NoError(t, err) + require.NotNil(t, cfg) + assert.True(t, cfg.Enabled) + assert.Equal(t, "admin@example.com,ops@example.com", cfg.AllowedEmails) + assert.Equal(t, "custom/backups/db", cfg.BackupPath) + assert.Equal(t, "/srv/gitea/repos", cfg.RepoRootPath) + assert.Equal(t, "smtp.example.com", cfg.SMTPAddr) + assert.Equal(t, "587", cfg.SMTPPort) + assert.Equal(t, "Gitea ", cfg.SMTPFrom) + assert.Equal(t, "smtp-user", cfg.SMTPUser) + assert.Equal(t, "smtp-pass", cfg.SMTPPasswd) + assert.NotEmpty(t, cfg.TokenSecret) + assert.Equal(t, defaultRecoveryTokenTTLMinutes, cfg.TokenTTLMin) +} + +func TestSaveRecoveryConfigDisabledRemovesFile(t *testing.T) { + oldCustomConf := CustomConf + defer func() { + CustomConf = oldCustomConf + }() + + CustomConf = filepath.Join(t.TempDir(), "custom", "conf", "app.ini") + require.NoError(t, os.MkdirAll(filepath.Dir(RecoveryConfigPath()), 0o755)) + require.NoError(t, os.WriteFile(RecoveryConfigPath(), []byte("[recovery]\nENABLED=true\n"), 0o600)) + + err := SaveRecoveryConfig(&RecoveryConfig{Enabled: false}) + require.NoError(t, err) + _, err = os.Stat(RecoveryConfigPath()) + assert.True(t, os.IsNotExist(err)) +} + +func TestRecoveryConfigAllowsEmail(t *testing.T) { + cfg := &RecoveryConfig{AllowedEmails: "admin@example.com, Ops@example.com "} + assert.True(t, cfg.AllowsEmail("admin@example.com")) + assert.True(t, cfg.AllowsEmail("ops@example.com")) + assert.False(t, cfg.AllowsEmail("other@example.com")) +} + +func TestIssueValidateAndConsumeRecoveryToken(t *testing.T) { + oldCustomConf := CustomConf + defer func() { + CustomConf = oldCustomConf + }() + + CustomConf = filepath.Join(t.TempDir(), "custom", "conf", "app.ini") + cfg := &RecoveryConfig{ + Enabled: true, + AllowedEmails: "admin@example.com", + TokenSecret: "test-secret", + TokenTTLMin: 5, + } + + now := time.Unix(1_700_000_000, 0) + token, err := IssueRecoveryToken(cfg, "admin@example.com", now) + require.NoError(t, err) + + claims, err := ValidateRecoveryToken(cfg, token, now.Add(time.Minute)) + require.NoError(t, err) + assert.Equal(t, "admin@example.com", claims.Email) + + require.NoError(t, ConsumeRecoveryToken(claims.ID)) + assert.ErrorIs(t, ConsumeRecoveryToken(claims.ID), ErrRecoveryTokenUsed) + _, err = ValidateRecoveryToken(cfg, token, now.Add(10*time.Minute)) + assert.ErrorIs(t, err, ErrRecoveryTokenInvalid) +} + +func TestIssueAndValidateRecoveryUnlockToken(t *testing.T) { + cfg := &RecoveryConfig{ + Enabled: true, + AllowedEmails: "admin@example.com", + TokenSecret: "test-secret", + TokenTTLMin: 5, + } + + now := time.Unix(1_700_000_000, 0) + unlockToken, err := IssueRecoveryUnlockToken(cfg, "admin@example.com", now) + require.NoError(t, err) + + claims, err := ValidateRecoveryUnlockToken(cfg, unlockToken, now.Add(time.Minute)) + require.NoError(t, err) + assert.Equal(t, "admin@example.com", claims.Email) + assert.Equal(t, recoveryTokenTypeUnlock, claims.Type) + + _, err = ValidateRecoveryToken(cfg, unlockToken, now.Add(time.Minute)) + assert.ErrorIs(t, err, ErrRecoveryTokenInvalid) +} + +// end edit/add - by petru @ codex diff --git a/modules/setting/recovery_token.go b/modules/setting/recovery_token.go new file mode 100644 index 0000000000..20f76ca740 --- /dev/null +++ b/modules/setting/recovery_token.go @@ -0,0 +1,152 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "path/filepath" + "slices" + "strings" + "time" + + "code.gitea.io/gitea/modules/generate" + + "github.com/golang-jwt/jwt/v5" +) + +// start edit/add - by petru @ codex +var ( + ErrRecoveryTokenInvalid = errors.New("invalid recovery token") + ErrRecoveryTokenUsed = errors.New("recovery token already used") + ErrRecoveryTokenMisconfigured = errors.New("recovery token configuration is incomplete") +) + +const ( + recoveryTokenTypeAccess = "access" + recoveryTokenTypeUnlock = "unlock" +) + +type RecoveryTokenClaims struct { + Email string `json:"email"` + Type string `json:"type"` + jwt.RegisteredClaims +} + +func IssueRecoveryToken(data *RecoveryConfig, email string, now time.Time) (string, error) { + return issueRecoveryToken(data, email, now, recoveryTokenTypeAccess) +} + +func IssueRecoveryUnlockToken(data *RecoveryConfig, email string, now time.Time) (string, error) { + return issueRecoveryToken(data, email, now, recoveryTokenTypeUnlock) +} + +func issueRecoveryToken(data *RecoveryConfig, email string, now time.Time, tokenType string) (string, error) { + if data == nil || strings.TrimSpace(data.TokenSecret) == "" { + return "", ErrRecoveryTokenMisconfigured + } + + tokenID, err := generate.NewSecretKey() + if err != nil { + return "", err + } + + claims := RecoveryTokenClaims{ + Email: strings.TrimSpace(email), + Type: tokenType, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(data.EffectiveTokenTTL())), + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), + ID: tokenID, + }, + } + + return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(data.TokenSecret)) +} + +func ValidateRecoveryToken(data *RecoveryConfig, token string, now time.Time) (*RecoveryTokenClaims, error) { + return validateRecoveryToken(data, token, now, recoveryTokenTypeAccess) +} + +func ValidateRecoveryUnlockToken(data *RecoveryConfig, token string, now time.Time) (*RecoveryTokenClaims, error) { + return validateRecoveryToken(data, token, now, recoveryTokenTypeUnlock) +} + +func validateRecoveryToken(data *RecoveryConfig, token string, now time.Time, expectedType string) (*RecoveryTokenClaims, error) { + if data == nil || strings.TrimSpace(data.TokenSecret) == "" { + return nil, ErrRecoveryTokenMisconfigured + } + + parsedToken, err := jwt.ParseWithClaims(token, &RecoveryTokenClaims{}, func(_ *jwt.Token) (any, error) { + return []byte(data.TokenSecret), nil + }, jwt.WithTimeFunc(func() time.Time { + return now + })) + if err != nil { + return nil, ErrRecoveryTokenInvalid + } + + claims, ok := parsedToken.Claims.(*RecoveryTokenClaims) + if !ok || !parsedToken.Valid || strings.TrimSpace(claims.Email) == "" || strings.TrimSpace(claims.ID) == "" || claims.Type != expectedType { + return nil, ErrRecoveryTokenInvalid + } + return claims, nil +} + +func ConsumeRecoveryToken(tokenID string) error { + tokenID = strings.TrimSpace(tokenID) + if tokenID == "" { + return ErrRecoveryTokenInvalid + } + + usedPath := RecoveryUsedTokensPath() + if usedPath == "" { + return errors.New("setting.CustomConf is not set") + } + if err := os.MkdirAll(filepath.Dir(usedPath), 0o755); err != nil { + return err + } + + tokenHash := hashRecoveryTokenID(tokenID) + usedHashes, err := loadRecoveryTokenHashes(usedPath) + if err != nil { + return err + } + if slices.Contains(usedHashes, tokenHash) { + return ErrRecoveryTokenUsed + } + + usedHashes = append(usedHashes, tokenHash) + return os.WriteFile(usedPath, []byte(strings.Join(usedHashes, "\n")+"\n"), 0o600) +} + +func loadRecoveryTokenHashes(usedPath string) ([]string, error) { + data, err := os.ReadFile(usedPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + lines := strings.Split(string(data), "\n") + hashes := make([]string, 0, len(lines)) + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + hashes = append(hashes, line) + } + } + return hashes, nil +} + +func hashRecoveryTokenID(tokenID string) string { + hash := sha256.Sum256([]byte(tokenID)) + return hex.EncodeToString(hash[:]) +} + +// end edit/add - by petru @ codex diff --git a/modules/setting/setting.go b/modules/setting/setting.go index 3c1ad14428..c45ab2b754 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -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 } diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 949e49ac20..9c69bc2a16 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -241,10 +241,17 @@ "install.installing_desc": "Installing now, please wait…", "install.title": "Initial Configuration", "install.language_balloon": "Choose a language", - "install.docker_helper": "If you run Gitea inside Docker, please read the documentation before changing any settings.", - "install.import_app_ini_title": "Import Existing Configuration", - "install.import_app_ini_desc": "Upload an existing app.ini file to prefill this installer form with the configuration values it already contains.", - "install.import_app_ini_file": "app.ini File", + "install.docker_helper": "If you run the instance inside Docker, please read the documentation before changing any settings.", + "install.import_app_ini_title": "Import Configuration File", + "install.import_app_ini_desc": "Open the recovery and reinstall options to import configuration files or choose available database backups before continuing.", + "install.recovery_title": "Recovery", + "install.recovery_desc": "Open the recovery options to restore or rebuild this existing instance after a configuration or database problem was detected.", + "install.recovery_problem_missing_app_ini": "The app.ini file is unavailable for this instance, so the database type and state cannot be determined without it.", + "install.recovery_problem_missing_app_ini_and_database_unavailable": "The app.ini file is unavailable for this instance, so the database type and state cannot be determined without it.", + "install.recovery_problem_database_unavailable": "The configured database file or database access is unavailable for this installed instance.", + "install.recovery_reinstall_title": "New Install or Replicate", + "install.recovery_reinstall_open": "Open recovery options", + "install.import_app_ini_file": "Select app.ini File", "install.import_app_ini_helper": "Optional. INI file only, up to %d KB. The imported values are copied into the installer form so you can review and adjust them before saving.", "install.import_app_ini_sensitive_secrets": "Import sensitive secrets from app.ini", "install.import_app_ini_sensitive_secrets_helper": "Optional. Also import LFS_JWT_SECRET, INTERNAL_TOKEN, and JWT_SECRET from the uploaded configuration. Use this for restores or migrations where existing signed tokens must continue working.", @@ -266,11 +273,82 @@ "install.ssl_mode": "SSL", "install.path": "Path", "install.sqlite_helper": "File path for the SQLite3 database.
Enter an absolute path if you run Gitea as a service.", + "install.database_backup_title": "Backup & Recovery Settings", + "install.recovery_launcher_backup_select": "Restore from database backup", + "install.recovery_launcher_backup_bundle": "Database backup bundle", + "install.recovery_launcher_backup_select_placeholder": "Choose a backup bundle", + "install.recovery_launcher_backup_import_app_ini": "Import app.ini", + "install.recovery_launcher_backup_restore_db": "Restore DB", + "install.recovery_launcher_sql_backup_file": "Select DB Backup File", + "install.recovery_launcher_database_backup_file": "Import Data Base Backup File", + "install.recovery_launcher_reset": "Reset", + "install.recovery_launcher_sql_backup_file_helper": "Optional. Upload a `.sql` or `.gz` database dump to restore it during the reinstall flow.", + "install.database_backup_desc": "Configure periodic database backups and the standalone recovery email metadata that this instace will keep for reinstall and disaster-recovery 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.recovery_email_enabled": "Enable email-based recovery authorization", + "install.recovery_email_enabled_helper": "Write a separate `.gitea-recovery.ini` next to `app.ini` that reuses the current Email Settings SMTP configuration for future recovery flows.", + "install.recovery_allowed_emails": "Valid recovery email addresses", + "install.recovery_allowed_emails_helper": "Comma- or newline-separated email addresses that will be allowed to receive one-time recovery authorization links.", + "install.recovery_email_requires_smtp": "Email-based recovery authorization requires the SMTP settings from Email Settings to be configured.", + "install.recovery_email_requires_addresses": "Email-based recovery authorization requires at least one valid recovery email address.", + "install.recovery_email_invalid_addresses": "The recovery email address list is invalid: %v", + "install.recovery_import_app_ini_required": "No compatible database backup bundles were found. Import an existing app.ini file to continue recovery for this installed instance.", + "install.recovery_identify_title": "Identify yourself", + "install.recovery_identify_email": "Recovery email address", + "install.recovery_identify_submit": "Send recovery link", + "install.recovery_identify_helper": "Enter one of the recovery email addresses configured for this instance. If it is allowed, %s will send a one-time access link for recovery.", + "install.recovery_email_sent_notice": "If the address is allowed, a one-time recovery link has been sent.", + "install.recovery_token_invalid": "The recovery link is invalid, expired, or has already been used.", + "install.recovery_reason_missing_config": "This instance was already installed, but its main configuration is unavailable. Recovery authorization is required before reinstalling or restoring it.", + "install.recovery_reason_database_unavailable": "This instance was already installed, but its configured database is unavailable or unreadable. Recovery authorization is required before reinstalling or restoring it.", + "install.recovery_access_mail_subject": "%s recovery access link", + "install.recovery_access_mail_intro": "A recovery request was made for this %s instance.", + "install.recovery_access_mail_action": "Open the recovery page", + "install.recovery_access_mail_expiry": "This one-time link expires in %d minutes.", + "install.recovery_access_mail_ignore": "If you did not request recovery access, you can ignore this email.", "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 %s 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": "No compatible database backup bundles were found for the selected database type.", + "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 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_database_backup_app_ini_required": "The selected backup bundle does not include an app.ini snapshot, so it cannot rebuild the missing configuration while keeping the current database.", + "install.recovery_database_backup_select_required": "Select a backup bundle before enabling database-backup recovery actions.", + "install.recovery_multiple_database_backups_error": "Choose either a backup bundle or an uploaded SQL/GZ database dump, not both at the same time.", + "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 +3233,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", diff --git a/options/locale/locale_ro-RO.json b/options/locale/locale_ro-RO.json index d316211abc..2e23a987d9 100644 --- a/options/locale/locale_ro-RO.json +++ b/options/locale/locale_ro-RO.json @@ -241,10 +241,17 @@ "install.installing_desc": "Se instalează așteptați...", "install.title": "Configurație inițială", "install.language_balloon": "Alegeți o limbă", - "install.docker_helper": "Dacă rulezi Gitea în interiorul Docker, te rugăm să citești documentația înainte de a modifica orice setare.", - "install.import_app_ini_title": "Importă o configurație existentă", - "install.import_app_ini_desc": "Încarcă un fișier app.ini existent pentru a precompleta acest formular de instalare cu valorile de configurare deja definite în el.", - "install.import_app_ini_file": "Fișier app.ini", + "install.docker_helper": "Dacă rulezi instanța în interiorul Docker, te rog să citești documentația înainte de a modifica orice setare.", + "install.import_app_ini_title": "Importă Fișierul de Configurație", + "install.import_app_ini_desc": "Deschide opțiunile de recovery și reinstalare pentru a importa fișiere de configurare sau a alege backupurile bazei de date disponibile înainte de a continua.", + "install.recovery_title": "Recuperare", + "install.recovery_desc": "Deschide opțiunile de recovery pentru a restaura sau reconstrui această instanță existentă după detectarea unei probleme de configurare sau de bază de date.", + "install.recovery_problem_missing_app_ini": "Fișierul app.ini este indisponibil pentru această instanță, iar tipul și starea bazei de date nu pot fi stabilite în lipsa acestuia.", + "install.recovery_problem_missing_app_ini_and_database_unavailable": "Fișierul app.ini este indisponibil pentru această instanță, iar tipul și starea bazei de date nu pot fi stabilite în lipsa acestuia.", + "install.recovery_problem_database_unavailable": "Fișierul bazei de date configurate sau accesul la baza de date nu este disponibil pentru instanța curentă.", + "install.recovery_reinstall_title": "Instalare Nouă sau Replicare", + "install.recovery_reinstall_open": "Deschide opțiunile de recovery", + "install.import_app_ini_file": "Selectază Fișierul app.ini", "install.import_app_ini_helper": "Opțional. Doar fișier INI, până la %d KB. Valorile importate sunt copiate în formularul de instalare pentru a le putea verifica și ajusta înainte de salvare.", "install.import_app_ini_sensitive_secrets": "Importă secretele sensibile din app.ini", "install.import_app_ini_sensitive_secrets_helper": "Opțional. Importă și LFS_JWT_SECRET, INTERNAL_TOKEN și JWT_SECRET din configurația încărcată. Folosește asta la restaurări sau migrări unde tokenurile semnate existente trebuie să continue să funcționeze.", @@ -266,11 +273,82 @@ "install.ssl_mode": "SSL", "install.path": "Cale Baza de Date", "install.sqlite_helper": "Calea fișierului pentru baza de date SQLite3.
Introduceți o cale absolută dacă rulați Gitea ca serviciu.", + "install.database_backup_title": "Setări Backup & Recuperare", + "install.recovery_launcher_backup_select": "Restaureaza din Backup-ul Automat", + "install.recovery_launcher_backup_bundle": "Set de Backup", + "install.recovery_launcher_backup_select_placeholder": "Alege un backup", + "install.recovery_launcher_backup_import_app_ini": "Importă app.ini", + "install.recovery_launcher_backup_restore_db": "Restaurează baza de date", + "install.recovery_launcher_sql_backup_file": "Selecteaza Backup-ul", + "install.recovery_launcher_database_backup_file": "Importa Backup-ul Bazei de Date", + "install.recovery_launcher_reset": "Reset", + "install.recovery_launcher_sql_backup_file_helper": "Opțional. Încarcă un dump de bază de date `.sql` sau `.gz` pentru a-l restaura în timpul fluxului de reinstalare.", + "install.database_backup_desc": "Configurează backupurile periodice ale bazei de date și metadatele separate pentru recuperare prin email pe care instanța le va păstra pentru scenarii de reinstalare sau recuperare.", + "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.recovery_email_enabled": "Activează autorizarea recovery prin email", + "install.recovery_email_enabled_helper": "Scrie un fișier separat `.gitea-recovery.ini` lângă `app.ini`, care reutilizează configurația SMTP actuală din Setări email pentru flow-urile viitoare de recovery.", + "install.recovery_allowed_emails": "Adrese email valide pentru recovery", + "install.recovery_allowed_emails_helper": "Adrese email separate prin virgulă sau rând nou, cărora li se va putea trimite linkul one-time de autorizare pentru recovery.", + "install.recovery_email_requires_smtp": "Autorizarea recovery prin email necesită configurarea setărilor SMTP din Setări email.", + "install.recovery_email_requires_addresses": "Autorizarea recovery prin email necesită cel puțin o adresă email validă pentru recovery.", + "install.recovery_email_invalid_addresses": "Lista adreselor email de recovery este invalidă: %v", + "install.recovery_import_app_ini_required": "Nu au fost găsite bundle-uri compatibile de backup pentru baza de date. Importă un fișier app.ini existent pentru a continua recovery-ul acestei instanțe instalate.", + "install.recovery_identify_title": "Identifică-te", + "install.recovery_identify_email": "Adresa email de recovery", + "install.recovery_identify_submit": "Trimite linkul de recovery", + "install.recovery_identify_helper": "Introdu una dintre adresele email de recovery configurate pentru această instanță. Dacă este permisă, %s va trimite un link one-time de acces pentru recovery.", + "install.recovery_email_sent_notice": "Dacă adresa este permisă, a fost trimis un link one-time de recovery.", + "install.recovery_token_invalid": "Linkul de recovery este invalid, expirat sau a fost deja folosit.", + "install.recovery_reason_missing_config": "Această instanță a fost deja instalată, dar configurația principală nu mai este disponibilă. Este necesară autorizarea de recovery înainte de reinstalare sau restaurare.", + "install.recovery_reason_database_unavailable": "Această instanță a fost deja instalată, dar baza de date configurată este indisponibilă sau ilizibilă. Este necesară autorizarea de recovery înainte de reinstalare sau restaurare.", + "install.recovery_access_mail_subject": "Link de acces pentru recovery %s", + "install.recovery_access_mail_intro": "A fost făcută o cerere de recovery pentru această instanță %s.", + "install.recovery_access_mail_action": "Deschide pagina de recovery", + "install.recovery_access_mail_expiry": "Acest link one-time expiră în %d minute.", + "install.recovery_access_mail_ignore": "Dacă nu ai cerut acces de recovery, poți ignora acest email.", "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 %s 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": "Nu au fost găsite bundle-uri de backup compatibile cu tipul de bază de date selectat.", + "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ă setul 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 ș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_database_backup_app_ini_required": "Bundle-ul de backup selectat nu include un snapshot app.ini, deci nu poate reconstrui configurația lipsă păstrând baza de date curentă.", + "install.recovery_database_backup_select_required": "Selectează un bundle de backup înainte de a activa acțiunile de recovery din backupul bazei de date.", + "install.recovery_multiple_database_backups_error": "Alege fie un bundle de backup, fie un dump SQL/GZ încărcat, nu ambele în același timp.", + "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 +3233,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", diff --git a/routers/install/install.go b/routers/install/install.go index 508eba84f0..7678448716 100644 --- a/routers/install/install.go +++ b/routers/install/install.go @@ -6,9 +6,12 @@ package install import ( "bytes" + "compress/gzip" + std_context "context" "errors" + "fmt" + templatehtml "html/template" "image" - _ "image/png" "io" "net/http" "net/mail" @@ -23,6 +26,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 +45,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 +64,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 +77,16 @@ type installBrandingAssetSpec struct { MimeType string } +// start edit/add - by petru @ codex +type installBackupChoice struct { + ID string + Label string + HasAppINI bool + HasDatabase bool +} + +// 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 +387,29 @@ 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") + form.RecoveryEmailEnabled = !installCustomConfExists() // edit/add - by petru @ codex + if recoveryCfg, err := setting.LoadRecoveryConfig(); err == nil && recoveryCfg != nil { + form.RecoveryEmailEnabled = recoveryCfg.Enabled // edit/add - by petru @ codex + form.RecoveryAllowedEmails = recoveryCfg.AllowedEmails // edit/add - by petru @ codex + if !installCustomConfExists() && strings.TrimSpace(recoveryCfg.BackupPath) != "" { + form.BackupPath = recoveryCfg.BackupPath // edit/add - by petru @ codex + } + if !installCustomConfExists() && strings.TrimSpace(recoveryCfg.RepoRootPath) != "" { + form.RepoRootPath = recoveryCfg.RepoRootPath // edit/add - by petru @ codex + } + } else if err != nil { + log.Error("Unable to load recovery config %q: %v", setting.RecoveryConfigPath(), err) // edit/add - by petru @ codex + } + // end edit/add - by petru @ codex form.AllowAdoptionOfUnadoptedRepositories = true // edit/add - by petru @ codex form.AllowDeleteOfUnadoptedRepositories = true // edit/add - by petru @ codex normalizeInstallRegistrationOptions(&form) @@ -374,20 +417,475 @@ func newInstallFormFromSettings() (forms.InstallForm, string) { return form, curDBType } -func renderInstallPage(ctx *context.Context, form *forms.InstallForm, curDBType string) { +func assignInstallPageData(ctx *context.Context, form *forms.InstallForm, curDBType string, availableBackups []*installBackupChoice, backupDiscoveryErr error) []*installBackupChoice { + recoveryRequest := isRecoveryRequest(ctx.Req) // edit/add - by petru @ codex + installerSiteName := resolveInstallPageSiteName(form, recoveryRequest) // edit/add - by petru @ codex + if recoveryRequest { + ctx.RespHeader().Set("Cache-Control", "no-store, no-cache, must-revalidate") // edit/add - by petru @ codex + ctx.RespHeader().Set("Pragma", "no-cache") // edit/add - by petru @ codex + ctx.RespHeader().Set("Expires", "0") // edit/add - by petru @ codex + } if form.DefaultLanguage == "" { form.DefaultLanguage = resolveInstallDefaultLanguage("") } normalizeInstallRegistrationOptions(form) + if availableBackups == nil { + if discoveredBackups, err := listInstallRecoveryBackupChoices(form, recoveryRequest); err == nil { + availableBackups = discoveredBackups + ctx.Data["InstallRecoveryBackups"] = availableBackups // edit/add - by petru @ codex + ctx.Data["InstallRecoveryOptionDatabaseBackupAvailable"] = len(availableBackups) > 0 + } else { + backupDiscoveryErr = err + ctx.Data["Err_BackupPath"] = true // edit/add - by petru @ codex + } + } else { + ctx.Data["InstallRecoveryBackups"] = availableBackups // edit/add - by petru @ codex + ctx.Data["InstallRecoveryOptionDatabaseBackupAvailable"] = len(availableBackups) > 0 + } + if recoveryRequest { + hasExistingDatabase := false + if dbPreviouslyUsed, err := detectInstallExistingDatabase(ctx); err == nil && dbPreviouslyUsed { + hasExistingDatabase = true + } + setInstallRecoverySourceAvailability(ctx, hasExistingDatabase, availableBackups, hasInstallRepositoryFilesystemRecovery(form)) // edit/add - by petru @ codex + } ctx.Data["CurDbType"] = curDBType - ctx.Data["InstallerSiteName"] = installMailerDisplayName(form.AppName) + ctx.Data["InstallIsRecoveryRequest"] = recoveryRequest // edit/add - by petru @ codex + ctx.Data["InstallerSiteName"] = installerSiteName // edit/add - by petru @ codex + ctx.Data["Title"] = ctx.Tr("install.install_btn_confirm", ctx.Data["InstallerSiteName"]) // edit/add - by petru @ codex + ctx.Data["InstallRecoverySectionTitle"] = ctx.Tr("install.import_app_ini_title") // edit/add - by petru @ codex + ctx.Data["InstallRecoverySectionDesc"] = ctx.Tr("install.import_app_ini_desc") // edit/add - by petru @ codex + ctx.Data["InstallRecoveryLauncherTitle"] = ctx.Tr("install.recovery_reinstall_title") // edit/add - by petru @ codex + ctx.Data["InstallRecoveryProblemText"] = "" // edit/add - by petru @ codex + if recoveryRequest { + ctx.Data["InstallRecoverySectionTitle"] = ctx.Tr("install.recovery_title") // edit/add - by petru @ codex + ctx.Data["InstallRecoverySectionDesc"] = ctx.Tr("install.recovery_desc") // edit/add - by petru @ codex + ctx.Data["InstallRecoveryLauncherTitle"] = ctx.Tr("install.recovery_title") // edit/add - by petru @ codex + if !installCustomConfExists() { + if dbPreviouslyUsed, err := detectInstallExistingDatabase(ctx); err != nil || !dbPreviouslyUsed { + ctx.Data["InstallRecoveryProblemText"] = ctx.Tr("install.recovery_problem_missing_app_ini_and_database_unavailable") // edit/add - by petru @ codex + } else { + ctx.Data["InstallRecoveryProblemText"] = ctx.Tr("install.recovery_problem_missing_app_ini") // edit/add - by petru @ codex + } + } else if dbPreviouslyUsed, err := detectInstallExistingDatabase(ctx); err != nil || !dbPreviouslyUsed { + ctx.Data["InstallRecoveryProblemText"] = ctx.Tr("install.recovery_problem_database_unavailable") // edit/add - by petru @ codex + } + } + if recoveryRequest && backupDiscoveryErr != nil { + ctx.Data["InstallRecoveryProblemText"] = ctx.Tr("install.recovery_database_backup_discovery_failed", backupDiscoveryErr) // edit/add - by petru @ codex + } + if ctx.Flash != nil && ctx.Flash.ErrorMsg != "" { + ctx.Data["InstallRecoveryProblemText"] = ctx.Flash.ErrorMsg // edit/add - by petru @ codex + } + ctx.Data["InstallFormAction"] = installRouteURL("/", recoveryRequest) // edit/add - by petru @ codex + ctx.Data["InstallImportAction"] = installRouteURL("/import_app_ini", recoveryRequest) // edit/add - by petru @ codex + ctx.Data["InstallBackupAppINIImportAction"] = installRouteURL("/import_backup_app_ini", recoveryRequest) // edit/add - by petru @ codex + ctx.Data["InstallTestMailAction"] = installRouteURL("/test_mail", recoveryRequest) // edit/add - by petru @ codex middleware.AssignForm(form, ctx.Data) + return availableBackups +} + +func renderInstallPage(ctx *context.Context, form *forms.InstallForm, curDBType string) { + assignInstallPageData(ctx, form, curDBType, nil, nil) // edit/add - by petru @ codex ctx.HTML(http.StatusOK, tplInstall) } -func importedDBType(dbType string, fallback string) string { +// start edit/add - by petru @ codex +func installRouteURL(path string, recovery bool) string { + target := setting.AppSubURL + path + if !recovery { + return target + } + return target + "?" + recoveryRequestQueryKey + "=1" +} + +// start edit/add - by petru @ codex +func recoveryInstallSiteName() string { + recoveryCfg, recoveryErr := setting.LoadRecoveryConfig() + if recoveryErr != nil || recoveryCfg == nil { + return "" + } + parsedFrom, parseErr := mail.ParseAddress(strings.TrimSpace(recoveryCfg.SMTPFrom)) + if parseErr != nil { + return "" + } + return strings.TrimSpace(parsedFrom.Name) +} + +func resolveInstallPageSiteName(form *forms.InstallForm, recovery bool) string { + if form == nil { + return installMailerDisplayName("") + } + + if recovery { + recoverySiteName := recoveryInstallSiteName() + if recoverySiteName != "" { + if !form.ImportedAppINI && installMailerDisplayName(form.AppName) == "Gitea" { + form.AppName = recoverySiteName + } + if resolvedSiteName := installMailerDisplayName(form.AppName); resolvedSiteName != "Gitea" { + return resolvedSiteName + } + return recoverySiteName + } + } + + return installMailerDisplayName(form.AppName) +} + +// end 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 installCustomConfExists() bool { + // start edit/add - by petru @ codex + if strings.TrimSpace(setting.CustomConf) == "" { + return false + } + _, err := os.Stat(setting.CustomConf) + return err == nil + // end edit/add - by petru @ codex +} + +// start edit/add - by petru @ codex +func normalizeInstallRecoveryEmails(raw string) (string, error) { + parts := strings.FieldsFunc(raw, func(r rune) bool { + return r == ',' || r == ';' || r == '\n' || r == '\r' || r == '\t' + }) + if len(parts) == 0 { + return "", nil + } + + normalized := make([]string, 0, len(parts)) + seen := make(map[string]struct{}, len(parts)) + for _, part := range parts { + addr, err := mail.ParseAddress(strings.TrimSpace(part)) + if err != nil { + return "", err + } + if _, ok := seen[addr.Address]; ok { + continue + } + seen[addr.Address] = struct{}{} + normalized = append(normalized, addr.Address) + } + return strings.Join(normalized, ","), nil +} + +// end edit/add - by petru @ codex + +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), + HasAppINI: strings.TrimSpace(backup.AppINISnapshotFile) != "", + HasDatabase: strings.TrimSpace(backup.FileName) != "", + }) + } + return choices, nil +} + +func listInstallRecoveryBackupChoices(form *forms.InstallForm, recoveryRequest bool) ([]*installBackupChoice, error) { + // start edit/add - by petru @ codex + if !recoveryRequest { + return listInstallBackupChoices(form.BackupPath, form.DbType) + } + + backupPaths := []string{form.BackupPath} + if recoveryCfg, err := setting.LoadRecoveryConfig(); err == nil && recoveryCfg != nil && strings.TrimSpace(recoveryCfg.BackupPath) != "" { + backupPaths = append(backupPaths, recoveryCfg.BackupPath) + } + + seenPaths := make(map[string]struct{}, len(backupPaths)) + seenChoices := make(map[string]struct{}) + choices := make([]*installBackupChoice, 0) + for _, backupPath := range backupPaths { + resolvedPath := resolveInstallBackupPath(backupPath) + if resolvedPath == "" { + continue + } + if _, ok := seenPaths[resolvedPath]; ok { + continue + } + seenPaths[resolvedPath] = struct{}{} + + pathChoices, err := listInstallBackupChoices(backupPath, "") + if err != nil { + return nil, err + } + for _, choice := range pathChoices { + choiceKey := choice.ID + "\x00" + choice.Label + if _, ok := seenChoices[choiceKey]; ok { + continue + } + seenChoices[choiceKey] = struct{}{} + choices = append(choices, choice) + } + } + + slices.SortFunc(choices, func(a, b *installBackupChoice) int { + if a.ID > b.ID { + return -1 + } + if a.ID < b.ID { + return 1 + } + return 0 + }) + return choices, nil + // end edit/add - by petru @ codex +} + +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 +} + +// start edit/add - by petru @ codex +func hasInstallRepositoryFilesystemRecovery(form *forms.InstallForm) bool { + if form == nil { + return false + } + if !installCustomConfExists() && (!form.ImportedAppINI || form.BackupImportAppINI) { + return false + } + return hasInstallRepositoryFilesystem(form.RepoRootPath) +} + +// end edit/add - by petru @ codex + +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 +} + +func mergeInstallRecoveryConfigSMTP(existing, next *setting.RecoveryConfig) *setting.RecoveryConfig { + // start edit/add - by petru @ codex + if next == nil { + return existing + } + if existing == nil { + return next + } + if strings.TrimSpace(next.SMTPAddr) == "" { + next.SMTPAddr = existing.SMTPAddr + } + if strings.TrimSpace(next.SMTPPort) == "" { + next.SMTPPort = existing.SMTPPort + } + if strings.TrimSpace(next.SMTPFrom) == "" { + next.SMTPFrom = existing.SMTPFrom + } + if strings.TrimSpace(next.SMTPUser) == "" { + next.SMTPUser = existing.SMTPUser + } + if next.SMTPPasswd == "" { + next.SMTPPasswd = existing.SMTPPasswd + } + return next + // end edit/add - by petru @ codex +} + +func shouldRestoreDatabaseFromBackup(recoveryMode string, dbPreviouslyUsed bool) bool { + // start edit/add - by petru @ codex + return recoveryMode == recoveryModeDatabaseBackup && !dbPreviouslyUsed + // end edit/add - by petru @ codex +} + +func shouldRequireBackupAppINISnapshot(recoveryMode string, dbPreviouslyUsed bool) bool { + // start edit/add - by petru @ codex + return recoveryMode == recoveryModeDatabaseBackup && dbPreviouslyUsed + // end edit/add - by petru @ codex +} + +// start edit/add - by petru @ codex +func hasInstallRecoveryAppINIConfigSource(form *forms.InstallForm, backupAppINICfg setting.ConfigProvider) bool { + if backupAppINICfg != nil { + return true + } + return form != nil && form.ImportedAppINI && !form.BackupImportAppINI +} + +// end edit/add - by petru @ codex + +// start edit/add - by petru @ codex +func removeInstallSQLiteDatabaseArtifacts(dbPath string) error { + dbPath = strings.TrimSpace(dbPath) + if dbPath == "" { + return nil + } + for _, path := range []string{dbPath, dbPath + "-wal", dbPath + "-shm", dbPath + "-journal"} { + err := os.Remove(path) + if err == nil || errors.Is(err, os.ErrNotExist) { + continue + } + return err + } + return nil +} + +// end edit/add - by petru @ codex + +func installUploadedDatabaseBackupProvided(ctx *context.Context) bool { + // start edit/add - by petru @ codex + file, header, err := ctx.Req.FormFile("database_backup_file") + if file != nil { + _ = file.Close() + } + if err != nil || header == nil { + return false + } + return strings.TrimSpace(header.Filename) != "" + // end edit/add - by petru @ codex +} + +func importUploadedDatabaseBackup(ctx *context.Context) error { + // start edit/add - by petru @ codex + file, header, err := ctx.Req.FormFile("database_backup_file") + if err != nil { + return err + } + defer file.Close() + + sqlPath, cleanup, err := prepareUploadedInstallDatabaseBackup(file, header.Filename) + if err != nil { + return err + } + defer cleanup() + + return db.ImportDatabase(sqlPath) + // end edit/add - by petru @ codex +} + +func prepareUploadedInstallDatabaseBackup(reader io.Reader, filename string) (string, func(), error) { + // start edit/add - by petru @ codex + trimmedName := strings.TrimSpace(strings.ToLower(filename)) + if trimmedName == "" { + return "", nil, errors.New("missing database backup file name") + } + + tempInputFile, err := os.CreateTemp("", "gitea-install-db-upload-*") + if err != nil { + return "", nil, err + } + inputPath := tempInputFile.Name() + cleanup := func() { + _ = os.Remove(inputPath) + } + if _, err := io.Copy(tempInputFile, reader); err != nil { + _ = tempInputFile.Close() + cleanup() + return "", nil, err + } + if err := tempInputFile.Close(); err != nil { + cleanup() + return "", nil, err + } + + if strings.HasSuffix(trimmedName, ".gz") { + sqlFile, err := os.CreateTemp("", "gitea-install-db-upload-*.sql") + if err != nil { + cleanup() + return "", nil, err + } + sqlPath := sqlFile.Name() + gzipCleanup := func() { + cleanup() + _ = os.Remove(sqlPath) + } + + inputFile, err := os.Open(inputPath) + if err != nil { + _ = sqlFile.Close() + gzipCleanup() + return "", nil, err + } + defer inputFile.Close() + + gzipReader, err := gzip.NewReader(inputFile) + if err != nil { + _ = sqlFile.Close() + gzipCleanup() + return "", nil, err + } + defer gzipReader.Close() + + if _, err := io.Copy(sqlFile, gzipReader); err != nil { + _ = sqlFile.Close() + gzipCleanup() + return "", nil, err + } + if err := sqlFile.Close(); err != nil { + gzipCleanup() + return "", nil, err + } + return sqlPath, gzipCleanup, nil + } + + return inputPath, cleanup, nil + // end edit/add - by petru @ codex +} + +// end edit/add - by petru @ codex + +func importedDBType(dbType, fallback string) string { dbType = strings.TrimSpace(strings.ToLower(dbType)) - if slices.Contains(setting.SupportedDatabaseTypes, dbType) { + switch dbType { + case "sqlite": + dbType = "sqlite3" // edit/add - by petru @ codex + case "postgresql": + dbType = "postgres" // edit/add - by petru @ codex + } + if dbType != "" { return dbType } return fallback @@ -409,7 +907,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 +931,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 +999,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") @@ -601,7 +1110,7 @@ func applyInstallSensitiveSecretsToConfig(cfg setting.ConfigProvider, form *form if form.ImportSensitiveSecrets && form.ImportedLFSJWTSecret != "" { cfg.Section("server").DeleteKey("LFS_JWT_SECRET_URI") cfg.Section("server").Key("LFS_JWT_SECRET").SetValue(form.ImportedLFSJWTSecret) - } else if !cfg.Section("server").HasKey("LFS_JWT_SECRET_URI") { + } else if !cfg.Section("server").HasKey("LFS_JWT_SECRET") && !cfg.Section("server").HasKey("LFS_JWT_SECRET_URI") { // edit/add - by petru @ codex _, lfsJwtSecret := generate.NewJwtSecretWithBase64() cfg.Section("server").Key("LFS_JWT_SECRET").SetValue(lfsJwtSecret) } @@ -635,24 +1144,100 @@ func applyInstallSensitiveSecretsToConfig(cfg setting.ConfigProvider, form *form // Install render installation page func Install(ctx *context.Context) { - if setting.InstallLock { - InstallDone(ctx) - return + if setting.InstallLock { // edit/add - by petru @ codex + if isRecoveryRequest(ctx.Req) { + // start edit/add - by petru @ codex + if dbPreviouslyUsed, err := detectInstallExistingDatabase(ctx); err == nil && dbPreviouslyUsed { + ctx.Redirect(setting.AppSubURL+"/post-install", http.StatusSeeOther) + return + } + // end edit/add - by petru @ codex + } else { + InstallDone(ctx) + return + } } form, curDBType := newInstallFormFromSettings() renderInstallPage(ctx, &form, curDBType) } +// start edit/add - by petru @ codex +func applyInstallImportPreviewStateFromRequest(form *forms.InstallForm, ctx *context.Context) { + if form == nil { + return + } + + form.ImportSensitiveSecrets = ctx.FormBool("import_sensitive_secrets") + form.ImportedAppINI = ctx.FormBool("imported_app_ini") + form.BackupRestoreID = ctx.FormString("backup_restore_id") + form.BackupImportAppINI = ctx.FormBool("backup_import_app_ini") + form.BackupRestoreDB = ctx.FormBool("backup_restore_db") + form.RecoveryMode = ctx.FormString("recovery_mode") + form.ReinstallConfirmFirst = ctx.FormBool("reinstall_confirm_first") + form.ReinstallConfirmSecond = ctx.FormBool("reinstall_confirm_second") + form.ReinstallConfirmThird = ctx.FormBool("reinstall_confirm_third") + form.ImportedLFSJWTSecret = ctx.FormString("imported_lfs_jwt_secret") + form.ImportedInternalToken = ctx.FormString("imported_internal_token") + form.ImportedOAuth2JWTSecret = ctx.FormString("imported_o_auth2_jwt_secret") + if backupPath := strings.TrimSpace(ctx.FormString("backup_path")); backupPath != "" { + form.BackupPath = backupPath + } + normalizeInstallRecoverySelectionState(form, false) // edit/add - by petru @ codex +} + +// start edit/add - by petru @ codex +func applyImportedAppINIDefaults(form *forms.InstallForm) { + if form == nil { + return + } + + form.ImportedAppINI = true + form.ImportSensitiveSecrets = true +} + +// end edit/add - by petru @ codex + +// start edit/add - by petru @ codex +func normalizeInstallRecoverySelectionState(form *forms.InstallForm, uploadedDatabaseBackupProvided bool) { + if form == nil { + return + } + + if form.BackupImportAppINI { + form.ImportSensitiveSecrets = true // edit/add - by petru @ codex + } + if uploadedDatabaseBackupProvided || (form.BackupRestoreDB && strings.TrimSpace(form.BackupRestoreID) != "") { + form.RecoveryMode = recoveryModeDatabaseBackup + } +} + +// start edit/add - by petru @ codex +func hasSelectedInstallDatabaseBackupSource(form *forms.InstallForm, uploadedDatabaseBackupProvided bool) bool { + if form == nil { + return uploadedDatabaseBackupProvided + } + return uploadedDatabaseBackupProvided || strings.TrimSpace(form.BackupRestoreID) != "" +} + +func defaultInstallRecoveryAllowedEmails(form *forms.InstallForm) string { // edit/add - by petru @ codex + if form == nil || strings.TrimSpace(form.RecoveryAllowedEmails) != "" { + return "" + } + return strings.TrimSpace(form.AdminEmail) +} + +// end edit/add - by petru @ codex + func ImportAppINI(ctx *context.Context) { - if setting.InstallLock { + if setting.InstallLock && !isRecoveryRequest(ctx.Req) { // edit/add - by petru @ codex InstallDone(ctx) return } form, curDBType := newInstallFormFromSettings() - form.ImportSensitiveSecrets = true // edit/add - by petru @ codex - form.ImportedAppINI = true // edit/add - by petru @ codex + applyInstallImportPreviewStateFromRequest(&form, ctx) // edit/add - by petru @ codex + applyImportedAppINIDefaults(&form) // edit/add - by petru @ codex cfg, err := readImportedInstallConfig(ctx) if err != nil { ctx.Data["CurDbType"] = curDBType @@ -665,6 +1250,40 @@ func ImportAppINI(ctx *context.Context) { renderInstallPage(ctx, &form, curDBType) } +// start edit/add - by petru @ codex +func ImportBackupAppINI(ctx *context.Context) { + if setting.InstallLock && !isRecoveryRequest(ctx.Req) { + InstallDone(ctx) + return + } + + form, curDBType := newInstallFormFromSettings() + applyInstallImportPreviewStateFromRequest(&form, ctx) + if form.BackupRestoreID == "" { + ctx.Data["CurDbType"] = curDBType + ctx.RenderWithErrDeprecated(ctx.Tr("install.recovery_database_backup_select_required"), tplInstall, &form) + return + } + + cfg, err := dbbackup_service.LoadBackupAppINIConfig(resolveInstallBackupPath(form.BackupPath), form.BackupRestoreID) + if err != nil { + ctx.Data["CurDbType"] = curDBType + ctx.RenderWithErrDeprecated(ctx.Tr("install.recovery_database_backup_restore_failed", err), tplInstall, &form) + return + } + if cfg == nil { + ctx.Data["CurDbType"] = curDBType + ctx.RenderWithErrDeprecated(ctx.Tr("install.recovery_import_app_ini_required"), tplInstall, &form) + return + } + + applyBackupAppINIToInstallForm(&form, cfg) + ctx.Flash.Success(ctx.Tr("install.import_app_ini_success"), true) + renderInstallPage(ctx, &form, form.DbType) +} + +// end edit/add - by petru @ codex + func installMailerDisplayName(appName string) string { appName = strings.TrimSpace(appName) if appName == "" { @@ -734,12 +1353,62 @@ 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 } } +func applyBackupAppINIToInstallForm(form *forms.InstallForm, cfg setting.ConfigProvider) { + // start edit/add - by petru @ codex + if form == nil || cfg == nil { + return + } + + selectedRecoveryMode := form.RecoveryMode + selectedBackupRestoreID := form.BackupRestoreID + selectedBackupPath := form.BackupPath + selectedBackupImportAppINI := form.BackupImportAppINI + + curDBType := importedDBType(form.DbType, form.DbType) + populateInstallFormFromConfig(form, cfg, curDBType) + applyImportedAppINIDefaults(form) + populateInstallSensitiveSecretsFromConfig(form, cfg) + + form.RecoveryMode = selectedRecoveryMode + form.BackupRestoreID = selectedBackupRestoreID + form.BackupPath = selectedBackupPath + form.BackupImportAppINI = selectedBackupImportAppINI + // end edit/add - by petru @ codex +} + +func detectInstallExistingDatabase(ctx std_context.Context) (bool, error) { + // start edit/add - by petru @ codex + db.UnsetDefaultEngine() + defer db.UnsetDefaultEngine() + + if err := db.InitEngine(ctx); err != nil { + return false, err + } + + if err := db_install.CheckDatabaseConnection(ctx); err != nil { + return false, err + } + + hasPostInstallationUser, err := db_install.HasPostInstallationUsers(ctx) + if err != nil { + return false, err + } + + dbMigrationVersion, err := db_install.GetMigrationVersion(ctx) + if err != nil { + return false, err + } + + return hasPostInstallationUser || dbMigrationVersion > 0, nil + // end edit/add - by petru @ codex +} + // end edit/add - by petru @ codex // TestMail checks the mail settings entered on the install page. @@ -845,7 +1514,7 @@ func inferInstallMailProtocol(smtpAddr, smtpPort string) string { } } -func checkDatabase(ctx *context.Context, form *forms.InstallForm) (bool, bool) { +func checkDatabase(ctx *context.Context, form *forms.InstallForm, availableBackups []*installBackupChoice, uploadedDatabaseBackupProvided bool) (bool, bool) { var err error if (setting.Database.Type == "sqlite3") && @@ -894,13 +1563,40 @@ 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, availableBackups, false) // edit/add - by petru @ codex + if form.RecoveryMode == "" { + form.RecoveryMode = recoveryModeExistingDatabase + } + + recoverySelectionValid := installRecoveryConfirmationsAccepted(form) + switch form.RecoveryMode { + case recoveryModeExistingDatabase: + // nothing extra + case recoveryModeDatabaseBackup: + recoverySelectionValid = recoverySelectionValid && hasSelectedInstallDatabaseBackupSource(form, uploadedDatabaseBackupProvided) + default: + recoverySelectionValid = false + } + if !recoverySelectionValid { + if form.RecoveryMode == recoveryModeDatabaseBackup && (len(availableBackups) > 0 || uploadedDatabaseBackupProvided) { + ctx.Data["Err_DatabaseBackupRecovery"] = true + assignInstallPageData(ctx, form, form.DbType, availableBackups, nil) // edit/add - by petru @ codex + ctx.Data["InstallRecoveryProblemText"] = ctx.Tr("install.recovery_database_backup_error") // edit/add - by petru @ codex + ctx.RenderWithErrDeprecated(ctx.Tr("install.recovery_database_backup_error"), tplInstall, form) + return false, dbPreviouslyUsed + } ctx.Data["Err_DbInstalledBefore"] = true + assignInstallPageData(ctx, form, form.DbType, availableBackups, nil) // edit/add - by petru @ codex + ctx.Data["InstallRecoveryProblemText"] = ctx.Tr("install.reinstall_error") // edit/add - by petru @ codex ctx.RenderWithErrDeprecated(ctx.Tr("install.reinstall_error"), tplInstall, form) return false, dbPreviouslyUsed } + if form.RecoveryMode == recoveryModeDatabaseBackup { + log.Info("User chose database-backup recovery over a pre-existing Gitea database; the selected recovery configuration source will be applied") + return true, dbPreviouslyUsed + } + log.Info("User confirmed re-installation of Gitea into a pre-existing database") } @@ -913,16 +1609,26 @@ func checkDatabase(ctx *context.Context, form *forms.InstallForm) (bool, bool) { // SubmitInstall response for submit install items func SubmitInstall(ctx *context.Context) { - if setting.InstallLock { + if setting.InstallLock && !isRecoveryRequest(ctx.Req) { // edit/add - by petru @ codex InstallDone(ctx) return } var err error + var backupAppINICfg setting.ConfigProvider // edit/add - by petru @ codex form := *web.GetForm(ctx).(*forms.InstallForm) + recoveryRequest := isRecoveryRequest(ctx.Req) || ctx.FormBool("recovery_request") // edit/add - by petru @ codex + if recoveryRequest { + setting.AllowSQLite3CreateForInstall = true // edit/add - by petru @ codex + defer func() { + setting.AllowSQLite3CreateForInstall = false // edit/add - by petru @ codex + }() + } form.ImportSensitiveSecrets = ctx.FormBool("import_sensitive_secrets") - form.ImportedAppINI = ctx.FormBool("imported_app_ini") // edit/add - by petru @ codex + form.ImportedAppINI = ctx.FormBool("imported_app_ini") // edit/add - by petru @ codex + form.BackupImportAppINI = ctx.FormBool("backup_import_app_ini") // edit/add - by petru @ codex + form.BackupRestoreDB = ctx.FormBool("backup_restore_db") // edit/add - by petru @ codex form.ImportedLFSJWTSecret = ctx.FormString("imported_lfs_jwt_secret") form.ImportedInternalToken = ctx.FormString("imported_internal_token") form.ImportedOAuth2JWTSecret = ctx.FormString("imported_o_auth2_jwt_secret") @@ -931,6 +1637,18 @@ func SubmitInstall(ctx *context.Context) { populateInstallSensitiveSecretsFromConfig(&form, importedCfg) } } + missingAppINIRecovery := recoveryRequest && !form.ImportedAppINI && !installCustomConfExists() // edit/add - by petru @ codex + uploadedDatabaseBackupProvided := installUploadedDatabaseBackupProvided(ctx) // edit/add - by petru @ codex + normalizeInstallRecoverySelectionState(&form, uploadedDatabaseBackupProvided) // edit/add - by petru @ codex + renderInstallError := func(message templatehtml.HTML, availableBackups []*installBackupChoice, backupDiscoveryErr error) { + effectiveCurDBType := form.DbType + if effectiveCurDBType == "" { + effectiveCurDBType = setting.Database.Type.String() + } + assignInstallPageData(ctx, &form, effectiveCurDBType, availableBackups, backupDiscoveryErr) // edit/add - by petru @ codex + ctx.Data["InstallRecoveryProblemText"] = message // edit/add - by petru @ codex + ctx.RenderWithErrDeprecated(string(message), tplInstall, &form) + } // fix form values if form.AppURL != "" && form.AppURL[len(form.AppURL)-1] != '/' { @@ -942,7 +1660,31 @@ func SubmitInstall(ctx *context.Context) { form.DefaultLanguage = strings.TrimSpace(form.DefaultLanguage) normalizeInstallRegistrationOptions(&form) populateInstallSMTPFromFields(&form) - ctx.Data["InstallerSiteName"] = installMailerDisplayName(form.AppName) // edit/add - by petru @ codex + // start edit/add - by petru @ codex + if normalizedRecoveryEmails, err := normalizeInstallRecoveryEmails(form.RecoveryAllowedEmails); err != nil { + ctx.Data["Err_BackupRecovery"] = true + ctx.RenderWithErrDeprecated(ctx.Tr("install.recovery_email_invalid_addresses", err), tplInstall, &form) + return + } else { + form.RecoveryAllowedEmails = normalizedRecoveryEmails // edit/add - by petru @ codex + } + if defaultRecoveryAllowedEmails := defaultInstallRecoveryAllowedEmails(&form); defaultRecoveryAllowedEmails != "" { // edit/add - by petru @ codex + form.RecoveryAllowedEmails = defaultRecoveryAllowedEmails // edit/add - by petru @ codex + } + if form.RecoveryEmailEnabled && !missingAppINIRecovery { + if strings.TrimSpace(form.SMTPAddr) == "" { + ctx.Data["Err_BackupRecovery"] = true + ctx.RenderWithErrDeprecated(ctx.Tr("install.recovery_email_requires_smtp"), tplInstall, &form) + return + } + if form.RecoveryAllowedEmails == "" { + ctx.Data["Err_BackupRecovery"] = true + ctx.RenderWithErrDeprecated(ctx.Tr("install.recovery_email_requires_addresses"), tplInstall, &form) + return + } + } + // end edit/add - by petru @ codex + ctx.Data["InstallerSiteName"] = resolveInstallPageSiteName(&form, isRecoveryRequest(ctx.Req)) // edit/add - by petru @ codex if form.DefaultLanguage == "" { form.DefaultLanguage = resolveInstallDefaultLanguage("") @@ -980,10 +1722,144 @@ func SubmitInstall(ctx *context.Context) { setting.Database.Path = form.DbPath setting.Database.LogSQL = !setting.IsProd - dbOK, dbPreviouslyUsed := checkDatabase(ctx, &form) - if !dbOK { + // start edit/add - by petru @ codex + if recoveryRequest && form.BackupRestoreID != "" && form.BackupImportAppINI { + backupAppINICfg, err = dbbackup_service.LoadBackupAppINIConfig(resolveInstallBackupPath(form.BackupPath), form.BackupRestoreID) + if err != nil { + ctx.Data["Err_DatabaseBackupRecovery"] = true + renderInstallError(ctx.Tr("install.recovery_database_backup_restore_failed", err), nil, nil) + return + } + if backupAppINICfg != nil { + applyBackupAppINIToInstallForm(&form, backupAppINICfg) + setting.Database.Type = setting.DatabaseType(form.DbType) + setting.Database.Host = form.DbHost + setting.Database.User = form.DbUser + setting.Database.Passwd = form.DbPasswd + setting.Database.Name = form.DbName + setting.Database.Schema = form.DbSchema + setting.Database.SSLMode = form.SSLMode + setting.Database.Path = form.DbPath + setting.Database.LogSQL = !setting.IsProd + } + } + normalizeInstallRecoverySelectionState(&form, uploadedDatabaseBackupProvided) // edit/add - by petru @ codex + + hasRepositoryFilesystem := hasInstallRepositoryFilesystemRecovery(&form) // edit/add - by petru @ codex + availableBackups, err := listInstallRecoveryBackupChoices(&form, recoveryRequest) + if err != nil { + ctx.Data["Err_BackupPath"] = true + renderInstallError(ctx.Tr("install.recovery_database_backup_discovery_failed", err), nil, err) return } + hasDatabaseBackups := len(availableBackups) > 0 + normalizeInstallRecoverySelectionState(&form, uploadedDatabaseBackupProvided) // edit/add - by petru @ codex + if uploadedDatabaseBackupProvided && form.BackupRestoreID != "" && form.BackupRestoreDB { + // start edit/add - by petru @ codex + ctx.Data["Err_DatabaseBackupRecovery"] = true + renderInstallError(ctx.Tr("install.recovery_multiple_database_backups_error"), availableBackups, nil) + return + // end edit/add - by petru @ codex + } + if (form.BackupImportAppINI || form.BackupRestoreDB) && !uploadedDatabaseBackupProvided && form.BackupRestoreID == "" { + // start edit/add - by petru @ codex + ctx.Data["Err_DatabaseBackupRecovery"] = true + renderInstallError(ctx.Tr("install.recovery_database_backup_select_required"), availableBackups, nil) + return + // end edit/add - by petru @ codex + } + if form.RecoveryMode == recoveryModeDatabaseBackup && !hasDatabaseBackups { + form.RecoveryMode = "" // edit/add - by petru @ codex + } + if missingAppINIRecovery && !hasDatabaseBackups && !hasRepositoryFilesystem { + ctx.Data["Err_BackupRecovery"] = true + ctx.RenderWithErrDeprecated(ctx.Tr("install.recovery_import_app_ini_required"), tplInstall, &form) + return + } + existingDatabaseDetected := false // edit/add - by petru @ codex + if recoveryRequest && form.ImportedAppINI { + existingDatabaseDetected, err = detectInstallExistingDatabase(ctx) + if err == nil && existingDatabaseDetected { + setInstallRecoverySourceAvailability(ctx, true, availableBackups, false) + if form.RecoveryMode == "" { + form.RecoveryMode = recoveryModeExistingDatabase + } + + recoverySelectionValid := installRecoveryConfirmationsAccepted(&form) + switch form.RecoveryMode { + case recoveryModeExistingDatabase: + // nothing extra + case recoveryModeDatabaseBackup: + recoverySelectionValid = recoverySelectionValid && hasSelectedInstallDatabaseBackupSource(&form, uploadedDatabaseBackupProvided) + default: + recoverySelectionValid = false + } + if !recoverySelectionValid { + if form.RecoveryMode == recoveryModeDatabaseBackup && (hasDatabaseBackups || uploadedDatabaseBackupProvided) { + ctx.Data["Err_DatabaseBackupRecovery"] = true + renderInstallError(ctx.Tr("install.recovery_database_backup_error"), availableBackups, nil) + return + } + ctx.Data["Err_DbInstalledBefore"] = true + renderInstallError(ctx.Tr("install.reinstall_error"), availableBackups, nil) + return + } + } + } + skipDatabasePreflight := false + if recoveryRequest && !existingDatabaseDetected && (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 && hasSelectedInstallDatabaseBackupSource(&form, uploadedDatabaseBackupProvided) + case recoveryModeRepositoryFileSystem: + // nothing extra + default: + recoverySelectionValid = false + } + + if !recoverySelectionValid { + if form.RecoveryMode == recoveryModeDatabaseBackup && (hasDatabaseBackups || uploadedDatabaseBackupProvided) { + ctx.Data["Err_DatabaseBackupRecovery"] = true + renderInstallError(ctx.Tr("install.recovery_database_backup_error"), availableBackups, nil) + return + } + ctx.Data["Err_RepositoryFilesystemRecovery"] = true + renderInstallError(ctx.Tr("install.recovery_repository_filesystem_error"), availableBackups, nil) + return + } + + if form.RecoveryMode == recoveryModeDatabaseBackup || form.RecoveryMode == recoveryModeRepositoryFileSystem { + skipDatabasePreflight = true + } + } + // end edit/add - by petru @ codex + + dbPreviouslyUsed := false + if !skipDatabasePreflight { + dbOK, checkedPreviouslyUsed := checkDatabase(ctx, &form, availableBackups, uploadedDatabaseBackupProvided) + if !dbOK { + return + } + dbPreviouslyUsed = checkedPreviouslyUsed + } + + if shouldRequireBackupAppINISnapshot(form.RecoveryMode, dbPreviouslyUsed) && !hasInstallRecoveryAppINIConfigSource(&form, backupAppINICfg) { + // start edit/add - by petru @ codex + ctx.Data["Err_DatabaseBackupRecovery"] = true + renderInstallError(ctx.Tr("install.recovery_database_backup_app_ini_required"), availableBackups, nil) + return + // end edit/add - by petru @ codex + } // Prepare AppDataPath, it is very important for Gitea if err = setting.PrepareAppDataPath(); err != nil { @@ -999,6 +1875,40 @@ func SubmitInstall(ctx *context.Context) { return } + // start 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 && hasSelectedInstallDatabaseBackupSource(&form, uploadedDatabaseBackupProvided) + case recoveryModeRepositoryFileSystem: + // nothing extra + default: + recoverySelectionValid = false + } + + if !recoverySelectionValid { + if form.RecoveryMode == recoveryModeDatabaseBackup && (hasDatabaseBackups || uploadedDatabaseBackupProvided) { + ctx.Data["Err_DatabaseBackupRecovery"] = true + renderInstallError(ctx.Tr("install.recovery_database_backup_error"), availableBackups, nil) + return + } + ctx.Data["Err_RepositoryFilesystemRecovery"] = true + renderInstallError(ctx.Tr("install.recovery_repository_filesystem_error"), availableBackups, nil) + 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,18 +1980,55 @@ 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 uploadedDatabaseBackupProvided || form.BackupRestoreDB || shouldRestoreDatabaseFromBackup(form.RecoveryMode, dbPreviouslyUsed) { + if setting.Database.Type.IsSQLite3() { + if err = removeInstallSQLiteDatabaseArtifacts(setting.Database.Path); err != nil { + ctx.Data["Err_DbSetting"] = true + renderInstallError(ctx.Tr("install.invalid_db_setting", err), availableBackups, nil) + return + } + } + if err = db.InitEngine(ctx); err != nil { + db.UnsetDefaultEngine() + ctx.Data["Err_DbSetting"] = true + renderInstallError(ctx.Tr("install.invalid_db_setting", err), availableBackups, nil) + return + } + if uploadedDatabaseBackupProvided { + if err = importUploadedDatabaseBackup(ctx); err != nil { + db.UnsetDefaultEngine() + ctx.Data["Err_DatabaseBackupRecovery"] = true + renderInstallError(ctx.Tr("install.recovery_database_backup_restore_failed", err), availableBackups, nil) + return + } + } else if form.BackupRestoreDB || shouldRestoreDatabaseFromBackup(form.RecoveryMode, dbPreviouslyUsed) { + if _, err = dbbackup_service.RestoreDatabaseBackup(ctx, resolveInstallBackupPath(form.BackupPath), form.BackupRestoreID, form.DbType); err != nil { + db.UnsetDefaultEngine() + ctx.Data["Err_DatabaseBackupRecovery"] = true + renderInstallError(ctx.Tr("install.recovery_database_backup_restore_failed", err), availableBackups, nil) + 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 + renderInstallError(ctx.Tr("install.invalid_db_setting", err), availableBackups, nil) + return + } } + // end edit/add - by petru @ codex // Save settings. - cfg, err := setting.NewConfigProviderFromFile(setting.CustomConf) - if err != nil { - log.Error("Failed to load custom conf '%s': %v", setting.CustomConf, err) + cfg := backupAppINICfg // edit/add - by petru @ codex + if cfg == nil { + cfg, err = setting.NewConfigProviderFromFile(setting.CustomConf) + if err != nil { + log.Error("Failed to load custom conf '%s': %v", setting.CustomConf, err) + } } cfg.Section("").Key("APP_NAME").SetValue(form.AppName) @@ -1105,6 +2052,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) @@ -1215,6 +2171,27 @@ func SubmitInstall(ctx *context.Context) { ctx.RenderWithErrDeprecated(ctx.Tr("install.save_config_failed", err), tplInstall, &form) return } + // start edit/add - by petru @ codex + recoveryCfgToSave := &setting.RecoveryConfig{ + Enabled: form.RecoveryEmailEnabled, + AllowedEmails: form.RecoveryAllowedEmails, + BackupPath: form.BackupPath, + RepoRootPath: form.RepoRootPath, + SMTPAddr: form.SMTPAddr, + SMTPPort: form.SMTPPort, + SMTPFrom: form.SMTPFrom, + SMTPUser: form.SMTPUser, + SMTPPasswd: form.SMTPPasswd, + } + existingRecoveryCfg, recoveryCfgErr := setting.LoadRecoveryConfig() + if recoveryCfgErr == nil { + recoveryCfgToSave = mergeInstallRecoveryConfigSMTP(existingRecoveryCfg, recoveryCfgToSave) // edit/add - by petru @ codex + } + if err = setting.SaveRecoveryConfig(recoveryCfgToSave); err != nil { + ctx.RenderWithErrDeprecated(ctx.Tr("install.save_config_failed", err), tplInstall, &form) + return + } + // end edit/add - by petru @ codex if err = saveInstallBrandingAssets(brandingUploads); err != nil { ctx.Data["Err_Branding"] = true @@ -1285,9 +2262,18 @@ func SubmitInstall(ctx *context.Context) { } } + if err = setting.EnsureInstallSentinel(); err != nil { + ctx.RenderWithErrDeprecated(ctx.Tr("install.save_config_failed", err), tplInstall, &form) + return + } + setting.ClearEnvConfigKeys() log.Info("First-time run install finished!") - InstallDone(ctx) + if isRecoveryRequest(ctx.Req) { + InstallDone(ctx) // edit/add - by petru @ codex + } else { + InstallDone(ctx) + } go func() { // Sleep for a while to make sure the user's browser has loaded the post-install page and its assets (images, css, js) @@ -1308,8 +2294,28 @@ func SubmitInstall(ctx *context.Context) { // InstallDone shows the "post-install" page, makes it easier to develop the page. // The name is not called as "PostInstall" to avoid misinterpretation as a handler for "POST /install" func InstallDone(ctx *context.Context) { //nolint:revive // export stutter - hasUsers, _ := user_model.HasUsers(ctx) + recoveryRequest := isRecoveryRequest(ctx.Req) // edit/add - by petru @ codex + hasUsers := struct{ HasAnyUser, HasOnlyOneUser bool }{} // edit/add - by petru @ codex + func() { + defer func() { + if r := recover(); r != nil { + log.Warn("InstallDone: unable to determine whether accounts exist: %v", r) // edit/add - by petru @ codex + } + }() + resolvedHasUsers, err := user_model.HasUsers(ctx) + if err != nil { + log.Warn("InstallDone: unable to determine whether accounts exist: %v", err) // edit/add - by petru @ codex + return + } + hasUsers = resolvedHasUsers + }() ctx.Data["IsAccountCreated"] = hasUsers.HasAnyUser ctx.Data["InstallProgressLogo"] = getInstallProgressLogo() + ctx.Data["InstallIsRecoveryRequest"] = recoveryRequest // edit/add - by petru @ codex + if recoveryRequest { + ctx.RespHeader().Set("Cache-Control", "no-store, no-cache, must-revalidate") // edit/add - by petru @ codex + ctx.RespHeader().Set("Pragma", "no-cache") // edit/add - by petru @ codex + ctx.RespHeader().Set("Expires", "0") // edit/add - by petru @ codex + } ctx.HTML(http.StatusOK, tplPostInstall) } diff --git a/routers/install/recovery.go b/routers/install/recovery.go new file mode 100644 index 0000000000..3ffdc8c06e --- /dev/null +++ b/routers/install/recovery.go @@ -0,0 +1,27 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package install + +import ( + "context" + "net/http" +) + +// start edit/add - by petru @ codex +type recoveryRequestContextKeyType struct{} + +var recoveryRequestContextKey = recoveryRequestContextKeyType{} + +const recoveryRequestQueryKey = "recovery" + +func WithRecoveryRequest(req *http.Request) *http.Request { + return req.WithContext(context.WithValue(req.Context(), recoveryRequestContextKey, true)) +} + +func isRecoveryRequest(req *http.Request) bool { + isRecovery, _ := req.Context().Value(recoveryRequestContextKey).(bool) + return isRecovery || req.URL.Query().Get(recoveryRequestQueryKey) == "1" // edit/add - by petru @ codex +} + +// end edit/add - by petru @ codex diff --git a/routers/install/routes.go b/routers/install/routes.go index ad416c95bc..e8eb6803c7 100644 --- a/routers/install/routes.go +++ b/routers/install/routes.go @@ -29,6 +29,7 @@ func Routes() *web.Router { r.Get("/", Install) // it must be on the root, because the "install.js" use the window.location to replace the "localhost" AppURL r.Post("/import_app_ini", ImportAppINI) + r.Post("/import_backup_app_ini", ImportBackupAppINI) // edit/add - by petru @ codex r.Post("/", web.Bind(forms.InstallForm{}), SubmitInstall) r.Post("/test_mail", TestMail) r.Get("/post-install", InstallDone) diff --git a/routers/install/routes_test.go b/routers/install/routes_test.go index d390001abf..4846384c02 100644 --- a/routers/install/routes_test.go +++ b/routers/install/routes_test.go @@ -5,6 +5,8 @@ package install import ( "bytes" + "context" + "encoding/json" "mime/multipart" "net/http" "net/http/httptest" @@ -12,6 +14,7 @@ import ( "path/filepath" "testing" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/test" @@ -62,6 +65,307 @@ func TestInstallMailerDisplayName(t *testing.T) { assert.Equal(t, "Gitea", installMailerDisplayName("")) } +// start edit/add - by petru @ codex +func TestIsRecoveryRequestAcceptsQueryFlag(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/?recovery=1", nil) + assert.True(t, isRecoveryRequest(req)) +} + +func TestRecoveryPageShowsBackupDiscoveryError(t *testing.T) { + // start edit/add - by petru @ codex + defer test.MockVariableValue(&setting.InstallLock, false)() + + tmpDir := t.TempDir() + customConfDir := filepath.Join(tmpDir, "custom", "conf") + require.NoError(t, os.MkdirAll(customConfDir, 0o755)) + + invalidBackupPath := filepath.Join(tmpDir, "invalid-backup-path") + require.NoError(t, os.WriteFile(invalidBackupPath, []byte("not-a-directory"), 0o644)) + + oldCustomConf := setting.CustomConf + defer func() { + setting.CustomConf = oldCustomConf + }() + setting.CustomConf = filepath.Join(customConfDir, "app.ini") + + require.NoError(t, setting.SaveRecoveryConfig(&setting.RecoveryConfig{ + Enabled: true, + AllowedEmails: "admin@example.com", + TokenSecret: "secret", + TokenTTLMin: 15, + BackupPath: invalidBackupPath, + })) + + r := Routes() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/?recovery=1", nil) + r.ServeHTTP(w, WithRecoveryRequest(req)) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "install.recovery_database_backup_discovery_failed") + // end edit/add - by petru @ codex +} + +func TestResolveInstallPageSiteNameKeepsImportedAppName(t *testing.T) { + tmpDir := t.TempDir() + oldCustomConf := setting.CustomConf + defer func() { + setting.CustomConf = oldCustomConf + }() + setting.CustomConf = filepath.Join(tmpDir, "custom", "conf", "app.ini") + + recoveryCfg := &setting.RecoveryConfig{ + Enabled: true, + AllowedEmails: "admin@example.com", + TokenSecret: "secret", + TokenTTLMin: 15, + SMTPFrom: "gitSafe ", + } + require.NoError(t, setting.SaveRecoveryConfig(recoveryCfg)) + + form := &forms.InstallForm{ + AppName: "Imported Safe: for your code", + ImportedAppINI: true, + } + + assert.Equal(t, "Imported Safe", resolveInstallPageSiteName(form, true)) + assert.Equal(t, "Imported Safe: for your code", form.AppName) +} + +func TestResolveInstallPageSiteNameUsesRecoveryFallbackForGenericAppName(t *testing.T) { + tmpDir := t.TempDir() + oldCustomConf := setting.CustomConf + defer func() { + setting.CustomConf = oldCustomConf + }() + setting.CustomConf = filepath.Join(tmpDir, "custom", "conf", "app.ini") + + recoveryCfg := &setting.RecoveryConfig{ + Enabled: true, + AllowedEmails: "admin@example.com", + TokenSecret: "secret", + TokenTTLMin: 15, + SMTPFrom: "gitSafe ", + } + require.NoError(t, setting.SaveRecoveryConfig(recoveryCfg)) + + form := &forms.InstallForm{AppName: "Gitea: Git with a cup of tea"} + + assert.Equal(t, "gitSafe", resolveInstallPageSiteName(form, true)) + assert.Equal(t, "gitSafe", form.AppName) +} + +func TestImportedDBTypeKeepsImportedSQLite3Value(t *testing.T) { + assert.Equal(t, "sqlite3", importedDBType("sqlite3", "mysql")) + assert.Equal(t, "sqlite3", importedDBType("sqlite", "mysql")) + assert.Equal(t, "postgres", importedDBType("postgresql", "mysql")) +} + +func TestApplyBackupAppINIToInstallForm(t *testing.T) { + // start edit/add - by petru @ codex + cfg, err := setting.NewConfigProviderFromData(` +APP_NAME = Restored Safe: for your code + +[database] +DB_TYPE = sqlite3 +PATH = /srv/gitea/data/gitea.db + +[server] +ROOT_URL = http://localhost:3000/ + +[mailer] +ENABLED = true +SMTP_ADDR = smtp.example.com +SMTP_PORT = 587 +FROM = Restored Safe +USER = smtp-user +PASSWD = smtp-pass + +[security] +INTERNAL_TOKEN = restored-internal +`) + require.NoError(t, err) + + form := &forms.InstallForm{ + DbType: "mysql", + BackupPath: "/srv/gitea/backups/db", + BackupRestoreID: "20260524-010101.000000001", + RecoveryMode: recoveryModeDatabaseBackup, + ImportSensitiveSecrets: false, + } + + applyBackupAppINIToInstallForm(form, cfg) + + assert.Equal(t, "Restored Safe: for your code", form.AppName) + assert.Equal(t, "sqlite3", form.DbType) + assert.Equal(t, "/srv/gitea/data/gitea.db", form.DbPath) + assert.Equal(t, "http://localhost:3000/", form.AppURL) + assert.Equal(t, "smtp.example.com", form.SMTPAddr) + assert.Equal(t, "smtp-user", form.SMTPUser) + assert.Equal(t, "restored-internal", form.ImportedInternalToken) + assert.True(t, form.ImportSensitiveSecrets) + assert.Equal(t, "/srv/gitea/backups/db", form.BackupPath) + assert.Equal(t, "20260524-010101.000000001", form.BackupRestoreID) + assert.Equal(t, recoveryModeDatabaseBackup, form.RecoveryMode) + // end edit/add - by petru @ codex +} + +func TestDetectInstallExistingDatabase(t *testing.T) { + // start edit/add - by petru @ codex + tmpDir := t.TempDir() + ctx := context.Background() + + originalType := setting.Database.Type + originalPath := setting.Database.Path + originalHost := setting.Database.Host + originalUser := setting.Database.User + originalPasswd := setting.Database.Passwd + originalName := setting.Database.Name + originalSchema := setting.Database.Schema + originalSSLMode := setting.Database.SSLMode + originalLogSQL := setting.Database.LogSQL + originalAllowSQLite3CreateForInstall := setting.AllowSQLite3CreateForInstall + defer func() { + setting.Database.Type = originalType + setting.Database.Path = originalPath + setting.Database.Host = originalHost + setting.Database.User = originalUser + setting.Database.Passwd = originalPasswd + setting.Database.Name = originalName + setting.Database.Schema = originalSchema + setting.Database.SSLMode = originalSSLMode + setting.Database.LogSQL = originalLogSQL + setting.AllowSQLite3CreateForInstall = originalAllowSQLite3CreateForInstall + db.UnsetDefaultEngine() + }() + + setting.Database.Type = setting.DatabaseType("sqlite3") + setting.Database.Path = filepath.Join(tmpDir, "gitea.db") + setting.Database.Host = "" + setting.Database.User = "" + setting.Database.Passwd = "" + setting.Database.Name = "" + setting.Database.Schema = "" + setting.Database.SSLMode = "" + setting.Database.LogSQL = false + setting.AllowSQLite3CreateForInstall = true + + require.NoError(t, db.InitEngine(ctx)) + _, err := db.GetEngine(ctx).Exec("CREATE TABLE version (version INTEGER)") + require.NoError(t, err) + _, err = db.GetEngine(ctx).Exec("INSERT INTO version (version) VALUES (1)") + require.NoError(t, err) + _, err = db.GetEngine(ctx).Exec("CREATE TABLE user (id INTEGER PRIMARY KEY)") + require.NoError(t, err) + _, err = db.GetEngine(ctx).Exec("INSERT INTO user (id) VALUES (1)") + require.NoError(t, err) + db.UnsetDefaultEngine() + + existingDatabaseDetected, err := detectInstallExistingDatabase(ctx) + require.NoError(t, err) + assert.True(t, existingDatabaseDetected) + // end edit/add - by petru @ codex +} + +func TestShouldRestoreDatabaseFromBackup(t *testing.T) { + // start edit/add - by petru @ codex + assert.True(t, shouldRestoreDatabaseFromBackup(recoveryModeDatabaseBackup, false)) + assert.False(t, shouldRestoreDatabaseFromBackup(recoveryModeDatabaseBackup, true)) + assert.False(t, shouldRestoreDatabaseFromBackup(recoveryModeExistingDatabase, false)) + // end edit/add - by petru @ codex +} + +func TestShouldRequireBackupAppINISnapshot(t *testing.T) { + // start edit/add - by petru @ codex + assert.True(t, shouldRequireBackupAppINISnapshot(recoveryModeDatabaseBackup, true)) + assert.False(t, shouldRequireBackupAppINISnapshot(recoveryModeDatabaseBackup, false)) + assert.False(t, shouldRequireBackupAppINISnapshot(recoveryModeExistingDatabase, true)) + // end edit/add - by petru @ codex +} + +func TestMergeInstallRecoveryConfigSMTP(t *testing.T) { + // start edit/add - by petru @ codex + existing := &setting.RecoveryConfig{ + SMTPAddr: "smtp.example.com", + SMTPPort: "587", + SMTPFrom: "gitSafe ", + SMTPUser: "smtp-user", + SMTPPasswd: "smtp-pass", + } + next := &setting.RecoveryConfig{ + Enabled: true, + AllowedEmails: "admin@example.com", + } + + merged := mergeInstallRecoveryConfigSMTP(existing, next) + require.NotNil(t, merged) + assert.Equal(t, "smtp.example.com", merged.SMTPAddr) + assert.Equal(t, "587", merged.SMTPPort) + assert.Equal(t, "gitSafe ", merged.SMTPFrom) + assert.Equal(t, "smtp-user", merged.SMTPUser) + assert.Equal(t, "smtp-pass", merged.SMTPPasswd) + // end edit/add - by petru @ codex +} + +func TestImportAppINIInRecoveryKeepsImportedValues(t *testing.T) { + defer test.MockVariableValue(&setting.InstallLock, false)() + defer test.MockVariableValue(&setting.SupportedDatabaseTypes, []string{"mysql", "postgres", "sqlite3"})() + defer test.MockVariableValue(&setting.AppName, "Gitea: Git with a cup of tea")() + + tmpDir := t.TempDir() + oldCustomConf := setting.CustomConf + defer func() { + setting.CustomConf = oldCustomConf + }() + setting.CustomConf = filepath.Join(tmpDir, "custom", "conf", "app.ini") + + recoveryCfg := &setting.RecoveryConfig{ + Enabled: true, + AllowedEmails: "admin@example.com", + TokenSecret: "secret", + TokenTTLMin: 15, + SMTPFrom: "gitSafe ", + } + require.NoError(t, setting.SaveRecoveryConfig(recoveryCfg)) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + fileWriter, err := writer.CreateFormFile("app_ini_file", "app.ini") + require.NoError(t, err) + _, err = fileWriter.Write([]byte(` +APP_NAME = Imported Safe: for your code + +[database] +DB_TYPE = sqlite3 +PATH = ` + filepath.ToSlash(filepath.Join(tmpDir, "data", "gitea.db")) + ` + +[mailer] +ENABLED = true +SMTP_ADDR = smtp.example.com +SMTP_PORT = 587 +FROM = Imported Safe +USER = smtp-user +PASSWD = smtp-pass +`)) + require.NoError(t, err) + require.NoError(t, writer.Close()) + + r := Routes() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/import_app_ini?recovery=1", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + r.ServeHTTP(w, WithRecoveryRequest(req)) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), `name="app_name" value="Imported Safe: for your code"`) + assert.Contains(t, w.Body.String(), `id="db_type" name="db_type" value="sqlite3"`) + assert.Contains(t, w.Body.String(), `name="smtp_addr" value="smtp.example.com"`) + assert.Contains(t, w.Body.String(), `name="smtp_from_name" value="Imported Safe"`) + assert.NotContains(t, w.Body.String(), `name="app_name" value="gitSafe"`) +} + +// end edit/add - by petru @ codex + func TestComposeInstallSMTPFrom(t *testing.T) { from, err := composeInstallSMTPFrom("gitSafe for your code", "", "noreply@example.com") require.NoError(t, err) @@ -140,6 +444,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 +508,85 @@ 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) +} + +func TestNewInstallFormFromSettingsRecoveryDefaults(t *testing.T) { + oldCustomConf := setting.CustomConf + defer func() { + setting.CustomConf = oldCustomConf + }() + + setting.CustomConf = filepath.Join(t.TempDir(), "custom", "conf", "app.ini") + require.NoError(t, setting.SaveRecoveryConfig(&setting.RecoveryConfig{ + Enabled: true, + AllowedEmails: "admin@example.com,ops@example.com", + SMTPAddr: "smtp.example.com", + SMTPPort: "587", + SMTPFrom: "Gitea ", + SMTPUser: "smtp-user", + SMTPPasswd: "smtp-pass", + })) + + form, _ := newInstallFormFromSettings() + assert.True(t, form.RecoveryEmailEnabled) + assert.Equal(t, "admin@example.com,ops@example.com", form.RecoveryAllowedEmails) +} + +func TestNewInstallFormFromSettingsDefaultsRecoveryEmailToEnabledOnFreshInstall(t *testing.T) { // edit/add - by petru @ codex + oldCustomConf := setting.CustomConf + defer func() { + setting.CustomConf = oldCustomConf + }() + + setting.CustomConf = filepath.Join(t.TempDir(), "custom", "conf", "app.ini") + form, _ := newInstallFormFromSettings() + assert.True(t, form.RecoveryEmailEnabled) +} + +func TestDefaultInstallRecoveryAllowedEmailsUsesAdminEmailWhenEmpty(t *testing.T) { // edit/add - by petru @ codex + form := &forms.InstallForm{AdminEmail: "admin@example.com"} + assert.Equal(t, "admin@example.com", defaultInstallRecoveryAllowedEmails(form)) + + form.RecoveryAllowedEmails = "ops@example.com" + assert.Equal(t, "", defaultInstallRecoveryAllowedEmails(form)) +} + +func TestNormalizeInstallRecoveryEmails(t *testing.T) { + normalized, err := normalizeInstallRecoveryEmails(" admin@example.com,\nops@example.com ; admin@example.com ") + require.NoError(t, err) + assert.Equal(t, "admin@example.com,ops@example.com", normalized) + + _, err = normalizeInstallRecoveryEmails("not-an-email") + require.Error(t, err) +} + +// 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 +597,7 @@ func TestApplyImportedAppINIRepositoryRecoveryDefaults(t *testing.T) { form = forms.InstallForm{ ImportedAppINI: true, + RecoveryMode: recoveryModeRepositoryFileSystem, AllowAdoptionOfUnadoptedRepositories: false, AllowDeleteOfUnadoptedRepositories: false, } @@ -218,6 +607,307 @@ 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 TestHasInstallRepositoryFilesystemRecoveryRequiresExistingOrManualAppINI(t *testing.T) { // edit/add - by petru @ codex + tmpDir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(tmpDir, "owner"), 0o755)) + require.NoError(t, os.Mkdir(filepath.Join(tmpDir, "owner", "repo.git"), 0o755)) + + oldCustomConf := setting.CustomConf + defer func() { + setting.CustomConf = oldCustomConf + }() + setting.CustomConf = filepath.Join(tmpDir, "custom", "conf", "app.ini") + + form := &forms.InstallForm{RepoRootPath: tmpDir} + assert.False(t, hasInstallRepositoryFilesystemRecovery(form)) + + form.ImportedAppINI = true + assert.True(t, hasInstallRepositoryFilesystemRecovery(form)) + + form.BackupImportAppINI = true + assert.False(t, hasInstallRepositoryFilesystemRecovery(form)) + + require.NoError(t, os.MkdirAll(filepath.Dir(setting.CustomConf), 0o755)) + require.NoError(t, os.WriteFile(setting.CustomConf, []byte("APP_NAME = Test\n"), 0o644)) + form.ImportedAppINI = false + form.BackupImportAppINI = false + assert.True(t, hasInstallRepositoryFilesystemRecovery(form)) +} + +func TestNormalizeInstallRecoverySelectionStateForcesBundleRestoreModeAndSensitiveSecrets(t *testing.T) { // edit/add - by petru @ codex + form := &forms.InstallForm{ + ImportedAppINI: true, + ImportSensitiveSecrets: false, + BackupImportAppINI: true, + BackupRestoreID: "20260524-010101.000000001", + BackupRestoreDB: true, + RecoveryMode: recoveryModeExistingDatabase, + } + + normalizeInstallRecoverySelectionState(form, false) + assert.True(t, form.ImportSensitiveSecrets) + assert.Equal(t, recoveryModeDatabaseBackup, form.RecoveryMode) +} + +func TestNormalizeInstallRecoverySelectionStateKeepsManualAppINIChoice(t *testing.T) { // edit/add - by petru @ codex + form := &forms.InstallForm{ + ImportedAppINI: true, + ImportSensitiveSecrets: false, + } + + normalizeInstallRecoverySelectionState(form, false) + assert.False(t, form.ImportSensitiveSecrets) +} + +func TestHasSelectedInstallDatabaseBackupSourceAcceptsUploadedFileWithoutBundle(t *testing.T) { // edit/add - by petru @ codex + form := &forms.InstallForm{} + assert.False(t, hasSelectedInstallDatabaseBackupSource(form, false)) + assert.True(t, hasSelectedInstallDatabaseBackupSource(form, true)) + + form.BackupRestoreID = "20260524-010101.000000001" + assert.True(t, hasSelectedInstallDatabaseBackupSource(form, false)) +} + +func TestHasInstallRecoveryAppINIConfigSourceAcceptsManualImportWithoutBundleSnapshot(t *testing.T) { // edit/add - by petru @ codex + form := &forms.InstallForm{ImportedAppINI: true} + assert.True(t, hasInstallRecoveryAppINIConfigSource(form, nil)) + + form.BackupImportAppINI = true + assert.False(t, hasInstallRecoveryAppINIConfigSource(form, nil)) +} + +func TestRemoveInstallSQLiteDatabaseArtifacts(t *testing.T) { // edit/add - by petru @ codex + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "gitea.db") + for _, path := range []string{dbPath, dbPath + "-wal", dbPath + "-shm", dbPath + "-journal"} { + require.NoError(t, os.WriteFile(path, []byte("x"), 0o644)) + } + + require.NoError(t, removeInstallSQLiteDatabaseArtifacts(dbPath)) + for _, path := range []string{dbPath, dbPath + "-wal", dbPath + "-shm", dbPath + "-journal"} { + _, err := os.Stat(path) + assert.ErrorIs(t, err, os.ErrNotExist) + } +} + +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) +} + +func TestListInstallRecoveryBackupChoicesWithoutAppINIShowsAllBackups(t *testing.T) { + // start edit/add - by petru @ codex + tmpDir := t.TempDir() + oldCustomConf := setting.CustomConf + defer func() { + setting.CustomConf = oldCustomConf + }() + setting.CustomConf = filepath.Join(tmpDir, "custom", "conf", "app.ini") + + writeInstallBackupManifestForTest(t, tmpDir, "20260524-010101.000000001", "sqlite3", 100) + writeInstallBackupManifestForTest(t, tmpDir, "20260524-010102.000000002", "mysql", 200) + + form := &forms.InstallForm{ + DbType: "mysql", + BackupPath: tmpDir, + } + + choices, err := listInstallRecoveryBackupChoices(form, true) + require.NoError(t, err) + require.Len(t, choices, 2) + assert.Equal(t, "20260524-010102.000000002", choices[0].ID) + assert.Equal(t, "20260524-010101.000000001", choices[1].ID) + // end edit/add - by petru @ codex +} + +func TestListInstallRecoveryBackupChoicesUsesRecoveryConfigBackupPathFallback(t *testing.T) { + // start edit/add - by petru @ codex + tmpDir := t.TempDir() + emptyBackupDir := filepath.Join(tmpDir, "empty-backups") + recoveryBackupDir := filepath.Join(tmpDir, "recovery-backups") + require.NoError(t, os.MkdirAll(emptyBackupDir, 0o755)) + require.NoError(t, os.MkdirAll(recoveryBackupDir, 0o755)) + + customConfDir := filepath.Join(tmpDir, "custom", "conf") + require.NoError(t, os.MkdirAll(customConfDir, 0o755)) + + oldCustomConf := setting.CustomConf + defer func() { + setting.CustomConf = oldCustomConf + }() + setting.CustomConf = filepath.Join(customConfDir, "app.ini") + require.NoError(t, os.WriteFile(setting.CustomConf, []byte("APP_NAME = Test\n"), 0o644)) + + require.NoError(t, setting.SaveRecoveryConfig(&setting.RecoveryConfig{ + Enabled: true, + AllowedEmails: "admin@example.com", + BackupPath: recoveryBackupDir, + })) + + writeInstallBackupManifestForTest(t, recoveryBackupDir, "20260524-010102.000000002", "mysql", 200) + + form := &forms.InstallForm{ + DbType: "mysql", + BackupPath: emptyBackupDir, + } + + choices, err := listInstallRecoveryBackupChoices(form, true) + require.NoError(t, err) + require.Len(t, choices, 1) + assert.Equal(t, "20260524-010102.000000002", choices[0].ID) + // end edit/add - by petru @ codex +} + +func TestListInstallRecoveryBackupChoicesWithAppINIStillShowsAllBackups(t *testing.T) { + // start edit/add - by petru @ codex + tmpDir := t.TempDir() + customConfDir := filepath.Join(tmpDir, "custom", "conf") + require.NoError(t, os.MkdirAll(customConfDir, 0o755)) + + oldCustomConf := setting.CustomConf + defer func() { + setting.CustomConf = oldCustomConf + }() + setting.CustomConf = filepath.Join(customConfDir, "app.ini") + require.NoError(t, os.WriteFile(setting.CustomConf, []byte("APP_NAME = Test\n"), 0o644)) + + writeInstallBackupManifestForTest(t, tmpDir, "20260524-010101.000000001", "sqlite3", 100) + writeInstallBackupManifestForTest(t, tmpDir, "20260524-010102.000000002", "mysql", 200) + + form := &forms.InstallForm{ + DbType: "mysql", + BackupPath: tmpDir, + } + + choices, err := listInstallRecoveryBackupChoices(form, true) + require.NoError(t, err) + require.Len(t, choices, 2) + assert.Equal(t, "20260524-010102.000000002", choices[0].ID) + assert.Equal(t, "20260524-010101.000000001", choices[1].ID) + // end edit/add - by petru @ codex +} + +func TestNewInstallFormFromSettingsUsesRecoveryBackupPathWithoutAppINI(t *testing.T) { + // start edit/add - by petru @ codex + tmpDir := t.TempDir() + oldCustomConf := setting.CustomConf + oldBackup := setting.Backup + defer func() { + setting.CustomConf = oldCustomConf + setting.Backup = oldBackup + }() + + setting.CustomConf = filepath.Join(tmpDir, "custom", "conf", "app.ini") + setting.Backup.Path = "data/backups/db" + + require.NoError(t, setting.SaveRecoveryConfig(&setting.RecoveryConfig{ + Enabled: true, + AllowedEmails: "admin@example.com", + BackupPath: filepath.Join(tmpDir, "restores", "db"), + RepoRootPath: filepath.Join(tmpDir, "restores", "repos"), + })) + + form, _ := newInstallFormFromSettings() + assert.Equal(t, filepath.Join(tmpDir, "restores", "db"), form.BackupPath) + assert.Equal(t, filepath.Join(tmpDir, "restores", "repos"), form.RepoRootPath) + // end edit/add - by petru @ codex +} + +// 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)) +} + +func writeInstallBackupManifestWithAppINIForTest(t *testing.T, rootPath, backupID, dbType string, createdUnix int64, appINIContent string) { // edit/add - by petru @ codex + 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, + "app_ini_snapshot_file": "app.ini", + }) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(backupDir, "manifest.json"), manifestBytes, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(backupDir, "app.ini"), []byte(appINIContent), 0o644)) +} + // end edit/add - by petru @ codex func TestPopulateInstallFormFromConfigReplacesSMTPFromSplitFields(t *testing.T) { @@ -390,12 +1080,55 @@ JWT_SECRET = oauth-secret r.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) - assert.Contains(t, w.Body.String(), `id="import_sensitive_secrets" name="import_sensitive_secrets" type="checkbox" checked`) + assert.Contains(t, w.Body.String(), `id="import_sensitive_secrets" name="import_sensitive_secrets" type="checkbox" form="install-form" checked`) assert.Contains(t, w.Body.String(), `name="imported_lfs_jwt_secret" value="lfs-secret"`) assert.Contains(t, w.Body.String(), `name="imported_internal_token" value="internal-secret"`) assert.Contains(t, w.Body.String(), `name="imported_o_auth2_jwt_secret" value="oauth-secret"`) } +func TestImportBackupAppINIInRecovery(t *testing.T) { // edit/add - by petru @ codex + defer test.MockVariableValue(&setting.InstallLock, false)() + + tmpDir := t.TempDir() + writeInstallBackupManifestWithAppINIForTest(t, tmpDir, "20260524-010101.000000001", "sqlite3", 100, ` +APP_NAME = Imported Safe: for your code + +[database] +DB_TYPE = sqlite3 +PATH = `+filepath.ToSlash(filepath.Join(tmpDir, "data", "gitea.db"))+` + +[mailer] +ENABLED = true +SMTP_ADDR = smtp.example.com +SMTP_PORT = 587 +FROM = Imported Safe +USER = smtp-user +PASSWD = smtp-pass +`) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + require.NoError(t, writer.WriteField("backup_path", tmpDir)) + require.NoError(t, writer.WriteField("backup_restore_id", "20260524-010101.000000001")) + require.NoError(t, writer.WriteField("backup_import_app_ini", "true")) + require.NoError(t, writer.Close()) + + r := Routes() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/import_backup_app_ini?recovery=1", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + r.ServeHTTP(w, WithRecoveryRequest(req)) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), `name="app_name" value="Imported Safe: for your code"`) + assert.Contains(t, w.Body.String(), `id="db_type" name="db_type" value="sqlite3"`) + assert.Contains(t, w.Body.String(), `name="smtp_addr" value="smtp.example.com"`) + assert.Contains(t, w.Body.String(), `name="smtp_from_name" value="Imported Safe"`) + assert.Contains(t, w.Body.String(), `name="imported_app_ini" value="true"`) + assert.Contains(t, w.Body.String(), `id="import_sensitive_secrets" name="import_sensitive_secrets" type="checkbox" form="install-form" checked`) + assert.Contains(t, w.Body.String(), `id="install_backup_restore_id" name="backup_restore_id" value="20260524-010101.000000001"`) +} + // end edit/add - by petru @ codex func TestApplyInstallSensitiveSecretsToConfigPersistsImportedValues(t *testing.T) { @@ -426,6 +1159,34 @@ func TestApplyInstallSensitiveSecretsToConfigPersistsImportedValues(t *testing.T assert.Contains(t, content, "JWT_SECRET = oauth-secret") } +// start edit/add - by petru @ codex +func TestApplyInstallSensitiveSecretsToConfigPreservesExistingLFSJWTSecretWithoutImport(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "app.ini") + + cfg, err := setting.NewConfigProviderFromData(` +[server] +LFS_JWT_SECRET = existing-lfs-secret +`) + require.NoError(t, err) + + form := forms.InstallForm{ + LFSRootPath: filepath.Join(tmpDir, "lfs"), + ImportSensitiveSecrets: false, + } + + require.NoError(t, applyInstallSensitiveSecretsToConfig(cfg, &form)) + require.NoError(t, cfg.SaveTo(configPath)) + + data, err := os.ReadFile(configPath) + require.NoError(t, err) + content := string(data) + assert.Contains(t, content, "LFS_JWT_SECRET = existing-lfs-secret") + assert.NotContains(t, content, "LFS_JWT_SECRET_URI") +} + +// end edit/add - by petru @ codex + func TestMain(m *testing.M) { unittest.MainTest(m) } diff --git a/routers/web/home.go b/routers/web/home.go index 489fc78822..d865043578 100644 --- a/routers/web/home.go +++ b/routers/web/home.go @@ -65,6 +65,14 @@ func Home(ctx *context.Context) { ctx.HTML(http.StatusOK, tplHome) } +// start edit/add - by petru @ codex +// PostInstallRedirect absorbs the temporary installer handoff URL after the normal web server is up. +func PostInstallRedirect(ctx *context.Context) { + ctx.Redirect(setting.AppSubURL + "/") +} + +// end edit/add - by petru @ codex + // HomeSitemap renders the main sitemap func HomeSitemap(ctx *context.Context) { m := sitemap.NewSitemapIndex() diff --git a/routers/web/web.go b/routers/web/web.go index e573d8baa2..7dcfdac90e 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -513,6 +513,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { // Especially some AJAX requests, we can reduce middleware number to improve performance. m.Get("/", Home) + m.Get("/post-install", PostInstallRedirect) // edit/add - by petru @ codex m.Get("/sitemap.xml", sitemapEnabled, optExploreSignIn, HomeSitemap) m.Group("/.well-known", func() { m.Get("/openid-configuration", auth.OIDCWellKnown) @@ -1071,7 +1072,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)) diff --git a/services/context/context.go b/services/context/context.go index a6a861ecaa..1cb467b38a 100644 --- a/services/context/context.go +++ b/services/context/context.go @@ -147,9 +147,10 @@ func ContexterInstallPage(data map[string]any) func(next http.Handler) http.Hand ctx := NewWebContext(base, rnd, session.GetContextSession(req)) ctx.Data.MergeFrom(middleware.CommonTemplateContextData()) ctx.Data.MergeFrom(reqctx.ContextData{ - "Title": ctx.Locale.Tr("install.install"), - "PageIsInstall": true, - "AllLangs": translation.AllLangs(), + "Title": ctx.Locale.Tr("install.install"), + "PageIsInstall": true, + "CurrentRequestURI": ctx.Req.URL.RequestURI(), // edit/add - by petru @ codex + "AllLangs": translation.AllLangs(), }) ctx.Data.MergeFrom(data) next.ServeHTTP(resp, ctx.Req) @@ -166,6 +167,7 @@ func Contexter() func(next http.Handler) http.Handler { ctx := NewWebContext(base, rnd, session.GetContextSession(req)) ctx.Data.MergeFrom(middleware.CommonTemplateContextData()) ctx.Data["Link"] = ctx.Link + ctx.Data["CurrentRequestURI"] = ctx.Req.URL.RequestURI() // edit/add - by petru @ codex // PageData is passed by reference, and it will be rendered to `window.config.pageData` in `head.tmpl` for JavaScript modules ctx.PageData = map[string]any{} diff --git a/services/cron/cron.go b/services/cron/cron.go index 7a4eb21bbb..8cdefa8c14 100644 --- a/services/cron/cron.go +++ b/services/cron/cron.go @@ -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 diff --git a/services/cron/tasks_extended.go b/services/cron/tasks_extended.go index 8f02000e9f..d7252cfdcf 100644 --- a/services/cron/tasks_extended.go +++ b/services/cron/tasks_extended.go @@ -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 } diff --git a/services/dbbackup/backup.go b/services/dbbackup/backup.go new file mode 100644 index 0000000000..e73991a8ee --- /dev/null +++ b/services/dbbackup/backup.go @@ -0,0 +1,412 @@ +// 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 diff --git a/services/dbbackup/backup_test.go b/services/dbbackup/backup_test.go new file mode 100644 index 0000000000..162fba45aa --- /dev/null +++ b/services/dbbackup/backup_test.go @@ -0,0 +1,123 @@ +// 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 TestLoadBackupAppINIConfig(t *testing.T) { + // start edit/add - by petru @ codex + tmpDir := t.TempDir() + backupID := "20260524-010101.000000001" + backupDir := filepath.Join(tmpDir, backupID) + require.NoError(t, os.MkdirAll(backupDir, 0o755)) + + manifestBytes, err := json.Marshal(&Manifest{ + SchemaVersion: backupManifestSchemaValue, + ID: backupID, + CreatedUnix: 100, + DBType: "sqlite3", + AppVersion: "dev", + AppName: "Test", + MigrationVersion: 1, + FileName: databaseDumpGzipFileName, + FileSHA256: "abc", + FileSize: 1, + Compressed: true, + AppINISnapshotFile: appINISnapshotFileName, + }) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(backupDir, manifestFileName), manifestBytes, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(backupDir, appINISnapshotFileName), []byte("[server]\nROOT_URL = http://example.com/\n"), 0o644)) + + cfg, err := LoadBackupAppINIConfig(tmpDir, backupID) + require.NoError(t, err) + require.NotNil(t, cfg) + assert.Equal(t, "http://example.com/", setting.ConfigSectionKeyString(cfg.Section("server"), "ROOT_URL")) + // end edit/add - by petru @ codex +} + +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 diff --git a/services/forms/user_form.go b/services/forms/user_form.go index 0f832caf5e..e599c80b6b 100644 --- a/services/forms/user_form.go +++ b/services/forms/user_form.go @@ -50,6 +50,10 @@ 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 + BackupImportAppINI bool // edit/add - by petru @ codex + BackupRestoreDB bool // edit/add - by petru @ codex RegisterConfirm bool RegisterManualConfirm bool MailNotify bool @@ -74,8 +78,17 @@ 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 + RecoveryEmailEnabled bool // edit/add - by petru @ codex + RecoveryAllowedEmails string // 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"` diff --git a/templates/admin/dashboard.tmpl b/templates/admin/dashboard.tmpl index f44dc24362..bee0cbe92a 100644 --- a/templates/admin/dashboard.tmpl +++ b/templates/admin/dashboard.tmpl @@ -58,6 +58,12 @@ {{ctx.Locale.Tr "admin.dashboard.delete_generated_repository_avatars"}} + + + {{ctx.Locale.Tr "admin.dashboard.database_backup"}} + + + {{ctx.Locale.Tr "admin.dashboard.sync_repo_branches"}} diff --git a/templates/base/footer_content.tmpl b/templates/base/footer_content.tmpl index 41ac137868..64560f8558 100644 --- a/templates/base/footer_content.tmpl +++ b/templates/base/footer_content.tmpl @@ -45,7 +45,7 @@ {{svg "octicon-globe" 14}} {{ctx.Locale.LangName}} diff --git a/templates/install.tmpl b/templates/install.tmpl index 3b867f241a..1478d872b8 100644 --- a/templates/install.tmpl +++ b/templates/install.tmpl @@ -6,26 +6,31 @@ {{ctx.Locale.Tr "install.title"}}
- {{if not .Err_DbInstalledBefore}}{{template "base/alert" .}}{{end}} + {{if not (or .Err_DbInstalledBefore .Err_RepositoryFilesystemRecovery .Err_DatabaseBackupRecovery)}}{{template "base/alert" .}}{{end}}

{{ctx.Locale.Tr "install.docker_helper" "https://docs.gitea.com/installation/install-with-docker"}}

-
-

{{ctx.Locale.Tr "install.import_app_ini_title"}}

-

{{ctx.Locale.Tr "install.import_app_ini_desc"}}

-
- - - {{ctx.Locale.Tr "install.import_app_ini_helper" .AppINIImportMaxSizeKB}} -
-
-
- - + + +
+
+

{{.InstallRecoverySectionTitle}}

+

{{.InstallRecoverySectionDesc}}

+
+
+
- {{ctx.Locale.Tr "install.import_app_ini_sensitive_secrets_helper"}}
+ + + + + + + + + {{if .InstallIsRecoveryRequest}}{{end}} @@ -96,6 +101,69 @@ {{ctx.Locale.Tr "install.sqlite_helper"}}
+ +
+ + {{ctx.Locale.Tr "install.database_backup_title"}} + + {{ctx.Locale.Tr "install.database_backup_desc"}} +
+
+ + +
+ {{ctx.Locale.Tr "install.database_backup_enabled_helper"}} +
+
+
+ + +
+ {{ctx.Locale.Tr "install.database_backup_run_at_start_helper"}} +
+
+ + + {{ctx.Locale.Tr "install.database_backup_schedule_helper"}} +
+
+ + + {{ctx.Locale.Tr "install.database_backup_path_helper"}} +
+
+ + + {{ctx.Locale.Tr "install.database_backup_retention_count_helper"}} +
+
+
+ + +
+ {{ctx.Locale.Tr "install.database_backup_compress_helper"}} +
+
+
+ + +
+ {{ctx.Locale.Tr "install.database_backup_include_app_ini_snapshot_helper"}} +
+
+
+ + +
+ {{ctx.Locale.Tr "install.recovery_email_enabled_helper"}} +
+
+ + + {{ctx.Locale.Tr "install.recovery_allowed_emails_helper"}} +
+
+

{{ctx.Locale.Tr "install.general_title"}}

@@ -174,7 +242,7 @@ {{ctx.Locale.Tr "install.branding_title"}} -

{{ctx.Locale.Tr "install.branding_desc"}}

+ {{ctx.Locale.Tr "install.branding_desc"}}
@@ -247,10 +315,10 @@
-
+
+
-
@@ -436,7 +504,7 @@ {{ctx.Locale.Tr "install.admin_title"}} -

{{ctx.Locale.Tr "install.admin_setting_desc"}}

+ {{ctx.Locale.Tr "install.admin_setting_desc"}}
@@ -505,7 +573,7 @@ {{$filePath := HTMLFormat `%s ` .CustomConfFile .CustomConfFile $copyBtn}} {{ctx.Locale.Tr "install.config_write_file_prompt" $filePath}}
- {{if not .Err_DbInstalledBefore}} + {{if not (or .Err_DbInstalledBefore .Err_RepositoryFilesystemRecovery .Err_DatabaseBackupRecovery)}}
-{{if .Err_DbInstalledBefore}} -