Merge remote-tracking branch 'origin/main' into garethgeorge/tmp

# Conflicts:
#	.github/workflows/reusable-release.yml
This commit is contained in:
Gareth George
2026-05-03 04:39:32 +00:00
20 changed files with 1104 additions and 405 deletions
+1 -1
View File
@@ -79,7 +79,7 @@ jobs:
installer:
name: Windows installers
needs: [goreleaser]
runs-on: windows-2022
runs-on: windows-latest
permissions:
contents: write
+100
View File
@@ -37,6 +37,7 @@ builds:
- amd64
- arm64
- arm
- "386"
goarm:
- 6
- 7
@@ -53,6 +54,7 @@ builds:
- amd64
- arm64
- arm
- "386"
goarm:
- 6
- 7
@@ -124,6 +126,51 @@ dockers:
- linux
- docker-entrypoint
- image_templates:
- garethgeorge/backrest:{{ .Tag }}-alpine-armv6
- ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-armv6
dockerfile: Dockerfile.alpine
use: buildx
goarch: arm
goarm: 6
build_flag_templates:
- "--pull"
- "--provenance=false"
- "--platform=linux/arm/v6"
ids:
- linux
- docker-entrypoint
- image_templates:
- garethgeorge/backrest:{{ .Tag }}-alpine-armv7
- ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-armv7
dockerfile: Dockerfile.alpine
use: buildx
goarch: arm
goarm: 7
build_flag_templates:
- "--pull"
- "--provenance=false"
- "--platform=linux/arm/v7"
ids:
- linux
- docker-entrypoint
- image_templates:
- garethgeorge/backrest:{{ .Tag }}-alpine-386
- ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-386
dockerfile: Dockerfile.alpine
use: buildx
goarch: "386"
goarm: 7
build_flag_templates:
- "--pull"
- "--provenance=false"
- "--platform=linux/386"
ids:
- linux
- docker-entrypoint
- image_templates:
- garethgeorge/backrest:{{ .Tag }}-scratch-arm64
- ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-arm64
@@ -179,95 +226,148 @@ dockers:
- linux
- docker-entrypoint
- image_templates:
- garethgeorge/backrest:{{ .Tag }}-scratch-386
- ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-386
dockerfile: Dockerfile.scratch
use: buildx
goarch: "386"
goarm: 7
build_flag_templates:
- "--pull"
- "--provenance=false"
- "--platform=linux/386"
ids:
- linux
- docker-entrypoint
docker_manifests:
- name_template: "garethgeorge/backrest:latest"
image_templates:
- "garethgeorge/backrest:{{ .Tag }}-alpine-amd64"
- "garethgeorge/backrest:{{ .Tag }}-alpine-arm64"
- "garethgeorge/backrest:{{ .Tag }}-alpine-armv6"
- "garethgeorge/backrest:{{ .Tag }}-alpine-armv7"
- "garethgeorge/backrest:{{ .Tag }}-alpine-386"
- name_template: "ghcr.io/garethgeorge/backrest:latest"
image_templates:
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-amd64"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-arm64"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-armv6"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-armv7"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-386"
- name_template: "garethgeorge/backrest:v{{ .Major }}"
image_templates:
- "garethgeorge/backrest:{{ .Tag }}-alpine-amd64"
- "garethgeorge/backrest:{{ .Tag }}-alpine-arm64"
- "garethgeorge/backrest:{{ .Tag }}-alpine-armv6"
- "garethgeorge/backrest:{{ .Tag }}-alpine-armv7"
- "garethgeorge/backrest:{{ .Tag }}-alpine-386"
- name_template: "ghcr.io/garethgeorge/backrest:v{{ .Major }}"
image_templates:
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-amd64"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-arm64"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-armv6"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-armv7"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-386"
- name_template: "garethgeorge/backrest:v{{ .Major }}.{{ .Minor }}"
image_templates:
- "garethgeorge/backrest:{{ .Tag }}-alpine-amd64"
- "garethgeorge/backrest:{{ .Tag }}-alpine-arm64"
- "garethgeorge/backrest:{{ .Tag }}-alpine-armv6"
- "garethgeorge/backrest:{{ .Tag }}-alpine-armv7"
- "garethgeorge/backrest:{{ .Tag }}-alpine-386"
- name_template: "ghcr.io/garethgeorge/backrest:v{{ .Major }}.{{ .Minor }}"
image_templates:
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-amd64"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-arm64"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-armv6"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-armv7"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-386"
- name_template: "garethgeorge/backrest:{{ .Tag }}"
image_templates:
- "garethgeorge/backrest:{{ .Tag }}-alpine-amd64"
- "garethgeorge/backrest:{{ .Tag }}-alpine-arm64"
- "garethgeorge/backrest:{{ .Tag }}-alpine-armv6"
- "garethgeorge/backrest:{{ .Tag }}-alpine-armv7"
- "garethgeorge/backrest:{{ .Tag }}-alpine-386"
- name_template: "ghcr.io/garethgeorge/backrest:{{ .Tag }}"
image_templates:
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-amd64"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-arm64"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-armv6"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-armv7"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-386"
- name_template: "garethgeorge/backrest:latest-alpine"
image_templates:
- "garethgeorge/backrest:{{ .Tag }}-alpine-amd64"
- "garethgeorge/backrest:{{ .Tag }}-alpine-arm64"
- "garethgeorge/backrest:{{ .Tag }}-alpine-armv6"
- "garethgeorge/backrest:{{ .Tag }}-alpine-armv7"
- "garethgeorge/backrest:{{ .Tag }}-alpine-386"
- name_template: "ghcr.io/garethgeorge/backrest:latest-alpine"
image_templates:
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-amd64"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-arm64"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-armv6"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-armv7"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-386"
- name_template: "garethgeorge/backrest:scratch"
image_templates:
- "garethgeorge/backrest:{{ .Tag }}-scratch-amd64"
- "garethgeorge/backrest:{{ .Tag }}-scratch-arm64"
- "garethgeorge/backrest:{{ .Tag }}-scratch-armv6"
- "garethgeorge/backrest:{{ .Tag }}-scratch-armv7"
- "garethgeorge/backrest:{{ .Tag }}-scratch-386"
- name_template: "ghcr.io/garethgeorge/backrest:scratch"
image_templates:
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-amd64"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-arm64"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-armv6"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-armv7"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-386"
- name_template: "garethgeorge/backrest:v{{ .Major }}-scratch"
image_templates:
- "garethgeorge/backrest:{{ .Tag }}-scratch-amd64"
- "garethgeorge/backrest:{{ .Tag }}-scratch-arm64"
- "garethgeorge/backrest:{{ .Tag }}-scratch-armv6"
- "garethgeorge/backrest:{{ .Tag }}-scratch-armv7"
- "garethgeorge/backrest:{{ .Tag }}-scratch-386"
- name_template: "ghcr.io/garethgeorge/backrest:v{{ .Major }}-scratch"
image_templates:
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-amd64"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-arm64"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-armv6"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-armv7"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-386"
- name_template: "garethgeorge/backrest:v{{ .Major }}.{{ .Minor }}-scratch"
image_templates:
- "garethgeorge/backrest:{{ .Tag }}-scratch-amd64"
- "garethgeorge/backrest:{{ .Tag }}-scratch-arm64"
- "garethgeorge/backrest:{{ .Tag }}-scratch-armv6"
- "garethgeorge/backrest:{{ .Tag }}-scratch-armv7"
- "garethgeorge/backrest:{{ .Tag }}-scratch-386"
- name_template: "ghcr.io/garethgeorge/backrest:v{{ .Major }}.{{ .Minor }}-scratch"
image_templates:
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-amd64"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-arm64"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-armv6"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-armv7"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-386"
- name_template: "garethgeorge/backrest:{{ .Tag }}-scratch"
image_templates:
- "garethgeorge/backrest:{{ .Tag }}-scratch-amd64"
- "garethgeorge/backrest:{{ .Tag }}-scratch-arm64"
- "garethgeorge/backrest:{{ .Tag }}-scratch-armv6"
- "garethgeorge/backrest:{{ .Tag }}-scratch-armv7"
- "garethgeorge/backrest:{{ .Tag }}-scratch-386"
- name_template: "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch"
image_templates:
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-amd64"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-arm64"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-armv6"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-armv7"
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-386"
brews:
- name: backrest
+25
View File
@@ -1,5 +1,30 @@
# Changelog
## [1.12.1](https://github.com/garethgeorge/backrest/compare/v1.12.0...v1.12.1) (2026-03-11)
### Features
* add new translation strings and expand Japanese language translations ([#1120](https://github.com/garethgeorge/backrest/issues/1120)) ([7f050d2](https://github.com/garethgeorge/backrest/commit/7f050d2fae6ab6a2a26b53de0dbac2043ee1cddb))
### Bug Fixes
* allow --stdin-from-command flag to be used ([c603f00](https://github.com/garethgeorge/backrest/commit/c603f0010e995f9680befdd37244700ec2f43336))
* condition snapshot start hook may not run if repo is unreachable and autounlock is enabled ([cd72b7f](https://github.com/garethgeorge/backrest/commit/cd72b7f8a94f830cf0a1365547d1ac47bc37a738))
* disable strict sftp trust / known host checking ([1bad3d0](https://github.com/garethgeorge/backrest/commit/1bad3d088792e80fc4c0c29d58b3f503a224c7e3))
* ensure that docker images package the latest rclone ([#1128](https://github.com/garethgeorge/backrest/issues/1128)) ([4734bc2](https://github.com/garethgeorge/backrest/commit/4734bc21b7940775d69920b1ca9a48e031763d71))
* increase bufio's max buffer size to handle large json lines for big repos ([bebe608](https://github.com/garethgeorge/backrest/commit/bebe60850566c603eb37659daf89b853ffdc9f63))
* reduce occurrence of benign 'API error reconnecting' warnings ([d4594cc](https://github.com/garethgeorge/backrest/commit/d4594cc6954820c9df2f1f87408853562aefbe84))
* snap operation details to view port when scrolling through a large operation tree ([7344541](https://github.com/garethgeorge/backrest/commit/7344541588ea4cc9fe1fbcd404430d09558d4321))
* support for linux/arm/v7 ([#1151](https://github.com/garethgeorge/backrest/issues/1151)) ([96d6dff](https://github.com/garethgeorge/backrest/commit/96d6dffabc29cfdaf4c1c3c90c448e2ce8408608))
* use LLM to generate more consistent translations ([#1103](https://github.com/garethgeorge/backrest/issues/1103)) ([0199440](https://github.com/garethgeorge/backrest/commit/0199440780418177452f0c9fcad945a549f8f95f))
### Miscellaneous Chores
* set next release version to 1.12.1 ([9e5d795](https://github.com/garethgeorge/backrest/commit/9e5d795efb5d91276729c9e54f4f5cceee27e148))
## [1.12.0](https://github.com/garethgeorge/backrest/compare/v1.11.2...v1.12.0) (2026-02-22)
+4 -4
View File
@@ -69,7 +69,7 @@ Source: "restic.exe"; DestDir: "{app}"; Flags: ignoreversion
[Icons]
; For user install mode only.
Name: "{autostartup}\{#B} systray"; Filename: "{app}\backrest.exe"; Parameters: "--windows-tray {code:GetPortParam}"; IconFilename: "{app}\icon.ico"; Check: IsUserInstallMode
Name: "{autostartup}\{#B} systray"; Filename: "{app}\backrest.exe"; Parameters: "--windows-tray {code:GetPortParam}"; IconFilename: "{app}\icon.ico"; Check: IsUserInstallMode; Tasks: autostart
Name: "{group}\{#B} systray"; Filename: "{app}\backrest.exe"; Parameters: "--windows-tray {code:GetPortParam}"; IconFilename: "{app}\icon.ico"; Check: IsUserInstallMode
; For both modes.
Name: "{group}\{#B}{code:GetIconSuffix}"; Filename: "http://localhost:{code:GetPort}/"; IconFilename: "{app}\icon.ico"
@@ -208,14 +208,14 @@ begin
if StrToVersion(InstalledVersion, InstalledVersionFull) and StrToVersion(AppVersion, NewVersionFull) then
begin
CompResult := ComparePackedVersion(InstalledVersionFull, NewVersionFull);
if CompResult < 0 then Msg := 'upgrade'
if CompResult < 0 then Msg := 'upgrade to'
else if CompResult = 0 then Msg := 'reinstall'
else if CompResult > 0 then Msg := 'downgrade'
else if CompResult > 0 then Msg := 'downgrade to'
else Msg := 'upgrade/reinstall/downgrade';
end;
MsgBox('Detected existing installation of Backrest ' + InstalledVersion +
' in ' + Chr(13) + Chr(10) + AppDir + Chr(13) + Chr(10) + Chr(13) + Chr(10) +
'Setup will ' + Msg + ' to version ' + AppVersion + '.', mbInformation, MB_OK);
'Setup will ' + Msg + ' version ' + AppVersion + '.', mbInformation, MB_OK);
end;
function NextButtonClick(CurPageID: Integer): Boolean;
+8 -32
View File
@@ -280,53 +280,29 @@ func (s *BackrestHandler) SetupSftp(ctx context.Context, req *connect.Request[v1
if port == "" {
port = "22"
}
user := req.Msg.Username
password := req.Msg.Password // Optional
if runtime.GOOS == "windows" {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("automated SFTP setup is not supported on Windows"))
}
// 1. Host Key Verification/Addition
if err := sftputil.AddHostKey(host, port, env.SSHDir()); err != nil {
return connect.NewResponse(&v1.SetupSftpResponse{
Error: fmt.Sprintf("Failed to add host key: %v", err),
}), nil
}
// 2. Generate Key
// 1. Generate key pair (local, always succeeds)
_, pubBytes, keyPath, err := sftputil.GenerateKey(host, env.SSHDir())
if err != nil {
return nil, fmt.Errorf("failed to generate key: %w", err)
}
pubKeyStr := string(pubBytes)
// 3. Install if password provided
if password != nil {
if err := sftputil.InstallKey(host, port, user, *password, pubBytes); err != nil {
return connect.NewResponse(&v1.SetupSftpResponse{
Error: fmt.Sprintf("Failed to install key: %v", err),
}), nil
}
// Verify
privPEM, err := os.ReadFile(keyPath)
if err != nil {
return nil, fmt.Errorf("failed to read generated private key for verification: %w", err)
}
if err := sftputil.VerifyConnection(host, port, user, privPEM); err != nil {
return connect.NewResponse(&v1.SetupSftpResponse{
Error: fmt.Sprintf("Key installed but verification failed: %v", err),
}), nil
}
// 2. Scan remote host key into known_hosts (network, non-fatal)
var hostKeyWarning string
if err := sftputil.AddHostKey(host, port, env.SSHDir()); err != nil {
zap.S().Warnf("SFTP host key scan failed for %s: %v", host, err)
hostKeyWarning = fmt.Sprintf("Could not scan host key (%v). Add the host key to known_hosts manually or ensure the host is reachable.", err)
}
return connect.NewResponse(&v1.SetupSftpResponse{
PublicKey: pubKeyStr,
PublicKey: string(pubBytes),
KeyPath: keyPath,
KnownHostsPath: filepath.Join(env.SSHDir(), "known_hosts"),
Error: hostKeyWarning,
}), nil
}
+6
View File
@@ -29,6 +29,12 @@ func RequireAuthentication(h http.Handler, auth *Authenticator) http.Handler {
return
}
// Pass OPTIONS through unauthenticated so CORS preflight succeeds.
if r.Method == http.MethodOptions {
h.ServeHTTP(w, r)
return
}
username, password, usesBasicAuth := r.BasicAuth()
if usesBasicAuth {
user, err := auth.Login(username, password)
@@ -5,7 +5,7 @@ import (
"go.uber.org/zap"
)
var migration003RelativeScheduling = func(config *v1.Config) {
var migration003RelativeScheduling = func(config *v1.Config) error {
zap.L().Info("applying config migration 003: relative scheduling")
// loop over plans and examine prune policy's
for _, repo := range config.Repos {
@@ -22,4 +22,5 @@ var migration003RelativeScheduling = func(config *v1.Config) {
schedule.Clock = v1.Schedule_CLOCK_LAST_RUN_TIME
}
}
return nil
}
+2 -1
View File
@@ -7,7 +7,7 @@ import (
v1 "github.com/garethgeorge/backrest/gen/go/v1"
)
var migration004RepoGuid = func(config *v1.Config) {
var migration004RepoGuid = func(config *v1.Config) error {
for _, repo := range config.Repos {
if repo.Guid != "" {
continue
@@ -16,4 +16,5 @@ var migration004RepoGuid = func(config *v1.Config) {
h.Write([]byte(repo.Id))
repo.Guid = hex.EncodeToString(h.Sum(nil))
}
return nil
}
@@ -0,0 +1,127 @@
package migrations
import (
"fmt"
"os"
"strings"
v1 "github.com/garethgeorge/backrest/gen/go/v1"
"go.uber.org/zap"
)
var conflictingEnvVars = []string{
"RESTIC_PASSWORD",
"RESTIC_PASSWORD_FILE",
"RESTIC_PASSWORD_COMMAND",
}
// migration005CheckRepoPasswords detects repos whose restic password may have
// been set incorrectly due to a bug fixed in this release
// (https://github.com/garethgeorge/backrest/issues/1139).
//
// The bug: RESTIC_PASSWORD, RESTIC_PASSWORD_FILE, and RESTIC_PASSWORD_COMMAND
// inherited from the process environment previously took precedence over the
// password configured in Backrest's UI, because WithEnviron() was appended
// after the config password. Repos initialized or used under those conditions
// were encrypted with the env var's password, not the one the user entered.
//
// On first run after upgrading, if any repo has a config password AND one of
// the above env vars is set, this migration returns an error and refuses to
// start, printing instructions for the user.
//
// If ISSUE_1139_FIX_PASSWORDS=1 is set in the environment, the migration
// instead applies the following automatic fixes to each affected repo and
// writes the corrected config to disk before startup continues:
//
// - RESTIC_PASSWORD: the env var's value is written into repo.Password,
// replacing the (likely wrong) value the user had entered in the UI.
//
// - RESTIC_PASSWORD_FILE or RESTIC_PASSWORD_COMMAND: repo.Password is
// cleared and the env var (name=value) is appended to repo.Env, so
// restic continues to resolve the password via the same mechanism it
// was using before. The env var can then be removed from the Backrest
// process environment, as the password source is now explicit in the
// repo config.
//
// After this migration runs successfully (whether or not a fix was needed)
// the config version is bumped and it will not run again on future starts.
var migration005CheckRepoPasswords = func(config *v1.Config) error {
// Find repos that have a password configured in the UI.
var affectedRepos []*v1.Repo
for _, repo := range config.Repos {
if repo.GetPassword() != "" {
affectedRepos = append(affectedRepos, repo)
}
}
if len(affectedRepos) == 0 {
return nil
}
// Check which conflicting env vars are set.
var setEnvVars []string
for _, envVar := range conflictingEnvVars {
if os.Getenv(envVar) != "" {
setEnvVars = append(setEnvVars, envVar)
}
}
if len(setEnvVars) == 0 {
return nil
}
// There is a potential conflict. Check if the user has acknowledged it.
if os.Getenv("ISSUE_1139_FIX_PASSWORDS") != "" {
for _, repo := range affectedRepos {
for _, envVar := range setEnvVars {
val := os.Getenv(envVar)
switch envVar {
case "RESTIC_PASSWORD":
zap.S().Warnf("repo %q: overwriting config password with value of RESTIC_PASSWORD env var (issue 1139 fix)", repo.Id)
repo.Password = val
case "RESTIC_PASSWORD_FILE", "RESTIC_PASSWORD_COMMAND":
zap.S().Warnf("repo %q: clearing config password and adding %s=%s to repo env (issue 1139 fix)", repo.Id, envVar, val)
repo.Password = ""
repo.Env = append(repo.Env, envVar+"="+val)
}
}
}
return nil
}
// Block startup with a detailed error message.
var b strings.Builder
b.WriteString("IMPORTANT: Backrest detected a potential password conflict affecting your restic repos (see https://github.com/garethgeorge/backrest/issues/1139).\n")
b.WriteString("\n")
b.WriteString("What happened:\n")
b.WriteString(" Backrest was using the wrong password for restic repos. The environment variables listed\n")
b.WriteString(" below were inherited by Backrest's process and previously took precedence over the password\n")
b.WriteString(" you configured in the UI. Your repos may have been encrypted with a different password than\n")
b.WriteString(" what your Backrest config says.\n")
b.WriteString("\n")
b.WriteString("Conflicting environment variables found:\n")
for _, envVar := range setEnvVars {
fmt.Fprintf(&b, " - %s\n", envVar)
}
b.WriteString("\n")
b.WriteString("Potentially affected repos:\n")
for _, repo := range affectedRepos {
fmt.Fprintf(&b, " - %s\n", repo.Id)
}
b.WriteString("\n")
b.WriteString("How to fix:\n")
b.WriteString(" Rerun Backrest with ISSUE_1139_FIX_PASSWORDS=1 set in the environment.\n")
b.WriteString(" Backrest will automatically update each affected repo's config as follows,\n")
b.WriteString(" then write the corrected config to disk and start normally:\n")
b.WriteString("\n")
b.WriteString(" RESTIC_PASSWORD: the env var's value is written into the repo's\n")
b.WriteString(" password field, replacing the value entered in the UI.\n")
b.WriteString("\n")
b.WriteString(" RESTIC_PASSWORD_FILE / the repo's password field is cleared and the env var\n")
b.WriteString(" RESTIC_PASSWORD_COMMAND: (name=value) is appended to the repo's env list, so\n")
b.WriteString(" restic continues to resolve the password the same way.\n")
b.WriteString("\n")
b.WriteString(" Once the fix has been applied you can remove the conflicting environment variable\n")
b.WriteString(" from your Backrest process environment — the password source will be stored\n")
b.WriteString(" explicitly in the repo config from that point on.\n")
return fmt.Errorf("%s", b.String())
}
@@ -0,0 +1,199 @@
package migrations
import (
"strings"
"testing"
v1 "github.com/garethgeorge/backrest/gen/go/v1"
)
func TestMigration005CheckRepoPasswords(t *testing.T) {
// Cannot use t.Parallel() because subtests use t.Setenv.
tests := []struct {
name string
config func() *v1.Config // factory so each run gets a fresh config
envVars map[string]string // env vars to set for the test
ackEnvVar bool // whether to set ISSUE_1139_FIX_PASSWORDS=1
wantErr bool
errContains string // substring that must appear in the error
checkConfig func(*testing.T, *v1.Config) // optional post-migration assertions
}{
{
name: "no repos with passwords - no conflict",
config: func() *v1.Config {
return &v1.Config{Repos: []*v1.Repo{{Id: "r", Uri: "/tmp/r"}}}
},
envVars: map[string]string{"RESTIC_PASSWORD": "somevalue"},
wantErr: false,
},
{
name: "repos with passwords but no conflicting env vars - no conflict",
config: func() *v1.Config {
return &v1.Config{Repos: []*v1.Repo{{Id: "r", Uri: "/tmp/r", Password: "secret"}}}
},
envVars: map[string]string{},
wantErr: false,
},
{
name: "RESTIC_PASSWORD conflict blocks startup without ack",
config: func() *v1.Config {
return &v1.Config{Repos: []*v1.Repo{{Id: "my-repo", Uri: "/tmp/r", Password: "wrong"}}}
},
envVars: map[string]string{"RESTIC_PASSWORD": "envpassword"},
wantErr: true,
errContains: "RESTIC_PASSWORD",
},
{
name: "RESTIC_PASSWORD_FILE conflict blocks startup without ack",
config: func() *v1.Config {
return &v1.Config{Repos: []*v1.Repo{{Id: "r", Uri: "/tmp/r", Password: "wrong"}}}
},
envVars: map[string]string{"RESTIC_PASSWORD_FILE": "/run/secrets/restic"},
wantErr: true,
errContains: "RESTIC_PASSWORD_FILE",
},
{
name: "RESTIC_PASSWORD_COMMAND conflict blocks startup without ack",
config: func() *v1.Config {
return &v1.Config{Repos: []*v1.Repo{{Id: "r", Uri: "/tmp/r", Password: "wrong"}}}
},
envVars: map[string]string{"RESTIC_PASSWORD_COMMAND": "cat /secrets/pw"},
wantErr: true,
errContains: "RESTIC_PASSWORD_COMMAND",
},
{
name: "error message names the affected repo",
config: func() *v1.Config {
return &v1.Config{Repos: []*v1.Repo{{Id: "my-repo", Uri: "/tmp/r", Password: "wrong"}}}
},
envVars: map[string]string{"RESTIC_PASSWORD": "envpassword"},
wantErr: true,
errContains: "my-repo",
},
{
name: "error message references ISSUE_1139_FIX_PASSWORDS",
config: func() *v1.Config {
return &v1.Config{Repos: []*v1.Repo{{Id: "r", Uri: "/tmp/r", Password: "wrong"}}}
},
envVars: map[string]string{"RESTIC_PASSWORD": "envpassword"},
wantErr: true,
errContains: "ISSUE_1139_FIX_PASSWORDS",
},
{
name: "RESTIC_PASSWORD: ack inlines value into repo.Password",
config: func() *v1.Config {
return &v1.Config{Repos: []*v1.Repo{{Id: "r", Uri: "/tmp/r", Password: "wrong"}}}
},
envVars: map[string]string{"RESTIC_PASSWORD": "real-password"},
ackEnvVar: true,
wantErr: false,
checkConfig: func(t *testing.T, cfg *v1.Config) {
t.Helper()
if got := cfg.Repos[0].Password; got != "real-password" {
t.Errorf("expected repo.Password = %q, got %q", "real-password", got)
}
},
},
{
name: "RESTIC_PASSWORD_FILE: ack moves to repo.Env and clears Password",
config: func() *v1.Config {
return &v1.Config{Repos: []*v1.Repo{{Id: "r", Uri: "/tmp/r", Password: "wrong"}}}
},
envVars: map[string]string{"RESTIC_PASSWORD_FILE": "/run/secrets/pw"},
ackEnvVar: true,
wantErr: false,
checkConfig: func(t *testing.T, cfg *v1.Config) {
t.Helper()
repo := cfg.Repos[0]
if repo.Password != "" {
t.Errorf("expected repo.Password to be cleared, got %q", repo.Password)
}
wantEnv := "RESTIC_PASSWORD_FILE=/run/secrets/pw"
for _, e := range repo.Env {
if e == wantEnv {
return
}
}
t.Errorf("expected repo.Env to contain %q, got %v", wantEnv, repo.Env)
},
},
{
name: "RESTIC_PASSWORD_COMMAND: ack moves to repo.Env and clears Password",
config: func() *v1.Config {
return &v1.Config{Repos: []*v1.Repo{{Id: "r", Uri: "/tmp/r", Password: "wrong"}}}
},
envVars: map[string]string{"RESTIC_PASSWORD_COMMAND": "cat /secrets/pw"},
ackEnvVar: true,
wantErr: false,
checkConfig: func(t *testing.T, cfg *v1.Config) {
t.Helper()
repo := cfg.Repos[0]
if repo.Password != "" {
t.Errorf("expected repo.Password to be cleared, got %q", repo.Password)
}
wantEnv := "RESTIC_PASSWORD_COMMAND=cat /secrets/pw"
for _, e := range repo.Env {
if e == wantEnv {
return
}
}
t.Errorf("expected repo.Env to contain %q, got %v", wantEnv, repo.Env)
},
},
{
name: "only repos with passwords are considered affected",
config: func() *v1.Config {
return &v1.Config{Repos: []*v1.Repo{
{Id: "no-pass", Uri: "/tmp/r1"},
{Id: "with-pass", Uri: "/tmp/r2", Password: "wrong"},
}}
},
envVars: map[string]string{"RESTIC_PASSWORD": "envpassword"},
wantErr: true,
errContains: "with-pass",
},
{
name: "no repos at all - no conflict",
config: func() *v1.Config {
return &v1.Config{}
},
envVars: map[string]string{"RESTIC_PASSWORD": "envpassword"},
wantErr: false,
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
for k, v := range tc.envVars {
t.Setenv(k, v)
}
// Ensure conflicting env vars not in tc.envVars are unset.
for _, k := range conflictingEnvVars {
if _, ok := tc.envVars[k]; !ok {
t.Setenv(k, "")
}
}
if tc.ackEnvVar {
t.Setenv("ISSUE_1139_FIX_PASSWORDS", "1")
} else {
t.Setenv("ISSUE_1139_FIX_PASSWORDS", "")
}
cfg := tc.config()
err := migration005CheckRepoPasswords(cfg)
if (err != nil) != tc.wantErr {
t.Errorf("migration005CheckRepoPasswords() error = %v, wantErr %v", err, tc.wantErr)
}
if tc.errContains != "" && err != nil {
if !strings.Contains(err.Error(), tc.errContains) {
t.Errorf("expected error to contain %q, got: %s", tc.errContains, err.Error())
}
}
if tc.checkConfig != nil {
tc.checkConfig(t, cfg)
}
})
}
}
+7 -3
View File
@@ -8,11 +8,12 @@ import (
"google.golang.org/protobuf/proto"
)
var migrations = []*func(*v1.Config){
var migrations = []*func(*v1.Config) error{
&noop, // migration001PrunePolicy is deprecated
&noop, // migration002Schedules is deprecated
&migration003RelativeScheduling,
&migration004RepoGuid,
&migration005CheckRepoPasswords,
}
var CurrentVersion = int32(len(migrations))
@@ -39,13 +40,16 @@ func ApplyMigrations(config *v1.Config) error {
if m == &noop {
return fmt.Errorf("config version %d is too old to migrate, please try first upgrading to backrest 1.4.0 which is the last version that may be compatible with your config", config.Version)
}
(*m)(config)
if err := (*m)(config); err != nil {
return err
}
}
config.Version = CurrentVersion
return nil
}
var noop = func(config *v1.Config) {
var noop = func(config *v1.Config) error {
// do nothing
return nil
}
+12 -4
View File
@@ -39,12 +39,20 @@ func NewRepoOrchestrator(config *v1.Config, repoConfig *v1.Repo, resticPath stri
}
var opts []restic.GenericOption
if p := repoConfig.GetPassword(); p != "" {
opts = append(opts, restic.WithEnv("RESTIC_PASSWORD="+p))
}
opts = append(opts, restic.WithEnviron())
if p := repoConfig.GetPassword(); p != "" {
// Set config password last so it takes precedence over any RESTIC_PASSWORD
// in the system environment (e.g. set via Docker Compose). Also clear
// RESTIC_PASSWORD_FILE and RESTIC_PASSWORD_COMMAND so they cannot
// override the password that the user configured in Backrest.
opts = append(opts, restic.WithEnv(
"RESTIC_PASSWORD="+p,
"RESTIC_PASSWORD_FILE=",
"RESTIC_PASSWORD_COMMAND=",
))
}
if env := repoConfig.GetEnv(); len(env) != 0 {
for _, e := range env {
opts = append(opts, restic.WithEnv(ExpandEnv(e)))
+42
View File
@@ -371,6 +371,48 @@ func initRepoHelper(t *testing.T, config *v1.Config, repo *v1.Repo) *RepoOrchest
return orchestrator
}
// TestConfigPasswordPrecedence verifies that a password set in the Backrest
// repo config takes precedence over RESTIC_PASSWORD (and related env vars) set
// in the process environment. This is a regression test for
// https://github.com/garethgeorge/backrest/issues/1139.
func TestConfigPasswordPrecedence(t *testing.T) {
// Cannot use t.Parallel() because t.Setenv is used.
repoDir := t.TempDir()
configPassword := "config-password"
wrongEnvPassword := "env-password-should-be-ignored"
repo := &v1.Repo{
Id: "test",
Uri: repoDir,
Password: configPassword,
Flags: []string{"--no-cache"},
}
// Initialize the repo using the config password.
orchestrator := initRepoHelper(t, configForTest, repo)
// Now set RESTIC_PASSWORD in the environment to a *different* value.
// NewRepoOrchestrator should still use the config password, not this one.
t.Setenv("RESTIC_PASSWORD", wrongEnvPassword)
t.Setenv("RESTIC_PASSWORD_FILE", "")
t.Setenv("RESTIC_PASSWORD_COMMAND", "")
// Re-create the orchestrator so it picks up the current environment.
orchestrator2, err := NewRepoOrchestrator(configForTest, repo, helpers.ResticBinary(t))
if err != nil {
t.Fatalf("failed to create repo orchestrator: %v", err)
}
// Listing snapshots exercises the repo unlock path and will fail with
// "wrong password" if the env var is incorrectly taking precedence.
if _, err := orchestrator2.Snapshots(context.Background()); err != nil {
t.Fatalf("Snapshots() failed — config password did not take precedence over RESTIC_PASSWORD env var: %v", err)
}
_ = orchestrator // suppress unused warning
}
func TestRestoreAmbiguity(t *testing.T) {
t.Parallel()
repoDir := t.TempDir()
+1 -1
View File
@@ -17,7 +17,7 @@ func NewOneoffForgetTask(repo *v1.Repo, planID string, flowID int64, at time.Tim
OneoffTask: OneoffTask{
BaseTask: BaseTask{
TaskType: "forget",
TaskName: fmt.Sprintf("forget for plan %q in repo %q", repo.Id, planID),
TaskName: fmt.Sprintf("forget for plan %q in repo %q", planID, repo.Id),
TaskRepo: repo,
TaskPlanID: planID,
},
+67 -26
View File
@@ -63,6 +63,7 @@
"resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-8.0.0.tgz",
"integrity": "sha512-6YzkKCw30EI/E9kHOIXsQDHmMvTllT8STzjMb4K2qzit33RW2pqCJP0sk+hidBntXxE+Vz4n1+RvCTfBw6OErw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@ant-design/fast-color": "^3.0.0"
}
@@ -72,6 +73,7 @@
"resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-2.0.1.tgz",
"integrity": "sha512-Lw1Z4cUQxdMmTNir67gU0HCpTl5TtkKCJPZ6UBvCqzcOTl/QmMFB6qAEoj8qFl0CuZDX9qQYa3m9+rEKfaBSbA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.11.1",
"@emotion/hash": "^0.8.0",
@@ -91,6 +93,7 @@
"resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-2.0.2.tgz",
"integrity": "sha512-Mq3Hm6fJuQeFNKSp3+yT4bjuhVbdrsyXE2RyfpJFL0xiYNZdaJ6oFaE3zFrzmHbmvTd2Wp3HCbRtkD4fU+v2ZA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@ant-design/cssinjs": "^2.0.1",
"@babel/runtime": "^7.23.2",
@@ -105,25 +108,29 @@
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz",
"integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@ant-design/cssinjs/node_modules/@emotion/unitless": {
"version": "0.7.5",
"resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz",
"integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@ant-design/cssinjs/node_modules/stylis": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
"integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@ant-design/fast-color": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-3.0.0.tgz",
"integrity": "sha512-eqvpP7xEDm2S7dUzl5srEQCBTXZMmY3ekf97zI+M2DHOYyKdJGH0qua0JACHTqbkRnD/KHFQP9J1uMJ/XWVzzA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8.x"
}
@@ -133,6 +140,7 @@
"resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.1.0.tgz",
"integrity": "sha512-KrWMu1fIg3w/1F2zfn+JlfNDU8dDqILfA5Tg85iqs1lf8ooyGlbkA+TkwfOKKgqpUmAiRY1PTFpuOU2DAIgSUg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@ant-design/colors": "^8.0.0",
"@ant-design/icons-svg": "^4.4.0",
@@ -151,13 +159,15 @@
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz",
"integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@ant-design/react-slick": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-2.0.0.tgz",
"integrity": "sha512-HMS9sRoEmZey8LsE/Yo6+klhlzU12PisjrVcydW3So7RdklyEd2qehyU6a7Yp+OYN72mgsYs3NFCyP2lCPFVqg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"clsx": "^2.1.1",
@@ -275,7 +285,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -538,8 +547,7 @@
"version": "2.10.2",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.10.2.tgz",
"integrity": "sha512-uFsRXwIGyu+r6AMdz+XijIIZJYpoWeYzILt5yZ2d3mCjQrWUTVpVD9WL/jZAbvp+Ed04rOhrsk7FiTcEDseB5A==",
"license": "(Apache-2.0 AND BSD-3-Clause)",
"peer": true
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@chakra-ui/react": {
"version": "3.30.0",
@@ -566,7 +574,6 @@
"resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.1.1.tgz",
"integrity": "sha512-JzhkaTvM73m2K1URT6tv53k2RwngSmCXLZJgK580qNQOXRzZRR/BCMfZw3h+90JpnG6XksP5bYT+cz0rpUzUWQ==",
"license": "Apache-2.0",
"peer": true,
"peerDependencies": {
"@bufbuild/protobuf": "^2.7.0"
}
@@ -598,7 +605,6 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
@@ -693,7 +699,6 @@
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -1352,7 +1357,6 @@
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.0.tgz",
"integrity": "sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@swc/helpers": "^0.5.0"
}
@@ -1702,7 +1706,6 @@
"integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^4.0.0",
"@octokit/graphql": "^7.1.0",
@@ -2404,6 +2407,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.0.4.tgz",
"integrity": "sha512-qgGdcVIF604M9EqjNF0hbUTz42bz/RDtxWdWuU5EQe3hi7M8ob54B6B35rOsvX5eSvIHIzT9iH1R3n+hk3CGfg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.24.4"
},
@@ -2416,6 +2420,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/cascader/-/cascader-1.9.0.tgz",
"integrity": "sha512-2jbthe1QZrMBgtCvNKkJFjZYC3uKl4N/aYm5SsMvO3T+F+qRT1CGsSM9bXnh1rLj7jDk/GK0natShWF/jinhWQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rc-component/select": "~1.3.0",
"@rc-component/tree": "~1.1.0",
@@ -2432,6 +2437,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/checkbox/-/checkbox-1.0.1.tgz",
"integrity": "sha512-08yTH8m+bSm8TOqbybbJ9KiAuIATti6bDs2mVeSfu4QfEnyeF6X0enHVvD1NEAyuBWEAo56QtLe++MYs2D9XiQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rc-component/util": "^1.3.0",
"clsx": "^2.1.1"
@@ -2446,6 +2452,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/collapse/-/collapse-1.1.2.tgz",
"integrity": "sha512-ilBYk1dLLJHu5Q74dF28vwtKUYQ42ZXIIDmqTuVy4rD8JQVvkXOs+KixVNbweyuIEtJYJ7+t+9GVD9dPc6N02w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.10.1",
"@rc-component/motion": "^1.1.4",
@@ -2462,6 +2469,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-3.0.3.tgz",
"integrity": "sha512-V7gFF9O7o5XwIWafdbOtqI4BUUkEUkgdBwp6favy3xajMX/2dDqytFaiXlcwrpq6aRyPLp5dKLAG5RFKLXMeGA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@ant-design/fast-color": "^3.0.0",
"@rc-component/util": "^1.3.0",
@@ -2477,6 +2485,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/context/-/context-2.0.1.tgz",
"integrity": "sha512-HyZbYm47s/YqtP6pKXNMjPEMaukyg7P0qVfgMLzr7YiFNMHbK2fKTAGzms9ykfGHSfyf75nBbgWw+hHkp+VImw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rc-component/util": "^1.3.0"
},
@@ -2490,6 +2499,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/dialog/-/dialog-1.5.1.tgz",
"integrity": "sha512-by4Sf/a3azcb89WayWuwG19/Y312xtu8N81HoVQQtnsBDylfs+dog98fTAvLinnpeoWG52m/M7QLRW6fXR3l1g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rc-component/motion": "^1.1.3",
"@rc-component/portal": "^2.0.0",
@@ -2506,6 +2516,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/drawer/-/drawer-1.3.0.tgz",
"integrity": "sha512-rE+sdXEmv2W25VBQ9daGbnb4J4hBIEKmdbj0b3xpY+K7TUmLXDIlSnoXraIbFZdGyek9WxxGKK887uRnFgI+pQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rc-component/motion": "^1.1.4",
"@rc-component/portal": "^2.0.0",
@@ -2522,6 +2533,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/dropdown/-/dropdown-1.0.2.tgz",
"integrity": "sha512-6PY2ecUSYhDPhkNHHb4wfeAya04WhpmUSKzdR60G+kMNVUCX2vjT/AgTS0Lz0I/K6xrPMJ3enQbwVpeN3sHCgg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rc-component/trigger": "^3.0.0",
"@rc-component/util": "^1.2.1",
@@ -2537,6 +2549,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/form/-/form-1.6.0.tgz",
"integrity": "sha512-A7vrN8kExtw4sW06mrsgCb1rowhvBFFvQU6Bk/NL0Fj6Wet/5GF0QnGCxBu/sG3JI9FEhsJWES0D44BW2d0hzg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rc-component/async-validator": "^5.0.3",
"@rc-component/util": "^1.5.0",
@@ -2555,6 +2568,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/image/-/image-1.5.3.tgz",
"integrity": "sha512-/NR7QW9uCN8Ugar+xsHZOPvzPySfEhcW2/vLcr7VPRM+THZMrllMRv7LAUgW7ikR+Z67Ab67cgPp5K5YftpJsQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rc-component/motion": "^1.0.0",
"@rc-component/portal": "^2.0.0",
@@ -2571,6 +2585,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/input/-/input-1.1.2.tgz",
"integrity": "sha512-Q61IMR47piUBudgixJ30CciKIy9b1H95qe7GgEKOmSJVJXvFRWJllJfQry9tif+MX2cWFXWJf/RXz4kaCeq/Fg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rc-component/util": "^1.4.0",
"clsx": "^2.1.1"
@@ -2585,6 +2600,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/input-number/-/input-number-1.6.2.tgz",
"integrity": "sha512-Gjcq7meZlCOiWN1t1xCC+7/s85humHVokTBI7PJgTfoyw5OWF74y3e6P8PHX104g9+b54jsodFIzyaj6p8LI9w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rc-component/mini-decimal": "^1.0.1",
"@rc-component/util": "^1.4.0",
@@ -2600,6 +2616,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/mentions/-/mentions-1.6.0.tgz",
"integrity": "sha512-KIkQNP6habNuTsLhUv0UGEOwG67tlmE7KNIJoQZZNggEZl5lQJTytFDb69sl5CK3TDdISCTjKP3nGEBKgT61CQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rc-component/input": "~1.1.0",
"@rc-component/menu": "~1.2.0",
@@ -2618,6 +2635,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/menu/-/menu-1.2.0.tgz",
"integrity": "sha512-VWwDuhvYHSnTGj4n6bV3ISrLACcPAzdPOq3d0BzkeiM5cve8BEYfvkEhNoM0PLzv51jpcejeyrLXeMVIJ+QJlg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rc-component/motion": "^1.1.4",
"@rc-component/overflow": "^1.0.0",
@@ -2635,6 +2653,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.0.tgz",
"integrity": "sha512-jS4E7T9Li2GuYwI6PyiVXmxTiM6b07rlD9Ge8uGZSCz3WlzcG5ZK7g5bbuKNeZ9pgUuPK/5guV781ujdVpm4HQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.0"
},
@@ -2647,6 +2666,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/motion/-/motion-1.1.6.tgz",
"integrity": "sha512-aEQobs/YA0kqRvHIPjQvOytdtdRVyhf/uXAal4chBjxDu6odHckExJzjn2D+Ju1aKK6hx3pAs6BXdV9+86xkgQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rc-component/util": "^1.2.0",
"clsx": "^2.1.1"
@@ -2661,6 +2681,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-2.0.1.tgz",
"integrity": "sha512-AyarjoLU5YlxuValRi+w8JRH2Z84TBbFO2RoGWz9d8bSu0FqT8DtugH3xC3BV7mUwlmROFauyWuXFuq4IFbH+w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rc-component/util": "^1.2.0"
},
@@ -2677,6 +2698,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/notification/-/notification-1.2.0.tgz",
"integrity": "sha512-OX3J+zVU7rvoJCikjrfW7qOUp7zlDeFBK2eA3SFbGSkDqo63Sl4Ss8A04kFP+fxHSxMDIS9jYVEZtU1FNCFuBA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rc-component/motion": "^1.1.4",
"@rc-component/util": "^1.2.1",
@@ -2695,6 +2717,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/overflow/-/overflow-1.0.0.tgz",
"integrity": "sha512-GSlBeoE0XTBi5cf3zl8Qh7Uqhn7v8RrlJ8ajeVpEkNe94HWy5l5BQ0Mwn2TVUq9gdgbfEMUmTX7tJFAg7mz0Rw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.11.1",
"@rc-component/resize-observer": "^1.0.1",
@@ -2711,6 +2734,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/pagination/-/pagination-1.2.0.tgz",
"integrity": "sha512-YcpUFE8dMLfSo6OARJlK6DbHHvrxz7pMGPGmC/caZSJJz6HRKHC1RPP001PRHCvG9Z/veD039uOQmazVuLJzlw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rc-component/util": "^1.3.0",
"clsx": "^2.1.1"
@@ -2725,6 +2749,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/picker/-/picker-1.9.0.tgz",
"integrity": "sha512-OLisdk8AWVCG9goBU1dWzuH5QlBQk8jktmQ6p0/IyBFwdKGwyIZOSjnBYo8hooHiTdl0lU+wGf/OfMtVBw02KQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rc-component/overflow": "^1.0.0",
"@rc-component/resize-observer": "^1.0.0",
@@ -2763,6 +2788,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-2.1.0.tgz",
"integrity": "sha512-P25IXWkzvBbyEtrAHRfqSNRkILXgAjDfuk0s4daPfHHO0XzVk3D3KJY3Lh069xwuBGtsTZpg+mP4WBLYl9GNaA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rc-component/util": "^1.2.1",
"clsx": "^2.1.1"
@@ -2780,6 +2806,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/progress/-/progress-1.0.2.tgz",
"integrity": "sha512-WZUnH9eGxH1+xodZKqdrHke59uyGZSWgj5HBM5Kwk5BrTMuAORO7VJ2IP5Qbm9aH3n9x3IcesqHHR0NWPBC7fQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rc-component/util": "^1.2.1",
"clsx": "^2.1.1"
@@ -2794,6 +2821,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-1.1.1.tgz",
"integrity": "sha512-LfLGNymzKdUPjXUbRP+xOhIWY4jQ+YMj5MmWAcgcAq1Ij8XP7tRmAXqyuv96XvLUBE/5cA8hLFl9eO1JQMujrA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.24.7"
},
@@ -2810,6 +2838,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/rate/-/rate-1.0.1.tgz",
"integrity": "sha512-bkXxeBqDpl5IOC7yL7GcSYjQx9G8H+6kLYQnNZWeBYq2OYIv1MONd6mqKTjnnJYpV0cQIU2z3atdW0j1kttpTw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rc-component/util": "^1.3.0",
"clsx": "^2.1.1"
@@ -2827,6 +2856,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/resize-observer/-/resize-observer-1.0.1.tgz",
"integrity": "sha512-r+w+Mz1EiueGk1IgjB3ptNXLYSLZ5vnEfKHH+gfgj7JMupftyzvUUl3fRcMZe5uMM04x0n8+G2o/c6nlO2+Wag==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rc-component/util": "^1.2.0"
},
@@ -2840,6 +2870,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/segmented/-/segmented-1.3.0.tgz",
"integrity": "sha512-5J/bJ01mbDnoA6P/FW8SxUvKn+OgUSTZJPzCNnTBntG50tzoP7DydGhqxp7ggZXZls7me3mc2EQDXakU3iTVFg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.11.1",
"@rc-component/motion": "^1.1.4",
@@ -2856,6 +2887,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/select/-/select-1.3.6.tgz",
"integrity": "sha512-CzbJ9TwmWcF5asvTMZ9BMiTE9CkkrigeOGRPpzCNmeZP7KBwwmYrmOIiKh9tMG7d6DyGAEAQ75LBxzPx+pGTHA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rc-component/overflow": "^1.0.0",
"@rc-component/trigger": "^3.0.0",
@@ -2876,6 +2908,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/slider/-/slider-1.0.1.tgz",
"integrity": "sha512-uDhEPU1z3WDfCJhaL9jfd2ha/Eqpdfxsn0Zb0Xcq1NGQAman0TWaR37OWp2vVXEOdV2y0njSILTMpTfPV1454g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rc-component/util": "^1.3.0",
"clsx": "^2.1.1"
@@ -2893,6 +2926,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/steps/-/steps-1.2.2.tgz",
"integrity": "sha512-/yVIZ00gDYYPHSY0JP+M+s3ZvuXLu2f9rEjQqiUDs7EcYsUYrpJ/1bLj9aI9R7MBR3fu/NGh6RM9u2qGfqp+Nw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rc-component/util": "^1.2.1",
"clsx": "^2.1.1"
@@ -2910,6 +2944,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/switch/-/switch-1.0.3.tgz",
"integrity": "sha512-Jgi+EbOBquje/XNdofr7xbJQZPYJP+BlPfR0h+WN4zFkdtB2EWqEfvkXJWeipflwjWip0/17rNbxEAqs8hVHfw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rc-component/util": "^1.3.0",
"clsx": "^2.1.1"
@@ -2924,6 +2959,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/table/-/table-1.9.0.tgz",
"integrity": "sha512-cq3P9FkD+F3eglkFYhBuNlHclg+r4jY8+ZIgK7zbEFo6IwpnA77YL/Gq4ensLw9oua3zFCTA6JDu6YgBei0TxA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rc-component/context": "^2.0.1",
"@rc-component/resize-observer": "^1.0.0",
@@ -2944,6 +2980,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/tabs/-/tabs-1.7.0.tgz",
"integrity": "sha512-J48cs2iBi7Ho3nptBxxIqizEliUC+ExE23faspUQKGQ550vaBlv3aGF8Epv/UB1vFWeoJDTW/dNzgIU0Qj5i/w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rc-component/dropdown": "~1.0.0",
"@rc-component/menu": "~1.2.0",
@@ -2965,6 +3002,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/textarea/-/textarea-1.1.2.tgz",
"integrity": "sha512-9rMUEODWZDMovfScIEHXWlVZuPljZ2pd1LKNjslJVitn4SldEzq5vO1CL3yy3Dnib6zZal2r2DPtjy84VVpF6A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rc-component/input": "~1.1.0",
"@rc-component/resize-observer": "^1.0.0",
@@ -2981,6 +3019,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/tooltip/-/tooltip-1.4.0.tgz",
"integrity": "sha512-8Rx5DCctIlLI4raR0I0xHjVTf1aF48+gKCNeAAo5bmF5VoR5YED+A/XEqzXv9KKqrJDRcd3Wndpxh2hyzrTtSg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rc-component/trigger": "^3.7.1",
"@rc-component/util": "^1.3.0",
@@ -2996,6 +3035,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-2.2.1.tgz",
"integrity": "sha512-BUCrVikGJsXli38qlJ+h2WyDD6dYxzDA9dV3o0ij6gYhAq6ooT08SUMWOikva9v4KZ2BEuluGl5bPcsjrSoBgQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rc-component/portal": "^2.0.0",
"@rc-component/trigger": "^3.0.0",
@@ -3015,6 +3055,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/tree/-/tree-1.1.0.tgz",
"integrity": "sha512-HZs3aOlvFgQdgrmURRc/f4IujiNBf4DdEeXUlkS0lPoLlx9RoqsZcF0caXIAMVb+NaWqKtGQDnrH8hqLCN5zlA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rc-component/motion": "^1.0.0",
"@rc-component/util": "^1.2.1",
@@ -3034,6 +3075,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/tree-select/-/tree-select-1.4.0.tgz",
"integrity": "sha512-I3UAlO2hNqy9CSKc8EBaESgnmKk2QaRzuZ2XHZGFCgsSMkGl06mdF97sVfROM02YIb64ocgLKefsjE0Ch4ocwQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rc-component/select": "~1.3.0",
"@rc-component/tree": "~1.1.0",
@@ -3050,6 +3092,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-3.7.2.tgz",
"integrity": "sha512-25x+D2k9SAkaK/MNMNmv2Nlv8FH1D9RtmjoMoLEw1Cid+sMV4pAAT5k49ku59UeXaOA1qwLUVrBUMq4A6gUSsQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rc-component/motion": "^1.1.4",
"@rc-component/portal": "^2.0.0",
@@ -3070,6 +3113,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/upload/-/upload-1.1.0.tgz",
"integrity": "sha512-LIBV90mAnUE6VK5N4QvForoxZc4XqEYZimcp7fk+lkE4XwHHyJWxpIXQQwMU8hJM+YwBbsoZkGksL1sISWHQxw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rc-component/util": "^1.3.0",
"clsx": "^2.1.1"
@@ -3084,6 +3128,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.6.0.tgz",
"integrity": "sha512-YbjuIVAm8InCnXVoA4n6G+uh31yESTxQ6fSY2frZ2/oMSvktoB+bumFUfNN7RKh7YeOkZgOvN2suGtEDhJSX0A==",
"license": "MIT",
"peer": true,
"dependencies": {
"is-mobile": "^5.0.0",
"react-is": "^18.2.0"
@@ -3098,6 +3143,7 @@
"resolved": "https://registry.npmjs.org/@rc-component/virtual-list/-/virtual-list-1.0.2.tgz",
"integrity": "sha512-uvTol/mH74FYsn5loDGJxo+7kjkO4i+y4j87Re1pxJBs0FaeuMuLRzQRGaXwnMcV1CxpZLi2Z56Rerj2M00fjQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.20.0",
"@rc-component/resize-observer": "^1.0.1",
@@ -3622,7 +3668,6 @@
"integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -3646,7 +3691,6 @@
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -4842,7 +4886,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -5118,7 +5161,8 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz",
"integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/consola": {
"version": "3.4.0",
@@ -5994,7 +6038,8 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-5.0.0.tgz",
"integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/is-number": {
"version": "7.0.0",
@@ -6078,6 +6123,7 @@
"resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz",
"integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==",
"license": "MIT",
"peer": true,
"dependencies": {
"string-convert": "^0.2.0"
}
@@ -6173,7 +6219,6 @@
"integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=14.0.0"
}
@@ -6709,7 +6754,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -6722,7 +6766,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -6980,7 +7023,6 @@
"integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -7085,7 +7127,6 @@
"integrity": "sha512-uf6HoO8fy6ClsrShvMgaKUn14f2EHQLQRtpsZZLeU/Mv0Q1K5P0+x2uvH6Cub39TVVbWNSrraUhDAoFph6vh0A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
@@ -7115,6 +7156,7 @@
"resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz",
"integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"compute-scroll-into-view": "^3.0.2"
}
@@ -7212,7 +7254,8 @@
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz",
"integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/string-width": {
"version": "5.1.2",
@@ -7354,6 +7397,7 @@
"resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
"integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12.22"
}
@@ -7428,7 +7472,6 @@
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
@@ -7449,7 +7492,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -7595,7 +7637,6 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
+132
View File
@@ -0,0 +1,132 @@
import { useEffect, useState } from "react";
import { Operation, OperationEvent, OperationStatus } from "../../gen/ts/v1/operations_pb";
import { OpSelector } from "../../gen/ts/v1/service_pb";
import { subscribeToOperations, unsubscribeFromOperations } from "./oplog";
import { getStatusForSelector, matchSelector } from "./logState";
import { debounce } from "../lib/util";
// Module-level shared state: all registered selectors and their cached statuses.
const selectors = new Map<string, { selector: OpSelector; refCount: number }>();
const statuses = new Map<string, OperationStatus>();
const listeners = new Set<() => void>();
let subscribed = false;
const notify = () => {
for (const l of listeners) l();
};
const fetchStatus = async (key: string, selector: OpSelector) => {
try {
const status = await getStatusForSelector(selector);
statuses.set(key, status);
notify();
} catch (_) {}
};
// Track pending keys to refresh; the debounced function drains this set.
const pendingKeys = new Set<string>();
const flushPending = debounce(
() => {
for (const key of pendingKeys) {
const entry = selectors.get(key);
if (entry) {
fetchStatus(key, entry.selector);
}
}
pendingKeys.clear();
},
1000,
{ maxWait: 10000, trailing: true },
);
const refreshAll = () => {
for (const key of selectors.keys()) {
pendingKeys.add(key);
}
flushPending();
};
const refreshMatching = (ops: Operation[]) => {
for (const [key, { selector }] of selectors) {
if (ops.some((op) => matchSelector(selector, op))) {
pendingKeys.add(key);
}
}
if (pendingKeys.size > 0) {
flushPending();
}
};
const handleEvent = (event?: OperationEvent, _err?: Error) => {
if (!event || !event.event) return;
switch (event.event.case) {
case "createdOperations":
case "updatedOperations":
refreshMatching(event.event.value.operations);
break;
case "deletedOperations":
refreshAll();
break;
}
};
const register = (selector: OpSelector): string => {
const key = JSON.stringify(selector);
const existing = selectors.get(key);
if (existing) {
existing.refCount++;
return key;
}
selectors.set(key, { selector, refCount: 1 });
fetchStatus(key, selector);
if (!subscribed) {
subscribed = true;
subscribeToOperations(handleEvent);
}
return key;
};
const unregister = (key: string) => {
const entry = selectors.get(key);
if (!entry) return;
entry.refCount--;
if (entry.refCount <= 0) {
selectors.delete(key);
statuses.delete(key);
notify();
if (selectors.size === 0 && subscribed) {
subscribed = false;
unsubscribeFromOperations(handleEvent);
flushPending.cancel();
pendingKeys.clear();
}
}
};
/**
* Hook that returns the OperationStatus for a given selector.
* Shares a single global operation event subscription across all consumers.
*/
export const useResourceStatus = (selector: OpSelector): OperationStatus => {
const key = JSON.stringify(selector);
const [status, setStatus] = useState<OperationStatus>(
() => statuses.get(key) ?? OperationStatus.STATUS_UNKNOWN,
);
useEffect(() => {
const k = register(selector);
const listener = () => {
setStatus(statuses.get(k) ?? OperationStatus.STATUS_UNKNOWN);
};
listeners.add(listener);
// Sync in case status was fetched between render and effect
listener();
return () => {
listeners.delete(listener);
unregister(k);
};
}, [key]);
return status;
};
+193 -162
View File
@@ -1,4 +1,4 @@
import React, { Suspense, useEffect, useState } from "react";
import React, { Suspense, useEffect, useMemo, useState } from "react";
import {
FiCalendar,
FiDatabase,
@@ -44,15 +44,15 @@ import {
DrawerTitle,
DrawerTrigger,
} from "../components/ui/drawer";
import { Config, Multihost_Peer } from "../../gen/ts/v1/config_pb";
import { Config, Multihost_Peer, Plan, Repo } from "../../gen/ts/v1/config_pb";
import { alerts } from "../components/common/Alerts";
import { useShowModal } from "../components/common/ModalManager";
import { uiBuildVersion } from "../state/buildcfg";
import { ActivityBar } from "../components/layout/ActivityBar";
import { OperationEvent, OperationStatus } from "../../gen/ts/v1/operations_pb";
import { subscribeToOperations, unsubscribeFromOperations } from "../api/oplog";
import { OperationStatus } from "../../gen/ts/v1/operations_pb";
import { useResourceStatus } from "../api/resourceStatus";
import LogoSvg from "../../assets/logo.svg";
import { debounce, keyBy } from "../lib/util";
import { keyBy } from "../lib/util";
import { Code } from "@connectrpc/connect";
import { LoginModal } from "../features/auth/LoginModal";
import { backrestService, setAuthToken } from "../api/client";
@@ -60,7 +60,6 @@ import { useConfig } from "./provider";
import { shouldShowSettings } from "../state/configutil";
import { OpSelector, OpSelectorSchema } from "../../gen/ts/v1/service_pb";
import { colorForStatus } from "../api/flowDisplayAggregator";
import { getStatusForSelector, matchSelector } from "../api/logState";
import {
Route,
Routes,
@@ -204,6 +203,155 @@ const PlanViewContainer = () => {
);
};
const SidebarPlanItem = React.memo(
({
plan,
repoGuid,
active,
onNav,
onEdit,
}: {
plan: Plan;
repoGuid: string | undefined;
active: boolean;
onNav: (path: string) => void;
onEdit: (plan: Plan) => void;
}) => {
const sel = useMemo(
() =>
create(OpSelectorSchema, {
originalInstanceKeyid: "",
planId: plan.id,
repoGuid: repoGuid,
}),
[plan.id, repoGuid],
);
const planPath = `/plan/${plan.id}`;
return (
<Flex
align="center"
pl={9}
pr={2}
py={1}
bg={active ? "bg.emphasized" : undefined}
_hover={{ bg: "bg.muted" }}
className="group"
>
<Box flexShrink={0} mr={2}>
<IconForResource selector={sel} />
</Box>
<Tooltip content={plan.id}>
<Box
flex="1"
minW="0"
cursor="pointer"
onClick={() => onNav(planPath)}
userSelect="none"
>
<Text
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{plan.id}
</Text>
</Box>
</Tooltip>
<Box
opacity={0}
_groupHover={{ opacity: 1 }}
transition="opacity 0.2s"
>
<IconButton
size="xs"
variant="ghost"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
onEdit(plan);
}}
>
<FiEdit2 />
</IconButton>
</Box>
</Flex>
);
},
);
const SidebarRepoItem = React.memo(
({
repo,
instanceId,
active,
onNav,
onEdit,
}: {
repo: Repo;
instanceId: string;
active: boolean;
onNav: (path: string) => void;
onEdit: (repo: Repo) => void;
}) => {
const sel = useMemo(
() =>
create(OpSelectorSchema, {
instanceId: instanceId,
repoGuid: repo.guid,
}),
[instanceId, repo.guid],
);
const repoPath = `/repo/${repo.id}`;
return (
<Flex
align="center"
pl={9}
pr={2}
py={1}
bg={active ? "bg.emphasized" : undefined}
_hover={{ bg: "bg.muted" }}
className="group"
>
<Box flexShrink={0} mr={2}>
<IconForResource selector={sel} />
</Box>
<Tooltip content={repo.uri}>
<Box
flex="1"
minW="0"
cursor="pointer"
onClick={() => onNav(repoPath)}
userSelect="none"
>
<Text
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{repo.id}
</Text>
</Box>
</Tooltip>
<Box
opacity={0}
_groupHover={{ opacity: 1 }}
transition="opacity 0.2s"
>
<IconButton
size="xs"
variant="ghost"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
onEdit(repo);
}}
>
<FiEdit2 />
</IconButton>
</Box>
</Flex>
);
},
);
const SidebarContent = ({ onClose }: { onClose?: () => void }) => {
const [config] = useConfig();
const peerStates = useSyncStates();
@@ -218,12 +366,11 @@ const SidebarContent = ({ onClose }: { onClose?: () => void }) => {
const isActive = (path: string) => location.pathname === path;
const reposById = useMemo(() => config ? keyBy(config.repos, (r) => r.id) : {}, [config?.repos]);
// Replicate getSidenavItems functionality with Chakra components
if (!config) return null;
const reposById = keyBy(config.repos, (r) => r.id);
// Sort logic can be added here if needed, currently adhering to original order
const configPlans = config.plans || [];
const configRepos = config.repos || [];
@@ -242,6 +389,7 @@ const SidebarContent = ({ onClose }: { onClose?: () => void }) => {
multiple
defaultValue={["plans", "repos", "authorized-clients"]}
variant="plain"
lazyMount
>
{/* DASHBOARD */}
<Box
@@ -285,67 +433,21 @@ const SidebarContent = ({ onClose }: { onClose?: () => void }) => {
>
<FiPlus /> {m.app_menu_add_plan()}
</Button>
{configPlans.map((plan) => {
const sel = create(OpSelectorSchema, {
originalInstanceKeyid: "",
planId: plan.id,
repoGuid: reposById[plan.repo]?.guid,
});
const planPath = `/plan/${plan.id}`;
const active = isActive(planPath);
return (
<Flex
key={plan.id}
align="center"
pl={9}
pr={2}
py={1}
bg={active ? "bg.emphasized" : undefined}
_hover={{ bg: "bg.muted" }}
className="group"
>
<Box flexShrink={0} mr={2}>
<IconForResource selector={sel} />
</Box>
<Tooltip content={plan.id}>
<Box
flex="1"
minW="0"
cursor="pointer"
onClick={() => handleNav(planPath)}
userSelect="none"
>
<Text
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{plan.id}
</Text>
</Box>
</Tooltip>
<Box
opacity={0}
_groupHover={{ opacity: 1 }}
transition="opacity 0.2s"
>
<IconButton
size="xs"
variant="ghost"
onClick={async (e: React.MouseEvent) => {
e.stopPropagation();
const { AddPlanModal } =
await import("../features/plans/AddPlanModal");
showModal(<AddPlanModal template={plan} />);
onClose?.();
}}
>
<FiEdit2 />
</IconButton>
</Box>
</Flex>
);
})}
{configPlans.map((plan) => (
<SidebarPlanItem
key={plan.id}
plan={plan}
repoGuid={reposById[plan.repo]?.guid}
active={isActive(`/plan/${plan.id}`)}
onNav={handleNav}
onEdit={async (plan) => {
const { AddPlanModal } =
await import("../features/plans/AddPlanModal");
showModal(<AddPlanModal template={plan} />);
onClose?.();
}}
/>
))}
</AccordionItemContent>
</AccordionItem>
@@ -375,67 +477,21 @@ const SidebarContent = ({ onClose }: { onClose?: () => void }) => {
>
<FiPlus /> {m.app_menu_add_repo()}
</Button>
{configRepos.map((repo) => {
const repoPath = `/repo/${repo.id}`;
const active = isActive(repoPath);
return (
<Flex
key={repo.id}
align="center"
pl={9}
pr={2}
py={1}
bg={active ? "bg.emphasized" : undefined}
_hover={{ bg: "bg.muted" }}
className="group"
>
<Box flexShrink={0} mr={2}>
<IconForResource
selector={create(OpSelectorSchema, {
instanceId: config.instance,
repoGuid: repo.guid,
})}
/>
</Box>
<Tooltip content={repo.id}>
<Box
flex="1"
minW="0"
cursor="pointer"
onClick={() => handleNav(repoPath)}
userSelect="none"
>
<Text
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{repo.id}
</Text>
</Box>
</Tooltip>
<Box
opacity={0}
_groupHover={{ opacity: 1 }}
transition="opacity 0.2s"
>
<IconButton
size="xs"
variant="ghost"
onClick={async (e: React.MouseEvent) => {
e.stopPropagation();
const { AddRepoModal } =
await import("../features/repositories/AddRepoModal");
showModal(<AddRepoModal template={repo} />);
onClose?.();
}}
>
<FiEdit2 />
</IconButton>
</Box>
</Flex>
);
})}
{configRepos.map((repo) => (
<SidebarRepoItem
key={repo.id}
repo={repo}
instanceId={config.instance}
active={isActive(`/repo/${repo.id}`)}
onNav={handleNav}
onEdit={async (repo) => {
const { AddRepoModal } =
await import("../features/repositories/AddRepoModal");
showModal(<AddRepoModal template={repo} />);
onClose?.();
}}
/>
))}
</AccordionItemContent>
</AccordionItem>
@@ -779,37 +835,12 @@ const AuthenticationBoundary = ({
return <>{children}</>;
};
const IconForResource = ({ selector }: { selector: OpSelector }) => {
const [status, setStatus] = useState(OperationStatus.STATUS_UNKNOWN);
useEffect(() => {
const load = async () => {
setStatus(await getStatusForSelector(selector));
};
load();
const refresh = debounce(load, 1000, { maxWait: 10000, trailing: true });
const callback = (event?: OperationEvent, err?: Error) => {
if (!event || !event.event) return;
switch (event.event.case) {
case "createdOperations":
case "updatedOperations":
const ops = event.event.value.operations;
if (ops.find((op) => matchSelector(selector, op))) {
refresh();
}
break;
case "deletedOperations":
refresh();
break;
}
};
subscribeToOperations(callback);
return () => {
unsubscribeFromOperations(callback);
};
}, [JSON.stringify(selector)]);
return iconForStatus(status);
};
const IconForResource = React.memo(
({ selector }: { selector: OpSelector }) => {
const status = useResourceStatus(selector);
return iconForStatus(status);
},
);
const iconForStatus = (status: OperationStatus) => {
const color = colorForStatus(status);
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Box,
Flex,
@@ -277,7 +277,7 @@ export const OperationTreeView = ({
);
};
const DisplayOperationTree = ({
const DisplayOperationTree = React.memo(({
operations,
isPlanView,
onSelect,
@@ -442,6 +442,84 @@ const DisplayOperationTree = ({
setExpandedValue(Array.from([...expandedValue, ...toExpand]));
}, [operations, isPlanView]);
const renderNode = useCallback(
({ node, nodeState }: { node: OpTreeNode; nodeState: any }) =>
nodeState.isBranch ? (
<TreeViewBranchControl
cursor="pointer"
onClick={() => {
setExpandedValue((prev) =>
prev.includes(node.id)
? prev.filter((id) => id !== node.id)
: [...prev, node.id],
);
}}
>
<Box
transform={nodeState.expanded ? "rotate(90deg)" : undefined}
transition="transform 0.2s"
display="inline-flex"
alignItems="center"
justifyContent="center"
w="20px"
flexShrink={0}
>
<LuChevronRight size="14px" />
</Box>
<TreeViewBranchTrigger>
<Box
display="inline-flex"
alignItems="center"
justifyContent="center"
w="24px"
flexShrink={0}
>
<LuFolder />
</Box>
</TreeViewBranchTrigger>
<TreeViewBranchText>{node.label}</TreeViewBranchText>
<TreeViewBranchContent />
</TreeViewBranchControl>
) : (
<TreeViewItem>
{/* Spacer to match chevron width */}
<Box w="20px" flexShrink={0} />
<Box
display="inline-flex"
alignItems="center"
justifyContent="center"
w="24px"
flexShrink={0}
>
{node.icon ? node.icon : <LuFile />}
</Box>
<TreeViewItemText>
{node.backup ? (
<VStack align="start" gap="0">
<Text>
{displayTypeToString(node.backup.type)}{" "}
{formatTime(node.backup.displayTime)}
</Text>
{node.backup.subtitleComponents &&
node.backup.subtitleComponents.length > 0 && (
<Text
color="fg.muted"
fontSize="xs"
fontFamily="mono"
>
{node.backup.subtitleComponents.join(", ")}
</Text>
)}
</VStack>
) : (
node.label
)}
</TreeViewItemText>
</TreeViewItem>
),
[],
);
if (!treeCollection) return <></>;
return (
@@ -460,87 +538,11 @@ const DisplayOperationTree = ({
}}
>
<TreeViewTree>
<TreeViewNode<OpTreeNode>
render={({ node, nodeState }) =>
nodeState.isBranch ? (
<TreeViewBranchControl
cursor="pointer"
onClick={() => {
setExpandedValue((prev) =>
prev.includes(node.id)
? prev.filter((id) => id !== node.id)
: [...prev, node.id],
);
}}
>
<Box
transform={nodeState.expanded ? "rotate(90deg)" : undefined}
transition="transform 0.2s"
display="inline-flex"
alignItems="center"
justifyContent="center"
w="20px"
flexShrink={0}
>
<LuChevronRight size="14px" />
</Box>
<TreeViewBranchTrigger>
<Box
display="inline-flex"
alignItems="center"
justifyContent="center"
w="24px"
flexShrink={0}
>
<LuFolder />
</Box>
</TreeViewBranchTrigger>
<TreeViewBranchText>{node.label}</TreeViewBranchText>
<TreeViewBranchContent />
</TreeViewBranchControl>
) : (
<TreeViewItem>
{/* Spacer to match chevron width */}
<Box w="20px" flexShrink={0} />
<Box
display="inline-flex"
alignItems="center"
justifyContent="center"
w="24px"
flexShrink={0}
>
{node.icon ? node.icon : <LuFile />}
</Box>
<TreeViewItemText>
{node.backup ? (
<VStack align="start" gap="0">
<Text>
{displayTypeToString(node.backup.type)}{" "}
{formatTime(node.backup.displayTime)}
</Text>
{node.backup.subtitleComponents &&
node.backup.subtitleComponents.length > 0 && (
<Text
color="fg.muted"
fontSize="xs"
fontFamily="mono"
>
{node.backup.subtitleComponents.join(", ")}
</Text>
)}
</VStack>
) : (
node.label
)}
</TreeViewItemText>
</TreeViewItem>
)
}
/>
<TreeViewNode<OpTreeNode> render={renderNode} />
</TreeViewTree>
</TreeViewRoot>
);
};
});
const BackupView = ({ backup }: { backup?: FlowDisplayInfo }) => {
if (!backup) {
@@ -17,7 +17,7 @@ import {
AccordionItemTrigger,
AccordionRoot,
} from "../../components/ui/accordion";
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { useShowModal } from "../../components/common/ModalManager";
import {
CommandPrefix_CPUNiceLevel,
@@ -108,6 +108,7 @@ interface SftpConfigSectionProps {
onChangeIdentityFile: (path: string) => void;
port: number | null;
onChangePort: (port: number | null) => void;
knownHostsPath: string;
onChangeKnownHostsPath: (path: string) => void;
isWindows: boolean;
}
@@ -118,70 +119,48 @@ const SftpConfigSection = ({
onChangeIdentityFile,
port,
onChangePort,
knownHostsPath,
onChangeKnownHostsPath,
isWindows,
}: SftpConfigSectionProps) => {
// Setup Keys state
const [sftpUsername, setSftpUsername] = useState("");
const [sftpPassword, setSftpPassword] = useState("");
const [setupLoading, setSetupLoading] = useState(false);
const [generatedPublicKey, setGeneratedPublicKey] = useState<string | null>(
null,
);
const [generatedPublicKey, setGeneratedPublicKey] = useState<string | null>(null);
const [hostKeyWarning, setHostKeyWarning] = useState<string | null>(null);
const [keyCopied, setKeyCopied] = useState(false);
if (isWindows) return null;
const handleSetupKeys = async () => {
const handleGenerateKey = async () => {
setSetupLoading(true);
setGeneratedPublicKey(null);
setHostKeyWarning(null);
try {
if (!uri) return;
// Simple parse of URI for host/port if not fully robust
let host = "";
// Parse host and port from the SFTP URI
const authority = uri.replace("sftp:", "").split("/")[0];
const hostPart = authority.includes("@") ? authority.split("@")[1] : authority;
let host = hostPart;
let defaultPort = "22";
const uriParts = uri.replace("sftp:", "").split("/");
const authority = uriParts[0];
let hostPart = authority;
if (authority.includes("@")) {
setSftpUsername(authority.split("@")[0]);
hostPart = authority.split("@")[1];
}
if (hostPart.includes(":")) {
host = hostPart.split(":")[0];
defaultPort = hostPart.split(":")[1];
} else {
host = hostPart;
[host, defaultPort] = hostPart.split(":");
}
// Override from manual input if username is set there
const username = sftpUsername || uri.match(/([^@]+)@/)?.[1] || "";
const res = await backrestService.setupSftp({
host: host,
host,
port: port ? port.toString() : defaultPort,
username: username,
password: sftpPassword || undefined,
username: "",
});
if (res.error) {
throw new Error(res.error);
}
onChangeIdentityFile(res.keyPath);
onChangeKnownHostsPath(res.knownHostsPath);
if (res.publicKey) {
setGeneratedPublicKey(res.publicKey);
}
alerts.success(
"Created SSH keypair at " +
res.keyPath +
" and updated known hosts file at " +
res.knownHostsPath,
);
alerts.success(
"Updated restic flags to use the SSH keypair and known hosts file.",
);
if (res.error) {
setHostKeyWarning(res.error);
}
alerts.success("Generated SSH keypair at " + res.keyPath);
} catch (e: any) {
alerts.error(formatErrorAlert(e, "SFTP Setup Failed"));
} finally {
@@ -195,34 +174,22 @@ const SftpConfigSection = ({
<AccordionRoot collapsible variant="enclosed">
<AccordionItem value="bootstrap">
<AccordionItemTrigger>
Bootstrap SSH Key (Optional)
Setup SSH Key (Optional)
</AccordionItemTrigger>
<AccordionItemContent>
<Stack gap={3} p={2}>
<CText fontSize="sm">
Enter your SSH credentials here. When you click "Setup Keys",
backrest will generate an SSH key pair.
Click "Generate Key" to create an SSH key pair for this host.
Backrest will attempt to scan the host key into known_hosts automatically.
You will then need to add the generated public key to{" "}
<Code>~/.ssh/authorized_keys</Code> on the remote server.
</CText>
<Field label="SSH Username">
<Input
placeholder="user"
value={sftpUsername}
onChange={(e) => setSftpUsername(e.target.value)}
/>
</Field>
<Field label="SSH Password">
<PasswordInput
placeholder="password (optional)"
value={sftpPassword}
onChange={(e) => setSftpPassword(e.target.value)}
/>
</Field>
<Button
size="sm"
onClick={handleSetupKeys}
onClick={handleGenerateKey}
loading={setupLoading}
>
Setup Keys
Generate Key
</Button>
</Stack>
</AccordionItemContent>
@@ -237,8 +204,7 @@ const SftpConfigSection = ({
Key Generated Successfully!
</CText>
<CText fontSize="sm">
Please add the following public key to your server's{" "}
<Code>~/.ssh/authorized_keys</Code> file:
Add the following public key to <Code>~/.ssh/authorized_keys</Code> on the remote server:
</CText>
<Box position="relative">
<Code
@@ -254,13 +220,22 @@ const SftpConfigSection = ({
size="xs"
onClick={() => {
navigator.clipboard.writeText(generatedPublicKey || "");
alerts.success("Key copied to clipboard");
setKeyCopied(true);
setTimeout(() => setKeyCopied(false), 2000);
}}
colorPalette={keyCopied ? "green" : undefined}
>
Copy
{keyCopied ? "Copied!" : "Copy"}
</Button>
</Box>
</Box>
{hostKeyWarning && (
<Box p={3} borderWidth={1} borderRadius="md" borderColor="yellow.400" bg="yellow.subtle">
<CText fontSize="sm" color="yellow.700">
<strong>Host key scan failed:</strong> {hostKeyWarning}
</CText>
</Box>
)}
</Stack>
</Box>
)}
@@ -288,6 +263,17 @@ const SftpConfigSection = ({
defaultValue={"22"}
/>
</Field>
<Field
label="Known Hosts File"
helperText="Optional: Path to a known_hosts file for host key verification. Populated automatically by Setup Keys."
>
<Input
placeholder="/home/user/.ssh/known_hosts"
value={knownHostsPath}
onChange={(e) => onChangeKnownHostsPath(e.target.value)}
/>
</Field>
</Stack>
);
};
@@ -304,12 +290,14 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => {
: toJson(RepoSchema, repoDefaults, { alwaysEmitImplicit: true }),
);
// SFTP specific state
// SFTP specific state
const [sftpIdentityFile, setSftpIdentityFile] = useState("");
const [sftpPort, setSftpPort] = useState<number | null>(null);
const [sftpKnownHostsPath, setSftpKnownHostsPath] = useState("");
// Ref to read current flags without making them a useEffect dependency
const flagsRef = useRef<string[]>([]);
const [confirmation, setConfirmation] = useState<ConfirmationState>({
open: false,
title: "",
@@ -323,11 +311,30 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => {
? toJson(RepoSchema, template, { alwaysEmitImplicit: true })
: toJson(RepoSchema, repoDefaults, { alwaysEmitImplicit: true }),
);
// Reset SFTP fields when template changes (or is null)
if (!template) {
setSftpIdentityFile("");
setSftpPort(null);
setSftpKnownHostsPath("");
setSftpIdentityFile("");
setSftpPort(null);
setSftpKnownHostsPath("");
if (template?.uri?.startsWith("sftp:")) {
// Populate SFTP fields by parsing the existing sftp.args flag
const sftpArgsFlag = (template.flags || []).find(
(f) => f.includes("sftp.args") || f.includes("sftp.command"),
);
if (sftpArgsFlag) {
const argsMatch = sftpArgsFlag.match(/sftp\.args=['"]?(.+?)['"]?\s*$/);
if (argsMatch) {
const argsStr = argsMatch[1].replace(/^'|'$/g, "");
const identityMatch = argsStr.match(/-i\s+["']?([^\s"']+)["']?/);
if (identityMatch) setSftpIdentityFile(identityMatch[1]);
const portMatch = argsStr.match(/-p\s+(\d+)/);
if (portMatch) setSftpPort(parseInt(portMatch[1], 10));
const knownHostsMatch = argsStr.match(
/-oUserKnownHostsFile=["']?([^\s"']+)["']?/,
);
if (knownHostsMatch) setSftpKnownHostsPath(knownHostsMatch[1]);
}
}
}
}, [template]);
@@ -353,49 +360,45 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => {
return curr;
};
// Logic to update flags based on SFTP inputs
useEffect(() => {
// If we are editing, we don't touch the flags. The user can edit them manually.
if (template) {
return;
}
// Keep flagsRef in sync with latest formData.flags so the SFTP effect can
// read the current value without flags being a reactive dependency.
flagsRef.current = (formData.flags as string[]) || [];
// Keep sftp.args flag in sync with the SFTP config fields.
useEffect(() => {
const uri = getField(["uri"]);
if (!uri?.startsWith("sftp:")) {
return;
}
const currentFlags = getField(["flags"]) || [];
// Read flags via ref so this effect does not re-run whenever the user
// edits the flags list (which would immediately erase empty rows).
const currentFlags = flagsRef.current;
const newFlags = currentFlags.filter(
(f: string) =>
f && !f.includes("sftp.args") && !f.includes("sftp.command"),
);
// Always include -oBatchMode=yes; quote paths to handle spaces.
let sftpArgs = "-oBatchMode=yes";
let argsChanged = false;
if (sftpIdentityFile) {
let cleanPath = sftpIdentityFile;
if (cleanPath.startsWith("@")) {
cleanPath = cleanPath.substring(1);
}
sftpArgs += ` -i ${cleanPath}`;
argsChanged = true;
sftpArgs += ` -i "${cleanPath}"`;
}
if (sftpPort && sftpPort !== 0 && sftpPort !== 22) {
sftpArgs += ` -p ${sftpPort}`;
argsChanged = true;
}
if (sftpKnownHostsPath) {
sftpArgs += ` -oUserKnownHostsFile=${sftpKnownHostsPath}`;
argsChanged = true;
sftpArgs += ` -oUserKnownHostsFile="${sftpKnownHostsPath}"`;
}
if (argsChanged) {
newFlags.push(`--option=sftp.args='${sftpArgs}'`);
}
newFlags.push(`--option=sftp.args='${sftpArgs}'`);
const sortedCurrent = [...currentFlags].sort();
const sortedNew = [...newFlags].sort();
@@ -407,8 +410,9 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => {
getField(["uri"]),
sftpIdentityFile,
sftpPort,
template,
getField(["flags"]),
sftpKnownHostsPath,
// flags intentionally omitted: flagsRef avoids a circular dep where any
// user edit to flags would re-trigger the effect and erase empty rows.
]);
if (!config) return null;
@@ -784,13 +788,14 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => {
</Field>
{/* SFTP Specific Fields */}
{getField(["uri"])?.startsWith("sftp:") && !template && (
{getField(["uri"])?.startsWith("sftp:") && (
<SftpConfigSection
uri={getField(["uri"])}
identityFile={sftpIdentityFile}
onChangeIdentityFile={setSftpIdentityFile}
port={sftpPort}
onChangePort={setSftpPort}
knownHostsPath={sftpKnownHostsPath}
onChangeKnownHostsPath={setSftpKnownHostsPath}
isWindows={isWindows}
/>
@@ -270,7 +270,6 @@ export const SnapshotBrowser = ({
cursor="pointer"
onClick={(e: any) => {
e.stopPropagation();
console.log("SnapshotBrowser onClick: " + node.key);
if (loadingKeys.has(node.key)) return;
const newExpanded = new Set(expandedKeys);
if (newExpanded.has(node.key)) {