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

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

606 lines
22 KiB
Go

// 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(
"<p>%s</p><p><a href=\"%s\">%s</a></p><p>%s</p><p>%s</p>",
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(
`<option value="%s"%s>%s</option>`,
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(`<div class="status">%s</div>`, html.EscapeString(statusMessage))
}
errorHTML := ""
if errorMessage != "" {
errorHTML = fmt.Sprintf(`<div class="error">%s</div>`, 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, `<!doctype html><html lang="%s"><head><meta charset="utf-8"><title>%s</title><meta name="viewport" content="width=device-width, initial-scale=1"><link rel="icon" href="%s/assets/img/favicon.svg" type="image/svg+xml"><link rel="alternate icon" href="%s/assets/img/favicon.png" type="image/png"><style>body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#111827;color:#f3f4f6}main{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}.card{max-width:720px;width:100%%;border:1px solid #374151;border-radius:12px;background:#1f2937;padding:32px;box-shadow:0 20px 40px rgba(0,0,0,.35)}h1{margin:0 0 12px;font-size:32px;line-height:1.2;text-align:center}p{margin:0 0 16px;color:#d1d5db;font-size:16px;line-height:1.6}.status,.error{border-radius:10px;padding:14px 16px;margin:0 0 18px}.status{background:#0f2a1f;color:#d1fae5;border:1px solid #14532d}.error{background:#3f1d1d;color:#fecaca;border:1px solid #7f1d1d}label{display:block;margin:0 0 8px;font-weight:600}select#recovery_lang{padding: 8px 14px;}input,select{width:100%%;box-sizing:border-box;border:1px solid #4b5563;border-radius:8px;background:#111827;color:#f3f4f6;padding:12px 14px;font-size:16px}button{margin-top:16px;width:100%%;border:0;border-radius:8px;background:#3b82f6;color:white;padding:12px 16px;font-size:16px;font-weight:600;cursor:pointer}.muted{font-size:14px;color:#9ca3af;margin-top:18px}.footer{margin-top:24px;padding-top:18px;border-top:1px solid #374151;display:flex;justify-content:flex-end}.footer-label{font-size:14px;color:#d1d5db;font-weight:600;margin:0 0 8px}.lang-picker{min-width:215px}.lang-picker form{margin:0}</style></head><body><main><div class="card"><h1>%s</h1><p>%s</p>%s%s<form action="%s/recovery/request" method="post"><label for="recovery_email">%s</label><input id="recovery_email" name="recovery_email" type="email" autocomplete="email" required><button type="submit">%s</button></form><p class="muted">%s</p><div class="footer"><div class="lang-picker"><form action="%s/recovery" method="get"><label class="footer-label" for="recovery_lang">%s</label><select id="recovery_lang" name="lang" onchange="this.form.submit()">%s</select></form></div></div></div></main></body></html>`,
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