Merge 0b6dd1bc70e8c9a12a06474ffb54683f87eab7cb into 0b9671313b14ffe839ecbd7dd2ae5ac7f6f05db8

This commit is contained in:
namecrane 2025-04-11 14:56:52 +01:00 committed by GitHub
commit c3e53ca36b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 1823 additions and 0 deletions

View File

@ -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
View 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
View 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
}

View 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{}
)