907 lines
23 KiB
Go
907 lines
23 KiB
Go
|
// Package linkbox provides an interface to the linkbox.to Cloud storage system.
|
||
|
package linkbox
|
||
|
|
||
|
import (
|
||
|
"bytes"
|
||
|
"context"
|
||
|
"crypto/md5"
|
||
|
"errors"
|
||
|
"fmt"
|
||
|
"io"
|
||
|
"net/http"
|
||
|
"net/url"
|
||
|
"path"
|
||
|
"strconv"
|
||
|
"strings"
|
||
|
"time"
|
||
|
|
||
|
"github.com/rclone/rclone/fs"
|
||
|
"github.com/rclone/rclone/fs/config/configmap"
|
||
|
"github.com/rclone/rclone/fs/config/configstruct"
|
||
|
"github.com/rclone/rclone/fs/fserrors"
|
||
|
"github.com/rclone/rclone/fs/fshttp"
|
||
|
"github.com/rclone/rclone/fs/hash"
|
||
|
"github.com/rclone/rclone/lib/pacer"
|
||
|
"github.com/rclone/rclone/lib/rest"
|
||
|
)
|
||
|
|
||
|
const (
|
||
|
retriesAmmount = 2
|
||
|
maxEntitiesPerPage = 64
|
||
|
minSleep = 200 * time.Millisecond
|
||
|
maxSleep = 2 * time.Second
|
||
|
pacerBurst = 1
|
||
|
linkboxAPIURL = "https://www.linkbox.to/api/open/"
|
||
|
)
|
||
|
|
||
|
func init() {
|
||
|
fsi := &fs.RegInfo{
|
||
|
Name: "linkbox",
|
||
|
Description: "Linkbox",
|
||
|
NewFs: NewFs,
|
||
|
Options: []fs.Option{{
|
||
|
Name: "token",
|
||
|
Help: "Token from https://www.linkbox.to/admin/account",
|
||
|
Sensitive: true,
|
||
|
Required: true,
|
||
|
}},
|
||
|
}
|
||
|
fs.Register(fsi)
|
||
|
}
|
||
|
|
||
|
// Options defines the configuration for this backend
|
||
|
type Options struct {
|
||
|
Token string `config:"token"`
|
||
|
}
|
||
|
|
||
|
// Fs stores the interface to the remote Linkbox files
|
||
|
type Fs struct {
|
||
|
name string
|
||
|
root string
|
||
|
opt Options // options for this backend
|
||
|
features *fs.Features // optional features
|
||
|
ci *fs.ConfigInfo // global config
|
||
|
srv *rest.Client // the connection to the server
|
||
|
pacer *fs.Pacer
|
||
|
}
|
||
|
|
||
|
// Object is a remote object that has been stat'd (so it exists, but is not necessarily open for reading)
|
||
|
type Object struct {
|
||
|
fs *Fs
|
||
|
remote string
|
||
|
size int64
|
||
|
modTime time.Time
|
||
|
contentType string
|
||
|
fullURL string
|
||
|
pid int
|
||
|
isDir bool
|
||
|
id string
|
||
|
}
|
||
|
|
||
|
// NewFs creates a new Fs object from the name and root. It connects to
|
||
|
// the host specified in the config file.
|
||
|
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
|
||
|
}
|
||
|
|
||
|
ci := fs.GetConfig(ctx)
|
||
|
|
||
|
f := &Fs{
|
||
|
name: name,
|
||
|
opt: *opt,
|
||
|
ci: ci,
|
||
|
srv: rest.NewClient(fshttp.NewClient(ctx)),
|
||
|
pacer: fs.NewPacer(ctx, pacer.NewDefault(pacer.MinSleep(minSleep), pacer.MaxSleep(maxSleep))),
|
||
|
}
|
||
|
|
||
|
f.pacer.SetRetries(retriesAmmount)
|
||
|
|
||
|
f.features = (&fs.Features{
|
||
|
CanHaveEmptyDirectories: true,
|
||
|
CaseInsensitive: true,
|
||
|
}).Fill(ctx, f)
|
||
|
|
||
|
// 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(ctx, 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
|
||
|
}
|
||
|
|
||
|
type entity struct {
|
||
|
Type string `json:"type"`
|
||
|
Name string `json:"name"`
|
||
|
URL string `json:"url"`
|
||
|
Ctime int64 `json:"ctime"`
|
||
|
Size int `json:"size"`
|
||
|
ID int `json:"id"`
|
||
|
Pid int `json:"pid"`
|
||
|
ItemID string `json:"item_id"`
|
||
|
}
|
||
|
type data struct {
|
||
|
Entities []entity `json:"list"`
|
||
|
}
|
||
|
type fileSearchRes struct {
|
||
|
SearchData data `json:"data"`
|
||
|
Status int `json:"status"`
|
||
|
Message string `json:"msg"`
|
||
|
}
|
||
|
|
||
|
func (f *Fs) getIDByDir(ctx context.Context, dir string) (int, error) {
|
||
|
var pid int
|
||
|
var err error
|
||
|
err = f.pacer.Call(func() (bool, error) {
|
||
|
pid, err = f._getIDByDir(ctx, dir)
|
||
|
return f.shouldRetry(ctx, err)
|
||
|
})
|
||
|
|
||
|
if fserrors.IsRetryError(err) {
|
||
|
fs.Debugf(f, "getting ID of Dir error: retrying: pid = {%d}, dir = {%s}, err = {%s}", pid, dir, err)
|
||
|
err = fs.ErrorDirNotFound
|
||
|
}
|
||
|
|
||
|
return pid, err
|
||
|
}
|
||
|
|
||
|
func (f *Fs) _getIDByDir(ctx context.Context, dir string) (int, error) {
|
||
|
if dir == "" || dir == "/" {
|
||
|
return 0, nil // we assume that it is root directory
|
||
|
}
|
||
|
|
||
|
path := strings.TrimPrefix(dir, "/")
|
||
|
dirs := strings.Split(path, "/")
|
||
|
pid := 0
|
||
|
|
||
|
for level, tdir := range dirs {
|
||
|
pageNumber := 0
|
||
|
numberOfEntities := maxEntitiesPerPage
|
||
|
|
||
|
for numberOfEntities == maxEntitiesPerPage {
|
||
|
pageNumber++
|
||
|
opts := makeSearchQuery("", pid, f.opt.Token, pageNumber)
|
||
|
responseResult := fileSearchRes{}
|
||
|
err := getUnmarshaledResponse(ctx, f, opts, &responseResult)
|
||
|
if err != nil {
|
||
|
return 0, fmt.Errorf("error in unmurshaling response from linkbox.to: %w", err)
|
||
|
}
|
||
|
|
||
|
numberOfEntities = len(responseResult.SearchData.Entities)
|
||
|
if len(responseResult.SearchData.Entities) == 0 {
|
||
|
return 0, fs.ErrorDirNotFound
|
||
|
}
|
||
|
|
||
|
for _, entity := range responseResult.SearchData.Entities {
|
||
|
if entity.Pid == pid && (entity.Type == "dir" || entity.Type == "sdir") && strings.EqualFold(entity.Name, tdir) {
|
||
|
pid = entity.ID
|
||
|
if level == len(dirs)-1 {
|
||
|
return pid, nil
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if pageNumber > 100000 {
|
||
|
return 0, fmt.Errorf("too many results")
|
||
|
}
|
||
|
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// fs.Debugf(f, "getIDByDir fs.ErrorDirNotFound dir = {%s} path = {%s}", dir, path)
|
||
|
|
||
|
return 0, fs.ErrorDirNotFound
|
||
|
}
|
||
|
|
||
|
func getUnmarshaledResponse(ctx context.Context, f *Fs, opts *rest.Opts, result interface{}) error {
|
||
|
err := f.pacer.Call(func() (bool, error) {
|
||
|
_, err := f.srv.CallJSON(ctx, opts, nil, &result)
|
||
|
return f.shouldRetry(ctx, err)
|
||
|
})
|
||
|
return err
|
||
|
}
|
||
|
|
||
|
func makeSearchQuery(name string, pid int, token string, pageNubmer int) *rest.Opts {
|
||
|
return &rest.Opts{
|
||
|
Method: "GET",
|
||
|
RootURL: linkboxAPIURL,
|
||
|
Path: "file_search",
|
||
|
Parameters: url.Values{
|
||
|
"token": {token},
|
||
|
"name": {name},
|
||
|
"pid": {strconv.Itoa(pid)},
|
||
|
"pageNo": {strconv.Itoa(pageNubmer)},
|
||
|
"pageSize": {strconv.Itoa(maxEntitiesPerPage)},
|
||
|
},
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func (f *Fs) getFilesByDir(ctx context.Context, dir string) ([]*Object, error) {
|
||
|
var responseResult fileSearchRes
|
||
|
var files []*Object
|
||
|
var numberOfEntities int
|
||
|
|
||
|
fullPath := path.Join(f.root, dir)
|
||
|
fullPath = strings.TrimPrefix(fullPath, "/")
|
||
|
|
||
|
pid, err := f.getIDByDir(ctx, fullPath)
|
||
|
|
||
|
if err != nil {
|
||
|
fs.Debugf(f, "getting files list error: dir = {%s} fullPath = {%s} pid = {%d} err = {%s}", dir, fullPath, pid, err)
|
||
|
|
||
|
return nil, err
|
||
|
}
|
||
|
|
||
|
pageNumber := 0
|
||
|
numberOfEntities = maxEntitiesPerPage
|
||
|
|
||
|
for numberOfEntities == maxEntitiesPerPage {
|
||
|
pageNumber++
|
||
|
opts := makeSearchQuery("", pid, f.opt.Token, pageNumber)
|
||
|
|
||
|
responseResult = fileSearchRes{}
|
||
|
err = getUnmarshaledResponse(ctx, f, opts, &responseResult)
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("getting files failed with error in unmurshaling response from linkbox.to: %w", err)
|
||
|
|
||
|
}
|
||
|
|
||
|
if responseResult.Status != 1 {
|
||
|
return nil, fmt.Errorf("parsing failed: %s", responseResult.Message)
|
||
|
}
|
||
|
|
||
|
numberOfEntities = len(responseResult.SearchData.Entities)
|
||
|
|
||
|
for _, entity := range responseResult.SearchData.Entities {
|
||
|
if entity.Pid != pid {
|
||
|
fs.Debugf(f, "getFilesByDir error with entity.Name {%s} dir {%s}", entity.Name, dir)
|
||
|
}
|
||
|
file := &Object{
|
||
|
fs: f,
|
||
|
remote: entity.Name,
|
||
|
modTime: time.Unix(entity.Ctime, 0),
|
||
|
contentType: entity.Type,
|
||
|
size: int64(entity.Size),
|
||
|
fullURL: entity.URL,
|
||
|
isDir: entity.Type == "dir" || entity.Type == "sdir",
|
||
|
id: entity.ItemID,
|
||
|
pid: entity.Pid,
|
||
|
}
|
||
|
|
||
|
files = append(files, file)
|
||
|
}
|
||
|
|
||
|
if pageNumber > 100000 {
|
||
|
return files, fmt.Errorf("too many results")
|
||
|
}
|
||
|
|
||
|
}
|
||
|
|
||
|
return files, nil
|
||
|
}
|
||
|
|
||
|
func splitDirAndName(remote string) (dir string, name string) {
|
||
|
lastSlashPosition := strings.LastIndex(remote, "/")
|
||
|
if lastSlashPosition == -1 {
|
||
|
dir = ""
|
||
|
name = remote
|
||
|
} else {
|
||
|
dir = remote[:lastSlashPosition]
|
||
|
name = remote[lastSlashPosition+1:]
|
||
|
}
|
||
|
|
||
|
// fs.Debugf(nil, "splitDirAndName remote = {%s}, dir = {%s}, name = {%s}", remote, dir, name)
|
||
|
|
||
|
return dir, name
|
||
|
}
|
||
|
|
||
|
// 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) {
|
||
|
// fs.Debugf(f, "List method dir = {%s}", dir)
|
||
|
|
||
|
objects, err := f.getFilesByDir(ctx, dir)
|
||
|
if err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
|
||
|
for _, obj := range objects {
|
||
|
prefix := ""
|
||
|
if dir != "" {
|
||
|
prefix = dir + "/"
|
||
|
}
|
||
|
|
||
|
if obj.isDir {
|
||
|
entries = append(entries, fs.NewDir(prefix+obj.remote, obj.modTime))
|
||
|
} else {
|
||
|
obj.remote = prefix + obj.remote
|
||
|
entries = append(entries, obj)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return entries, nil
|
||
|
}
|
||
|
|
||
|
func getObject(ctx context.Context, f *Fs, name string, pid int, token string) (entity, error) {
|
||
|
var err error
|
||
|
var entity entity
|
||
|
err = f.pacer.Call(func() (bool, error) {
|
||
|
entity, err = _getObject(ctx, f, name, pid, token)
|
||
|
return f.shouldRetry(ctx, err)
|
||
|
})
|
||
|
// fs.Debugf(f, "getObject: name = {%s}, pid = {%d}, err = {%#v}", name, pid, err)
|
||
|
|
||
|
if fserrors.IsRetryError(err) {
|
||
|
fs.Debugf(f, "getObject IsRetryError: name = {%s}, pid = {%d}, err = {%#v}", name, pid, err)
|
||
|
|
||
|
err = fs.ErrorObjectNotFound
|
||
|
}
|
||
|
|
||
|
return entity, err
|
||
|
}
|
||
|
|
||
|
func _getObject(ctx context.Context, f *Fs, name string, pid int, token string) (entity, error) {
|
||
|
pageNumber := 0
|
||
|
numberOfEntities := maxEntitiesPerPage
|
||
|
|
||
|
for numberOfEntities == maxEntitiesPerPage {
|
||
|
pageNumber++
|
||
|
opts := makeSearchQuery("", pid, token, pageNumber)
|
||
|
|
||
|
searchResponse := fileSearchRes{}
|
||
|
err := getUnmarshaledResponse(ctx, f, opts, &searchResponse)
|
||
|
if err != nil {
|
||
|
return entity{}, fmt.Errorf("unable to create new object: %w", err)
|
||
|
}
|
||
|
if searchResponse.Status != 1 {
|
||
|
return entity{}, fmt.Errorf("unable to create new object: %s", searchResponse.Message)
|
||
|
}
|
||
|
numberOfEntities = len(searchResponse.SearchData.Entities)
|
||
|
|
||
|
// fs.Debugf(f, "getObject numberOfEntities {%d} name {%s}", numberOfEntities, name)
|
||
|
|
||
|
for _, obj := range searchResponse.SearchData.Entities {
|
||
|
// fs.Debugf(f, "getObject entity.Name {%s} name {%s}", obj.Name, name)
|
||
|
if obj.Pid == pid && strings.EqualFold(obj.Name, name) {
|
||
|
// fs.Debugf(f, "getObject found entity.Name {%s} name {%s}", obj.Name, name)
|
||
|
if obj.Type == "dir" || obj.Type == "sdir" {
|
||
|
return entity{}, fs.ErrorIsDir
|
||
|
}
|
||
|
return obj, nil
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if pageNumber > 100000 {
|
||
|
return entity{}, fmt.Errorf("too many results")
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return entity{}, fs.ErrorObjectNotFound
|
||
|
}
|
||
|
|
||
|
// NewObject finds the Object at remote. If it can't be found
|
||
|
// it returns the error ErrorObjectNotFound.
|
||
|
//
|
||
|
// If remote points to a directory then it should return
|
||
|
// ErrorIsDir if possible without doing any extra work,
|
||
|
// otherwise ErrorObjectNotFound.
|
||
|
func (f *Fs) NewObject(ctx context.Context, remote string) (fs.Object, error) {
|
||
|
var newObject entity
|
||
|
var dir, name string
|
||
|
|
||
|
fullPath := path.Join(f.root, remote)
|
||
|
dir, name = splitDirAndName(fullPath)
|
||
|
|
||
|
dirID, err := f.getIDByDir(ctx, dir)
|
||
|
if err != nil {
|
||
|
return nil, fs.ErrorObjectNotFound
|
||
|
}
|
||
|
|
||
|
newObject, err = getObject(ctx, f, name, dirID, f.opt.Token)
|
||
|
if err != nil {
|
||
|
// fs.Debugf(f, "NewObject getObject error = {%s}", err)
|
||
|
|
||
|
return nil, err
|
||
|
}
|
||
|
|
||
|
if newObject == (entity{}) {
|
||
|
return nil, fs.ErrorObjectNotFound
|
||
|
}
|
||
|
|
||
|
return &Object{
|
||
|
fs: f,
|
||
|
remote: name,
|
||
|
modTime: time.Unix(newObject.Ctime, 0),
|
||
|
fullURL: newObject.URL,
|
||
|
size: int64(newObject.Size),
|
||
|
id: newObject.ItemID,
|
||
|
pid: newObject.Pid,
|
||
|
}, nil
|
||
|
}
|
||
|
|
||
|
// Mkdir makes the directory (container, bucket)
|
||
|
//
|
||
|
// Shouldn't return an error if it already exists
|
||
|
func (f *Fs) Mkdir(ctx context.Context, dir string) error {
|
||
|
var pdir, name string
|
||
|
|
||
|
fullPath := path.Join(f.root, dir)
|
||
|
if fullPath == "" {
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
fullPath = strings.TrimPrefix(fullPath, "/")
|
||
|
|
||
|
dirs := strings.Split(fullPath, "/")
|
||
|
dirs = append([]string{""}, dirs...)
|
||
|
|
||
|
for i, dirName := range dirs {
|
||
|
pdir = path.Join(pdir, dirName)
|
||
|
name = dirs[i+1]
|
||
|
pid, err := f.getIDByDir(ctx, pdir)
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
|
||
|
opts := &rest.Opts{
|
||
|
Method: "GET",
|
||
|
RootURL: linkboxAPIURL,
|
||
|
Path: "folder_create",
|
||
|
Parameters: url.Values{
|
||
|
"token": {f.opt.Token},
|
||
|
"name": {name},
|
||
|
"pid": {strconv.Itoa(pid)},
|
||
|
"isShare": {"0"},
|
||
|
"canInvite": {"1"},
|
||
|
"canShare": {"1"},
|
||
|
"withBodyImg": {"1"},
|
||
|
"desc": {""},
|
||
|
},
|
||
|
}
|
||
|
|
||
|
response := getResponse{}
|
||
|
|
||
|
err = getUnmarshaledResponse(ctx, f, opts, &response)
|
||
|
if err != nil {
|
||
|
return fmt.Errorf("Mkdir error in unmurshaling response from linkbox.to: %w", err)
|
||
|
|
||
|
}
|
||
|
|
||
|
if i+1 == len(dirs)-1 {
|
||
|
break
|
||
|
}
|
||
|
|
||
|
// response status 1501 means that directory already exists
|
||
|
if response.Status != 1 && response.Status != 1501 {
|
||
|
return fmt.Errorf("could not create dir[%s]: %s", dir, response.Message)
|
||
|
}
|
||
|
|
||
|
}
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
func (f *Fs) purgeCheck(ctx context.Context, dir string, check bool) error {
|
||
|
fullPath := path.Join(f.root, dir)
|
||
|
|
||
|
if fullPath == "" {
|
||
|
return fs.ErrorDirNotFound
|
||
|
}
|
||
|
|
||
|
fullPath = strings.TrimPrefix(fullPath, "/")
|
||
|
dirIDs, err := f.getIDByDir(ctx, fullPath)
|
||
|
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
|
||
|
entries, err := f.List(ctx, dir)
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
|
||
|
if len(entries) != 0 && check {
|
||
|
return fs.ErrorDirectoryNotEmpty
|
||
|
}
|
||
|
|
||
|
opts := &rest.Opts{
|
||
|
Method: "GET",
|
||
|
RootURL: linkboxAPIURL,
|
||
|
Path: "folder_del",
|
||
|
Parameters: url.Values{
|
||
|
"token": {f.opt.Token},
|
||
|
"dirIds": {strconv.Itoa(dirIDs)},
|
||
|
},
|
||
|
}
|
||
|
|
||
|
response := getResponse{}
|
||
|
err = getUnmarshaledResponse(ctx, f, opts, &response)
|
||
|
|
||
|
if err != nil {
|
||
|
return fmt.Errorf("purging error in unmurshaling response from linkbox.to: %w", err)
|
||
|
|
||
|
}
|
||
|
|
||
|
if response.Status != 1 {
|
||
|
// it can be some different error, but Linkbox
|
||
|
// returns very few statuses
|
||
|
return fs.ErrorDirExists
|
||
|
}
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
// Rmdir removes the directory (container, bucket) if empty
|
||
|
//
|
||
|
// Return an error if it doesn't exist or isn't empty
|
||
|
func (f *Fs) Rmdir(ctx context.Context, dir string) error {
|
||
|
return f.purgeCheck(ctx, dir, true)
|
||
|
}
|
||
|
|
||
|
// SetModTime sets modTime on a particular file
|
||
|
func (o *Object) SetModTime(ctx context.Context, modTime time.Time) error {
|
||
|
return fs.ErrorCantSetModTime
|
||
|
}
|
||
|
|
||
|
// Open opens the file for read. Call Close() on the returned io.ReadCloser
|
||
|
func (o *Object) Open(ctx context.Context, options ...fs.OpenOption) (io.ReadCloser, error) {
|
||
|
var res *http.Response
|
||
|
downloadURL := o.fullURL
|
||
|
if downloadURL == "" {
|
||
|
_, name := splitDirAndName(o.Remote())
|
||
|
newObject, err := getObject(ctx, o.fs, name, o.pid, o.fs.opt.Token)
|
||
|
if err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
if newObject == (entity{}) {
|
||
|
// fs.Debugf(o.fs, "Open entity is empty: name = {%s}", name)
|
||
|
return nil, fs.ErrorObjectNotFound
|
||
|
}
|
||
|
|
||
|
downloadURL = newObject.URL
|
||
|
}
|
||
|
|
||
|
opts := &rest.Opts{
|
||
|
Method: "GET",
|
||
|
RootURL: downloadURL,
|
||
|
Options: options,
|
||
|
}
|
||
|
|
||
|
err := o.fs.pacer.Call(func() (bool, error) {
|
||
|
var err error
|
||
|
res, err = o.fs.srv.Call(ctx, opts)
|
||
|
return o.fs.shouldRetry(ctx, err)
|
||
|
})
|
||
|
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("Open failed: %w", err)
|
||
|
}
|
||
|
|
||
|
return res.Body, nil
|
||
|
}
|
||
|
|
||
|
// Update in to the object with the modTime given of the given size
|
||
|
//
|
||
|
// When called from outside an Fs by rclone, src.Size() will always be >= 0.
|
||
|
// But for unknown-sized objects (indicated by src.Size() == -1), Upload should either
|
||
|
// return an error or update the object properly (rather than e.g. calling panic).
|
||
|
func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) error {
|
||
|
if src.Size() == 0 {
|
||
|
return fs.ErrorCantUploadEmptyFiles
|
||
|
}
|
||
|
|
||
|
remote := o.Remote()
|
||
|
tmpObject, err := o.fs.NewObject(ctx, remote)
|
||
|
|
||
|
if err == nil {
|
||
|
// fs.Debugf(o.fs, "Update: removing old file")
|
||
|
_ = tmpObject.Remove(ctx)
|
||
|
}
|
||
|
|
||
|
first10m := io.LimitReader(in, 10_485_760)
|
||
|
first10mBytes, err := io.ReadAll(first10m)
|
||
|
if err != nil {
|
||
|
return fmt.Errorf("Update err in reading file: %w", err)
|
||
|
}
|
||
|
|
||
|
// get upload authorization (step 1)
|
||
|
opts := &rest.Opts{
|
||
|
Method: "GET",
|
||
|
RootURL: linkboxAPIURL,
|
||
|
Path: "get_upload_url",
|
||
|
Options: options,
|
||
|
Parameters: url.Values{
|
||
|
"token": {o.fs.opt.Token},
|
||
|
"fileMd5ofPre10m": {fmt.Sprintf("%x", md5.Sum(first10mBytes))},
|
||
|
"fileSize": {strconv.FormatInt(src.Size(), 10)},
|
||
|
},
|
||
|
}
|
||
|
|
||
|
getFistStepResult := getUploadURLResponse{}
|
||
|
err = getUnmarshaledResponse(ctx, o.fs, opts, &getFistStepResult)
|
||
|
if err != nil {
|
||
|
return fmt.Errorf("Update err in unmarshaling response: %w", err)
|
||
|
}
|
||
|
|
||
|
switch getFistStepResult.Status {
|
||
|
case 1:
|
||
|
// upload file using link from first step
|
||
|
var res *http.Response
|
||
|
|
||
|
file := io.MultiReader(bytes.NewReader(first10mBytes), in)
|
||
|
|
||
|
opts := &rest.Opts{
|
||
|
Method: "PUT",
|
||
|
RootURL: getFistStepResult.Data.SignURL,
|
||
|
Options: options,
|
||
|
Body: file,
|
||
|
}
|
||
|
|
||
|
err = o.fs.pacer.CallNoRetry(func() (bool, error) {
|
||
|
res, err = o.fs.srv.Call(ctx, opts)
|
||
|
return o.fs.shouldRetry(ctx, err)
|
||
|
})
|
||
|
|
||
|
if err != nil {
|
||
|
return fmt.Errorf("update err in uploading file: %w", err)
|
||
|
}
|
||
|
|
||
|
_, err = io.ReadAll(res.Body)
|
||
|
if err != nil {
|
||
|
return fmt.Errorf("update err in reading response: %w", err)
|
||
|
}
|
||
|
|
||
|
case 600:
|
||
|
// Status means that we don't need to upload file
|
||
|
// We need only to make second step
|
||
|
default:
|
||
|
return fmt.Errorf("got unexpected message from Linkbox: %s", getFistStepResult.Message)
|
||
|
}
|
||
|
|
||
|
fullPath := path.Join(o.fs.root, remote)
|
||
|
fullPath = strings.TrimPrefix(fullPath, "/")
|
||
|
|
||
|
pdir, name := splitDirAndName(fullPath)
|
||
|
pid, err := o.fs.getIDByDir(ctx, pdir)
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
|
||
|
// create file item at Linkbox (second step)
|
||
|
opts = &rest.Opts{
|
||
|
Method: "GET",
|
||
|
RootURL: linkboxAPIURL,
|
||
|
Path: "folder_upload_file",
|
||
|
Options: options,
|
||
|
Parameters: url.Values{
|
||
|
"token": {o.fs.opt.Token},
|
||
|
"fileMd5ofPre10m": {fmt.Sprintf("%x", md5.Sum(first10mBytes))},
|
||
|
"fileSize": {strconv.FormatInt(src.Size(), 10)},
|
||
|
"pid": {strconv.Itoa(pid)},
|
||
|
"diyName": {name},
|
||
|
},
|
||
|
}
|
||
|
|
||
|
getSecondStepResult := getUploadURLResponse{}
|
||
|
err = getUnmarshaledResponse(ctx, o.fs, opts, &getSecondStepResult)
|
||
|
if err != nil {
|
||
|
return fmt.Errorf("Update err in unmarshaling response: %w", err)
|
||
|
}
|
||
|
if getSecondStepResult.Status != 1 {
|
||
|
return fmt.Errorf("get bad status from linkbox: %s", getSecondStepResult.Message)
|
||
|
}
|
||
|
|
||
|
newObject, err := getObject(ctx, o.fs, name, pid, o.fs.opt.Token)
|
||
|
if err != nil {
|
||
|
return fs.ErrorObjectNotFound
|
||
|
}
|
||
|
if newObject == (entity{}) {
|
||
|
return fs.ErrorObjectNotFound
|
||
|
}
|
||
|
|
||
|
o.pid = pid
|
||
|
o.remote = remote
|
||
|
o.modTime = time.Unix(newObject.Ctime, 0)
|
||
|
o.size = src.Size()
|
||
|
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
// Remove this object
|
||
|
func (o *Object) Remove(ctx context.Context) error {
|
||
|
opts := &rest.Opts{
|
||
|
Method: "GET",
|
||
|
RootURL: linkboxAPIURL,
|
||
|
Path: "file_del",
|
||
|
Parameters: url.Values{
|
||
|
"token": {o.fs.opt.Token},
|
||
|
"itemIds": {o.id},
|
||
|
},
|
||
|
}
|
||
|
|
||
|
requestResult := getUploadURLResponse{}
|
||
|
err := getUnmarshaledResponse(ctx, o.fs, opts, &requestResult)
|
||
|
if err != nil {
|
||
|
return fmt.Errorf("could not Remove: %w", err)
|
||
|
|
||
|
}
|
||
|
|
||
|
if requestResult.Status != 1 {
|
||
|
return fmt.Errorf("got unexpected message from Linkbox: %s", requestResult.Message)
|
||
|
}
|
||
|
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
// ModTime returns the modification time of the remote http file
|
||
|
func (o *Object) ModTime(ctx context.Context) time.Time {
|
||
|
return o.modTime
|
||
|
}
|
||
|
|
||
|
// Remote the name of the remote HTTP file, relative to the fs root
|
||
|
func (o *Object) Remote() string {
|
||
|
return o.remote
|
||
|
}
|
||
|
|
||
|
// Size returns the size in bytes of the remote http file
|
||
|
func (o *Object) Size() int64 {
|
||
|
return o.size
|
||
|
}
|
||
|
|
||
|
// String returns the URL to the remote HTTP file
|
||
|
func (o *Object) String() string {
|
||
|
if o == nil {
|
||
|
return "<nil>"
|
||
|
}
|
||
|
return o.remote
|
||
|
}
|
||
|
|
||
|
// Fs is the filesystem this remote http file object is located within
|
||
|
func (o *Object) Fs() fs.Info {
|
||
|
return o.fs
|
||
|
}
|
||
|
|
||
|
// Hash returns "" since HTTP (in Go or OpenSSH) doesn't support remote calculation of hashes
|
||
|
func (o *Object) Hash(ctx context.Context, r hash.Type) (string, error) {
|
||
|
return "", hash.ErrUnsupported
|
||
|
}
|
||
|
|
||
|
// Storable returns whether the remote http file is a regular file
|
||
|
// (not a directory, symbolic link, block device, character device, named pipe, etc.)
|
||
|
func (o *Object) Storable() bool {
|
||
|
return true
|
||
|
}
|
||
|
|
||
|
// Features returns the optional features of this Fs
|
||
|
// Info provides a read only interface to information about a filesystem.
|
||
|
func (f *Fs) Features() *fs.Features {
|
||
|
return f.features
|
||
|
}
|
||
|
|
||
|
// Name of the remote (as passed into NewFs)
|
||
|
// Name returns the configured name of the file system
|
||
|
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 returns a description of the FS
|
||
|
func (f *Fs) String() string {
|
||
|
return fmt.Sprintf("Linkbox root '%s'", f.root)
|
||
|
}
|
||
|
|
||
|
// Precision of the ModTimes in this Fs
|
||
|
func (f *Fs) Precision() time.Duration {
|
||
|
return fs.ModTimeNotSupported
|
||
|
}
|
||
|
|
||
|
// Hashes returns hash.HashNone to indicate remote hashing is unavailable
|
||
|
// Returns the supported hash types of the filesystem
|
||
|
func (f *Fs) Hashes() hash.Set {
|
||
|
return hash.Set(hash.None)
|
||
|
}
|
||
|
|
||
|
/*
|
||
|
{
|
||
|
"data": {
|
||
|
"signUrl": "http://xx -- Then CURL PUT your file with sign url "
|
||
|
},
|
||
|
"msg": "please use this url to upload (PUT method)",
|
||
|
"status": 1
|
||
|
}
|
||
|
*/
|
||
|
type getResponse struct {
|
||
|
Message string `json:"msg"`
|
||
|
Status int `json:"status"`
|
||
|
}
|
||
|
|
||
|
type getUploadURLData struct {
|
||
|
SignURL string `json:"signUrl"`
|
||
|
}
|
||
|
|
||
|
type getUploadURLResponse struct {
|
||
|
Data getUploadURLData `json:"data"`
|
||
|
getResponse
|
||
|
}
|
||
|
|
||
|
// Put in to the remote path with the modTime given of the given size
|
||
|
//
|
||
|
// When called from outside an Fs by rclone, src.Size() will always be >= 0.
|
||
|
// But for unknown-sized objects (indicated by src.Size() == -1), Put should either
|
||
|
// return an error or upload it properly (rather than e.g. calling panic).
|
||
|
//
|
||
|
// May create the object even if it returns an error - if so
|
||
|
// will return the object and the error, otherwise will return
|
||
|
// nil and the error
|
||
|
func (f *Fs) Put(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) {
|
||
|
o := &Object{
|
||
|
fs: f,
|
||
|
remote: src.Remote(),
|
||
|
size: src.Size(),
|
||
|
}
|
||
|
dir, _ := splitDirAndName(src.Remote())
|
||
|
err := f.Mkdir(ctx, dir)
|
||
|
if err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
err = o.Update(ctx, in, src, options...)
|
||
|
return o, err
|
||
|
}
|
||
|
|
||
|
// Purge all files in the directory specified
|
||
|
//
|
||
|
// Implement this if you have a way of deleting all the files
|
||
|
// quicker than just running Remove() on the result of List()
|
||
|
//
|
||
|
// Return an error if it doesn't exist
|
||
|
func (f *Fs) Purge(ctx context.Context, dir string) error {
|
||
|
return f.purgeCheck(ctx, dir, false)
|
||
|
}
|
||
|
|
||
|
// shouldRetry determines whether a given err rates being retried
|
||
|
func (f *Fs) shouldRetry(ctx context.Context, err error) (bool, error) {
|
||
|
if err == fs.ErrorDirNotFound {
|
||
|
// fs.Debugf(nil, "retry with %v", err)
|
||
|
|
||
|
return true, err
|
||
|
}
|
||
|
|
||
|
if err == fs.ErrorObjectNotFound {
|
||
|
// fs.Debugf(nil, "retry with %v", err)
|
||
|
|
||
|
return true, err
|
||
|
}
|
||
|
return false, err
|
||
|
}
|
||
|
|
||
|
// Check the interfaces are satisfied
|
||
|
var (
|
||
|
_ fs.Fs = &Fs{}
|
||
|
_ fs.Purger = &Fs{}
|
||
|
_ fs.Object = &Object{}
|
||
|
)
|