diff --git a/backend/all/all.go b/backend/all/all.go index 36d95ce67..234b2bf50 100644 --- a/backend/all/all.go +++ b/backend/all/all.go @@ -19,6 +19,7 @@ import ( _ "github.com/rclone/rclone/backend/dropbox" _ "github.com/rclone/rclone/backend/fichier" _ "github.com/rclone/rclone/backend/filefabric" + _ "github.com/rclone/rclone/backend/filelu" _ "github.com/rclone/rclone/backend/filescom" _ "github.com/rclone/rclone/backend/ftp" _ "github.com/rclone/rclone/backend/gofile" diff --git a/backend/filelu/api/types.go b/backend/filelu/api/types.go new file mode 100644 index 000000000..8c506e810 --- /dev/null +++ b/backend/filelu/api/types.go @@ -0,0 +1,81 @@ +// Package api defines types for interacting with the FileLu API. +package api + +import "encoding/json" + +// CreateFolderResponse represents the response for creating a folder. +type CreateFolderResponse struct { + Status int `json:"status"` + Msg string `json:"msg"` + Result struct { + FldID interface{} `json:"fld_id"` + } `json:"result"` +} + +// DeleteFolderResponse represents the response for deleting a folder. +type DeleteFolderResponse struct { + Status int `json:"status"` + Msg string `json:"msg"` +} + +// FolderListResponse represents the response for listing folders. +type FolderListResponse struct { + Status int `json:"status"` + Msg string `json:"msg"` + Result struct { + Files []struct { + Name string `json:"name"` + FldID json.Number `json:"fld_id"` + Path string `json:"path"` + FileCode string `json:"file_code"` + Size int64 `json:"size"` + } `json:"files"` + Folders []struct { + Name string `json:"name"` + FldID json.Number `json:"fld_id"` + Path string `json:"path"` + } `json:"folders"` + } `json:"result"` +} + +// FileDirectLinkResponse represents the response for a direct link to a file. +type FileDirectLinkResponse struct { + Status int `json:"status"` + Msg string `json:"msg"` + Result struct { + URL string `json:"url"` + Size int64 `json:"size"` + } `json:"result"` +} + +// FileInfoResponse represents the response for file information. +type FileInfoResponse struct { + Status int `json:"status"` + Msg string `json:"msg"` + Result []struct { + Size string `json:"size"` + Name string `json:"name"` + FileCode string `json:"filecode"` + Hash string `json:"hash"` + Status int `json:"status"` + } `json:"result"` +} + +// DeleteFileResponse represents the response for deleting a file. +type DeleteFileResponse struct { + Status int `json:"status"` + Msg string `json:"msg"` +} + +// AccountInfoResponse represents the response for account information. +type AccountInfoResponse struct { + Status int `json:"status"` // HTTP status code of the response. + Msg string `json:"msg"` // Message describing the response. + Result struct { + PremiumExpire string `json:"premium_expire"` // Expiration date of premium access. + Email string `json:"email"` // User's email address. + UType string `json:"utype"` // User type (e.g., premium or free). + Storage string `json:"storage"` // Total storage available to the user. + StorageUsed string `json:"storage_used"` // Amount of storage used. + } `json:"result"` // Nested result structure containing account details. +} diff --git a/backend/filelu/filelu.go b/backend/filelu/filelu.go new file mode 100644 index 000000000..306f9f80e --- /dev/null +++ b/backend/filelu/filelu.go @@ -0,0 +1,366 @@ +// Package filelu provides an interface to the FileLu storage system. +package filelu + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path" + "strings" + "time" + + "github.com/rclone/rclone/fs" + "github.com/rclone/rclone/fs/config" + "github.com/rclone/rclone/fs/config/configmap" + "github.com/rclone/rclone/fs/config/configstruct" + "github.com/rclone/rclone/fs/fshttp" + "github.com/rclone/rclone/lib/encoder" + "github.com/rclone/rclone/lib/pacer" + "github.com/rclone/rclone/lib/rest" +) + +// Register the backend with Rclone +func init() { + fs.Register(&fs.RegInfo{ + Name: "filelu", + Description: "FileLu Cloud Storage", + NewFs: NewFs, + Options: []fs.Option{{ + Name: "key", + Help: "Your FileLu Rclone key from My Account", + Required: true, + Sensitive: true, + }, + { + Name: config.ConfigEncoding, + Help: config.ConfigEncodingHelp, + Advanced: true, + Default: (encoder.Base | // Slash,LtGt,DoubleQuote,Question,Asterisk,Pipe,Hash,Percent,BackSlash,Del,Ctl,RightSpace,InvalidUtf8,Dot + encoder.EncodeSlash | + encoder.EncodeLtGt | + encoder.EncodeExclamation | + encoder.EncodeDoubleQuote | + encoder.EncodeSingleQuote | + encoder.EncodeBackQuote | + encoder.EncodeQuestion | + encoder.EncodeDollar | + encoder.EncodeColon | + encoder.EncodeAsterisk | + encoder.EncodePipe | + encoder.EncodeHash | + encoder.EncodePercent | + encoder.EncodeBackSlash | + encoder.EncodeCrLf | + encoder.EncodeDel | + encoder.EncodeCtl | + encoder.EncodeLeftSpace | + encoder.EncodeLeftPeriod | + encoder.EncodeLeftTilde | + encoder.EncodeLeftCrLfHtVt | + encoder.EncodeRightPeriod | + encoder.EncodeRightCrLfHtVt | + encoder.EncodeSquareBracket | + encoder.EncodeSemicolon | + encoder.EncodeRightSpace | + encoder.EncodeInvalidUtf8 | + encoder.EncodeDot), + }, + }}) +} + +// Options defines the configuration for the FileLu backend +type Options struct { + Key string `config:"key"` + Enc encoder.MultiEncoder `config:"encoding"` +} + +// Fs represents the FileLu file system +type Fs struct { + name string + root string + opt Options + features *fs.Features + endpoint string + pacer *pacer.Pacer + srv *rest.Client + client *http.Client + targetFile string +} + +// NewFs creates a new Fs object for FileLu +func NewFs(ctx context.Context, name string, root string, m configmap.Mapper) (fs.Fs, error) { + opt := new(Options) + err := configstruct.Set(m, opt) + if err != nil { + return nil, fmt.Errorf("failed to parse config: %w", err) + } + + if opt.Key == "" { + return nil, fmt.Errorf("FileLu Rclone Key is required") + } + + client := fshttp.NewClient(ctx) + + if strings.TrimSpace(root) == "" { + root = "" + } + root = strings.Trim(root, "/") + + filename := "" + + f := &Fs{ + name: name, + opt: *opt, + endpoint: "https://filelu.com/rclone", + client: client, + srv: rest.NewClient(client).SetRoot("https://filelu.com/rclone"), + pacer: pacer.New(), + targetFile: filename, + root: root, + } + + f.features = (&fs.Features{ + CanHaveEmptyDirectories: true, + WriteMetadata: false, + SlowHash: true, + }).Fill(ctx, f) + + rootContainer, rootDirectory := rootSplit(f.root) + if rootContainer != "" && rootDirectory != "" { + // Check to see if the (container,directory) is actually an existing file + oldRoot := f.root + newRoot, leaf := path.Split(oldRoot) + f.root = strings.Trim(newRoot, "/") + _, err := f.NewObject(ctx, leaf) + if err != nil { + if err == fs.ErrorObjectNotFound || err == fs.ErrorNotAFile { + // File doesn't exist or is a directory so return old f + f.root = strings.Trim(oldRoot, "/") + return f, nil + } + return nil, err + } + // return an error with an fs which points to the parent + return f, fs.ErrorIsFile + } + + return f, nil +} + +// Mkdir to create directory on remote server. +func (f *Fs) Mkdir(ctx context.Context, dir string) error { + fullPath := path.Clean(f.root + "/" + dir) + _, err := f.createFolder(ctx, fullPath) + return err +} + +// About provides usage statistics for the remote +func (f *Fs) About(ctx context.Context) (*fs.Usage, error) { + accountInfo, err := f.getAccountInfo(ctx) + if err != nil { + return nil, err + } + + totalStorage, err := parseStorageToBytes(accountInfo.Result.Storage) + if err != nil { + return nil, fmt.Errorf("failed to parse total storage: %w", err) + } + + usedStorage, err := parseStorageToBytes(accountInfo.Result.StorageUsed) + if err != nil { + return nil, fmt.Errorf("failed to parse used storage: %w", err) + } + + return &fs.Usage{ + Total: fs.NewUsageValue(totalStorage), // Total bytes available + Used: fs.NewUsageValue(usedStorage), // Total bytes used + Free: fs.NewUsageValue(totalStorage - usedStorage), + }, nil +} + +// Purge deletes the directory and all its contents +func (f *Fs) Purge(ctx context.Context, dir string) error { + fullPath := path.Join(f.root, dir) + if fullPath != "" { + fullPath = "/" + strings.Trim(fullPath, "/") + } + return f.deleteFolder(ctx, fullPath) +} + +// List returns a list of files and folders +// List returns a list of files and folders for the given directory +func (f *Fs) List(ctx context.Context, dir string) (fs.DirEntries, error) { + // Compose full path for API call + fullPath := path.Join(f.root, dir) + fullPath = "/" + strings.Trim(fullPath, "/") + if fullPath == "/" { + fullPath = "" + } + + var entries fs.DirEntries + result, err := f.getFolderList(ctx, fullPath) + if err != nil { + return nil, err + } + + fldMap := map[string]bool{} + for _, folder := range result.Result.Folders { + fldMap[folder.FldID.String()] = true + if f.root == "" && dir == "" && strings.Contains(folder.Path, "/") { + continue + } + + paths := strings.Split(folder.Path, fullPath+"/") + remote := paths[0] + if len(paths) > 1 { + remote = paths[1] + } + + if strings.Contains(remote, "/") { + continue + } + + pathsWithoutRoot := strings.Split(folder.Path, "/"+f.root+"/") + remotePathWithoutRoot := pathsWithoutRoot[0] + if len(pathsWithoutRoot) > 1 { + remotePathWithoutRoot = pathsWithoutRoot[1] + } + remotePathWithoutRoot = strings.TrimPrefix(remotePathWithoutRoot, "/") + entries = append(entries, fs.NewDir(remotePathWithoutRoot, time.Now())) + } + for _, file := range result.Result.Files { + if _, ok := fldMap[file.FldID.String()]; ok { + continue + } + remote := path.Join(dir, file.Name) + // trim leading slashes + remote = strings.TrimPrefix(remote, "/") + obj := &Object{ + fs: f, + remote: remote, + size: file.Size, + modTime: time.Now(), + } + entries = append(entries, obj) + } + return entries, nil +} + +// Put uploads a file directly to the destination folder in the FileLu storage system. +func (f *Fs) Put(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) { + if src.Size() == 0 { + return nil, fs.ErrorCantUploadEmptyFiles + } + + err := f.uploadFile(ctx, in, src.Remote()) + if err != nil { + return nil, err + } + + newObject := &Object{ + fs: f, + remote: src.Remote(), + size: src.Size(), + modTime: src.ModTime(ctx), + } + fs.Infof(f, "Put: Successfully uploaded new file %q", src.Remote()) + return newObject, nil +} + +// Move moves the file to the specified location +func (f *Fs) Move(ctx context.Context, src fs.Object, destinationPath string) (fs.Object, error) { + + if strings.HasPrefix(destinationPath, "/") || strings.Contains(destinationPath, ":\\") { + dir := path.Dir(destinationPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("failed to create destination directory: %w", err) + } + + reader, err := src.Open(ctx) + if err != nil { + return nil, fmt.Errorf("failed to open source file: %w", err) + } + defer func() { + if err := reader.Close(); err != nil { + fs.Logf(nil, "Failed to close file body: %v", err) + } + }() + + dest, err := os.Create(destinationPath) + if err != nil { + return nil, fmt.Errorf("failed to create destination file: %w", err) + } + defer func() { + if err := dest.Close(); err != nil { + fs.Logf(nil, "Failed to close file body: %v", err) + } + }() + + if _, err := io.Copy(dest, reader); err != nil { + return nil, fmt.Errorf("failed to copy file content: %w", err) + } + + if err := src.Remove(ctx); err != nil { + return nil, fmt.Errorf("failed to remove source file: %w", err) + } + + return nil, nil + } + + reader, err := src.Open(ctx) + if err != nil { + return nil, fmt.Errorf("failed to open source object: %w", err) + } + defer func() { + if err := reader.Close(); err != nil { + fs.Logf(nil, "Failed to close file body: %v", err) + } + }() + + err = f.uploadFile(ctx, reader, destinationPath) + if err != nil { + return nil, fmt.Errorf("failed to upload file to destination: %w", err) + } + + if err := src.Remove(ctx); err != nil { + return nil, fmt.Errorf("failed to delete source file: %w", err) + } + + return &Object{ + fs: f, + remote: destinationPath, + size: src.Size(), + modTime: src.ModTime(ctx), + }, nil +} + +// Rmdir removes a directory +func (f *Fs) Rmdir(ctx context.Context, dir string) error { + fullPath := path.Join(f.root, dir) + if fullPath != "" { + fullPath = "/" + strings.Trim(fullPath, "/") + } + + // Step 1: Check if folder is empty + listResp, err := f.getFolderList(ctx, fullPath) + if err != nil { + return err + } + if len(listResp.Result.Files) > 0 || len(listResp.Result.Folders) > 0 { + return fmt.Errorf("Rmdir: directory %q is not empty", fullPath) + } + + // Step 2: Delete the folder + return f.deleteFolder(ctx, fullPath) +} + +// Check the interfaces are satisfied +var ( + _ fs.Fs = (*Fs)(nil) + _ fs.Purger = (*Fs)(nil) + _ fs.Abouter = (*Fs)(nil) + _ fs.Mover = (*Fs)(nil) + _ fs.Object = (*Object)(nil) +) diff --git a/backend/filelu/filelu_client.go b/backend/filelu/filelu_client.go new file mode 100644 index 000000000..0beb1085c --- /dev/null +++ b/backend/filelu/filelu_client.go @@ -0,0 +1,324 @@ +package filelu + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/rclone/rclone/backend/filelu/api" + "github.com/rclone/rclone/fs" + "github.com/rclone/rclone/fs/fserrors" + "github.com/rclone/rclone/lib/rest" +) + +// createFolder creates a folder at the specified path. +func (f *Fs) createFolder(ctx context.Context, dirPath string) (*api.CreateFolderResponse, error) { + encodedDir := f.fromStandardPath(dirPath) + apiURL := fmt.Sprintf("%s/folder/create?folder_path=%s&key=%s", + f.endpoint, + url.QueryEscape(encodedDir), + url.QueryEscape(f.opt.Key), // assuming f.opt.Key is the correct field + ) + + req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + var resp *http.Response + result := api.CreateFolderResponse{} + err = f.pacer.Call(func() (bool, error) { + var innerErr error + resp, innerErr = f.client.Do(req) + return fserrors.ShouldRetry(innerErr), innerErr + }) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + fs.Logf(nil, "Failed to close response body: %v", err) + } + }() + + err = json.NewDecoder(resp.Body).Decode(&result) + if err != nil { + return nil, fmt.Errorf("error decoding response: %w", err) + } + if result.Status != 200 { + return nil, fmt.Errorf("error: %s", result.Msg) + } + + fs.Infof(f, "Successfully created folder %q with ID %v", dirPath, result.Result.FldID) + return &result, nil +} + +// getFolderList List both files and folders in a directory. +func (f *Fs) getFolderList(ctx context.Context, path string) (*api.FolderListResponse, error) { + encodedDir := f.fromStandardPath(path) + apiURL := fmt.Sprintf("%s/folder/list?folder_path=%s&key=%s", + f.endpoint, + url.QueryEscape(encodedDir), + url.QueryEscape(f.opt.Key), + ) + + var body []byte + err := f.pacer.Call(func() (bool, error) { + req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) + if err != nil { + return false, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := f.client.Do(req) + if err != nil { + return shouldRetry(err), fmt.Errorf("failed to list directory: %w", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + fs.Logf(nil, "Failed to close response body: %v", err) + } + }() + + body, err = io.ReadAll(resp.Body) + if err != nil { + return false, fmt.Errorf("error reading response body: %w", err) + } + + return shouldRetryHTTP(resp.StatusCode), nil + }) + if err != nil { + return nil, err + } + + var response api.FolderListResponse + if err := json.NewDecoder(bytes.NewReader(body)).Decode(&response); err != nil { + return nil, fmt.Errorf("error decoding response: %w", err) + } + if response.Status != 200 { + if strings.Contains(response.Msg, "Folder not found") { + return nil, fs.ErrorDirNotFound + } + return nil, fmt.Errorf("API error: %s", response.Msg) + } + + for index := range response.Result.Folders { + response.Result.Folders[index].Path = f.toStandardPath(response.Result.Folders[index].Path) + } + + for index := range response.Result.Files { + response.Result.Files[index].Name = f.toStandardPath(response.Result.Files[index].Name) + } + + return &response, nil + +} + +// deleteFolder deletes a folder at the specified path. +func (f *Fs) deleteFolder(ctx context.Context, fullPath string) error { + fullPath = f.fromStandardPath(fullPath) + deleteURL := fmt.Sprintf("%s/folder/delete?folder_path=%s&key=%s", + f.endpoint, + url.QueryEscape(fullPath), + url.QueryEscape(f.opt.Key), + ) + + delResp := api.DeleteFolderResponse{} + err := f.pacer.Call(func() (bool, error) { + req, err := http.NewRequestWithContext(ctx, "GET", deleteURL, nil) + if err != nil { + return false, err + } + resp, err := f.client.Do(req) + if err != nil { + return fserrors.ShouldRetry(err), err + } + defer func() { + if err := resp.Body.Close(); err != nil { + fs.Logf(nil, "Failed to close response body: %v", err) + } + }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return false, err + } + + if err := json.Unmarshal(body, &delResp); err != nil { + return false, fmt.Errorf("error decoding delete response: %w", err) + } + if delResp.Status != 200 { + return false, fmt.Errorf("delete error: %s", delResp.Msg) + } + return false, nil + }) + if err != nil { + return err + } + + fs.Infof(f, "Rmdir: successfully deleted %q", fullPath) + return nil +} + +// getDirectLink of file from FileLu to download. +func (f *Fs) getDirectLink(ctx context.Context, filePath string) (string, int64, error) { + filePath = f.fromStandardPath(filePath) + apiURL := fmt.Sprintf("%s/file/direct_link?file_path=%s&key=%s", + f.endpoint, + url.QueryEscape(filePath), + url.QueryEscape(f.opt.Key), + ) + + result := api.FileDirectLinkResponse{} + err := f.pacer.Call(func() (bool, error) { + req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) + if err != nil { + return false, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := f.client.Do(req) + if err != nil { + return shouldRetry(err), fmt.Errorf("failed to fetch direct link: %w", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + fs.Logf(nil, "Failed to close response body: %v", err) + } + }() + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return false, fmt.Errorf("error decoding response: %w", err) + } + + if result.Status != 200 { + return false, fmt.Errorf("API error: %s", result.Msg) + } + + return shouldRetryHTTP(resp.StatusCode), nil + }) + if err != nil { + return "", 0, err + } + + return result.Result.URL, result.Result.Size, nil +} + +// deleteFile deletes a file based on filePath +func (f *Fs) deleteFile(ctx context.Context, filePath string) error { + filePath = f.fromStandardPath(filePath) + apiURL := fmt.Sprintf("%s/file/remove?file_path=%s&key=%s", + f.endpoint, + url.QueryEscape(filePath), + url.QueryEscape(f.opt.Key), + ) + + result := api.DeleteFileResponse{} + err := f.pacer.Call(func() (bool, error) { + req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) + if err != nil { + return false, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := f.client.Do(req) + if err != nil { + return shouldRetry(err), fmt.Errorf("failed to fetch direct link: %w", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + fs.Logf(nil, "Failed to close response body: %v", err) + } + }() + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return false, fmt.Errorf("error decoding response: %w", err) + } + + if result.Status != 200 { + return false, fmt.Errorf("API error: %s", result.Msg) + } + + return shouldRetryHTTP(resp.StatusCode), nil + }) + return err +} + +// getAccountInfo retrieves account information +func (f *Fs) getAccountInfo(ctx context.Context) (*api.AccountInfoResponse, error) { + opts := rest.Opts{ + Method: "GET", + Path: "/account/info", + Parameters: url.Values{ + "key": {f.opt.Key}, + }, + } + + var result api.AccountInfoResponse + err := f.pacer.Call(func() (bool, error) { + _, callErr := f.srv.CallJSON(ctx, &opts, nil, &result) + return fserrors.ShouldRetry(callErr), callErr + }) + + if err != nil { + return nil, err + } + + if result.Status != 200 { + return nil, fmt.Errorf("error: %s", result.Msg) + } + + return &result, nil +} + +// getFileInfo retrieves file information based on file code +func (f *Fs) getFileInfo(ctx context.Context, fileCode string) (*api.FileInfoResponse, error) { + u, _ := url.Parse(f.endpoint + "/file/info2") + q := u.Query() + q.Set("file_code", fileCode) // raw path — Go handles escaping properly here + q.Set("key", f.opt.Key) + u.RawQuery = q.Encode() + + apiURL := f.endpoint + "/file/info2?" + u.RawQuery + + var body []byte + err := f.pacer.Call(func() (bool, error) { + req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) + if err != nil { + return false, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := f.client.Do(req) + if err != nil { + return shouldRetry(err), fmt.Errorf("failed to fetch file info: %w", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + fs.Logf(nil, "Failed to close response body: %v", err) + } + }() + + body, err = io.ReadAll(resp.Body) + if err != nil { + return false, fmt.Errorf("error reading response body: %w", err) + } + + return shouldRetryHTTP(resp.StatusCode), nil + }) + if err != nil { + return nil, err + } + result := api.FileInfoResponse{} + + if err := json.NewDecoder(bytes.NewReader(body)).Decode(&result); err != nil { + return nil, fmt.Errorf("error decoding response: %w", err) + } + + if result.Status != 200 || len(result.Result) == 0 { + return nil, fs.ErrorObjectNotFound + } + + return &result, nil +} diff --git a/backend/filelu/filelu_file_uploader.go b/backend/filelu/filelu_file_uploader.go new file mode 100644 index 000000000..6da56ebdd --- /dev/null +++ b/backend/filelu/filelu_file_uploader.go @@ -0,0 +1,193 @@ +package filelu + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "path" + "strings" + + "github.com/rclone/rclone/fs" +) + +// uploadFile uploads a file to FileLu +func (f *Fs) uploadFile(ctx context.Context, fileContent io.Reader, fileFullPath string) error { + directory := path.Dir(fileFullPath) + fileName := path.Base(fileFullPath) + if directory == "." { + directory = "" + } + destinationFolderPath := path.Join(f.root, directory) + if destinationFolderPath != "" { + destinationFolderPath = "/" + strings.Trim(destinationFolderPath, "/") + } + + existingEntries, err := f.List(ctx, path.Dir(fileFullPath)) + if err != nil { + if errors.Is(err, fs.ErrorDirNotFound) { + err = f.Mkdir(ctx, path.Dir(fileFullPath)) + if err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + } else { + return fmt.Errorf("failed to list existing files: %w", err) + } + } + + for _, entry := range existingEntries { + if entry.Remote() == fileFullPath { + _, ok := entry.(fs.Object) + if !ok { + continue + } + + // If the file exists but is different, remove it + filePath := "/" + strings.Trim(destinationFolderPath+"/"+fileName, "/") + err = f.deleteFile(ctx, filePath) + if err != nil { + return fmt.Errorf("failed to delete existing file: %w", err) + } + } + } + + uploadURL, sessID, err := f.getUploadServer(ctx) + if err != nil { + return fmt.Errorf("failed to retrieve upload server: %w", err) + } + + // Since the fileCode isn't used, just handle the error + if _, err := f.uploadFileWithDestination(ctx, uploadURL, sessID, fileName, fileContent, destinationFolderPath); err != nil { + return fmt.Errorf("failed to upload file: %w", err) + } + + return nil +} + +// getUploadServer gets the upload server URL with proper key authentication +func (f *Fs) getUploadServer(ctx context.Context) (string, string, error) { + apiURL := fmt.Sprintf("%s/upload/server?key=%s", f.endpoint, url.QueryEscape(f.opt.Key)) + + var result struct { + Status int `json:"status"` + SessID string `json:"sess_id"` + Result string `json:"result"` + Msg string `json:"msg"` + } + + err := f.pacer.Call(func() (bool, error) { + req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) + if err != nil { + return false, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := f.client.Do(req) + if err != nil { + return shouldRetry(err), fmt.Errorf("failed to get upload server: %w", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + fs.Logf(nil, "Failed to close response body: %v", err) + } + }() + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return false, fmt.Errorf("error decoding response: %w", err) + } + + if result.Status != 200 { + return false, fmt.Errorf("API error: %s", result.Msg) + } + + return shouldRetryHTTP(resp.StatusCode), nil + }) + + if err != nil { + return "", "", err + } + + return result.Result, result.SessID, nil +} + +// uploadFileWithDestination uploads a file directly to a specified folder using file content reader. +func (f *Fs) uploadFileWithDestination(ctx context.Context, uploadURL, sessID, fileName string, fileContent io.Reader, dirPath string) (string, error) { + destinationPath := f.fromStandardPath(dirPath) + encodedFileName := f.fromStandardPath(fileName) + pr, pw := io.Pipe() + writer := multipart.NewWriter(pw) + isDeletionRequired := false + go func() { + defer func() { + if err := pw.Close(); err != nil { + fs.Logf(nil, "Failed to close: %v", err) + } + }() + _ = writer.WriteField("sess_id", sessID) + _ = writer.WriteField("utype", "prem") + _ = writer.WriteField("fld_path", destinationPath) + + part, err := writer.CreateFormFile("file_0", encodedFileName) + if err != nil { + pw.CloseWithError(fmt.Errorf("failed to create form file: %w", err)) + return + } + + if _, err := io.Copy(part, fileContent); err != nil { + isDeletionRequired = true + pw.CloseWithError(fmt.Errorf("failed to copy file content: %w", err)) + return + } + + if err := writer.Close(); err != nil { + pw.CloseWithError(fmt.Errorf("failed to close writer: %w", err)) + } + }() + + var fileCode string + err := f.pacer.Call(func() (bool, error) { + req, err := http.NewRequestWithContext(ctx, "POST", uploadURL, pr) + if err != nil { + return false, fmt.Errorf("failed to create upload request: %w", err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + + resp, err := f.client.Do(req) + if err != nil { + return shouldRetry(err), fmt.Errorf("failed to send upload request: %w", err) + } + defer respBodyClose(resp.Body) + + var result []struct { + FileCode string `json:"file_code"` + FileStatus string `json:"file_status"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return false, fmt.Errorf("failed to parse upload response: %w", err) + } + + if len(result) == 0 || result[0].FileStatus != "OK" { + return false, fmt.Errorf("upload failed with status: %s", result[0].FileStatus) + } + + fileCode = result[0].FileCode + return shouldRetryHTTP(resp.StatusCode), nil + }) + + if err != nil && isDeletionRequired { + // Attempt to delete the file if upload fails + _ = f.deleteFile(ctx, destinationPath+"/"+fileName) + } + + return fileCode, err +} + +// respBodyClose to check body response. +func respBodyClose(responseBody io.Closer) { + if cerr := responseBody.Close(); cerr != nil { + fmt.Printf("Error closing response body: %v\n", cerr) + } +} diff --git a/backend/filelu/filelu_helper.go b/backend/filelu/filelu_helper.go new file mode 100644 index 000000000..771e6c2fa --- /dev/null +++ b/backend/filelu/filelu_helper.go @@ -0,0 +1,112 @@ +package filelu + +import ( + "context" + "errors" + "fmt" + "path" + "strings" + "time" + + "github.com/rclone/rclone/fs" + "github.com/rclone/rclone/fs/fserrors" + "github.com/rclone/rclone/fs/hash" +) + +// errFileNotFound represent file not found error +var errFileNotFound error = errors.New("file not found") + +// getFileCode retrieves the file code for a given file path +func (f *Fs) getFileCode(ctx context.Context, filePath string) (string, error) { + // Prepare parent directory + parentDir := path.Dir(filePath) + + // Call List to get all the files + result, err := f.getFolderList(ctx, parentDir) + if err != nil { + return "", err + } + + for _, file := range result.Result.Files { + filePathFromServer := parentDir + "/" + file.Name + if parentDir == "/" { + filePathFromServer = "/" + file.Name + } + if filePath == filePathFromServer { + return file.FileCode, nil + } + } + + return "", errFileNotFound +} + +// Features returns the optional features of this Fs +func (f *Fs) Features() *fs.Features { + return f.features +} + +func (f *Fs) fromStandardPath(remote string) string { + return f.opt.Enc.FromStandardPath(remote) +} + +func (f *Fs) toStandardPath(remote string) string { + return f.opt.Enc.ToStandardPath(remote) +} + +// Hashes returns an empty hash set, indicating no hash support +func (f *Fs) Hashes() hash.Set { + return hash.NewHashSet() // Properly creates an empty hash set +} + +// Name returns the remote name +func (f *Fs) Name() string { + return f.name +} + +// Root returns the root path +func (f *Fs) Root() string { + return f.root +} + +// Precision returns the precision of the remote +func (f *Fs) Precision() time.Duration { + return fs.ModTimeNotSupported +} + +func (f *Fs) String() string { + return fmt.Sprintf("FileLu root '%s'", f.root) +} + +// isFileCode checks if a string looks like a file code +func isFileCode(s string) bool { + if len(s) != 12 { + return false + } + for _, c := range s { + if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) { + return false + } + } + return true +} + +func shouldRetry(err error) bool { + return fserrors.ShouldRetry(err) +} + +func shouldRetryHTTP(code int) bool { + return code == 429 || code >= 500 +} + +func rootSplit(absPath string) (bucket, bucketPath string) { + // No bucket + if absPath == "" { + return "", "" + } + slash := strings.IndexRune(absPath, '/') + // Bucket but no path + if slash < 0 { + return absPath, "" + } + return absPath[:slash], absPath[slash+1:] +} diff --git a/backend/filelu/filelu_object.go b/backend/filelu/filelu_object.go new file mode 100644 index 000000000..4c7f3145a --- /dev/null +++ b/backend/filelu/filelu_object.go @@ -0,0 +1,259 @@ +package filelu + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "path" + "regexp" + "strconv" + "strings" + "time" + + "github.com/rclone/rclone/fs" + "github.com/rclone/rclone/fs/hash" +) + +// Object describes a FileLu object +type Object struct { + fs *Fs + remote string + size int64 + modTime time.Time +} + +// NewObject creates a new Object for the given remote path +func (f *Fs) NewObject(ctx context.Context, remote string) (fs.Object, error) { + var filePath string + filePath = path.Join(f.root, remote) + filePath = "/" + strings.Trim(filePath, "/") + + // Get File code + fileCode, err := f.getFileCode(ctx, filePath) + if err != nil { + return nil, fs.ErrorObjectNotFound + } + + // Get File info + fileInfos, err := f.getFileInfo(ctx, fileCode) + if err != nil { + return nil, fmt.Errorf("failed to get file info: %w", err) + } + + fileInfo := fileInfos.Result[0] + size, _ := strconv.ParseInt(fileInfo.Size, 10, 64) + + returnedRemote := remote + return &Object{ + fs: f, + remote: returnedRemote, + size: size, + modTime: time.Now(), + }, nil +} + +// Open opens the object for reading +func (o *Object) Open(ctx context.Context, options ...fs.OpenOption) (io.ReadCloser, error) { + filePath := path.Join(o.fs.root, o.remote) + // Get direct link + directLink, size, err := o.fs.getDirectLink(ctx, filePath) + if err != nil { + return nil, fmt.Errorf("failed to get direct link: %w", err) + } + + o.size = size + + // Offset and Count for range download + var offset int64 + var count int64 + fs.FixRangeOption(options, o.size) + for _, option := range options { + switch x := option.(type) { + case *fs.RangeOption: + offset, count = x.Decode(o.size) + if count < 0 { + count = o.size - offset + } + case *fs.SeekOption: + offset = x.Offset + count = o.size + default: + if option.Mandatory() { + fs.Logf(o, "Unsupported mandatory option: %v", option) + } + } + } + + var reader io.ReadCloser + err = o.fs.pacer.Call(func() (bool, error) { + req, err := http.NewRequestWithContext(ctx, "GET", directLink, nil) + if err != nil { + return false, fmt.Errorf("failed to create download request: %w", err) + } + + resp, err := o.fs.client.Do(req) + if err != nil { + return shouldRetry(err), fmt.Errorf("failed to download file: %w", err) + } + + if resp.StatusCode != http.StatusOK { + defer func() { + if err := resp.Body.Close(); err != nil { + fs.Logf(nil, "Failed to close response body: %v", err) + } + }() + return false, fmt.Errorf("failed to download file: HTTP %d", resp.StatusCode) + } + + // Wrap the response body to handle offset and count + currentContents, err := io.ReadAll(resp.Body) + if err != nil { + return false, fmt.Errorf("failed to read response body: %w", err) + } + + if offset > 0 { + if offset > int64(len(currentContents)) { + return false, fmt.Errorf("offset %d exceeds file size %d", offset, len(currentContents)) + } + currentContents = currentContents[offset:] + } + if count > 0 && count < int64(len(currentContents)) { + currentContents = currentContents[:count] + } + reader = io.NopCloser(bytes.NewReader(currentContents)) + + return false, nil + }) + if err != nil { + return nil, err + } + + return reader, nil +} + +// Update updates the object with new data +func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) error { + if src.Size() <= 0 { + return fs.ErrorCantUploadEmptyFiles + } + + err := o.fs.uploadFile(ctx, in, o.remote) + if err != nil { + return fmt.Errorf("failed to upload file: %w", err) + } + o.size = src.Size() + return nil +} + +// Remove deletes the object from FileLu +func (o *Object) Remove(ctx context.Context) error { + fullPath := "/" + strings.Trim(path.Join(o.fs.root, o.remote), "/") + + err := o.fs.deleteFile(ctx, fullPath) + if err != nil { + return err + } + fs.Infof(o.fs, "Successfully deleted file: %s", fullPath) + return nil +} + +// Hash returns the MD5 hash of an object +func (o *Object) Hash(ctx context.Context, t hash.Type) (string, error) { + if t != hash.MD5 { + return "", hash.ErrUnsupported + } + + var fileCode string + if isFileCode(o.fs.root) { + fileCode = o.fs.root + } else { + matches := regexp.MustCompile(`\((.*?)\)`).FindAllStringSubmatch(o.remote, -1) + for _, match := range matches { + if len(match) > 1 && len(match[1]) == 12 { + fileCode = match[1] + break + } + } + } + if fileCode == "" { + return "", fmt.Errorf("no valid file code found in the remote path") + } + + apiURL := fmt.Sprintf("%s/file/info?file_code=%s&key=%s", + o.fs.endpoint, url.QueryEscape(fileCode), url.QueryEscape(o.fs.opt.Key)) + + var result struct { + Status int `json:"status"` + Msg string `json:"msg"` + Result []struct { + Hash string `json:"hash"` + } `json:"result"` + } + err := o.fs.pacer.Call(func() (bool, error) { + req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) + if err != nil { + return false, err + } + resp, err := o.fs.client.Do(req) + if err != nil { + return shouldRetry(err), err + } + defer func() { + if err := resp.Body.Close(); err != nil { + fs.Logf(nil, "Failed to close response body: %v", err) + } + }() + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return false, err + } + return shouldRetryHTTP(resp.StatusCode), nil + }) + if err != nil { + return "", err + } + if result.Status != 200 || len(result.Result) == 0 { + return "", fmt.Errorf("error: unable to fetch hash: %s", result.Msg) + } + + return result.Result[0].Hash, nil +} + +// String returns a string representation of the object +func (o *Object) String() string { + return o.remote +} + +// Fs returns the parent Fs +func (o *Object) Fs() fs.Info { + return o.fs +} + +// Remote returns the remote path +func (o *Object) Remote() string { + return o.remote +} + +// Size returns the size of the object +func (o *Object) Size() int64 { + return o.size +} + +// ModTime returns the modification time of the object +func (o *Object) ModTime(ctx context.Context) time.Time { + return o.modTime +} + +// SetModTime sets the modification time of the object +func (o *Object) SetModTime(ctx context.Context, modTime time.Time) error { + return fs.ErrorCantSetModTime +} + +// Storable indicates whether the object is storable +func (o *Object) Storable() bool { + return true +} diff --git a/backend/filelu/filelu_test.go b/backend/filelu/filelu_test.go new file mode 100644 index 000000000..93b364a88 --- /dev/null +++ b/backend/filelu/filelu_test.go @@ -0,0 +1,16 @@ +package filelu_test + +import ( + "testing" + + "github.com/rclone/rclone/fstest/fstests" +) + +// TestIntegration runs integration tests for the FileLu backend +func TestIntegration(t *testing.T) { + fstests.Run(t, &fstests.Opt{ + RemoteName: "TestFileLu:", + NilObject: nil, + SkipInvalidUTF8: true, + }) +} diff --git a/backend/filelu/utils.go b/backend/filelu/utils.go new file mode 100644 index 000000000..0525cfd50 --- /dev/null +++ b/backend/filelu/utils.go @@ -0,0 +1,15 @@ +package filelu + +import ( + "fmt" +) + +// parseStorageToBytes converts a storage string (e.g., "10") to bytes +func parseStorageToBytes(storage string) (int64, error) { + var gb float64 + _, err := fmt.Sscanf(storage, "%f", &gb) + if err != nil { + return 0, fmt.Errorf("failed to parse storage: %w", err) + } + return int64(gb * 1024 * 1024 * 1024), nil +} diff --git a/bin/make_manual.py b/bin/make_manual.py index 2d0fd580e..962a4731b 100755 --- a/bin/make_manual.py +++ b/bin/make_manual.py @@ -43,6 +43,7 @@ docs = [ "combine.md", "dropbox.md", "filefabric.md", + "filelu.md", "filescom.md", "ftp.md", "gofile.md", diff --git a/docs/content/_index.md b/docs/content/_index.md index 64ce9ed52..344c9a931 100644 --- a/docs/content/_index.md +++ b/docs/content/_index.md @@ -124,6 +124,7 @@ WebDAV or S3, that work out of the box.) {{< provider name="Enterprise File Fabric" home="https://storagemadeeasy.com/about/" config="/filefabric/" >}} {{< provider name="Fastmail Files" home="https://www.fastmail.com/" config="/webdav/#fastmail-files" >}} {{< provider name="Files.com" home="https://www.files.com/" config="/filescom/" >}} +{{< provider name="FileLu Cloud Storage" home="https://filelu.com/" config="/filelu/" >}} {{< provider name="FlashBlade" home="https://www.purestorage.com/products/unstructured-data-storage.html" config="/s3/#pure-storage-flashblade" >}} {{< provider name="FTP" home="https://en.wikipedia.org/wiki/File_Transfer_Protocol" config="/ftp/" >}} {{< provider name="Gofile" home="https://gofile.io/" config="/gofile/" >}} diff --git a/docs/content/filelu.md b/docs/content/filelu.md new file mode 100644 index 000000000..fb411c469 --- /dev/null +++ b/docs/content/filelu.md @@ -0,0 +1,275 @@ +--- +title: "FileLu" +description: "Rclone docs for FileLu" +versionIntroduced: "v1.70" +--- + +# {{< icon "fa fa-folder" >}} FileLu + +[FileLu](https://filelu.com/) is a reliable cloud storage provider +offering features like secure file uploads, downloads, flexible +storage options, and sharing capabilities. With support for high +storage limits and seamless integration with rclone, FileLu makes +managing files in the cloud easy. Its cross-platform file backup +services let you upload and back up files from any internet-connected +device. + +## Configuration + +Here is an example of how to make a remote called `filelu`. First, run: + + rclone config + +This will guide you through an interactive setup process: + +``` +No remotes found, make a new one? +n) New remote +s) Set configuration password +q) Quit config +n/s/q> n +name> filelu +Type of storage to configure. +Choose a number from below, or type in your own value +[snip] +xx / FileLu Cloud Storage + \ "filelu" +[snip] +Storage> filelu +Enter your FileLu Rclone Key: +key> YOUR_FILELU_RCLONE_KEY RC_xxxxxxxxxxxxxxxxxxxxxxxx +Configuration complete. + +Keep this "filelu" remote? +y) Yes this is OK +e) Edit this remote +d) Delete this remote +y/e/d> y +``` + +### Paths + +A path without an initial `/` will operate in the `Rclone` directory. + +A path with an initial `/` will operate at the root where you can see +the `Rclone` directory. + +``` +$ rclone lsf TestFileLu:/ +CCTV/ +Camera/ +Documents/ +Music/ +Photos/ +Rclone/ +Vault/ +Videos/ +``` + +### Example Commands + +Create a new folder named `foldername` in the `Rclone` directory: + + rclone mkdir filelu:foldername + +Delete a folder on FileLu: + + rclone rmdir filelu:/folder/path/ + +Delete a file on FileLu: + + rclone delete filelu:/hello.txt + +List files from your FileLu account: + + rclone ls filelu: + +List all folders: + + rclone lsd filelu: + +Copy a specific file to the FileLu root: + + rclone copy D:\\hello.txt filelu: + +Copy files from a local directory to a FileLu directory: + + rclone copy D:/local-folder filelu:/remote-folder/path/ + +Download a file from FileLu into a local directory: + + rclone copy filelu:/file-path/hello.txt D:/local-folder + +Move files from a local directory to a FileLu directory: + + rclone move D:\\local-folder filelu:/remote-path/ + +Sync files from a local directory to a FileLu directory: + + rclone sync --interactive D:/local-folder filelu:/remote-path/ + +Mount remote to local Linux: + + rclone mount filelu: /root/mnt --vfs-cache-mode full + +Mount remote to local Windows: + + rclone mount filelu: D:/local_mnt --vfs-cache-mode full + +Get storage info about the FileLu account: + + rclone about filelu: + +All the other rclone commands are supported by this backend. + +### FolderID instead of folder path + +We use the FolderID instead of the folder name to prevent errors when +users have identical folder names or paths. For example, if a user has +two or three folders named "test_folders," the system may become +confused and won't know which folder to move. In large storage +systems, some clients have hundred of thousands of folders and a few +millions of files, duplicate folder names or paths are quite common. + +### Modification Times and Hashes + +FileLu supports both modification times and MD5 hashes. + +FileLu only supports filenames and folder names up to 255 characters in length, where a +character is a Unicode character. + +### Duplicated Files + +When uploading and syncing via Rclone, FileLu does not allow uploading +duplicate files within the same directory. However, you can upload +duplicate files, provided they are in different directories (folders). + +### Failure to Log / Invalid Credentials or KEY + +Ensure that you have the correct Rclone key, which can be found in [My +Account](https://filelu.com/account/). Every time you toggle Rclone +OFF and ON in My Account, a new RC_xxxxxxxxxxxxxxxxxxxx key is +generated. Be sure to update your Rclone configuration with the new +key. + +If you are connecting to your FileLu remote for the first time and +encounter an error such as: + +``` +Failed to create file system for "my-filelu-remote:": couldn't login: Invalid credentials +``` + +Ensure your Rclone Key is correct. + +### Process `killed` + +Accounts with large files or extensive metadata may experience +significant memory usage during list/sync operations. Ensure the +system running `rclone` has sufficient memory and CPU to handle these +operations. + +{{< rem autogenerated options start" - DO NOT EDIT - instead edit fs.RegInfo in backend/filelu/filelu.go then run make backenddocs" >}} +### Standard options + +Here are the Standard options specific to filelu (FileLu Cloud Storage). + +#### --filelu-key + +Your FileLu Rclone key from My Account + +Properties: + +- Config: key +- Env Var: RCLONE_FILELU_KEY +- Type: string +- Required: true + +### Advanced options + +Here are the Advanced options specific to filelu (FileLu Cloud Storage). + +#### --filelu-description + +Description of the remote. + +Properties: + +- Config: description +- Env Var: RCLONE_FILELU_DESCRIPTION +- Type: string +- Required: false + +## Backend commands + +Here are the commands specific to the filelu backend. + +Run them with + + rclone backend COMMAND remote: + +The help below will explain what arguments each command takes. + +See the [backend](/commands/rclone_backend/) command for more +info on how to pass options and arguments. + +These can be run on a running backend using the rc command +[backend/command](/rc/#backend-command). + +### rename + +Rename a file in a FileLu directory + + rclone backend rename remote: [options] [+] + + +For example: + + rclone backend rename filelu:/file-path/hello.txt "hello_new_name.txt" + + +### movefile + +Move file within the remote FileLu directory + + rclone backend movefile remote: [options] [+] + + +For example: + + rclone backend movefile filelu:/source-path/hello.txt /destination-path/ + + +### movefolder + +Move a folder on remote FileLu + + rclone backend movefolder remote: [options] [+] + + +For example: + + rclone backend movefolder filelu:/sorce-fld-path/hello-folder/ /destication-fld-path/hello-folder/ + + +### renamefolder + +Rename a folder on FileLu + + rclone backend renamefolder remote: [options] [+] + + +For example: + + rclone backend renamefolder filelu:/folder-path/folder-name "new-folder-name" + + +{{< rem autogenerated options stop >}} + +## Limitations + +This backend uses a custom library implementing the FileLu API. While +it supports file transfers, some advanced features may not yet be +available. Please report any issues to the [rclone forum](https://forum.rclone.org/) +for troubleshooting and updates. + +For further information, visit [FileLu's website](https://filelu.com/). diff --git a/docs/content/overview.md b/docs/content/overview.md index 9cf84899f..0e60730c7 100644 --- a/docs/content/overview.md +++ b/docs/content/overview.md @@ -26,6 +26,7 @@ Here is an overview of the major features of each cloud storage system. | Dropbox | DBHASH ¹ | R | Yes | No | - | - | | Enterprise File Fabric | - | R/W | Yes | No | R/W | - | | Files.com | MD5, CRC32 | DR/W | Yes | No | R | - | +| FileLu Cloud Storage | MD5 | R/W | No | Yes | R | - | | FTP | - | R/W ¹⁰ | No | No | - | - | | Gofile | MD5 | DR/W | No | Yes | R | - | | Google Cloud Storage | MD5 | R/W | No | No | R/W | - | diff --git a/docs/layouts/chrome/navbar.html b/docs/layouts/chrome/navbar.html index dd180a0d2..218e6d49a 100644 --- a/docs/layouts/chrome/navbar.html +++ b/docs/layouts/chrome/navbar.html @@ -66,7 +66,8 @@ Digi Storage Dropbox Enterprise File Fabric - Files.com + Files.com + FileLu Cloud Storage FTP Gofile Google Cloud Storage diff --git a/fstest/test_all/config.yaml b/fstest/test_all/config.yaml index b8aec57e8..4c48c958e 100644 --- a/fstest/test_all/config.yaml +++ b/fstest/test_all/config.yaml @@ -531,3 +531,8 @@ backends: - backend: "iclouddrive" remote: "TestICloudDrive:" fastlist: false + - backend: "filelu" + remote: "TestFileLu:" + fastlist: false + ignore: + - TestRWFileHandleWriteNoWrite