mirror of
https://github.com/gilbertchen/duplicacy
synced 2025-12-06 00:03:38 +00:00
531 lines
13 KiB
Go
531 lines
13 KiB
Go
// Copyright (c) Acrosync LLC. All rights reserved.
|
|
// Free for personal use and commercial trial
|
|
// Commercial use requires per-user licenses available from https://duplicacy.com
|
|
|
|
package duplicacy
|
|
|
|
import (
|
|
"context"
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"io/ioutil"
|
|
"math/rand"
|
|
"net/http"
|
|
"strings"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
"path/filepath"
|
|
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
type OneDriveError struct {
|
|
Status int
|
|
Message string
|
|
}
|
|
|
|
func (err OneDriveError) Error() string {
|
|
return fmt.Sprintf("%d %s", err.Status, err.Message)
|
|
}
|
|
|
|
type OneDriveErrorResponse struct {
|
|
Error OneDriveError `json:"error"`
|
|
}
|
|
|
|
type OneDriveClient struct {
|
|
HTTPClient *http.Client
|
|
|
|
TokenFile string
|
|
Token *oauth2.Token
|
|
OAConfig *oauth2.Config
|
|
TokenLock *sync.Mutex
|
|
|
|
IsConnected bool
|
|
TestMode bool
|
|
|
|
IsBusiness bool
|
|
RefreshTokenURL string
|
|
APIURL string
|
|
}
|
|
|
|
func NewOneDriveClient(tokenFile string, isBusiness bool, client_id string, client_secret string, drive_id string) (*OneDriveClient, error) {
|
|
|
|
description, err := ioutil.ReadFile(tokenFile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
token := new(oauth2.Token)
|
|
if err := json.Unmarshal(description, token); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
client := &OneDriveClient{
|
|
HTTPClient: http.DefaultClient,
|
|
TokenFile: tokenFile,
|
|
Token: token,
|
|
OAConfig: nil,
|
|
TokenLock: &sync.Mutex{},
|
|
IsBusiness: isBusiness,
|
|
}
|
|
|
|
if (client_id != "") {
|
|
oneOauthConfig := oauth2.Config{
|
|
ClientID: client_id,
|
|
ClientSecret: client_secret,
|
|
Scopes: []string{"Files.ReadWrite", "offline_access"},
|
|
Endpoint: oauth2.Endpoint{
|
|
AuthURL: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
|
|
TokenURL: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
|
|
},
|
|
}
|
|
|
|
client.OAConfig = &oneOauthConfig
|
|
}
|
|
|
|
if isBusiness {
|
|
client.RefreshTokenURL = "https://duplicacy.com/odb_refresh"
|
|
client.APIURL = "https://graph.microsoft.com/v1.0/me/drive"
|
|
if drive_id != "" {
|
|
client.APIURL = "https://graph.microsoft.com/v1.0/drives/"+drive_id
|
|
}
|
|
} else {
|
|
client.RefreshTokenURL = "https://duplicacy.com/one_refresh"
|
|
client.APIURL = "https://api.onedrive.com/v1.0/drive"
|
|
}
|
|
|
|
client.RefreshToken(false)
|
|
|
|
return client, nil
|
|
}
|
|
|
|
func (client *OneDriveClient) call(url string, method string, input interface{}, contentType string) (io.ReadCloser, int64, error) {
|
|
|
|
var response *http.Response
|
|
|
|
backoff := 1
|
|
for i := 0; i < 12; i++ {
|
|
|
|
LOG_DEBUG("ONEDRIVE_CALL", "%s %s", method, url)
|
|
|
|
var inputReader io.Reader
|
|
|
|
switch input.(type) {
|
|
default:
|
|
jsonInput, err := json.Marshal(input)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
inputReader = bytes.NewReader(jsonInput)
|
|
case []byte:
|
|
inputReader = bytes.NewReader(input.([]byte))
|
|
case int:
|
|
inputReader = nil
|
|
case *bytes.Buffer:
|
|
inputReader = bytes.NewReader(input.(*bytes.Buffer).Bytes())
|
|
case *RateLimitedReader:
|
|
input.(*RateLimitedReader).Reset()
|
|
inputReader = input.(*RateLimitedReader)
|
|
}
|
|
|
|
request, err := http.NewRequest(method, url, inputReader)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
if reader, ok := inputReader.(*RateLimitedReader); ok {
|
|
request.ContentLength = reader.Length()
|
|
request.Header.Set("Content-Range", fmt.Sprintf("bytes 0-%d/%d", reader.Length() - 1, reader.Length()))
|
|
}
|
|
|
|
if url != client.RefreshTokenURL {
|
|
client.TokenLock.Lock()
|
|
request.Header.Set("Authorization", "Bearer "+client.Token.AccessToken)
|
|
client.TokenLock.Unlock()
|
|
}
|
|
if contentType != "" {
|
|
request.Header.Set("Content-Type", contentType)
|
|
}
|
|
|
|
request.Header.Set("User-Agent", "ISV|Acrosync|Duplicacy/2.0")
|
|
|
|
response, err = client.HTTPClient.Do(request)
|
|
if err != nil {
|
|
if client.IsConnected {
|
|
if strings.Contains(err.Error(), "TLS handshake timeout") {
|
|
// Give a long timeout regardless of backoff when a TLS timeout happens, hoping that
|
|
// idle connections are not to be reused on reconnect.
|
|
retryAfter := time.Duration(rand.Float32()*60000 + 180000)
|
|
LOG_INFO("ONEDRIVE_RETRY", "TLS handshake timeout; retry after %d milliseconds", retryAfter)
|
|
time.Sleep(retryAfter * time.Millisecond)
|
|
} else {
|
|
// For all other errors just blindly retry until the maximum is reached
|
|
retryAfter := time.Duration(rand.Float32() * 1000.0 * float32(backoff))
|
|
LOG_INFO("ONEDRIVE_RETRY", "%v; retry after %d milliseconds", err, retryAfter)
|
|
time.Sleep(retryAfter * time.Millisecond)
|
|
}
|
|
backoff *= 2
|
|
if backoff > 256 {
|
|
backoff = 256
|
|
}
|
|
continue
|
|
}
|
|
return nil, 0, err
|
|
}
|
|
|
|
client.IsConnected = true
|
|
|
|
if response.StatusCode < 400 {
|
|
return response.Body, response.ContentLength, nil
|
|
}
|
|
|
|
defer response.Body.Close()
|
|
|
|
errorResponse := &OneDriveErrorResponse{
|
|
Error: OneDriveError{Status: response.StatusCode},
|
|
}
|
|
|
|
if response.StatusCode == 401 {
|
|
|
|
if url == client.RefreshTokenURL {
|
|
return nil, 0, OneDriveError{Status: response.StatusCode, Message: "Authorization error when refreshing token"}
|
|
}
|
|
|
|
err = client.RefreshToken(true)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
continue
|
|
} else if response.StatusCode == 409 {
|
|
return nil, 0, OneDriveError{Status: response.StatusCode, Message: "Conflict"}
|
|
} else if response.StatusCode > 401 && response.StatusCode != 404 {
|
|
delay := int((rand.Float32() * 0.5 + 0.5) * 1000.0 * float32(backoff))
|
|
if backoffList, found := response.Header["Retry-After"]; found && len(backoffList) > 0 {
|
|
retryAfter, _ := strconv.Atoi(backoffList[0])
|
|
if retryAfter * 1000 > delay {
|
|
delay = retryAfter * 1000
|
|
}
|
|
}
|
|
|
|
LOG_INFO("ONEDRIVE_RETRY", "Response code: %d; retry after %d milliseconds", response.StatusCode, delay)
|
|
time.Sleep(time.Duration(delay) * time.Millisecond)
|
|
backoff *= 2
|
|
if backoff > 256 {
|
|
backoff = 256
|
|
}
|
|
continue
|
|
} else {
|
|
if err := json.NewDecoder(response.Body).Decode(errorResponse); err != nil {
|
|
return nil, 0, OneDriveError{Status: response.StatusCode, Message: fmt.Sprintf("Unexpected response")}
|
|
}
|
|
|
|
errorResponse.Error.Status = response.StatusCode
|
|
return nil, 0, errorResponse.Error
|
|
}
|
|
}
|
|
|
|
return nil, 0, fmt.Errorf("Maximum number of retries reached")
|
|
}
|
|
|
|
func (client *OneDriveClient) RefreshToken(force bool) (err error) {
|
|
client.TokenLock.Lock()
|
|
defer client.TokenLock.Unlock()
|
|
|
|
if !force && client.Token.Valid() {
|
|
return nil
|
|
}
|
|
|
|
if (client.OAConfig == nil) {
|
|
readCloser, _, err := client.call(client.RefreshTokenURL, "POST", client.Token, "")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to refresh the access token: %v", err)
|
|
}
|
|
|
|
defer readCloser.Close()
|
|
|
|
if err = json.NewDecoder(readCloser).Decode(client.Token); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
ctx := context.Background()
|
|
tokenSource := client.OAConfig.TokenSource(ctx, client.Token)
|
|
token, err := tokenSource.Token()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to refresh the access token: %v", err)
|
|
}
|
|
client.Token = token
|
|
}
|
|
|
|
description, err := json.Marshal(client.Token)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = ioutil.WriteFile(client.TokenFile, description, 0644)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type OneDriveEntry struct {
|
|
ID string
|
|
Name string
|
|
Folder map[string]interface{}
|
|
Size int64
|
|
}
|
|
|
|
type OneDriveListEntriesOutput struct {
|
|
Entries []OneDriveEntry `json:"value"`
|
|
NextLink string `json:"@odata.nextLink"`
|
|
}
|
|
|
|
func (client *OneDriveClient) ListEntries(path string) ([]OneDriveEntry, error) {
|
|
|
|
entries := []OneDriveEntry{}
|
|
|
|
url := client.APIURL + "/root:/" + path + ":/children"
|
|
if path == "" {
|
|
url = client.APIURL + "/root/children"
|
|
}
|
|
if client.TestMode {
|
|
url += "?top=8"
|
|
} else {
|
|
url += "?top=1000"
|
|
}
|
|
url += "&select=name,size,folder"
|
|
|
|
for {
|
|
readCloser, _, err := client.call(url, "GET", 0, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
defer readCloser.Close()
|
|
|
|
output := &OneDriveListEntriesOutput{}
|
|
|
|
if err = json.NewDecoder(readCloser).Decode(&output); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
entries = append(entries, output.Entries...)
|
|
|
|
url = output.NextLink
|
|
if url == "" {
|
|
break
|
|
}
|
|
}
|
|
|
|
return entries, nil
|
|
}
|
|
|
|
func (client *OneDriveClient) GetFileInfo(path string) (string, bool, int64, error) {
|
|
|
|
url := client.APIURL + "/root:/" + path
|
|
if path == "" { url = client.APIURL + "/root" }
|
|
url += "?select=id,name,size,folder"
|
|
|
|
readCloser, _, err := client.call(url, "GET", 0, "")
|
|
if err != nil {
|
|
if e, ok := err.(OneDriveError); ok && e.Status == 404 {
|
|
return "", false, 0, nil
|
|
} else {
|
|
return "", false, 0, err
|
|
}
|
|
}
|
|
|
|
defer readCloser.Close()
|
|
|
|
output := &OneDriveEntry{}
|
|
|
|
if err = json.NewDecoder(readCloser).Decode(&output); err != nil {
|
|
return "", false, 0, err
|
|
}
|
|
|
|
return output.ID, len(output.Folder) != 0, output.Size, nil
|
|
}
|
|
|
|
func (client *OneDriveClient) DownloadFile(path string) (io.ReadCloser, int64, error) {
|
|
|
|
url := client.APIURL + "/items/root:/" + path + ":/content"
|
|
|
|
return client.call(url, "GET", 0, "")
|
|
}
|
|
|
|
func (client *OneDriveClient) UploadFile(path string, content []byte, rateLimit int) (err error) {
|
|
|
|
// Upload file using the simple method; this is only possible for OneDrive Personal or if the file
|
|
// is smaller than 4MB for OneDrive Business
|
|
if !client.IsBusiness || (client.TestMode && rand.Int() % 2 == 0) {
|
|
url := client.APIURL + "/root:/" + path + ":/content"
|
|
|
|
readCloser, _, err := client.call(url, "PUT", CreateRateLimitedReader(content, rateLimit), "application/octet-stream")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
readCloser.Close()
|
|
return nil
|
|
}
|
|
|
|
// For large files, create an upload session first
|
|
uploadURL, err := client.CreateUploadSession(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return client.UploadFileSession(uploadURL, content, rateLimit)
|
|
}
|
|
|
|
func (client *OneDriveClient) CreateUploadSession(path string) (uploadURL string, err error) {
|
|
|
|
type CreateUploadSessionItem struct {
|
|
ConflictBehavior string `json:"@microsoft.graph.conflictBehavior"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
input := map[string]interface{} {
|
|
"item": CreateUploadSessionItem {
|
|
ConflictBehavior: "replace",
|
|
Name: filepath.Base(path),
|
|
},
|
|
}
|
|
|
|
readCloser, _, err := client.call(client.APIURL + "/root:/" + path + ":/createUploadSession", "POST", input, "application/json")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
type CreateUploadSessionOutput struct {
|
|
UploadURL string `json:"uploadUrl"`
|
|
}
|
|
|
|
output := &CreateUploadSessionOutput{}
|
|
|
|
if err = json.NewDecoder(readCloser).Decode(&output); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
readCloser.Close()
|
|
return output.UploadURL, nil
|
|
}
|
|
|
|
func (client *OneDriveClient) UploadFileSession(uploadURL string, content []byte, rateLimit int) (err error) {
|
|
|
|
readCloser, _, err := client.call(uploadURL, "PUT", CreateRateLimitedReader(content, rateLimit), "")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
type UploadFileSessionOutput struct {
|
|
Size int `json:"size"`
|
|
}
|
|
output := &UploadFileSessionOutput{}
|
|
|
|
if err = json.NewDecoder(readCloser).Decode(&output); err != nil {
|
|
return fmt.Errorf("Failed to complete the file upload session: %v", err)
|
|
}
|
|
|
|
if output.Size != len(content) {
|
|
return fmt.Errorf("Uploaded %d bytes out of %d bytes", output.Size, len(content))
|
|
}
|
|
|
|
readCloser.Close()
|
|
return nil
|
|
}
|
|
|
|
func (client *OneDriveClient) DeleteFile(path string) error {
|
|
|
|
url := client.APIURL + "/root:/" + path
|
|
|
|
readCloser, _, err := client.call(url, "DELETE", 0, "")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
readCloser.Close()
|
|
return nil
|
|
}
|
|
|
|
func (client *OneDriveClient) MoveFile(path string, parent string) error {
|
|
|
|
url := client.APIURL + "/root:/" + path
|
|
|
|
parentReference := make(map[string]string)
|
|
parentReference["path"] = "/root:/" + parent
|
|
|
|
parameters := make(map[string]interface{})
|
|
parameters["parentReference"] = parentReference
|
|
|
|
readCloser, _, err := client.call(url, "PATCH", parameters, "application/json")
|
|
if err != nil {
|
|
if e, ok := err.(OneDriveError); ok && e.Status == 400 {
|
|
// The destination directory doesn't exist; trying to create it...
|
|
dir := filepath.Dir(parent)
|
|
if dir == "." {
|
|
dir = ""
|
|
}
|
|
client.CreateDirectory(dir, filepath.Base(parent))
|
|
readCloser, _, err = client.call(url, "PATCH", parameters, "application/json")
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
readCloser.Close()
|
|
return nil
|
|
}
|
|
|
|
func (client *OneDriveClient) CreateDirectory(path string, name string) error {
|
|
|
|
url := client.APIURL + "/root/children"
|
|
|
|
if path != "" {
|
|
|
|
pathID, isDir, _, err := client.GetFileInfo(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if pathID == "" {
|
|
dir := filepath.Dir(path)
|
|
if dir != "." {
|
|
// The parent directory doesn't exist; trying to create it...
|
|
client.CreateDirectory(dir, filepath.Base(path))
|
|
isDir = true
|
|
}
|
|
}
|
|
|
|
if !isDir {
|
|
return fmt.Errorf("The path '%s' is not a directory", path)
|
|
}
|
|
|
|
url = client.APIURL + "/root:/" + path + ":/children"
|
|
}
|
|
|
|
parameters := make(map[string]interface{})
|
|
parameters["name"] = name
|
|
parameters["folder"] = make(map[string]int)
|
|
|
|
readCloser, _, err := client.call(url, "POST", parameters, "application/json")
|
|
if err != nil {
|
|
if e, ok := err.(OneDriveError); ok && e.Status == 409 {
|
|
// This error usually means the directory already exists
|
|
LOG_TRACE("ONEDRIVE_MKDIR", "The directory '%s/%s' already exists", path, name)
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
readCloser.Close()
|
|
return nil
|
|
}
|