mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-07 15:13:51 +08:00
- 新增 WebDAV 与 S3 独立配置、凭据和远端状态管理 - 使用 Argon2id 与 AES-256-GCM 加密连接及配置备份 - 支持分类备份、自动同步、远端预览和一次性确认恢复 - 连接与已保存查询按 ID 合并恢复,并保留本地独有数据 - 补充前后端回归测试、六语言文案和恢复失败回滚
368 lines
11 KiB
Go
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"
|
|
}
|