onedrive: Support for OneDrive for Business added #254

- 2 test fail (MimeType and modification date when copying)
- no headless setup
- uses the credentials for the "rclonetest" app I have created
This commit is contained in:
Oliver Heyme 2017-08-03 21:57:42 +02:00 committed by Nick Craig-Wood
parent a91448c83a
commit 7ef18b6b35
2 changed files with 160 additions and 35 deletions

View file

@ -61,6 +61,12 @@ client_id>
Microsoft App Client Secret - leave blank normally.
client_secret>
Remote config
Choose OneDrive account type?
* Say b for a OneDrive business account
* Say p for a personal OneDrive account
b) Business
p) Personal
b/p> p
Use auto config?
* Say Y if not sure
* Say N if you are working on a remote or headless machine
@ -106,6 +112,23 @@ To copy a local directory to an OneDrive directory called backup
rclone copy /home/source remote:backup
### OneDrive for Business ###
There is experimental support for OneDrive for Business.
Select "b" when ask
```
Choose OneDrive account type?
* Say b for a OneDrive business account
* Say p for a personal OneDrive account
b) Business
p) Personal
b/p>
```
After that rclone requires two authentications. First to authenicate your account
and second to get the final token to access your companies resources.
Headless authentication is not working at the moment.
### Modified time and hashes ###
OneDrive allows modification times to be set on objects accurate to 1
@ -142,10 +165,6 @@ is 10MB.
Note that OneDrive is case insensitive so you can't have a
file called "Hello.doc" and one called "hello.doc".
Rclone only supports your default OneDrive, and doesn't work with One
Drive for business. Both these issues may be fixed at some point
depending on user demand!
There are quite a few characters that can't be in OneDrive file
names. These can't occur on Windows platforms, but on non-Windows
platforms they are common. Rclone will map these names to and from an

View file

@ -25,18 +25,22 @@ import (
)
const (
rcloneClientID = "0000000044165769"
rcloneEncryptedClientSecret = "ugVWLNhKkVT1-cbTRO-6z1MlzwdW6aMwpKgNaFG-qXjEn_WfDnG9TVyRA5yuoliU"
minSleep = 10 * time.Millisecond
maxSleep = 2 * time.Second
decayConstant = 2 // bigger for slower decay, exponential
rootURL = "https://api.onedrive.com/v1.0" // root URL for requests
rclonePersonalClientID = "0000000044165769"
rclonePersonalEncryptedClientSecret = "ugVWLNhKkVT1-cbTRO-6z1MlzwdW6aMwpKgNaFG-qXjEn_WfDnG9TVyRA5yuoliU"
rcloneBusinessClientID = "52857fec-4bc2-483f-9f1b-5fe28e97532c"
rcloneBusinessEncryptedClientSecret = "6t4pC8l6L66SFYVIi8PgECDyjXy_ABo1nsTaE-Lr9LpzC6yT4vNOwHsakwwdEui0O6B0kX8_xbBLj91J"
minSleep = 10 * time.Millisecond
maxSleep = 2 * time.Second
decayConstant = 2 // bigger for slower decay, exponential
rootURLPersonal = "https://api.onedrive.com/v1.0/drive" // root URL for requests
discoveryServiceURL = "https://api.office.com/discovery/"
configResourceURL = "resource_url"
)
// Globals
var (
// Description of how to auth for this app
oauthConfig = &oauth2.Config{
// Description of how to auth for this app for a personal account
oauthPersonalConfig = &oauth2.Config{
Scopes: []string{
"wl.signin", // Allow single sign-on capabilities
"wl.offline_access", // Allow receiving a refresh token
@ -46,10 +50,23 @@ var (
AuthURL: "https://login.live.com/oauth20_authorize.srf",
TokenURL: "https://login.live.com/oauth20_token.srf",
},
ClientID: rcloneClientID,
ClientSecret: fs.MustReveal(rcloneEncryptedClientSecret),
ClientID: rclonePersonalClientID,
ClientSecret: fs.MustReveal(rclonePersonalEncryptedClientSecret),
RedirectURL: oauthutil.RedirectLocalhostURL,
}
// Description of how to auth for this app for a business account
oauthBusinessConfig = &oauth2.Config{
Endpoint: oauth2.Endpoint{
AuthURL: "https://login.microsoftonline.com/common/oauth2/authorize",
TokenURL: "https://login.microsoftonline.com/common/oauth2/token",
},
ClientID: rcloneBusinessClientID,
ClientSecret: fs.MustReveal(rcloneBusinessEncryptedClientSecret),
RedirectURL: oauthutil.RedirectLocalhostURL,
}
oauthBusinessResource = oauth2.SetAuthURLParam("resource", discoveryServiceURL)
chunkSize = fs.SizeSuffix(10 * 1024 * 1024)
uploadCutoff = fs.SizeSuffix(10 * 1024 * 1024)
)
@ -61,9 +78,74 @@ func init() {
Description: "Microsoft OneDrive",
NewFs: NewFs,
Config: func(name string) {
err := oauthutil.Config("onedrive", name, oauthConfig)
if err != nil {
log.Fatalf("Failed to configure token: %v", err)
// choose account type
fmt.Printf("Choose OneDrive account type?\n")
fmt.Printf(" * Say b for a OneDrive business account\n")
fmt.Printf(" * Say p for a personal OneDrive account\n")
isPersonal := fs.Command([]string{"bBusiness", "pPersonal"}) == 'p'
if isPersonal {
// for personal accounts we don't safe a field about the account
err := oauthutil.Config("onedrive", name, oauthPersonalConfig)
if err != nil {
log.Fatalf("Failed to configure token: %v", err)
}
} else {
err := oauthutil.Config("onedrivebusiness", name, oauthBusinessConfig, oauthBusinessResource)
if err != nil {
log.Fatalf("Failed to configure token: %v", err)
}
type serviceResource struct {
ServiceAPIVersion string `json:"serviceApiVersion"`
ServiceEndpointURI string `json:"serviceEndpointUri"`
ServiceResourceID string `json:"serviceResourceId"`
}
type serviceResponse struct {
Services []serviceResource `json:"value"`
}
oAuthClient, _, err := oauthutil.NewClient(name, oauthBusinessConfig)
if err != nil {
log.Fatalf("Failed to configure OneDrive: %v", err)
}
srv := rest.NewClient(oAuthClient).SetRoot(discoveryServiceURL)
opts := rest.Opts{
Method: "GET",
Path: "/v2.0/me/services",
}
services := serviceResponse{}
resp, err := srv.CallJSON(&opts, nil, &services)
if err != nil {
log.Fatalf("Failed to query available services: %v", err)
}
if resp.StatusCode != 200 {
log.Fatalf("Failed to query available services: Got HTTP error code %d", resp.StatusCode)
}
foundService := false
for _, service := range services.Services {
if service.ServiceAPIVersion == "v2.0" {
foundService = true
fs.ConfigFileSet(name, configResourceURL, service.ServiceResourceID)
oauthBusinessResource = oauth2.SetAuthURLParam("resource", service.ServiceResourceID)
fs.Logf(nil, "Found API %s endpoint %s", service.ServiceAPIVersion, service.ServiceEndpointURI)
}
// we only support 2.0 API
fs.Logf(nil, "Skipping API %s endpoint %s", service.ServiceAPIVersion, service.ServiceEndpointURI)
}
if !foundService {
log.Fatalf("No Service found")
}
fs.ConfigFileDeleteKey(name, fs.ConfigToken)
err = oauthutil.Config("onedrivebusiness", name, oauthBusinessConfig, oauthBusinessResource)
if err != nil {
log.Fatalf("Failed to configure token: %v", err)
}
}
},
Options: []fs.Option{{
@ -74,6 +156,7 @@ func init() {
Help: "Microsoft App Client Secret - leave blank normally.",
}},
})
fs.VarP(&chunkSize, "onedrive-chunk-size", "", "Above this size files will be chunked - must be multiple of 320k.")
fs.VarP(&uploadCutoff, "onedrive-upload-cutoff", "", "Cutoff for switching to chunked upload - must be <= 100MB")
}
@ -87,6 +170,7 @@ type Fs struct {
dirCache *dircache.DirCache // Map of directory path to directory id
pacer *pacer.Pacer // pacer for API calls
tokenRenewer *oauthutil.Renew // renew the token on expiry
isBusiness bool // true if this is an OneDrive Business account
}
// Object describes a one drive object
@ -168,7 +252,7 @@ func shouldRetry(resp *http.Response, err error) (bool, error) {
func (f *Fs) readMetaDataForPath(path string) (info *api.Item, resp *http.Response, err error) {
opts := rest.Opts{
Method: "GET",
Path: "/drive/root:/" + pathEscape(replaceReservedChars(path)),
Path: "/root:/" + pathEscape(replaceReservedChars(path)),
}
err = f.pacer.Call(func() (bool, error) {
resp, err = f.srv.CallJSON(&opts, nil, &info)
@ -193,6 +277,23 @@ func errorHandler(resp *http.Response) error {
// NewFs constructs an Fs from the path, container:path
func NewFs(name, root string) (fs.Fs, error) {
// get the resource URL from the config file0
resourceURL := fs.ConfigFileGet(name, configResourceURL, "")
// if we have a resource URL it's a business account otherwise a personal one
var rootURL string
var oauthConfig *oauth2.Config
if resourceURL == "" {
// personal account setup
oauthConfig = oauthPersonalConfig
rootURL = rootURLPersonal
} else {
// business account setup
oauthConfig = oauthBusinessConfig
rootURL = resourceURL + "_api/v2.0/drives/me"
// update the URL in the AuthOptions
oauthBusinessResource = oauth2.SetAuthURLParam("resource", resourceURL)
}
root = parsePath(root)
oAuthClient, ts, err := oauthutil.NewClient(name, oauthConfig)
if err != nil {
@ -200,14 +301,18 @@ func NewFs(name, root string) (fs.Fs, error) {
}
f := &Fs{
name: name,
root: root,
srv: rest.NewClient(oAuthClient).SetRoot(rootURL),
pacer: pacer.New().SetMinSleep(minSleep).SetMaxSleep(maxSleep).SetDecayConstant(decayConstant),
name: name,
root: root,
srv: rest.NewClient(oAuthClient).SetRoot(rootURL),
pacer: pacer.New().SetMinSleep(minSleep).SetMaxSleep(maxSleep).SetDecayConstant(decayConstant),
isBusiness: resourceURL != "",
}
f.features = (&fs.Features{
CaseInsensitive: true,
ReadMimeType: true,
CaseInsensitive: true,
// OneDrive for business doesn't support mime types properly
// so we disable it until resolved
// https://github.com/OneDrive/onedrive-api-docs/issues/643
ReadMimeType: !f.isBusiness,
CanHaveEmptyDirectories: true,
}).Fill(f)
f.srv.SetErrorHandler(errorHandler)
@ -323,7 +428,7 @@ func (f *Fs) CreateDir(pathID, leaf string) (newID string, err error) {
var info *api.Item
opts := rest.Opts{
Method: "POST",
Path: "/drive/items/" + pathID + "/children",
Path: "/items/" + pathID + "/children",
}
mkdir := api.CreateItemRequest{
Name: replaceReservedChars(leaf),
@ -357,7 +462,7 @@ func (f *Fs) listAll(dirID string, directoriesOnly bool, filesOnly bool, fn list
// https://dev.onedrive.com/odata/optional-query-parameters.htm
opts := rest.Opts{
Method: "GET",
Path: "/drive/items/" + dirID + "/children?top=1000",
Path: "/items/" + dirID + "/children?top=1000",
}
OUTER:
for {
@ -504,7 +609,7 @@ func (f *Fs) Mkdir(dir string) error {
func (f *Fs) deleteObject(id string) error {
opts := rest.Opts{
Method: "DELETE",
Path: "/drive/items/" + id,
Path: "/items/" + id,
NoResponse: true,
}
return f.pacer.Call(func() (bool, error) {
@ -644,7 +749,7 @@ func (f *Fs) Copy(src fs.Object, remote string) (fs.Object, error) {
// Copy the object
opts := rest.Opts{
Method: "POST",
Path: "/drive/items/" + srcObj.id + "/action.copy",
Path: "/items/" + srcObj.id + "/action.copy",
ExtraHeaders: map[string]string{"Prefer": "respond-async"},
NoResponse: true,
}
@ -712,7 +817,7 @@ func (f *Fs) Move(src fs.Object, remote string) (fs.Object, error) {
// Move the object
opts := rest.Opts{
Method: "PATCH",
Path: "/drive/items/" + srcObj.id,
Path: "/items/" + srcObj.id,
}
move := api.MoveItemRequest{
Name: replaceReservedChars(leaf),
@ -863,7 +968,7 @@ func (o *Object) ModTime() time.Time {
func (o *Object) setModTime(modTime time.Time) (*api.Item, error) {
opts := rest.Opts{
Method: "PATCH",
Path: "/drive/root:/" + pathEscape(o.srvPath()),
Path: "/root:/" + pathEscape(o.srvPath()),
}
update := api.SetFileSystemInfo{
FileSystemInfo: api.FileSystemInfoFacet{
@ -901,7 +1006,7 @@ func (o *Object) Open(options ...fs.OpenOption) (in io.ReadCloser, err error) {
var resp *http.Response
opts := rest.Opts{
Method: "GET",
Path: "/drive/items/" + o.id + "/content",
Path: "/items/" + o.id + "/content",
Options: options,
}
err = o.fs.pacer.Call(func() (bool, error) {
@ -918,7 +1023,7 @@ func (o *Object) Open(options ...fs.OpenOption) (in io.ReadCloser, err error) {
func (o *Object) createUploadSession() (response *api.CreateUploadResponse, err error) {
opts := rest.Opts{
Method: "POST",
Path: "/drive/root:/" + pathEscape(o.srvPath()) + ":/upload.createSession",
Path: "/root:/" + pathEscape(o.srvPath()) + ":/upload.createSession",
}
var resp *http.Response
err = o.fs.pacer.Call(func() (bool, error) {
@ -1023,9 +1128,10 @@ func (o *Object) Update(in io.Reader, src fs.ObjectInfo, options ...fs.OpenOptio
// This is for less than 100 MB of content
var resp *http.Response
opts := rest.Opts{
Method: "PUT",
Path: "/drive/root:/" + pathEscape(o.srvPath()) + ":/content",
Body: in,
Method: "PUT",
Path: "/root:/" + pathEscape(o.srvPath()) + ":/content",
ContentLength: &size,
Body: in,
}
// for go1.8 (see release notes) we must nil the Body if we want a
// "Content-Length: 0" header which onedrive requires for all files.