mirror of
https://github.com/rclone/rclone.git
synced 2024-11-25 18:04:55 +01:00
5d6b8141ec
As of Go 1.16, the same functionality is now provided by package io or package os, and those implementations should be preferred in new code.
1096 lines
33 KiB
Go
1096 lines
33 KiB
Go
package seafile
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"path"
|
|
"strings"
|
|
|
|
"github.com/rclone/rclone/backend/seafile/api"
|
|
"github.com/rclone/rclone/fs"
|
|
"github.com/rclone/rclone/lib/readers"
|
|
"github.com/rclone/rclone/lib/rest"
|
|
)
|
|
|
|
// Start of the API URLs
|
|
const (
|
|
APIv20 = "api2/repos/"
|
|
APIv21 = "api/v2.1/repos/"
|
|
)
|
|
|
|
// Errors specific to seafile fs
|
|
var (
|
|
ErrorInternalDuringUpload = errors.New("internal server error during file upload")
|
|
)
|
|
|
|
// ==================== Seafile API ====================
|
|
|
|
func (f *Fs) getAuthorizationToken(ctx context.Context) (string, error) {
|
|
return getAuthorizationToken(ctx, f.srv, f.opt.User, f.opt.Password, "")
|
|
}
|
|
|
|
// getAuthorizationToken can be called outside of an fs (during configuration of the remote to get the authentication token)
|
|
// it's doing a single call (no pacer involved)
|
|
func getAuthorizationToken(ctx context.Context, srv *rest.Client, user, password, oneTimeCode string) (string, error) {
|
|
// API Documentation
|
|
// https://download.seafile.com/published/web-api/home.md#user-content-Quick%20Start
|
|
opts := rest.Opts{
|
|
Method: "POST",
|
|
Path: "api2/auth-token/",
|
|
ExtraHeaders: map[string]string{"Authorization": ""}, // unset the Authorization for this request
|
|
IgnoreStatus: true, // so we can load the error messages back into result
|
|
}
|
|
|
|
// 2FA
|
|
if oneTimeCode != "" {
|
|
opts.ExtraHeaders["X-SEAFILE-OTP"] = oneTimeCode
|
|
}
|
|
|
|
request := api.AuthenticationRequest{
|
|
Username: user,
|
|
Password: password,
|
|
}
|
|
result := api.AuthenticationResult{}
|
|
|
|
_, err := srv.CallJSON(ctx, &opts, &request, &result)
|
|
if err != nil {
|
|
// This is only going to be http errors here
|
|
return "", fmt.Errorf("failed to authenticate: %w", err)
|
|
}
|
|
if result.Errors != nil && len(result.Errors) > 0 {
|
|
return "", errors.New(strings.Join(result.Errors, ", "))
|
|
}
|
|
if result.Token == "" {
|
|
// No error in "non_field_errors" field but still empty token
|
|
return "", errors.New("failed to authenticate")
|
|
}
|
|
return result.Token, nil
|
|
}
|
|
|
|
func (f *Fs) getServerInfo(ctx context.Context) (account *api.ServerInfo, err error) {
|
|
// API Documentation
|
|
// https://download.seafile.com/published/web-api/v2.1/server-info.md#user-content-Get%20Server%20Information
|
|
opts := rest.Opts{
|
|
Method: "GET",
|
|
Path: "api2/server-info/",
|
|
}
|
|
|
|
result := api.ServerInfo{}
|
|
|
|
var resp *http.Response
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
resp, err = f.srv.CallJSON(ctx, &opts, nil, &result)
|
|
return f.shouldRetry(ctx, resp, err)
|
|
})
|
|
if err != nil {
|
|
if resp != nil {
|
|
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
|
return nil, fs.ErrorPermissionDenied
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("failed to get server info: %w", err)
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
func (f *Fs) getUserAccountInfo(ctx context.Context) (account *api.AccountInfo, err error) {
|
|
// API Documentation
|
|
// https://download.seafile.com/published/web-api/v2.1/account.md#user-content-Check%20Account%20Info
|
|
opts := rest.Opts{
|
|
Method: "GET",
|
|
Path: "api2/account/info/",
|
|
}
|
|
|
|
result := api.AccountInfo{}
|
|
|
|
var resp *http.Response
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
resp, err = f.srv.CallJSON(ctx, &opts, nil, &result)
|
|
return f.shouldRetry(ctx, resp, err)
|
|
})
|
|
if err != nil {
|
|
if resp != nil {
|
|
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
|
return nil, fs.ErrorPermissionDenied
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("failed to get account info: %w", err)
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
func (f *Fs) getLibraries(ctx context.Context) ([]api.Library, error) {
|
|
// API Documentation
|
|
// https://download.seafile.com/published/web-api/v2.1/libraries.md#user-content-List%20Libraries
|
|
opts := rest.Opts{
|
|
Method: "GET",
|
|
Path: APIv20,
|
|
}
|
|
|
|
result := make([]api.Library, 1)
|
|
|
|
var resp *http.Response
|
|
var err error
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
resp, err = f.srv.CallJSON(ctx, &opts, nil, &result)
|
|
return f.shouldRetry(ctx, resp, err)
|
|
})
|
|
if err != nil {
|
|
if resp != nil {
|
|
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
|
return nil, fs.ErrorPermissionDenied
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("failed to get libraries: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (f *Fs) createLibrary(ctx context.Context, libraryName, password string) (library *api.CreateLibrary, err error) {
|
|
// API Documentation
|
|
// https://download.seafile.com/published/web-api/v2.1/libraries.md#user-content-Create%20Library
|
|
opts := rest.Opts{
|
|
Method: "POST",
|
|
Path: APIv20,
|
|
}
|
|
|
|
request := api.CreateLibraryRequest{
|
|
Name: f.opt.Enc.FromStandardName(libraryName),
|
|
Description: "Created by rclone",
|
|
Password: password,
|
|
}
|
|
result := &api.CreateLibrary{}
|
|
|
|
var resp *http.Response
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
resp, err = f.srv.CallJSON(ctx, &opts, &request, &result)
|
|
return f.shouldRetry(ctx, resp, err)
|
|
})
|
|
if err != nil {
|
|
if resp != nil {
|
|
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
|
return nil, fs.ErrorPermissionDenied
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("failed to create library: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (f *Fs) deleteLibrary(ctx context.Context, libraryID string) error {
|
|
// API Documentation
|
|
// https://download.seafile.com/published/web-api/v2.1/libraries.md#user-content-Create%20Library
|
|
opts := rest.Opts{
|
|
Method: "DELETE",
|
|
Path: APIv20 + libraryID + "/",
|
|
}
|
|
|
|
result := ""
|
|
|
|
var resp *http.Response
|
|
var err error
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
resp, err = f.srv.CallJSON(ctx, &opts, nil, &result)
|
|
return f.shouldRetry(ctx, resp, err)
|
|
})
|
|
if err != nil {
|
|
if resp != nil {
|
|
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
|
return fs.ErrorPermissionDenied
|
|
}
|
|
}
|
|
return fmt.Errorf("failed to delete library: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *Fs) decryptLibrary(ctx context.Context, libraryID, password string) error {
|
|
// API Documentation
|
|
// https://download.seafile.com/published/web-api/v2.1/library-encryption.md#user-content-Decrypt%20Library
|
|
if libraryID == "" {
|
|
return errors.New("cannot list files without a library")
|
|
}
|
|
// This is another call that cannot accept a JSON input so we have to build it manually
|
|
opts := rest.Opts{
|
|
Method: "POST",
|
|
Path: APIv20 + libraryID + "/",
|
|
ContentType: "application/x-www-form-urlencoded",
|
|
Body: bytes.NewBuffer([]byte("password=" + f.opt.Enc.FromStandardName(password))),
|
|
NoResponse: true,
|
|
}
|
|
var resp *http.Response
|
|
var err error
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
resp, err = f.srv.Call(ctx, &opts)
|
|
return f.shouldRetry(ctx, resp, err)
|
|
})
|
|
if err != nil {
|
|
if resp != nil {
|
|
if resp.StatusCode == 400 {
|
|
return errors.New("incorrect password")
|
|
}
|
|
if resp.StatusCode == 409 {
|
|
fs.Debugf(nil, "library is not encrypted")
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("failed to decrypt library: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *Fs) getDirectoryEntriesAPIv21(ctx context.Context, libraryID, dirPath string, recursive bool) ([]api.DirEntry, error) {
|
|
// API Documentation
|
|
// https://download.seafile.com/published/web-api/v2.1/directories.md#user-content-List%20Items%20in%20Directory
|
|
// This is using the undocumented version 2.1 of the API (so we can use the recursive option which is not available in the version 2)
|
|
if libraryID == "" {
|
|
return nil, errors.New("cannot list files without a library")
|
|
}
|
|
dirPath = path.Join("/", dirPath)
|
|
|
|
recursiveFlag := "0"
|
|
if recursive {
|
|
recursiveFlag = "1"
|
|
}
|
|
opts := rest.Opts{
|
|
Method: "GET",
|
|
Path: APIv21 + libraryID + "/dir/",
|
|
Parameters: url.Values{
|
|
"recursive": {recursiveFlag},
|
|
"p": {f.opt.Enc.FromStandardPath(dirPath)},
|
|
},
|
|
}
|
|
result := &api.DirEntries{}
|
|
var resp *http.Response
|
|
var err error
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
resp, err = f.srv.CallJSON(ctx, &opts, nil, &result)
|
|
return f.shouldRetry(ctx, resp, err)
|
|
})
|
|
if err != nil {
|
|
if resp != nil {
|
|
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
|
return nil, fs.ErrorPermissionDenied
|
|
}
|
|
if resp.StatusCode == 404 {
|
|
return nil, fs.ErrorDirNotFound
|
|
}
|
|
if resp.StatusCode == 440 {
|
|
// Encrypted library and password not provided
|
|
return nil, fs.ErrorPermissionDenied
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("failed to get directory contents: %w", err)
|
|
}
|
|
|
|
// Clean up encoded names
|
|
for index, fileInfo := range result.Entries {
|
|
fileInfo.Name = f.opt.Enc.ToStandardName(fileInfo.Name)
|
|
fileInfo.Path = f.opt.Enc.ToStandardPath(fileInfo.Path)
|
|
result.Entries[index] = fileInfo
|
|
}
|
|
return result.Entries, nil
|
|
}
|
|
|
|
func (f *Fs) getDirectoryDetails(ctx context.Context, libraryID, dirPath string) (*api.DirectoryDetail, error) {
|
|
// API Documentation
|
|
// https://download.seafile.com/published/web-api/v2.1/directories.md#user-content-Get%20Directory%20Detail
|
|
if libraryID == "" {
|
|
return nil, errors.New("cannot read directory without a library")
|
|
}
|
|
dirPath = path.Join("/", dirPath)
|
|
|
|
opts := rest.Opts{
|
|
Method: "GET",
|
|
Path: APIv21 + libraryID + "/dir/detail/",
|
|
Parameters: url.Values{"path": {f.opt.Enc.FromStandardPath(dirPath)}},
|
|
}
|
|
result := &api.DirectoryDetail{}
|
|
var resp *http.Response
|
|
var err error
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
resp, err = f.srv.CallJSON(ctx, &opts, nil, &result)
|
|
return f.shouldRetry(ctx, resp, err)
|
|
})
|
|
if err != nil {
|
|
if resp != nil {
|
|
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
|
return nil, fs.ErrorPermissionDenied
|
|
}
|
|
if resp.StatusCode == 404 {
|
|
return nil, fs.ErrorDirNotFound
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("failed to get directory details: %w", err)
|
|
}
|
|
result.Name = f.opt.Enc.ToStandardName(result.Name)
|
|
result.Path = f.opt.Enc.ToStandardPath(result.Path)
|
|
return result, nil
|
|
}
|
|
|
|
// createDir creates a new directory. The API will add a number to the directory name if it already exist
|
|
func (f *Fs) createDir(ctx context.Context, libraryID, dirPath string) error {
|
|
// API Documentation
|
|
// https://download.seafile.com/published/web-api/v2.1/directories.md#user-content-Create%20New%20Directory
|
|
if libraryID == "" {
|
|
return errors.New("cannot create directory without a library")
|
|
}
|
|
dirPath = path.Join("/", dirPath)
|
|
|
|
// This call *cannot* handle json parameters in the body, so we have to build the request body manually
|
|
opts := rest.Opts{
|
|
Method: "POST",
|
|
Path: APIv20 + libraryID + "/dir/",
|
|
Parameters: url.Values{"p": {f.opt.Enc.FromStandardPath(dirPath)}},
|
|
NoRedirect: true,
|
|
ContentType: "application/x-www-form-urlencoded",
|
|
Body: bytes.NewBuffer([]byte("operation=mkdir")),
|
|
NoResponse: true,
|
|
}
|
|
|
|
var resp *http.Response
|
|
var err error
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
resp, err = f.srv.Call(ctx, &opts)
|
|
return f.shouldRetry(ctx, resp, err)
|
|
})
|
|
if err != nil {
|
|
if resp != nil {
|
|
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
|
return fs.ErrorPermissionDenied
|
|
}
|
|
}
|
|
return fmt.Errorf("failed to create directory: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *Fs) renameDir(ctx context.Context, libraryID, dirPath, newName string) error {
|
|
// API Documentation
|
|
// https://download.seafile.com/published/web-api/v2.1/directories.md#user-content-Rename%20Directory
|
|
if libraryID == "" {
|
|
return errors.New("cannot rename directory without a library")
|
|
}
|
|
dirPath = path.Join("/", dirPath)
|
|
|
|
// This call *cannot* handle json parameters in the body, so we have to build the request body manually
|
|
postParameters := url.Values{
|
|
"operation": {"rename"},
|
|
"newname": {f.opt.Enc.FromStandardPath(newName)},
|
|
}
|
|
|
|
opts := rest.Opts{
|
|
Method: "POST",
|
|
Path: APIv20 + libraryID + "/dir/",
|
|
Parameters: url.Values{"p": {f.opt.Enc.FromStandardPath(dirPath)}},
|
|
ContentType: "application/x-www-form-urlencoded",
|
|
Body: bytes.NewBuffer([]byte(postParameters.Encode())),
|
|
NoResponse: true,
|
|
}
|
|
|
|
var resp *http.Response
|
|
var err error
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
resp, err = f.srv.Call(ctx, &opts)
|
|
return f.shouldRetry(ctx, resp, err)
|
|
})
|
|
if err != nil {
|
|
if resp != nil {
|
|
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
|
return fs.ErrorPermissionDenied
|
|
}
|
|
}
|
|
return fmt.Errorf("failed to rename directory: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *Fs) moveDir(ctx context.Context, srcLibraryID, srcDir, srcName, dstLibraryID, dstPath string) error {
|
|
// API Documentation
|
|
// https://download.seafile.com/published/web-api/v2.1/files-directories-batch-op.md#user-content-Batch%20Move%20Items%20Synchronously
|
|
if srcLibraryID == "" || dstLibraryID == "" || srcName == "" {
|
|
return errors.New("libraryID and/or file path argument(s) missing")
|
|
}
|
|
srcDir = path.Join("/", srcDir)
|
|
dstPath = path.Join("/", dstPath)
|
|
|
|
opts := rest.Opts{
|
|
Method: "POST",
|
|
Path: APIv21 + "sync-batch-move-item/",
|
|
NoResponse: true,
|
|
}
|
|
|
|
request := &api.BatchSourceDestRequest{
|
|
SrcLibraryID: srcLibraryID,
|
|
SrcParentDir: f.opt.Enc.FromStandardPath(srcDir),
|
|
SrcItems: []string{f.opt.Enc.FromStandardPath(srcName)},
|
|
DstLibraryID: dstLibraryID,
|
|
DstParentDir: f.opt.Enc.FromStandardPath(dstPath),
|
|
}
|
|
|
|
var resp *http.Response
|
|
var err error
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
resp, err = f.srv.CallJSON(ctx, &opts, &request, nil)
|
|
return f.shouldRetry(ctx, resp, err)
|
|
})
|
|
if err != nil {
|
|
if resp != nil {
|
|
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
|
return fs.ErrorPermissionDenied
|
|
}
|
|
if resp.StatusCode == 404 {
|
|
return fs.ErrorObjectNotFound
|
|
}
|
|
}
|
|
return fmt.Errorf("failed to move directory '%s' from '%s' to '%s': %w", srcName, srcDir, dstPath, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (f *Fs) deleteDir(ctx context.Context, libraryID, filePath string) error {
|
|
// API Documentation
|
|
// https://download.seafile.com/published/web-api/v2.1/directories.md#user-content-Delete%20Directory
|
|
if libraryID == "" {
|
|
return errors.New("cannot delete directory without a library")
|
|
}
|
|
filePath = path.Join("/", filePath)
|
|
|
|
opts := rest.Opts{
|
|
Method: "DELETE",
|
|
Path: APIv20 + libraryID + "/dir/",
|
|
Parameters: url.Values{"p": {f.opt.Enc.FromStandardPath(filePath)}},
|
|
NoResponse: true,
|
|
}
|
|
|
|
var resp *http.Response
|
|
var err error
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
resp, err = f.srv.CallJSON(ctx, &opts, nil, nil)
|
|
return f.shouldRetry(ctx, resp, err)
|
|
})
|
|
if err != nil {
|
|
if resp != nil {
|
|
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
|
return fs.ErrorPermissionDenied
|
|
}
|
|
}
|
|
return fmt.Errorf("failed to delete directory: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *Fs) getFileDetails(ctx context.Context, libraryID, filePath string) (*api.FileDetail, error) {
|
|
// API Documentation
|
|
// https://download.seafile.com/published/web-api/v2.1/file.md#user-content-Get%20File%20Detail
|
|
if libraryID == "" {
|
|
return nil, errors.New("cannot open file without a library")
|
|
}
|
|
filePath = path.Join("/", filePath)
|
|
|
|
opts := rest.Opts{
|
|
Method: "GET",
|
|
Path: APIv20 + libraryID + "/file/detail/",
|
|
Parameters: url.Values{"p": {f.opt.Enc.FromStandardPath(filePath)}},
|
|
}
|
|
result := &api.FileDetail{}
|
|
var resp *http.Response
|
|
var err error
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
resp, err = f.srv.CallJSON(ctx, &opts, nil, &result)
|
|
return f.shouldRetry(ctx, resp, err)
|
|
})
|
|
if err != nil {
|
|
if resp != nil {
|
|
if resp.StatusCode == 404 {
|
|
return nil, fs.ErrorObjectNotFound
|
|
}
|
|
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
|
return nil, fs.ErrorPermissionDenied
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("failed to get file details: %w", err)
|
|
}
|
|
result.Name = f.opt.Enc.ToStandardName(result.Name)
|
|
result.Parent = f.opt.Enc.ToStandardPath(result.Parent)
|
|
return result, nil
|
|
}
|
|
|
|
func (f *Fs) deleteFile(ctx context.Context, libraryID, filePath string) error {
|
|
// API Documentation
|
|
// https://download.seafile.com/published/web-api/v2.1/file.md#user-content-Delete%20File
|
|
if libraryID == "" {
|
|
return errors.New("cannot delete file without a library")
|
|
}
|
|
filePath = path.Join("/", filePath)
|
|
|
|
opts := rest.Opts{
|
|
Method: "DELETE",
|
|
Path: APIv20 + libraryID + "/file/",
|
|
Parameters: url.Values{"p": {f.opt.Enc.FromStandardPath(filePath)}},
|
|
NoResponse: true,
|
|
}
|
|
err := f.pacer.Call(func() (bool, error) {
|
|
resp, err := f.srv.CallJSON(ctx, &opts, nil, nil)
|
|
return f.shouldRetry(ctx, resp, err)
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete file: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *Fs) getDownloadLink(ctx context.Context, libraryID, filePath string) (string, error) {
|
|
// API Documentation
|
|
// https://download.seafile.com/published/web-api/v2.1/file.md#user-content-Download%20File
|
|
if libraryID == "" {
|
|
return "", errors.New("cannot download file without a library")
|
|
}
|
|
filePath = path.Join("/", filePath)
|
|
|
|
opts := rest.Opts{
|
|
Method: "GET",
|
|
Path: APIv20 + libraryID + "/file/",
|
|
Parameters: url.Values{"p": {f.opt.Enc.FromStandardPath(filePath)}},
|
|
}
|
|
result := ""
|
|
var resp *http.Response
|
|
var err error
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
resp, err = f.srv.CallJSON(ctx, &opts, nil, &result)
|
|
return f.shouldRetry(ctx, resp, err)
|
|
})
|
|
if err != nil {
|
|
if resp != nil {
|
|
if resp.StatusCode == 404 {
|
|
return "", fs.ErrorObjectNotFound
|
|
}
|
|
}
|
|
return "", fmt.Errorf("failed to get download link: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (f *Fs) download(ctx context.Context, url string, size int64, options ...fs.OpenOption) (io.ReadCloser, error) {
|
|
// Check if we need to download partial content
|
|
var start, end int64 = 0, size
|
|
partialContent := false
|
|
for _, option := range options {
|
|
switch x := option.(type) {
|
|
case *fs.SeekOption:
|
|
start = x.Offset
|
|
partialContent = true
|
|
case *fs.RangeOption:
|
|
if x.Start >= 0 {
|
|
start = x.Start
|
|
if x.End > 0 && x.End < size {
|
|
end = x.End + 1
|
|
}
|
|
} else {
|
|
// {-1, 20} should load the last 20 characters [len-20:len]
|
|
start = size - x.End
|
|
}
|
|
partialContent = true
|
|
default:
|
|
if option.Mandatory() {
|
|
fs.Logf(nil, "Unsupported mandatory option: %v", option)
|
|
}
|
|
}
|
|
}
|
|
// Build the http request
|
|
opts := rest.Opts{
|
|
Method: "GET",
|
|
RootURL: url,
|
|
Options: options,
|
|
}
|
|
var resp *http.Response
|
|
var err error
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
resp, err = f.srv.Call(ctx, &opts)
|
|
return f.shouldRetry(ctx, resp, err)
|
|
})
|
|
if err != nil {
|
|
if resp != nil {
|
|
if resp.StatusCode == 404 {
|
|
return nil, fmt.Errorf("file not found '%s'", url)
|
|
}
|
|
}
|
|
return nil, err
|
|
}
|
|
// Non-encrypted libraries are accepting the HTTP Range header,
|
|
// BUT encrypted libraries are simply ignoring it
|
|
if partialContent && resp.StatusCode == 200 {
|
|
// Partial content was requested through a Range header, but a full content was sent instead
|
|
rangeDownloadNotice.Do(func() {
|
|
fs.Logf(nil, "%s ignored our request of partial content. This is probably because encrypted libraries are not accepting range requests. Loading this file might be slow!", f.String())
|
|
})
|
|
if start > 0 {
|
|
// We need to read and discard the beginning of the data...
|
|
_, err = io.CopyN(io.Discard, resp.Body, start)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
// ... and return a limited reader for the remaining of the data
|
|
return readers.NewLimitedReadCloser(resp.Body, end-start), nil
|
|
}
|
|
return resp.Body, nil
|
|
}
|
|
|
|
func (f *Fs) getUploadLink(ctx context.Context, libraryID string) (string, error) {
|
|
// API Documentation
|
|
// https://download.seafile.com/published/web-api/v2.1/file-upload.md
|
|
if libraryID == "" {
|
|
return "", errors.New("cannot upload file without a library")
|
|
}
|
|
opts := rest.Opts{
|
|
Method: "GET",
|
|
Path: APIv20 + libraryID + "/upload-link/",
|
|
}
|
|
result := ""
|
|
var resp *http.Response
|
|
var err error
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
resp, err = f.srv.CallJSON(ctx, &opts, nil, &result)
|
|
return f.shouldRetry(ctx, resp, err)
|
|
})
|
|
if err != nil {
|
|
if resp != nil {
|
|
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
|
return "", fs.ErrorPermissionDenied
|
|
}
|
|
}
|
|
return "", fmt.Errorf("failed to get upload link: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (f *Fs) upload(ctx context.Context, in io.Reader, uploadLink, filePath string) (*api.FileDetail, error) {
|
|
// API Documentation
|
|
// https://download.seafile.com/published/web-api/v2.1/file-upload.md
|
|
fileDir, filename := path.Split(filePath)
|
|
parameters := url.Values{
|
|
"parent_dir": {"/"},
|
|
"relative_path": {f.opt.Enc.FromStandardPath(fileDir)},
|
|
"need_idx_progress": {"true"},
|
|
"replace": {"1"},
|
|
}
|
|
formReader, contentType, _, err := rest.MultipartUpload(ctx, in, parameters, "file", f.opt.Enc.FromStandardName(filename))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to make multipart upload: %w", err)
|
|
}
|
|
|
|
opts := rest.Opts{
|
|
Method: "POST",
|
|
RootURL: uploadLink,
|
|
Body: formReader,
|
|
ContentType: contentType,
|
|
Parameters: url.Values{"ret-json": {"1"}}, // It needs to be on the url, not in the body parameters
|
|
}
|
|
result := make([]api.FileDetail, 1)
|
|
var resp *http.Response
|
|
// If an error occurs during the call, do not attempt to retry: The upload link is single use only
|
|
err = f.pacer.CallNoRetry(func() (bool, error) {
|
|
resp, err = f.srv.CallJSON(ctx, &opts, nil, &result)
|
|
return f.shouldRetryUpload(ctx, resp, err)
|
|
})
|
|
if err != nil {
|
|
if resp != nil {
|
|
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
|
return nil, fs.ErrorPermissionDenied
|
|
}
|
|
if resp.StatusCode == 500 {
|
|
// This is a temporary error - we will get a new upload link before retrying
|
|
return nil, ErrorInternalDuringUpload
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("failed to upload file: %w", err)
|
|
}
|
|
if len(result) > 0 {
|
|
result[0].Parent = f.opt.Enc.ToStandardPath(result[0].Parent)
|
|
result[0].Name = f.opt.Enc.ToStandardName(result[0].Name)
|
|
return &result[0], nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *Fs) listShareLinks(ctx context.Context, libraryID, remote string) ([]api.SharedLink, error) {
|
|
// API Documentation
|
|
// https://download.seafile.com/published/web-api/v2.1/share-links.md#user-content-List%20Share%20Link%20of%20a%20Folder%20(File)
|
|
if libraryID == "" {
|
|
return nil, errors.New("cannot get share links without a library")
|
|
}
|
|
remote = path.Join("/", remote)
|
|
|
|
opts := rest.Opts{
|
|
Method: "GET",
|
|
Path: "api/v2.1/share-links/",
|
|
Parameters: url.Values{"repo_id": {libraryID}, "path": {f.opt.Enc.FromStandardPath(remote)}},
|
|
}
|
|
result := make([]api.SharedLink, 1)
|
|
var resp *http.Response
|
|
var err error
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
resp, err = f.srv.CallJSON(ctx, &opts, nil, &result)
|
|
return f.shouldRetry(ctx, resp, err)
|
|
})
|
|
if err != nil {
|
|
if resp != nil {
|
|
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
|
return nil, fs.ErrorPermissionDenied
|
|
}
|
|
if resp.StatusCode == 404 {
|
|
return nil, fs.ErrorObjectNotFound
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("failed to list shared links: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// createShareLink will only work with non-encrypted libraries
|
|
func (f *Fs) createShareLink(ctx context.Context, libraryID, remote string) (*api.SharedLink, error) {
|
|
// API Documentation
|
|
// https://download.seafile.com/published/web-api/v2.1/share-links.md#user-content-Create%20Share%20Link
|
|
if libraryID == "" {
|
|
return nil, errors.New("cannot create a shared link without a library")
|
|
}
|
|
remote = path.Join("/", remote)
|
|
|
|
opts := rest.Opts{
|
|
Method: "POST",
|
|
Path: "api/v2.1/share-links/",
|
|
}
|
|
request := &api.ShareLinkRequest{
|
|
LibraryID: libraryID,
|
|
Path: f.opt.Enc.FromStandardPath(remote),
|
|
}
|
|
result := &api.SharedLink{}
|
|
var resp *http.Response
|
|
var err error
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
resp, err = f.srv.CallJSON(ctx, &opts, &request, &result)
|
|
return f.shouldRetry(ctx, resp, err)
|
|
})
|
|
if err != nil {
|
|
if resp != nil {
|
|
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
|
return nil, fs.ErrorPermissionDenied
|
|
}
|
|
if resp.StatusCode == 404 {
|
|
return nil, fs.ErrorObjectNotFound
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("failed to create a shared link: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (f *Fs) copyFile(ctx context.Context, srcLibraryID, srcPath, dstLibraryID, dstPath string) (*api.FileInfo, error) {
|
|
// API Documentation
|
|
// https://download.seafile.com/published/web-api/v2.1/file.md#user-content-Copy%20File
|
|
// It's using the api/v2.1 which is not in the documentation (as of Apr 2020) but works better than api2
|
|
if srcLibraryID == "" || dstLibraryID == "" {
|
|
return nil, errors.New("libraryID and/or file path argument(s) missing")
|
|
}
|
|
srcPath = path.Join("/", srcPath)
|
|
dstPath = path.Join("/", dstPath)
|
|
|
|
opts := rest.Opts{
|
|
Method: "POST",
|
|
Path: APIv21 + srcLibraryID + "/file/",
|
|
Parameters: url.Values{"p": {f.opt.Enc.FromStandardPath(srcPath)}},
|
|
}
|
|
request := &api.FileOperationRequest{
|
|
Operation: api.CopyFileOperation,
|
|
DestinationLibraryID: dstLibraryID,
|
|
DestinationPath: f.opt.Enc.FromStandardPath(dstPath),
|
|
}
|
|
result := &api.FileInfo{}
|
|
var resp *http.Response
|
|
var err error
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
resp, err = f.srv.CallJSON(ctx, &opts, &request, &result)
|
|
return f.shouldRetry(ctx, resp, err)
|
|
})
|
|
if err != nil {
|
|
if resp != nil {
|
|
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
|
return nil, fs.ErrorPermissionDenied
|
|
}
|
|
if resp.StatusCode == 404 {
|
|
fs.Debugf(nil, "Copy: %s", err)
|
|
return nil, fs.ErrorObjectNotFound
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("failed to copy file %s:'%s' to %s:'%s': %w", srcLibraryID, srcPath, dstLibraryID, dstPath, err)
|
|
}
|
|
return f.decodeFileInfo(result), nil
|
|
}
|
|
|
|
func (f *Fs) moveFile(ctx context.Context, srcLibraryID, srcPath, dstLibraryID, dstPath string) (*api.FileInfo, error) {
|
|
// API Documentation
|
|
// https://download.seafile.com/published/web-api/v2.1/file.md#user-content-Move%20File
|
|
// It's using the api/v2.1 which is not in the documentation (as of Apr 2020) but works better than api2
|
|
if srcLibraryID == "" || dstLibraryID == "" {
|
|
return nil, errors.New("libraryID and/or file path argument(s) missing")
|
|
}
|
|
srcPath = path.Join("/", srcPath)
|
|
dstPath = path.Join("/", dstPath)
|
|
|
|
opts := rest.Opts{
|
|
Method: "POST",
|
|
Path: APIv21 + srcLibraryID + "/file/",
|
|
Parameters: url.Values{"p": {f.opt.Enc.FromStandardPath(srcPath)}},
|
|
}
|
|
request := &api.FileOperationRequest{
|
|
Operation: api.MoveFileOperation,
|
|
DestinationLibraryID: dstLibraryID,
|
|
DestinationPath: f.opt.Enc.FromStandardPath(dstPath),
|
|
}
|
|
result := &api.FileInfo{}
|
|
var resp *http.Response
|
|
var err error
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
resp, err = f.srv.CallJSON(ctx, &opts, &request, &result)
|
|
return f.shouldRetry(ctx, resp, err)
|
|
})
|
|
if err != nil {
|
|
if resp != nil {
|
|
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
|
return nil, fs.ErrorPermissionDenied
|
|
}
|
|
if resp.StatusCode == 404 {
|
|
fs.Debugf(nil, "Move: %s", err)
|
|
return nil, fs.ErrorObjectNotFound
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("failed to move file %s:'%s' to %s:'%s': %w", srcLibraryID, srcPath, dstLibraryID, dstPath, err)
|
|
}
|
|
return f.decodeFileInfo(result), nil
|
|
}
|
|
|
|
func (f *Fs) renameFile(ctx context.Context, libraryID, filePath, newname string) (*api.FileInfo, error) {
|
|
// API Documentation
|
|
// https://download.seafile.com/published/web-api/v2.1/file.md#user-content-Rename%20File
|
|
// It's using the api/v2.1 which is not in the documentation (as of Apr 2020) but works better than api2
|
|
if libraryID == "" || newname == "" {
|
|
return nil, errors.New("libraryID and/or file path argument(s) missing")
|
|
}
|
|
filePath = path.Join("/", filePath)
|
|
|
|
opts := rest.Opts{
|
|
Method: "POST",
|
|
Path: APIv21 + libraryID + "/file/",
|
|
Parameters: url.Values{"p": {f.opt.Enc.FromStandardPath(filePath)}},
|
|
}
|
|
request := &api.FileOperationRequest{
|
|
Operation: api.RenameFileOperation,
|
|
NewName: f.opt.Enc.FromStandardName(newname),
|
|
}
|
|
result := &api.FileInfo{}
|
|
var resp *http.Response
|
|
var err error
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
resp, err = f.srv.CallJSON(ctx, &opts, &request, &result)
|
|
return f.shouldRetry(ctx, resp, err)
|
|
})
|
|
if err != nil {
|
|
if resp != nil {
|
|
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
|
return nil, fs.ErrorPermissionDenied
|
|
}
|
|
if resp.StatusCode == 404 {
|
|
fs.Debugf(nil, "Rename: %s", err)
|
|
return nil, fs.ErrorObjectNotFound
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("failed to rename file '%s' to '%s': %w", filePath, newname, err)
|
|
}
|
|
return f.decodeFileInfo(result), nil
|
|
}
|
|
|
|
func (f *Fs) decodeFileInfo(input *api.FileInfo) *api.FileInfo {
|
|
input.Name = f.opt.Enc.ToStandardName(input.Name)
|
|
input.Path = f.opt.Enc.ToStandardPath(input.Path)
|
|
return input
|
|
}
|
|
|
|
func (f *Fs) emptyLibraryTrash(ctx context.Context, libraryID string) error {
|
|
// API Documentation
|
|
// https://download.seafile.com/published/web-api/v2.1/libraries.md#user-content-Clean%20Library%20Trash
|
|
if libraryID == "" {
|
|
return errors.New("cannot clean up trash without a library")
|
|
}
|
|
opts := rest.Opts{
|
|
Method: "DELETE",
|
|
Path: APIv21 + libraryID + "/trash/",
|
|
NoResponse: true,
|
|
}
|
|
var resp *http.Response
|
|
var err error
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
resp, err = f.srv.CallJSON(ctx, &opts, nil, nil)
|
|
return f.shouldRetry(ctx, resp, err)
|
|
})
|
|
if err != nil {
|
|
if resp != nil {
|
|
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
|
return fs.ErrorPermissionDenied
|
|
}
|
|
if resp.StatusCode == 404 {
|
|
return fs.ErrorObjectNotFound
|
|
}
|
|
}
|
|
return fmt.Errorf("failed empty the library trash: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// === API v2 from the official documentation, but that have been replaced by the much better v2.1 (undocumented as of Apr 2020)
|
|
// === getDirectoryEntriesAPIv2 is needed to keep compatibility with seafile v6,
|
|
// === the others can probably be removed after the API v2.1 is documented
|
|
|
|
func (f *Fs) getDirectoryEntriesAPIv2(ctx context.Context, libraryID, dirPath string) ([]api.DirEntry, error) {
|
|
// API Documentation
|
|
// https://download.seafile.com/published/web-api/v2.1/directories.md#user-content-List%20Items%20in%20Directory
|
|
if libraryID == "" {
|
|
return nil, errors.New("cannot list files without a library")
|
|
}
|
|
dirPath = path.Join("/", dirPath)
|
|
|
|
opts := rest.Opts{
|
|
Method: "GET",
|
|
Path: APIv20 + libraryID + "/dir/",
|
|
Parameters: url.Values{"p": {f.opt.Enc.FromStandardPath(dirPath)}},
|
|
}
|
|
result := make([]api.DirEntry, 1)
|
|
var resp *http.Response
|
|
var err error
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
resp, err = f.srv.CallJSON(ctx, &opts, nil, &result)
|
|
return f.shouldRetry(ctx, resp, err)
|
|
})
|
|
if err != nil {
|
|
if resp != nil {
|
|
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
|
return nil, fs.ErrorPermissionDenied
|
|
}
|
|
if resp.StatusCode == 404 {
|
|
return nil, fs.ErrorDirNotFound
|
|
}
|
|
if resp.StatusCode == 440 {
|
|
// Encrypted library and password not provided
|
|
return nil, fs.ErrorPermissionDenied
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("failed to get directory contents: %w", err)
|
|
}
|
|
|
|
// Clean up encoded names
|
|
for index, fileInfo := range result {
|
|
fileInfo.Name = f.opt.Enc.ToStandardName(fileInfo.Name)
|
|
fileInfo.Path = f.opt.Enc.ToStandardPath(fileInfo.Path)
|
|
result[index] = fileInfo
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (f *Fs) copyFileAPIv2(ctx context.Context, srcLibraryID, srcPath, dstLibraryID, dstPath string) (*api.FileInfo, error) {
|
|
// API Documentation
|
|
// https://download.seafile.com/published/web-api/v2.1/file.md#user-content-Copy%20File
|
|
if srcLibraryID == "" || dstLibraryID == "" {
|
|
return nil, errors.New("libraryID and/or file path argument(s) missing")
|
|
}
|
|
srcPath = path.Join("/", srcPath)
|
|
dstPath = path.Join("/", dstPath)
|
|
|
|
// Older API does not seem to accept JSON input here either
|
|
postParameters := url.Values{
|
|
"operation": {"copy"},
|
|
"dst_repo": {dstLibraryID},
|
|
"dst_dir": {f.opt.Enc.FromStandardPath(dstPath)},
|
|
}
|
|
opts := rest.Opts{
|
|
Method: "POST",
|
|
Path: APIv20 + srcLibraryID + "/file/",
|
|
Parameters: url.Values{"p": {f.opt.Enc.FromStandardPath(srcPath)}},
|
|
ContentType: "application/x-www-form-urlencoded",
|
|
Body: bytes.NewBuffer([]byte(postParameters.Encode())),
|
|
}
|
|
result := &api.FileInfo{}
|
|
var resp *http.Response
|
|
var err error
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
resp, err = f.srv.Call(ctx, &opts)
|
|
return f.shouldRetry(ctx, resp, err)
|
|
})
|
|
if err != nil {
|
|
if resp != nil {
|
|
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
|
return nil, fs.ErrorPermissionDenied
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("failed to copy file %s:'%s' to %s:'%s': %w", srcLibraryID, srcPath, dstLibraryID, dstPath, err)
|
|
}
|
|
err = rest.DecodeJSON(resp, &result)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return f.decodeFileInfo(result), nil
|
|
}
|
|
|
|
func (f *Fs) renameFileAPIv2(ctx context.Context, libraryID, filePath, newname string) error {
|
|
// API Documentation
|
|
// https://download.seafile.com/published/web-api/v2.1/file.md#user-content-Rename%20File
|
|
if libraryID == "" || newname == "" {
|
|
return errors.New("libraryID and/or file path argument(s) missing")
|
|
}
|
|
filePath = path.Join("/", filePath)
|
|
|
|
// No luck with JSON input with the older api2
|
|
postParameters := url.Values{
|
|
"operation": {"rename"},
|
|
"reloaddir": {"true"}, // This is an undocumented trick to avoid an http code 301 response (found in https://github.com/haiwen/seahub/blob/master/seahub/api2/views.py)
|
|
"newname": {f.opt.Enc.FromStandardName(newname)},
|
|
}
|
|
|
|
opts := rest.Opts{
|
|
Method: "POST",
|
|
Path: APIv20 + libraryID + "/file/",
|
|
Parameters: url.Values{"p": {f.opt.Enc.FromStandardPath(filePath)}},
|
|
ContentType: "application/x-www-form-urlencoded",
|
|
Body: bytes.NewBuffer([]byte(postParameters.Encode())),
|
|
NoRedirect: true,
|
|
NoResponse: true,
|
|
}
|
|
var resp *http.Response
|
|
var err error
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
resp, err = f.srv.Call(ctx, &opts)
|
|
return f.shouldRetry(ctx, resp, err)
|
|
})
|
|
if err != nil {
|
|
if resp != nil {
|
|
if resp.StatusCode == 301 {
|
|
// This is the normal response from the server
|
|
return nil
|
|
}
|
|
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
|
return fs.ErrorPermissionDenied
|
|
}
|
|
if resp.StatusCode == 404 {
|
|
return fs.ErrorObjectNotFound
|
|
}
|
|
}
|
|
return fmt.Errorf("failed to rename file: %w", err)
|
|
}
|
|
return nil
|
|
}
|