mirror of
https://github.com/rclone/rclone.git
synced 2025-04-16 16:18:52 +08:00
Merge 0b6dd1bc70e8c9a12a06474ffb54683f87eab7cb into 0b9671313b14ffe839ecbd7dd2ae5ac7f6f05db8
This commit is contained in:
commit
c3e53ca36b
@ -37,6 +37,7 @@ import (
|
||||
_ "github.com/rclone/rclone/backend/mailru"
|
||||
_ "github.com/rclone/rclone/backend/mega"
|
||||
_ "github.com/rclone/rclone/backend/memory"
|
||||
_ "github.com/rclone/rclone/backend/namecrane"
|
||||
_ "github.com/rclone/rclone/backend/netstorage"
|
||||
_ "github.com/rclone/rclone/backend/onedrive"
|
||||
_ "github.com/rclone/rclone/backend/opendrive"
|
||||
|
235
backend/namecrane/auth.go
Normal file
235
backend/namecrane/auth.go
Normal file
@ -0,0 +1,235 @@
|
||||
package namecrane
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/rclone/rclone/fs"
|
||||
"github.com/rclone/rclone/fs/config/configmap"
|
||||
"github.com/rclone/rclone/fs/config/obscure"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNoToken = errors.New("could not find access token")
|
||||
ErrExpiredRefreshToken = errors.New("refresh token expired")
|
||||
)
|
||||
|
||||
const (
|
||||
accessTokenKey = "access_token"
|
||||
accessTokenExpireKey = "access_token_expires"
|
||||
refreshTokenKey = "refresh_token"
|
||||
refreshTokenExpireKey = "refresh_token_expires"
|
||||
)
|
||||
|
||||
// AuthManager manages the authentication token.
|
||||
type AuthManager struct {
|
||||
mu sync.Mutex
|
||||
cm configmap.Mapper
|
||||
expiresAt time.Time
|
||||
client *http.Client
|
||||
apiURL string
|
||||
lastResponse *authResponse
|
||||
}
|
||||
|
||||
// NewAuthManager initializes the AuthManager.
|
||||
func NewAuthManager(client *http.Client, cm configmap.Mapper, apiURL string) *AuthManager {
|
||||
return &AuthManager{
|
||||
client: client,
|
||||
cm: cm,
|
||||
apiURL: apiURL,
|
||||
}
|
||||
}
|
||||
|
||||
type authRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
TwoFactorCode string `json:"twoFactorCode"`
|
||||
}
|
||||
|
||||
type authResponse struct {
|
||||
Username string `json:"username"`
|
||||
Token string `json:"accessToken"`
|
||||
TokenExpiration time.Time `json:"accessTokenExpiration"` // Token expiration datetime
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
RefreshTokenExpiration time.Time `json:"refreshTokenExpiration"`
|
||||
}
|
||||
|
||||
func (am *AuthManager) fillFromConfigMapper() error {
|
||||
fs.Debugf(am, "Filling last response value from config mapper")
|
||||
|
||||
var response authResponse
|
||||
var ok bool
|
||||
var err error
|
||||
|
||||
if response.Token, ok = am.cm.Get(accessTokenKey); !ok {
|
||||
fs.Debugf(am, "Token not found in config mapper")
|
||||
return nil
|
||||
} else {
|
||||
response.Token, err = obscure.Reveal(response.Token)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if tokenExpiration, ok := am.cm.Get(accessTokenExpireKey); ok {
|
||||
response.TokenExpiration, err = time.Parse(time.RFC3339, tokenExpiration)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
fs.Debugf(am, "Token expiration not found in config mapper")
|
||||
return nil
|
||||
}
|
||||
|
||||
if response.RefreshToken, ok = am.cm.Get(refreshTokenKey); !ok {
|
||||
fs.Debugf(am, "Refresh token not found in config mapper")
|
||||
return nil
|
||||
} else {
|
||||
response.RefreshToken, err = obscure.Reveal(response.RefreshToken)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if refreshTokenExpiration, ok := am.cm.Get(refreshTokenExpireKey); ok {
|
||||
var err error
|
||||
response.RefreshTokenExpiration, err = time.Parse(time.RFC3339, refreshTokenExpiration)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
fs.Debugf(am, "Refresh token expiration not found in config mapper")
|
||||
return nil
|
||||
}
|
||||
|
||||
fs.Debugf(am, "All information found and filled")
|
||||
|
||||
am.lastResponse = &response
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Authenticate obtains a new token.
|
||||
func (am *AuthManager) Authenticate(ctx context.Context, username, password, twoFactorCode string) error {
|
||||
fs.Debugf(am, "Trying to authenticate user")
|
||||
|
||||
am.mu.Lock()
|
||||
defer am.mu.Unlock()
|
||||
|
||||
// Construct the API URL for authentication
|
||||
url := fmt.Sprintf("%s/api/v1/auth/authenticate-user", am.apiURL)
|
||||
|
||||
res, err := doHttpRequest(ctx, am.client, http.MethodPost, url, authRequest{
|
||||
Username: username,
|
||||
Password: password,
|
||||
TwoFactorCode: twoFactorCode,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected status code %d", res.StatusCode)
|
||||
}
|
||||
|
||||
// Parse the response
|
||||
var response authResponse
|
||||
|
||||
if err := res.Decode(&response); err != nil {
|
||||
return fmt.Errorf("failed to decode authenteication response: %w", err)
|
||||
}
|
||||
|
||||
// Store the token and expiration time
|
||||
am.lastResponse = &response
|
||||
|
||||
am.updateConfigMapper(response)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type refreshRequest struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
func (am *AuthManager) RefreshToken(ctx context.Context) error {
|
||||
fs.Debugf(am, "Trying to refresh token")
|
||||
|
||||
am.mu.Lock()
|
||||
defer am.mu.Unlock()
|
||||
|
||||
url := fmt.Sprintf("%s/api/v1/auth/refresh-token", am.apiURL)
|
||||
|
||||
res, err := doHttpRequest(ctx, am.client, http.MethodPost, url, refreshRequest{
|
||||
Token: am.lastResponse.RefreshToken,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected status code %d", res.StatusCode)
|
||||
}
|
||||
|
||||
var response authResponse
|
||||
|
||||
if err := res.Decode(&response); err != nil {
|
||||
return fmt.Errorf("failed to decode refresh response: %w", err)
|
||||
}
|
||||
|
||||
am.lastResponse = &response
|
||||
|
||||
am.updateConfigMapper(response)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateConfigMapper stores data into the config map
|
||||
func (am *AuthManager) updateConfigMapper(response authResponse) {
|
||||
fs.Debugf(am, "Updating config mapper values")
|
||||
|
||||
am.cm.Set(accessTokenKey, obscure.MustObscure(response.Token))
|
||||
am.cm.Set(accessTokenExpireKey, response.TokenExpiration.Format(time.RFC3339))
|
||||
am.cm.Set(refreshTokenKey, obscure.MustObscure(response.RefreshToken))
|
||||
am.cm.Set(refreshTokenExpireKey, response.RefreshTokenExpiration.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// GetToken ensures the token is valid and returns it.
|
||||
func (am *AuthManager) GetToken(ctx context.Context) (string, error) {
|
||||
if am.lastResponse == nil || am.lastResponse.Token == "" {
|
||||
fs.Debugf(am, "No token set in AuthManager")
|
||||
return "", ErrNoToken
|
||||
}
|
||||
|
||||
// Handle if we can't use our refresh token
|
||||
if am.lastResponse.RefreshTokenExpiration.Before(time.Now()) {
|
||||
fs.Debugf(am, "Refresh token expired")
|
||||
return "", ErrExpiredRefreshToken
|
||||
}
|
||||
|
||||
// Give us a 5 minute grace period to prevent race conditions/issues
|
||||
if am.lastResponse.TokenExpiration.Before(time.Now().Add(5 * time.Minute)) {
|
||||
fs.Debugf(am, "Access token expires soon, need to refresh")
|
||||
|
||||
// Refresh token
|
||||
if err := am.RefreshToken(ctx); err != nil {
|
||||
return "", fmt.Errorf("failed to refresh token: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
fs.Debug(am, "Using existing token")
|
||||
|
||||
return am.lastResponse.Token, nil
|
||||
}
|
||||
|
||||
func (am *AuthManager) String() string {
|
||||
return "Namecrane Auth Manager"
|
||||
}
|
856
backend/namecrane/client.go
Normal file
856
backend/namecrane/client.go
Normal file
@ -0,0 +1,856 @@
|
||||
package namecrane
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rclone/rclone/fs"
|
||||
"io"
|
||||
"math"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnknownType = errors.New("unknown content type")
|
||||
ErrUnexpectedStatus = errors.New("unexpected status")
|
||||
ErrNoFolder = errors.New("no folder found")
|
||||
ErrNoFile = errors.New("no file found")
|
||||
)
|
||||
|
||||
const (
|
||||
defaultFileType = "application/octet-stream"
|
||||
contextFileStorage = "file-storage"
|
||||
maxChunkSize = 15 * 1024 * 1024 // 15 MB
|
||||
|
||||
apiUpload = "api/upload"
|
||||
apiFiles = "api/v1/filestorage/files"
|
||||
apiMoveFiles = "api/v1/filestorage/move-files"
|
||||
apiEditFile = "api/v1/filestorage/{fileId}/edit"
|
||||
apiGetFileLink = "api/v1/filestorage/{fileId}/getlink"
|
||||
apiFolder = "api/v1/filestorage/folder"
|
||||
apiFolders = "api/v1/filestorage/folders"
|
||||
apiPutFolder = "api/v1/filestorage/folder-put"
|
||||
apiDeleteFolder = "api/v1/filestorage/delete-folder"
|
||||
apiPatchFolder = "api/v1/filestorage/folder-patch"
|
||||
apiFileDownload = "api/v1/filestorage/%s/download"
|
||||
)
|
||||
|
||||
// Namecrane is the Namecrane API Client implementation
|
||||
type Namecrane struct {
|
||||
apiURL string
|
||||
authManager *AuthManager
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a new Namecrane Client with the specified URL and auth manager
|
||||
func NewClient(apiURL string, authManager *AuthManager) *Namecrane {
|
||||
return &Namecrane{
|
||||
apiURL: apiURL,
|
||||
authManager: authManager,
|
||||
client: http.DefaultClient,
|
||||
}
|
||||
}
|
||||
|
||||
// defaultResponse represents a default API response, containing Success and optionally Message
|
||||
type defaultResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Response wraps an *http.Response and provides extra functionality
|
||||
type Response struct {
|
||||
*http.Response
|
||||
}
|
||||
|
||||
// Data is a quick and dirty "read this data" for debugging
|
||||
func (r *Response) Data() []byte {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
// Decode only supports JSON.
|
||||
// The API is weird and returns text/plain for JSON sometimes, but it's almost always JSON.
|
||||
func (r *Response) Decode(data any) error {
|
||||
// Close by default on decode
|
||||
defer r.Close()
|
||||
|
||||
return json.NewDecoder(r.Body).Decode(data)
|
||||
}
|
||||
|
||||
// Close is a redirect to r.Body.Close for shorthand
|
||||
func (r *Response) Close() error {
|
||||
return r.Body.Close()
|
||||
}
|
||||
|
||||
// File represents a file object on the remote server, identified by `ID`
|
||||
type File struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"fileName"`
|
||||
Type string `json:"type"`
|
||||
Size int64 `json:"size"`
|
||||
DateAdded time.Time `json:"dateAdded"`
|
||||
FolderPath string `json:"folderPath"`
|
||||
}
|
||||
|
||||
// Folder represents a folder object on the remote server
|
||||
type Folder struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
Version string `json:"version"`
|
||||
Count int `json:"count"`
|
||||
Subfolders []Folder `json:"subfolders"`
|
||||
Files []File `json:"files"`
|
||||
}
|
||||
|
||||
// Flatten takes all folders and subfolders, returning them as a single slice
|
||||
func (f Folder) Flatten() []Folder {
|
||||
folders := []Folder{f}
|
||||
|
||||
for _, folder := range f.Subfolders {
|
||||
folders = append(folders, folder.Flatten()...)
|
||||
}
|
||||
|
||||
return folders
|
||||
}
|
||||
|
||||
func (n *Namecrane) String() string {
|
||||
return "Namecrane API (Endpoint: " + n.apiURL + ")"
|
||||
}
|
||||
|
||||
// uploadChunk uploads a chunk, then waits for it to be accepted.
|
||||
// When the last chunk is uploaded, the backend will combine the file, then return a 200 with a body.
|
||||
func (n *Namecrane) uploadChunk(ctx context.Context, reader io.Reader, fileName string, fileSize, chunkSize int64, fields map[string]string) (*Response, error) {
|
||||
// Send POST request to upload
|
||||
var requestBody bytes.Buffer
|
||||
writer := multipart.NewWriter(&requestBody)
|
||||
|
||||
for key, value := range fields {
|
||||
if err := writer.WriteField(key, value); err != nil {
|
||||
return nil, fmt.Errorf("failed to write field %s: %w", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add the file content for this chunk
|
||||
part, err := writer.CreateFormFile("file", fileName)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create form file: %w", err)
|
||||
}
|
||||
|
||||
if _, err = io.CopyN(part, reader, chunkSize); err != nil && err != io.EOF {
|
||||
return nil, fmt.Errorf("failed to copy chunk data: %w", err)
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// --- Send the chunk ---
|
||||
|
||||
resp, err := n.doRequest(ctx, http.MethodPost, apiUpload,
|
||||
requestBody.Bytes(),
|
||||
WithContentType(writer.FormDataContentType()))
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to upload file: %w", err)
|
||||
}
|
||||
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// Upload will push a file to the Namecrane API
|
||||
func (n *Namecrane) Upload(ctx context.Context, in io.Reader, filePath string, fileSize int64) (*File, error) {
|
||||
fileName := path.Base(filePath)
|
||||
|
||||
// encode brackets, fixing bug within uploader
|
||||
// fileName = url.PathEscape(fileName)
|
||||
|
||||
basePath := path.Dir(filePath)
|
||||
|
||||
if basePath == "" || basePath[0] != '/' {
|
||||
basePath = "/" + basePath
|
||||
}
|
||||
|
||||
// Prepare context data
|
||||
contextBytes, err := json.Marshal(folderRequest{
|
||||
Folder: basePath,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
contextData := string(contextBytes)
|
||||
|
||||
// Calculate total chunks
|
||||
totalChunks := int(math.Ceil(float64(fileSize) / maxChunkSize))
|
||||
|
||||
remaining := fileSize
|
||||
|
||||
id, err := uuid.NewV7()
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fields := map[string]string{
|
||||
"resumableChunkSize": strconv.FormatInt(maxChunkSize, 10),
|
||||
"resumableTotalSize": strconv.FormatInt(fileSize, 10),
|
||||
"resumableIdentifier": id.String(),
|
||||
"resumableType": defaultFileType,
|
||||
"resumableFilename": fileName,
|
||||
"resumableRelativePath": fileName,
|
||||
"resumableTotalChunks": strconv.Itoa(totalChunks),
|
||||
"context": contextFileStorage,
|
||||
"contextData": contextData,
|
||||
}
|
||||
|
||||
var res *Response
|
||||
|
||||
for chunk := 1; chunk <= totalChunks; chunk++ {
|
||||
chunkSize := int64(maxChunkSize)
|
||||
|
||||
if remaining < maxChunkSize {
|
||||
chunkSize = remaining
|
||||
}
|
||||
|
||||
// strconv.FormatInt is pretty much fmt.Sprintf but without needing to parse the format, replace things, etc.
|
||||
// base 10 is the default, see strconv.Itoa
|
||||
fields["resumableChunkNumber"] = strconv.Itoa(chunk)
|
||||
fields["resumableCurrentChunkSize"] = strconv.FormatInt(chunkSize, 10)
|
||||
|
||||
// --- Prepare the chunk payload ---
|
||||
res, err = n.uploadChunk(ctx, in, fileName, fileSize, chunkSize, fields)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("chunk upload failed, error: %w", err)
|
||||
}
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
var status defaultResponse
|
||||
|
||||
if err := res.Decode(&status); err != nil {
|
||||
return nil, fmt.Errorf("chunk %d upload failed, status: %d, response: %s", chunk, res.StatusCode, string(res.Data()))
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("chunk %d upload failed, status: %d, message: %s", chunk, res.StatusCode, status.Message)
|
||||
}
|
||||
|
||||
fs.Debugf(n, "Successfully uploaded chunk %d of %d of size %d/%d for file '%s'\n", chunk, totalChunks, chunkSize, remaining, fileName)
|
||||
|
||||
if chunk == totalChunks {
|
||||
var file File
|
||||
|
||||
if err := res.Decode(&file); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &file, nil
|
||||
} else {
|
||||
_ = res.Close()
|
||||
}
|
||||
|
||||
// Update progress
|
||||
remaining -= chunkSize
|
||||
}
|
||||
|
||||
fs.Errorf(n, "Received no response from last upload chunk")
|
||||
|
||||
return nil, errors.New("no response from endpoint")
|
||||
}
|
||||
|
||||
type ListResponse struct {
|
||||
Files []File `json:"files"`
|
||||
}
|
||||
|
||||
type FolderResponse struct {
|
||||
defaultResponse
|
||||
Folder Folder `json:"folder"`
|
||||
}
|
||||
|
||||
// GetFolders returns all folders at the root level
|
||||
func (n *Namecrane) GetFolders(ctx context.Context) ([]Folder, error) {
|
||||
res, err := n.doRequest(ctx, http.MethodGet, apiFolders, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%w: %d", ErrUnexpectedStatus, res.StatusCode)
|
||||
}
|
||||
|
||||
var response FolderResponse
|
||||
|
||||
if err := res.Decode(&response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Root folder is response.Folder
|
||||
return response.Folder.Flatten(), nil
|
||||
}
|
||||
|
||||
// GetFolder returns a single folder
|
||||
func (n *Namecrane) GetFolder(ctx context.Context, folder string) (*Folder, error) {
|
||||
res, err := n.doRequest(ctx, http.MethodPost, apiFolder, folderRequest{
|
||||
Folder: folder,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%w: %d", ErrUnexpectedStatus, res.StatusCode)
|
||||
}
|
||||
|
||||
var folderResponse FolderResponse
|
||||
|
||||
if err := res.Decode(&folderResponse); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !folderResponse.Success {
|
||||
if folderResponse.Message == "Folder not found" {
|
||||
return nil, ErrNoFolder
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("received error from API: %s", folderResponse.Message)
|
||||
}
|
||||
|
||||
return &folderResponse.Folder, nil
|
||||
}
|
||||
|
||||
// filesRequest is a struct containing the appropriate fields for making a `GetFiles` request
|
||||
type filesRequest struct {
|
||||
FileIDs []string `json:"fileIds"`
|
||||
}
|
||||
|
||||
// GetFiles returns file data of the specified files
|
||||
func (n *Namecrane) GetFiles(ctx context.Context, ids ...string) ([]File, error) {
|
||||
res, err := n.doRequest(ctx, http.MethodPost, apiFiles, filesRequest{
|
||||
FileIDs: ids,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%w: %d", ErrUnexpectedStatus, res.StatusCode)
|
||||
}
|
||||
|
||||
var response ListResponse
|
||||
|
||||
if err := res.Decode(&response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response.Files, nil
|
||||
}
|
||||
|
||||
// DeleteFiles deletes the remote files specified by ids
|
||||
func (n *Namecrane) DeleteFiles(ctx context.Context, ids ...string) error {
|
||||
res, err := n.doRequest(ctx, http.MethodPost, apiFiles, filesRequest{
|
||||
FileIDs: ids,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%w: %d", ErrUnexpectedStatus, res.StatusCode)
|
||||
}
|
||||
|
||||
var response defaultResponse
|
||||
|
||||
if err := res.Decode(&response); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadFile opens the specified file as an io.ReadCloser, with optional `opts` (range header, etc)
|
||||
func (n *Namecrane) DownloadFile(ctx context.Context, id string, opts ...RequestOpt) (io.ReadCloser, error) {
|
||||
res, err := n.doRequest(ctx, http.MethodGet, fmt.Sprintf(apiFileDownload, id), nil, opts...)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%w: %d", ErrUnexpectedStatus, res.StatusCode)
|
||||
}
|
||||
|
||||
return res.Body, nil
|
||||
}
|
||||
|
||||
// GetFileID gets a file id from a specified directory and file name
|
||||
func (n *Namecrane) GetFileID(ctx context.Context, dir, fileName string) (string, error) {
|
||||
var folder *Folder
|
||||
|
||||
if dir == "" || dir == "/" {
|
||||
folders, err := n.GetFolders(ctx)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
folder = &folders[0]
|
||||
} else {
|
||||
var err error
|
||||
|
||||
folder, err = n.GetFolder(ctx, dir)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
for _, file := range folder.Files {
|
||||
if file.Name == fileName {
|
||||
return file.ID, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", ErrNoFile
|
||||
}
|
||||
|
||||
// Find uses similar methods to GetFileID, but instead checks for both files AND folders
|
||||
func (n *Namecrane) Find(ctx context.Context, file string) (*Folder, *File, error) {
|
||||
base, name := n.parsePath(file)
|
||||
|
||||
var folder *Folder
|
||||
|
||||
if base == "" || base == "/" {
|
||||
folders, err := n.GetFolders(ctx)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
folder = &folders[0]
|
||||
|
||||
if name == "" {
|
||||
return folder, nil, nil
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
|
||||
folder, err = n.GetFolder(ctx, base)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
for _, file := range folder.Files {
|
||||
if file.Name == name {
|
||||
return nil, &file, nil
|
||||
}
|
||||
}
|
||||
|
||||
for _, folder := range folder.Subfolders {
|
||||
if folder.Name == name {
|
||||
return &folder, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil, ErrNoFile
|
||||
}
|
||||
|
||||
// folderRequest is used for creating and deleting folders
|
||||
type folderRequest struct {
|
||||
ParentFolder string `json:"parentFolder,omitempty"`
|
||||
Folder string `json:"folder"`
|
||||
}
|
||||
|
||||
// CreateFolder creates a new remote folder
|
||||
func (n *Namecrane) CreateFolder(ctx context.Context, folder string) (*Folder, error) {
|
||||
parent, subfolder := n.parsePath(folder)
|
||||
|
||||
res, err := n.doRequest(ctx, http.MethodPost, apiPutFolder, folderRequest{
|
||||
ParentFolder: parent,
|
||||
Folder: subfolder,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%w: %d", ErrUnexpectedStatus, res.StatusCode)
|
||||
}
|
||||
|
||||
var response FolderResponse
|
||||
|
||||
if err := res.Decode(&response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !response.Success {
|
||||
return nil, fmt.Errorf("failed to create directory, status: %d, response: %s", res.StatusCode, response.Message)
|
||||
}
|
||||
|
||||
return &response.Folder, nil
|
||||
}
|
||||
|
||||
// DeleteFolder deletes a specified folder by name
|
||||
func (n *Namecrane) DeleteFolder(ctx context.Context, folder string) error {
|
||||
parent, subfolder := n.parsePath(folder)
|
||||
|
||||
res, err := n.doRequest(ctx, http.MethodPost, apiDeleteFolder, folderRequest{
|
||||
ParentFolder: parent,
|
||||
Folder: subfolder,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%w: %d", ErrUnexpectedStatus, res.StatusCode)
|
||||
}
|
||||
|
||||
var status defaultResponse
|
||||
|
||||
if err := res.Decode(&status); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !status.Success {
|
||||
return fmt.Errorf("failed to remove directory, status: %d, response: %s", res.StatusCode, status.Message)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type moveFilesRequest struct {
|
||||
NewFolder string `json:"newFolder"`
|
||||
FileIDs []string `json:"fileIDs"`
|
||||
}
|
||||
|
||||
// MoveFiles moves files to the specified folder
|
||||
func (n *Namecrane) MoveFiles(ctx context.Context, folder string, fileIDs ...string) error {
|
||||
res, err := n.doRequest(ctx, http.MethodPost, apiMoveFiles, moveFilesRequest{
|
||||
NewFolder: folder,
|
||||
FileIDs: fileIDs,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%w: %d", ErrUnexpectedStatus, res.StatusCode)
|
||||
}
|
||||
|
||||
var response FolderResponse
|
||||
|
||||
if err := res.Decode(&response); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !response.Success {
|
||||
return fmt.Errorf("failed to create directory, status: %d, response: %s", res.StatusCode, response.Message)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type editFileRequest struct {
|
||||
NewFilename string `json:"newFilename"`
|
||||
}
|
||||
|
||||
// RenameFile will rename the specified file to the new name
|
||||
func (n *Namecrane) RenameFile(ctx context.Context, fileID string, name string) error {
|
||||
res, err := n.doRequest(ctx, http.MethodPost, apiEditFile, editFileRequest{
|
||||
NewFilename: name,
|
||||
}, WithURLParameter("fileId", fileID))
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%w: %d", ErrUnexpectedStatus, res.StatusCode)
|
||||
}
|
||||
|
||||
var response defaultResponse
|
||||
|
||||
if err := res.Decode(&response); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !response.Success {
|
||||
return fmt.Errorf("failed to create directory, status: %d, response: %s", res.StatusCode, response.Message)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type EditFileParams struct {
|
||||
Password string `json:"password"`
|
||||
Published bool `json:"published"`
|
||||
PublishedUntil time.Time `json:"publishedUntil"`
|
||||
ShortLink string `json:"shortLink"`
|
||||
PublicDownloadLink string `json:"publicDownloadLink"`
|
||||
}
|
||||
|
||||
// EditFile updates a file on the backend
|
||||
func (n *Namecrane) EditFile(ctx context.Context, fileID string, params EditFileParams) error {
|
||||
res, err := n.doRequest(ctx, http.MethodPost, apiEditFile, params, WithURLParameter("fileId", fileID))
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%w: %d", ErrUnexpectedStatus, res.StatusCode)
|
||||
}
|
||||
|
||||
var response defaultResponse
|
||||
|
||||
if err := res.Decode(&response); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !response.Success {
|
||||
return fmt.Errorf("failed to create directory, status: %d, response: %s", res.StatusCode, response.Message)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type linkResponse struct {
|
||||
defaultResponse
|
||||
PublicLink string `json:"publicLink"`
|
||||
ShortLink string `json:"shortLink"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
}
|
||||
|
||||
// GetLink creates a short link and public link to a file
|
||||
// This is combined with EditFile to make it public
|
||||
func (n *Namecrane) GetLink(ctx context.Context, fileID string) (string, string, error) {
|
||||
res, err := n.doRequest(ctx, http.MethodGet, apiGetFileLink, nil, WithURLParameter("fileId", fileID))
|
||||
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return "", "", fmt.Errorf("%w: %d", ErrUnexpectedStatus, res.StatusCode)
|
||||
}
|
||||
|
||||
var response linkResponse
|
||||
|
||||
if err := res.Decode(&response); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if !response.Success {
|
||||
return "", "", fmt.Errorf("failed to create directory, status: %d, response: %s", res.StatusCode, response.Message)
|
||||
}
|
||||
|
||||
return response.ShortLink, response.PublicLink, nil
|
||||
}
|
||||
|
||||
type patchFolderRequest struct {
|
||||
folderRequest
|
||||
ParentFolder string `json:"parentFolder"`
|
||||
Folder string `json:"folder"`
|
||||
NewFolderName string `json:"newFolderName,omitempty"`
|
||||
NewParentFolder string `json:"newParentFolder,omitempty"`
|
||||
}
|
||||
|
||||
func (n *Namecrane) MoveFolder(ctx context.Context, folder, newParentFolder string) error {
|
||||
_, subfolder := n.parsePath(folder)
|
||||
|
||||
res, err := n.doRequest(ctx, http.MethodPost, apiPatchFolder, patchFolderRequest{
|
||||
//ParentFolder: parent,
|
||||
Folder: folder,
|
||||
NewParentFolder: newParentFolder,
|
||||
NewFolderName: subfolder,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%w: %d (%s)", ErrUnexpectedStatus, res.StatusCode, string(res.Data()))
|
||||
}
|
||||
|
||||
var response defaultResponse
|
||||
|
||||
if err := res.Decode(&response); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !response.Success {
|
||||
return fmt.Errorf("failed to move directory, status: %d, response: %s", res.StatusCode, response.Message)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// apiUrl joins the base API URL with the path specified
|
||||
func (n *Namecrane) apiUrl(subPath string) (string, error) {
|
||||
u, err := url.Parse(n.apiURL)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
u.Path = path.Join(u.Path, subPath)
|
||||
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// RequestOpt is a quick helper for changing request options
|
||||
type RequestOpt func(r *http.Request)
|
||||
|
||||
// WithContentType overrides specified content types
|
||||
func WithContentType(contentType string) RequestOpt {
|
||||
return func(r *http.Request) {
|
||||
r.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeader sets header values on the request
|
||||
func WithHeader(key, value string) RequestOpt {
|
||||
return func(r *http.Request) {
|
||||
r.Header.Set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
// WithURLParameter replaces a URL parameter encased in {} with the value
|
||||
func WithURLParameter(key string, value any) RequestOpt {
|
||||
return func(r *http.Request) {
|
||||
var valStr string
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
valStr = v
|
||||
case int:
|
||||
valStr = strconv.Itoa(v)
|
||||
default:
|
||||
valStr = fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
r.URL.Path = strings.Replace(r.URL.Path, "{"+key+"}", valStr, -1)
|
||||
}
|
||||
}
|
||||
|
||||
func doHttpRequest(ctx context.Context, client *http.Client, method, u string, body any, opts ...RequestOpt) (*Response, error) {
|
||||
var bodyReader io.Reader
|
||||
var jsonBody bool
|
||||
|
||||
if body != nil {
|
||||
switch method {
|
||||
case http.MethodPost:
|
||||
switch v := body.(type) {
|
||||
case io.Reader:
|
||||
bodyReader = v
|
||||
case []byte:
|
||||
bodyReader = bytes.NewReader(v)
|
||||
case string:
|
||||
bodyReader = strings.NewReader(v)
|
||||
default:
|
||||
b, err := json.Marshal(body)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fs.Debugf(nil, "body: %s", string(b))
|
||||
|
||||
bodyReader = bytes.NewReader(b)
|
||||
|
||||
jsonBody = true
|
||||
}
|
||||
case http.MethodGet:
|
||||
switch v := body.(type) {
|
||||
case *url.Values:
|
||||
u += "?" + v.Encode()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create the HTTP request
|
||||
req, err := http.NewRequestWithContext(ctx, method, u, bodyReader)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create rmdir request: %w", err)
|
||||
}
|
||||
|
||||
if jsonBody {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
// Apply extra options like overriding content types
|
||||
for _, opt := range opts {
|
||||
opt(req)
|
||||
}
|
||||
|
||||
// Execute the HTTP request
|
||||
resp, err := client.Do(req)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to execute rmdir request: %w", err)
|
||||
}
|
||||
|
||||
return &Response{
|
||||
Response: resp,
|
||||
}, err
|
||||
}
|
||||
|
||||
func (n *Namecrane) doRequest(ctx context.Context, method, path string, body any, opts ...RequestOpt) (*Response, error) {
|
||||
ctx = context.WithValue(ctx, "httpClient", n.client)
|
||||
|
||||
token, err := n.authManager.GetToken(ctx)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to retrieve token: %w", err)
|
||||
}
|
||||
|
||||
opts = append(opts, WithHeader("Authorization", "Bearer "+token))
|
||||
|
||||
apiUrl, err := n.apiUrl(path)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return doHttpRequest(ctx, n.client, method, apiUrl, body, opts...)
|
||||
}
|
||||
|
||||
// parsePath parses the last segment off the specified path, representing either a file or directory
|
||||
func (n *Namecrane) parsePath(path string) (basePath, lastSegment string) {
|
||||
trimmedPath := strings.Trim(path, "/")
|
||||
|
||||
segments := strings.Split(trimmedPath, "/")
|
||||
|
||||
if len(segments) > 1 {
|
||||
basePath = "/" + strings.Join(segments[:len(segments)-1], "/")
|
||||
|
||||
lastSegment = segments[len(segments)-1]
|
||||
} else {
|
||||
basePath = "/"
|
||||
lastSegment = segments[0]
|
||||
}
|
||||
|
||||
return
|
||||
}
|
731
backend/namecrane/namecrane.go
Normal file
731
backend/namecrane/namecrane.go
Normal file
@ -0,0 +1,731 @@
|
||||
package namecrane
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/rclone/rclone/fs/config/configstruct"
|
||||
"github.com/rclone/rclone/fs/config/obscure"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rclone/rclone/fs"
|
||||
"github.com/rclone/rclone/fs/config/configmap"
|
||||
"github.com/rclone/rclone/fs/hash"
|
||||
)
|
||||
|
||||
/**
|
||||
* NameCrane Mail File Storage
|
||||
* Copyright (c) 2025 Namecrane LLC
|
||||
* PSA: No cranes harmed in the development of this module.
|
||||
*/
|
||||
|
||||
var (
|
||||
ErrEmptyDirectory = errors.New("directory name cannot be empty")
|
||||
)
|
||||
|
||||
type Fs struct {
|
||||
name string
|
||||
root string
|
||||
features *fs.Features
|
||||
client *Namecrane
|
||||
apiURL string
|
||||
authManager *AuthManager
|
||||
}
|
||||
|
||||
type Object struct {
|
||||
fs *Fs
|
||||
file *File
|
||||
folder *Folder
|
||||
remote string
|
||||
}
|
||||
|
||||
type Directory struct {
|
||||
*Object
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
ApiURL string `config:"api_url"`
|
||||
Username string `config:"username"`
|
||||
Password string `config:"password"`
|
||||
TwoFA string `config:"2fa"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
fs.Register(&fs.RegInfo{
|
||||
Name: "namecrane",
|
||||
Description: "NameCrane Mail File Storage",
|
||||
NewFs: NewFs,
|
||||
Options: []fs.Option{{
|
||||
Name: "api_url",
|
||||
Help: `NameCrane API URL, like https://us1.workspace.org`,
|
||||
Default: "https://us1.workspace.org",
|
||||
}, {
|
||||
Name: "username",
|
||||
Help: `NameCrane username`,
|
||||
Required: true,
|
||||
}, {
|
||||
Name: "password",
|
||||
Help: `NameCrane password
|
||||
|
||||
Only required for the first auth, subsequent requests re-use the access/refresh token`,
|
||||
Required: true,
|
||||
IsPassword: true,
|
||||
}, {
|
||||
Name: "2fa",
|
||||
Help: `Two Factor Authentication Code
|
||||
|
||||
Can be supplied with --namecrane-2fa=CODE when using any command for the first auth`,
|
||||
Required: false,
|
||||
}, {
|
||||
Name: accessTokenKey,
|
||||
Help: "Access token (internal only)",
|
||||
Required: false,
|
||||
Advanced: true,
|
||||
Sensitive: true,
|
||||
IsPassword: true,
|
||||
Hide: fs.OptionHideBoth,
|
||||
}, {
|
||||
Name: accessTokenExpireKey,
|
||||
Help: "Access token expiration (internal only)",
|
||||
Required: false,
|
||||
Advanced: true,
|
||||
Sensitive: true,
|
||||
Hide: fs.OptionHideBoth,
|
||||
}, {
|
||||
Name: refreshTokenKey,
|
||||
Help: "Refresh token (internal only)",
|
||||
Required: false,
|
||||
Advanced: true,
|
||||
Sensitive: true,
|
||||
IsPassword: true,
|
||||
Hide: fs.OptionHideBoth,
|
||||
}, {
|
||||
Name: refreshTokenExpireKey,
|
||||
Help: "Refresh token expiration (internal only)",
|
||||
Required: false,
|
||||
Advanced: true,
|
||||
Sensitive: true,
|
||||
Hide: fs.OptionHideBoth,
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, error) {
|
||||
opt := new(Options)
|
||||
|
||||
if err := configstruct.Set(m, opt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pass, err := obscure.Reveal(opt.Password)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("NewFS decrypt password: %w", err)
|
||||
}
|
||||
|
||||
opt.Password = pass
|
||||
|
||||
if root == "" || root == "." {
|
||||
root = "/"
|
||||
}
|
||||
|
||||
authManager := NewAuthManager(http.DefaultClient, m, opt.ApiURL)
|
||||
|
||||
authManager.fillFromConfigMapper()
|
||||
|
||||
if _, err := authManager.GetToken(ctx); errors.Is(err, ErrNoToken) {
|
||||
if opt.Username != "" && opt.Password != "" {
|
||||
err = authManager.Authenticate(ctx, opt.Username, opt.Password, opt.TwoFA)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to authenticate: %w", err)
|
||||
}
|
||||
}
|
||||
} else if err != nil {
|
||||
// Other error occurred, potentially needing a re-login
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := NewClient(opt.ApiURL, authManager)
|
||||
|
||||
f := &Fs{
|
||||
name: name,
|
||||
root: root,
|
||||
client: client,
|
||||
apiURL: opt.ApiURL,
|
||||
authManager: authManager,
|
||||
}
|
||||
|
||||
// Validate that the root is a directory, not a file
|
||||
_, file, err := client.Find(ctx, root)
|
||||
|
||||
// Ignore ErrNoFile as rclone will create directories for us
|
||||
if err != nil && !errors.Is(err, ErrNoFile) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if file != nil {
|
||||
// Path is a file, not a folder. Set the root to the folder and return a special error.
|
||||
f.root = file.FolderPath
|
||||
|
||||
return f, fs.ErrorIsFile
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (f *Fs) Name() string {
|
||||
return f.name
|
||||
}
|
||||
|
||||
func (f *Fs) Root() string {
|
||||
return f.root
|
||||
}
|
||||
|
||||
func (f *Fs) String() string {
|
||||
if f.root == "" {
|
||||
return fmt.Sprintf("NameCrane backend at %s", f.apiURL)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("NameCrane backend at %s, root '%s'", f.apiURL, f.root)
|
||||
}
|
||||
|
||||
func (f *Fs) Features() *fs.Features {
|
||||
if f.features == nil {
|
||||
f.features = (&fs.Features{
|
||||
CanHaveEmptyDirectories: true,
|
||||
}).Fill(context.Background(), f)
|
||||
}
|
||||
return f.features
|
||||
}
|
||||
|
||||
func (f *Fs) Rmdir(ctx context.Context, dir string) error {
|
||||
normalizedDir := path.Join(f.root, path.Clean(dir))
|
||||
|
||||
if normalizedDir == "" {
|
||||
return ErrEmptyDirectory
|
||||
}
|
||||
|
||||
// Normalize the directory path
|
||||
err := f.client.DeleteFolder(ctx, normalizedDir)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fs.Debugf(f, "Successfully removed directory '%s'", normalizedDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fs) Mkdir(ctx context.Context, dir string) error {
|
||||
normalizedDir := path.Join(f.root, path.Clean(dir))
|
||||
|
||||
if normalizedDir == "" {
|
||||
return ErrEmptyDirectory
|
||||
}
|
||||
|
||||
res, err := f.client.CreateFolder(ctx, normalizedDir)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fs.Debugf(f, "Successfully created directory '%s'", res.Path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fs) Stat(ctx context.Context, remote string) (fs.DirEntry, error) {
|
||||
// Fetch the folder path and file name from the remote path
|
||||
dir, fileName := path.Split(remote)
|
||||
|
||||
// Prepend root path
|
||||
dir = path.Join(f.root, dir)
|
||||
|
||||
if dir == "" || dir[0] != '/' {
|
||||
dir = "/" + dir
|
||||
}
|
||||
|
||||
fs.Debugf(f, "Stat file at %s: %s -> %s", remote, dir, fileName)
|
||||
|
||||
id, err := f.client.GetFileID(ctx, dir, fileName)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
files, err := f.client.GetFiles(ctx, id)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
file := files[0]
|
||||
|
||||
return &Object{
|
||||
fs: f,
|
||||
file: &file,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *Fs) Hashes() hash.Set {
|
||||
// Return the hash types supported by the backend.
|
||||
// If no hashing is supported, return hash.None.
|
||||
return hash.NewHashSet()
|
||||
}
|
||||
|
||||
func (f *Fs) List(ctx context.Context, dir string) (fs.DirEntries, error) {
|
||||
remote := path.Join(f.root, dir)
|
||||
|
||||
if remote == "" || remote[0] != '/' {
|
||||
remote = "/" + remote
|
||||
}
|
||||
|
||||
fs.Debugf(f, "List contents of %s: %s", dir, remote)
|
||||
|
||||
// If the path is a subdirectory, use GetFolder instead of GetFolders
|
||||
if remote != "/" {
|
||||
fs.Debugf(f, "Listing files in non-root directory %s", remote)
|
||||
|
||||
folder, err := f.client.GetFolder(ctx, remote)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNoFolder) {
|
||||
return nil, fs.ErrorDirNotFound
|
||||
}
|
||||
|
||||
fs.Errorf(f, "Unable to find directory %s", remote)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return f.folderToEntries(*folder), nil
|
||||
}
|
||||
|
||||
root, err := f.client.GetFolders(ctx)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// root[0] is always the root folder
|
||||
return f.folderToEntries(root[0]), nil
|
||||
}
|
||||
|
||||
func (f *Fs) folderToEntries(folder Folder) fs.DirEntries {
|
||||
var entries fs.DirEntries
|
||||
|
||||
for _, file := range folder.Files {
|
||||
entries = append(entries, &Object{
|
||||
fs: f,
|
||||
file: &file,
|
||||
})
|
||||
}
|
||||
|
||||
for _, subfolder := range folder.Subfolders {
|
||||
entries = append(entries, &Directory{
|
||||
Object: &Object{
|
||||
fs: f,
|
||||
folder: &subfolder,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
func (f *Fs) Sortable() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (f *Fs) NewObject(ctx context.Context, remote string) (fs.Object, error) {
|
||||
fs.Debugf(f, "New object %s", remote)
|
||||
|
||||
remote = path.Join(f.root, remote)
|
||||
|
||||
folder, file, err := f.client.Find(ctx, remote)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNoFile) {
|
||||
return nil, fs.ErrorObjectNotFound
|
||||
}
|
||||
|
||||
fs.Debugf(f, "Unable to find existing file at %s, not necessarily a bad thing: %s", remote, err.Error())
|
||||
}
|
||||
|
||||
return &Object{
|
||||
fs: f,
|
||||
remote: remote,
|
||||
folder: folder,
|
||||
file: file,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *Fs) newObject(remote string) *Object {
|
||||
return &Object{
|
||||
fs: f,
|
||||
remote: remote,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Fs) Put(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) {
|
||||
remote := src.Remote()
|
||||
|
||||
remote = path.Join(f.root, remote)
|
||||
|
||||
if remote[0] != '/' {
|
||||
remote = "/" + remote
|
||||
}
|
||||
|
||||
fs.Debugf(f, "Put contents of %s to %s", src.Remote(), remote)
|
||||
|
||||
file, err := f.client.Upload(ctx, in, remote, src.Size())
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Return the uploaded object
|
||||
return &Object{
|
||||
fs: f,
|
||||
file: file,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
fs.Debugf(f, "Moving directory %s to %s", srcRemote, dstRemote)
|
||||
|
||||
srcRemote = path.Join(srcFs.root, srcRemote)
|
||||
dstRemote = path.Join(f.root, dstRemote)
|
||||
|
||||
// Check source remote for the folder to move
|
||||
folder, _, err := f.client.Find(ctx, srcRemote)
|
||||
|
||||
if err != nil || folder == nil {
|
||||
return fs.ErrorDirNotFound
|
||||
}
|
||||
|
||||
// Confirm that the parent folder exists in the destination path
|
||||
parent, _, err := f.client.Find(ctx, dstRemote)
|
||||
|
||||
if errors.Is(err, ErrNoFile) {
|
||||
// If the parent does not exist, create it (equivalent to MkdirAll)
|
||||
parent, err = f.client.CreateFolder(ctx, dstRemote)
|
||||
|
||||
if err != nil {
|
||||
return fs.ErrorDirNotFound
|
||||
}
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check dest path for existing folder (dstRemote + folder.Name)
|
||||
existing, _, _ := f.client.Find(ctx, path.Join(dstRemote, folder.Name))
|
||||
|
||||
if existing != nil {
|
||||
return fs.ErrorDirExists
|
||||
}
|
||||
|
||||
// Use server side move
|
||||
err = f.client.MoveFolder(ctx, folder.Path, parent.Path)
|
||||
|
||||
if err != nil {
|
||||
// not quite clear, but probably trying to move directory across file system
|
||||
// boundaries. Copying might still work.
|
||||
fs.Debugf(src, "Can't move dir: %v: trying copy", err)
|
||||
return fs.ErrorCantDirMove
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
remote = path.Join(f.root, remote)
|
||||
|
||||
// Temporary Object under construction
|
||||
dstObj := f.newObject(remote)
|
||||
|
||||
// Check if the destination is a folder
|
||||
_, err := dstObj.Stat(ctx)
|
||||
|
||||
if errors.Is(err, ErrNoFile) {
|
||||
// OK
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if dstObj.folder != nil {
|
||||
return nil, errors.New("can't move file onto non-file")
|
||||
}
|
||||
|
||||
newFolder, _ := f.client.parsePath(remote)
|
||||
|
||||
baseFolder, _, err := f.client.Find(ctx, newFolder)
|
||||
|
||||
if err != nil && errors.Is(err, ErrNoFile) {
|
||||
baseFolder, err = f.client.CreateFolder(ctx, newFolder)
|
||||
|
||||
if err != nil {
|
||||
fs.Debugf(f, "Unable to create parent directory due to error %s", err.Error())
|
||||
return nil, fs.ErrorDirNotFound
|
||||
}
|
||||
} else if err != nil {
|
||||
fs.Debugf(f, "Unable to get parent directory due to error %s", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = f.client.MoveFiles(ctx, baseFolder.Path, srcObj.file.ID)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = dstObj.Stat(ctx)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dstObj, 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) (string, error) {
|
||||
remote = path.Join(f.root, remote)
|
||||
|
||||
_, file, err := f.client.Find(ctx, remote)
|
||||
|
||||
if errors.Is(err, ErrNoFile) {
|
||||
return "", fs.ErrorObjectNotFound
|
||||
}
|
||||
|
||||
// Unlink just sets published to false
|
||||
if unlink {
|
||||
err = f.client.EditFile(ctx, file.ID, EditFileParams{
|
||||
Published: false,
|
||||
PublishedUntil: time.Time{},
|
||||
})
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Generate the link
|
||||
shortLink, publicLink, err := f.client.GetLink(ctx, file.ID)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
publicLink = strings.TrimRight(f.apiURL, "/") + "/" + publicLink
|
||||
|
||||
params := EditFileParams{
|
||||
ShortLink: shortLink,
|
||||
PublicDownloadLink: publicLink,
|
||||
Published: true,
|
||||
}
|
||||
|
||||
if expire.IsSet() {
|
||||
params.PublishedUntil = time.Now().Add(time.Duration(expire))
|
||||
}
|
||||
|
||||
// Set the file to public
|
||||
err = f.client.EditFile(ctx, file.ID, params)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return publicLink, nil
|
||||
}
|
||||
|
||||
func (o *Object) ModTime(ctx context.Context) time.Time {
|
||||
if o.file != nil {
|
||||
return o.file.DateAdded
|
||||
}
|
||||
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func (o *Object) Fs() fs.Info {
|
||||
return o.fs
|
||||
}
|
||||
|
||||
func (o *Object) SetModTime(ctx context.Context, modTime time.Time) error {
|
||||
return fs.ErrorCantSetModTime
|
||||
}
|
||||
|
||||
func (o *Object) String() string {
|
||||
if o.file != nil {
|
||||
return strings.TrimRight(o.file.FolderPath, "/") + "/" + o.file.Name
|
||||
} else if o.folder != nil {
|
||||
return o.folder.Path
|
||||
}
|
||||
|
||||
return o.remote
|
||||
}
|
||||
|
||||
func (o *Object) Hash(ctx context.Context, ht hash.Type) (string, error) {
|
||||
return "", hash.ErrUnsupported
|
||||
}
|
||||
|
||||
func (f *Fs) Precision() time.Duration {
|
||||
// Return the time precision supported by the backend.
|
||||
// Use fs.ModTimeNotSupported if modification times are not supported.
|
||||
return fs.ModTimeNotSupported
|
||||
}
|
||||
|
||||
// Remote joins the path with the fs root
|
||||
func (o *Object) Remote() string {
|
||||
// Ensure paths are normalized and relative
|
||||
remotePath := path.Clean(o.String())
|
||||
rootPath := path.Clean(o.fs.root)
|
||||
|
||||
// Strip the root path from the remote if necessary
|
||||
remotePath = strings.TrimPrefix(remotePath, rootPath)
|
||||
|
||||
// Return the relative path
|
||||
return strings.TrimLeft(remotePath, "/")
|
||||
}
|
||||
|
||||
func (o *Object) Storable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Size returns the size of the object in bytes.
|
||||
func (o *Object) Size() int64 {
|
||||
if o.file != nil {
|
||||
return o.file.Size
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// Stat will ensure that either folder or file is populated, then return the object to use as ObjectInfo
|
||||
func (o *Object) Stat(ctx context.Context) (fs.ObjectInfo, error) {
|
||||
if o.file != nil || o.folder != nil {
|
||||
return o, nil
|
||||
}
|
||||
|
||||
fs.Debugf(o.fs, "Stat object %s", o.remote)
|
||||
|
||||
folder, file, err := o.fs.client.Find(ctx, o.remote)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Since one of these will be nil, we're fine setting both without an if check
|
||||
o.folder = folder
|
||||
o.file = file
|
||||
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// Open will open the file for reading
|
||||
func (o *Object) Open(ctx context.Context, options ...fs.OpenOption) (io.ReadCloser, error) {
|
||||
if o.file == nil {
|
||||
// Populate file from path
|
||||
_, file, err := o.fs.client.Find(ctx, o.remote)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if file == nil {
|
||||
return nil, fs.ErrorIsDir
|
||||
}
|
||||
|
||||
o.file = file
|
||||
}
|
||||
|
||||
// Support ranges (maybe, not sure if the API supports this?)
|
||||
opts := make([]RequestOpt, 0)
|
||||
|
||||
for _, opt := range options {
|
||||
key, value := opt.Header()
|
||||
|
||||
if key != "" && value != "" {
|
||||
opts = append(opts, WithHeader(key, value))
|
||||
}
|
||||
}
|
||||
|
||||
return o.fs.client.DownloadFile(ctx, o.file.ID, opts...)
|
||||
}
|
||||
|
||||
// Update pushes a file up to the backend
|
||||
func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) error {
|
||||
obj, err := o.fs.Put(ctx, in, src, options...)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
o.file = obj.(*Object).file
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove deletes the file represented by the object from the remote.
|
||||
func (o *Object) Remove(ctx context.Context) error {
|
||||
if o.file == nil {
|
||||
return fs.ErrorNotAFile
|
||||
}
|
||||
return o.fs.client.DeleteFiles(ctx, o.file.ID)
|
||||
}
|
||||
|
||||
// Items returns the count of items in this directory or this
|
||||
// directory and subdirectories if known, -1 for unknown
|
||||
func (d *Directory) Items() int64 {
|
||||
return int64(len(d.folder.Files))
|
||||
}
|
||||
|
||||
// ID returns the internal ID of this directory if known, or
|
||||
// "" otherwise
|
||||
func (d *Directory) ID() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Hash does nothing on a directory
|
||||
//
|
||||
// This method is implemented with the incorrect type signature to
|
||||
// stop the Directory type asserting to fs.Object or fs.ObjectInfo
|
||||
func (d *Directory) Hash() {
|
||||
// Does nothing
|
||||
}
|
||||
|
||||
// Check the interfaces are satisfied
|
||||
var (
|
||||
_ fs.Fs = &Fs{}
|
||||
_ fs.Object = &Object{}
|
||||
_ fs.Directory = &Directory{}
|
||||
_ fs.SetModTimer = &Directory{}
|
||||
)
|
Loading…
x
Reference in New Issue
Block a user