Modified - [install] [backup] [database] [recovery] Consolidated database backup and installer recovery support.
release-nightly / nightly-binary (push) Has been cancelled
release-nightly / nightly-container (push) Has been cancelled

- 1 - Add: Gitea now creates timestamped database backup bundles under `[backup].PATH`, 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)
This commit is contained in:
2026-06-01 01:20:43 +03:00
parent 406e6d0697
commit 471cfdd161
40 changed files with 5265 additions and 200 deletions
+3
View File
@@ -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.
+8
View File
@@ -893,3 +893,11 @@ History search guidance:
- 1 - Mod: `/-/admin/badges` now shows a delete button in each row that opens the badge delete modal directly from the table.
- 2 - Mod: `/-/admin/orgs` now shows a delete button in each row that opens an organization delete modal directly from the table.
- 3 - Mod: the new table actions reuse the original delete modal layouts already used in badge edit and organization settings, and deleting an organization from `/-/admin/orgs` now returns to the admin organizations list instead of `/`.
186 - [2026-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.
+605
View File
@@ -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(
"<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
+169 -1
View File
@@ -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, `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>503 Service Unavailable</title><meta name="viewport" content="width=device-width, initial-scale=1"><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;text-align:center;box-shadow:0 20px 40px rgba(0,0,0,.35)}h1{margin:0 0 16px;font-size:32px;line-height:1.2}p{margin:0;color:#d1d5db;font-size:18px;line-height:1.6}</style></head><body><main><div class="card"><h1>503 Service Unavailable</h1><p>%s</p></div></main></body></html>`, 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 {
+146
View File
@@ -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 <noreply@example.com>",
}))
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
+34
View File
@@ -2108,6 +2108,22 @@ LEVEL = Info
;; Empty means server's location setting
;DEFAULT_UI_LOCATION =
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;[backup]
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; start edit/add - by petru @ codex
;; Directory where periodic database backups are written.
;PATH = data/backups/db
;; Number of backups to keep. Set to 0 to disable pruning.
;RETENTION_COUNT = 7
;; Compress the SQL dump as `database.sql.gz`.
;COMPRESS = true
;; Save a copy of the current app.ini alongside the backup manifest.
;INCLUDE_APP_INI_SNAPSHOT = true
;; end edit/add - by petru @ codex
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;[cron]
@@ -2130,6 +2146,24 @@ LEVEL = Info
;; Basic cron tasks - enabled by default
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Create database backups
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;[cron.database_backup]
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; start edit/add - by petru @ codex
;; Enable running the periodic database backup task.
;ENABLED = true
;; Run the database backup task when Gitea starts.
;RUN_AT_START = false
;; Whether to emit notice on successful execution too.
;NOTICE_ON_SUCCESS = false
;; Time interval for job to run.
;SCHEDULE = @daily
;; end edit/add - by petru @ codex
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Clean up old repository archives
+9
View File
@@ -31,3 +31,12 @@ func DumpDatabase(filePath, dbType string) error {
}
return xormEngine.DumpTablesToFile(tbs, filePath)
}
// start edit/add - by petru @ codex
// ImportDatabase imports SQL statements from a file into the current default engine.
func ImportDatabase(filePath string) error {
_, err := xormEngine.ImportFile(filePath)
return err
}
// end edit/add - by petru @ codex
+37
View File
@@ -0,0 +1,37 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
import "path/filepath"
// start edit/add - by petru @ codex
var Backup = struct {
Path string
RetentionCount int
Compress bool
IncludeAppINISnapshot bool
}{
RetentionCount: 7,
Compress: true,
IncludeAppINISnapshot: true,
}
func loadBackupFrom(rootCfg ConfigProvider) {
sec := rootCfg.Section("backup")
Backup.Path = sec.Key("PATH").MustString(filepath.Join(AppDataPath, "backups", "db"))
if !filepath.IsAbs(Backup.Path) {
Backup.Path = filepath.Join(AppWorkPath, Backup.Path)
}
Backup.RetentionCount = sec.Key("RETENTION_COUNT").MustInt(7)
if Backup.RetentionCount < 0 {
Backup.RetentionCount = 0
}
Backup.Compress = sec.Key("COMPRESS").MustBool(true)
Backup.IncludeAppINISnapshot = sec.Key("INCLUDE_APP_INI_SNAPSHOT").MustBool(true)
}
// end edit/add - by petru @ codex
+45
View File
@@ -0,0 +1,45 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// start edit/add - by petru @ codex
func TestLoadBackupFrom(t *testing.T) {
oldAppWorkPath := AppWorkPath
oldAppDataPath := AppDataPath
oldBackup := Backup
defer func() {
AppWorkPath = oldAppWorkPath
AppDataPath = oldAppDataPath
Backup = oldBackup
}()
AppWorkPath = "/srv/gitea"
AppDataPath = "data"
cfg, err := NewConfigProviderFromData(`
[backup]
PATH = backups/custom-db
RETENTION_COUNT = -4
COMPRESS = false
INCLUDE_APP_INI_SNAPSHOT = false
`)
require.NoError(t, err)
loadBackupFrom(cfg)
assert.Equal(t, filepath.Join(AppWorkPath, "backups", "custom-db"), Backup.Path)
assert.Equal(t, 0, Backup.RetentionCount)
assert.False(t, Backup.Compress)
assert.False(t, Backup.IncludeAppINISnapshot)
}
// end edit/add - by petru @ codex
+9 -2
View File
@@ -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)
}
+52
View File
@@ -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
+50
View File
@@ -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
+41
View File
@@ -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
+177
View File
@@ -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
+135
View File
@@ -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 <noreply@example.com>",
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 <noreply@example.com>", 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
+152
View File
@@ -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
+1
View File
@@ -135,6 +135,7 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error {
}
loadTimeFrom(cfg)
loadRepositoryFrom(cfg)
loadBackupFrom(cfg) // edit/add - by petru @ codex
if err := loadAvatarsFrom(cfg); err != nil {
return err
}
+83 -4
View File
@@ -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 <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"%s\">documentation</a> 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 <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"%s\">documentation</a> 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.<br>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",
+83 -4
View File
@@ -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 <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"%s\">documentația</a> î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 <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"%s\">documentația</a> î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.<br>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",
+1038 -32
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -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
+1
View File
@@ -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)
+762 -1
View File
@@ -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 <noreply@example.com>",
}
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 <noreply@example.com>",
}
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 <mail@example.com>
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 <noreply@example.com>",
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 <noreply@example.com>", 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 <noreply@example.com>",
}
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 <mail@example.com>
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 <noreply@example.com>",
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 <mail@example.com>
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)
}
+8
View File
@@ -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()
+2 -1
View File
@@ -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))
+5 -3
View File
@@ -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{}
+1 -1
View File
@@ -100,7 +100,7 @@ func ListTasks() TaskTable {
tTable := make([]*TaskTableRow, 0, len(tasks))
for _, task := range tasks {
spec := "-"
spec := task.config.GetSchedule() // edit/add - by petru @ codex
var (
next time.Time
prev time.Time
+17
View File
@@ -15,6 +15,7 @@ import (
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/updatechecker"
asymkey_service "code.gitea.io/gitea/services/asymkey"
dbbackup_service "code.gitea.io/gitea/services/dbbackup" // edit/add - by petru @ codex
repo_service "code.gitea.io/gitea/services/repository"
archiver_service "code.gitea.io/gitea/services/repository/archiver"
user_service "code.gitea.io/gitea/services/user"
@@ -234,6 +235,21 @@ func registerRebuildIssueIndexer() {
})
}
// start edit/add - by petru @ codex
func registerDatabaseBackup() {
RegisterTaskFatal("database_backup", &BaseConfig{
Enabled: true,
RunAtStart: false,
Schedule: "@daily",
NoticeOnSuccess: false,
}, func(ctx context.Context, _ *user_model.User, _ Config) error {
_, _, err := dbbackup_service.CreateDatabaseBackup(ctx)
return err
})
}
// end edit/add - by petru @ codex
func initExtendedTasks() {
registerDeleteInactiveUsers()
registerDeleteExpiredAccountRequests()
@@ -250,4 +266,5 @@ func initExtendedTasks() {
registerDeleteOldSystemNotices()
registerGCLFS()
registerRebuildIssueIndexer()
registerDatabaseBackup() // edit/add - by petru @ codex
}
+412
View File
@@ -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
+123
View File
@@ -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
+15 -2
View File
@@ -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"`
+6
View File
@@ -58,6 +58,12 @@
<td>{{ctx.Locale.Tr "admin.dashboard.delete_generated_repository_avatars"}}</td>
<td class="tw-text-right"><button type="submit" class="ui primary button" name="op" value="delete_generated_repository_avatars">{{svg "octicon-play"}} {{ctx.Locale.Tr "admin.dashboard.operation_run"}}</button></td>
</tr>
<!-- start edit/add - by petru @ codex -->
<tr>
<td>{{ctx.Locale.Tr "admin.dashboard.database_backup"}}</td>
<td class="tw-text-right"><button type="submit" class="ui primary button" name="op" value="database_backup">{{svg "octicon-play"}} {{ctx.Locale.Tr "admin.dashboard.operation_run"}}</button></td>
</tr>
<!-- end edit/add - by petru @ codex -->
<tr>
<td>{{ctx.Locale.Tr "admin.dashboard.sync_repo_branches"}}</td>
<td class="tw-text-right"><button type="submit" class="ui primary button" name="op" value="sync_repo_branches">{{svg "octicon-play"}} {{ctx.Locale.Tr "admin.dashboard.operation_run"}}</button></td>
+1 -1
View File
@@ -45,7 +45,7 @@
<span class="flex-text-inline">{{svg "octicon-globe" 14}} {{ctx.Locale.LangName}}</span>
<div class="menu language-menu">
{{range .AllLangs -}}
<a lang="{{.Lang}}" data-url="{{AppSubUrl}}/?lang={{.Lang}}" class="item {{if eq ctx.Locale.Lang .Lang}}selected{{end}}">{{.Name}}</a>
<a lang="{{.Lang}}" data-url="{{QueryBuild $.CurrentRequestURI "lang" .Lang}}" class="item {{if eq ctx.Locale.Lang .Lang}}selected{{end}}">{{.Name}}</a> <!-- edit/add - by petru @ codex -->
{{end -}}
</div>
</div>
+621 -140
View File
@@ -6,26 +6,31 @@
{{ctx.Locale.Tr "install.title"}}
</h3>
<div class="ui attached segment">
{{if not .Err_DbInstalledBefore}}{{template "base/alert" .}}{{end}} <!-- edit/add - by petru @ codex -->
{{if not (or .Err_DbInstalledBefore .Err_RepositoryFilesystemRecovery .Err_DatabaseBackupRecovery)}}{{template "base/alert" .}}{{end}} <!-- edit/add - by petru @ codex -->
<p>{{ctx.Locale.Tr "install.docker_helper" "https://docs.gitea.com/installation/install-with-docker"}}</p>
<form id="install-form" class="ui form js-install-form" action="{{AppSubUrl}}/" method="post" enctype="multipart/form-data"> <!-- edit/add - by petru @ codex -->
<h4 class="ui dividing header">{{ctx.Locale.Tr "install.import_app_ini_title"}}</h4>
<p>{{ctx.Locale.Tr "install.import_app_ini_desc"}}</p>
<div class="inline field">
<label for="app_ini_file">{{ctx.Locale.Tr "install.import_app_ini_file"}}</label>
<input id="app_ini_file" name="app_ini_file" type="file" accept=".ini,text/plain" data-import-action="{{AppSubUrl}}/import_app_ini">
<span class="help">{{ctx.Locale.Tr "install.import_app_ini_helper" .AppINIImportMaxSizeKB}}</span>
</div>
<div class="inline field">
<div class="ui checkbox">
<label for="import_sensitive_secrets">{{ctx.Locale.Tr "install.import_app_ini_sensitive_secrets"}}</label>
<input id="import_sensitive_secrets" name="import_sensitive_secrets" type="checkbox" {{if .import_sensitive_secrets}}checked{{end}}>
<form id="install-form" class="ui form js-install-form" action="{{.InstallFormAction}}" method="post" enctype="multipart/form-data"> <!-- edit/add - by petru @ codex -->
<!-- start edit/add - by petru @ codex -->
<div class="install-recovery-entry">
<div class="install-recovery-entry-copy">
<h4 class="ui header">{{.InstallRecoverySectionTitle}}</h4>
<p>{{.InstallRecoverySectionDesc}}</p>
</div>
<div class="install-recovery-entry-actions">
<button type="button" class="ui primary button js-install-recovery-launcher">{{ctx.Locale.Tr "install.recovery_reinstall_open"}}</button>
</div>
<span class="help">{{ctx.Locale.Tr "install.import_app_ini_sensitive_secrets_helper"}}</span>
</div>
<!-- end edit/add - by petru @ codex -->
<input type="hidden" name="imported_app_ini" value="{{if .imported_app_ini}}true{{end}}"> <!-- edit/add - by petru @ codex -->
<input type="hidden" id="install_backup_restore_id" name="backup_restore_id" value="{{.backup_restore_id}}"> <!-- edit/add - by petru @ codex -->
<input type="hidden" id="install_backup_import_app_ini" name="backup_import_app_ini" value="{{if .backup_import_app_ini}}true{{end}}"> <!-- edit/add - by petru @ codex -->
<input type="hidden" id="install_backup_restore_db" name="backup_restore_db" value="{{if .backup_restore_db}}true{{end}}"> <!-- edit/add - by petru @ codex -->
<input type="hidden" id="install_recovery_mode" name="recovery_mode" value="{{.recovery_mode}}"> <!-- edit/add - by petru @ codex -->
<input type="hidden" id="install_reinstall_confirm_first" name="reinstall_confirm_first" value="{{if .reinstall_confirm_first}}true{{end}}"> <!-- edit/add - by petru @ codex -->
<input type="hidden" id="install_reinstall_confirm_second" name="reinstall_confirm_second" value="{{if .reinstall_confirm_second}}true{{end}}"> <!-- edit/add - by petru @ codex -->
<input type="hidden" id="install_reinstall_confirm_third" name="reinstall_confirm_third" value="{{if .reinstall_confirm_third}}true{{end}}"> <!-- edit/add - by petru @ codex -->
{{if .InstallIsRecoveryRequest}}<input type="hidden" name="recovery_request" value="true">{{end}} <!-- edit/add - by petru @ codex -->
<input type="hidden" name="imported_lfs_jwt_secret" value="{{.imported_lfs_jwt_secret}}">
<input type="hidden" name="imported_internal_token" value="{{.imported_internal_token}}">
<input type="hidden" name="imported_o_auth2_jwt_secret" value="{{.imported_o_auth2_jwt_secret}}">
@@ -96,6 +101,69 @@
<span class="help">{{ctx.Locale.Tr "install.sqlite_helper"}}</span>
</div>
</div>
<!-- start edit/add - by petru @ codex -->
<details class="optional field"{{if or .Err_BackupPath .Err_BackupRecovery}} open{{end}}>
<summary class="right-content tw-py-2{{if or .Err_BackupPath .Err_BackupRecovery}} tw-text-red{{end}}">
{{ctx.Locale.Tr "install.database_backup_title"}}
</summary>
<span class="desc">{{ctx.Locale.Tr "install.database_backup_desc"}}</span>
<div class="inline field">
<div class="ui checkbox">
<label>{{ctx.Locale.Tr "install.database_backup_enabled"}}</label>
<input name="backup_enabled" type="checkbox" {{if .backup_enabled}}checked{{end}}>
</div>
<span class="help">{{ctx.Locale.Tr "install.database_backup_enabled_helper"}}</span>
</div>
<div class="inline field">
<div class="ui checkbox">
<label>{{ctx.Locale.Tr "install.database_backup_run_at_start"}}</label>
<input name="backup_run_at_start" type="checkbox" {{if .backup_run_at_start}}checked{{end}}>
</div>
<span class="help">{{ctx.Locale.Tr "install.database_backup_run_at_start_helper"}}</span>
</div>
<div class="inline field">
<label for="backup_schedule">{{ctx.Locale.Tr "install.database_backup_schedule"}}</label>
<input id="backup_schedule" name="backup_schedule" value="{{.backup_schedule}}" placeholder="@daily">
<span class="help">{{ctx.Locale.Tr "install.database_backup_schedule_helper"}}</span>
</div>
<div class="inline field">
<label for="backup_path">{{ctx.Locale.Tr "install.database_backup_path"}}</label>
<input id="backup_path" name="backup_path" value="{{.backup_path}}">
<span class="help">{{ctx.Locale.Tr "install.database_backup_path_helper"}}</span>
</div>
<div class="inline field">
<label for="backup_retention_count">{{ctx.Locale.Tr "install.database_backup_retention_count"}}</label>
<input id="backup_retention_count" name="backup_retention_count" type="number" min="0" value="{{.backup_retention_count}}">
<span class="help">{{ctx.Locale.Tr "install.database_backup_retention_count_helper"}}</span>
</div>
<div class="inline field">
<div class="ui checkbox">
<label>{{ctx.Locale.Tr "install.database_backup_compress"}}</label>
<input name="backup_compress" type="checkbox" {{if .backup_compress}}checked{{end}}>
</div>
<span class="help">{{ctx.Locale.Tr "install.database_backup_compress_helper"}}</span>
</div>
<div class="inline field">
<div class="ui checkbox">
<label>{{ctx.Locale.Tr "install.database_backup_include_app_ini_snapshot"}}</label>
<input name="backup_include_app_ini_snapshot" type="checkbox" {{if .backup_include_app_ini_snapshot}}checked{{end}}>
</div>
<span class="help">{{ctx.Locale.Tr "install.database_backup_include_app_ini_snapshot_helper"}}</span>
</div>
<div class="inline field">
<div class="ui checkbox">
<label>{{ctx.Locale.Tr "install.recovery_email_enabled"}}</label>
<input name="recovery_email_enabled" type="checkbox" {{if .recovery_email_enabled}}checked{{end}}>
</div>
<span class="help">{{ctx.Locale.Tr "install.recovery_email_enabled_helper"}}</span>
</div>
<div class="inline field {{if .Err_BackupRecovery}}error{{end}}">
<label for="recovery_allowed_emails">{{ctx.Locale.Tr "install.recovery_allowed_emails"}}</label>
<textarea id="recovery_allowed_emails" name="recovery_allowed_emails" rows="3">{{.recovery_allowed_emails}}</textarea>
<span class="help">{{ctx.Locale.Tr "install.recovery_allowed_emails_helper"}}</span>
</div>
</details>
<!-- end edit/add - by petru @ codex -->
<!-- General Settings -->
<h4 class="ui dividing header">{{ctx.Locale.Tr "install.general_title"}}</h4>
@@ -174,7 +242,7 @@
<summary class="right-content tw-py-2{{if .Err_Branding}} tw-text-red{{end}}">
{{ctx.Locale.Tr "install.branding_title"}}
</summary>
<p class="center">{{ctx.Locale.Tr "install.branding_desc"}}</p>
<span class="desc">{{ctx.Locale.Tr "install.branding_desc"}}</span>
<div class="inline field">
<div class="ui checkbox">
<label for="branding_use_shared_assets">{{ctx.Locale.Tr "install.branding.shared_assets"}}</label>
@@ -247,10 +315,10 @@
<div class="inline field">
<label for="test_mail_email">{{ctx.Locale.Tr "install.test_mail_to"}}</label>
<div class="tw-inline-flex tw-items-center tw-gap-2 tw-flex-wrap" style="width: 60%;">
<div class="ui small input">
<div class="ui test_mail small input">
<input id="test_mail_email" name="test_mail_email" type="email" placeholder="{{ctx.Locale.Tr "admin.config.test_email_placeholder"}}" size="40">
<button class="ui tiny js-install-test-mail-button button" data-mail-action-ui="inline-v2" data-action="{{.InstallTestMailAction}}" type="button" style="background: var(--color-primary); color: var(--color-primary-contrast); border-color: var(--color-primary);">{{ctx.Locale.Tr "admin.config.send_test_mail_submit"}}</button>
</div>
<button class="ui tiny js-install-test-mail-button button" data-mail-action-ui="inline-v2" data-action="{{AppSubUrl}}/test_mail" type="button" style="background: var(--color-primary); color: var(--color-primary-contrast); border-color: var(--color-primary);">{{ctx.Locale.Tr "admin.config.send_test_mail_submit"}}</button>
<span class="js-install-test-mail-message"></span>
</div>
</div>
@@ -436,7 +504,7 @@
<summary class="right-content tw-py-2{{if .Err_Admin}} tw-text-red{{end}}">
{{ctx.Locale.Tr "install.admin_title"}}
</summary>
<p class="center">{{ctx.Locale.Tr "install.admin_setting_desc"}}</p>
<span class="desc">{{ctx.Locale.Tr "install.admin_setting_desc"}}</span>
<div class="inline field {{if .Err_AdminName}}error{{end}}">
<label for="admin_name">{{ctx.Locale.Tr "install.admin_name"}}</label>
<input id="admin_name" name="admin_name" value="{{.admin_name}}">
@@ -505,7 +573,7 @@
{{$filePath := HTMLFormat `<span class="ui label">%s</span> <button class="btn interact-fg" data-clipboard-text="%s">%s</button>` .CustomConfFile .CustomConfFile $copyBtn}}
{{ctx.Locale.Tr "install.config_write_file_prompt" $filePath}}
</div>
{{if not .Err_DbInstalledBefore}} <!-- edit/add - by petru @ codex -->
{{if not (or .Err_DbInstalledBefore .Err_RepositoryFilesystemRecovery .Err_DatabaseBackupRecovery)}} <!-- edit/add - by petru @ codex -->
<div class="tw-mt-4 tw-mb-2 tw-text-center">
<button
class="ui primary button js-install-confirm-button"
@@ -519,44 +587,158 @@
</div>
</div>
</div>
{{if .Err_DbInstalledBefore}}
<!-- start edit/add - by petru @ codex -->
<div class="ui small modal install-reinstall-confirm-modal js-install-reinstall-modal">
<div class="ui negative message install-reinstall-alert">{{ctx.Locale.Tr "install.reinstall_error"}}</div>
<div class="ui large modal install-recovery-launcher-modal js-install-recovery-launcher-modal">
<div class="header">{{.InstallRecoveryLauncherTitle}}</div>
<div class="content">
<p class="reinstall-message">{{ctx.Locale.Tr "install.reinstall_confirm_message"}}</p>
<div class="reinstall-confirm">
<div class="ui checkbox">
<input id="reinstall_confirm_first" name="reinstall_confirm_first" type="checkbox" form="install-form">
<label for="reinstall_confirm_first">{{ctx.Locale.Tr "install.reinstall_confirm_check_1"}}</label>
{{if .InstallRecoveryProblemText}}
<div class="ui negative message install-recovery-launcher-problem">{{.InstallRecoveryProblemText}}</div>
{{end}}
<div class="ui form">
<h5 class="ui dividing header">{{ctx.Locale.Tr "install.recovery_source_title"}}</h5>
{{if .InstallRecoveryOptionDatabaseBackupAvailable}}
<div class="ui raised segment">
<h5 class="ui small header">{{ctx.Locale.Tr "install.recovery_launcher_backup_select"}}</h5>
<div class="inline field install-recovery-inline-field">
<label for="launcher_backup_restore_id">{{ctx.Locale.Tr "install.recovery_launcher_backup_bundle"}}</label>
<select id="launcher_backup_restore_id" class="install-recovery-select">
<option value="">{{ctx.Locale.Tr "install.recovery_launcher_backup_select_placeholder"}}</option>
{{range .InstallRecoveryBackups}}
<option value="{{.ID}}" data-has-app-ini="{{if .HasAppINI}}true{{else}}false{{end}}" data-has-database="{{if .HasDatabase}}true{{else}}false{{end}}" {{if eq $.backup_restore_id .ID}}selected{{end}}>{{.Label}}</option>
{{end}}
</select>
</div>
<p class="help">{{ctx.Locale.Tr "install.recovery_database_backup_select_helper"}}</p>
<div class="grouped fields js-launcher-backup-actions tw-hidden">
<div class="field js-launcher-backup-import-app-ini-field tw-hidden">
<div class="ui checkbox">
<input id="launcher_backup_import_app_ini" type="checkbox" data-import-action="{{.InstallBackupAppINIImportAction}}" {{if .backup_import_app_ini}}checked{{end}}>
<label for="launcher_backup_import_app_ini">{{ctx.Locale.Tr "install.recovery_launcher_backup_import_app_ini"}}</label>
</div>
</div>
<div class="field js-launcher-backup-restore-db-field tw-hidden">
<div class="ui checkbox">
<input id="launcher_backup_restore_db" type="checkbox" {{if .backup_restore_db}}checked{{end}}>
<label for="launcher_backup_restore_db">{{ctx.Locale.Tr "install.recovery_launcher_backup_restore_db"}}</label>
</div>
</div>
</div>
</div>
{{end}}
<div class="ui raised segment js-launcher-import-app-ini-segment">
<h5 class="ui small header">{{ctx.Locale.Tr "install.import_app_ini_title"}}</h5>
<div class="inline field install-recovery-inline-field">
<div class="install-recovery-inline-control-row">
<label for="app_ini_file">{{ctx.Locale.Tr "install.import_app_ini_file"}}</label>
<input id="app_ini_file" name="app_ini_file" type="file" accept=".ini,text/plain" form="install-form" data-import-action="{{.InstallImportAction}}">
<button type="button" class="ui button basic small js-install-recovery-reset-app-ini tw-hidden">{{ctx.Locale.Tr "install.recovery_launcher_reset"}}</button>
</div>
</div>
<span class="help">{{ctx.Locale.Tr "install.import_app_ini_helper" .AppINIImportMaxSizeKB}}</span>
<div class="field">
<div class="ui checkbox">
<input id="import_sensitive_secrets" name="import_sensitive_secrets" type="checkbox" form="install-form" {{if .import_sensitive_secrets}}checked{{end}}>
<label for="import_sensitive_secrets">{{ctx.Locale.Tr "install.import_app_ini_sensitive_secrets"}}</label>
</div>
<span class="help">{{ctx.Locale.Tr "install.import_app_ini_sensitive_secrets_helper"}}</span>
</div>
</div>
</div>
<div class="reinstall-confirm">
<div class="ui checkbox">
<input id="reinstall_confirm_second" name="reinstall_confirm_second" type="checkbox" form="install-form">
<label for="reinstall_confirm_second">{{ctx.Locale.Tr "install.reinstall_confirm_check_2"}}</label>
<div class="ui raised segment js-launcher-database-backup-file-segment">
<h5 class="ui small header">{{ctx.Locale.Tr "install.recovery_launcher_database_backup_file"}}</h5>
<div class="inline field install-recovery-inline-field">
<div class="install-recovery-inline-control-row">
<label for="database_backup_file">{{ctx.Locale.Tr "install.recovery_launcher_sql_backup_file"}}</label>
<input id="database_backup_file" name="database_backup_file" type="file" accept=".sql,.gz,application/gzip,application/x-gzip,text/plain" form="install-form">
<button type="button" class="ui button basic small js-install-recovery-reset-db-backup tw-hidden">{{ctx.Locale.Tr "install.recovery_launcher_reset"}}</button>
</div>
</div>
<span class="help">{{ctx.Locale.Tr "install.recovery_launcher_sql_backup_file_helper"}}</span>
</div>
</div>
<div class="reinstall-confirm">
<div class="ui checkbox">
<input id="reinstall_confirm_third" name="reinstall_confirm_third" type="checkbox" form="install-form">
<label for="reinstall_confirm_third">{{ctx.Locale.Tr "install.reinstall_confirm_check_3"}}</label>
{{if and .InstallRecoveryOptionRepositoryFilesystemAvailable (not .InstallRecoveryOptionExistingDBAvailable) (not .InstallRecoveryOptionDatabaseBackupAvailable)}}
<div class="ui raised segment js-launcher-repository-filesystem-segment">
<h5 class="ui small header">{{ctx.Locale.Tr "install.recovery_source_repository_filesystem"}}</h5>
<div class="field">
<div class="ui checkbox">
<input id="launcher_repository_filesystem" type="checkbox" {{if eq .recovery_mode "repository_filesystem"}}checked{{end}}>
<label for="launcher_repository_filesystem">{{ctx.Locale.Tr "install.recovery_source_repository_filesystem_helper"}}</label>
</div>
</div>
</div>
{{end}}
<div class="ui raised segment js-install-recovery-launcher-confirm-panel tw-hidden" data-confirm-kind="backup_restore">
<div class="grouped fields">
<div class="field">
<div class="ui checkbox">
<input id="launcher_backup_confirm_first" type="checkbox">
<label for="launcher_backup_confirm_first">{{ctx.Locale.Tr "install.recovery_database_backup_confirm_check_1"}}</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input id="launcher_backup_confirm_second" type="checkbox">
<label for="launcher_backup_confirm_second">{{ctx.Locale.Tr "install.recovery_database_backup_confirm_check_2"}}</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input id="launcher_backup_confirm_third" type="checkbox">
<label for="launcher_backup_confirm_third">{{ctx.Locale.Tr "install.recovery_database_backup_confirm_check_3"}}</label>
</div>
</div>
</div>
</div>
<div class="ui raised segment js-install-recovery-launcher-confirm-panel tw-hidden" data-confirm-kind="partial_restore">
<div class="grouped fields">
<div class="field">
<div class="ui checkbox">
<input id="launcher_partial_confirm_first" type="checkbox">
<label for="launcher_partial_confirm_first">{{ctx.Locale.Tr "install.reinstall_confirm_check_1"}}</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input id="launcher_partial_confirm_second" type="checkbox">
<label for="launcher_partial_confirm_second">{{ctx.Locale.Tr "install.reinstall_confirm_check_2"}}</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input id="launcher_partial_confirm_third" type="checkbox">
<label for="launcher_partial_confirm_third">{{ctx.Locale.Tr "install.reinstall_confirm_check_3"}}</label>
</div>
</div>
</div>
</div>
<div class="ui raised segment js-install-recovery-launcher-confirm-panel tw-hidden" data-confirm-kind="repository_filesystem">
<div class="grouped fields">
<div class="field">
<div class="ui checkbox">
<input id="launcher_repository_confirm_first" type="checkbox">
<label for="launcher_repository_confirm_first">{{ctx.Locale.Tr "install.recovery_repository_filesystem_confirm_check_1"}}</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input id="launcher_repository_confirm_second" type="checkbox">
<label for="launcher_repository_confirm_second">{{ctx.Locale.Tr "install.recovery_repository_filesystem_confirm_check_2"}}</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input id="launcher_repository_confirm_third" type="checkbox">
<label for="launcher_repository_confirm_third">{{ctx.Locale.Tr "install.recovery_repository_filesystem_confirm_check_3"}}</label>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="actions">
<a class="ui button cancel" href="{{AppSubUrl}}/">{{ctx.Locale.Tr "go_back"}}</a>
<button
type="submit"
form="install-form"
class="ui primary button js-install-confirm-button"
data-install-label-template="{{ctx.Locale.Tr "install.install_btn_confirm" "__SITE_NAME__"}}"
disabled
>{{ctx.Locale.Tr "install.install_btn_confirm" .InstallerSiteName}}</button>
<button type="button" class="ui button deny">{{ctx.Locale.Tr "cancel"}}</button>
<button type="submit" form="install-form" class="ui primary button approve js-install-recovery-launcher-save">{{ctx.Locale.Tr "save"}}</button>
</div>
</div>
<!-- end edit/add - by petru @ codex -->
{{end}}
<div class="install-language-balloon" id="install-language-balloon" role="status" aria-live="polite">
{{ctx.Locale.Tr "install.language_balloon"}}
</div>
@@ -570,89 +752,108 @@
}, 5000);
};
// start edit/add - by petru @ codex
const syncInstallFormFromImportedResponse = (form, parsed) => {
const importedForm = parsed.querySelector('.js-install-form');
if (!importedForm) throw new Error('import form not found');
for (const importedInput of importedForm.querySelectorAll('input, textarea, select')) {
if (!(importedInput instanceof HTMLInputElement || importedInput instanceof HTMLTextAreaElement || importedInput instanceof HTMLSelectElement)) continue;
if (!importedInput.name) continue;
if (importedInput.type === 'file') continue;
if (importedInput.type === 'radio') {
const currentRadio = form.querySelector(`input[type="radio"][name="${CSS.escape(importedInput.name)}"][value="${CSS.escape(importedInput.value)}"]`);
if (currentRadio instanceof HTMLInputElement) currentRadio.checked = importedInput.checked;
continue;
}
if (importedInput.type === 'checkbox') {
const currentCheckbox = form.querySelector(`input[type="checkbox"][name="${CSS.escape(importedInput.name)}"]`) || document.querySelector(`input[type="checkbox"][name="${CSS.escape(importedInput.name)}"][form="install-form"]`); // edit/add - by petru @ codex
if (currentCheckbox instanceof HTMLInputElement) {
currentCheckbox.checked = importedInput.checked;
currentCheckbox.dispatchEvent(new Event('change', {bubbles: true}));
const checkboxContainer = currentCheckbox.closest('.ui.checkbox');
if (checkboxContainer) {
checkboxContainer.classList.toggle('checked', importedInput.checked);
}
}
continue;
}
const currentInput = form.querySelector(`[name="${CSS.escape(importedInput.name)}"]`) || document.querySelector(`[name="${CSS.escape(importedInput.name)}"][form="install-form"]`); // edit/add - by petru @ codex
if (currentInput instanceof HTMLInputElement || currentInput instanceof HTMLTextAreaElement || currentInput instanceof HTMLSelectElement) {
currentInput.value = importedInput.value;
currentInput.dispatchEvent(new Event('change', {bubbles: true}));
}
}
const importedDbTypeInput = importedForm.querySelector('#db_type');
const currentDbTypeInput = form.querySelector('#db_type');
if (importedDbTypeInput instanceof HTMLInputElement && currentDbTypeInput instanceof HTMLInputElement) {
currentDbTypeInput.value = importedDbTypeInput.value;
currentDbTypeInput.dispatchEvent(new Event('change', {bubbles: true}));
const currentDbText = form.querySelector('.database.type.dropdown .text');
const importedDbText = importedForm.querySelector('.database.type.dropdown .text');
if (currentDbText && importedDbText) currentDbText.textContent = importedDbText.textContent;
}
const importedLangInput = importedForm.querySelector('input[name="default_language"]');
const currentLangInput = form.querySelector('input[name="default_language"]');
if (importedLangInput instanceof HTMLInputElement && currentLangInput instanceof HTMLInputElement) {
currentLangInput.value = importedLangInput.value;
currentLangInput.dispatchEvent(new Event('change', {bubbles: true}));
const currentLangText = form.querySelector('.language.selection.dropdown .text');
const importedLangText = importedForm.querySelector('.language.selection.dropdown .text');
if (currentLangText && importedLangText) currentLangText.textContent = importedLangText.textContent;
}
for (const existingFlash of document.querySelectorAll('.flash-message')) {
existingFlash.remove();
}
const importedAlerts = parsed.querySelector('.ui.attached.segment > .ui.message.flash-message, .ui.attached.segment > .flash-message');
if (importedAlerts) {
const segment = document.querySelector('.ui.attached.segment');
if (segment) {
segment.insertBefore(importedAlerts.cloneNode(true), segment.firstChild);
dismissInstallSuccessFlash();
}
}
};
const importInstallFormState = async (form, action) => {
const formData = new FormData(form);
try {
const response = await fetch(action, {
method: 'POST',
body: formData,
credentials: 'same-origin',
});
const html = await response.text();
const parsed = new DOMParser().parseFromString(html, 'text/html');
syncInstallFormFromImportedResponse(form, parsed);
return true;
} catch {
form.action = action;
form.submit();
return false;
}
};
// end edit/add - by petru @ codex
if (installAppINIInput) {
installAppINIInput.addEventListener('change', async () => {
if (!installAppINIInput.files || installAppINIInput.files.length === 0) return;
const form = installAppINIInput.closest('.js-install-form');
const form = document.querySelector('.js-install-form');
if (!form) return;
const formData = new FormData(form);
try {
const response = await fetch(installAppINIInput.dataset.importAction, {
method: 'POST',
body: formData,
credentials: 'same-origin',
});
const html = await response.text();
const parsed = new DOMParser().parseFromString(html, 'text/html');
const importedForm = parsed.querySelector('.js-install-form');
if (!importedForm) throw new Error('import form not found');
for (const importedInput of importedForm.querySelectorAll('input, textarea, select')) {
if (!(importedInput instanceof HTMLInputElement || importedInput instanceof HTMLTextAreaElement || importedInput instanceof HTMLSelectElement)) continue;
if (!importedInput.name) continue;
if (importedInput.type === 'file') continue;
if (importedInput.type === 'radio') {
const currentRadio = form.querySelector(`input[type="radio"][name="${CSS.escape(importedInput.name)}"][value="${CSS.escape(importedInput.value)}"]`);
if (currentRadio instanceof HTMLInputElement) currentRadio.checked = importedInput.checked;
continue;
}
if (importedInput.type === 'checkbox') {
const currentCheckbox = form.querySelector(`input[type="checkbox"][name="${CSS.escape(importedInput.name)}"]`);
if (currentCheckbox instanceof HTMLInputElement) {
currentCheckbox.checked = importedInput.checked;
currentCheckbox.dispatchEvent(new Event('change', {bubbles: true}));
const checkboxContainer = currentCheckbox.closest('.ui.checkbox');
if (checkboxContainer) {
checkboxContainer.classList.toggle('checked', importedInput.checked);
}
}
continue;
}
const currentInput = form.querySelector(`[name="${CSS.escape(importedInput.name)}"]`);
if (currentInput instanceof HTMLInputElement || currentInput instanceof HTMLTextAreaElement || currentInput instanceof HTMLSelectElement) {
currentInput.value = importedInput.value;
currentInput.dispatchEvent(new Event('change', {bubbles: true}));
}
}
const importedDbTypeInput = importedForm.querySelector('#db_type');
const currentDbTypeInput = form.querySelector('#db_type');
if (importedDbTypeInput instanceof HTMLInputElement && currentDbTypeInput instanceof HTMLInputElement) {
currentDbTypeInput.value = importedDbTypeInput.value;
currentDbTypeInput.dispatchEvent(new Event('change', {bubbles: true}));
const currentDbText = form.querySelector('.database.type.dropdown .text');
const importedDbText = importedForm.querySelector('.database.type.dropdown .text');
if (currentDbText && importedDbText) currentDbText.textContent = importedDbText.textContent;
}
const importedLangInput = importedForm.querySelector('input[name="default_language"]');
const currentLangInput = form.querySelector('input[name="default_language"]');
if (importedLangInput instanceof HTMLInputElement && currentLangInput instanceof HTMLInputElement) {
currentLangInput.value = importedLangInput.value;
currentLangInput.dispatchEvent(new Event('change', {bubbles: true}));
const currentLangText = form.querySelector('.language.selection.dropdown .text');
const importedLangText = importedForm.querySelector('.language.selection.dropdown .text');
if (currentLangText && importedLangText) currentLangText.textContent = importedLangText.textContent;
}
for (const existingFlash of document.querySelectorAll('.flash-message')) {
existingFlash.remove();
}
const importedAlerts = parsed.querySelector('.ui.attached.segment > .ui.message.flash-message, .ui.attached.segment > .flash-message');
if (importedAlerts) {
const segment = document.querySelector('.ui.attached.segment');
if (segment) {
segment.insertBefore(importedAlerts.cloneNode(true), segment.firstChild);
dismissInstallSuccessFlash();
}
}
} catch {
form.action = installAppINIInput.dataset.importAction;
form.submit();
const imported = await importInstallFormState(form, installAppINIInput.dataset.importAction); // edit/add - by petru @ codex
if (imported && importSensitiveSecretsCheckbox instanceof HTMLInputElement) {
importSensitiveSecretsCheckbox.checked = true; // edit/add - by petru @ codex
const checkboxContainer = importSensitiveSecretsCheckbox.closest('.ui.checkbox');
if (checkboxContainer) checkboxContainer.classList.add('checked');
}
if (imported) syncLauncherBackupState(); // edit/add - by petru @ codex
syncRecoveryFileResetButtons(); // edit/add - by petru @ codex
});
}
@@ -684,9 +885,302 @@
};
const installForm = document.querySelector('.js-install-form');
const recoveryLauncherButton = document.querySelector('.js-install-recovery-launcher');
const recoveryLauncherModal = document.querySelector('.js-install-recovery-launcher-modal');
const hiddenBackupRestoreID = installForm?.querySelector('#install_backup_restore_id');
const hiddenBackupImportAppINI = installForm?.querySelector('#install_backup_import_app_ini');
const hiddenBackupRestoreDB = installForm?.querySelector('#install_backup_restore_db');
const hiddenRecoveryMode = installForm?.querySelector('#install_recovery_mode');
const hiddenReinstallConfirmFirst = installForm?.querySelector('#install_reinstall_confirm_first');
const hiddenReinstallConfirmSecond = installForm?.querySelector('#install_reinstall_confirm_second');
const hiddenReinstallConfirmThird = installForm?.querySelector('#install_reinstall_confirm_third');
const launcherBackupRestoreID = recoveryLauncherModal?.querySelector('#launcher_backup_restore_id');
const launcherBackupImportAppINI = recoveryLauncherModal?.querySelector('#launcher_backup_import_app_ini');
const launcherBackupRestoreDB = recoveryLauncherModal?.querySelector('#launcher_backup_restore_db');
const launcherRepositoryFilesystem = recoveryLauncherModal?.querySelector('#launcher_repository_filesystem');
const launcherDatabaseBackupFile = recoveryLauncherModal?.querySelector('#database_backup_file');
const importSensitiveSecretsCheckbox = document.querySelector('#import_sensitive_secrets'); // edit/add - by petru @ codex
const resetAppINIButton = recoveryLauncherModal?.querySelector('.js-install-recovery-reset-app-ini');
const resetDatabaseBackupButton = recoveryLauncherModal?.querySelector('.js-install-recovery-reset-db-backup');
const launcherBackupActions = recoveryLauncherModal?.querySelector('.js-launcher-backup-actions');
const launcherBackupImportAppINIField = recoveryLauncherModal?.querySelector('.js-launcher-backup-import-app-ini-field');
const launcherBackupRestoreDBField = recoveryLauncherModal?.querySelector('.js-launcher-backup-restore-db-field');
const launcherImportAppINISegment = recoveryLauncherModal?.querySelector('.js-launcher-import-app-ini-segment');
const launcherDatabaseBackupFileSegment = recoveryLauncherModal?.querySelector('.js-launcher-database-backup-file-segment');
const launcherRepositoryFilesystemSegment = recoveryLauncherModal?.querySelector('.js-launcher-repository-filesystem-segment');
const launcherConfirmPanels = recoveryLauncherModal ? recoveryLauncherModal.querySelectorAll('.js-install-recovery-launcher-confirm-panel') : [];
const launcherSaveButton = recoveryLauncherModal?.querySelector('.js-install-recovery-launcher-save');
const appNameInput = installForm?.querySelector('#app_name');
const smtpFromNameInput = installForm?.querySelector('#smtp_from_name');
const installConfirmButtons = installForm ? installForm.querySelectorAll('.js-install-confirm-button') : [];
const setLauncherPanelVisible = (panel, visible) => {
panel.classList.toggle('tw-hidden', !visible);
for (const input of panel.querySelectorAll('input[type="checkbox"]')) {
if (!(input instanceof HTMLInputElement)) continue;
if (!visible) input.checked = false;
const checkboxContainer = input.closest('.ui.checkbox');
if (checkboxContainer) checkboxContainer.classList.toggle('checked', visible && input.checked);
}
};
const resetLauncherConfirmations = () => { // edit/add - by petru @ codex
for (const panel of launcherConfirmPanels) {
for (const input of panel.querySelectorAll('input[type="checkbox"]')) {
if (!(input instanceof HTMLInputElement)) continue;
input.checked = false;
const checkboxContainer = input.closest('.ui.checkbox');
if (checkboxContainer) checkboxContainer.classList.remove('checked');
}
}
if (hiddenReinstallConfirmFirst instanceof HTMLInputElement) hiddenReinstallConfirmFirst.value = '';
if (hiddenReinstallConfirmSecond instanceof HTMLInputElement) hiddenReinstallConfirmSecond.value = '';
if (hiddenReinstallConfirmThird instanceof HTMLInputElement) hiddenReinstallConfirmThird.value = '';
};
const clearImportedAppINIPreviewState = () => { // edit/add - by petru @ codex
if (!installForm) return;
const importedAppINIState = installForm.querySelector('input[name="imported_app_ini"]');
if (importedAppINIState instanceof HTMLInputElement) importedAppINIState.value = '';
const importedLFSJWTSecret = installForm.querySelector('input[name="imported_lfs_jwt_secret"]');
if (importedLFSJWTSecret instanceof HTMLInputElement) importedLFSJWTSecret.value = '';
const importedInternalToken = installForm.querySelector('input[name="imported_internal_token"]');
if (importedInternalToken instanceof HTMLInputElement) importedInternalToken.value = '';
const importedOAuth2JWTSecret = installForm.querySelector('input[name="imported_o_auth2_jwt_secret"]');
if (importedOAuth2JWTSecret instanceof HTMLInputElement) importedOAuth2JWTSecret.value = '';
if (importSensitiveSecretsCheckbox instanceof HTMLInputElement) {
importSensitiveSecretsCheckbox.checked = false; // edit/add - by petru @ codex
const checkboxContainer = importSensitiveSecretsCheckbox.closest('.ui.checkbox');
if (checkboxContainer) checkboxContainer.classList.remove('checked');
}
};
const syncLauncherSaveButton = () => {
if (!(launcherSaveButton instanceof HTMLButtonElement)) return;
const hasBundleSelected = launcherBackupRestoreID instanceof HTMLSelectElement && launcherBackupRestoreID.value !== '';
const hasSQLBackupSelected = launcherDatabaseBackupFile instanceof HTMLInputElement && !!launcherDatabaseBackupFile.files && launcherDatabaseBackupFile.files.length > 0;
const repositoryFilesystemSelected = launcherRepositoryFilesystem instanceof HTMLInputElement && launcherRepositoryFilesystem.checked;
const importedAppINIState = installForm?.querySelector('input[name="imported_app_ini"]'); // edit/add - by petru @ codex
const hasImportedAppINI = importedAppINIState instanceof HTMLInputElement && importedAppINIState.value === 'true'; // edit/add - by petru @ codex
const importSelected = launcherBackupImportAppINI instanceof HTMLInputElement && !launcherBackupImportAppINI.closest('.tw-hidden') && launcherBackupImportAppINI.checked;
const restoreSelected = launcherBackupRestoreDB instanceof HTMLInputElement && !launcherBackupRestoreDB.closest('.tw-hidden') && launcherBackupRestoreDB.checked;
let requiredPanel = null;
if (repositoryFilesystemSelected) {
requiredPanel = recoveryLauncherModal?.querySelector('[data-confirm-kind="repository_filesystem"]');
} else if (hasSQLBackupSelected || (hasBundleSelected && restoreSelected)) {
requiredPanel = recoveryLauncherModal?.querySelector('[data-confirm-kind="backup_restore"]');
} else if ((hasBundleSelected && (importSelected || restoreSelected)) || hasImportedAppINI) {
requiredPanel = recoveryLauncherModal?.querySelector('[data-confirm-kind="partial_restore"]');
}
if (!requiredPanel) {
if (hiddenReinstallConfirmFirst instanceof HTMLInputElement) hiddenReinstallConfirmFirst.value = '';
if (hiddenReinstallConfirmSecond instanceof HTMLInputElement) hiddenReinstallConfirmSecond.value = '';
if (hiddenReinstallConfirmThird instanceof HTMLInputElement) hiddenReinstallConfirmThird.value = '';
launcherSaveButton.disabled = (hasBundleSelected && !(importSelected || restoreSelected)) || (!hasBundleSelected && !hasSQLBackupSelected && !hasImportedAppINI);
return;
}
const confirmationInputs = requiredPanel.querySelectorAll('input[type="checkbox"]');
if (hiddenReinstallConfirmFirst instanceof HTMLInputElement) hiddenReinstallConfirmFirst.value = confirmationInputs[0] instanceof HTMLInputElement && confirmationInputs[0].checked ? 'true' : '';
if (hiddenReinstallConfirmSecond instanceof HTMLInputElement) hiddenReinstallConfirmSecond.value = confirmationInputs[1] instanceof HTMLInputElement && confirmationInputs[1].checked ? 'true' : '';
if (hiddenReinstallConfirmThird instanceof HTMLInputElement) hiddenReinstallConfirmThird.value = confirmationInputs[2] instanceof HTMLInputElement && confirmationInputs[2].checked ? 'true' : '';
launcherSaveButton.disabled = [...confirmationInputs].some((input) => !(input instanceof HTMLInputElement) || !input.checked);
};
const syncLauncherBackupState = () => {
if (hiddenBackupRestoreID instanceof HTMLInputElement && launcherBackupRestoreID instanceof HTMLSelectElement) {
hiddenBackupRestoreID.value = launcherBackupRestoreID.value;
}
if (hiddenBackupImportAppINI instanceof HTMLInputElement && launcherBackupImportAppINI instanceof HTMLInputElement) {
hiddenBackupImportAppINI.value = launcherBackupImportAppINI.checked ? 'true' : '';
}
if (hiddenBackupRestoreDB instanceof HTMLInputElement && launcherBackupRestoreDB instanceof HTMLInputElement) {
hiddenBackupRestoreDB.value = launcherBackupRestoreDB.checked ? 'true' : '';
}
const selectedBackupOption = launcherBackupRestoreID instanceof HTMLSelectElement ? launcherBackupRestoreID.selectedOptions[0] : null;
const hasBundleSelected = launcherBackupRestoreID instanceof HTMLSelectElement && launcherBackupRestoreID.value !== '';
const hasAppINIResource = !!selectedBackupOption && selectedBackupOption.dataset.hasAppIni === 'true';
const hasDatabaseResource = !!selectedBackupOption && selectedBackupOption.dataset.hasDatabase === 'true';
const repositoryFilesystemSelected = launcherRepositoryFilesystem instanceof HTMLInputElement && launcherRepositoryFilesystem.checked;
if (launcherBackupActions) {
launcherBackupActions.classList.toggle('tw-hidden', !hasBundleSelected);
}
if (launcherBackupImportAppINIField) {
launcherBackupImportAppINIField.classList.toggle('tw-hidden', !(hasBundleSelected && hasAppINIResource));
}
if (launcherBackupRestoreDBField) {
launcherBackupRestoreDBField.classList.toggle('tw-hidden', !(hasBundleSelected && hasDatabaseResource));
}
if (launcherBackupImportAppINI instanceof HTMLInputElement && !(hasBundleSelected && hasAppINIResource)) {
launcherBackupImportAppINI.checked = false;
const checkboxContainer = launcherBackupImportAppINI.closest('.ui.checkbox');
if (checkboxContainer) checkboxContainer.classList.remove('checked');
}
if (launcherBackupRestoreDB instanceof HTMLInputElement && !(hasBundleSelected && hasDatabaseResource)) {
launcherBackupRestoreDB.checked = false;
const checkboxContainer = launcherBackupRestoreDB.closest('.ui.checkbox');
if (checkboxContainer) checkboxContainer.classList.remove('checked');
}
if (repositoryFilesystemSelected) {
if (launcherBackupRestoreDB instanceof HTMLInputElement) {
launcherBackupRestoreDB.checked = false;
const checkboxContainer = launcherBackupRestoreDB.closest('.ui.checkbox');
if (checkboxContainer) checkboxContainer.classList.remove('checked');
}
if (launcherDatabaseBackupFile instanceof HTMLInputElement) {
launcherDatabaseBackupFile.value = '';
}
}
if (launcherImportAppINISegment) {
const hideImportAppINISection = hasBundleSelected && launcherBackupImportAppINI instanceof HTMLInputElement && launcherBackupImportAppINI.checked;
launcherImportAppINISegment.classList.toggle('tw-hidden', hideImportAppINISection);
if (hideImportAppINISection && installAppINIInput instanceof HTMLInputElement) {
installAppINIInput.value = '';
}
}
const importedAppINIState = installForm?.querySelector('input[name="imported_app_ini"]');
const hasImportedAppINI = importedAppINIState instanceof HTMLInputElement && importedAppINIState.value === 'true';
const forceExactAppINIImport = hasBundleSelected && launcherBackupImportAppINI instanceof HTMLInputElement && launcherBackupImportAppINI.checked;
if (importSensitiveSecretsCheckbox instanceof HTMLInputElement) {
if (forceExactAppINIImport) {
importSensitiveSecretsCheckbox.checked = true; // edit/add - by petru @ codex
}
importSensitiveSecretsCheckbox.disabled = forceExactAppINIImport;
const checkboxContainer = importSensitiveSecretsCheckbox.closest('.ui.checkbox');
if (checkboxContainer) {
checkboxContainer.classList.toggle('checked', importSensitiveSecretsCheckbox.checked);
checkboxContainer.classList.toggle('disabled', forceExactAppINIImport);
}
}
if (launcherDatabaseBackupFileSegment) {
const hideDatabaseBackupFileSection = hasBundleSelected && launcherBackupRestoreDB instanceof HTMLInputElement && launcherBackupRestoreDB.checked;
launcherDatabaseBackupFileSegment.classList.toggle('tw-hidden', hideDatabaseBackupFileSection);
if (hideDatabaseBackupFileSection && launcherDatabaseBackupFile instanceof HTMLInputElement) {
launcherDatabaseBackupFile.value = '';
}
}
const hasSQLBackupSelected = launcherDatabaseBackupFile instanceof HTMLInputElement && !!launcherDatabaseBackupFile.files && launcherDatabaseBackupFile.files.length > 0;
let activeConfirmKind = '';
if (repositoryFilesystemSelected) {
activeConfirmKind = 'repository_filesystem';
} else if (hasSQLBackupSelected || (hasBundleSelected && launcherBackupRestoreDB instanceof HTMLInputElement && launcherBackupRestoreDB.checked)) {
activeConfirmKind = 'backup_restore';
} else if ((hasBundleSelected && ((launcherBackupImportAppINI instanceof HTMLInputElement && launcherBackupImportAppINI.checked) || (launcherBackupRestoreDB instanceof HTMLInputElement && launcherBackupRestoreDB.checked))) || hasImportedAppINI) {
activeConfirmKind = 'partial_restore';
}
if (hiddenRecoveryMode instanceof HTMLInputElement) {
hiddenRecoveryMode.value = activeConfirmKind === 'backup_restore' ? 'database_backup' : activeConfirmKind === 'partial_restore' ? 'existing_database' : activeConfirmKind === 'repository_filesystem' ? 'repository_filesystem' : '';
}
if (launcherRepositoryFilesystemSegment) {
launcherRepositoryFilesystemSegment.classList.toggle('tw-hidden', hasSQLBackupSelected);
}
if (hasSQLBackupSelected && launcherRepositoryFilesystem instanceof HTMLInputElement) {
launcherRepositoryFilesystem.checked = false;
const checkboxContainer = launcherRepositoryFilesystem.closest('.ui.checkbox');
if (checkboxContainer) checkboxContainer.classList.remove('checked');
}
for (const panel of launcherConfirmPanels) {
setLauncherPanelVisible(panel, panel instanceof HTMLElement && panel.dataset.confirmKind === activeConfirmKind);
}
syncLauncherSaveButton();
};
// start edit/add - by petru @ codex
const syncInstallPanelFromSelectedBackupAppINI = async () => {
if (!(installForm instanceof HTMLFormElement)) return;
if (!(launcherBackupImportAppINI instanceof HTMLInputElement) || !launcherBackupImportAppINI.checked) return;
if (!(launcherBackupRestoreID instanceof HTMLSelectElement) || launcherBackupRestoreID.value === '') return;
const importAction = launcherBackupImportAppINI.dataset.importAction;
if (!importAction) return;
await importInstallFormState(installForm, importAction);
syncLauncherBackupState();
};
const syncRecoveryFileResetButtons = () => {
if (resetAppINIButton) {
const importedAppINIState = installForm?.querySelector('input[name="imported_app_ini"]'); // edit/add - by petru @ codex
const hasImportedAppINI = importedAppINIState instanceof HTMLInputElement && importedAppINIState.value === 'true'; // edit/add - by petru @ codex
const hasBundleImportedAppINI = hiddenBackupImportAppINI instanceof HTMLInputElement && hiddenBackupImportAppINI.value === 'true'; // edit/add - by petru @ codex
resetAppINIButton.classList.toggle('tw-hidden', !(hasImportedAppINI && !hasBundleImportedAppINI)); // edit/add - by petru @ codex
}
if (resetDatabaseBackupButton) {
const hasDatabaseBackupFile = launcherDatabaseBackupFile instanceof HTMLInputElement && !!launcherDatabaseBackupFile.files && launcherDatabaseBackupFile.files.length > 0;
resetDatabaseBackupButton.classList.toggle('tw-hidden', !hasDatabaseBackupFile);
}
};
// end edit/add - by petru @ codex
if (recoveryLauncherButton && recoveryLauncherModal) {
recoveryLauncherButton.addEventListener('click', () => {
window.$(recoveryLauncherModal).modal({
autofocus: false,
closable: true,
}).modal('show');
});
}
if (launcherBackupRestoreID instanceof HTMLSelectElement) {
launcherBackupRestoreID.addEventListener('change', async () => {
if (launcherBackupRestoreID.value === '') resetLauncherConfirmations(); // edit/add - by petru @ codex
syncLauncherBackupState();
await syncInstallPanelFromSelectedBackupAppINI();
});
}
if (launcherBackupImportAppINI instanceof HTMLInputElement) {
launcherBackupImportAppINI.addEventListener('change', async () => {
if (!launcherBackupImportAppINI.checked) resetLauncherConfirmations(); // edit/add - by petru @ codex
syncLauncherBackupState();
await syncInstallPanelFromSelectedBackupAppINI();
});
}
if (launcherBackupRestoreDB instanceof HTMLInputElement) {
launcherBackupRestoreDB.addEventListener('change', () => {
if (!launcherBackupRestoreDB.checked) resetLauncherConfirmations(); // edit/add - by petru @ codex
syncLauncherBackupState();
});
}
if (launcherRepositoryFilesystem instanceof HTMLInputElement) {
launcherRepositoryFilesystem.addEventListener('change', () => {
if (!launcherRepositoryFilesystem.checked) resetLauncherConfirmations(); // edit/add - by petru @ codex
syncLauncherBackupState();
syncRecoveryFileResetButtons(); // edit/add - by petru @ codex
});
}
if (launcherDatabaseBackupFile instanceof HTMLInputElement) {
launcherDatabaseBackupFile.addEventListener('change', () => {
if (!launcherDatabaseBackupFile.files || launcherDatabaseBackupFile.files.length === 0) resetLauncherConfirmations(); // edit/add - by petru @ codex
syncLauncherBackupState();
syncRecoveryFileResetButtons(); // edit/add - by petru @ codex
});
}
if (resetAppINIButton instanceof HTMLButtonElement && installAppINIInput instanceof HTMLInputElement) {
resetAppINIButton.addEventListener('click', () => {
installAppINIInput.value = ''; // edit/add - by petru @ codex
clearImportedAppINIPreviewState(); // edit/add - by petru @ codex
resetLauncherConfirmations(); // edit/add - by petru @ codex
syncLauncherBackupState(); // edit/add - by petru @ codex
syncRecoveryFileResetButtons(); // edit/add - by petru @ codex
});
}
if (resetDatabaseBackupButton instanceof HTMLButtonElement && launcherDatabaseBackupFile instanceof HTMLInputElement) {
resetDatabaseBackupButton.addEventListener('click', () => {
launcherDatabaseBackupFile.value = ''; // edit/add - by petru @ codex
resetLauncherConfirmations(); // edit/add - by petru @ codex
syncLauncherBackupState(); // edit/add - by petru @ codex
syncRecoveryFileResetButtons(); // edit/add - by petru @ codex
});
}
for (const panel of launcherConfirmPanels) {
for (const input of panel.querySelectorAll('input[type="checkbox"]')) {
if (!(input instanceof HTMLInputElement)) continue;
input.addEventListener('change', syncLauncherSaveButton);
}
}
syncLauncherBackupState();
syncRecoveryFileResetButtons(); // edit/add - by petru @ codex
if (appNameInput instanceof HTMLInputElement && smtpFromNameInput instanceof HTMLInputElement) {
let lastAutoMailerName = smtpFromNameInput.value.trim() || deriveInstallMailerName(appNameInput.value);
const syncInstallBranding = () => {
@@ -762,26 +1256,13 @@
document.addEventListener('DOMContentLoaded', () => {
dismissInstallSuccessFlash();
{{if .Err_DbInstalledBefore}}
// start edit/add - by petru @ codex
const reinstallModal = document.querySelector('.js-install-reinstall-modal');
if (reinstallModal) {
const reinstallConfirmInputs = reinstallModal.querySelectorAll('input[name^="reinstall_confirm_"]');
const reinstallConfirmButton = reinstallModal.querySelector('.js-install-confirm-button');
const syncReinstallConfirmButton = () => {
if (!(reinstallConfirmButton instanceof HTMLButtonElement)) return;
reinstallConfirmButton.disabled = [...reinstallConfirmInputs].some((input) => !(input instanceof HTMLInputElement) || !input.checked);
};
for (const input of reinstallConfirmInputs) {
input.addEventListener('change', syncReinstallConfirmButton);
}
syncReinstallConfirmButton();
window.$(reinstallModal).modal({
{{if or .Err_DbInstalledBefore .Err_RepositoryFilesystemRecovery .Err_DatabaseBackupRecovery}}
if (recoveryLauncherModal) {
window.$(recoveryLauncherModal).modal({
autofocus: false,
closable: false,
}).modal('show');
}
// end edit/add - by petru @ codex
{{end}}
const installLanguageBalloon = document.querySelector('#install-language-balloon');
+40 -1
View File
@@ -3,8 +3,47 @@
<div class="home tw-text-center tw-h-full tw-flex tw-flex-col tw-justify-center"><!-- the "home" class makes the links green -->
<div{{if eq .InstallProgressLogo "loading.png"}} class="tw-ml-[-30px]"{{end}}><img width="160" src="{{AssetUrlPrefix}}/img/{{.InstallProgressLogo}}" alt aria-hidden="true"></div>
<div class="tw-my-[2em] tw-text-[18px]">
<a id="goto-after-install" href="{{AppSubUrl}}{{Iif .IsAccountCreated "/user/login" "/user/sign_up"}}">{{ctx.Locale.Tr "install.installing_desc"}}</a>
<a
id="goto-after-install"
href="{{AppSubUrl}}{{if .InstallIsRecoveryRequest}}/{{else}}{{Iif .IsAccountCreated "/user/login" "/user/sign_up"}}{{end}}"
data-probe-url="{{if .InstallIsRecoveryRequest}}{{AppSubUrl}}/post-install{{else}}{{AppSubUrl}}{{Iif .IsAccountCreated "/user/login" "/user/sign_up"}}{{end}}"
data-probe-until-missing="{{if .InstallIsRecoveryRequest}}true{{else}}false{{end}}"
>{{ctx.Locale.Tr "install.installing_desc"}}</a>
</div>
</div>
</div>
<script>
// edit/add - by petru @ codex
if (window.location.search.includes('recovery=1')) {
window.history.replaceState(null, '', '{{AppSubUrl}}/');
}
{{if .InstallIsRecoveryRequest}}
window.__giteaRecoveryPostInstallHandled = true;
{
const gotoLink = document.getElementById('goto-after-install');
if (gotoLink instanceof HTMLAnchorElement) {
const targetUrl = gotoLink.getAttribute('href');
const probeUrl = gotoLink.dataset.probeUrl;
if (targetUrl && probeUrl) {
let recoveryPostInstallPoll = window.setInterval(async () => {
try {
const resp = await fetch(probeUrl, {cache: 'no-store', credentials: 'same-origin'});
if (resp.status === 404 || resp.redirected || !resp.url.endsWith('/post-install')) {
window.clearInterval(recoveryPostInstallPoll);
recoveryPostInstallPoll = 0;
const currentUrl = new URL(window.location.href); // edit/add - by petru @ codex
const nextUrl = new URL(targetUrl, window.location.href); // edit/add - by petru @ codex
if (currentUrl.pathname === nextUrl.pathname && currentUrl.search === nextUrl.search && currentUrl.hash === nextUrl.hash) {
window.location.reload(); // edit/add - by petru @ codex
} else {
window.location.replace(targetUrl);
}
}
} catch {}
}, 1000);
}
}
}
{{end}}
</script>
{{template "base/footer" .}}
+6
View File
@@ -210,6 +210,12 @@ button {
details summary {
cursor: pointer;
/* color: #cdbe46; */
}
.optional.field summary.right-content.tw-py-2 {
cursor: pointer;
color: #cdbe46;
}
progress {
+288
View File
@@ -29,6 +29,10 @@
width: auto;
}
summary.right-content.tw-py-2 {
margin-top: 17px;
}
.page-content.install form.ui.form input:not([type="checkbox"],[type="radio"]),
.page-content.install form.ui.form .ui.selection.dropdown {
width: 60%;
@@ -41,11 +45,248 @@
.page-content.install form.ui.form details.optional.field[open] {
padding-bottom: 10px;
}
/* start edit/add - by petru @ codex */
.page-content.install .install-recovery-entry {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
margin: 0 0 1.5rem;
padding: 1.25rem 1.5rem;
border: 1px solid var(--color-secondary-alpha-40);
border-radius: var(--border-radius-medium);
background: var(--color-box-body);
}
.page-content.install .install-recovery-entry-copy {
text-align: left;
}
.page-content.install .install-recovery-entry-copy .ui.header {
margin-bottom: 0.45rem;
}
.page-content.install .install-recovery-entry-copy p {
margin: 0;
color: var(--color-text-light-2);
}
.page-content.install .install-recovery-entry-actions {
flex: 0 0 auto;
}
.page-content.install .install-recovery-launcher-modal {
max-width: 760px;
}
.page-content.install .install-recovery-launcher-modal .install-recovery-launcher-problem {
margin-bottom: 1rem;
}
.page-content.install .install-recovery-launcher-modal .content {
padding-top: 1.25rem;
}
.page-content.install .install-recovery-launcher-modal .ui.dividing.header {
margin-top: 0;
}
.page-content.install .install-recovery-launcher-modal .ui.raised.segment {
padding: 1rem 1rem 1.1rem;
}
.page-content.install .install-recovery-launcher-modal .ui.small.header {
margin-bottom: 0.85rem;
}
.page-content.install .install-recovery-launcher-modal .install-recovery-inline-field {
display: flex !important;
align-items: center;
gap: 0.85rem;
width: 100%;
margin-bottom: 0;
flex-wrap: nowrap;
}
.page-content.install .install-recovery-launcher-modal .install-recovery-inline-field > label {
flex: 0 0 11rem;
margin: 0;
font-weight: var(--font-weight-semibold);
white-space: nowrap;
}
.page-content.install .install-recovery-launcher-modal .install-recovery-inline-control-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1 1 auto;
width: auto;
min-width: 0;
flex-wrap: nowrap;
}
.install-recovery-inline-control-row > label {
text-align: right;
width: 30%;
padding-right: 12px;
margin-right: 0;
/* vertical-align: -webkit-baseline-middle; */
}
.install-recovery-select {
padding: .78571429em 3.2em .78571429em 1em;
width: 70% !important;
}
.install-recovery-inline-control-row > input {
width: 66% !important;
vertical-align: unset !important;
}
.page-content.install .install-recovery-launcher-modal .install-recovery-inline-field > input[type="file"],
.page-content.install .install-recovery-launcher-modal .install-recovery-inline-field > select {
width: 100% !important;
min-width: 0;
}
.page-content.install .install-recovery-launcher-modal .install-recovery-inline-control-row > input[type="file"] {
flex: 1 1 auto;
min-width: 0;
padding: 0.35rem;
border: 1px solid var(--color-secondary-alpha-40);
border-radius: 0.85rem;
background: var(--color-box-body);
color: var(--color-text);
}
.install-recovery-inline-control-row > button {
font-size: 14px !important;
min-height: 42px !important;
background: #322226 !important;
}
.page-content.install .install-recovery-launcher-modal .install-recovery-inline-control-row > .button {
flex: 0 0 auto;
margin: 0 !important;
white-space: nowrap;
}
.page-content.install .install-recovery-launcher-modal .install-recovery-inline-control-row > input[type="file"]::file-selector-button {
margin-right: 0.85rem;
padding: 0.6rem 0.9rem;
border: 1px solid var(--color-secondary-alpha-40);
border-radius: 0.6rem;
background: var(--color-body);
color: var(--color-text);
cursor: pointer;
}
.page-content.install .install-recovery-launcher-modal .install-recovery-inline-control-row > input[type="file"]::-webkit-file-upload-button {
margin-right: 0.85rem;
padding: 0.6rem 0.9rem;
border: 1px solid var(--color-secondary-alpha-40);
border-radius: 0.6rem;
background: var(--color-body);
color: var(--color-text);
cursor: pointer;
}
.page-content.install .install-recovery-launcher-modal .install-recovery-select {
display: block;
appearance: none;
min-height: 2.95rem;
padding: 0.8rem 3rem 0.8rem 1rem;
border: 1px solid var(--color-secondary-alpha-40);
border-radius: 0.85rem;
background-color: var(--color-box-body);
background-image: linear-gradient(45deg, transparent 50%, var(--color-text-light-2) 50%), linear-gradient(135deg, var(--color-text-light-2) 50%, transparent 50%);
background-position: calc(100% - 22px) calc(50% - 3px), calc(100% - 16px) calc(50% - 3px);
background-size: 6px 6px, 6px 6px;
background-repeat: no-repeat;
color: var(--color-text);
box-shadow: inset 0 1px 0 var(--color-secondary-alpha-20);
transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
}
.page-content.install .install-recovery-launcher-modal .install-recovery-select:hover {
border-color: var(--color-primary-light-2);
}
.page-content.install .install-recovery-launcher-modal .install-recovery-select:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-alpha-20), inset 0 1px 0 var(--color-secondary-alpha-20);
outline: none;
}
.page-content.install .install-recovery-launcher-layout {
display: grid;
gap: 1rem;
}
.page-content.install .install-recovery-launcher-section {
padding: 1rem 1.1rem;
border: 1px solid var(--color-secondary-alpha-40);
border-radius: var(--border-radius-medium);
background: var(--color-box-header);
}
.page-content.install .install-recovery-launcher-section-header {
margin-bottom: 0.85rem;
}
.page-content.install .install-recovery-launcher-section-header h5 {
margin: 0;
font-size: 1rem;
}
.page-content.install .install-recovery-launcher-section .field,
.page-content.install .install-recovery-launcher-section .reinstall-confirm {
width: 100%;
margin: 0;
}
.page-content.install .install-recovery-launcher-section .field + .reinstall-confirm,
.page-content.install .install-recovery-launcher-section .reinstall-confirm + .reinstall-confirm {
margin-top: 0.85rem;
}
.page-content.install .install-recovery-launcher-section label {
display: block;
margin-bottom: 0.4rem;
font-weight: var(--font-weight-semibold);
}
.page-content.install .install-recovery-launcher-section input[type="file"],
.page-content.install .install-recovery-launcher-section select {
width: 100%;
}
.page-content.install .install-recovery-launcher-section .help {
display: block;
margin-top: 0.45rem;
color: var(--color-text-light-2);
line-height: 1.45;
}
.page-content.install .install-recovery-launcher-section .ui.checkbox label {
margin-bottom: 0;
font-weight: var(--font-weight-normal);
}
.page-content.install .install-recovery-launcher-checkboxes {
display: grid;
gap: 0.75rem;
margin-top: 0.9rem;
}
/* end edit/add - by petru @ codex */
.page-content.install form.ui.form details.optional.field[open]:not(:last-child) {
border-bottom: 1px dashed var(--color-secondary);
}
.page-content.install form.ui.form details.optional.field[open] summary {
margin-bottom: 10px;
margin-top: 17px;
}
.page-content.install form.ui.form details.optional.field * {
@@ -75,6 +316,34 @@
padding-top: 1.25rem;
}
.page-content.install .install-reinstall-confirm-modal .recovery-source-block {
margin-bottom: 1rem;
}
.page-content.install .install-reinstall-confirm-modal .recovery-source-option {
padding: 0.75rem 0;
border-top: 1px solid var(--color-secondary-alpha-40);
}
.page-content.install .install-reinstall-confirm-modal .recovery-source-option:first-of-type {
border-top: 0;
padding-top: 0.25rem;
}
.page-content.install .install-reinstall-confirm-modal .recovery-source-option .help {
display: block;
margin-top: 0.35rem;
margin-left: 1.5rem;
}
.page-content.install .install-reinstall-confirm-modal .js-install-recovery-confirm-panel {
display: none;
}
.page-content.install .install-reinstall-confirm-modal .js-install-recovery-confirm-panel.is-active {
display: block;
}
.ui.negative.message.install-reinstall-alert {
font-size: 19px;
}
@@ -164,6 +433,25 @@
padding-bottom: calc(150px + env(safe-area-inset-bottom));
}
/* start edit/add - by petru @ codex */
.page-content.install .install-recovery-entry {
flex-direction: column;
align-items: stretch;
}
.page-content.install .install-recovery-launcher-modal .install-recovery-inline-field {
display: flex !important;
}
.page-content.install .install-recovery-launcher-modal .install-recovery-inline-field > label {
flex: 0 0 11rem;
}
.page-content.install .install-recovery-entry-actions .button {
width: 100%;
}
/* end edit/add - by petru @ codex */
.install-language-balloon {
right: 1rem;
bottom: calc(118px + env(safe-area-inset-bottom));
+30
View File
@@ -43,6 +43,10 @@
vertical-align: top;
}
.ui.form .inline.field > textarea {
width: 60%;
}
.ui.form ::-webkit-datetime-edit,
.ui.form ::-webkit-inner-spin-button {
height: 1.21428571em;
@@ -175,6 +179,18 @@ textarea:focus,
width: auto;
}
.ui.test_mail.small.input {
width: 100% !important;
}
.ui.test_mail.small.input > input {
margin: 0 10px 0 0;
}
.ui.test_mail.small.input > button {
margin: 0 0 0 0;
}
.ui.form .error.message,
.ui.form .error.message:empty {
display: none;
@@ -426,6 +442,20 @@ input:-webkit-autofill:active {
opacity: var(--opacity-disabled);
}
.page-content.install .ui.form .field>.desc {
margin-left: calc(30% + 5px);
margin-right: calc(9% + 5px);
width: auto;
}
.form .desc {
color: #efd8a1;
margin-top: 0.25em;
padding-bottom: 0.6em;
display: inline-block;
text-wrap: wrap;
}
.form .help {
color: var(--color-secondary-dark-5);
margin-top: 0.25em;
+1
View File
@@ -142,6 +142,7 @@ h4.ui.header .sub.header {
.ui.dividing.header {
border-bottom-color: var(--color-secondary);
color: #32cd32;
}
.ui.dividing.header .sub.header {
+22 -7
View File
@@ -29,13 +29,13 @@ function initPreInstall() {
// Database type change detection.
document.querySelector<HTMLInputElement>('#db_type')!.addEventListener('change', function () {
const dbType = this.value;
const dbType = this.value || 'mysql'; // edit/add - by petru @ codex
hideElem('div[data-db-setting-for]');
showElem(`div[data-db-setting-for=${dbType}]`);
showElem(`div[data-db-setting-for="${dbType}"]`); // edit/add - by petru @ codex
if (dbType !== 'sqlite3') {
// for most remote database servers
showElem('div[data-db-setting-for=common-host]');
showElem('div[data-db-setting-for="common-host"]'); // edit/add - by petru @ codex
const lastDbHost = dbHost.value;
const isDbHostDefault = !lastDbHost || Object.values(defaultDbHosts).includes(lastDbHost);
if (isDbHostDefault) {
@@ -51,7 +51,12 @@ function initPreInstall() {
const appUrl = document.querySelector<HTMLInputElement>('#app_url')!;
if (appUrl.value.includes('://localhost')) {
appUrl.value = window.location.href;
// start edit/add - by petru @ codex
const currentUrl = new URL(window.location.href);
currentUrl.search = '';
currentUrl.hash = '';
appUrl.value = currentUrl.href;
// end edit/add - by petru @ codex
}
const domain = document.querySelector<HTMLInputElement>('#domain')!;
@@ -163,14 +168,24 @@ function initPreInstall() {
}
function initPostInstall() {
const el = document.querySelector('#goto-after-install');
// start edit/add - by petru @ codex
if ((window as typeof window & {__giteaRecoveryPostInstallHandled?: boolean}).__giteaRecoveryPostInstallHandled) {
return;
}
// end edit/add - by petru @ codex
const el = document.querySelector<HTMLAnchorElement>('#goto-after-install');
if (!el) return;
const targetUrl = el.getAttribute('href')!;
const probeUrl = el.dataset.probeUrl || targetUrl;
const probeUntilMissing = el.dataset.probeUntilMissing === 'true';
let tid: ReturnType<typeof setInterval> | null = setInterval(async () => {
try {
const resp = await GET(targetUrl);
if (tid && resp.status === 200) {
const resp = await GET(probeUrl, {cache: 'no-store'});
const shouldRedirect = probeUntilMissing
? resp.status === 404 || resp.redirected || !resp.url.endsWith('/post-install')
: resp.status === 200;
if (tid && shouldRedirect) {
clearInterval(tid);
tid = null;
window.location.href = targetUrl;