mirror of
https://github.com/rclone/rclone.git
synced 2024-12-19 01:03:42 +08:00
65012beea4
This commit reorganises the oauth code to use our own config struct which has all the info for the normal oauth method and also the client credentials flow method. It updates all backends which use lib/oauthutil to use the new config struct which shouldn't change any functionality. It also adds code for dealing with the client credential flow config which doesn't require the use of a browser and doesn't have or need a refresh token. Co-authored-by: Nick Craig-Wood <nick@craig-wood.com>
2152 lines
65 KiB
Go
2152 lines
65 KiB
Go
// Package jottacloud provides an interface to the Jottacloud storage system.
|
|
package jottacloud
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/md5"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math/rand"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rclone/rclone/backend/jottacloud/api"
|
|
"github.com/rclone/rclone/fs"
|
|
"github.com/rclone/rclone/fs/accounting"
|
|
"github.com/rclone/rclone/fs/config"
|
|
"github.com/rclone/rclone/fs/config/configmap"
|
|
"github.com/rclone/rclone/fs/config/configstruct"
|
|
"github.com/rclone/rclone/fs/config/obscure"
|
|
"github.com/rclone/rclone/fs/fserrors"
|
|
"github.com/rclone/rclone/fs/fshttp"
|
|
"github.com/rclone/rclone/fs/hash"
|
|
"github.com/rclone/rclone/fs/walk"
|
|
"github.com/rclone/rclone/lib/encoder"
|
|
"github.com/rclone/rclone/lib/oauthutil"
|
|
"github.com/rclone/rclone/lib/pacer"
|
|
"github.com/rclone/rclone/lib/rest"
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
// Globals
|
|
const (
|
|
minSleep = 10 * time.Millisecond
|
|
maxSleep = 2 * time.Second
|
|
decayConstant = 2 // bigger for slower decay, exponential
|
|
defaultDevice = "Jotta"
|
|
defaultMountpoint = "Archive"
|
|
jfsURL = "https://jfs.jottacloud.com/jfs/"
|
|
apiURL = "https://api.jottacloud.com/"
|
|
wwwURL = "https://www.jottacloud.com/"
|
|
cachePrefix = "rclone-jcmd5-"
|
|
configDevice = "device"
|
|
configMountpoint = "mountpoint"
|
|
configTokenURL = "tokenURL"
|
|
configClientID = "client_id"
|
|
configClientSecret = "client_secret"
|
|
configUsername = "username"
|
|
configVersion = 1
|
|
|
|
defaultTokenURL = "https://id.jottacloud.com/auth/realms/jottacloud/protocol/openid-connect/token"
|
|
defaultClientID = "jottacli"
|
|
|
|
legacyTokenURL = "https://api.jottacloud.com/auth/v1/token"
|
|
legacyRegisterURL = "https://api.jottacloud.com/auth/v1/register"
|
|
legacyClientID = "nibfk8biu12ju7hpqomr8b1e40"
|
|
legacyEncryptedClientSecret = "Vp8eAv7eVElMnQwN-kgU9cbhgApNDaMqWdlDi5qFydlQoji4JBxrGMF2"
|
|
legacyConfigVersion = 0
|
|
|
|
teliaseCloudTokenURL = "https://cloud-auth.telia.se/auth/realms/telia_se/protocol/openid-connect/token"
|
|
teliaseCloudAuthURL = "https://cloud-auth.telia.se/auth/realms/telia_se/protocol/openid-connect/auth"
|
|
teliaseCloudClientID = "desktop"
|
|
|
|
telianoCloudTokenURL = "https://sky-auth.telia.no/auth/realms/get/protocol/openid-connect/token"
|
|
telianoCloudAuthURL = "https://sky-auth.telia.no/auth/realms/get/protocol/openid-connect/auth"
|
|
telianoCloudClientID = "desktop"
|
|
|
|
tele2CloudTokenURL = "https://mittcloud-auth.tele2.se/auth/realms/comhem/protocol/openid-connect/token"
|
|
tele2CloudAuthURL = "https://mittcloud-auth.tele2.se/auth/realms/comhem/protocol/openid-connect/auth"
|
|
tele2CloudClientID = "desktop"
|
|
|
|
onlimeCloudTokenURL = "https://cloud-auth.onlime.dk/auth/realms/onlime_wl/protocol/openid-connect/token"
|
|
onlimeCloudAuthURL = "https://cloud-auth.onlime.dk/auth/realms/onlime_wl/protocol/openid-connect/auth"
|
|
onlimeCloudClientID = "desktop"
|
|
)
|
|
|
|
// Register with Fs
|
|
func init() {
|
|
// needs to be done early so we can use oauth during config
|
|
fs.Register(&fs.RegInfo{
|
|
Name: "jottacloud",
|
|
Description: "Jottacloud",
|
|
NewFs: NewFs,
|
|
Config: Config,
|
|
MetadataInfo: &fs.MetadataInfo{
|
|
Help: `Jottacloud has limited support for metadata, currently an extended set of timestamps.`,
|
|
System: map[string]fs.MetadataHelp{
|
|
"btime": {
|
|
Help: "Time of file birth (creation), read from rclone metadata",
|
|
Type: "RFC 3339",
|
|
Example: "2006-01-02T15:04:05.999999999Z07:00",
|
|
},
|
|
"mtime": {
|
|
Help: "Time of last modification, read from rclone metadata",
|
|
Type: "RFC 3339",
|
|
Example: "2006-01-02T15:04:05.999999999Z07:00",
|
|
},
|
|
"utime": {
|
|
Help: "Time of last upload, when current revision was created, generated by backend",
|
|
Type: "RFC 3339",
|
|
Example: "2006-01-02T15:04:05.999999999Z07:00",
|
|
ReadOnly: true,
|
|
},
|
|
"content-type": {
|
|
Help: "MIME type, also known as media type",
|
|
Type: "string",
|
|
Example: "text/plain",
|
|
ReadOnly: true,
|
|
},
|
|
},
|
|
},
|
|
Options: append(oauthutil.SharedOptions, []fs.Option{{
|
|
Name: "md5_memory_limit",
|
|
Help: "Files bigger than this will be cached on disk to calculate the MD5 if required.",
|
|
Default: fs.SizeSuffix(10 * 1024 * 1024),
|
|
Advanced: true,
|
|
}, {
|
|
Name: "trashed_only",
|
|
Help: "Only show files that are in the trash.\n\nThis will show trashed files in their original directory structure.",
|
|
Default: false,
|
|
Advanced: true,
|
|
}, {
|
|
Name: "hard_delete",
|
|
Help: "Delete files permanently rather than putting them into the trash.",
|
|
Default: false,
|
|
Advanced: true,
|
|
}, {
|
|
Name: "upload_resume_limit",
|
|
Help: "Files bigger than this can be resumed if the upload fail's.",
|
|
Default: fs.SizeSuffix(10 * 1024 * 1024),
|
|
Advanced: true,
|
|
}, {
|
|
Name: "no_versions",
|
|
Help: "Avoid server side versioning by deleting files and recreating files instead of overwriting them.",
|
|
Default: false,
|
|
Advanced: true,
|
|
}, {
|
|
Name: config.ConfigEncoding,
|
|
Help: config.ConfigEncodingHelp,
|
|
Advanced: true,
|
|
// Encode invalid UTF-8 bytes as xml doesn't handle them properly.
|
|
//
|
|
// Also: '*', '/', ':', '<', '>', '?', '\"', '\x00', '|'
|
|
Default: (encoder.Display |
|
|
encoder.EncodeWin | // :?"*<>|
|
|
encoder.EncodeInvalidUtf8),
|
|
}}...),
|
|
})
|
|
}
|
|
|
|
// Config runs the backend configuration protocol
|
|
func Config(ctx context.Context, name string, m configmap.Mapper, config fs.ConfigIn) (*fs.ConfigOut, error) {
|
|
switch config.State {
|
|
case "":
|
|
return fs.ConfigChooseExclusiveFixed("auth_type_done", "config_type", `Select authentication type.`, []fs.OptionExample{{
|
|
Value: "standard",
|
|
Help: "Standard authentication.\nUse this if you're a normal Jottacloud user.",
|
|
}, {
|
|
Value: "legacy",
|
|
Help: "Legacy authentication.\nThis is only required for certain whitelabel versions of Jottacloud and not recommended for normal users.",
|
|
}, {
|
|
Value: "telia_se",
|
|
Help: "Telia Cloud authentication.\nUse this if you are using Telia Cloud (Sweden).",
|
|
}, {
|
|
Value: "telia_no",
|
|
Help: "Telia Sky authentication.\nUse this if you are using Telia Sky (Norway).",
|
|
}, {
|
|
Value: "tele2",
|
|
Help: "Tele2 Cloud authentication.\nUse this if you are using Tele2 Cloud.",
|
|
}, {
|
|
Value: "onlime",
|
|
Help: "Onlime Cloud authentication.\nUse this if you are using Onlime Cloud.",
|
|
}})
|
|
case "auth_type_done":
|
|
// Jump to next state according to config chosen
|
|
return fs.ConfigGoto(config.Result)
|
|
case "standard": // configure a jottacloud backend using the modern JottaCli token based authentication
|
|
m.Set("configVersion", fmt.Sprint(configVersion))
|
|
return fs.ConfigInput("standard_token", "config_login_token", "Personal login token.\nGenerate here: https://www.jottacloud.com/web/secure")
|
|
case "standard_token":
|
|
loginToken := config.Result
|
|
m.Set(configClientID, defaultClientID)
|
|
m.Set(configClientSecret, "")
|
|
|
|
srv := rest.NewClient(fshttp.NewClient(ctx))
|
|
token, tokenEndpoint, err := doTokenAuth(ctx, srv, loginToken)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get oauth token: %w", err)
|
|
}
|
|
m.Set(configTokenURL, tokenEndpoint)
|
|
err = oauthutil.PutToken(name, m, &token, true)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error while saving token: %w", err)
|
|
}
|
|
return fs.ConfigGoto("choose_device")
|
|
case "legacy": // configure a jottacloud backend using legacy authentication
|
|
m.Set("configVersion", fmt.Sprint(legacyConfigVersion))
|
|
return fs.ConfigConfirm("legacy_api", false, "config_machine_specific", `Do you want to create a machine specific API key?
|
|
|
|
Rclone has it's own Jottacloud API KEY which works fine as long as one
|
|
only uses rclone on a single machine. When you want to use rclone with
|
|
this account on more than one machine it's recommended to create a
|
|
machine specific API key. These keys can NOT be shared between
|
|
machines.`)
|
|
case "legacy_api":
|
|
srv := rest.NewClient(fshttp.NewClient(ctx))
|
|
if config.Result == "true" {
|
|
deviceRegistration, err := registerDevice(ctx, srv)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to register device: %w", err)
|
|
}
|
|
m.Set(configClientID, deviceRegistration.ClientID)
|
|
m.Set(configClientSecret, obscure.MustObscure(deviceRegistration.ClientSecret))
|
|
fs.Debugf(nil, "Got clientID %q and clientSecret %q", deviceRegistration.ClientID, deviceRegistration.ClientSecret)
|
|
}
|
|
return fs.ConfigInput("legacy_username", "config_username", "Username (e-mail address)")
|
|
case "legacy_username":
|
|
m.Set(configUsername, config.Result)
|
|
return fs.ConfigPassword("legacy_password", "config_password", "Password (only used in setup, will not be stored)")
|
|
case "legacy_password":
|
|
m.Set("password", config.Result)
|
|
m.Set("auth_code", "")
|
|
return fs.ConfigGoto("legacy_do_auth")
|
|
case "legacy_auth_code":
|
|
authCode := strings.ReplaceAll(config.Result, "-", "") // remove any "-" contained in the code so we have a 6 digit number
|
|
m.Set("auth_code", authCode)
|
|
return fs.ConfigGoto("legacy_do_auth")
|
|
case "legacy_do_auth":
|
|
username, _ := m.Get(configUsername)
|
|
password, _ := m.Get("password")
|
|
password = obscure.MustReveal(password)
|
|
authCode, _ := m.Get("auth_code")
|
|
|
|
srv := rest.NewClient(fshttp.NewClient(ctx))
|
|
clientID, ok := m.Get(configClientID)
|
|
if !ok {
|
|
clientID = legacyClientID
|
|
}
|
|
clientSecret, ok := m.Get(configClientSecret)
|
|
if !ok {
|
|
clientSecret = legacyEncryptedClientSecret
|
|
}
|
|
|
|
oauthConfig := &oauth2.Config{
|
|
Endpoint: oauth2.Endpoint{
|
|
AuthURL: legacyTokenURL,
|
|
},
|
|
ClientID: clientID,
|
|
ClientSecret: obscure.MustReveal(clientSecret),
|
|
}
|
|
token, err := doLegacyAuth(ctx, srv, oauthConfig, username, password, authCode)
|
|
if err == errAuthCodeRequired {
|
|
return fs.ConfigInput("legacy_auth_code", "config_auth_code", "Verification Code\nThis account uses 2 factor authentication you will receive a verification code via SMS.")
|
|
}
|
|
m.Set("password", "")
|
|
m.Set("auth_code", "")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get oauth token: %w", err)
|
|
}
|
|
err = oauthutil.PutToken(name, m, &token, true)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error while saving token: %w", err)
|
|
}
|
|
return fs.ConfigGoto("choose_device")
|
|
case "telia_se": // telia_se cloud config
|
|
m.Set("configVersion", fmt.Sprint(configVersion))
|
|
m.Set(configClientID, teliaseCloudClientID)
|
|
m.Set(configTokenURL, teliaseCloudTokenURL)
|
|
return oauthutil.ConfigOut("choose_device", &oauthutil.Options{
|
|
OAuth2Config: &oauthutil.Config{
|
|
AuthURL: teliaseCloudAuthURL,
|
|
TokenURL: teliaseCloudTokenURL,
|
|
ClientID: teliaseCloudClientID,
|
|
Scopes: []string{"openid", "jotta-default", "offline_access"},
|
|
RedirectURL: oauthutil.RedirectLocalhostURL,
|
|
},
|
|
})
|
|
case "telia_no": // telia_no cloud config
|
|
m.Set("configVersion", fmt.Sprint(configVersion))
|
|
m.Set(configClientID, telianoCloudClientID)
|
|
m.Set(configTokenURL, telianoCloudTokenURL)
|
|
return oauthutil.ConfigOut("choose_device", &oauthutil.Options{
|
|
OAuth2Config: &oauthutil.Config{
|
|
AuthURL: telianoCloudAuthURL,
|
|
TokenURL: telianoCloudTokenURL,
|
|
ClientID: telianoCloudClientID,
|
|
Scopes: []string{"openid", "jotta-default", "offline_access"},
|
|
RedirectURL: oauthutil.RedirectLocalhostURL,
|
|
},
|
|
})
|
|
case "tele2": // tele2 cloud config
|
|
m.Set("configVersion", fmt.Sprint(configVersion))
|
|
m.Set(configClientID, tele2CloudClientID)
|
|
m.Set(configTokenURL, tele2CloudTokenURL)
|
|
return oauthutil.ConfigOut("choose_device", &oauthutil.Options{
|
|
OAuth2Config: &oauthutil.Config{
|
|
AuthURL: tele2CloudAuthURL,
|
|
TokenURL: tele2CloudTokenURL,
|
|
ClientID: tele2CloudClientID,
|
|
Scopes: []string{"openid", "jotta-default", "offline_access"},
|
|
RedirectURL: oauthutil.RedirectLocalhostURL,
|
|
},
|
|
})
|
|
case "onlime": // onlime cloud config
|
|
m.Set("configVersion", fmt.Sprint(configVersion))
|
|
m.Set(configClientID, onlimeCloudClientID)
|
|
m.Set(configTokenURL, onlimeCloudTokenURL)
|
|
return oauthutil.ConfigOut("choose_device", &oauthutil.Options{
|
|
OAuth2Config: &oauthutil.Config{
|
|
AuthURL: onlimeCloudAuthURL,
|
|
TokenURL: onlimeCloudTokenURL,
|
|
ClientID: onlimeCloudClientID,
|
|
Scopes: []string{"openid", "jotta-default", "offline_access"},
|
|
RedirectURL: oauthutil.RedirectLocalhostURL,
|
|
},
|
|
})
|
|
case "choose_device":
|
|
return fs.ConfigConfirm("choose_device_query", false, "config_non_standard", `Use a non-standard device/mountpoint?
|
|
Choosing no, the default, will let you access the storage used for the archive
|
|
section of the official Jottacloud client. If you instead want to access the
|
|
sync or the backup section, for example, you must choose yes.`)
|
|
|
|
case "choose_device_query":
|
|
if config.Result != "true" {
|
|
m.Set(configDevice, "")
|
|
m.Set(configMountpoint, "")
|
|
return fs.ConfigGoto("end")
|
|
}
|
|
oAuthClient, _, err := getOAuthClient(ctx, name, m)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
jfsSrv := rest.NewClient(oAuthClient).SetRoot(jfsURL)
|
|
apiSrv := rest.NewClient(oAuthClient).SetRoot(apiURL)
|
|
|
|
cust, err := getCustomerInfo(ctx, apiSrv)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
acc, err := getDriveInfo(ctx, jfsSrv, cust.Username)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
deviceNames := make([]string, len(acc.Devices))
|
|
for i, dev := range acc.Devices {
|
|
if i > 0 && dev.Name == defaultDevice {
|
|
// Insert the special Jotta device as first entry, making it the default choice.
|
|
copy(deviceNames[1:i+1], deviceNames[0:i])
|
|
deviceNames[0] = dev.Name
|
|
} else {
|
|
deviceNames[i] = dev.Name
|
|
}
|
|
}
|
|
|
|
help := fmt.Sprintf(`The device to use. In standard setup the built-in %s device is used,
|
|
which contains predefined mountpoints for archive, sync etc. All other devices
|
|
are treated as backup devices by the official Jottacloud client. You may create
|
|
a new by entering a unique name.`, defaultDevice)
|
|
return fs.ConfigChoose("choose_device_result", "config_device", help, len(deviceNames), func(i int) (string, string) {
|
|
return deviceNames[i], ""
|
|
})
|
|
case "choose_device_result":
|
|
device := config.Result
|
|
|
|
oAuthClient, _, err := getOAuthClient(ctx, name, m)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
jfsSrv := rest.NewClient(oAuthClient).SetRoot(jfsURL)
|
|
apiSrv := rest.NewClient(oAuthClient).SetRoot(apiURL)
|
|
|
|
cust, err := getCustomerInfo(ctx, apiSrv)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
acc, err := getDriveInfo(ctx, jfsSrv, cust.Username)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
isNew := true
|
|
for _, dev := range acc.Devices {
|
|
if strings.EqualFold(dev.Name, device) { // If device name exists with different casing we prefer the existing (not sure if and how the api handles the opposite)
|
|
device = dev.Name // Prefer same casing as existing, e.g. if user entered "jotta" we use the standard casing "Jotta" instead
|
|
isNew = false
|
|
break
|
|
}
|
|
}
|
|
var dev *api.JottaDevice
|
|
if isNew {
|
|
fs.Debugf(nil, "Creating new device: %s", device)
|
|
dev, err = createDevice(ctx, jfsSrv, path.Join(cust.Username, device))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
m.Set(configDevice, device)
|
|
|
|
if !isNew {
|
|
dev, err = getDeviceInfo(ctx, jfsSrv, path.Join(cust.Username, device))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
var help string
|
|
if device == defaultDevice {
|
|
// With built-in Jotta device the mountpoint choice is exclusive,
|
|
// we do not want to risk any problems by creating new mountpoints on it.
|
|
help = fmt.Sprintf(`The mountpoint to use on the built-in device %s.
|
|
The standard setup is to use the %s mountpoint. Most other mountpoints
|
|
have very limited support in rclone and should generally be avoided.`, defaultDevice, defaultMountpoint)
|
|
return fs.ConfigChooseExclusive("choose_device_mountpoint", "config_mountpoint", help, len(dev.MountPoints), func(i int) (string, string) {
|
|
return dev.MountPoints[i].Name, ""
|
|
})
|
|
}
|
|
help = fmt.Sprintf(`The mountpoint to use on the non-standard device %s.
|
|
You may create a new by entering a unique name.`, device)
|
|
return fs.ConfigChoose("choose_device_mountpoint", "config_mountpoint", help, len(dev.MountPoints), func(i int) (string, string) {
|
|
return dev.MountPoints[i].Name, ""
|
|
})
|
|
case "choose_device_mountpoint":
|
|
mountpoint := config.Result
|
|
|
|
oAuthClient, _, err := getOAuthClient(ctx, name, m)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
jfsSrv := rest.NewClient(oAuthClient).SetRoot(jfsURL)
|
|
apiSrv := rest.NewClient(oAuthClient).SetRoot(apiURL)
|
|
|
|
cust, err := getCustomerInfo(ctx, apiSrv)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
device, _ := m.Get(configDevice)
|
|
|
|
dev, err := getDeviceInfo(ctx, jfsSrv, path.Join(cust.Username, device))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
isNew := true
|
|
for _, mnt := range dev.MountPoints {
|
|
if strings.EqualFold(mnt.Name, mountpoint) {
|
|
mountpoint = mnt.Name
|
|
isNew = false
|
|
break
|
|
}
|
|
}
|
|
|
|
if isNew {
|
|
if device == defaultDevice {
|
|
return nil, fmt.Errorf("custom mountpoints not supported on built-in %s device: %w", defaultDevice, err)
|
|
}
|
|
fs.Debugf(nil, "Creating new mountpoint: %s", mountpoint)
|
|
_, err := createMountPoint(ctx, jfsSrv, path.Join(cust.Username, device, mountpoint))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
m.Set(configMountpoint, mountpoint)
|
|
|
|
return fs.ConfigGoto("end")
|
|
case "end":
|
|
// All the config flows end up here in case we need to carry on with something
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("unknown state %q", config.State)
|
|
}
|
|
|
|
// Options defines the configuration for this backend
|
|
type Options struct {
|
|
Device string `config:"device"`
|
|
Mountpoint string `config:"mountpoint"`
|
|
MD5MemoryThreshold fs.SizeSuffix `config:"md5_memory_limit"`
|
|
TrashedOnly bool `config:"trashed_only"`
|
|
HardDelete bool `config:"hard_delete"`
|
|
NoVersions bool `config:"no_versions"`
|
|
UploadThreshold fs.SizeSuffix `config:"upload_resume_limit"`
|
|
Enc encoder.MultiEncoder `config:"encoding"`
|
|
}
|
|
|
|
// Fs represents a remote jottacloud
|
|
type Fs struct {
|
|
name string
|
|
root string
|
|
user string
|
|
opt Options
|
|
features *fs.Features
|
|
fileEndpoint string
|
|
allocateEndpoint string
|
|
jfsSrv *rest.Client
|
|
apiSrv *rest.Client
|
|
pacer *fs.Pacer
|
|
tokenRenewer *oauthutil.Renew // renew the token on expiry
|
|
}
|
|
|
|
// Object describes a jottacloud object
|
|
//
|
|
// Will definitely have info but maybe not meta
|
|
type Object struct {
|
|
fs *Fs
|
|
remote string
|
|
hasMetaData bool
|
|
size int64
|
|
createTime time.Time
|
|
modTime time.Time
|
|
updateTime time.Time
|
|
md5 string
|
|
mimeType string
|
|
}
|
|
|
|
// ------------------------------------------------------------
|
|
|
|
// Name of the remote (as passed into NewFs)
|
|
func (f *Fs) Name() string {
|
|
return f.name
|
|
}
|
|
|
|
// Root of the remote (as passed into NewFs)
|
|
func (f *Fs) Root() string {
|
|
return f.root
|
|
}
|
|
|
|
// String converts this Fs to a string
|
|
func (f *Fs) String() string {
|
|
return fmt.Sprintf("jottacloud root '%s'", f.root)
|
|
}
|
|
|
|
// Features returns the optional features of this Fs
|
|
func (f *Fs) Features() *fs.Features {
|
|
return f.features
|
|
}
|
|
|
|
// joinPath joins two path/url elements
|
|
//
|
|
// Does not perform clean on the result like path.Join does,
|
|
// which breaks urls by changing prefix "https://" into "https:/".
|
|
func joinPath(base string, rel string) string {
|
|
if rel == "" {
|
|
return base
|
|
}
|
|
if strings.HasSuffix(base, "/") {
|
|
return base + strings.TrimPrefix(rel, "/")
|
|
}
|
|
if strings.HasPrefix(rel, "/") {
|
|
return strings.TrimSuffix(base, "/") + rel
|
|
}
|
|
return base + "/" + rel
|
|
}
|
|
|
|
// retryErrorCodes is a slice of error codes that we will retry
|
|
var retryErrorCodes = []int{
|
|
429, // Too Many Requests.
|
|
500, // Internal Server Error
|
|
502, // Bad Gateway
|
|
503, // Service Unavailable
|
|
504, // Gateway Timeout
|
|
509, // Bandwidth Limit Exceeded
|
|
}
|
|
|
|
// shouldRetry returns a boolean as to whether this resp and err
|
|
// deserve to be retried. It returns the err as a convenience
|
|
func shouldRetry(ctx context.Context, resp *http.Response, err error) (bool, error) {
|
|
if fserrors.ContextError(ctx, &err) {
|
|
return false, err
|
|
}
|
|
return fserrors.ShouldRetry(err) || fserrors.ShouldRetryHTTP(resp, retryErrorCodes), err
|
|
}
|
|
|
|
// registerDevice register a new device for use with the jottacloud API
|
|
func registerDevice(ctx context.Context, srv *rest.Client) (reg *api.DeviceRegistrationResponse, err error) {
|
|
// random generator to generate random device names
|
|
seededRand := rand.New(rand.NewSource(time.Now().UnixNano()))
|
|
randonDeviceNamePartLength := 21
|
|
randomDeviceNamePart := make([]byte, randonDeviceNamePartLength)
|
|
charset := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
for i := range randomDeviceNamePart {
|
|
randomDeviceNamePart[i] = charset[seededRand.Intn(len(charset))]
|
|
}
|
|
randomDeviceName := "rclone-" + string(randomDeviceNamePart)
|
|
fs.Debugf(nil, "Trying to register device '%s'", randomDeviceName)
|
|
|
|
values := url.Values{}
|
|
values.Set("device_id", randomDeviceName)
|
|
|
|
opts := rest.Opts{
|
|
Method: "POST",
|
|
RootURL: legacyRegisterURL,
|
|
ContentType: "application/x-www-form-urlencoded",
|
|
ExtraHeaders: map[string]string{"Authorization": "Bearer c2xrZmpoYWRsZmFramhkc2xma2phaHNkbGZramhhc2xkZmtqaGFzZGxrZmpobGtq"},
|
|
Parameters: values,
|
|
}
|
|
|
|
var deviceRegistration *api.DeviceRegistrationResponse
|
|
_, err = srv.CallJSON(ctx, &opts, nil, &deviceRegistration)
|
|
return deviceRegistration, err
|
|
}
|
|
|
|
var errAuthCodeRequired = errors.New("auth code required")
|
|
|
|
// doLegacyAuth runs the actual token request for V1 authentication
|
|
//
|
|
// Call this first with blank authCode. If errAuthCodeRequired is
|
|
// returned then call it again with an authCode
|
|
func doLegacyAuth(ctx context.Context, srv *rest.Client, oauthConfig *oauth2.Config, username, password, authCode string) (token oauth2.Token, err error) {
|
|
// prepare out token request with username and password
|
|
values := url.Values{}
|
|
values.Set("grant_type", "PASSWORD")
|
|
values.Set("password", password)
|
|
values.Set("username", username)
|
|
values.Set("client_id", oauthConfig.ClientID)
|
|
values.Set("client_secret", oauthConfig.ClientSecret)
|
|
opts := rest.Opts{
|
|
Method: "POST",
|
|
RootURL: oauthConfig.Endpoint.AuthURL,
|
|
ContentType: "application/x-www-form-urlencoded",
|
|
Parameters: values,
|
|
}
|
|
if authCode != "" {
|
|
opts.ExtraHeaders = make(map[string]string)
|
|
opts.ExtraHeaders["X-Jottacloud-Otp"] = authCode
|
|
}
|
|
|
|
// do the first request
|
|
var jsonToken api.TokenJSON
|
|
resp, err := srv.CallJSON(ctx, &opts, nil, &jsonToken)
|
|
if err != nil && authCode == "" {
|
|
// if 2fa is enabled the first request is expected to fail. We will do another request with the 2fa code as an additional http header
|
|
if resp != nil {
|
|
if resp.Header.Get("X-JottaCloud-OTP") == "required; SMS" {
|
|
return token, errAuthCodeRequired
|
|
}
|
|
}
|
|
}
|
|
|
|
token.AccessToken = jsonToken.AccessToken
|
|
token.RefreshToken = jsonToken.RefreshToken
|
|
token.TokenType = jsonToken.TokenType
|
|
token.Expiry = time.Now().Add(time.Duration(jsonToken.ExpiresIn) * time.Second)
|
|
return token, err
|
|
}
|
|
|
|
// doTokenAuth runs the actual token request for V2 authentication
|
|
func doTokenAuth(ctx context.Context, apiSrv *rest.Client, loginTokenBase64 string) (token oauth2.Token, tokenEndpoint string, err error) {
|
|
loginTokenBytes, err := base64.RawURLEncoding.DecodeString(loginTokenBase64)
|
|
if err != nil {
|
|
return token, "", err
|
|
}
|
|
|
|
// decode login token
|
|
var loginToken api.LoginToken
|
|
decoder := json.NewDecoder(bytes.NewReader(loginTokenBytes))
|
|
err = decoder.Decode(&loginToken)
|
|
if err != nil {
|
|
return token, "", err
|
|
}
|
|
|
|
// retrieve endpoint urls
|
|
opts := rest.Opts{
|
|
Method: "GET",
|
|
RootURL: loginToken.WellKnownLink,
|
|
}
|
|
var wellKnown api.WellKnown
|
|
_, err = apiSrv.CallJSON(ctx, &opts, nil, &wellKnown)
|
|
if err != nil {
|
|
return token, "", err
|
|
}
|
|
|
|
// prepare out token request with username and password
|
|
values := url.Values{}
|
|
values.Set("client_id", defaultClientID)
|
|
values.Set("grant_type", "password")
|
|
values.Set("password", loginToken.AuthToken)
|
|
values.Set("scope", "openid offline_access")
|
|
values.Set("username", loginToken.Username)
|
|
values.Encode()
|
|
opts = rest.Opts{
|
|
Method: "POST",
|
|
RootURL: wellKnown.TokenEndpoint,
|
|
ContentType: "application/x-www-form-urlencoded",
|
|
Body: strings.NewReader(values.Encode()),
|
|
}
|
|
|
|
// do the first request
|
|
var jsonToken api.TokenJSON
|
|
_, err = apiSrv.CallJSON(ctx, &opts, nil, &jsonToken)
|
|
if err != nil {
|
|
return token, "", err
|
|
}
|
|
|
|
token.AccessToken = jsonToken.AccessToken
|
|
token.RefreshToken = jsonToken.RefreshToken
|
|
token.TokenType = jsonToken.TokenType
|
|
token.Expiry = time.Now().Add(time.Duration(jsonToken.ExpiresIn) * time.Second)
|
|
return token, wellKnown.TokenEndpoint, err
|
|
}
|
|
|
|
// getCustomerInfo queries general information about the account
|
|
func getCustomerInfo(ctx context.Context, apiSrv *rest.Client) (info *api.CustomerInfo, err error) {
|
|
opts := rest.Opts{
|
|
Method: "GET",
|
|
Path: "account/v1/customer",
|
|
}
|
|
|
|
_, err = apiSrv.CallJSON(ctx, &opts, nil, &info)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("couldn't get customer info: %w", err)
|
|
}
|
|
|
|
return info, nil
|
|
}
|
|
|
|
// getDriveInfo queries general information about the account and the available devices and mountpoints.
|
|
func getDriveInfo(ctx context.Context, srv *rest.Client, username string) (info *api.DriveInfo, err error) {
|
|
opts := rest.Opts{
|
|
Method: "GET",
|
|
Path: username,
|
|
}
|
|
|
|
_, err = srv.CallXML(ctx, &opts, nil, &info)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("couldn't get drive info: %w", err)
|
|
}
|
|
|
|
return info, nil
|
|
}
|
|
|
|
// getDeviceInfo queries Information about a jottacloud device
|
|
func getDeviceInfo(ctx context.Context, srv *rest.Client, path string) (info *api.JottaDevice, err error) {
|
|
opts := rest.Opts{
|
|
Method: "GET",
|
|
Path: urlPathEscape(path),
|
|
}
|
|
|
|
_, err = srv.CallXML(ctx, &opts, nil, &info)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("couldn't get device info: %w", err)
|
|
}
|
|
|
|
return info, nil
|
|
}
|
|
|
|
// createDevice makes a device
|
|
func createDevice(ctx context.Context, srv *rest.Client, path string) (info *api.JottaDevice, err error) {
|
|
opts := rest.Opts{
|
|
Method: "POST",
|
|
Path: urlPathEscape(path),
|
|
Parameters: url.Values{},
|
|
}
|
|
|
|
opts.Parameters.Set("type", "WORKSTATION")
|
|
|
|
_, err = srv.CallXML(ctx, &opts, nil, &info)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("couldn't create device: %w", err)
|
|
}
|
|
return info, nil
|
|
}
|
|
|
|
// createMountPoint makes a mount point
|
|
func createMountPoint(ctx context.Context, srv *rest.Client, path string) (info *api.JottaMountPoint, err error) {
|
|
opts := rest.Opts{
|
|
Method: "POST",
|
|
Path: urlPathEscape(path),
|
|
}
|
|
|
|
_, err = srv.CallXML(ctx, &opts, nil, &info)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("couldn't create mountpoint: %w", err)
|
|
}
|
|
return info, nil
|
|
}
|
|
|
|
// setEndpoints generates the API endpoints
|
|
func (f *Fs) setEndpoints() {
|
|
if f.opt.Device == "" {
|
|
f.opt.Device = defaultDevice
|
|
}
|
|
if f.opt.Mountpoint == "" {
|
|
f.opt.Mountpoint = defaultMountpoint
|
|
}
|
|
f.fileEndpoint = path.Join(f.user, f.opt.Device, f.opt.Mountpoint)
|
|
f.allocateEndpoint = path.Join("/jfs", f.opt.Device, f.opt.Mountpoint)
|
|
}
|
|
|
|
// readMetaDataForPath reads the metadata from the path
|
|
func (f *Fs) readMetaDataForPath(ctx context.Context, path string) (info *api.JottaFile, err error) {
|
|
opts := rest.Opts{
|
|
Method: "GET",
|
|
Path: f.filePath(path),
|
|
}
|
|
var result api.JottaFile
|
|
var resp *http.Response
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
resp, err = f.jfsSrv.CallXML(ctx, &opts, nil, &result)
|
|
return shouldRetry(ctx, resp, err)
|
|
})
|
|
|
|
if apiErr, ok := err.(*api.Error); ok {
|
|
// does not exist
|
|
if apiErr.StatusCode == http.StatusNotFound {
|
|
return nil, fs.ErrorObjectNotFound
|
|
}
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read metadata failed: %w", err)
|
|
}
|
|
if result.XMLName.Local == "folder" {
|
|
return nil, fs.ErrorIsDir
|
|
} else if result.XMLName.Local != "file" {
|
|
return nil, fs.ErrorNotAFile
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// errorHandler parses a non 2xx error response into an error
|
|
func errorHandler(resp *http.Response) error {
|
|
// Decode error response
|
|
errResponse := new(api.Error)
|
|
err := rest.DecodeXML(resp, &errResponse)
|
|
if err != nil {
|
|
fs.Debugf(nil, "Couldn't decode error response: %v", err)
|
|
}
|
|
if errResponse.Message == "" {
|
|
errResponse.Message = resp.Status
|
|
}
|
|
if errResponse.StatusCode == 0 {
|
|
errResponse.StatusCode = resp.StatusCode
|
|
}
|
|
return errResponse
|
|
}
|
|
|
|
// Jottacloud wants '+' to be URL encoded even though the RFC states it's not reserved
|
|
func urlPathEscape(in string) string {
|
|
return strings.ReplaceAll(rest.URLPathEscape(in), "+", "%2B")
|
|
}
|
|
|
|
// filePathRaw returns an unescaped file path (f.root, file)
|
|
// Optionally made absolute by prefixing with "/", typically required when used
|
|
// as request parameter instead of the path (which is relative to some root url).
|
|
func (f *Fs) filePathRaw(file string, absolute bool) string {
|
|
prefix := ""
|
|
if absolute {
|
|
prefix = "/"
|
|
}
|
|
return path.Join(prefix, f.fileEndpoint, f.opt.Enc.FromStandardPath(path.Join(f.root, file)))
|
|
}
|
|
|
|
// filePath returns an escaped file path (f.root, file)
|
|
func (f *Fs) filePath(file string) string {
|
|
return urlPathEscape(f.filePathRaw(file, false))
|
|
}
|
|
|
|
// allocatePathRaw returns an unescaped allocate file path (f.root, file)
|
|
// Optionally made absolute by prefixing with "/", typically required when used
|
|
// as request parameter instead of the path (which is relative to some root url).
|
|
func (f *Fs) allocatePathRaw(file string, absolute bool) string {
|
|
prefix := ""
|
|
if absolute {
|
|
prefix = "/"
|
|
}
|
|
return path.Join(prefix, f.allocateEndpoint, f.opt.Enc.FromStandardPath(path.Join(f.root, file)))
|
|
}
|
|
|
|
// Jottacloud requires the grant_type 'refresh_token' string
|
|
// to be uppercase and throws a 400 Bad Request if we use the
|
|
// lower case used by the oauth2 module
|
|
//
|
|
// This filter catches all refresh requests, reads the body,
|
|
// changes the case and then sends it on
|
|
func grantTypeFilter(req *http.Request) {
|
|
if legacyTokenURL == req.URL.String() {
|
|
// read the entire body
|
|
refreshBody, err := io.ReadAll(req.Body)
|
|
if err != nil {
|
|
return
|
|
}
|
|
_ = req.Body.Close()
|
|
|
|
// make the refresh token upper case
|
|
refreshBody = []byte(strings.Replace(string(refreshBody), "grant_type=refresh_token", "grant_type=REFRESH_TOKEN", 1))
|
|
|
|
// set the new ReadCloser (with a dummy Close())
|
|
req.Body = io.NopCloser(bytes.NewReader(refreshBody))
|
|
}
|
|
}
|
|
|
|
func getOAuthClient(ctx context.Context, name string, m configmap.Mapper) (oAuthClient *http.Client, ts *oauthutil.TokenSource, err error) {
|
|
// Check config version
|
|
var ver int
|
|
version, ok := m.Get("configVersion")
|
|
if ok {
|
|
ver, err = strconv.Atoi(version)
|
|
if err != nil {
|
|
return nil, nil, errors.New("failed to parse config version")
|
|
}
|
|
ok = (ver == configVersion) || (ver == legacyConfigVersion)
|
|
}
|
|
if !ok {
|
|
return nil, nil, errors.New("outdated config - please reconfigure this backend")
|
|
}
|
|
|
|
baseClient := fshttp.NewClient(ctx)
|
|
oauthConfig := &oauthutil.Config{
|
|
AuthURL: defaultTokenURL,
|
|
TokenURL: defaultTokenURL,
|
|
}
|
|
if ver == configVersion {
|
|
oauthConfig.ClientID = defaultClientID
|
|
// if custom endpoints are set use them else stick with defaults
|
|
if tokenURL, ok := m.Get(configTokenURL); ok {
|
|
oauthConfig.TokenURL = tokenURL
|
|
// jottacloud is weird. we need to use the tokenURL as authURL
|
|
oauthConfig.AuthURL = tokenURL
|
|
}
|
|
} else if ver == legacyConfigVersion {
|
|
clientID, ok := m.Get(configClientID)
|
|
if !ok {
|
|
clientID = legacyClientID
|
|
}
|
|
clientSecret, ok := m.Get(configClientSecret)
|
|
if !ok {
|
|
clientSecret = legacyEncryptedClientSecret
|
|
}
|
|
oauthConfig.ClientID = clientID
|
|
oauthConfig.ClientSecret = obscure.MustReveal(clientSecret)
|
|
|
|
oauthConfig.TokenURL = legacyTokenURL
|
|
oauthConfig.AuthURL = legacyTokenURL
|
|
|
|
// add the request filter to fix token refresh
|
|
if do, ok := baseClient.Transport.(interface {
|
|
SetRequestFilter(f func(req *http.Request))
|
|
}); ok {
|
|
do.SetRequestFilter(grantTypeFilter)
|
|
} else {
|
|
fs.Debugf(name+":", "Couldn't add request filter - uploads will fail")
|
|
}
|
|
}
|
|
|
|
// Create OAuth Client
|
|
oAuthClient, ts, err = oauthutil.NewClientWithBaseClient(ctx, name, m, oauthConfig, baseClient)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to configure Jottacloud oauth client: %w", err)
|
|
}
|
|
return oAuthClient, ts, nil
|
|
}
|
|
|
|
// NewFs constructs an Fs from the path, container:path
|
|
func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, error) {
|
|
// Parse config into Options struct
|
|
opt := new(Options)
|
|
err := configstruct.Set(m, opt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
oAuthClient, ts, err := getOAuthClient(ctx, name, m)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rootIsDir := strings.HasSuffix(root, "/")
|
|
root = strings.Trim(root, "/")
|
|
|
|
f := &Fs{
|
|
name: name,
|
|
root: root,
|
|
opt: *opt,
|
|
jfsSrv: rest.NewClient(oAuthClient).SetRoot(jfsURL),
|
|
apiSrv: rest.NewClient(oAuthClient).SetRoot(apiURL),
|
|
pacer: fs.NewPacer(ctx, pacer.NewDefault(pacer.MinSleep(minSleep), pacer.MaxSleep(maxSleep), pacer.DecayConstant(decayConstant))),
|
|
}
|
|
f.features = (&fs.Features{
|
|
CaseInsensitive: true,
|
|
CanHaveEmptyDirectories: true,
|
|
ReadMimeType: true,
|
|
WriteMimeType: false,
|
|
ReadMetadata: true,
|
|
WriteMetadata: true,
|
|
UserMetadata: false,
|
|
}).Fill(ctx, f)
|
|
f.jfsSrv.SetErrorHandler(errorHandler)
|
|
if opt.TrashedOnly { // we cannot support showing Trashed Files when using ListR right now
|
|
f.features.ListR = nil
|
|
}
|
|
|
|
// Renew the token in the background
|
|
f.tokenRenewer = oauthutil.NewRenew(f.String(), ts, func() error {
|
|
_, err := f.readMetaDataForPath(ctx, "")
|
|
if err == fs.ErrorNotAFile || err == fs.ErrorIsDir {
|
|
err = nil
|
|
}
|
|
return err
|
|
})
|
|
|
|
cust, err := getCustomerInfo(ctx, f.apiSrv)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
f.user = cust.Username
|
|
f.setEndpoints()
|
|
|
|
if root != "" && !rootIsDir {
|
|
// Check to see if the root actually an existing file
|
|
remote := path.Base(root)
|
|
f.root = path.Dir(root)
|
|
if f.root == "." {
|
|
f.root = ""
|
|
}
|
|
_, err := f.NewObject(context.TODO(), remote)
|
|
if err != nil {
|
|
if errors.Is(err, fs.ErrorObjectNotFound) || errors.Is(err, fs.ErrorNotAFile) || errors.Is(err, fs.ErrorIsDir) {
|
|
// File doesn't exist so return old f
|
|
f.root = root
|
|
return f, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
// return an error with an fs which points to the parent
|
|
return f, fs.ErrorIsFile
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
// Return an Object from a path
|
|
//
|
|
// If it can't be found it returns the error fs.ErrorObjectNotFound.
|
|
func (f *Fs) newObjectWithInfo(ctx context.Context, remote string, info *api.JottaFile) (fs.Object, error) {
|
|
o := &Object{
|
|
fs: f,
|
|
remote: remote,
|
|
}
|
|
var err error
|
|
if info != nil {
|
|
if !f.validFile(info) {
|
|
return nil, fs.ErrorObjectNotFound
|
|
}
|
|
err = o.setMetaData(info) // sets the info
|
|
} else {
|
|
err = o.readMetaData(ctx, false) // reads info and meta, returning an error
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return o, nil
|
|
}
|
|
|
|
// NewObject finds the Object at remote. If it can't be found
|
|
// it returns the error fs.ErrorObjectNotFound.
|
|
func (f *Fs) NewObject(ctx context.Context, remote string) (fs.Object, error) {
|
|
return f.newObjectWithInfo(ctx, remote, nil)
|
|
}
|
|
|
|
// CreateDir makes a directory
|
|
func (f *Fs) CreateDir(ctx context.Context, path string) (jf *api.JottaFolder, err error) {
|
|
// fs.Debugf(f, "CreateDir(%q, %q)\n", pathID, leaf)
|
|
var resp *http.Response
|
|
opts := rest.Opts{
|
|
Method: "POST",
|
|
Path: f.filePath(path),
|
|
Parameters: url.Values{},
|
|
}
|
|
|
|
opts.Parameters.Set("mkDir", "true")
|
|
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
resp, err = f.jfsSrv.CallXML(ctx, &opts, nil, &jf)
|
|
return shouldRetry(ctx, resp, err)
|
|
})
|
|
if err != nil {
|
|
//fmt.Printf("...Error %v\n", err)
|
|
return nil, err
|
|
}
|
|
// fmt.Printf("...Id %q\n", *info.Id)
|
|
return jf, nil
|
|
}
|
|
|
|
// List the objects and directories in dir into entries. The
|
|
// entries can be returned in any order but should be for a
|
|
// complete directory.
|
|
//
|
|
// dir should be "" to list the root, and should not have
|
|
// trailing slashes.
|
|
//
|
|
// This should return ErrDirNotFound if the directory isn't
|
|
// found.
|
|
func (f *Fs) List(ctx context.Context, dir string) (entries fs.DirEntries, err error) {
|
|
opts := rest.Opts{
|
|
Method: "GET",
|
|
Path: f.filePath(dir),
|
|
}
|
|
|
|
var resp *http.Response
|
|
var result api.JottaFolder
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
resp, err = f.jfsSrv.CallXML(ctx, &opts, nil, &result)
|
|
return shouldRetry(ctx, resp, err)
|
|
})
|
|
|
|
if err != nil {
|
|
if apiErr, ok := err.(*api.Error); ok {
|
|
// does not exist
|
|
if apiErr.StatusCode == http.StatusNotFound {
|
|
return nil, fs.ErrorDirNotFound
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("couldn't list files: %w", err)
|
|
}
|
|
|
|
if !f.validFolder(&result) {
|
|
return nil, fs.ErrorDirNotFound
|
|
}
|
|
|
|
for i := range result.Folders {
|
|
item := &result.Folders[i]
|
|
if f.validFolder(item) {
|
|
remote := path.Join(dir, f.opt.Enc.ToStandardName(item.Name))
|
|
d := fs.NewDir(remote, time.Time(item.ModifiedAt))
|
|
entries = append(entries, d)
|
|
}
|
|
}
|
|
|
|
for i := range result.Files {
|
|
item := &result.Files[i]
|
|
if f.validFile(item) {
|
|
remote := path.Join(dir, f.opt.Enc.ToStandardName(item.Name))
|
|
if o, err := f.newObjectWithInfo(ctx, remote, item); err == nil {
|
|
entries = append(entries, o)
|
|
}
|
|
}
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
func parseListRStream(ctx context.Context, r io.Reader, filesystem *Fs, callback func(fs.DirEntry) error) error {
|
|
|
|
type stats struct {
|
|
Folders int `xml:"folders"`
|
|
Files int `xml:"files"`
|
|
}
|
|
var expected, actual stats
|
|
|
|
type xmlFile struct {
|
|
Path string `xml:"path"`
|
|
Name string `xml:"filename"`
|
|
Checksum string `xml:"md5"`
|
|
Size int64 `xml:"size"`
|
|
Modified api.Rfc3339Time `xml:"modified"` // Note: Liststream response includes 3 decimal milliseconds, but we ignore them since there is second precision everywhere else
|
|
Created api.Rfc3339Time `xml:"created"`
|
|
}
|
|
|
|
type xmlFolder struct {
|
|
Path string `xml:"path"`
|
|
}
|
|
|
|
addFolder := func(path string) error {
|
|
return callback(fs.NewDir(filesystem.opt.Enc.ToStandardPath(path), time.Time{}))
|
|
}
|
|
|
|
addFile := func(f *xmlFile) error {
|
|
return callback(&Object{
|
|
hasMetaData: true,
|
|
fs: filesystem,
|
|
remote: filesystem.opt.Enc.ToStandardPath(path.Join(f.Path, f.Name)),
|
|
size: f.Size,
|
|
md5: f.Checksum,
|
|
createTime: time.Time(f.Created),
|
|
modTime: time.Time(f.Modified),
|
|
})
|
|
}
|
|
|
|
// liststream paths are /mountpoint/root/path
|
|
// so the returned paths should have /mountpoint/root/ trimmed
|
|
// as the caller is expecting path.
|
|
pathPrefix := filesystem.opt.Enc.FromStandardPath(path.Join("/", filesystem.opt.Mountpoint, filesystem.root))
|
|
trimPathPrefix := func(p string) string {
|
|
p = strings.TrimPrefix(p, pathPrefix)
|
|
p = strings.TrimPrefix(p, "/")
|
|
return p
|
|
}
|
|
|
|
uniqueFolders := map[string]bool{}
|
|
decoder := xml.NewDecoder(r)
|
|
|
|
for {
|
|
t, err := decoder.Token()
|
|
if err != nil {
|
|
if err != io.EOF {
|
|
return err
|
|
}
|
|
break
|
|
}
|
|
switch se := t.(type) {
|
|
case xml.StartElement:
|
|
switch se.Name.Local {
|
|
case "file":
|
|
var f xmlFile
|
|
if err := decoder.DecodeElement(&f, &se); err != nil {
|
|
return err
|
|
}
|
|
f.Path = trimPathPrefix(f.Path)
|
|
actual.Files++
|
|
if !uniqueFolders[f.Path] {
|
|
uniqueFolders[f.Path] = true
|
|
actual.Folders++
|
|
if err := addFolder(f.Path); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := addFile(&f); err != nil {
|
|
return err
|
|
}
|
|
case "folder":
|
|
var f xmlFolder
|
|
if err := decoder.DecodeElement(&f, &se); err != nil {
|
|
return err
|
|
}
|
|
f.Path = trimPathPrefix(f.Path)
|
|
uniqueFolders[f.Path] = true
|
|
actual.Folders++
|
|
if err := addFolder(f.Path); err != nil {
|
|
return err
|
|
}
|
|
case "stats":
|
|
if err := decoder.DecodeElement(&expected, &se); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if expected.Folders != actual.Folders ||
|
|
expected.Files != actual.Files {
|
|
return fmt.Errorf("invalid result from listStream: expected[%#v] != actual[%#v]", expected, actual)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListR lists the objects and directories of the Fs starting
|
|
// from dir recursively into out.
|
|
//
|
|
// dir should be "" to start from the root, and should not
|
|
// have trailing slashes.
|
|
func (f *Fs) ListR(ctx context.Context, dir string, callback fs.ListRCallback) (err error) {
|
|
opts := rest.Opts{
|
|
Method: "GET",
|
|
Path: f.filePath(dir),
|
|
Parameters: url.Values{},
|
|
}
|
|
opts.Parameters.Set("mode", "liststream")
|
|
list := walk.NewListRHelper(callback)
|
|
|
|
var resp *http.Response
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
resp, err = f.jfsSrv.Call(ctx, &opts)
|
|
if err != nil {
|
|
return shouldRetry(ctx, resp, err)
|
|
}
|
|
|
|
err = parseListRStream(ctx, resp.Body, f, func(d fs.DirEntry) error {
|
|
if d.Remote() == dir {
|
|
return nil
|
|
}
|
|
return list.Add(d)
|
|
})
|
|
_ = resp.Body.Close()
|
|
return shouldRetry(ctx, resp, err)
|
|
})
|
|
if err != nil {
|
|
if apiErr, ok := err.(*api.Error); ok {
|
|
// does not exist
|
|
if apiErr.StatusCode == http.StatusNotFound {
|
|
return fs.ErrorDirNotFound
|
|
}
|
|
}
|
|
return fmt.Errorf("couldn't list files: %w", err)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return list.Flush()
|
|
}
|
|
|
|
// Creates from the parameters passed in a half finished Object which
|
|
// must have setMetaData called on it
|
|
//
|
|
// Used to create new objects
|
|
func (f *Fs) createObject(remote string, modTime time.Time, size int64) (o *Object) {
|
|
// Temporary Object under construction
|
|
o = &Object{
|
|
fs: f,
|
|
remote: remote,
|
|
size: size,
|
|
modTime: modTime,
|
|
}
|
|
return o
|
|
}
|
|
|
|
// Put the object
|
|
//
|
|
// Copy the reader in to the new object which is returned.
|
|
//
|
|
// The new object may have been created if an error is returned
|
|
func (f *Fs) Put(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) {
|
|
o := f.createObject(src.Remote(), src.ModTime(ctx), src.Size())
|
|
return o, o.Update(ctx, in, src, options...)
|
|
}
|
|
|
|
// mkParentDir makes the parent of the native path dirPath if
|
|
// necessary and any directories above that
|
|
func (f *Fs) mkParentDir(ctx context.Context, dirPath string) error {
|
|
// defer log.Trace(dirPath, "")("")
|
|
// chop off trailing / if it exists
|
|
parent := path.Dir(strings.TrimSuffix(dirPath, "/"))
|
|
if parent == "." {
|
|
parent = ""
|
|
}
|
|
return f.Mkdir(ctx, parent)
|
|
}
|
|
|
|
// Mkdir creates the container if it doesn't exist
|
|
func (f *Fs) Mkdir(ctx context.Context, dir string) error {
|
|
_, err := f.CreateDir(ctx, dir)
|
|
return err
|
|
}
|
|
|
|
// purgeCheck removes the root directory, if check is set then it
|
|
// refuses to do so if it has anything in
|
|
func (f *Fs) purgeCheck(ctx context.Context, dir string, check bool) (err error) {
|
|
root := path.Join(f.root, dir)
|
|
if root == "" {
|
|
return errors.New("can't purge root directory")
|
|
}
|
|
|
|
// check that the directory exists
|
|
entries, err := f.List(ctx, dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if check {
|
|
if len(entries) != 0 {
|
|
return fs.ErrorDirectoryNotEmpty
|
|
}
|
|
}
|
|
|
|
opts := rest.Opts{
|
|
Method: "POST",
|
|
Path: f.filePath(dir),
|
|
Parameters: url.Values{},
|
|
NoResponse: true,
|
|
}
|
|
|
|
if f.opt.HardDelete {
|
|
opts.Parameters.Set("rmDir", "true")
|
|
} else {
|
|
opts.Parameters.Set("dlDir", "true")
|
|
}
|
|
|
|
var resp *http.Response
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
resp, err = f.jfsSrv.Call(ctx, &opts)
|
|
return shouldRetry(ctx, resp, err)
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("couldn't purge directory: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Rmdir deletes the root folder
|
|
//
|
|
// Returns an error if it isn't empty
|
|
func (f *Fs) Rmdir(ctx context.Context, dir string) error {
|
|
return f.purgeCheck(ctx, dir, true)
|
|
}
|
|
|
|
// Precision return the precision of this Fs
|
|
func (f *Fs) Precision() time.Duration {
|
|
return time.Second
|
|
}
|
|
|
|
// Purge deletes all the files and the container
|
|
func (f *Fs) Purge(ctx context.Context, dir string) error {
|
|
return f.purgeCheck(ctx, dir, false)
|
|
}
|
|
|
|
// createOrUpdate tries to make remote file match without uploading.
|
|
// If the remote file exists, and has matching size and md5, only
|
|
// timestamps are updated. If the file does not exist or does does
|
|
// not match size and md5, but matching content can be constructed
|
|
// from deduplication, the file will be updated/created. If the file
|
|
// is currently in trash, but can be made to match, it will be
|
|
// restored. Returns ErrorObjectNotFound if upload will be necessary
|
|
// to get a matching remote file.
|
|
func (f *Fs) createOrUpdate(ctx context.Context, file string, createTime time.Time, modTime time.Time, size int64, md5 string) (info *api.JottaFile, err error) {
|
|
opts := rest.Opts{
|
|
Method: "POST",
|
|
Path: f.filePath(file),
|
|
Parameters: url.Values{},
|
|
ExtraHeaders: make(map[string]string),
|
|
}
|
|
|
|
opts.Parameters.Set("cphash", "true")
|
|
|
|
opts.ExtraHeaders["JSize"] = strconv.FormatInt(size, 10)
|
|
opts.ExtraHeaders["JMd5"] = md5
|
|
opts.ExtraHeaders["JCreated"] = api.JottaTime(createTime).String()
|
|
opts.ExtraHeaders["JModified"] = api.JottaTime(modTime).String()
|
|
|
|
var resp *http.Response
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
resp, err = f.jfsSrv.CallXML(ctx, &opts, nil, &info)
|
|
return shouldRetry(ctx, resp, err)
|
|
})
|
|
|
|
if apiErr, ok := err.(*api.Error); ok {
|
|
// does not exist, i.e. not matching size and md5, and not possible to make it by deduplication
|
|
if apiErr.StatusCode == http.StatusNotFound {
|
|
return nil, fs.ErrorObjectNotFound
|
|
}
|
|
}
|
|
return info, nil
|
|
}
|
|
|
|
// copyOrMoves copies or moves directories or files depending on the method parameter
|
|
func (f *Fs) copyOrMove(ctx context.Context, method, src, dest string) (info *api.JottaFile, err error) {
|
|
opts := rest.Opts{
|
|
Method: "POST",
|
|
Path: src,
|
|
Parameters: url.Values{},
|
|
}
|
|
|
|
opts.Parameters.Set(method, f.filePathRaw(dest, true))
|
|
|
|
var resp *http.Response
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
resp, err = f.jfsSrv.CallXML(ctx, &opts, nil, &info)
|
|
return shouldRetry(ctx, resp, err)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return info, nil
|
|
}
|
|
|
|
// Copy src to this remote using server-side copy operations.
|
|
//
|
|
// This is stored with the remote path given.
|
|
//
|
|
// It returns the destination Object and a possible error.
|
|
//
|
|
// Will only be called if src.Fs().Name() == f.Name()
|
|
//
|
|
// If it isn't possible then return fs.ErrorCantCopy
|
|
func (f *Fs) Copy(ctx context.Context, src fs.Object, remote string) (fs.Object, error) {
|
|
srcObj, ok := src.(*Object)
|
|
if !ok {
|
|
fs.Debugf(src, "Can't copy - not same remote type")
|
|
return nil, fs.ErrorCantMove
|
|
}
|
|
|
|
meta, err := fs.GetMetadataOptions(ctx, f, src, fs.MetadataAsOpenOptions(ctx))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := f.mkParentDir(ctx, remote); err != nil {
|
|
return nil, err
|
|
}
|
|
info, err := f.copyOrMove(ctx, "cp", srcObj.filePath(), remote)
|
|
|
|
if err == nil {
|
|
var createTime time.Time
|
|
var createTimeMeta bool
|
|
var modTime time.Time
|
|
var modTimeMeta bool
|
|
if meta != nil {
|
|
createTime, createTimeMeta = srcObj.parseFsMetadataTime(meta, "btime")
|
|
if !createTimeMeta {
|
|
createTime = srcObj.createTime
|
|
}
|
|
modTime, modTimeMeta = srcObj.parseFsMetadataTime(meta, "mtime")
|
|
if !modTimeMeta {
|
|
modTime = srcObj.modTime
|
|
}
|
|
}
|
|
if bool(info.Deleted) && !f.opt.TrashedOnly && info.State == "COMPLETED" {
|
|
// Workaround necessary when destination was a trashed file, to avoid the copied file also being in trash (bug in api?)
|
|
fs.Debugf(src, "Server-side copied to trashed destination, restoring")
|
|
info, err = f.createOrUpdate(ctx, remote, createTime, modTime, info.Size, info.MD5)
|
|
} else if createTimeMeta || modTimeMeta {
|
|
info, err = f.createOrUpdate(ctx, remote, createTime, modTime, info.Size, info.MD5)
|
|
}
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("couldn't copy file: %w", err)
|
|
}
|
|
|
|
return f.newObjectWithInfo(ctx, remote, info)
|
|
//return f.newObjectWithInfo(remote, &result)
|
|
}
|
|
|
|
// Move src to this remote using server-side move operations.
|
|
//
|
|
// This is stored with the remote path given.
|
|
//
|
|
// It returns the destination Object and a possible error.
|
|
//
|
|
// Will only be called if src.Fs().Name() == f.Name()
|
|
//
|
|
// If it isn't possible then return fs.ErrorCantMove
|
|
func (f *Fs) Move(ctx context.Context, src fs.Object, remote string) (fs.Object, error) {
|
|
srcObj, ok := src.(*Object)
|
|
if !ok {
|
|
fs.Debugf(src, "Can't move - not same remote type")
|
|
return nil, fs.ErrorCantMove
|
|
}
|
|
|
|
meta, err := fs.GetMetadataOptions(ctx, f, src, fs.MetadataAsOpenOptions(ctx))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := f.mkParentDir(ctx, remote); err != nil {
|
|
return nil, err
|
|
}
|
|
info, err := f.copyOrMove(ctx, "mv", srcObj.filePath(), remote)
|
|
|
|
if err == nil && meta != nil {
|
|
createTime, createTimeMeta := srcObj.parseFsMetadataTime(meta, "btime")
|
|
if !createTimeMeta {
|
|
createTime = srcObj.createTime
|
|
}
|
|
modTime, modTimeMeta := srcObj.parseFsMetadataTime(meta, "mtime")
|
|
if !modTimeMeta {
|
|
modTime = srcObj.modTime
|
|
}
|
|
if createTimeMeta || modTimeMeta {
|
|
info, err = f.createOrUpdate(ctx, remote, createTime, modTime, info.Size, info.MD5)
|
|
}
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("couldn't move file: %w", err)
|
|
}
|
|
|
|
return f.newObjectWithInfo(ctx, remote, info)
|
|
//return f.newObjectWithInfo(remote, result)
|
|
}
|
|
|
|
// DirMove moves src, srcRemote to this remote at dstRemote
|
|
// using server-side move operations.
|
|
//
|
|
// Will only be called if src.Fs().Name() == f.Name()
|
|
//
|
|
// If it isn't possible then return fs.ErrorCantDirMove
|
|
//
|
|
// If destination exists then return fs.ErrorDirExists
|
|
func (f *Fs) DirMove(ctx context.Context, src fs.Fs, srcRemote, dstRemote string) error {
|
|
srcFs, ok := src.(*Fs)
|
|
if !ok {
|
|
fs.Debugf(srcFs, "Can't move directory - not same remote type")
|
|
return fs.ErrorCantDirMove
|
|
}
|
|
srcPath := path.Join(srcFs.root, srcRemote)
|
|
dstPath := path.Join(f.root, dstRemote)
|
|
|
|
// Refuse to move to or from the root
|
|
if srcPath == "" || dstPath == "" {
|
|
fs.Debugf(src, "DirMove error: Can't move root")
|
|
return errors.New("can't move root directory")
|
|
}
|
|
//fmt.Printf("Move src: %s (FullPath %s), dst: %s (FullPath: %s)\n", srcRemote, srcPath, dstRemote, dstPath)
|
|
|
|
var err error
|
|
_, err = f.List(ctx, dstRemote)
|
|
if err == fs.ErrorDirNotFound {
|
|
// OK
|
|
} else if err != nil {
|
|
return err
|
|
} else {
|
|
return fs.ErrorDirExists
|
|
}
|
|
|
|
_, err = f.copyOrMove(ctx, "mvDir", path.Join(f.fileEndpoint, f.opt.Enc.FromStandardPath(srcPath))+"/", dstRemote)
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("couldn't move directory: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PublicLink generates a public link to the remote path (usually readable by anyone)
|
|
func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (link string, err error) {
|
|
opts := rest.Opts{
|
|
Method: "GET",
|
|
Path: f.filePath(remote),
|
|
Parameters: url.Values{},
|
|
}
|
|
|
|
if unlink {
|
|
opts.Parameters.Set("mode", "disableShare")
|
|
} else {
|
|
opts.Parameters.Set("mode", "enableShare")
|
|
}
|
|
|
|
var resp *http.Response
|
|
var result api.JottaFile
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
resp, err = f.jfsSrv.CallXML(ctx, &opts, nil, &result)
|
|
return shouldRetry(ctx, resp, err)
|
|
})
|
|
|
|
if apiErr, ok := err.(*api.Error); ok {
|
|
// does not exist
|
|
if apiErr.StatusCode == http.StatusNotFound {
|
|
return "", fs.ErrorObjectNotFound
|
|
}
|
|
}
|
|
if err != nil {
|
|
if unlink {
|
|
return "", fmt.Errorf("couldn't remove public link: %w", err)
|
|
}
|
|
return "", fmt.Errorf("couldn't create public link: %w", err)
|
|
}
|
|
if unlink {
|
|
if result.PublicURI != "" {
|
|
return "", fmt.Errorf("couldn't remove public link - %q", result.PublicURI)
|
|
}
|
|
return "", nil
|
|
}
|
|
if result.PublicURI == "" {
|
|
return "", errors.New("couldn't create public link - no uri received")
|
|
}
|
|
if result.PublicSharePath != "" {
|
|
webLink := joinPath(wwwURL, result.PublicSharePath)
|
|
fs.Debugf(nil, "Web link: %s", webLink)
|
|
} else {
|
|
fs.Debugf(nil, "No web link received")
|
|
}
|
|
directLink := joinPath(wwwURL, fmt.Sprintf("opin/io/downloadPublic/%s/%s", f.user, result.PublicURI))
|
|
fs.Debugf(nil, "Direct link: %s", directLink)
|
|
return directLink, nil
|
|
}
|
|
|
|
// About gets quota information
|
|
func (f *Fs) About(ctx context.Context) (*fs.Usage, error) {
|
|
info, err := getDriveInfo(ctx, f.jfsSrv, f.user)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
usage := &fs.Usage{
|
|
Used: fs.NewUsageValue(info.Usage),
|
|
}
|
|
if info.Capacity > 0 {
|
|
usage.Total = fs.NewUsageValue(info.Capacity)
|
|
usage.Free = fs.NewUsageValue(info.Capacity - info.Usage)
|
|
}
|
|
return usage, nil
|
|
}
|
|
|
|
// UserInfo fetches info about the current user
|
|
func (f *Fs) UserInfo(ctx context.Context) (userInfo map[string]string, err error) {
|
|
cust, err := getCustomerInfo(ctx, f.apiSrv)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]string{
|
|
"Username": cust.Username,
|
|
"Email": cust.Email,
|
|
"Name": cust.Name,
|
|
"AccountType": cust.AccountType,
|
|
"SubscriptionType": cust.SubscriptionType,
|
|
}, nil
|
|
}
|
|
|
|
// CleanUp empties the trash
|
|
func (f *Fs) CleanUp(ctx context.Context) error {
|
|
opts := rest.Opts{
|
|
Method: "POST",
|
|
Path: "files/v1/purge_trash",
|
|
}
|
|
|
|
var info api.TrashResponse
|
|
_, err := f.apiSrv.CallJSON(ctx, &opts, nil, &info)
|
|
if err != nil {
|
|
return fmt.Errorf("couldn't empty trash: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Shutdown shutdown the fs
|
|
func (f *Fs) Shutdown(ctx context.Context) error {
|
|
f.tokenRenewer.Shutdown()
|
|
return nil
|
|
}
|
|
|
|
// Hashes returns the supported hash sets.
|
|
func (f *Fs) Hashes() hash.Set {
|
|
return hash.Set(hash.MD5)
|
|
}
|
|
|
|
// ---------------------------------------------
|
|
|
|
// Fs returns the parent Fs
|
|
func (o *Object) Fs() fs.Info {
|
|
return o.fs
|
|
}
|
|
|
|
// Return a string version
|
|
func (o *Object) String() string {
|
|
if o == nil {
|
|
return "<nil>"
|
|
}
|
|
return o.remote
|
|
}
|
|
|
|
// Remote returns the remote path
|
|
func (o *Object) Remote() string {
|
|
return o.remote
|
|
}
|
|
|
|
// filePath returns an escaped file path (f.root, remote)
|
|
func (o *Object) filePath() string {
|
|
return o.fs.filePath(o.remote)
|
|
}
|
|
|
|
// Hash returns the MD5 of an object returning a lowercase hex string
|
|
func (o *Object) Hash(ctx context.Context, t hash.Type) (string, error) {
|
|
if t != hash.MD5 {
|
|
return "", hash.ErrUnsupported
|
|
}
|
|
return o.md5, nil
|
|
}
|
|
|
|
// Size returns the size of an object in bytes
|
|
func (o *Object) Size() int64 {
|
|
ctx := context.TODO()
|
|
err := o.readMetaData(ctx, false)
|
|
if err != nil {
|
|
fs.Logf(o, "Failed to read metadata: %v", err)
|
|
return 0
|
|
}
|
|
return o.size
|
|
}
|
|
|
|
// MimeType of an Object if known, "" otherwise
|
|
func (o *Object) MimeType(ctx context.Context) string {
|
|
return o.mimeType
|
|
}
|
|
|
|
// validFile checks if info indicates file is valid
|
|
func (f *Fs) validFile(info *api.JottaFile) bool {
|
|
if info.State != "COMPLETED" {
|
|
return false // File is incomplete or corrupt
|
|
}
|
|
if !info.Deleted {
|
|
return !f.opt.TrashedOnly // Regular file; return false if TrashedOnly, else true
|
|
}
|
|
return f.opt.TrashedOnly // Deleted file; return true if TrashedOnly, else false
|
|
}
|
|
|
|
// validFolder checks if info indicates folder is valid
|
|
func (f *Fs) validFolder(info *api.JottaFolder) bool {
|
|
// Returns true if folder is not deleted.
|
|
// If TrashedOnly option then always returns true, because a folder not
|
|
// in trash must be traversed to get to files/subfolders that are.
|
|
return !bool(info.Deleted) || f.opt.TrashedOnly
|
|
}
|
|
|
|
// setMetaData sets the metadata from info
|
|
func (o *Object) setMetaData(info *api.JottaFile) (err error) {
|
|
o.hasMetaData = true
|
|
o.size = info.Size
|
|
o.md5 = info.MD5
|
|
o.mimeType = info.MimeType
|
|
o.createTime = time.Time(info.CreatedAt)
|
|
o.modTime = time.Time(info.ModifiedAt)
|
|
o.updateTime = time.Time(info.UpdatedAt)
|
|
return nil
|
|
}
|
|
|
|
// readMetaData reads and updates the metadata for an object
|
|
func (o *Object) readMetaData(ctx context.Context, force bool) (err error) {
|
|
if o.hasMetaData && !force {
|
|
return nil
|
|
}
|
|
info, err := o.fs.readMetaDataForPath(ctx, o.remote)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !o.fs.validFile(info) {
|
|
return fs.ErrorObjectNotFound
|
|
}
|
|
return o.setMetaData(info)
|
|
}
|
|
|
|
// parseFsMetadataTime parses a time string from fs.Metadata with key
|
|
func (o *Object) parseFsMetadataTime(m fs.Metadata, key string) (t time.Time, ok bool) {
|
|
value, ok := m[key]
|
|
if ok {
|
|
var err error
|
|
t, err = time.Parse(time.RFC3339Nano, value) // metadata stores RFC3339Nano timestamps
|
|
if err != nil {
|
|
fs.Debugf(o, "failed to parse metadata %s: %q: %v", key, value, err)
|
|
ok = false
|
|
}
|
|
}
|
|
return t, ok
|
|
}
|
|
|
|
// ModTime returns the modification time of the object
|
|
//
|
|
// It attempts to read the objects mtime and if that isn't present the
|
|
// LastModified returned in the http headers
|
|
func (o *Object) ModTime(ctx context.Context) time.Time {
|
|
err := o.readMetaData(ctx, false)
|
|
if err != nil {
|
|
fs.Logf(o, "Failed to read metadata: %v", err)
|
|
return time.Now()
|
|
}
|
|
return o.modTime
|
|
}
|
|
|
|
// SetModTime sets the modification time of the local fs object
|
|
func (o *Object) SetModTime(ctx context.Context, modTime time.Time) error {
|
|
// make sure metadata is available, we need its current size and md5
|
|
err := o.readMetaData(ctx, false)
|
|
if err != nil {
|
|
fs.Logf(o, "Failed to read metadata: %v", err)
|
|
return err
|
|
}
|
|
|
|
// request check/update with existing metadata and new modtime
|
|
// (note that if size/md5 does not match, the file content will
|
|
// also be modified if deduplication is possible, i.e. it is
|
|
// important to use correct/latest values)
|
|
_, err = o.fs.createOrUpdate(ctx, o.remote, o.createTime, modTime, o.size, o.md5)
|
|
if err != nil {
|
|
if err == fs.ErrorObjectNotFound {
|
|
// file was modified (size/md5 changed) between readMetaData and createOrUpdate?
|
|
return errors.New("metadata did not match")
|
|
}
|
|
return err
|
|
}
|
|
|
|
// update local metadata
|
|
o.modTime = modTime
|
|
return nil
|
|
}
|
|
|
|
// Storable returns a boolean showing whether this object storable
|
|
func (o *Object) Storable() bool {
|
|
return true
|
|
}
|
|
|
|
// Open an object for read
|
|
func (o *Object) Open(ctx context.Context, options ...fs.OpenOption) (in io.ReadCloser, err error) {
|
|
fs.FixRangeOption(options, o.size)
|
|
var resp *http.Response
|
|
opts := rest.Opts{
|
|
Method: "GET",
|
|
Path: o.filePath(),
|
|
Parameters: url.Values{},
|
|
Options: options,
|
|
}
|
|
|
|
opts.Parameters.Set("mode", "bin")
|
|
|
|
err = o.fs.pacer.Call(func() (bool, error) {
|
|
resp, err = o.fs.jfsSrv.Call(ctx, &opts)
|
|
return shouldRetry(ctx, resp, err)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return resp.Body, err
|
|
}
|
|
|
|
// Read the md5 of in returning a reader which will read the same contents
|
|
//
|
|
// The cleanup function should be called when out is finished with
|
|
// regardless of whether this function returned an error or not.
|
|
func readMD5(in io.Reader, size, threshold int64) (md5sum string, out io.Reader, cleanup func(), err error) {
|
|
// we need an MD5
|
|
md5Hasher := md5.New()
|
|
// use the teeReader to write to the local file AND calculate the MD5 while doing so
|
|
teeReader := io.TeeReader(in, md5Hasher)
|
|
|
|
// nothing to clean up by default
|
|
cleanup = func() {}
|
|
|
|
// don't cache small files on disk to reduce wear of the disk
|
|
if size > threshold {
|
|
var tempFile *os.File
|
|
|
|
// create the cache file
|
|
tempFile, err = os.CreateTemp("", cachePrefix)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
_ = os.Remove(tempFile.Name()) // Delete the file - may not work on Windows
|
|
|
|
// clean up the file after we are done downloading
|
|
cleanup = func() {
|
|
// the file should normally already be close, but just to make sure
|
|
_ = tempFile.Close()
|
|
_ = os.Remove(tempFile.Name()) // delete the cache file after we are done - may be deleted already
|
|
}
|
|
|
|
// copy the ENTIRE file to disc and calculate the MD5 in the process
|
|
if _, err = io.Copy(tempFile, teeReader); err != nil {
|
|
return
|
|
}
|
|
// jump to the start of the local file so we can pass it along
|
|
if _, err = tempFile.Seek(0, 0); err != nil {
|
|
return
|
|
}
|
|
|
|
// replace the already read source with a reader of our cached file
|
|
out = tempFile
|
|
} else {
|
|
// that's a small file, just read it into memory
|
|
var inData []byte
|
|
inData, err = io.ReadAll(teeReader)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// set the reader to our read memory block
|
|
out = bytes.NewReader(inData)
|
|
}
|
|
return hex.EncodeToString(md5Hasher.Sum(nil)), out, cleanup, nil
|
|
}
|
|
|
|
// Update the object with the contents of the io.Reader, modTime and size
|
|
//
|
|
// If existing is set then it updates the object rather than creating a new one.
|
|
//
|
|
// The new object may have been created if an error is returned
|
|
func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (err error) {
|
|
if o.fs.opt.NoVersions {
|
|
err := o.readMetaData(ctx, false)
|
|
if err == nil {
|
|
// if the object exists delete it
|
|
err = o.remove(ctx, true)
|
|
if err != nil && err != fs.ErrorObjectNotFound {
|
|
// if delete failed then report that, unless it was because the file did not exist after all
|
|
return fmt.Errorf("failed to remove old object: %w", err)
|
|
}
|
|
} else if err != fs.ErrorObjectNotFound {
|
|
// if the object does not exist we can just continue but if the error is something different we should report that
|
|
return err
|
|
}
|
|
}
|
|
o.fs.tokenRenewer.Start()
|
|
defer o.fs.tokenRenewer.Stop()
|
|
size := src.Size()
|
|
md5String, err := src.Hash(ctx, hash.MD5)
|
|
if err != nil || md5String == "" {
|
|
// unwrap the accounting from the input, we use wrap to put it
|
|
// back on after the buffering
|
|
var wrap accounting.WrapFn
|
|
in, wrap = accounting.UnWrap(in)
|
|
var cleanup func()
|
|
md5String, in, cleanup, err = readMD5(in, size, int64(o.fs.opt.MD5MemoryThreshold))
|
|
defer cleanup()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to calculate MD5: %w", err)
|
|
}
|
|
// Wrap the accounting back onto the stream
|
|
in = wrap(in)
|
|
}
|
|
// Fetch metadata if --metadata is in use
|
|
meta, err := fs.GetMetadataOptions(ctx, o.fs, src, options)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read metadata from source object: %w", err)
|
|
}
|
|
var createdTime string
|
|
var modTime string
|
|
if meta != nil {
|
|
if t, ok := o.parseFsMetadataTime(meta, "btime"); ok {
|
|
createdTime = api.Rfc3339Time(t).String() // jottacloud api wants RFC3339 timestamps
|
|
}
|
|
if t, ok := o.parseFsMetadataTime(meta, "mtime"); ok {
|
|
modTime = api.Rfc3339Time(t).String()
|
|
}
|
|
}
|
|
if modTime == "" { // prefer mtime in meta as Modified time, fallback to source ModTime
|
|
modTime = api.Rfc3339Time(src.ModTime(ctx)).String()
|
|
}
|
|
if createdTime == "" { // if no Created time set same as Modified
|
|
createdTime = modTime
|
|
}
|
|
|
|
// use the api to allocate the file first and get resume / deduplication info
|
|
var resp *http.Response
|
|
opts := rest.Opts{
|
|
Method: "POST",
|
|
Path: "files/v1/allocate",
|
|
Options: options,
|
|
ExtraHeaders: make(map[string]string),
|
|
}
|
|
|
|
// the allocate request
|
|
var request = api.AllocateFileRequest{
|
|
Bytes: size,
|
|
Created: createdTime,
|
|
Modified: modTime,
|
|
Md5: md5String,
|
|
Path: o.fs.allocatePathRaw(o.remote, true),
|
|
}
|
|
|
|
// send it
|
|
var response api.AllocateFileResponse
|
|
err = o.fs.pacer.CallNoRetry(func() (bool, error) {
|
|
resp, err = o.fs.apiSrv.CallJSON(ctx, &opts, &request, &response)
|
|
return shouldRetry(ctx, resp, err)
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// If the file state is INCOMPLETE and CORRUPT, we must upload it.
|
|
// Else, if the file state is COMPLETE, we don't need to upload it because
|
|
// the content is already there, possibly it was created with deduplication,
|
|
// and also any metadata changes are already performed by the allocate request.
|
|
if response.State != "COMPLETED" {
|
|
// how much do we still have to upload?
|
|
remainingBytes := size - response.ResumePos
|
|
opts = rest.Opts{
|
|
Method: "POST",
|
|
RootURL: response.UploadURL,
|
|
ContentLength: &remainingBytes,
|
|
ContentType: "application/octet-stream",
|
|
Body: in,
|
|
ExtraHeaders: make(map[string]string),
|
|
}
|
|
if response.ResumePos != 0 {
|
|
opts.ExtraHeaders["Range"] = "bytes=" + strconv.FormatInt(response.ResumePos, 10) + "-" + strconv.FormatInt(size-1, 10)
|
|
}
|
|
|
|
// copy the already uploaded bytes into the trash :)
|
|
var result api.UploadResponse
|
|
_, err = io.CopyN(io.Discard, in, response.ResumePos)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// send the remaining bytes
|
|
_, err = o.fs.apiSrv.CallJSON(ctx, &opts, nil, &result)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Upload response contains main metadata properties (size, md5 and modTime)
|
|
// which could be set back to the object, but it does not contain the
|
|
// necessary information to set the createTime and updateTime properties,
|
|
// so must therefore perform a read instead.
|
|
}
|
|
// in any case we must update the object meta data
|
|
return o.readMetaData(ctx, true)
|
|
}
|
|
|
|
func (o *Object) remove(ctx context.Context, hard bool) error {
|
|
opts := rest.Opts{
|
|
Method: "POST",
|
|
Path: o.filePath(),
|
|
Parameters: url.Values{},
|
|
NoResponse: true,
|
|
}
|
|
|
|
if hard {
|
|
opts.Parameters.Set("rm", "true")
|
|
} else {
|
|
opts.Parameters.Set("dl", "true")
|
|
}
|
|
|
|
err := o.fs.pacer.Call(func() (bool, error) {
|
|
resp, err := o.fs.jfsSrv.CallXML(ctx, &opts, nil, nil)
|
|
return shouldRetry(ctx, resp, err)
|
|
})
|
|
if apiErr, ok := err.(*api.Error); ok {
|
|
// attempting to hard delete will fail if path does not exist, but standard delete will succeed
|
|
if apiErr.StatusCode == http.StatusNotFound {
|
|
return fs.ErrorObjectNotFound
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Remove an object
|
|
func (o *Object) Remove(ctx context.Context) error {
|
|
return o.remove(ctx, o.fs.opt.HardDelete)
|
|
}
|
|
|
|
// Metadata returns metadata for an object
|
|
//
|
|
// It should return nil if there is no Metadata
|
|
func (o *Object) Metadata(ctx context.Context) (metadata fs.Metadata, err error) {
|
|
err = o.readMetaData(ctx, false)
|
|
if err != nil {
|
|
fs.Logf(o, "Failed to read metadata: %v", err)
|
|
return nil, err
|
|
}
|
|
metadata.Set("btime", o.createTime.Format(time.RFC3339Nano)) // metadata timestamps should be RFC3339Nano
|
|
metadata.Set("mtime", o.modTime.Format(time.RFC3339Nano))
|
|
metadata.Set("utime", o.updateTime.Format(time.RFC3339Nano))
|
|
metadata.Set("content-type", o.mimeType)
|
|
return metadata, nil
|
|
}
|
|
|
|
// Check the interfaces are satisfied
|
|
var (
|
|
_ fs.Fs = (*Fs)(nil)
|
|
_ fs.Purger = (*Fs)(nil)
|
|
_ fs.Copier = (*Fs)(nil)
|
|
_ fs.Mover = (*Fs)(nil)
|
|
_ fs.DirMover = (*Fs)(nil)
|
|
_ fs.ListRer = (*Fs)(nil)
|
|
_ fs.PublicLinker = (*Fs)(nil)
|
|
_ fs.Abouter = (*Fs)(nil)
|
|
_ fs.UserInfoer = (*Fs)(nil)
|
|
_ fs.CleanUpper = (*Fs)(nil)
|
|
_ fs.Shutdowner = (*Fs)(nil)
|
|
_ fs.Object = (*Object)(nil)
|
|
_ fs.MimeTyper = (*Object)(nil)
|
|
_ fs.Metadataer = (*Object)(nil)
|
|
)
|