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]

Reply via email to