Files
MyGoNavi/internal/cloudbackup/remote.go
AutumnNazi ee53a56183 feat(cloud-backup): 支持加密云端备份与选择性恢复
- 新增 WebDAV 与 S3 独立配置、凭据和远端状态管理
- 使用 Argon2id 与 AES-256-GCM 加密连接及配置备份
- 支持分类备份、自动同步、远端预览和一次性确认恢复
- 连接与已保存查询按 ID 合并恢复,并保留本地独有数据
- 补充前后端回归测试、六语言文案和恢复失败回滚
2026-07-27 20:20:00 +08:00

368 lines
11 KiB
Go

package cloudbackup
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const (
ProviderWebDAV = "webdav"
ProviderS3 = "s3"
webDAVMKCOL = "MKCOL"
)
type RemoteConfig struct {
Provider string
Endpoint string
Bucket string
ObjectKey string
Region string
}
type Credentials struct {
Username string
Password string
AccessKey string
SecretKey string
}
type ObjectMetadata struct {
ETag string `json:"etag,omitempty"`
LastModified string `json:"lastModified,omitempty"`
Size int64 `json:"size,omitempty"`
}
type Remote interface {
Put(context.Context, []byte) (ObjectMetadata, error)
Get(context.Context) ([]byte, ObjectMetadata, error)
Head(context.Context) (ObjectMetadata, error)
}
func NewRemote(config RemoteConfig, credentials Credentials, client *http.Client) (Remote, error) {
config.Provider = strings.ToLower(strings.TrimSpace(config.Provider))
config.Endpoint = strings.TrimRight(strings.TrimSpace(config.Endpoint), "/")
config.ObjectKey = strings.Trim(strings.TrimSpace(config.ObjectKey), "/")
if config.Endpoint == "" {
return nil, errors.New("backup endpoint is required")
}
parsed, err := url.Parse(config.Endpoint)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return nil, errors.New("backup endpoint must be an absolute URL")
}
if parsed.Scheme != "https" && !isLoopbackHost(parsed.Hostname()) {
return nil, errors.New("backup endpoint must use HTTPS unless it targets localhost")
}
if client == nil {
client = &http.Client{Timeout: 45 * time.Second}
}
switch config.Provider {
case ProviderWebDAV:
if config.ObjectKey == "" {
return nil, errors.New("WebDAV backup file path is required")
}
if strings.TrimSpace(credentials.Username) == "" || strings.TrimSpace(credentials.Password) == "" {
return nil, errors.New("WebDAV username and password are required")
}
return &webDAVRemote{config: config, credentials: credentials, client: client}, nil
case ProviderS3:
if config.ObjectKey == "" {
return nil, errors.New("S3 object key is required")
}
if strings.TrimSpace(config.Bucket) == "" || strings.TrimSpace(config.Region) == "" || strings.TrimSpace(credentials.AccessKey) == "" || strings.TrimSpace(credentials.SecretKey) == "" {
return nil, errors.New("S3 bucket, region, access key and secret key are required")
}
return &s3Remote{config: config, credentials: credentials, client: client}, nil
default:
return nil, fmt.Errorf("unsupported backup provider: %s", config.Provider)
}
}
type webDAVRemote struct {
config RemoteConfig
credentials Credentials
client *http.Client
}
func (r *webDAVRemote) objectURL() string {
base, err := url.Parse(r.config.Endpoint)
if err != nil {
return ""
}
base.Path = appendObjectPath(base.Path, r.config.ObjectKey)
base.RawPath = ""
return base.String()
}
func (r *webDAVRemote) request(ctx context.Context, method string, body []byte) (*http.Response, error) {
return r.requestURL(ctx, method, r.objectURL(), body)
}
func (r *webDAVRemote) requestURL(ctx context.Context, method, requestURL string, body []byte) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, requestURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.SetBasicAuth(r.credentials.Username, r.credentials.Password)
if method == http.MethodPut {
req.Header.Set("Content-Type", "application/octet-stream")
}
return r.client.Do(req)
}
func (r *webDAVRemote) Put(ctx context.Context, body []byte) (ObjectMetadata, error) {
resp, err := r.request(ctx, http.MethodPut, body)
if err != nil {
return ObjectMetadata{}, err
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
defer resp.Body.Close()
return ObjectMetadata{ETag: strings.Trim(resp.Header.Get("ETag"), `"`)}, nil
}
putErr := readRemoteError(resp)
_ = resp.Body.Close()
if resp.StatusCode != http.StatusNotFound && resp.StatusCode != http.StatusConflict {
return ObjectMetadata{}, putErr
}
collectionURLs := r.collectionURLs()
if len(collectionURLs) == 0 {
return ObjectMetadata{}, putErr
}
if err := r.ensureCollections(ctx, collectionURLs); err != nil {
return ObjectMetadata{}, err
}
resp, err = r.request(ctx, http.MethodPut, body)
if err != nil {
return ObjectMetadata{}, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return ObjectMetadata{}, readRemoteError(resp)
}
return ObjectMetadata{ETag: strings.Trim(resp.Header.Get("ETag"), `"`)}, nil
}
func (r *webDAVRemote) collectionURLs() []string {
base, err := url.Parse(r.config.Endpoint)
if err != nil {
return nil
}
segments := strings.Split(r.config.ObjectKey, "/")
urls := make([]string, 0, len(segments)-1)
for _, segment := range segments[:len(segments)-1] {
if strings.TrimSpace(segment) == "" {
continue
}
base.Path = appendObjectPath(base.Path, segment)
base.RawPath = ""
urls = append(urls, base.String())
}
return urls
}
func (r *webDAVRemote) ensureCollections(ctx context.Context, collectionURLs []string) error {
for _, collectionURL := range collectionURLs {
resp, err := r.requestURL(ctx, webDAVMKCOL, collectionURL, nil)
if err != nil {
return err
}
if (resp.StatusCode < 200 || resp.StatusCode >= 300) && resp.StatusCode != http.StatusMethodNotAllowed {
requestErr := readRemoteError(resp)
_ = resp.Body.Close()
return requestErr
}
_ = resp.Body.Close()
}
return nil
}
func (r *webDAVRemote) Get(ctx context.Context) ([]byte, ObjectMetadata, error) {
resp, err := r.request(ctx, http.MethodGet, nil)
if err != nil {
return nil, ObjectMetadata{}, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, ObjectMetadata{}, readRemoteError(resp)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxEnvelopeBytes+1))
if err != nil {
return nil, ObjectMetadata{}, err
}
if len(body) > maxEnvelopeBytes {
return nil, ObjectMetadata{}, errors.New("remote backup is too large")
}
return body, responseMetadata(resp), nil
}
func (r *webDAVRemote) Head(ctx context.Context) (ObjectMetadata, error) {
resp, err := r.request(ctx, http.MethodHead, nil)
if err != nil {
return ObjectMetadata{}, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return ObjectMetadata{}, readRemoteError(resp)
}
return responseMetadata(resp), nil
}
type s3Remote struct {
config RemoteConfig
credentials Credentials
client *http.Client
}
func (r *s3Remote) objectURL() (string, string, error) {
base, err := url.Parse(r.config.Endpoint)
if err != nil {
return "", "", err
}
base.Path = appendObjectPath(base.Path, strings.Join(append([]string{r.config.Bucket}, strings.Split(r.config.ObjectKey, "/")...), "/"))
base.RawPath = ""
return base.String(), base.EscapedPath(), nil
}
func appendObjectPath(basePath, objectPath string) string {
path := strings.TrimSuffix(strings.TrimSpace(basePath), "/")
for _, segment := range strings.Split(objectPath, "/") {
if strings.TrimSpace(segment) == "" {
continue
}
path += "/" + segment
}
if path == "" {
return "/"
}
if !strings.HasPrefix(path, "/") {
return "/" + path
}
return path
}
func (r *s3Remote) do(ctx context.Context, method string, body []byte) (*http.Response, error) {
requestURL, canonicalURI, err := r.objectURL()
if err != nil {
return nil, err
}
parsed, err := url.Parse(requestURL)
if err != nil {
return nil, err
}
payloadHash := sha256Hex(body)
now := time.Now().UTC()
amzDate := now.Format("20060102T150405Z")
date := now.Format("20060102")
canonicalHeaders := "host:" + parsed.Host + "\n" + "x-amz-content-sha256:" + payloadHash + "\n" + "x-amz-date:" + amzDate + "\n"
signedHeaders := "host;x-amz-content-sha256;x-amz-date"
canonicalRequest := strings.Join([]string{method, canonicalURI, "", canonicalHeaders, signedHeaders, payloadHash}, "\n")
credentialScope := date + "/" + r.config.Region + "/s3/aws4_request"
stringToSign := strings.Join([]string{"AWS4-HMAC-SHA256", amzDate, credentialScope, sha256Hex([]byte(canonicalRequest))}, "\n")
signature := hex.EncodeToString(signingKey([]byte(r.credentials.SecretKey), date, r.config.Region, "s3", stringToSign))
req, err := http.NewRequestWithContext(ctx, method, requestURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Host", parsed.Host)
req.Header.Set("x-amz-content-sha256", payloadHash)
req.Header.Set("x-amz-date", amzDate)
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+r.credentials.AccessKey+"/"+credentialScope+", SignedHeaders="+signedHeaders+", Signature="+signature)
if method == http.MethodPut {
req.Header.Set("Content-Type", "application/octet-stream")
}
return r.client.Do(req)
}
func (r *s3Remote) Put(ctx context.Context, body []byte) (ObjectMetadata, error) {
resp, err := r.do(ctx, http.MethodPut, body)
if err != nil {
return ObjectMetadata{}, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return ObjectMetadata{}, readRemoteError(resp)
}
return ObjectMetadata{ETag: strings.Trim(resp.Header.Get("ETag"), `"`)}, nil
}
func (r *s3Remote) Get(ctx context.Context) ([]byte, ObjectMetadata, error) {
resp, err := r.do(ctx, http.MethodGet, nil)
if err != nil {
return nil, ObjectMetadata{}, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, ObjectMetadata{}, readRemoteError(resp)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxEnvelopeBytes+1))
if err != nil {
return nil, ObjectMetadata{}, err
}
if len(body) > maxEnvelopeBytes {
return nil, ObjectMetadata{}, errors.New("remote backup is too large")
}
return body, responseMetadata(resp), nil
}
func (r *s3Remote) Head(ctx context.Context) (ObjectMetadata, error) {
resp, err := r.do(ctx, http.MethodHead, nil)
if err != nil {
return ObjectMetadata{}, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return ObjectMetadata{}, readRemoteError(resp)
}
return responseMetadata(resp), nil
}
func signingKey(secret []byte, date, region, service, value string) []byte {
kDate := hmacSHA256([]byte("AWS4"+string(secret)), []byte(date))
kRegion := hmacSHA256(kDate, []byte(region))
kService := hmacSHA256(kRegion, []byte(service))
kSigning := hmacSHA256(kService, []byte("aws4_request"))
return hmacSHA256(kSigning, []byte(value))
}
func hmacSHA256(key, value []byte) []byte {
h := hmac.New(sha256.New, key)
_, _ = h.Write(value)
return h.Sum(nil)
}
func sha256Hex(value []byte) string {
sum := sha256.Sum256(value)
return hex.EncodeToString(sum[:])
}
func responseMetadata(resp *http.Response) ObjectMetadata {
var size int64
if resp.ContentLength >= 0 {
size = resp.ContentLength
}
return ObjectMetadata{ETag: strings.Trim(resp.Header.Get("ETag"), `"`), LastModified: resp.Header.Get("Last-Modified"), Size: size}
}
func readRemoteError(resp *http.Response) error {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
detail := strings.TrimSpace(string(body))
if detail == "" {
detail = resp.Status
}
return fmt.Errorf("remote backup request failed: %s: %s", resp.Status, detail)
}
func isLoopbackHost(host string) bool {
host = strings.ToLower(strings.TrimSpace(host))
return host == "localhost" || host == "127.0.0.1" || host == "::1"
}