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