diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index 6035a95a..134a033e 100644 --- a/.github/workflows/reusable-release.yml +++ b/.github/workflows/reusable-release.yml @@ -79,7 +79,7 @@ jobs: installer: name: Windows installers needs: [goreleaser] - runs-on: windows-2022 + runs-on: windows-latest permissions: contents: write diff --git a/.goreleaser.yaml b/.goreleaser.yaml index ac4d1ae8..5f42b0e6 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cf5d798..a99fcb1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/build/windows/installer.iss b/build/windows/installer.iss index 831a849d..03f7763c 100644 --- a/build/windows/installer.iss +++ b/build/windows/installer.iss @@ -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; diff --git a/internal/api/backresthandler.go b/internal/api/backresthandler.go index 22f3c636..496794f5 100644 --- a/internal/api/backresthandler.go +++ b/internal/api/backresthandler.go @@ -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 } diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go index 14ab07f3..403330fb 100644 --- a/internal/auth/middleware.go +++ b/internal/auth/middleware.go @@ -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) diff --git a/internal/config/migrations/003relativescheduling.go b/internal/config/migrations/003relativescheduling.go index 574ea018..546368fc 100644 --- a/internal/config/migrations/003relativescheduling.go +++ b/internal/config/migrations/003relativescheduling.go @@ -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 } diff --git a/internal/config/migrations/004repoguid.go b/internal/config/migrations/004repoguid.go index b55f2f0c..bd9aded3 100644 --- a/internal/config/migrations/004repoguid.go +++ b/internal/config/migrations/004repoguid.go @@ -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 } diff --git a/internal/config/migrations/005checkrepopasswords.go b/internal/config/migrations/005checkrepopasswords.go new file mode 100644 index 00000000..bd0f3fc5 --- /dev/null +++ b/internal/config/migrations/005checkrepopasswords.go @@ -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()) +} diff --git a/internal/config/migrations/005checkrepopasswords_test.go b/internal/config/migrations/005checkrepopasswords_test.go new file mode 100644 index 00000000..e8b31825 --- /dev/null +++ b/internal/config/migrations/005checkrepopasswords_test.go @@ -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) + } + }) + } +} diff --git a/internal/config/migrations/migrations.go b/internal/config/migrations/migrations.go index 89a6fcf3..41a63b86 100644 --- a/internal/config/migrations/migrations.go +++ b/internal/config/migrations/migrations.go @@ -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 } diff --git a/internal/orchestrator/repo/repo.go b/internal/orchestrator/repo/repo.go index fbc303c5..82b2355e 100644 --- a/internal/orchestrator/repo/repo.go +++ b/internal/orchestrator/repo/repo.go @@ -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))) diff --git a/internal/orchestrator/repo/repo_test.go b/internal/orchestrator/repo/repo_test.go index 35b03bab..42ccbe8a 100644 --- a/internal/orchestrator/repo/repo_test.go +++ b/internal/orchestrator/repo/repo_test.go @@ -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() diff --git a/internal/orchestrator/tasks/taskforget.go b/internal/orchestrator/tasks/taskforget.go index 432710a0..4c3fac13 100644 --- a/internal/orchestrator/tasks/taskforget.go +++ b/internal/orchestrator/tasks/taskforget.go @@ -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, }, diff --git a/webui/package-lock.json b/webui/package-lock.json index f7df3a40..a25abf93 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -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", diff --git a/webui/src/api/resourceStatus.tsx b/webui/src/api/resourceStatus.tsx new file mode 100644 index 00000000..31ca49ec --- /dev/null +++ b/webui/src/api/resourceStatus.tsx @@ -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(); +const statuses = new Map(); +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(); + +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( + () => 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; +}; diff --git a/webui/src/app/App.tsx b/webui/src/app/App.tsx index 48f209fd..d419adfc 100644 --- a/webui/src/app/App.tsx +++ b/webui/src/app/App.tsx @@ -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 ( + + + + + + onNav(planPath)} + userSelect="none" + > + + {plan.id} + + + + + { + e.stopPropagation(); + onEdit(plan); + }} + > + + + + + ); + }, +); + +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 ( + + + + + + onNav(repoPath)} + userSelect="none" + > + + {repo.id} + + + + + { + e.stopPropagation(); + onEdit(repo); + }} + > + + + + + ); + }, +); + 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 */} void }) => { > {m.app_menu_add_plan()} - {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 ( - - - - - - handleNav(planPath)} - userSelect="none" - > - - {plan.id} - - - - - { - e.stopPropagation(); - const { AddPlanModal } = - await import("../features/plans/AddPlanModal"); - showModal(); - onClose?.(); - }} - > - - - - - ); - })} + {configPlans.map((plan) => ( + { + const { AddPlanModal } = + await import("../features/plans/AddPlanModal"); + showModal(); + onClose?.(); + }} + /> + ))} @@ -375,67 +477,21 @@ const SidebarContent = ({ onClose }: { onClose?: () => void }) => { > {m.app_menu_add_repo()} - {configRepos.map((repo) => { - const repoPath = `/repo/${repo.id}`; - const active = isActive(repoPath); - return ( - - - - - - handleNav(repoPath)} - userSelect="none" - > - - {repo.id} - - - - - { - e.stopPropagation(); - const { AddRepoModal } = - await import("../features/repositories/AddRepoModal"); - showModal(); - onClose?.(); - }} - > - - - - - ); - })} + {configRepos.map((repo) => ( + { + const { AddRepoModal } = + await import("../features/repositories/AddRepoModal"); + showModal(); + onClose?.(); + }} + /> + ))} @@ -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); diff --git a/webui/src/features/operations/OperationTreeView.tsx b/webui/src/features/operations/OperationTreeView.tsx index b186a305..259468cc 100644 --- a/webui/src/features/operations/OperationTreeView.tsx +++ b/webui/src/features/operations/OperationTreeView.tsx @@ -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 ? ( + { + setExpandedValue((prev) => + prev.includes(node.id) + ? prev.filter((id) => id !== node.id) + : [...prev, node.id], + ); + }} + > + + + + + + + + + {node.label} + + + ) : ( + + {/* Spacer to match chevron width */} + + + {node.icon ? node.icon : } + + + {node.backup ? ( + + + {displayTypeToString(node.backup.type)}{" "} + {formatTime(node.backup.displayTime)} + + {node.backup.subtitleComponents && + node.backup.subtitleComponents.length > 0 && ( + + {node.backup.subtitleComponents.join(", ")} + + )} + + ) : ( + node.label + )} + + + ), + [], + ); + if (!treeCollection) return <>; return ( @@ -460,87 +538,11 @@ const DisplayOperationTree = ({ }} > - - render={({ node, nodeState }) => - nodeState.isBranch ? ( - { - setExpandedValue((prev) => - prev.includes(node.id) - ? prev.filter((id) => id !== node.id) - : [...prev, node.id], - ); - }} - > - - - - - - - - - {node.label} - - - ) : ( - - {/* Spacer to match chevron width */} - - - {node.icon ? node.icon : } - - - {node.backup ? ( - - - {displayTypeToString(node.backup.type)}{" "} - {formatTime(node.backup.displayTime)} - - {node.backup.subtitleComponents && - node.backup.subtitleComponents.length > 0 && ( - - {node.backup.subtitleComponents.join(", ")} - - )} - - ) : ( - node.label - )} - - - ) - } - /> + render={renderNode} /> ); -}; +}); const BackupView = ({ backup }: { backup?: FlowDisplayInfo }) => { if (!backup) { diff --git a/webui/src/features/repositories/AddRepoModal.tsx b/webui/src/features/repositories/AddRepoModal.tsx index 682bef6e..39c3da98 100644 --- a/webui/src/features/repositories/AddRepoModal.tsx +++ b/webui/src/features/repositories/AddRepoModal.tsx @@ -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( - null, - ); + const [generatedPublicKey, setGeneratedPublicKey] = useState(null); + const [hostKeyWarning, setHostKeyWarning] = useState(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 = ({ - Bootstrap SSH Key (Optional) + Setup SSH Key (Optional) - 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{" "} + ~/.ssh/authorized_keys on the remote server. - - setSftpUsername(e.target.value)} - /> - - - setSftpPassword(e.target.value)} - /> - @@ -237,8 +204,7 @@ const SftpConfigSection = ({ Key Generated Successfully! - Please add the following public key to your server's{" "} - ~/.ssh/authorized_keys file: + Add the following public key to ~/.ssh/authorized_keys on the remote server: { navigator.clipboard.writeText(generatedPublicKey || ""); - alerts.success("Key copied to clipboard"); + setKeyCopied(true); + setTimeout(() => setKeyCopied(false), 2000); }} + colorPalette={keyCopied ? "green" : undefined} > - Copy + {keyCopied ? "Copied!" : "Copy"} + {hostKeyWarning && ( + + + Host key scan failed: {hostKeyWarning} + + + )} )} @@ -288,6 +263,17 @@ const SftpConfigSection = ({ defaultValue={"22"} /> + + + onChangeKnownHostsPath(e.target.value)} + /> + ); }; @@ -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(null); const [sftpKnownHostsPath, setSftpKnownHostsPath] = useState(""); + // Ref to read current flags without making them a useEffect dependency + const flagsRef = useRef([]); + const [confirmation, setConfirmation] = useState({ 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 }) => { {/* SFTP Specific Fields */} - {getField(["uri"])?.startsWith("sftp:") && !template && ( + {getField(["uri"])?.startsWith("sftp:") && ( diff --git a/webui/src/features/repositories/SnapshotBrowser.tsx b/webui/src/features/repositories/SnapshotBrowser.tsx index 5d2c8e6f..290b62ba 100644 --- a/webui/src/features/repositories/SnapshotBrowser.tsx +++ b/webui/src/features/repositories/SnapshotBrowser.tsx @@ -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)) {