471cfdd161
- 1 - Add: Gitea now creates timestamped database backup bundles under `[backup].PATH`, exposes the backup schedule in the installer, and surfaces the `database_backup` cron task in admin monitoring. - 2 - Add: installed instances now use `.gitea-installed` and `.gitea-recovery.ini` to enter email-gated recovery instead of falling back to public install mode when configuration or database access is broken. - 3 - Mod: the installer recovery flow now covers backup-bundle restore, bundled or manual `app.ini` reuse, uploaded SQL/GZ database restores, and repository-filesystem recovery with source-specific validation, confirmations, and preserved launcher state. - 4 - Fix: recovery now restores bundled `app.ini` snapshots when needed, discovers backup bundles from both the active backup path and persisted `.gitea-recovery.ini` path, and preserves SMTP and other rebuilt settings correctly when `app.ini` is missing or incomplete. - 5 - Fix: recovery validation and restore handling now accept either a selected backup bundle or an uploaded SQL/GZ dump, keep sensitive secrets and existing `LFS_JWT_SECRET` when appropriate, clear SQLite restore targets before import, and complete the post-install handoff without redirect loops. - 6 - Mod: fresh installs now default recovery email authorization to enabled with first-admin fallback, and the install/recovery UI, styling, and EN/RO wording were refined to match the final launcher behavior. Co-Authored-By: petru @ codex (GPT-5) <codex@openai.com> (cherry picked from commit 9879caf2292691b0cb521d12e6fee924b066bae2)
606 lines
22 KiB
Go
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
|