This is an automated email from the ASF dual-hosted git repository. hsluoyz pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
commit 4c622d843765a7d7cae762b175be5fafd61d3db7 Author: Yang Luo <[email protected]> AuthorDate: Fri Sep 4 11:50:35 2026 +0800 feat(sync): copy the backups to a folder, a WebDAV share or S3 --- cloudsync/detect.go | 147 +++++++++ cloudsync/detect_unix.go | 39 +++ cloudsync/detect_windows.go | 49 +++ cloudsync/folder.go | 163 ++++++++++ cloudsync/s3.go | 293 +++++++++++++++++ cloudsync/sigv4.go | 87 +++++ cloudsync/sync.go | 227 +++++++++++++ cloudsync/target.go | 212 +++++++++++++ cloudsync/webdav.go | 356 +++++++++++++++++++++ controllers/backup.go | 22 +- controllers/cloud_sync.go | 112 +++++++ object/backup.go | 24 +- object/cloud_sync.go | 387 +++++++++++++++++++++++ object/setting.go | 16 + routers/router.go | 4 + web/src/backend/BackupBackend.ts | 6 +- web/src/backend/CloudSyncBackend.ts | 38 +++ web/src/components/settings/backup-panel.tsx | 46 ++- web/src/components/settings/cloud-sync-panel.tsx | 298 +++++++++++++++++ web/src/locales/en/data.json | 45 ++- web/src/locales/zh/data.json | 45 ++- web/src/pages/SettingPage.tsx | 8 +- web/src/types.ts | 72 +++++ 23 files changed, 2678 insertions(+), 18 deletions(-) diff --git a/cloudsync/detect.go b/cloudsync/detect.go new file mode 100644 index 0000000..fa93af5 --- /dev/null +++ b/cloudsync/detect.go @@ -0,0 +1,147 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cloudsync + +import ( + "os" + "path/filepath" + "runtime" + "sort" + "strings" +) + +// folderName is the subdirectory Gateway makes inside whatever folder it is +// pointed at, so that a shared Dropbox never ends up with loose snapshots in +// its root. +const folderName = "Casbin Gateway" + +// Folder is a directory on this machine that something already syncs, offered +// as a choice so that nobody has to find out where their OneDrive lives. +type Folder struct { + Name string `json:"name"` + Path string `json:"path"` + // Suggested is Path with Gateway's own subdirectory on the end, which is + // what picking the folder fills the field with. + Suggested string `json:"suggested"` +} + +// candidate is one place a client is known to sync, relative to the home +// directory unless it starts at the root. +type candidate struct { + name string + path string +} + +// DetectFolders finds the synced folders this machine actually has. It only +// reports directories that exist: an empty list means the clients are not +// installed here, not that the service is unsupported. +func DetectFolders() []Folder { + home, err := os.UserHomeDir() + if err != nil { + home = "" + } + + folders := []Folder{} + seen := map[string]bool{} + add := func(name string, path string) { + path = ExpandPath(path) + if path == "" || seen[strings.ToLower(path)] { + return + } + + info, err := os.Stat(path) + if err != nil || !info.IsDir() { + return + } + + seen[strings.ToLower(path)] = true + folders = append(folders, Folder{Name: name, Path: path, Suggested: filepath.Join(path, folderName)}) + } + + // The clients that name their own folder in the environment are asked + // first: OneDrive for Business puts the tenant name in the path, and + // guessing it is hopeless. + for _, key := range []string{"OneDrive", "OneDriveConsumer", "OneDriveCommercial"} { + if value := os.Getenv(key); value != "" { + // Named after the folder itself: a machine signed into a personal + // and a work account has two of these, and "OneDrive" twice over + // tells nobody which is which. + add(filepath.Base(ExpandPath(value)), value) + } + } + + for _, item := range homeCandidates() { + add(item.name, filepath.Join(home, item.path)) + } + for _, item := range mountCandidates() { + add(item.name, item.path) + } + + return folders +} + +// homeCandidates is where the desktop clients put their folder. The iCloud +// path is the only odd one: on macOS the Drive is a directory inside the +// container Apple syncs, and on Windows the client makes a plain one. +func homeCandidates() []candidate { + items := []candidate{ + {"Dropbox", "Dropbox"}, + {"OneDrive", "OneDrive"}, + {"Google Drive", filepath.Join("Google Drive", "My Drive")}, + {"Google Drive", "Google Drive"}, + {"Nextcloud", "Nextcloud"}, + {"ownCloud", "ownCloud"}, + {"Syncthing", "Sync"}, + {"Seafile", "Seafile"}, + {"Nutstore", "Nutstore"}, + } + + switch runtime.GOOS { + case "darwin": + items = append(items, candidate{"iCloud Drive", filepath.Join("Library", "Mobile Documents", "com~apple~CloudDocs")}) + case "windows": + items = append(items, candidate{"iCloud Drive", "iCloudDrive"}) + } + return items +} + +func mountedUnder(dir string) []candidate { + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + + items := []candidate{} + for _, entry := range entries { + if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { + continue + } + items = append(items, candidate{entry.Name(), filepath.Join(dir, entry.Name())}) + } + + sort.Slice(items, func(i, j int) bool { return items[i].name < items[j].name }) + return items +} + +// defaultFolderExample is the placeholder the folder field shows. The folders +// this machine really has are offered next to the field; this is only what an +// empty one looks like, so it is guessed rather than searched for. +func defaultFolderExample() string { + home, err := os.UserHomeDir() + if err != nil { + home = "~" + } + return filepath.Join(home, "Dropbox", folderName) +} diff --git a/cloudsync/detect_unix.go b/cloudsync/detect_unix.go new file mode 100644 index 0000000..ef816e2 --- /dev/null +++ b/cloudsync/detect_unix.go @@ -0,0 +1,39 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !windows + +package cloudsync + +import ( + "os" + "path/filepath" + "runtime" +) + +// mountCandidates is where a NAS share ends up once it is mounted. Every entry +// is one share, so they are listed rather than guessed at. +func mountCandidates() []candidate { + if runtime.GOOS == "darwin" { + return mountedUnder("/Volumes") + } + + items := mountedUnder("/mnt") + items = append(items, mountedUnder("/media")...) + if user := os.Getenv("USER"); user != "" { + items = append(items, mountedUnder(filepath.Join("/media", user))...) + items = append(items, mountedUnder(filepath.Join("/run/user", user, "gvfs"))...) + } + return items +} diff --git a/cloudsync/detect_windows.go b/cloudsync/detect_windows.go new file mode 100644 index 0000000..3f49d6f --- /dev/null +++ b/cloudsync/detect_windows.go @@ -0,0 +1,49 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +package cloudsync + +import ( + "golang.org/x/sys/windows" +) + +// mountCandidates is the mapped network drives, which is what a NAS looks like +// on Windows. The letters are read from the drive mask rather than by opening +// each one: a disconnected mapping answers a stat only after its timeout. +func mountCandidates() []candidate { + mask, err := windows.GetLogicalDrives() + if err != nil { + return nil + } + + items := []candidate{} + for letter := 'A'; letter <= 'Z'; letter++ { + if mask&(1<<uint(letter-'A')) == 0 { + continue + } + + root := string(letter) + `:\` + path, err := windows.UTF16PtrFromString(root) + if err != nil { + continue + } + if windows.GetDriveType(path) != windows.DRIVE_REMOTE { + continue + } + items = append(items, candidate{string(letter) + ":", root}) + } + return items +} diff --git a/cloudsync/folder.go b/cloudsync/folder.go new file mode 100644 index 0000000..fc34267 --- /dev/null +++ b/cloudsync/folder.go @@ -0,0 +1,163 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cloudsync + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +// KindFolder is a directory on this machine. It is how Dropbox, OneDrive, +// iCloud Drive, Google Drive and a mounted NAS share are all supported without +// a line of code for any of them: their desktop clients already sync a folder, +// so writing the snapshots into it is the whole integration. +const KindFolder = "folder" + +func init() { + Register(&Kind{ + Name: KindFolder, + DisplayName: "Folder", + Description: "Folder description", + Fields: []Field{ + { + Name: "path", + Label: "Folder path", + Type: FieldText, + Required: true, + Placeholder: defaultFolderExample(), + Hint: "Folder path hint", + }, + }, + New: newFolderTarget, + }) +} + +type folderTarget struct { + dir string +} + +func newFolderTarget(config Config) (Target, error) { + dir := ExpandPath(config.Option("path")) + if dir == "" { + return nil, fmt.Errorf("the folder has no path") + } + return &folderTarget{dir: dir}, nil +} + +func (target *folderTarget) Describe() string { + return target.dir +} + +func (target *folderTarget) List(ctx context.Context) ([]File, error) { + entries, err := os.ReadDir(target.dir) + if err != nil { + // A folder that is not there yet is an empty one: it is created by the + // first file written into it, and a reachable target with nothing in it + // is not an error. + if os.IsNotExist(err) { + return []File{}, nil + } + return nil, err + } + + files := []File{} + for _, entry := range entries { + if entry.IsDir() { + continue + } + + info, err := entry.Info() + if err != nil { + continue + } + files = append(files, File{Name: entry.Name(), Size: info.Size(), ModifiedTime: info.ModTime()}) + } + return files, nil +} + +func (target *folderTarget) Read(ctx context.Context, name string) ([]byte, error) { + path, err := target.path(name) + if err != nil { + return nil, err + } + return os.ReadFile(path) +} + +func (target *folderTarget) Write(ctx context.Context, name string, data []byte) error { + path, err := target.path(name) + if err != nil { + return err + } + return writeFile(path, data) +} + +func (target *folderTarget) Remove(ctx context.Context, name string) error { + path, err := target.path(name) + if err != nil { + return err + } + + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +// path keeps a name a name. Nothing outside the folder is read, written or +// deleted, whatever the target listed or the settings row was made to say. +func (target *folderTarget) path(name string) (string, error) { + if name == "" || name != filepath.Base(name) || strings.ContainsAny(name, `/\`) { + return "", fmt.Errorf("this is not a file name: %s", name) + } + return filepath.Join(target.dir, name), nil +} + +// ExpandPath resolves the "~" and the environment variables a path typed by +// hand carries, so "%OneDrive%\Backups" and "~/Dropbox" both land where the +// person meant. +func ExpandPath(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "" + } + + path = os.ExpandEnv(path) + path = expandWindowsEnv(path) + if path == "~" || strings.HasPrefix(path, "~/") || strings.HasPrefix(path, `~\`) { + if home, err := os.UserHomeDir(); err == nil { + path = filepath.Join(home, strings.TrimPrefix(path[1:], string(filepath.Separator))) + } + } + return filepath.Clean(path) +} + +// winEnvPattern is the %NAME% spelling of an environment variable, which +// os.ExpandEnv leaves alone and which is the one people paste on Windows. +var winEnvPattern = regexp.MustCompile(`%([A-Za-z_][A-Za-z0-9_]*)%`) + +// expandWindowsEnv fills in the variables it knows and leaves the rest of the +// path exactly as it was typed. +func expandWindowsEnv(path string) string { + return winEnvPattern.ReplaceAllStringFunc(path, func(match string) string { + if value, found := os.LookupEnv(strings.Trim(match, "%")); found { + return value + } + return match + }) +} diff --git a/cloudsync/s3.go b/cloudsync/s3.go new file mode 100644 index 0000000..f604cf0 --- /dev/null +++ b/cloudsync/s3.go @@ -0,0 +1,293 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cloudsync + +import ( + "bytes" + "context" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "time" +) + +// KindS3 is object storage with the S3 API, which is most of it: S3 itself, +// Cloudflare R2, Backblaze B2, MinIO, Aliyun OSS and Tencent COS. +const KindS3 = "s3" + +const defaultS3Region = "us-east-1" + +func init() { + Register(&Kind{ + Name: KindS3, + DisplayName: "S3", + Description: "S3 description", + Fields: []Field{ + {Name: "bucket", Label: "Bucket", Type: FieldText, Required: true}, + {Name: "region", Label: "Region", Type: FieldText, Placeholder: defaultS3Region}, + {Name: "endpoint", Label: "Endpoint", Type: FieldText, Placeholder: "https://s3.us-east-1.amazonaws.com", Hint: "Endpoint hint"}, + {Name: "prefix", Label: "Key prefix", Type: FieldText, Placeholder: "casbin-gateway/", Hint: "Key prefix hint"}, + {Name: "accessKeyId", Label: "Access key ID", Type: FieldText, Required: true}, + {Name: "secretAccessKey", Label: "Secret access key", Type: FieldSecret, Required: true}, + {Name: "pathStyle", Label: "Path-style addressing", Type: FieldSwitch, Hint: "Path-style addressing hint"}, + }, + New: newS3Target, + }) +} + +type s3Target struct { + bucket string + region string + prefix string + accessKey string + secretKey string + // endpoint carries the scheme and the host only, the bucket never. + endpoint *url.URL + pathStyle bool + client *http.Client +} + +func newS3Target(config Config) (Target, error) { + region := config.OptionDefault("region", defaultS3Region) + + raw := config.OptionDefault("endpoint", fmt.Sprintf("https://s3.%s.amazonaws.com", region)) + if !strings.Contains(raw, "://") { + raw = "https://" + raw + } + + endpoint, err := url.Parse(raw) + if err != nil { + return nil, err + } + if endpoint.Host == "" { + return nil, fmt.Errorf("this is not an S3 endpoint: %s", raw) + } + + // Everything that is not AWS itself is addressed by path: MinIO and most + // NAS gateways never got the wildcard certificate a bucket subdomain needs. + pathStyle, set := config.OptionBool("pathStyle") + if !set { + pathStyle = !strings.HasSuffix(endpoint.Hostname(), "amazonaws.com") + } + + prefix := strings.Trim(strings.ReplaceAll(config.Option("prefix"), "\\", "/"), "/") + if prefix != "" { + prefix += "/" + } + + return &s3Target{ + bucket: config.Option("bucket"), + region: region, + prefix: prefix, + accessKey: config.Option("accessKeyId"), + secretKey: config.Option("secretAccessKey"), + endpoint: &url.URL{Scheme: endpoint.Scheme, Host: endpoint.Host}, + pathStyle: pathStyle, + client: config.client(), + }, nil +} + +func (target *s3Target) Describe() string { + return fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(target.endpoint.String(), "/"), target.bucket, target.prefix) +} + +func (target *s3Target) List(ctx context.Context) ([]File, error) { + files := []File{} + token := "" + + for { + query := map[string]string{"list-type": "2", "max-keys": "1000"} + if target.prefix != "" { + query["prefix"] = target.prefix + } + if token != "" { + query["continuation-token"] = token + } + + body, status, err := target.do(ctx, http.MethodGet, "", query, nil) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, statusError(status, body) + } + + result := &s3ListResult{} + if err := xml.Unmarshal(body, result); err != nil { + return nil, err + } + + for _, item := range result.Contents { + name := strings.TrimPrefix(item.Key, target.prefix) + // A key with a slash left in it lives in a folder of its own, which + // is somebody else's file rather than one of ours. + if name == "" || strings.Contains(name, "/") { + continue + } + + file := File{Name: name, Size: item.Size} + if at, err := time.Parse(time.RFC3339, item.LastModified); err == nil { + file.ModifiedTime = at + } + files = append(files, file) + } + + if !result.IsTruncated || result.NextContinuationToken == "" { + return files, nil + } + token = result.NextContinuationToken + } +} + +func (target *s3Target) Read(ctx context.Context, name string) ([]byte, error) { + body, status, err := target.do(ctx, http.MethodGet, name, nil, nil) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, statusError(status, body) + } + return body, nil +} + +func (target *s3Target) Write(ctx context.Context, name string, data []byte) error { + body, status, err := target.do(ctx, http.MethodPut, name, nil, data) + if err != nil { + return err + } + if status/100 != 2 { + return statusError(status, body) + } + return nil +} + +func (target *s3Target) Remove(ctx context.Context, name string) error { + body, status, err := target.do(ctx, http.MethodDelete, name, nil, nil) + if err != nil { + return err + } + if status/100 != 2 && status != http.StatusNotFound { + return statusError(status, body) + } + return nil +} + +func (target *s3Target) do(ctx context.Context, method string, name string, query map[string]string, payload []byte) ([]byte, int, error) { + key, err := target.key(name) + if err != nil { + return nil, 0, err + } + + requestUrl := *target.endpoint + if target.pathStyle { + requestUrl.Path = "/" + target.bucket + "/" + key + } else { + requestUrl.Host = target.bucket + "." + target.endpoint.Host + requestUrl.Path = "/" + key + } + requestUrl.RawQuery = canonicalQuery(query) + + var body io.Reader + if payload != nil { + body = bytes.NewReader(payload) + } + + req, err := http.NewRequestWithContext(ctx, method, requestUrl.String(), body) + if err != nil { + return nil, 0, err + } + if payload != nil { + req.ContentLength = int64(len(payload)) + req.Header.Set("Content-Type", "application/json") + } + + target.sign(req, requestUrl.Path, requestUrl.RawQuery, payload) + + res, err := target.client.Do(req) + if err != nil { + return nil, 0, err + } + defer res.Body.Close() + + answer, err := io.ReadAll(res.Body) + if err != nil { + return nil, res.StatusCode, err + } + return answer, res.StatusCode, nil +} + +// key is where one file lives in the bucket. A listing arrives over the +// network, so a name that is not a name never becomes a key. The empty name is +// the bucket itself, which is what a listing asks for. +func (target *s3Target) key(name string) (string, error) { + if name == "" { + return "", nil + } + if strings.ContainsAny(name, "/\\") { + return "", fmt.Errorf("this is not a file name: %s", name) + } + return target.prefix + name, nil +} + +type s3ListResult struct { + IsTruncated bool `xml:"IsTruncated"` + NextContinuationToken string `xml:"NextContinuationToken"` + Contents []struct { + Key string `xml:"Key"` + Size int64 `xml:"Size"` + LastModified string `xml:"LastModified"` + } `xml:"Contents"` +} + +func canonicalQuery(query map[string]string) string { + if len(query) == 0 { + return "" + } + + keys := make([]string, 0, len(query)) + for key := range query { + keys = append(keys, key) + } + sort.Strings(keys) + + pairs := make([]string, 0, len(keys)) + for _, key := range keys { + pairs = append(pairs, uriEncode(key, true)+"="+uriEncode(query[key], true)) + } + return strings.Join(pairs, "&") +} + +// uriEncode is the escaping S3 signs with: everything but the unreserved set, +// and the slash only when it is part of a value rather than of a path. +func uriEncode(value string, encodeSlash bool) string { + var encoded strings.Builder + for i := 0; i < len(value); i++ { + char := value[i] + switch { + case (char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9'), + char == '-', char == '_', char == '.', char == '~': + encoded.WriteByte(char) + case char == '/' && !encodeSlash: + encoded.WriteByte(char) + default: + fmt.Fprintf(&encoded, "%%%02X", char) + } + } + return encoded.String() +} diff --git a/cloudsync/sigv4.go b/cloudsync/sigv4.go new file mode 100644 index 0000000..21287b5 --- /dev/null +++ b/cloudsync/sigv4.go @@ -0,0 +1,87 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cloudsync + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "strings" + "time" +) + +// Signature Version 4, which every S3-compatible service speaks and which is +// some eighty lines of hashing. It is written out here rather than pulled in: +// the AWS SDK is a large dependency for four requests. +const ( + sigAlgorithm = "AWS4-HMAC-SHA256" + sigService = "s3" + // The headers signed on every request. Anything else a proxy adds on the + // way out stays out of the signature. + sigHeaders = "host;x-amz-content-sha256;x-amz-date" +) + +// sign fills in the Authorization header. The path and query it is given are +// the ones the request will really carry, because the signature covers them +// exactly as they go over the wire. +func (target *s3Target) sign(req *http.Request, path string, query string, payload []byte) { + now := time.Now().UTC() + stamp := now.Format("20060102T150405Z") + day := now.Format("20060102") + + payloadHash := hashHex(payload) + req.Header.Set("X-Amz-Content-Sha256", payloadHash) + req.Header.Set("X-Amz-Date", stamp) + + canonicalRequest := strings.Join([]string{ + req.Method, + uriEncode(path, false), + query, + "host:" + req.URL.Host + "\n" + + "x-amz-content-sha256:" + payloadHash + "\n" + + "x-amz-date:" + stamp + "\n", + sigHeaders, + payloadHash, + }, "\n") + + scope := strings.Join([]string{day, target.region, sigService, "aws4_request"}, "/") + stringToSign := strings.Join([]string{ + sigAlgorithm, + stamp, + scope, + hashHex([]byte(canonicalRequest)), + }, "\n") + + key := []byte("AWS4" + target.secretKey) + for _, part := range []string{day, target.region, sigService, "aws4_request"} { + key = sign(key, part) + } + + req.Header.Set("Authorization", fmt.Sprintf("%s Credential=%s/%s, SignedHeaders=%s, Signature=%s", + sigAlgorithm, target.accessKey, scope, sigHeaders, hex.EncodeToString(sign(key, stringToSign)))) +} + +func sign(key []byte, value string) []byte { + mac := hmac.New(sha256.New, key) + mac.Write([]byte(value)) + return mac.Sum(nil) +} + +func hashHex(payload []byte) string { + sum := sha256.Sum256(payload) + return hex.EncodeToString(sum[:]) +} diff --git a/cloudsync/sync.go b/cloudsync/sync.go new file mode 100644 index 0000000..28e6e4d --- /dev/null +++ b/cloudsync/sync.go @@ -0,0 +1,227 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cloudsync + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" +) + +// Which way one run copies. Both is the useful default: a machine that only +// pushes never learns what another one backed up. +const ( + DirectionBoth = "both" + DirectionUp = "up" + DirectionDown = "down" +) + +// Options are what one run may do. +type Options struct { + // Match keeps a run to the files it understands. The directory is on disk + // and anything can drop a file in it. + Match func(name string) bool + // Retention is how many files the target keeps, the oldest dropped first + // by name. Zero keeps every file that ever landed there. + Retention int + Direction string + // MaxBytes is the largest file that is copied. A bigger one is left where + // it is rather than sent through memory. + MaxBytes int64 +} + +// Result is what one run did, and what the Settings page reports. +type Result struct { + Uploaded []string `json:"uploaded"` + Downloaded []string `json:"downloaded"` + // Removed is what the retention dropped at the target. Nothing is ever + // deleted locally: a file missing here is one this machine never took. + Removed []string `json:"removed"` + Skipped int `json:"skipped"` + // Errors are the files that failed. One bad file does not stop the run: + // the point of a sync is the other twenty that went over. + Errors []string `json:"errors"` + // Remote is what the target holds afterwards. + Remote []File `json:"remote"` +} + +func (result *Result) failed(name string, err error) { + result.Errors = append(result.Errors, fmt.Sprintf("%s: %s", name, err.Error())) +} + +func newResult() *Result { + return &Result{Uploaded: []string{}, Downloaded: []string{}, Removed: []string{}, Errors: []string{}, Remote: []File{}} +} + +// SyncDir makes a directory and a target hold the same files. Snapshots are +// immutable, so a name present on both sides is the same file on both sides +// and is never copied again, and neither side is ever asked which copy is +// newer. +func SyncDir(ctx context.Context, target Target, dir string, options Options) (*Result, error) { + result := newResult() + + remoteFiles, err := target.List(ctx) + if err != nil { + return nil, err + } + + remote := map[string]File{} + for _, file := range remoteFiles { + if options.Match == nil || options.Match(file.Name) { + remote[file.Name] = file + } else { + result.Skipped++ + } + } + + local, err := listDir(dir, options.Match) + if err != nil { + return nil, err + } + + if options.Direction != DirectionDown { + for _, name := range sortedNames(local) { + if _, there := remote[name]; there { + continue + } + + data, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + result.failed(name, err) + continue + } + if options.MaxBytes > 0 && int64(len(data)) > options.MaxBytes { + result.Skipped++ + continue + } + if err := target.Write(ctx, name, data); err != nil { + result.failed(name, err) + continue + } + + remote[name] = File{Name: name, Size: int64(len(data))} + result.Uploaded = append(result.Uploaded, name) + } + } + + if options.Direction != DirectionUp { + for _, name := range sortedNames(remote) { + if _, here := local[name]; here { + continue + } + if options.MaxBytes > 0 && remote[name].Size > options.MaxBytes { + result.Skipped++ + continue + } + + data, err := target.Read(ctx, name) + if err != nil { + result.failed(name, err) + continue + } + if err := writeFile(filepath.Join(dir, name), data); err != nil { + result.failed(name, err) + continue + } + + local[name] = int64(len(data)) + result.Downloaded = append(result.Downloaded, name) + } + } + + // Only after the pull: a file dropped for the retention has to have been + // somewhere else first. + if options.Direction != DirectionDown { + prune(ctx, target, remote, options.Retention, result) + } + + for _, name := range sortedNames(remote) { + result.Remote = append(result.Remote, remote[name]) + } + return result, nil +} + +// prune keeps the newest files at the target. The names start with the time +// they were taken, so they sort the way the contents would. +func prune(ctx context.Context, target Target, remote map[string]File, retention int, result *Result) { + if retention <= 0 || len(remote) <= retention { + return + } + + names := sortedNames(remote) + for i := 0; i < len(names)-retention; i++ { + name := names[i] + if err := target.Remove(ctx, name); err != nil { + result.failed(name, err) + continue + } + + delete(remote, name) + result.Removed = append(result.Removed, name) + } +} + +func listDir(dir string, match func(string) bool) (map[string]int64, error) { + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return map[string]int64{}, nil + } + return nil, err + } + + files := map[string]int64{} + for _, entry := range entries { + if entry.IsDir() || (match != nil && !match(entry.Name())) { + continue + } + + info, err := entry.Info() + if err != nil { + continue + } + files[entry.Name()] = info.Size() + } + return files, nil +} + +// writeFile lands a file under its own name only once it is whole, so that a +// folder somebody else's client is watching never uploads half a snapshot. +func writeFile(path string, data []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + + partial := path + ".part" + if err := os.WriteFile(partial, data, 0o600); err != nil { + return err + } + if err := os.Rename(partial, path); err != nil { + os.Remove(partial) + return err + } + return nil +} + +func sortedNames[V any](files map[string]V) []string { + names := make([]string, 0, len(files)) + for name := range files { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/cloudsync/target.go b/cloudsync/target.go new file mode 100644 index 0000000..4f1d24c --- /dev/null +++ b/cloudsync/target.go @@ -0,0 +1,212 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package cloudsync keeps a copy of Gateway's data somewhere that is not this +// machine. A target is a flat directory of named blobs and nothing more: that +// is all a snapshot needs, and it is the largest set of operations a synced +// folder, a WebDAV share and an S3 bucket all have in common. Supporting one +// more service - Dropbox, OneDrive or a NAS with an API of its own - is one +// file in this package that calls Register(), with nothing above it to change. +package cloudsync + +import ( + "context" + "fmt" + "net/http" + "sort" + "strings" + "sync" + "time" +) + +// File is one blob at the target. Snapshots are immutable and named after the +// moment they were taken, so the name is the whole identity and nothing is +// ever compared by content. +type File struct { + Name string `json:"name"` + Size int64 `json:"size"` + ModifiedTime time.Time `json:"modifiedTime"` +} + +// Target is one place a copy of the data lives. +type Target interface { + // Describe is where the copies land, in one line, for the page that says + // where they went. It carries no credentials. + Describe() string + List(ctx context.Context) ([]File, error) + Read(ctx context.Context, name string) ([]byte, error) + Write(ctx context.Context, name string, data []byte) error + Remove(ctx context.Context, name string) error +} + +// Config is what the settings row stores: a kind, and the options that kind +// asks for. The options stay untyped on purpose - a new kind brings its own +// keys and nothing outside its own file has to learn them. +type Config struct { + Kind string `json:"kind"` + Options map[string]string `json:"options"` + // HttpClient is handed in rather than built here, so every kind goes out + // through Gateway's own transport, proxy setting and all. + HttpClient *http.Client `json:"-"` +} + +func (config Config) Option(name string) string { + return strings.TrimSpace(config.Options[name]) +} + +func (config Config) OptionDefault(name string, fallback string) string { + if value := config.Option(name); value != "" { + return value + } + return fallback +} + +// OptionBool reads a switch. An option nobody has set is neither true nor +// false, which is what the third return value is for: a kind that defaults to +// true has to tell "off" apart from "not answered". +func (config Config) OptionBool(name string) (value bool, set bool) { + raw := strings.ToLower(config.Option(name)) + if raw == "" { + return false, false + } + return raw == "true" || raw == "1" || raw == "yes", true +} + +func (config Config) client() *http.Client { + if config.HttpClient != nil { + return config.HttpClient + } + return http.DefaultClient +} + +// The kinds of thing a field holds. A secret is encrypted at rest and typed +// into a password box; the rest are plain. +const ( + FieldText = "text" + FieldSecret = "secret" + FieldSwitch = "switch" +) + +// Field is one thing a kind needs to be told, described well enough for the +// Settings page to draw the form without knowing the kind exists. +type Field struct { + Name string `json:"name"` + Label string `json:"label"` + Type string `json:"type"` + Placeholder string `json:"placeholder"` + Hint string `json:"hint"` + Required bool `json:"required"` +} + +func (field Field) IsSecret() bool { + return field.Type == FieldSecret +} + +// Kind is one storage service, registered by the file that implements it. +type Kind struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + Fields []Field `json:"fields"` + // New builds a target from a configuration that has already been checked + // against Fields, so it only has to fail on what it alone can judge. + New func(config Config) (Target, error) `json:"-"` +} + +var ( + kindMutex sync.RWMutex + kinds = map[string]*Kind{} + kindOrder []string +) + +// Register adds one kind. It is called from the init() of the file that +// implements it, so linking the file in is all it takes to offer the service. +func Register(kind *Kind) { + kindMutex.Lock() + defer kindMutex.Unlock() + + if _, exists := kinds[kind.Name]; !exists { + kindOrder = append(kindOrder, kind.Name) + } + kinds[kind.Name] = kind +} + +// Kinds is every registered kind in registration order, which is what the +// Settings page builds its form from. +func Kinds() []*Kind { + kindMutex.RLock() + defer kindMutex.RUnlock() + + res := make([]*Kind, 0, len(kindOrder)) + for _, name := range kindOrder { + res = append(res, kinds[name]) + } + return res +} + +func GetKind(name string) *Kind { + kindMutex.RLock() + defer kindMutex.RUnlock() + + return kinds[strings.TrimSpace(name)] +} + +// SecretOptions is which of a kind's options are credentials, so that whoever +// stores them knows what to encrypt without keeping a list of its own. +func SecretOptions(kindName string) []string { + kind := GetKind(kindName) + if kind == nil { + return nil + } + + res := []string{} + for _, field := range kind.Fields { + if field.IsSecret() { + res = append(res, field.Name) + } + } + sort.Strings(res) + return res +} + +// New builds the target one configuration describes. +func New(config Config) (Target, error) { + kind := GetKind(config.Kind) + if kind == nil { + return nil, fmt.Errorf("unknown sync target: %s", config.Kind) + } + + for _, field := range kind.Fields { + if field.Required && config.Option(field.Name) == "" { + return nil, fmt.Errorf("%s needs %s", kind.DisplayName, field.Label) + } + } + return kind.New(config) +} + +// Check is what the "Test" button asks: whether these credentials reach that +// storage. Listing is the cheapest operation every kind has, and the only one +// that proves the target both exists and can be read. +func Check(ctx context.Context, config Config) (Target, []File, error) { + target, err := New(config) + if err != nil { + return nil, nil, err + } + + files, err := target.List(ctx) + if err != nil { + return target, nil, err + } + return target, files, nil +} diff --git a/cloudsync/webdav.go b/cloudsync/webdav.go new file mode 100644 index 0000000..20b7496 --- /dev/null +++ b/cloudsync/webdav.go @@ -0,0 +1,356 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cloudsync + +import ( + "bytes" + "context" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "path" + "strings" + "time" +) + +// KindWebdav is a WebDAV share: Nextcloud, ownCloud, Synology and QNAP, and the +// storage most other NAS firmware offers. +const KindWebdav = "webdav" + +// propfindBody asks for the one level below the collection, which is the whole +// of what a listing needs. +const propfindBody = `<?xml version="1.0" encoding="utf-8"?>` + + `<d:propfind xmlns:d="DAV:"><d:prop>` + + `<d:getcontentlength/><d:getlastmodified/><d:resourcetype/>` + + `</d:prop></d:propfind>` + +func init() { + Register(&Kind{ + Name: KindWebdav, + DisplayName: "WebDAV", + Description: "WebDAV description", + Fields: []Field{ + {Name: "url", Label: "Server URL", Type: FieldText, Required: true, Placeholder: "https://dav.example.com/dav"}, + {Name: "path", Label: "Folder on the server", Type: FieldText, Placeholder: "casbin-gateway", Hint: "Folder on the server hint"}, + {Name: "username", Label: "Username", Type: FieldText}, + {Name: "password", Label: "Password", Type: FieldSecret, Hint: "WebDAV password hint"}, + }, + New: newWebdavTarget, + }) +} + +type webdavTarget struct { + // base always ends in a slash: it names a collection, and a WebDAV server + // answers a collection asked for without one with a redirect. + base string + username string + password string + client *http.Client +} + +func newWebdavTarget(config Config) (Target, error) { + raw := config.Option("url") + if !strings.Contains(raw, "://") { + raw = "https://" + raw + } + + parsed, err := url.Parse(raw) + if err != nil { + return nil, err + } + if parsed.Host == "" { + return nil, fmt.Errorf("this is not a WebDAV URL: %s", config.Option("url")) + } + + // Assigned decoded: url.URL escapes the path itself when it prints, and a + // folder escaped here as well would arrive with its percent signs escaped. + parsed.Path = "/" + strings.Trim(path.Join(parsed.Path, cleanPath(config.Option("path"))), "/") + "/" + parsed.RawQuery, parsed.Fragment = "", "" + + return &webdavTarget{ + base: parsed.String(), + username: config.Option("username"), + password: config.Option("password"), + client: config.client(), + }, nil +} + +// cleanPath is a folder typed by hand, with the segments that would climb out +// of it dropped and a backslash taken to mean the same as a slash. +func cleanPath(raw string) string { + segments := []string{} + for _, segment := range strings.Split(strings.Trim(strings.ReplaceAll(raw, "\\", "/"), "/"), "/") { + if segment != "" && segment != "." && segment != ".." { + segments = append(segments, segment) + } + } + return strings.Join(segments, "/") +} + +func (target *webdavTarget) Describe() string { + return target.base +} + +func (target *webdavTarget) List(ctx context.Context) ([]File, error) { + req, err := target.request(ctx, "PROPFIND", target.base, []byte(propfindBody)) + if err != nil { + return nil, err + } + req.Header.Set("Depth", "1") + req.Header.Set("Content-Type", "application/xml; charset=utf-8") + + body, status, err := target.send(req) + if err != nil { + return nil, err + } + // The folder is made by the first file written into it, so one that is not + // there yet is an empty folder rather than a failure. + if status == http.StatusNotFound { + return []File{}, nil + } + if status != http.StatusMultiStatus && status != http.StatusOK { + return nil, statusError(status, body) + } + + multistatus := &davMultistatus{} + if err := xml.Unmarshal(body, multistatus); err != nil { + return nil, err + } + + basePath := target.basePath() + files := []File{} + for _, response := range multistatus.Responses { + name, isDir := response.file(basePath) + if name == "" || isDir { + continue + } + files = append(files, File{Name: name, Size: response.size(), ModifiedTime: response.modified()}) + } + return files, nil +} + +func (target *webdavTarget) Read(ctx context.Context, name string) ([]byte, error) { + req, err := target.fileRequest(ctx, http.MethodGet, name, nil) + if err != nil { + return nil, err + } + + body, status, err := target.send(req) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, statusError(status, body) + } + return body, nil +} + +func (target *webdavTarget) Write(ctx context.Context, name string, data []byte) error { + if err := target.makeCollections(ctx); err != nil { + return err + } + + req, err := target.fileRequest(ctx, http.MethodPut, name, data) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + body, status, err := target.send(req) + if err != nil { + return err + } + if status/100 != 2 { + return statusError(status, body) + } + return nil +} + +func (target *webdavTarget) Remove(ctx context.Context, name string) error { + req, err := target.fileRequest(ctx, http.MethodDelete, name, nil) + if err != nil { + return err + } + + body, status, err := target.send(req) + if err != nil { + return err + } + if status/100 != 2 && status != http.StatusNotFound { + return statusError(status, body) + } + return nil +} + +// makeCollections creates the folder, and every folder above it, the way +// WebDAV wants it: one MKCOL per level, most of which are already there. +func (target *webdavTarget) makeCollections(ctx context.Context) error { + parsed, err := url.Parse(target.base) + if err != nil { + return err + } + + segments := strings.Split(strings.Trim(parsed.Path, "/"), "/") + for i := range segments { + if segments[i] == "" { + continue + } + + collection := *parsed + collection.Path = "/" + strings.Join(segments[:i+1], "/") + "/" + req, err := target.request(ctx, "MKCOL", collection.String(), nil) + if err != nil { + return err + } + + // 405 is the collection already being there, and 403 is a server that + // will not have one made that high up. Neither stops the PUT below, + // which reports whatever is really wrong. + body, status, err := target.send(req) + if err != nil { + return err + } + if status/100 != 2 && status != http.StatusMethodNotAllowed && status != http.StatusForbidden && status != http.StatusConflict { + return statusError(status, body) + } + } + return nil +} + +func (target *webdavTarget) basePath() string { + parsed, err := url.Parse(target.base) + if err != nil { + return "/" + } + return parsed.Path +} + +func (target *webdavTarget) fileRequest(ctx context.Context, method string, name string, data []byte) (*http.Request, error) { + if name == "" || strings.ContainsAny(name, "/\\") { + return nil, fmt.Errorf("this is not a file name: %s", name) + } + return target.request(ctx, method, target.base+url.PathEscape(name), data) +} + +func (target *webdavTarget) request(ctx context.Context, method string, rawUrl string, data []byte) (*http.Request, error) { + var body io.Reader + if data != nil { + body = bytes.NewReader(data) + } + + req, err := http.NewRequestWithContext(ctx, method, rawUrl, body) + if err != nil { + return nil, err + } + if target.username != "" || target.password != "" { + req.SetBasicAuth(target.username, target.password) + } + return req, nil +} + +func (target *webdavTarget) send(req *http.Request) ([]byte, int, error) { + res, err := target.client.Do(req) + if err != nil { + return nil, 0, err + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, res.StatusCode, err + } + return body, res.StatusCode, nil +} + +// statusError keeps enough of the answer to tell a wrong password from a wrong +// path, and no more: these bodies are HTML error pages as often as not. +func statusError(status int, body []byte) error { + message := strings.TrimSpace(string(body)) + if len(message) > 200 { + message = message[:200] + "..." + } + if message == "" { + return fmt.Errorf("the server answered %d %s", status, http.StatusText(status)) + } + return fmt.Errorf("the server answered %d: %s", status, message) +} + +type davMultistatus struct { + XMLName xml.Name `xml:"DAV: multistatus"` + Responses []davResponse `xml:"DAV: response"` +} + +type davResponse struct { + Href string `xml:"DAV: href"` + Propstats []davPropstat `xml:"DAV: propstat"` +} + +type davPropstat struct { + Prop davProp `xml:"DAV: prop"` +} + +type davProp struct { + ContentLength int64 `xml:"DAV: getcontentlength"` + LastModified string `xml:"DAV: getlastmodified"` + Collection *struct{} `xml:"DAV: resourcetype>collection"` +} + +// file is the name this response describes, and whether it is a folder. The +// collection the listing was asked for describes itself first, and is the one +// response with nothing to say. +func (response davResponse) file(basePath string) (string, bool) { + href := response.Href + if parsed, err := url.Parse(href); err == nil { + href = parsed.Path + } + if strings.TrimSuffix(href, "/") == strings.TrimSuffix(basePath, "/") { + return "", true + } + + name := path.Base(strings.TrimSuffix(href, "/")) + if unescaped, err := url.PathUnescape(name); err == nil { + name = unescaped + } + + for _, propstat := range response.Propstats { + if propstat.Prop.Collection != nil { + return name, true + } + } + return name, strings.HasSuffix(href, "/") +} + +func (response davResponse) size() int64 { + for _, propstat := range response.Propstats { + if propstat.Prop.ContentLength > 0 { + return propstat.Prop.ContentLength + } + } + return 0 +} + +func (response davResponse) modified() time.Time { + for _, propstat := range response.Propstats { + if propstat.Prop.LastModified == "" { + continue + } + if at, err := http.ParseTime(propstat.Prop.LastModified); err == nil { + return at + } + } + return time.Time{} +} diff --git a/controllers/backup.go b/controllers/backup.go index d727515..1a85ce9 100644 --- a/controllers/backup.go +++ b/controllers/backup.go @@ -16,7 +16,9 @@ package controllers import ( "encoding/json" + "os" + "github.com/apache/casbin-gateway/cloudsync" "github.com/apache/casbin-gateway/object" ) @@ -179,8 +181,11 @@ func (c *ApiController) DeleteBackup() { c.GetBackupState() } -// UpdateBackupSchedule stores the schedule on the built-in setting row, the -// same place the Settings page saves everything else. +// UpdateBackupSchedule stores the schedule, and the directory the snapshots go +// in, on the built-in setting row - the same place the Settings page saves +// everything else. The directory is the other way to put the data in Dropbox, +// OneDrive, iCloud Drive or a NAS share: point it at the folder their client +// already syncs and every snapshot is uploaded as it is written. func (c *ApiController) UpdateBackupSchedule() { if c.RequireAdmin() { return @@ -190,6 +195,7 @@ func (c *ApiController) UpdateBackupSchedule() { Mode string `json:"mode"` IntervalHours int `json:"intervalHours"` Retention int `json:"retention"` + Dir string `json:"dir"` } if err := json.Unmarshal(c.Ctx.Input.RequestBody, &request); err != nil { c.ResponseError(err.Error()) @@ -208,6 +214,17 @@ func (c *ApiController) UpdateBackupSchedule() { return } + // An empty directory is the default one beside the database. Anything else + // is created here: a folder that cannot be made is a setting that would + // otherwise fail every backup from now on, silently. + dir := cloudsync.ExpandPath(request.Dir) + if dir != "" { + if err := os.MkdirAll(dir, 0o755); err != nil { + c.ResponseError(err.Error()) + return + } + } + setting, err := object.GetBuiltInSetting() if err != nil { c.ResponseError(err.Error()) @@ -221,6 +238,7 @@ func (c *ApiController) UpdateBackupSchedule() { setting.BackupMode = request.Mode setting.BackupIntervalHours = request.IntervalHours setting.BackupRetention = request.Retention + setting.BackupDir = dir if _, err := object.UpdateSetting(object.BuiltInSettingId, setting); err != nil { c.ResponseError(err.Error()) return diff --git a/controllers/cloud_sync.go b/controllers/cloud_sync.go new file mode 100644 index 0000000..da0cb53 --- /dev/null +++ b/controllers/cloud_sync.go @@ -0,0 +1,112 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controllers + +import ( + "encoding/json" + + "github.com/apache/casbin-gateway/cloudsync" + "github.com/apache/casbin-gateway/object" +) + +// GetCloudSyncState is where the backups are copied to, how the last run went, +// and what else this Gateway could be pointed at. Admin-only like the rest of +// the backup API: the answer carries the credentials of the target. +func (c *ApiController) GetCloudSyncState() { + if c.RequireAdmin() { + return + } + + c.ResponseOk(object.GetCloudSyncState()) +} + +// UpdateCloudSync stores where the copies go. +func (c *ApiController) UpdateCloudSync() { + if c.RequireAdmin() { + return + } + + var request struct { + Mode string `json:"mode"` + Kind string `json:"kind"` + Options map[string]string `json:"options"` + } + if err := json.Unmarshal(c.Ctx.Input.RequestBody, &request); err != nil { + c.ResponseError(err.Error()) + return + } + + if err := object.SaveCloudSyncConfig(request.Mode, request.Kind, request.Options); err != nil { + c.ResponseError(err.Error()) + return + } + + c.GetCloudSyncState() +} + +// TestCloudSync reaches the target the form is describing, without storing it. +// Listing is the only check worth making: it proves the credentials, the path +// and the permission to read in one request. +func (c *ApiController) TestCloudSync() { + if c.RequireAdmin() { + return + } + + var request struct { + Kind string `json:"kind"` + Options map[string]string `json:"options"` + } + if err := json.Unmarshal(c.Ctx.Input.RequestBody, &request); err != nil { + c.ResponseError(err.Error()) + return + } + + target, files, err := object.TestCloudSync(request.Kind, request.Options) + if err != nil { + c.ResponseError(err.Error()) + return + } + + c.ResponseOk(map[string]interface{}{"target": target, "files": files}) +} + +// RunCloudSync copies now rather than on the schedule. The direction is what +// the buttons offer: everything both ways, only up, or only down. +func (c *ApiController) RunCloudSync() { + if c.RequireAdmin() { + return + } + + var request struct { + Direction string `json:"direction"` + } + if err := json.Unmarshal(c.Ctx.Input.RequestBody, &request); err != nil { + c.ResponseError(err.Error()) + return + } + + switch request.Direction { + case cloudsync.DirectionUp, cloudsync.DirectionDown: + default: + request.Direction = cloudsync.DirectionBoth + } + + if _, err := object.RunCloudSync(request.Direction); err != nil { + c.ResponseError(err.Error()) + return + } + + c.GetCloudSyncState() +} diff --git a/object/backup.go b/object/backup.go index 4fc6ad1..facdcc0 100644 --- a/object/backup.go +++ b/object/backup.go @@ -25,6 +25,7 @@ import ( "sync" "time" + "github.com/apache/casbin-gateway/cloudsync" "github.com/apache/casbin-gateway/conf" "github.com/apache/casbin-gateway/util" "github.com/beego/beego" @@ -89,6 +90,10 @@ type BackupState struct { IntervalHours int `json:"intervalHours"` Retention int `json:"retention"` Dir string `json:"dir"` + // Folders are the synced folders this machine has, so that putting the + // backups straight into Dropbox, OneDrive, iCloud Drive or a NAS share is a + // choice on the page rather than a path to go and find. + Folders []cloudsync.Folder `json:"folders"` // NextTime is when the schedule runs again, empty when nothing is scheduled. TakenTime string `json:"takenTime"` NextTime string `json:"nextTime"` @@ -104,8 +109,11 @@ func GetBackupMode() string { return BackupAuto } +// backupDir is where the snapshots are written. The path is expanded here, so +// that pointing it at "~/Dropbox/Casbin Gateway" or "%OneDrive%\Backups" works +// wherever it was typed. func backupDir() string { - return conf.GetBackupDir() + return cloudsync.ExpandPath(conf.GetBackupDir()) } // backupPath resolves one name inside the backup directory. A name that is not @@ -167,6 +175,10 @@ func CreateBackup(reason string) (*Backup, error) { beego.Error("the old backups could not be pruned:", err) } + // A copy on this disk is not a backup of this disk, so the snapshot goes to + // wherever the cloud sync points as soon as it has been written. + cloudSyncAfterBackup() + return describeBackup(name, int64(len(data)), snapshot), nil } @@ -296,6 +308,7 @@ func GetBackupState() (*BackupState, error) { IntervalHours: conf.GetBackupIntervalHours(), Retention: conf.GetBackupRetention(), Dir: backupDir(), + Folders: cloudsync.DetectFolders(), Latest: backupLatest, Error: backupError, Backups: backups, @@ -321,8 +334,9 @@ func GetBackupState() (*BackupState, error) { return state, nil } -// StartBackupSchedule takes a snapshot of the configuration every so often. The -// schedule is read each time round rather than held, so turning it off stops it. +// StartBackupSchedule takes a snapshot of the configuration every so often, and +// copies the snapshots to the cloud sync target on the same round. The schedule +// is read each time round rather than held, so turning it off stops it. func StartBackupSchedule() { go func() { time.Sleep(backupDelay) @@ -335,6 +349,10 @@ func StartBackupSchedule() { backupMutex.Unlock() } } + // Copying the backups off the machine is on the same schedule as + // taking them, and runs whether or not one was due: the pull is how + // this machine learns what another one backed up. + cloudSyncTick() time.Sleep(backupTick) } }() diff --git a/object/cloud_sync.go b/object/cloud_sync.go new file mode 100644 index 0000000..a4b4697 --- /dev/null +++ b/object/cloud_sync.go @@ -0,0 +1,387 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package object + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/apache/casbin-gateway/cloudsync" + "github.com/apache/casbin-gateway/conf" + "github.com/apache/casbin-gateway/proxy" + "github.com/apache/casbin-gateway/util" + "github.com/beego/beego" + "github.com/xorm-io/core" +) + +// Whether the backups are copied somewhere that is not this machine. It is off +// until somebody says where to: a snapshot carries every API key on the host, +// and nothing leaves the machine on a guess. +const ( + CloudSyncAuto = "auto" + CloudSyncOff = "off" +) + +// cloudSyncTimeout bounds one run. A snapshot is a few kilobytes, so anything +// past this is a target that is not answering rather than a slow upload. +const cloudSyncTimeout = 5 * time.Minute + +// cloudSyncMaxBytes is the largest file one run copies. Backups are kilobytes; +// this is only here so that a folder somebody else fills cannot be pulled into +// memory whole. +const cloudSyncMaxBytes = 64 << 20 + +// cloudSyncWait is how long a run waits for one already in flight. A push of a +// few kilobytes is over in a moment, so waiting is how pressing the button just +// after a backup stops looking like a collision. +const ( + cloudSyncWait = 15 * time.Second + cloudSyncPoll = 200 * time.Millisecond +) + +// errCloudSyncBusy is a run that waited and still found the target busy. +var errCloudSyncBusy = errors.New("a cloud sync is already running") + +var ( + cloudSyncMutex sync.Mutex + cloudSyncRunning bool + cloudSyncPending bool + cloudSyncTime time.Time + cloudSyncError string + cloudSyncResult *cloudsync.Result +) + +// CloudSyncState is where the copies go and how the last run went, which is +// what the Settings page reports. Like BackupState it lives in memory: it +// describes what this process has done, not a stored setting. +type CloudSyncState struct { + Mode string `json:"mode"` + Kind string `json:"kind"` + // Options are the stored ones with the credentials decrypted, which is what + // the admin typed and what the form has to show again to be edited. + Options map[string]string `json:"options"` + // Kinds is every storage Gateway can sync to, described well enough for the + // page to draw the form for one it has never heard of. + Kinds []*cloudsync.Kind `json:"kinds"` + // Folders are the synced folders found on this machine, so that pointing + // Gateway at Dropbox or a NAS share is a click rather than a path to find. + Folders []cloudsync.Folder `json:"folders"` + // Target is where the copies land, in one line and without credentials. + Target string `json:"target"` + Running bool `json:"running"` + SyncedTime string `json:"syncedTime"` + Error string `json:"error"` + Result *cloudsync.Result `json:"result"` +} + +func GetCloudSyncMode() string { + if strings.EqualFold(conf.GetConfigStringUnquoted("cloudSyncMode"), CloudSyncAuto) { + return CloudSyncAuto + } + return CloudSyncOff +} + +func GetCloudSyncKind() string { + return conf.GetConfigStringUnquoted("cloudSyncKind") +} + +// storedCloudSyncOptions is the options column as it sits in the database, +// credentials still encrypted. +func storedCloudSyncOptions() map[string]string { + options := map[string]string{} + + raw := conf.GetConfigString("cloudSyncOptions") + if strings.TrimSpace(raw) == "" { + return options + } + if err := json.Unmarshal([]byte(raw), &options); err != nil { + beego.Error("the cloud sync options could not be read:", err) + return map[string]string{} + } + return options +} + +// cloudSyncAad binds a credential's ciphertext to the option it was typed +// into, so a WebDAV password moved into the S3 key no longer decrypts. +func cloudSyncAad(kind string, name string) string { + return fmt.Sprintf("%s/cloudSync/%s/%s", BuiltInSettingId, kind, name) +} + +func encryptCloudSyncOptions(kind string, options map[string]string) (map[string]string, error) { + res := map[string]string{} + for name, value := range options { + res[name] = value + } + + for _, name := range cloudsync.SecretOptions(kind) { + encrypted, err := util.EncryptWithKey(apiKeyEncryptionSecret(), res[name], cloudSyncAad(kind, name)) + if err != nil { + return nil, err + } + res[name] = encrypted + } + return res, nil +} + +// decryptCloudSyncOptions is the other half. A credential that will not +// decrypt is left as it stands rather than dropping the whole configuration: +// the target then fails to authenticate, which is the truth of the matter. +func decryptCloudSyncOptions(kind string, options map[string]string) map[string]string { + res := map[string]string{} + for name, value := range options { + res[name] = value + } + + for _, name := range cloudsync.SecretOptions(kind) { + plain, err := util.DecryptWithKey(apiKeyEncryptionSecret(), res[name], cloudSyncAad(kind, name)) + if err != nil { + beego.Error("a cloud sync credential could not be decrypted:", name, err) + continue + } + res[name] = plain + } + return res +} + +// CloudSyncConfig is the stored configuration, ready to be handed to +// cloudsync. Outbound traffic goes through Gateway's own client, so a target +// reached over the configured proxy needs nothing said here. +func CloudSyncConfig() cloudsync.Config { + kind := GetCloudSyncKind() + return cloudsync.Config{ + Kind: kind, + Options: decryptCloudSyncOptions(kind, storedCloudSyncOptions()), + HttpClient: proxy.ProxyHttpClient, + } +} + +// SaveCloudSyncConfig stores where the copies go. The configuration is built +// once before it is written, so a target that cannot be made is reported here +// rather than by a schedule nobody is watching. +func SaveCloudSyncConfig(mode string, kind string, options map[string]string) error { + if mode != CloudSyncAuto && mode != CloudSyncOff { + return fmt.Errorf("cloudSyncMode must be \"auto\" or \"off\"") + } + + if kind == "" { + mode, options = CloudSyncOff, map[string]string{} + } else if _, err := cloudsync.New(cloudsync.Config{Kind: kind, Options: options}); err != nil { + return err + } + + encrypted, err := encryptCloudSyncOptions(kind, options) + if err != nil { + return err + } + + stored, err := json.Marshal(encrypted) + if err != nil { + return err + } + + setting, err := GetBuiltInSetting() + if err != nil { + return err + } + if setting == nil { + return fmt.Errorf("the built-in setting does not exist") + } + + setting.CloudSyncMode, setting.CloudSyncKind, setting.CloudSyncOptions = mode, kind, string(stored) + if _, err = UpdateSetting(BuiltInSettingId, setting); err != nil { + return err + } + return nil +} + +// GetCloudSyncState answers the Settings page. +func GetCloudSyncState() *CloudSyncState { + cloudSyncMutex.Lock() + defer cloudSyncMutex.Unlock() + + config := CloudSyncConfig() + state := &CloudSyncState{ + Mode: GetCloudSyncMode(), + Kind: config.Kind, + Options: config.Options, + Kinds: cloudsync.Kinds(), + Folders: cloudsync.DetectFolders(), + Running: cloudSyncRunning, + Error: cloudSyncError, + Result: cloudSyncResult, + } + + if config.Kind != "" { + if target, err := cloudsync.New(config); err == nil { + state.Target = target.Describe() + } + } + if !cloudSyncTime.IsZero() { + state.SyncedTime = util.FormatTime(cloudSyncTime) + } + return state +} + +// TestCloudSync is what the "Test" button asks, of a configuration that is +// being typed rather than of the stored one. +func TestCloudSync(kind string, options map[string]string) (string, []cloudsync.File, error) { + config := cloudsync.Config{Kind: kind, Options: options, HttpClient: proxy.ProxyHttpClient} + + ctx, cancel := context.WithTimeout(context.Background(), cloudSyncTimeout) + defer cancel() + + target, files, err := cloudsync.Check(ctx, config) + if err != nil { + if target == nil { + return "", nil, err + } + return target.Describe(), nil, err + } + return target.Describe(), files, nil +} + +// RunCloudSync copies the backup directory and the target onto each other. The +// files are immutable snapshots named after the moment they were taken, so +// there is nothing to merge: each side ends up holding what the other had. +func RunCloudSync(direction string) (*cloudsync.Result, error) { + config := CloudSyncConfig() + if config.Kind == "" { + return nil, fmt.Errorf("no cloud sync target is configured") + } + + target, err := cloudsync.New(config) + if err != nil { + return nil, err + } + + if !acquireCloudSync() { + return nil, errCloudSyncBusy + } + + ctx, cancel := context.WithTimeout(context.Background(), cloudSyncTimeout) + defer cancel() + + result, err := cloudsync.SyncDir(ctx, target, backupDir(), cloudsync.Options{ + Match: backupNamePattern.MatchString, + Retention: conf.GetBackupRetention(), + Direction: direction, + MaxBytes: cloudSyncMaxBytes, + }) + + cloudSyncMutex.Lock() + cloudSyncRunning = false + cloudSyncTime = time.Now() + cloudSyncResult = result + cloudSyncError = "" + if err != nil { + cloudSyncError = err.Error() + } else if len(result.Errors) > 0 { + cloudSyncError = strings.Join(result.Errors, "; ") + } + // A snapshot taken while this run was going may have missed it, so the run + // that was turned away happens now rather than at the next interval. + pending := cloudSyncPending + cloudSyncPending = false + cloudSyncMutex.Unlock() + + if pending { + go func() { + if _, err := RunCloudSync(cloudsync.DirectionUp); err != nil && !errors.Is(err, errCloudSyncBusy) { + beego.Error("the cloud sync that was waiting failed:", err) + } + }() + } + + return result, err +} + +// acquireCloudSync makes this the only run going. One that gives up leaves the +// work behind it: whichever run is in flight picks it up as it finishes. +func acquireCloudSync() bool { + deadline := time.Now().Add(cloudSyncWait) + for { + cloudSyncMutex.Lock() + if !cloudSyncRunning { + cloudSyncRunning, cloudSyncPending = true, false + cloudSyncMutex.Unlock() + return true + } + cloudSyncMutex.Unlock() + + if time.Now().After(deadline) { + cloudSyncMutex.Lock() + cloudSyncPending = true + cloudSyncMutex.Unlock() + return false + } + time.Sleep(cloudSyncPoll) + } +} + +// cloudSyncTick is the scheduled half, called from the same loop that takes the +// backups. The interval is the backup one: a copy of a snapshot is due exactly +// as often as the snapshot is. +func cloudSyncTick() { + if GetCloudSyncMode() != CloudSyncAuto || GetCloudSyncKind() == "" { + return + } + + cloudSyncMutex.Lock() + due := !cloudSyncRunning && (cloudSyncTime.IsZero() || time.Since(cloudSyncTime) >= backupInterval()) + cloudSyncMutex.Unlock() + + if !due { + return + } + // The first run of a process pulls as well as pushes, which is how a second + // machine, or a reinstalled one, finds the backups it never took. + if _, err := RunCloudSync(cloudsync.DirectionBoth); err != nil { + beego.Error("the cloud sync failed:", err) + } +} + +// cloudSyncAfterBackup pushes the snapshot that was just taken. It runs in the +// background: a backup is not held up by a NAS that is asleep. +func cloudSyncAfterBackup() { + if GetCloudSyncMode() != CloudSyncAuto || GetCloudSyncKind() == "" { + return + } + + go func() { + // A run already going is not a failure: it either carries this snapshot + // along or leaves the push queued behind it. + if _, err := RunCloudSync(cloudsync.DirectionUp); err != nil && !errors.Is(err, errCloudSyncBusy) { + beego.Error("the backup could not be copied to the cloud sync target:", err) + } + }() +} + +// initCloudSyncSetting fills the column in for an installation that predates +// it, the way every other setting added later is filled in. +func initCloudSyncSetting(setting *Setting) { + if setting.CloudSyncMode != "" { + return + } + + setting.CloudSyncMode = CloudSyncOff + if _, err := ormer.Engine.ID(core.PK{setting.Owner, setting.Name}).Cols("cloud_sync_mode").Update(setting); err != nil { + panic(err) + } +} diff --git a/object/setting.go b/object/setting.go index 48e85b9..e0b3edd 100644 --- a/object/setting.go +++ b/object/setting.go @@ -61,6 +61,14 @@ type Setting struct { BackupRetention int `xorm:"int" json:"backupRetention"` BackupDir string `xorm:"varchar(500)" json:"backupDir"` + // CloudSyncMode is "auto" or "off", the kind is which storage the backups + // are copied to, and the options are what that kind was asked for. The + // options are one JSON document rather than a column each: a kind added + // later brings its own keys, and this table should not have to learn them. + CloudSyncMode string `xorm:"varchar(20)" json:"cloudSyncMode"` + CloudSyncKind string `xorm:"varchar(50)" json:"cloudSyncKind"` + CloudSyncOptions string `xorm:"varchar(2000)" json:"cloudSyncOptions"` + AgentPatchStateDir string `xorm:"varchar(500)" json:"agentPatchStateDir"` AgentRecordCapacity int `xorm:"int" json:"agentRecordCapacity"` AgentMonitorPollSeconds int `xorm:"int" json:"agentMonitorPollSeconds"` @@ -105,6 +113,10 @@ func SyncSettingToConf(setting *Setting) { "backupRetention": strconv.Itoa(setting.BackupRetention), "backupDir": setting.BackupDir, + "cloudSyncMode": setting.CloudSyncMode, + "cloudSyncKind": setting.CloudSyncKind, + "cloudSyncOptions": setting.CloudSyncOptions, + "agentPatchStateDir": setting.AgentPatchStateDir, "agentRecordCapacity": strconv.Itoa(setting.AgentRecordCapacity), "agentMonitorPollSeconds": strconv.Itoa(setting.AgentMonitorPollSeconds), @@ -237,6 +249,8 @@ func InitBuiltInSetting() { } } + initCloudSyncSetting(setting) + SyncSettingToConf(setting) } @@ -268,6 +282,8 @@ func newSettingFromConf() *Setting { BackupRetention: conf.GetBackupRetention(), BackupDir: conf.GetBackupDir(), + CloudSyncMode: CloudSyncOff, + AgentPatchStateDir: conf.GetAgentPatchStateDir(), AgentRecordCapacity: conf.GetAgentRecordCapacity(), AgentMonitorPollSeconds: conf.GetAgentMonitorPollSeconds(), diff --git a/routers/router.go b/routers/router.go index 5fa4b5c..ce2e83e 100644 --- a/routers/router.go +++ b/routers/router.go @@ -52,6 +52,10 @@ func initAPI() { beego.Router("/api/restore-backup", &controllers.ApiController{}, "POST:RestoreBackup") beego.Router("/api/delete-backup", &controllers.ApiController{}, "POST:DeleteBackup") beego.Router("/api/update-backup-schedule", &controllers.ApiController{}, "POST:UpdateBackupSchedule") + beego.Router("/api/get-cloud-sync-state", &controllers.ApiController{}, "GET:GetCloudSyncState") + beego.Router("/api/update-cloud-sync", &controllers.ApiController{}, "POST:UpdateCloudSync") + beego.Router("/api/test-cloud-sync", &controllers.ApiController{}, "POST:TestCloudSync") + beego.Router("/api/run-cloud-sync", &controllers.ApiController{}, "POST:RunCloudSync") beego.Router("/api/get-agents", &controllers.ApiController{}, "GET:GetAgents") beego.Router("/api/get-agent-catalog", &controllers.ApiController{}, "GET:GetAgentCatalog") diff --git a/web/src/backend/BackupBackend.ts b/web/src/backend/BackupBackend.ts index c8d4470..ce63fe7 100644 --- a/web/src/backend/BackupBackend.ts +++ b/web/src/backend/BackupBackend.ts @@ -55,10 +55,14 @@ export function deleteBackup(name: string) { return request<BackupState>("/api/delete-backup", "POST", {name: name}); } -export function updateBackupSchedule(mode: BackupMode, intervalHours: number, retention: number) { +/** The schedule, and the directory the snapshots are written to: an empty one + * is the default beside the database, and a synced folder is how they end up + * in Dropbox, OneDrive, iCloud Drive or on a NAS. */ +export function updateBackupSchedule(mode: BackupMode, intervalHours: number, retention: number, dir: string) { return request<BackupState>("/api/update-backup-schedule", "POST", { mode: mode, intervalHours: intervalHours, retention: retention, + dir: dir, }); } diff --git a/web/src/backend/CloudSyncBackend.ts b/web/src/backend/CloudSyncBackend.ts new file mode 100644 index 0000000..a1f428b --- /dev/null +++ b/web/src/backend/CloudSyncBackend.ts @@ -0,0 +1,38 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {request} from "@/backend/request"; +import type {CloudSyncDirection, CloudSyncFile, CloudSyncMode, CloudSyncState} from "@/types"; + +/** Where the backups are copied to, what else they could be copied to, and how + * the last run went. */ +export function getCloudSyncState() { + return request<CloudSyncState>("/api/get-cloud-sync-state"); +} + +export function updateCloudSync(mode: CloudSyncMode, kind: string, options: Record<string, string>) { + return request<CloudSyncState>("/api/update-cloud-sync", "POST", {mode: mode, kind: kind, options: options}); +} + +/** Reaches the target the form is describing, without storing it. */ +export function testCloudSync(kind: string, options: Record<string, string>) { + return request<{target: string; files: CloudSyncFile[]}>("/api/test-cloud-sync", "POST", { + kind: kind, + options: options, + }); +} + +export function runCloudSync(direction: CloudSyncDirection) { + return request<CloudSyncState>("/api/run-cloud-sync", "POST", {direction: direction}); +} diff --git a/web/src/components/settings/backup-panel.tsx b/web/src/components/settings/backup-panel.tsx index 89a87e8..49dce69 100644 --- a/web/src/components/settings/backup-panel.tsx +++ b/web/src/components/settings/backup-panel.tsx @@ -13,7 +13,7 @@ // limitations under the License. import * as React from "react"; -import {Archive, Download, History, Trash2} from "lucide-react"; +import {Archive, Download, FolderSymlink, History, Trash2} from "lucide-react"; import i18next from "i18next"; import * as BackupBackend from "@/backend/BackupBackend"; @@ -25,6 +25,7 @@ import {MessageAlert} from "@/components/ui/alert"; import {Badge} from "@/components/ui/badge"; import {Button} from "@/components/ui/button"; import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"; +import {Input} from "@/components/ui/input"; import {Switch} from "@/components/ui/switch"; import type {Backup, BackupState, ImportReport} from "@/types"; @@ -151,10 +152,13 @@ function BackupRow({ * is also taken in front of every import, so the restore below is what undoes * one that turned out to be the wrong file. */ -export function BackupPanel({onSettingChanged}: {onSettingChanged: () => void}) { +export function BackupPanel({onSettingChanged, reloadToken = 0}: {onSettingChanged: () => void; reloadToken?: number}) { const [state, setState] = React.useState<BackupState | null>(null); const [busy, setBusy] = React.useState(false); const [report, setReport] = React.useState<ImportReport | null>(null); + // Null while the field shows what is stored, a string once it is being + // edited: the directory only moves when the button beside it is pressed. + const [dir, setDir] = React.useState<string | null>(null); const load = React.useCallback(() => { BackupBackend.getBackupState() @@ -166,7 +170,9 @@ export function BackupPanel({onSettingChanged}: {onSettingChanged: () => void}) .catch(() => undefined); }, []); - React.useEffect(load, [load]); + // A sync that pulled backups from the cloud target changes this list, and the + // panel that did it says so by bumping the token. + React.useEffect(load, [load, reloadToken]); if (state === null) { return null; @@ -193,8 +199,10 @@ export function BackupPanel({onSettingChanged}: {onSettingChanged: () => void}) .then(() => setBusy(false)); }; - const schedule = (mode: BackupState["mode"], intervalHours: number, retention: number) => - apply(BackupBackend.updateBackupSchedule(mode, intervalHours, retention)); + const schedule = (mode: BackupState["mode"], intervalHours: number, retention: number, directory?: string) => + apply(BackupBackend.updateBackupSchedule(mode, intervalHours, retention, directory ?? state.dir)).then(() => + setDir(null), + ); const backUpNow = () => { setReport(null); @@ -312,8 +320,32 @@ export function BackupPanel({onSettingChanged}: {onSettingChanged: () => void}) </div> </div> - <div className="text-muted-foreground text-xs"> - {i18next.t("setting:Backups live in {dir}").replace("{dir}", state.dir)} + <div className="flex flex-col gap-1.5"> + <span className="text-muted-foreground text-xs">{i18next.t("setting:Where backups live")}</span> + <div className="flex flex-wrap items-center gap-2"> + <Input + className="h-8 max-w-md" + value={dir ?? state.dir} + disabled={busy} + aria-label={i18next.t("setting:Where backups live")} + onChange={event => setDir(event.target.value)} + /> + <Button + variant="outline" + size="xs" + disabled={busy || dir === null || dir === state.dir} + onClick={() => schedule(state.mode, state.intervalHours, state.retention, dir ?? state.dir)} + > + {i18next.t("general:Save")} + </Button> + {(state.folders ?? []).map(folder => ( + <Button key={folder.path} variant="ghost" size="xs" disabled={busy} onClick={() => setDir(folder.suggested)}> + <FolderSymlink /> + {folder.name} + </Button> + ))} + </div> + <span className="text-muted-foreground text-xs">{i18next.t("setting:Where backups live hint")}</span> </div> {state.error === "" ? null : ( diff --git a/web/src/components/settings/cloud-sync-panel.tsx b/web/src/components/settings/cloud-sync-panel.tsx new file mode 100644 index 0000000..32de3a0 --- /dev/null +++ b/web/src/components/settings/cloud-sync-panel.tsx @@ -0,0 +1,298 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as React from "react"; +import {CloudDownload, FolderSymlink, PlugZap, RefreshCw} from "lucide-react"; +import i18next from "i18next"; + +import * as CloudSyncBackend from "@/backend/CloudSyncBackend"; +import * as Setting from "@/Setting"; +import {Field} from "@/components/shared/form-dialog"; +import {PasswordInput} from "@/components/shared/password-input"; +import {SimpleSelect} from "@/components/shared/simple-select"; +import {MessageAlert} from "@/components/ui/alert"; +import {Badge} from "@/components/ui/badge"; +import {Button} from "@/components/ui/button"; +import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"; +import {Input} from "@/components/ui/input"; +import {Switch} from "@/components/ui/switch"; +import type {CloudSyncDirection, CloudSyncField, CloudSyncKind, CloudSyncState} from "@/types"; + +/** The kinds, their fields and their hints are described by the server, so a + * storage added there needs nothing here. The English text it sends doubles as + * the translation key, and stands as written when there is no translation. */ +function text(key: string) { + return key === "" ? "" : i18next.t(`setting:${key}`, {defaultValue: key}); +} + +/** One field of the chosen kind, drawn from what the server said it is. */ +function KindField({ + field, + value, + disabled, + onChange, +}: { + field: CloudSyncField; + value: string; + disabled: boolean; + onChange: (value: string) => void; +}) { + const id = `cloud-sync-${field.name}`; + + if (field.type === "switch") { + return ( + <Field label={text(field.label)} hint={text(field.hint)}> + <div className="flex h-9 items-center"> + <Switch + id={id} + checked={value === "true"} + disabled={disabled} + aria-label={text(field.label)} + onCheckedChange={checked => onChange(checked ? "true" : "false")} + /> + </div> + </Field> + ); + } + + const Control = field.type === "secret" ? PasswordInput : Input; + return ( + <Field label={text(field.label)} htmlFor={id} required={field.required} hint={text(field.hint)}> + <Control + id={id} + value={value} + placeholder={field.placeholder} + disabled={disabled} + onChange={event => onChange(event.target.value)} + /> + </Field> + ); +} + +/** + * Where a copy of the backups goes: a folder a desktop client already syncs, a + * WebDAV share, or an S3 bucket. The snapshots are immutable files named after + * the moment they were taken, so a sync has nothing to merge - each side ends + * up holding what the other had, and a machine that has pulled them can restore + * any of them from the list above. + */ +export function CloudSyncPanel({onSynced}: {onSynced: () => void}) { + const [state, setState] = React.useState<CloudSyncState | null>(null); + const [kind, setKind] = React.useState(""); + // Kept per kind, so trying another storage and coming back does not mean + // typing the credentials again. + const [options, setOptions] = React.useState<Record<string, Record<string, string>>>({}); + const [busy, setBusy] = React.useState(false); + const [tested, setTested] = React.useState(""); + + const load = React.useCallback(() => { + CloudSyncBackend.getCloudSyncState() + .then(res => { + if (res.status === "ok") { + setState(res.data); + setKind(current => current || res.data.kind || res.data.kinds[0]?.name || ""); + setOptions(current => ({...current, [res.data.kind]: res.data.options ?? {}})); + } + }) + .catch(() => undefined); + }, []); + + React.useEffect(load, [load]); + + if (state === null) { + return null; + } + + const auto = state.mode === "auto"; + const kinds = state.kinds ?? []; + const current: CloudSyncKind | undefined = kinds.find(item => item.name === kind); + const values = options[kind] ?? {}; + + const setValue = (name: string, value: string) => + setOptions(all => ({...all, [kind]: {...(all[kind] ?? {}), [name]: value}})); + + const apply = (promise: Promise<{status: string; msg: string; data?: CloudSyncState}>, done?: () => void) => { + setBusy(true); + return promise + .then(res => { + if (res.status === "ok" && res.data) { + const saved = res.data; + setState(saved); + setOptions(all => ({...all, [saved.kind]: saved.options ?? {}})); + done?.(); + } else { + Setting.showMessage("error", `${i18next.t("general:Failed to save")}: ${res.msg}`); + } + }) + .catch(failure => Setting.showMessage("error", failure.message || String(failure))) + .then(() => setBusy(false)); + }; + + const save = (mode: CloudSyncState["mode"]) => { + setTested(""); + return apply(CloudSyncBackend.updateCloudSync(mode, kind, values), () => + Setting.showMessage("success", i18next.t("general:Successfully saved")), + ); + }; + + const test = () => { + setBusy(true); + setTested(""); + return CloudSyncBackend.testCloudSync(kind, values) + .then(res => { + if (res.status === "ok" && res.data) { + setTested( + i18next + .t("setting:Reached {target}, {count} files there") + .replace("{target}", res.data.target) + .replace("{count}", String(res.data.files?.length ?? 0)), + ); + } else { + Setting.showMessage("error", res.msg); + } + }) + .catch(failure => Setting.showMessage("error", failure.message || String(failure))) + .then(() => setBusy(false)); + }; + + // A run that pulled anything changes the list of backups above, so the page + // is told rather than left a reload behind. + const run = (direction: CloudSyncDirection) => + apply(CloudSyncBackend.runCloudSync(direction), () => { + Setting.showMessage("success", i18next.t("setting:The sync finished")); + onSynced(); + }); + + const result = state.result; + const summary = + result === null + ? "" + : i18next + .t("setting:{up} sent, {down} received, {gone} dropped") + .replace("{up}", String(result.uploaded?.length ?? 0)) + .replace("{down}", String(result.downloaded?.length ?? 0)) + .replace("{gone}", String(result.removed?.length ?? 0)); + + return ( + <Card className="gap-4 py-5"> + <CardHeader className="px-5"> + <div className="flex flex-wrap items-start justify-between gap-3"> + <div className="min-w-0"> + <CardTitle className="text-[15px]">{i18next.t("setting:Cloud sync")}</CardTitle> + <CardDescription>{i18next.t("setting:Cloud sync description")}</CardDescription> + </div> + <div className="flex shrink-0 items-center gap-2"> + <span className="text-muted-foreground text-xs"> + {i18next.t(auto ? "setting:Syncing is on" : "setting:Syncing is off")} + </span> + <Switch + checked={auto} + disabled={busy || kind === ""} + aria-label={i18next.t("setting:Cloud sync")} + onCheckedChange={checked => save(checked ? "auto" : "off")} + /> + </div> + </div> + </CardHeader> + + <CardContent className="flex flex-col gap-4 px-5"> + <div className="grid gap-3 sm:grid-cols-2"> + <Field label={i18next.t("setting:Storage")} hint={text(current?.description ?? "")}> + <SimpleSelect + value={kind} + disabled={busy} + onChange={value => { + setKind(value); + setTested(""); + }} + options={kinds.map(item => ({label: text(item.displayName), value: item.name}))} + /> + </Field> + + {(current?.fields ?? []).map(field => ( + <KindField + key={field.name} + field={field} + value={values[field.name] ?? ""} + disabled={busy} + onChange={value => setValue(field.name, value)} + /> + ))} + </div> + + {kind === "folder" && (state.folders ?? []).length > 0 ? ( + <div className="flex flex-wrap items-center gap-1.5"> + <span className="text-muted-foreground text-xs">{i18next.t("setting:Found on this machine")}</span> + {state.folders.map(folder => ( + <Button + key={folder.path} + variant="outline" + size="xs" + disabled={busy} + onClick={() => setValue("path", folder.suggested)} + > + <FolderSymlink /> + {folder.name} + </Button> + ))} + </div> + ) : null} + + <div className="grid gap-3 sm:grid-cols-2"> + <div className="flex flex-col gap-0.5"> + <span className="text-muted-foreground text-xs">{i18next.t("setting:Copies go to")}</span> + <span className="text-sm break-all"> + {state.target === "" ? i18next.t("setting:Nowhere yet") : state.target} + </span> + </div> + <div className="flex flex-col gap-0.5"> + <span className="text-muted-foreground text-xs">{i18next.t("setting:Last sync")}</span> + <span className="flex flex-wrap items-center gap-2 text-sm"> + {state.syncedTime === "" ? i18next.t("setting:Never") : Setting.getFormattedDate(state.syncedTime)} + {summary === "" ? null : ( + <Badge variant="muted" className="font-normal"> + {summary} + </Badge> + )} + </span> + </div> + </div> + + {tested === "" ? null : <MessageAlert variant="success" title={tested} />} + + {state.error === "" ? null : ( + <MessageAlert title={i18next.t("setting:The last sync failed")} description={state.error} /> + )} + + <div className="flex flex-wrap gap-2"> + <Button variant="outline" disabled={busy || kind === ""} onClick={() => save(state.mode)}> + {i18next.t("general:Save")} + </Button> + <Button variant="outline" disabled={busy || kind === ""} onClick={test}> + <PlugZap /> + {i18next.t("setting:Test connection")} + </Button> + <Button variant="outline" disabled={busy || state.kind === ""} loading={busy} onClick={() => run("both")}> + <RefreshCw /> + {i18next.t("setting:Sync now")} + </Button> + <Button variant="ghost" disabled={busy || state.kind === ""} onClick={() => run("down")}> + <CloudDownload /> + {i18next.t("setting:Fetch what is there")} + </Button> + </div> + </CardContent> + </Card> + ); +} diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json index fe1fa13..9bc08d4 100644 --- a/web/src/locales/en/data.json +++ b/web/src/locales/en/data.json @@ -1039,6 +1039,7 @@ "A backup was taken first": "A backup was taken first, as {name}.", "API key encryption key": "API key encryption key", "API key encryption key hint": "Encrypts the provider API keys at rest, any length. Back it up with the database: losing it makes already-encrypted keys unreadable.", + "Access key ID": "Access key ID", "Added": "Added", "Agent poll seconds": "Agent poll interval, in seconds", "Agent record capacity": "Agent records kept", @@ -1052,8 +1053,8 @@ "Backups are off": "Backups are off", "Backups are on": "Backups are on", "Backups description": "A snapshot of the configuration, written beside the database rather than inside it, so it survives the database it was taken from. One is also taken in front of every import.", - "Backups live in {dir}": "Backups live in {dir}", "Before an import": "Before an import", + "Bucket": "Bucket", "By hand": "By hand", "Casdoor application": "Casdoor application", "Casdoor endpoint": "Casdoor endpoint", @@ -1064,14 +1065,27 @@ "Choose another file": "Choose another file", "Client ID": "Client ID", "Client secret": "Client secret", + "Cloud sync": "Cloud sync", + "Cloud sync description": "A copy of every backup, kept somewhere that is not this machine. The snapshots are immutable files, so a sync has nothing to merge: each side ends up holding what the other had, and any of them can be restored from the list above.", + "Copies go to": "Copies go to", "Delete this backup?": "Delete this backup?", "Deleted": "Deleted", "Download": "Download", "Due now": "Due now", + "Endpoint": "Endpoint", + "Endpoint hint": "Left empty, the AWS endpoint for the region above.", "Every {count} hours": "Every {count} hours", "Export": "Export", "Export to a file": "Export to a file", "Failed": "Failed", + "Fetch what is there": "Fetch what is there", + "Folder": "Folder", + "Folder description": "A directory on this machine. Point it at the folder the Dropbox, OneDrive, iCloud Drive or NAS client already syncs, and the copies ride along with it.", + "Folder on the server": "Folder on the server", + "Folder on the server hint": "Left empty, the URL itself is the folder. It is created if it is not there yet.", + "Folder path": "Folder path", + "Folder path hint": "Supports ~ and environment variables, such as %OneDrive%\\Backups.", + "Found on this machine": "Found on this machine:", "How many to keep": "How many to keep", "How often": "How often", "Import": "Import", @@ -1083,10 +1097,13 @@ "Include the API keys": "Include the API keys", "Include the API keys hint": "Without them the file describes the configuration but cannot serve traffic.", "Keep what is here": "Keep what is here", + "Key prefix": "Key prefix", + "Key prefix hint": "Where the backups live in the bucket. Left empty, they sit at its root.", "Keys are taken from the file": "A provider the file has no key for keeps the key it already has here.", "LLM records": "LLM records", "LLM records description": "Prompts can carry sensitive data, so anything beyond metadata is opt-in.", "Last backup": "Last backup", + "Last sync": "Last sync", "Max payload bytes": "Max stored body, in bytes", "Max records": "Max records", "Model prices": "Model prices", @@ -1099,6 +1116,7 @@ "Nothing in it": "Nothing in it", "Nothing is scheduled": "Nothing is scheduled", "Nothing would change": "Nothing would change", + "Nowhere yet": "Nowhere yet", "On a schedule": "On a schedule", "Once a day": "Once a day", "Once a week": "Once a week", @@ -1106,6 +1124,9 @@ "Outbound SOCKS5 proxy hint": "\"host:port\", used for outbound traffic such as update downloads.", "Overwrite what the file names": "Overwrite what the file names", "Page description": "Everything Casbin Gateway reads out of its configuration. Changes are stored in the database and applied at once.", + "Password": "Password", + "Path-style addressing": "Path-style addressing", + "Path-style addressing hint": "On for MinIO and most self-hosted gateways, off for AWS S3.", "Pricing file": "Pricing file", "Pricing file hint": "Optional JSON file of token prices in US dollars per million, overriding the built-in list prices.", "Probe cases": "Probe cases", @@ -1116,7 +1137,9 @@ "Providers": "Providers", "Queue capacity": "Write queue capacity", "Queue capacity hint": "A full queue drops records rather than slowing the proxy down.", + "Reached {target}, {count} files there": "Reached {target}, {count} files there", "Record mode": "Record mode", + "Region": "Region", "Relay token": "Relay token", "Relay token hint": "What an agent sends to /v1. Requests from this machine never need it. Clear the field and save to issue a new one.", "Replace it entirely": "Replace it entirely", @@ -1125,26 +1148,44 @@ "Restore this backup?": "Restore this backup?", "Restore warning": "The sections this backup covers end up exactly as they were when it was taken: what it does not name is deleted. A backup of what is here now is taken first.", "Retention days": "Retention days", + "S3": "S3", + "S3 description": "Object storage with the S3 API: S3 itself, Cloudflare R2, Backblaze B2, MinIO, Aliyun OSS and Tencent COS.", + "Secret access key": "Secret access key", "Security": "Security", + "Server URL": "Server URL", "Settings": "Settings", "Sign-in": "Sign-in", "Sign-in description": "Casdoor is optional: leave the endpoint empty to sign in against Gateway's own user table.", "Skipped": "Skipped", + "Storage": "Storage", + "Sync now": "Sync now", + "Syncing is off": "Syncing is off", + "Syncing is on": "Syncing is on", "Taken on {host} at {time}": "Taken on {host} at {time}", + "Test connection": "Test connection", "The backup was restored": "The backup was restored", "The file carries every key": "The file carries every key", "The file carries every key hint": "Anyone who opens it can read every provider API key on this machine. Keep it where the keys themselves belong.", "The last backup failed": "The last backup failed", + "The last sync failed": "The last sync failed", "The snapshot was imported": "The snapshot was imported", "The snapshot was saved": "The snapshot was saved", + "The sync finished": "The sync finished", "This file carries no keys": "This file carries no API keys, so every provider in it needs its key typed again.", "This file is not a Gateway snapshot": "This file is not a Gateway snapshot", "Updated": "Updated", + "Username": "Username", + "WebDAV": "WebDAV", + "WebDAV description": "A WebDAV share: Nextcloud, ownCloud, Synology, QNAP and most other NAS firmware.", + "WebDAV password hint": "Most services want an app password here rather than the one you sign in with.", "What this import would do": "What this import would do", "What this restore did": "What this restore did", "What this restore would do": "What this restore would do", "What to do with what is already here": "What to do with what is already here", - "{count} in this file": "{count} in this file" + "Where backups live": "Where backups live", + "Where backups live hint": "The directory the snapshots are written to. Point it at a folder Dropbox, OneDrive, iCloud Drive or your NAS already syncs, and every backup is uploaded as it is written.", + "{count} in this file": "{count} in this file", + "{up} sent, {down} received, {gone} dropped": "{up} sent, {down} received, {gone} dropped" }, "usage": { "Add a price": "Add a price", diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json index d443060..04a2a8e 100644 --- a/web/src/locales/zh/data.json +++ b/web/src/locales/zh/data.json @@ -1111,6 +1111,7 @@ "A backup was taken first": "导入前已先备份为 {name}。", "API key encryption key": "API Key 加密密钥", "API key encryption key hint": "用于加密存储的供应商 API Key,长度不限。请与数据库一起备份:一旦丢失,已加密的密钥将无法读取。", + "Access key ID": "Access Key ID", "Added": "新增", "Agent poll seconds": "Agent 轮询间隔(秒)", "Agent record capacity": "Agent 记录条数", @@ -1124,8 +1125,8 @@ "Backups are off": "自动备份已关闭", "Backups are on": "自动备份已开启", "Backups description": "配置的快照,写在数据库旁边而不是里面,所以它比数据库本身活得更久。每次导入之前也会自动备份一份。", - "Backups live in {dir}": "备份保存在 {dir}", "Before an import": "导入前", + "Bucket": "存储桶", "By hand": "手动", "Casdoor application": "Casdoor 应用", "Casdoor endpoint": "Casdoor 地址", @@ -1136,14 +1137,27 @@ "Choose another file": "换一个文件", "Client ID": "Client ID", "Client secret": "Client Secret", + "Cloud sync": "云同步", + "Cloud sync description": "把每一份备份都复制到这台机器之外的地方。快照是不可变的文件,因此同步无需合并:两边最终都会拥有对方的文件,其中任何一份都可以从上面的列表中恢复。", + "Copies go to": "副本位置", "Delete this backup?": "删除这个备份?", "Deleted": "删除", "Download": "下载", "Due now": "现在就该备份", + "Endpoint": "接入点", + "Endpoint hint": "留空则使用该地区的 AWS 接入点。", "Every {count} hours": "每 {count} 小时一次", "Export": "导出", "Export to a file": "导出为文件", "Failed": "失败", + "Fetch what is there": "拉取远端文件", + "Folder": "文件夹", + "Folder description": "这台机器上的一个目录。指向 Dropbox、OneDrive、iCloud 云盘或 NAS 客户端已经在同步的文件夹,副本便会随之上传。", + "Folder on the server": "服务器上的文件夹", + "Folder on the server hint": "留空则使用地址本身指向的目录,不存在时会自动创建。", + "Folder path": "文件夹路径", + "Folder path hint": "支持 ~ 和环境变量,例如 %OneDrive%\\Backups。", + "Found on this machine": "在这台机器上找到:", "How many to keep": "保留数量", "How often": "备份频率", "Import": "导入", @@ -1155,10 +1169,13 @@ "Include the API keys": "包含 API 密钥", "Include the API keys hint": "不包含时,文件只描述配置,落地后还不能真正转发请求。", "Keep what is here": "保留现有的,只添加新的", + "Key prefix": "对象前缀", + "Key prefix hint": "备份在存储桶中的存放路径,留空则放在根目录。", "Keys are taken from the file": "文件里没有密钥的供应商,会保留这台机器上已有的密钥。", "LLM records": "LLM 记录", "LLM records description": "提示词可能包含敏感数据,因此元数据之外的内容都需要主动开启。", "Last backup": "上次备份", + "Last sync": "上次同步", "Max payload bytes": "单条正文上限(字节)", "Max records": "最大记录条数", "Model prices": "模型价格", @@ -1171,6 +1188,7 @@ "Nothing in it": "里面什么都没有", "Nothing is scheduled": "没有排期", "Nothing would change": "不会有任何改动", + "Nowhere yet": "尚未设置", "On a schedule": "按计划", "Once a day": "每天一次", "Once a week": "每周一次", @@ -1178,6 +1196,9 @@ "Outbound SOCKS5 proxy hint": "形如 \"host:port\",用于升级下载等出站流量。", "Overwrite what the file names": "覆盖文件里提到的", "Page description": "Casbin Gateway 的全部配置项。修改会保存到数据库并立即生效。", + "Password": "密码", + "Path-style addressing": "路径风格寻址", + "Path-style addressing hint": "MinIO 和大多数自建网关需要开启,AWS S3 保持关闭。", "Pricing file": "价格文件", "Pricing file hint": "可选的 JSON 文件,按每百万 Token 的美元价格覆盖内置价目表。", "Probe cases": "探测用例", @@ -1188,7 +1209,9 @@ "Providers": "供应商", "Queue capacity": "写入队列容量", "Queue capacity hint": "队列写满时会丢弃记录,而不是拖慢代理。", + "Reached {target}, {count} files there": "已连接 {target},那里有 {count} 个文件", "Record mode": "记录模式", + "Region": "地区", "Relay token": "中继令牌", "Relay token hint": "智能体访问 /v1 时携带的令牌。来自本机的请求不需要它。清空后保存即可重新生成。", "Replace it entirely": "完全替换", @@ -1197,26 +1220,44 @@ "Restore this backup?": "恢复这个备份?", "Restore warning": "这个备份覆盖的部分会完全回到备份当时的样子:备份里没有的会被删除。恢复前会先为当前配置备份一份。", "Retention days": "保留天数", + "S3": "S3", + "S3 description": "兼容 S3 接口的对象存储:S3、Cloudflare R2、Backblaze B2、MinIO、阿里云 OSS、腾讯云 COS 等。", + "Secret access key": "Secret Access Key", "Security": "安全", + "Server URL": "服务器地址", "Settings": "设置", "Sign-in": "登录", "Sign-in description": "Casdoor 是可选的:地址留空则使用 Gateway 自带的用户表登录。", "Skipped": "跳过", + "Storage": "存储", + "Sync now": "立即同步", + "Syncing is off": "同步已关闭", + "Syncing is on": "同步已开启", "Taken on {host} at {time}": "{time} 于 {host} 导出", + "Test connection": "测试连接", "The backup was restored": "备份已恢复", "The file carries every key": "这个文件会带上全部密钥", "The file carries every key hint": "任何打开它的人都能看到这台机器上所有供应商的 API 密钥,请按密钥本身的标准保管。", "The last backup failed": "上次备份失败", + "The last sync failed": "上次同步失败", "The snapshot was imported": "快照已导入", "The snapshot was saved": "快照已保存", + "The sync finished": "同步完成", "This file carries no keys": "这个文件里没有 API 密钥,里面每个供应商的密钥都要重新填。", "This file is not a Gateway snapshot": "这个文件不是 Gateway 的配置快照", "Updated": "更新", + "Username": "用户名", + "WebDAV": "WebDAV", + "WebDAV description": "支持 WebDAV 的网盘或 NAS:Nextcloud、ownCloud、群晖、威联通等。", + "WebDAV password hint": "多数服务要求在这里填应用专用密码,而不是登录密码。", "What this import would do": "这次导入将会做什么", "What this restore did": "这次恢复做了什么", "What this restore would do": "这次恢复将会做什么", "What to do with what is already here": "已经存在的怎么办", - "{count} in this file": "文件里有 {count} 项" + "Where backups live": "备份存放位置", + "Where backups live hint": "快照写入的目录。指向 Dropbox、OneDrive、iCloud 云盘或 NAS 已经在同步的文件夹,每次备份写入时就会被一并上传。", + "{count} in this file": "文件里有 {count} 项", + "{up} sent, {down} received, {gone} dropped": "上传 {up} 个,下载 {down} 个,清理 {gone} 个" }, "usage": { "Add a price": "新增价格", diff --git a/web/src/pages/SettingPage.tsx b/web/src/pages/SettingPage.tsx index 93ba034..50ad9e0 100644 --- a/web/src/pages/SettingPage.tsx +++ b/web/src/pages/SettingPage.tsx @@ -18,6 +18,7 @@ import i18next from "i18next"; import * as SettingBackend from "@/backend/SettingBackend"; import * as Setting from "@/Setting"; import {BackupPanel} from "@/components/settings/backup-panel"; +import {CloudSyncPanel} from "@/components/settings/cloud-sync-panel"; import {ImportExportPanel} from "@/components/settings/import-export-panel"; import {Field} from "@/components/shared/form-dialog"; import {Loading} from "@/components/shared/loading"; @@ -38,6 +39,9 @@ export default function SettingPage({account}: {account: Account}) { const isAdmin = Setting.isAdminUser(account); const [setting, setSetting] = React.useState<SettingType | null>(null); const [saving, setSaving] = React.useState(false); + // Bumped when a cloud sync pulls something, which is what makes the list of + // backups above it reload. + const [synced, setSynced] = React.useState(0); // The panels below store parts of this same row, so the form is reloaded // after one of them writes rather than left to save what it read on load. @@ -200,7 +204,9 @@ export default function SettingPage({account}: {account: Account}) { {textField("httpProxy", i18next.t("setting:Outbound SOCKS5 proxy"), i18next.t("setting:Outbound SOCKS5 proxy hint"))} </Section> - <BackupPanel onSettingChanged={load} /> + <BackupPanel onSettingChanged={load} reloadToken={synced} /> + + <CloudSyncPanel onSynced={() => setSynced(count => count + 1)} /> <ImportExportPanel onSettingChanged={load} /> </PageContainer> diff --git a/web/src/types.ts b/web/src/types.ts index 9291c12..079b51e 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -847,6 +847,12 @@ export interface Setting { backupIntervalHours: number; backupRetention: number; backupDir: string; + + cloudSyncMode: CloudSyncMode; + cloudSyncKind: string; + /** The options of the chosen kind, as one JSON document: a kind added later + * brings its own keys, and this row does not have to learn them. */ + cloudSyncOptions: string; } export type BackupMode = "auto" | "off"; @@ -931,6 +937,72 @@ export interface BackupState { latest: string; error: string; backups: Backup[]; + /** The synced folders this machine has, offered as a place to keep them. */ + folders: SyncedFolder[]; +} + +export type CloudSyncMode = "auto" | "off"; + +export type CloudSyncDirection = "both" | "up" | "down"; + +/** A folder on this machine that a desktop client already syncs. */ +export interface SyncedFolder { + name: string; + path: string; + /** The path with Gateway's own subdirectory on the end, which is what + * picking the folder fills the field with. */ + suggested: string; +} + +/** One thing a storage kind needs to be told. The server describes its own + * form, so a kind added later needs no change here. */ +export interface CloudSyncField { + name: string; + label: string; + type: "text" | "secret" | "switch"; + placeholder: string; + hint: string; + required: boolean; +} + +/** One storage Gateway can copy the backups to. */ +export interface CloudSyncKind { + name: string; + displayName: string; + description: string; + fields: CloudSyncField[]; +} + +/** One file at the target. */ +export interface CloudSyncFile { + name: string; + size: number; + modifiedTime: string; +} + +/** What one run did. */ +export interface CloudSyncResult { + uploaded: string[]; + downloaded: string[]; + removed: string[]; + skipped: number; + errors: string[]; + remote: CloudSyncFile[]; +} + +/** Where the backups are copied to, and how the last run went. */ +export interface CloudSyncState { + mode: CloudSyncMode; + kind: string; + options: Record<string, string>; + kinds: CloudSyncKind[]; + folders: SyncedFolder[]; + /** Where the copies land, in one line and without credentials. */ + target: string; + running: boolean; + syncedTime: string; + error: string; + result: CloudSyncResult | null; } /** The kinds of configuration the Skills, MCP & Prompts page manages. */ --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
