Modified - [install] [backup] [database] [recovery] Consolidated database backup and installer recovery support.
- 1 - Add: Gitea now creates timestamped database backup bundles under `[backup].PATH`, exposes the backup schedule in the installer, and surfaces the `database_backup` cron task in admin monitoring. - 2 - Add: installed instances now use `.gitea-installed` and `.gitea-recovery.ini` to enter email-gated recovery instead of falling back to public install mode when configuration or database access is broken. - 3 - Mod: the installer recovery flow now covers backup-bundle restore, bundled or manual `app.ini` reuse, uploaded SQL/GZ database restores, and repository-filesystem recovery with source-specific validation, confirmations, and preserved launcher state. - 4 - Fix: recovery now restores bundled `app.ini` snapshots when needed, discovers backup bundles from both the active backup path and persisted `.gitea-recovery.ini` path, and preserves SMTP and other rebuilt settings correctly when `app.ini` is missing or incomplete. - 5 - Fix: recovery validation and restore handling now accept either a selected backup bundle or an uploaded SQL/GZ dump, keep sensitive secrets and existing `LFS_JWT_SECRET` when appropriate, clear SQLite restore targets before import, and complete the post-install handoff without redirect loops. - 6 - Mod: fresh installs now default recovery email authorization to enabled with first-admin fallback, and the install/recovery UI, styling, and EN/RO wording were refined to match the final launcher behavior. Co-Authored-By: petru @ codex (GPT-5) <codex@openai.com> (cherry picked from commit 9879caf2292691b0cb521d12e6fee924b066bae2)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"`
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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" .}}
|
||||
|
||||
@@ -210,6 +210,12 @@ button {
|
||||
|
||||
details summary {
|
||||
cursor: pointer;
|
||||
/* color: #cdbe46; */
|
||||
}
|
||||
|
||||
.optional.field summary.right-content.tw-py-2 {
|
||||
cursor: pointer;
|
||||
color: #cdbe46;
|
||||
}
|
||||
|
||||
progress {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user