diff --git a/README.md b/README.md index 2c99a640b..fd1101bc9 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ directories to and from different cloud storage providers. - Exaba [:page_facing_up:](https://rclone.org/s3/#exaba) - Fastmail Files [:page_facing_up:](https://rclone.org/webdav/#fastmail-files) - FileLu [:page_facing_up:](https://rclone.org/filelu/) +- Filen [:page_facing_up:](https://rclone.org/filen/) - Files.com [:page_facing_up:](https://rclone.org/filescom/) - FlashBlade [:page_facing_up:](https://rclone.org/s3/#pure-storage-flashblade) - FTP [:page_facing_up:](https://rclone.org/ftp/) diff --git a/backend/all/all.go b/backend/all/all.go index 7ad63c271..1a3c3d37c 100644 --- a/backend/all/all.go +++ b/backend/all/all.go @@ -22,6 +22,7 @@ import ( _ "github.com/rclone/rclone/backend/fichier" _ "github.com/rclone/rclone/backend/filefabric" _ "github.com/rclone/rclone/backend/filelu" + _ "github.com/rclone/rclone/backend/filen" _ "github.com/rclone/rclone/backend/filescom" _ "github.com/rclone/rclone/backend/ftp" _ "github.com/rclone/rclone/backend/gofile" diff --git a/backend/filen/filen.go b/backend/filen/filen.go new file mode 100644 index 000000000..2a3d8cb16 --- /dev/null +++ b/backend/filen/filen.go @@ -0,0 +1,1178 @@ +// Package filen provides an interface to Filen cloud storage. +package filen + +import ( + "context" + "errors" + "fmt" + "io" + pathModule "path" + "strings" + "sync" + "time" + + sdk "github.com/FilenCloudDienste/filen-sdk-go/filen" + "github.com/FilenCloudDienste/filen-sdk-go/filen/client" + "github.com/FilenCloudDienste/filen-sdk-go/filen/types" + + "github.com/google/uuid" + "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/config/obscure" + "github.com/rclone/rclone/fs/hash" + "github.com/rclone/rclone/fs/list" + "github.com/rclone/rclone/lib/encoder" + "golang.org/x/sync/errgroup" +) + +func init() { + fs.Register(&fs.RegInfo{ + Name: "filen", + Description: "Filen", + NewFs: NewFs, + Options: []fs.Option{ + { + Name: "email", + Help: "Email of your Filen account", + Required: true, + }, + { + Name: "password", + Help: "Password of your Filen account", + Required: true, + IsPassword: true, + Sensitive: true, + }, + { + Name: "api_key", + Help: `API Key for your Filen account + +Get this using the Filen CLI export-api-key command +You can download the Filen CLI from https://github.com/FilenCloudDienste/filen-cli`, + Required: true, + IsPassword: true, + Sensitive: true, + }, + { + Name: "upload_concurrency", + Help: `Concurrency for chunked uploads. + +This is the upper limit for how many transfers for the same file are running concurrently. +Setting this above to a value smaller than 1 will cause uploads to deadlock. + +If you are uploading small numbers of large files over high-speed links +and these uploads do not fully utilize your bandwidth, then increasing +this may help to speed up the transfers.`, + Default: 16, + Advanced: true, + }, + { + Name: config.ConfigEncoding, + Help: config.ConfigEncodingHelp, + Advanced: true, + Default: encoder.Standard | encoder.EncodeInvalidUtf8, + }, + { + Name: "master_keys", + Help: "Master Keys (internal use only)", + Sensitive: true, + Advanced: true, + }, { + Name: "private_key", + Help: "Private RSA Key (internal use only)", + Sensitive: true, + Advanced: true, + }, { + Name: "public_key", + Help: "Public RSA Key (internal use only)", + Sensitive: true, + Advanced: true, + }, { + Name: "auth_version", + Help: "Authentication Version (internal use only)", + Advanced: true, + }, { + Name: "base_folder_uuid", + Help: "UUID of Account Root Directory (internal use only)", + Sensitive: true, + Advanced: true, + }, + }, + }) +} + +// NewFs constructs a Fs at the path root +func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, error) { + opt := new(Options) + err := configstruct.Set(m, opt) + if err != nil { + return nil, err + } + + root = opt.Encoder.FromStandardPath(root) + password, err := obscure.Reveal(opt.Password) + if err != nil { + return nil, fmt.Errorf("failed to reveal password: %w", err) + } + apiKey, err := obscure.Reveal(opt.APIKey) + if err != nil { + return nil, fmt.Errorf("failed to reveal api key: %w", err) + } + + var filen *sdk.Filen + if password == "INTERNAL" { + tsconfig := sdk.TSConfig{ + Email: opt.Email, + MasterKeys: strings.Split(opt.MasterKeys, "|"), + APIKey: apiKey, + PublicKey: opt.PublicKey, + PrivateKey: opt.PrivateKey, + AuthVersion: opt.AuthVersion, + BaseFolderUUID: opt.BaseFolderUUID, + } + filen, err = sdk.NewFromTSConfig(tsconfig) + if err != nil { + return nil, err + } + } else { + filen, err = sdk.NewWithAPIKey(ctx, opt.Email, password, apiKey) + if err != nil { + return nil, err + } + } + + maybeRootDir, err := filen.FindDirectory(ctx, root) + if errors.Is(err, fs.ErrorIsFile) { // FsIsFile special case + var err2 error + root = pathModule.Dir(root) + maybeRootDir, err2 = filen.FindDirectory(ctx, root) + if err2 != nil { + return nil, err2 + } + } else if err != nil { + return nil, err + } + + fileSystem := &Fs{ + name: name, + root: Directory{}, + filen: filen, + Enc: opt.Encoder, + concurrency: opt.UploadConcurrency, + } + + fileSystem.features = (&fs.Features{ + ReadMimeType: true, + WriteMimeType: true, + CanHaveEmptyDirectories: true, + ChunkWriterDoesntSeek: true, + }).Fill(ctx, fileSystem) + + fileSystem.root = Directory{ + fs: fileSystem, + directory: maybeRootDir, // could be null at this point + path: root, + } + + // must return the error from FindDirectory (see FsIsFile) + return fileSystem, err +} + +// Options defines the configuration for this backend +type Options struct { + Email string `config:"email"` + Password string `config:"password"` + APIKey string `config:"api_key"` + Encoder encoder.MultiEncoder `config:"encoding"` + MasterKeys string `config:"master_keys"` + PrivateKey string `config:"private_key"` + PublicKey string `config:"public_key"` + AuthVersion int `config:"auth_version"` + BaseFolderUUID string `config:"base_folder_uuid"` + UploadConcurrency int `config:"upload_concurrency"` +} + +// Fs represents a virtual filesystem mounted on a specific root folder +type Fs struct { + name string + root Directory + filen *sdk.Filen + Enc encoder.MultiEncoder + features *fs.Features + concurrency int +} + +// Name of the remote (as passed into NewFs) +func (f *Fs) Name() string { + return f.name +} + +// Root of the remote (as passed into NewFs) +func (f *Fs) Root() string { + return f.root.path +} + +// String converts this Fs to a string +func (f *Fs) String() string { + return fmt.Sprintf("Filen %s at /%s", f.filen.Email, f.root.String()) +} + +// Precision return the precision of this Fs +func (f *Fs) Precision() time.Duration { + return time.Millisecond +} + +// Hashes returns the supported hash sets. +func (f *Fs) Hashes() hash.Set { + return hash.Set(hash.SHA512) +} + +// Features returns the optional features of this Fs +func (f *Fs) Features() *fs.Features { + return f.features +} + +// List the objects and directories in dir into entries. The +// entries can be returned in any order but should be for a +// complete directory. +// +// dir should be "" to list the root, and should not have +// trailing slashes. +// +// This should return ErrDirNotFound if the directory isn't +// found. +func (f *Fs) List(ctx context.Context, dir string) (entries fs.DirEntries, err error) { + dir = f.Enc.FromStandardPath(dir) + // find directory uuid + directory, err := f.filen.FindDirectory(ctx, f.resolvePath(dir)) + if err != nil { + return nil, err + } + + if directory == nil { + return nil, fs.ErrorDirNotFound + } + + // read directory content + files, directories, err := f.filen.ReadDirectory(ctx, directory) + if err != nil { + return nil, err + } + entries = make(fs.DirEntries, 0, len(files)+len(directories)) + + for _, directory := range directories { + entries = append(entries, &Directory{ + fs: f, + path: pathModule.Join(dir, directory.Name), + directory: directory, + }) + } + for _, file := range files { + file := &Object{ + fs: f, + path: pathModule.Join(dir, file.Name), + file: file, + } + entries = append(entries, file) + } + return entries, nil +} + +// NewObject finds the Object at remote. If it can't be found +// it returns the error ErrorObjectNotFound. +// +// If remote points to a directory then it should return +// ErrorIsDir if possible without doing any extra work, +// otherwise ErrorObjectNotFound. +func (f *Fs) NewObject(ctx context.Context, remote string) (fs.Object, error) { + remote = f.Enc.FromStandardPath(remote) + file, err := f.filen.FindFile(ctx, f.resolvePath(remote)) + if err != nil { + return nil, err + } + if file == nil { + return nil, fs.ErrorObjectNotFound + } + return &Object{ + fs: f, + path: remote, + file: file, + }, nil +} + +// Put in to the remote path with the modTime given of the given size +// +// When called from outside an Fs by rclone, src.Size() will always be >= 0. +// But for unknown-sized objects (indicated by src.Size() == -1), Put should either +// return an error or upload it properly (rather than e.g. calling panic). +// +// May create the object even if it returns an error - if so +// will return the object and the error, otherwise will return +// nil and the error +func (f *Fs) Put(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) { + for _, option := range options { + if option.Mandatory() { + fs.Logf(option, "Unsupported mandatory option: %v", option) + } + } + path := f.Enc.FromStandardPath(src.Remote()) + resolvedPath := f.resolvePath(path) + modTime := src.ModTime(ctx) + parent, err := f.filen.FindDirectoryOrCreate(ctx, pathModule.Dir(resolvedPath)) + if err != nil { + return nil, err + } + incompleteFile, err := types.NewIncompleteFile(f.filen.FileEncryptionVersion, pathModule.Base(resolvedPath), fs.MimeType(ctx, src), modTime, modTime, parent) + if err != nil { + return nil, err + } + uploadedFile, err := f.filen.UploadFile(ctx, incompleteFile, in) + if err != nil { + return nil, err + } + return &Object{ + fs: f, + path: path, + file: uploadedFile, + }, nil +} + +// PutStream uploads to the remote path with the modTime given of indeterminate size +// +// May create the object even if it returns an error - if so +// will return the object and the error, otherwise will return +// nil and the error +func (f *Fs) PutStream(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) { + return f.Put(ctx, in, src, options...) +} + +type chunkWriter struct { + sdk.FileUpload + filen *sdk.Filen + bucketAndRegion chan client.V3UploadResponse + chunkSize int64 + + chunksLock sync.Mutex + knownChunks map[int][]byte // known chunks to be hashed + nextChunkToHash int + + sizeLock sync.Mutex + size int64 +} + +func (cw *chunkWriter) WriteChunk(ctx context.Context, chunkNumber int, reader io.ReadSeeker) (bytesWritten int64, err error) { + realChunkNumber := int(int64(chunkNumber) * (cw.chunkSize) / sdk.ChunkSize) + chunk := make([]byte, sdk.ChunkSize, sdk.ChunkSize+cw.EncryptionKey.Cipher.Overhead()) + + totalWritten := int64(0) + for sliceStart := 0; sliceStart < int(cw.chunkSize); sliceStart += sdk.ChunkSize { + chunk = chunk[:sdk.ChunkSize] + chunkRead := 0 + for { + read, err := reader.Read(chunk[chunkRead:]) + chunkRead += read + if err == io.EOF || chunkRead == sdk.ChunkSize { + break + } + if err != nil { + return 0, err + } + } + if chunkRead == 0 { + break + } + chunkReadSlice := chunk[:chunkRead] + err = func() error { + cw.chunksLock.Lock() + defer cw.chunksLock.Unlock() + if cw.nextChunkToHash == realChunkNumber { + _, err := cw.Hasher.Write(chunkReadSlice) + if err != nil { + return err + } + cw.nextChunkToHash++ + for ; ; cw.nextChunkToHash++ { + chunk := cw.knownChunks[cw.nextChunkToHash] + if chunk == nil { + break + } + _, err := cw.Hasher.Write(chunk) + if err != nil { + return err + } + delete(cw.knownChunks, cw.nextChunkToHash) + } + } else { + chunkCopy := make([]byte, len(chunkReadSlice)) + copy(chunkCopy, chunkReadSlice) + cw.knownChunks[realChunkNumber] = chunkCopy + } + return nil + }() + if err != nil { + return totalWritten, err + } + resp, err := cw.filen.UploadChunk(ctx, &cw.FileUpload, realChunkNumber, chunkReadSlice) + select { // only care about getting this once + case cw.bucketAndRegion <- *resp: + default: + } + if err != nil { + return totalWritten, err + } + totalWritten += int64(len(chunkReadSlice)) + realChunkNumber++ + } + + cw.sizeLock.Lock() + cw.size += totalWritten + cw.sizeLock.Unlock() + return totalWritten, nil +} + +func (cw *chunkWriter) Close(ctx context.Context) error { + cw.chunksLock.Lock() + defer close(cw.bucketAndRegion) + defer cw.chunksLock.Unlock() + cw.sizeLock.Lock() + size := cw.size + cw.sizeLock.Unlock() + if len(cw.knownChunks) != 0 { + return errors.New("not all chunks have been hashed") + } + _, err := cw.filen.CompleteFileUpload(ctx, &cw.FileUpload, cw.bucketAndRegion, size) + return err +} + +func (cw *chunkWriter) Abort(ctx context.Context) error { + return nil +} + +// OpenChunkWriter returns the chunk size and a ChunkWriter +// +// Pass in the remote and the src object +// You can also use options to hint at the desired chunk size +func (f *Fs) OpenChunkWriter(ctx context.Context, remote string, src fs.ObjectInfo, options ...fs.OpenOption) (info fs.ChunkWriterInfo, writer fs.ChunkWriter, err error) { + path := f.Enc.FromStandardPath(remote) + resolvedPath := f.resolvePath(path) + modTime := src.ModTime(ctx) + + chunkSize := int64(sdk.ChunkSize) + for _, option := range options { + switch x := option.(type) { + case *fs.ChunkOption: + chunkSize = x.ChunkSize + default: + if option.Mandatory() { + fs.Logf(option, "Unsupported mandatory option: %v", option) + } + } + } + + if chunkSize%sdk.ChunkSize != 0 { + return info, nil, errors.New("chunk size must be a multiple of 1MB") + } + + info = fs.ChunkWriterInfo{ + ChunkSize: chunkSize, + Concurrency: f.concurrency, + LeavePartsOnError: false, + } + + parent, err := f.filen.FindDirectoryOrCreate(ctx, pathModule.Dir(resolvedPath)) + if err != nil { + return info, nil, err + } + incompleteFile, err := types.NewIncompleteFile(f.filen.FileEncryptionVersion, pathModule.Base(resolvedPath), fs.MimeType(ctx, src), modTime, modTime, parent) + if err != nil { + return info, nil, err + } + // unused + fu := f.filen.NewFileUpload(incompleteFile) + return info, &chunkWriter{ + FileUpload: *fu, + filen: f.filen, + chunkSize: chunkSize, + bucketAndRegion: make(chan client.V3UploadResponse, 1), + knownChunks: make(map[int][]byte), + nextChunkToHash: 0, + size: 0, + }, nil +} + +// Mkdir makes the directory (container, bucket) +// +// Shouldn't return an error if it already exists +func (f *Fs) Mkdir(ctx context.Context, dir string) error { + dirObj, err := f.filen.FindDirectoryOrCreate(ctx, f.resolvePath(f.Enc.FromStandardPath(dir))) + if err != nil { + return err + } + if dir == f.root.path { + f.root.directory = dirObj + } + return nil +} + +// Rmdir removes the directory (container, bucket) if empty +// +// Return an error if it doesn't exist or isn't empty +func (f *Fs) Rmdir(ctx context.Context, dir string) error { + // find directory + resolvedPath := f.resolvePath(f.Enc.FromStandardPath(dir)) + //if resolvedPath == f.root.path { + // return fs.ErrorDirNotFound + //} + directory, err := f.filen.FindDirectory(ctx, resolvedPath) + if err != nil { + return err + } + if directory == nil { + return errors.New("directory not found") + } + + files, dirs, err := f.filen.ReadDirectory(ctx, directory) + if err != nil { + return err + } + if len(files) > 0 || len(dirs) > 0 { + return errors.New("directory is not empty") + } + + // trash directory + err = f.filen.TrashDirectory(ctx, directory) + if err != nil { + return err + } + return nil +} + +// Directory is Filen's directory type +type Directory struct { + fs *Fs + path string + directory types.DirectoryInterface +} + +// Fs returns read only access to the Fs that this object is part of +func (dir *Directory) Fs() fs.Info { + return dir.fs +} + +// String returns a description of the Object +func (dir *Directory) String() string { + if dir == nil { + return "" + } + return dir.Remote() +} + +// Remote returns the remote path +func (dir *Directory) Remote() string { + return dir.fs.Enc.ToStandardPath(dir.path) +} + +// ModTime returns the modification date of the file +// It should return a best guess if one isn't available +func (dir *Directory) ModTime(ctx context.Context) time.Time { + directory, ok := dir.directory.(*types.Directory) + if !ok { + return time.Time{} // todo add account creation time? + } + + if directory.Created.IsZero() { + obj, err := dir.fs.filen.FindDirectory(ctx, dir.fs.resolvePath(dir.path)) + newDir, ok := obj.(*types.Directory) + if err != nil || !ok { + return time.Now() + } + directory = newDir + dir.directory = newDir + } + return directory.Created +} + +// Size returns the size of the file +// +// filen doesn't have an efficient way to find the size of a directory +func (dir *Directory) Size() int64 { + return -1 +} + +// Items returns the count of items in this directory or this +// directory and subdirectories if known, -1 for unknown +func (dir *Directory) Items() int64 { + return -1 +} + +// ID returns the internal ID of this directory if known, or +// "" otherwise +func (dir *Directory) ID() string { + return dir.directory.GetUUID() +} + +// Object is Filen's normal file +type Object struct { + fs *Fs + path string + file *types.File + isMoved bool +} + +// Fs returns read only access to the Fs that this object is part of +func (o *Object) Fs() fs.Info { + return o.fs +} + +// String returns a description of the Object +func (o *Object) String() string { + if o == nil { + return "" + } + return o.Remote() +} + +// Remote returns the remote path +func (o *Object) Remote() string { + return o.fs.Enc.ToStandardPath(o.path) +} + +// ModTime returns the modification date of the file +// It should return a best guess if one isn't available +func (o *Object) ModTime(ctx context.Context) time.Time { + if o.file.LastModified.IsZero() { + newFile, err := o.fs.filen.FindFile(ctx, o.fs.resolvePath(o.path)) + if err == nil && newFile != nil { + o.file = newFile + } + } + return o.file.LastModified +} + +// Size returns the size of the file +func (o *Object) Size() int64 { + return o.file.Size +} + +// Hash returns the selected checksum of the file +// If no checksum is available it returns "" +func (o *Object) Hash(ctx context.Context, ty hash.Type) (string, error) { + if ty != hash.SHA512 { + return "", hash.ErrUnsupported + } + if o.file.Hash == "" { + foundFile, err := o.fs.filen.FindFile(ctx, o.fs.resolvePath(o.path)) + if err != nil { + return "", err + } + if foundFile == nil { + return "", fs.ErrorObjectNotFound + } + o.file = foundFile + } + return o.file.Hash, nil +} + +// Storable says whether this object can be stored +func (o *Object) Storable() bool { + return true +} + +// SetModTime sets the metadata on the object to set the modification date +func (o *Object) SetModTime(ctx context.Context, t time.Time) error { + o.file.LastModified = t + return o.fs.filen.UpdateMeta(ctx, o.file) +} + +// Open opens the file for read. Call Close() on the returned io.ReadCloser +func (o *Object) Open(ctx context.Context, options ...fs.OpenOption) (io.ReadCloser, error) { + fs.FixRangeOption(options, o.Size()) + // Create variables to hold our options + var offset int64 + var limit int64 = -1 // -1 means no limit + + // Parse the options + for _, option := range options { + switch opt := option.(type) { + case *fs.RangeOption: + offset = opt.Start + limit = opt.End + 1 // +1 because End is inclusive + case *fs.SeekOption: + offset = opt.Offset + default: + if option.Mandatory() { + fs.Logf(option, "Unsupported mandatory option: %v", option) + } + } + } + + // Get the base reader + readCloser := o.fs.filen.GetDownloadReaderWithOffset(ctx, o.file, offset, limit) + return readCloser, nil +} + +// Update in to the object with the modTime given of the given size +// +// When called from outside an Fs by rclone, src.Size() will always be >= 0. +// But for unknown-sized objects (indicated by src.Size() == -1), Upload should either +// return an error or update the object properly (rather than e.g. calling panic). +func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) error { + for _, option := range options { + if option.Mandatory() { + fs.Logf(option, "Unsupported mandatory option: %v", option) + } + } + newModTime := src.ModTime(ctx) + newIncomplete, err := o.file.NewFromBase(o.fs.filen.FileEncryptionVersion) + if err != nil { + return err + } + newIncomplete.LastModified = newModTime + newIncomplete.Created = newModTime + newIncomplete.SetMimeType(fs.MimeType(ctx, src)) + uploadedFile, err := o.fs.filen.UploadFile(ctx, newIncomplete, in) + if err != nil { + return err + } + o.file = uploadedFile + return nil +} + +// Remove this object +func (o *Object) Remove(ctx context.Context) error { + if o.isMoved { // doesn't exist at this path + return nil + } + err := o.fs.filen.TrashFile(ctx, *o.file) + if err != nil { + return err + } + return nil +} + +// MimeType returns the content type of the Object if +// known, or "" if not +func (o *Object) MimeType(_ context.Context) string { + return o.file.MimeType +} + +// ID returns the ID of the Object if known, or "" if not +func (o *Object) ID() string { + return o.file.GetUUID() +} + +// ParentID returns the ID of the parent directory if known or nil if not +func (o *Object) ParentID() string { + return o.file.GetParent() +} + +// Purge all files in the directory specified +// +// Implement this if you have a way of deleting all the files +// quicker than just running Remove() on the result of List() +// +// Return an error if it doesn't exist +func (f *Fs) Purge(ctx context.Context, dir string) error { + path := f.resolvePath(f.Enc.FromStandardPath(dir)) + foundDir, err := f.filen.FindDirectory(ctx, path) + if err != nil { + return err + } else if foundDir == nil { + return fs.ErrorDirNotFound + } + return f.filen.TrashDirectory(ctx, foundDir) +} + +// 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) { + obj, ok := src.(*Object) + if !ok { + return nil, fmt.Errorf("can't move %T: %w", src, fs.ErrorCantMove) + } + newRemote := f.Enc.FromStandardPath(remote) + oldPath, newPath := obj.fs.resolvePath(f.Enc.FromStandardPath(src.Remote())), f.resolvePath(newRemote) + oldParentPath, newParentPath := pathModule.Dir(oldPath), pathModule.Dir(newPath) + oldName, newName := pathModule.Base(oldPath), pathModule.Base(newPath) + if oldPath == newPath { + return nil, fs.ErrorCantMove + } + err := f.filen.Lock(ctx) + if err != nil { + return nil, err + } + defer f.filen.Unlock() + if oldParentPath == newParentPath { + err = f.rename(ctx, obj.file, newPath, newName) + } else if newName == oldName { + err = f.move(ctx, obj.file, newPath, newParentPath) + } else { + err = f.moveWithRename(ctx, obj.file, oldPath, oldName, newPath, newParentPath, newName) + } + if err != nil { + return nil, err + } + return moveFileObjIntoNewPath(obj, newRemote), nil +} + +// moveWithRename moves item to newPath +// using a more complex set of operations designed to handle the fact that +// Filen doesn't support a single moveRename operation +// which requires some annoying hackery to get around reliably +func (f *Fs) moveWithRename(ctx context.Context, item types.NonRootFileSystemObject, oldPath, oldName, newPath, newParentPath, newName string) error { + g, gCtx := errgroup.WithContext(ctx) + var ( + newParentDir types.DirectoryInterface + renamedToUUID bool + ) + + // rename to random UUID first + g.Go(func() error { + err := f.filen.Rename(gCtx, item, uuid.NewString()) + if err != nil { + return fmt.Errorf("failed to rename file: %w : %w", err, fs.ErrorCantMove) + } + renamedToUUID = true + return nil + }) + defer func() { + // safety to try and not leave the item in a bad state + if renamedToUUID { + err := f.filen.Rename(ctx, item, oldName) + if err != nil { + fmt.Printf("ERROR: FAILED TO REVERT UUID RENAME for file %s: %s", oldPath, err) + } + } + }() + + // find parent dir + g.Go(func() error { + var err error + newParentDir, err = f.filen.FindDirectoryOrCreate(gCtx, newParentPath) + return err + }) + + if err := g.Wait(); err != nil { + return err + } + + // move + oldParentUUID := item.GetParent() + err := f.filen.MoveItem(ctx, item, newParentDir.GetUUID(), true) + if err != nil { + return fmt.Errorf("failed to move file: %w : %w", err, fs.ErrorCantMove) + } + defer func() { + // safety to try and not leave the item in a bad state + if renamedToUUID { + err := f.filen.MoveItem(ctx, item, oldParentUUID, true) + if err != nil { + fmt.Printf("ERROR: FAILED TO REVERT MOVE for file %s: %s", oldPath, err) + } + } + }() + + // rename to final name + err = f.filen.Rename(ctx, item, newName) + if err != nil { + return fmt.Errorf("failed to rename file: %w : %w", err, fs.ErrorCantMove) + } + renamedToUUID = false + + return nil +} + +// move moves item to newPath +// by finding the parent and calling moveWithParentUUID +func (f *Fs) move(ctx context.Context, item types.NonRootFileSystemObject, newPath, newParentPath string) error { + newParentDir, err := f.filen.FindDirectoryOrCreate(ctx, newParentPath) + if err != nil { + return fmt.Errorf("failed to find or create directory: %w : %w", err, fs.ErrorCantMove) + } + return f.moveWithParentUUID(ctx, item, newParentDir.GetUUID()) +} + +// moveWithParentUUID moves item to newParentUUID +// using a simple filen.MoveItem operation +func (f *Fs) moveWithParentUUID(ctx context.Context, item types.NonRootFileSystemObject, newParentUUID string) error { + err := f.filen.MoveItem(ctx, item, newParentUUID, true) + if err != nil { + return fmt.Errorf("failed to move file: %w : %w", err, fs.ErrorCantMove) + } + + return nil +} + +// rename moves item to newPath +// using a simple Filen rename operation +func (f *Fs) rename(ctx context.Context, item types.NonRootFileSystemObject, newPath string, newName string) error { + err := f.filen.Rename(ctx, item, newName) + if err != nil { + return fmt.Errorf("failed to rename item: %w : %w", err, fs.ErrorCantMove) + } + return nil +} + +// moveFileObjIntoNewPath 'moves' an existing object into a new path +// invalidating the previous object +// and making a copy with the passed path +// +// this is to work around the fact that rclone expects to have to delete a file after moving +func moveFileObjIntoNewPath(o *Object, newPath string) *Object { + newFile := &Object{ + fs: o.fs, + path: newPath, + file: o.file, + } + o.isMoved = true + return newFile +} + +// 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 { + + srcF, ok := src.(*Fs) + if !ok || srcF == nil { + return fs.ErrorCantDirMove + } + err := f.filen.Lock(ctx) + if err != nil { + return err + } + defer f.filen.Unlock() + g, gCtx := errgroup.WithContext(ctx) + var ( + srcDirInt types.DirectoryInterface + dstDir types.DirectoryInterface + srcPath = srcF.resolvePath(srcF.Enc.FromStandardPath(srcRemote)) + dstPath = f.resolvePath(f.Enc.FromStandardPath(dstRemote)) + ) + if srcPath == dstPath { + return fs.ErrorDirExists + } + + g.Go(func() error { + var err error + srcDirInt, err = srcF.filen.FindDirectory(gCtx, srcPath) + return err + }) + g.Go(func() error { + var err error + dstDir, err = f.filen.FindDirectory(gCtx, dstPath) + return err + }) + + if err := g.Wait(); err != nil { + return err + } + + if srcDirInt == nil { + return fs.ErrorDirNotFound + } + + if dstDir != nil { + return fs.ErrorDirExists + } + + srcDir, ok := srcDirInt.(*types.Directory) + if !ok { + return fs.ErrorCantDirMove + } + + return f.dirMoveEntireDir(ctx, srcDir, srcPath, dstPath) +} + +// dirMoveEntireDir moves srcDir to newPath +// used for the case where the target directory doesn't exist +func (f *Fs) dirMoveEntireDir(ctx context.Context, srcDir *types.Directory, oldPath string, newPath string) error { + oldParentPath, newParentPath := pathModule.Dir(oldPath), pathModule.Dir(newPath) + oldName, newName := pathModule.Base(oldPath), pathModule.Base(newPath) + var err error + if oldPath == newPath { + return fs.ErrorDirExists + } else if oldParentPath == newParentPath { + err = f.rename(ctx, srcDir, newPath, newName) + } else if newName == oldName { + err = f.move(ctx, srcDir, newPath, newParentPath) + } else { + err = f.moveWithRename(ctx, srcDir, oldPath, oldName, newPath, newParentPath, newName) + } + if err != nil { + return err + } + return err +} + +// ListR lists the objects and directories of the Fs starting +// from dir recursively into out. +// +// dir should be "" to start from the root, and should not +// have trailing slashes. +// +// This should return ErrDirNotFound if the directory isn't +// found. +// +// It should call callback for each tranche of entries read. +// These need not be returned in any particular order. If +// callback returns an error then the listing will stop +// immediately. +// +// Don't implement this unless you have a more efficient way +// of listing recursively that doing a directory traversal. +func (f *Fs) ListR(ctx context.Context, dir string, callback fs.ListRCallback) (err error) { + basePath := f.Enc.FromStandardPath(dir) + path := f.resolvePath(basePath) + foundDir, err := f.filen.FindDirectory(ctx, path) + if err != nil { + return err + } + if foundDir == nil { + return fs.ErrorDirNotFound + } + + files, dirs, err := f.filen.ListRecursive(ctx, foundDir) + if err != nil { + return err + } + listHelper := list.NewHelper(callback) + // have to build paths + uuidDirMap, uuidPathMap := buildUUIDDirMaps(basePath, foundDir, dirs) + + for _, dir := range dirs { + path, err := getPathForUUID(dir.GetUUID(), uuidPathMap, uuidDirMap) + if err != nil { + return err + } + err = listHelper.Add(&Directory{ + fs: f, + directory: dir, + path: path, + }) + if err != nil { + return err + } + } + + for _, file := range files { + parentPath, err := getPathForUUID(file.GetParent(), uuidPathMap, uuidDirMap) + if err != nil { + return err + } + err = listHelper.Add(&Object{ + fs: f, + file: file, + path: pathModule.Join(parentPath, file.GetName()), + }) + if err != nil { + return err + } + } + return listHelper.Flush() +} + +func buildUUIDDirMaps(rootPath string, rootDir types.DirectoryInterface, dirs []*types.Directory) (map[string]types.DirectoryInterface, map[string]string) { + uuidPathMap := make(map[string]string, len(dirs)+1) + uuidPathMap[rootDir.GetUUID()] = rootPath + + uuidDirMap := make(map[string]types.DirectoryInterface, len(dirs)+1) + uuidDirMap[rootDir.GetUUID()] = rootDir + for _, dir := range dirs { + uuidDirMap[dir.GetUUID()] = dir + } + return uuidDirMap, uuidPathMap +} + +func getPathForUUID(uuid string, uuidPathMap map[string]string, uuidDirMap map[string]types.DirectoryInterface) (string, error) { + if path, ok := uuidPathMap[uuid]; ok { + return path, nil + } + dir, ok := uuidDirMap[uuid] + if !ok { + return "", fs.ErrorDirNotFound + } + parentPath, err := getPathForUUID(dir.GetParent(), uuidPathMap, uuidDirMap) + if err != nil { + return "", err + } + path := pathModule.Join(parentPath, dir.GetName()) + uuidPathMap[uuid] = path + return path, nil +} + +// About gets quota information from the Fs +func (f *Fs) About(ctx context.Context) (*fs.Usage, error) { + userInfo, err := f.filen.GetUserInfo(ctx) + if err != nil { + return nil, err + } + + total := int64(userInfo.MaxStorage) + used := int64(userInfo.UsedStorage) + free := total - used + return &fs.Usage{ + Total: &total, + Used: &used, + Trashed: nil, + Other: nil, + Free: &free, + Objects: nil, + }, nil +} + +// CleanUp the trash in the Fs +func (f *Fs) CleanUp(ctx context.Context) error { + // not sure if this is implemented correctly, since this trashes ALL trash + // not just the trash in the currently mounted fs + // not currently wiping file versions because that feels dangerous + // especially since versioning can be toggled on/off + return f.filen.EmptyTrash(ctx) +} + +// helpers + +// resolvePath returns the absolute path specified by the input path, which is seen relative to the remote's root. +func (f *Fs) resolvePath(path string) string { + return pathModule.Join(f.root.path, path) +} + +// Check the interfaces are satisfied +var ( + _ fs.Fs = &Fs{} + _ fs.Mover = &Fs{} + _ fs.DirMover = &Fs{} + _ fs.Purger = &Fs{} + _ fs.PutStreamer = &Fs{} + _ fs.CleanUpper = &Fs{} + _ fs.ListRer = &Fs{} + _ fs.Abouter = &Fs{} + _ fs.OpenChunkWriter = &Fs{} + _ fs.Directory = &Directory{} + _ fs.Object = &Object{} + _ fs.MimeTyper = &Object{} + _ fs.IDer = &Object{} + _ fs.ParentIDer = &Object{} + _ fs.ChunkWriter = &chunkWriter{} +) + +// todo PublicLinker, +// we could technically implement ChangeNotifier, but +// 1) the current implementation on Filen's side isn't great, it's worth waiting until SSE +// 2) I'm not really clear that the benefits are so great +// a bunch of the information would get wasted, since the Filen does actually specify exact updates, +// whereas rclone seems to only accept a path and object type diff --git a/backend/filen/filen_test.go b/backend/filen/filen_test.go new file mode 100644 index 000000000..808456274 --- /dev/null +++ b/backend/filen/filen_test.go @@ -0,0 +1,14 @@ +package filen + +import ( + "testing" + + "github.com/rclone/rclone/fstest/fstests" +) + +func TestIntegration(t *testing.T) { + fstests.Run(t, &fstests.Opt{ + RemoteName: "TestFilen:", + NilObject: (*Object)(nil), + }) +} diff --git a/bin/make_manual.py b/bin/make_manual.py index e4593b213..936a23923 100755 --- a/bin/make_manual.py +++ b/bin/make_manual.py @@ -47,6 +47,7 @@ docs = [ "dropbox.md", "filefabric.md", "filelu.md", + "filen.md", "filescom.md", "ftp.md", "gofile.md", diff --git a/docs/content/_index.md b/docs/content/_index.md index 16cc1d9e4..1b3fdfb37 100644 --- a/docs/content/_index.md +++ b/docs/content/_index.md @@ -136,6 +136,7 @@ WebDAV or S3, that work out of the box.) {{< provider name="Fastmail Files" home="https://www.fastmail.com/" config="/webdav/#fastmail-files" >}} {{< provider name="FileLu Cloud Storage" home="https://filelu.com/" config="/filelu/" >}} {{< provider name="FileLu S5 (S3-Compatible Object Storage)" home="https://s5lu.com/" config="/s3/#filelu-s5" >}} +{{< provider name="Filen" home="https://www.filen.io/" config="/filen/" >}} {{< provider name="Files.com" home="https://www.files.com/" config="/filescom/" >}} {{< 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/" >}} diff --git a/docs/content/docs.md b/docs/content/docs.md index 780573626..e71e95d2f 100644 --- a/docs/content/docs.md +++ b/docs/content/docs.md @@ -47,6 +47,7 @@ See the following for detailed instructions for - [Dropbox](/dropbox/) - [Enterprise File Fabric](/filefabric/) - [FileLu Cloud Storage](/filelu/) +- [Filen](/filen/) - [Files.com](/filescom/) - [FTP](/ftp/) - [Gofile](/gofile/) diff --git a/docs/content/filen.md b/docs/content/filen.md new file mode 100644 index 000000000..4c6e2b2a1 --- /dev/null +++ b/docs/content/filen.md @@ -0,0 +1,244 @@ +--- +title: "Filen" +description: "Rclone docs for Filen" +versionIntroduced: "1.73" +--- + +# {{< icon "fa fa-solid fa-f" >}} Filen +## Configuration +The initial setup for Filen requires that you get an API key for your account, +currently this is only possible using the [Filen CLI](https://github.com/FilenCloudDienste/filen-cli). +This means you must first download the CLI, login, and then run the `export-api-key` command. + +Here is an example of how to make a remote called `FilenRemote`. 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> FilenRemote +Option Storage. + +Type of storage to configure. +Choose a number from below, or type in your own value. +[snip] +XX / Filen + \ "filen" +[snip] +Storage> filen + +Option Email. +The email of your Filen account +Enter a value. +Email> youremail@provider.com + +Option Password. +The password of your Filen account +Choose an alternative below. +y) Yes, type in my own password +g) Generate random password +y/g> y +Enter the password: +password: +Confirm the password: +password: + +Option API Key. +An API Key for your Filen account +Get this using the Filen CLI export-api-key command +You can download the Filen CLI from https://github.com/FilenCloudDienste/filen-cli +Choose an alternative below. +y) Yes, type in my own password +g) Generate random password +y/g> y +Enter the password: +password: +Confirm the password: +password: + +Edit advanced config? +y) Yes +n) No (default) +y/n> n + +Configuration complete. +Options: +- type: filen +- Email: youremail@provider.com +- Password: *** ENCRYPTED *** +- API Key: *** ENCRYPTED *** +Keep this "FilenRemote" remote? +y) Yes this is OK (default) +e) Edit this remote +d) Delete this remote +y/e/d> y +``` + +### Modification times and hashes +Modification times are fully supported for files, for directories, only the creation time matters. + +Filen supports SHA512 hashes. + +### Restricted filename characters +Invalid UTF-8 bytes will be [replaced](/overview/#invalid-utf8) + + +### API Key + +{{< rem autogenerated options start" - DO NOT EDIT - instead edit fs.RegInfo in backend/filen/filen.go then run make backenddocs" >}} +### Standard options + +Here are the Standard options specific to filen (Filen). + +#### --filen-email + +Email of your Filen account + +Properties: + +- Config: email +- Env Var: RCLONE_FILEN_EMAIL +- Type: string +- Required: true + +#### --filen-password + +Password of your Filen account + +**NB** Input to this must be obscured - see [rclone obscure](/commands/rclone_obscure/). + +Properties: + +- Config: password +- Env Var: RCLONE_FILEN_PASSWORD +- Type: string +- Required: true + +#### --filen-api-key + +API Key for your Filen account + +Get this using the Filen CLI export-api-key command +You can download the Filen CLI from https://github.com/FilenCloudDienste/filen-cli + +**NB** Input to this must be obscured - see [rclone obscure](/commands/rclone_obscure/). + +Properties: + +- Config: api_key +- Env Var: RCLONE_FILEN_API_KEY +- Type: string +- Required: true + +### Advanced options + +Here are the Advanced options specific to filen (Filen). + +#### --filen-encoding + +The encoding for the backend. + +See the [encoding section in the overview](/overview/#encoding) for more info. + +Properties: + +- Config: encoding +- Env Var: RCLONE_FILEN_ENCODING +- Type: Encoding +- Default: Slash,Del,Ctl,InvalidUtf8,Dot + +#### --filen-upload-concurrency + +Concurrency for multipart uploads. + +This is the number of chunks of the same file that are uploaded +concurrently for multipart uploads. + +Note that chunks are stored in memory and there may be up to +"--transfers" * "--filen-upload-concurrency" chunks stored at once +in memory. + +If you are uploading small numbers of large files over high-speed links +and these uploads do not fully utilize your bandwidth, then increasing +this may help to speed up the transfers. + +Properties: + +- Config: upload_concurrency +- Env Var: RCLONE_FILEN_UPLOAD_CONCURRENCY +- Type: int +- Default: 16 + +#### --filen-master-keys + +Master Keys (internal use only) + +Properties: + +- Config: master_keys +- Env Var: RCLONE_FILEN_MASTER_KEYS +- Type: string +- Required: false + +#### --filen-private-key + +Private RSA Key (internal use only) + +Properties: + +- Config: private_key +- Env Var: RCLONE_FILEN_PRIVATE_KEY +- Type: string +- Required: false + +#### --filen-public-key + +Public RSA Key (internal use only) + +Properties: + +- Config: public_key +- Env Var: RCLONE_FILEN_PUBLIC_KEY +- Type: string +- Required: false + +#### --filen-auth-version + +Authentication Version (internal use only) + +Properties: + +- Config: auth_version +- Env Var: RCLONE_FILEN_AUTH_VERSION +- Type: string +- Required: false + +#### --filen-base-folder-uuid + +UUID of Account Root Directory (internal use only) + +Properties: + +- Config: base_folder_uuid +- Env Var: RCLONE_FILEN_BASE_FOLDER_UUID +- Type: string +- Required: false + +#### --filen-description + +Description of the remote. + +Properties: + +- Config: description +- Env Var: RCLONE_FILEN_DESCRIPTION +- Type: string +- Required: false + +{{< rem autogenerated options stop >}} diff --git a/docs/content/overview.md b/docs/content/overview.md index 52a244e91..d3bd1e534 100644 --- a/docs/content/overview.md +++ b/docs/content/overview.md @@ -27,6 +27,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 | - | | FileLu Cloud Storage | MD5 | R/W | No | Yes | R | - | +| Filen | SHA512 | R/W | Yes | No | R/W | - | | Files.com | MD5, CRC32 | DR/W | Yes | No | R | - | | FTP | - | R/W ¹⁰ | No | No | - | - | | Gofile | MD5 | DR/W | No | Yes | R | - | @@ -520,6 +521,7 @@ upon backend-specific capabilities. | Dropbox | Yes | Yes | Yes | Yes | No | No | Yes | No | Yes | Yes | Yes | | Cloudinary | No | No | No | No | No | No | Yes | No | No | No | No | | Enterprise File Fabric | Yes | Yes | Yes | Yes | Yes | No | No | No | No | No | Yes | +| Filen | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | No | Yes | Yes | | Files.com | Yes | Yes | Yes | Yes | No | No | Yes | No | Yes | No | Yes | | FTP | No | No | Yes | Yes | No | No | Yes | No | No | No | Yes | | Gofile | Yes | Yes | Yes | Yes | No | No | Yes | No | Yes | Yes | Yes | diff --git a/docs/layouts/chrome/navbar.html b/docs/layouts/chrome/navbar.html index b6b617ea3..b17a8cc39 100644 --- a/docs/layouts/chrome/navbar.html +++ b/docs/layouts/chrome/navbar.html @@ -71,6 +71,7 @@ Enterprise File Fabric FileLu Cloud Storage FileLu S5 (S3-Compatible) + Filen Files.com FTP Gofile diff --git a/fstest/test_all/config.yaml b/fstest/test_all/config.yaml index d0ddcbd10..2371d4b42 100644 --- a/fstest/test_all/config.yaml +++ b/fstest/test_all/config.yaml @@ -164,6 +164,9 @@ backends: - backend: "gofile" remote: "TestGoFile:" fastlist: true + - backend: "filen" + remote: "TestFilen:" + fastlist: false - backend: "filescom" remote: "TestFilesCom:" fastlist: false diff --git a/go.mod b/go.mod index 3440bb78f..e82cb7405 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.3 github.com/Azure/go-ntlmssp v0.0.2-0.20251110135918-10b7b7e7cd26 + github.com/FilenCloudDienste/filen-sdk-go v0.0.34 github.com/Files-com/files-sdk-go/v3 v3.2.264 github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd github.com/a1ex3/zstd-seekable-format-go/pkg v0.10.0 @@ -155,6 +156,7 @@ require ( github.com/cronokirby/saferith v0.33.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect + github.com/dromara/dongle v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.9.1 // indirect github.com/emersion/go-message v0.18.2 // indirect diff --git a/go.sum b/go.sum index 30e0aa049..92ec04240 100644 --- a/go.sum +++ b/go.sum @@ -61,6 +61,8 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgv github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/FilenCloudDienste/filen-sdk-go v0.0.34 h1:Fd/wagh/Qn35p3PkCUYubmaELATQlYGC9pxpJ9TkHUE= +github.com/FilenCloudDienste/filen-sdk-go v0.0.34/go.mod h1:XkI1Iq30/tU8vk4Zd1cKr2cCTiFqBEf0ZfG4+KKUBrY= github.com/Files-com/files-sdk-go/v3 v3.2.264 h1:lMHTplAYI9FtmCo/QOcpRxmPA5REVAct1r2riQmDQKw= github.com/Files-com/files-sdk-go/v3 v3.2.264/go.mod h1:wGqkOzRu/ClJibvDgcfuJNAqI2nLhe8g91tPlDKRCdE= github.com/IBM/go-sdk-core/v5 v5.21.0 h1:DUnYhvC4SoC8T84rx5omnhY3+xcQg/Whyoa3mDPIMkk= @@ -232,6 +234,8 @@ github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/dop251/scsu v0.0.0-20220106150536-84ac88021d00 h1:xJBhC00smQpSZw3Kr0ErMUBXhUSjYoLRm2szxdbRBL0= github.com/dop251/scsu v0.0.0-20220106150536-84ac88021d00/go.mod h1:nNICngOdmNImBb/vuL+dSc0aIg3ryNATpjxThNoPw4g= +github.com/dromara/dongle v1.0.1 h1:si/7UP/EXxnFVZok1cNos70GiMGxInAYMilHQFP5dJs= +github.com/dromara/dongle v1.0.1/go.mod h1:ebFhTaDgxaDIKppycENTWlBsxz8mWCPWOLnsEgDpMv4= github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5 h1:FT+t0UEDykcor4y3dMVKXIiWJETBpRgERYTGlmMd7HU= github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5/go.mod h1:rSS3kM9XMzSQ6pw91Qgd6yB5jdt70N4OdtrAf74As5M= github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4= @@ -249,6 +253,7 @@ github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7 github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff h1:4N8wnS3f1hNHSmFD5zgFkWCyA4L1kCDkImPAtK7D6tg= github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= +github.com/emmansun/gmsm v0.15.5/go.mod h1:2m4jygryohSWkaSduFErgCwQKab5BNjURoFrn2DNwyU= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -748,6 +753,7 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= @@ -828,6 +834,7 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= @@ -904,6 +911,7 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -916,6 +924,7 @@ golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= @@ -932,6 +941,7 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=