From 5f71d186b20b34f0ef53f6295027dab42ed91b25 Mon Sep 17 00:00:00 2001 From: Fred Date: Mon, 18 May 2020 22:56:54 +0100 Subject: [PATCH] seafile: implement 2FA --- backend/seafile/seafile.go | 166 ++++++++++++++++++++++++++++--------- backend/seafile/webapi.go | 137 ++++++++++++++++-------------- docs/content/seafile.md | 137 +++++++++++++++++++++++++++--- 3 files changed, 329 insertions(+), 111 deletions(-) diff --git a/backend/seafile/seafile.go b/backend/seafile/seafile.go index 4c152a21b..024f04957 100644 --- a/backend/seafile/seafile.go +++ b/backend/seafile/seafile.go @@ -32,8 +32,16 @@ import ( ) const ( - librariesCacheKey = "all" - retryAfterHeader = "Retry-After" + librariesCacheKey = "all" + retryAfterHeader = "Retry-After" + configURL = "url" + configUser = "user" + configPassword = "pass" + config2FA = "2fa" + configLibrary = "library" + configLibraryKey = "library_key" + configCreateLibrary = "create_library" + configAuthToken = "auth_token" ) // This is global to all instances of fs @@ -49,8 +57,9 @@ func init() { Name: "seafile", Description: "seafile", NewFs: NewFs, + Config: Config, Options: []fs.Option{{ - Name: "url", + Name: configURL, Help: "URL of seafile host to connect to", Required: true, Examples: []fs.OptionExample{{ @@ -58,26 +67,35 @@ func init() { Help: "Connect to cloud.seafile.com", }}, }, { - Name: "user", - Help: "User name", + Name: configUser, + Help: "User name (usually email address)", Required: true, }, { - Name: "pass", + // Password is not required, it will be left blank for 2FA + Name: configPassword, Help: "Password", IsPassword: true, - Required: true, }, { - Name: "library", + Name: config2FA, + Help: "Two-factor authentication ('true' if the account has 2FA enabled)", + Default: false, + }, { + Name: configLibrary, Help: "Name of the library. Leave blank to access all non-encrypted libraries.", }, { - Name: "library_key", + Name: configLibraryKey, Help: "Library password (for encrypted libraries only). Leave blank if you pass it through the command line.", IsPassword: true, }, { - Name: "create_library", - Help: "Should create library if it doesn't exist", + Name: configCreateLibrary, + Help: "Should rclone create a library if it doesn't exist", Advanced: true, Default: false, + }, { + // Keep the authentication token after entering the 2FA code + Name: configAuthToken, + Help: "Authentication token", + Hide: fs.OptionHideBoth, }, { Name: config.ConfigEncoding, Help: config.ConfigEncodingHelp, @@ -97,6 +115,8 @@ type Options struct { URL string `config:"url"` User string `config:"user"` Password string `config:"pass"` + Is2FA bool `config:"2fa"` + AuthToken string `config:"auth_token"` LibraryName string `config:"library"` LibraryKey string `config:"library_key"` CreateLibrary bool `config:"create_library"` @@ -205,10 +225,16 @@ func NewFs(name, root string, m configmap.Mapper) (fs.Fs, error) { f.moveDirNotAvailable = true } - err = f.authorizeAccount(ctx) - if err != nil { - return nil, err + // Take the authentication token from the configuration first + token := f.opt.AuthToken + if token == "" { + // If not available, send the user/password instead + token, err = f.authorizeAccount(ctx) + if err != nil { + return nil, err + } } + f.setAuthorizationToken(token) if f.libraryName != "" { // Check if the library exists @@ -270,26 +296,108 @@ func NewFs(name, root string, m configmap.Mapper) (fs.Fs, error) { return f, nil } +// Config callback for 2FA +func Config(name string, m configmap.Mapper) { + serverURL, ok := m.Get(configURL) + if !ok || serverURL == "" { + // If there's no server URL, it means we're trying an operation at the backend level, like a "rclone authorize seafile" + fmt.Print("\nOperation not supported on this remote.\nIf you need a 2FA code on your account, use the command:\n\nrclone config reconnect :\n\n") + return + } + + // Stop if we are running non-interactive config + if fs.Config.AutoConfirm { + return + } + + u, err := url.Parse(serverURL) + if err != nil { + fs.Errorf(nil, "Invalid server URL %s", serverURL) + return + } + + is2faEnabled, _ := m.Get(config2FA) + if is2faEnabled != "true" { + fmt.Println("Two-factor authentication is not enabled on this account.") + return + } + + username, _ := m.Get(configUser) + if username == "" { + fs.Errorf(nil, "A username is required") + return + } + + password, _ := m.Get(configPassword) + if password != "" { + password, _ = obscure.Reveal(password) + } + // Just make sure we do have a password + for password == "" { + fmt.Print("Two-factor authentication: please enter your password (it won't be saved in the configuration)\npassword> ") + password = config.ReadPassword() + } + + // Create rest client for getAuthorizationToken + url := u.String() + if !strings.HasPrefix(url, "/") { + url += "/" + } + srv := rest.NewClient(fshttp.NewClient(fs.Config)).SetRoot(url) + + // We loop asking for a 2FA code + for { + code := "" + for code == "" { + fmt.Print("Two-factor authentication: please enter your 2FA code\n2fa code> ") + code = config.ReadLine() + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + fmt.Println("Authenticating...") + token, err := getAuthorizationToken(ctx, srv, username, password, code) + if err != nil { + fmt.Printf("Authentication failed: %v\n", err) + tryAgain := strings.ToLower(config.ReadNonEmptyLine("Do you want to try again (y/n)?")) + if tryAgain != "y" && tryAgain != "yes" { + // The user is giving up, we're done here + break + } + } + if token != "" { + fmt.Println("Success!") + // Let's save the token into the configuration + m.Set(configAuthToken, token) + // And delete any previous entry for password + m.Set(configPassword, "") + config.SaveConfig() + // And we're done here + break + } + } +} + // sets the AuthorizationToken up func (f *Fs) setAuthorizationToken(token string) { f.srv.SetHeader("Authorization", "Token "+token) } // authorizeAccount gets the auth token. -func (f *Fs) authorizeAccount(ctx context.Context) error { +func (f *Fs) authorizeAccount(ctx context.Context) (string, error) { f.authMu.Lock() defer f.authMu.Unlock() + token, err := f.getAuthorizationToken(ctx) if err != nil { - return err + return "", err } - f.setAuthorizationToken(token) - return nil + return token, nil } // retryErrorCodes is a slice of error codes that we will retry var retryErrorCodes = []int{ - 401, // Unauthorized (eg "Token has expired") 408, // Request Timeout 429, // Rate exceeded. 500, // Get occasional 500 Internal Server Error @@ -298,9 +406,9 @@ var retryErrorCodes = []int{ 520, // Operation failed (We get them sometimes when running tests in parallel) } -// shouldRetryNoAuth returns a boolean as to whether this resp and err +// shouldRetry returns a boolean as to whether this resp and err // deserve to be retried. It returns the err as a convenience -func (f *Fs) shouldRetryNoReauth(resp *http.Response, err error) (bool, error) { +func (f *Fs) shouldRetry(resp *http.Response, err error) (bool, error) { // For 429 errors look at the Retry-After: header and // set the retry appropriately, starting with a minimum of 1 // second if it isn't set. @@ -319,22 +427,6 @@ func (f *Fs) shouldRetryNoReauth(resp *http.Response, err error) (bool, error) { return fserrors.ShouldRetry(err) || fserrors.ShouldRetryHTTP(resp, retryErrorCodes), err } -// shouldRetry returns a boolean as to whether this resp and err -// deserve to be retried. It returns the err as a convenience -func (f *Fs) shouldRetry(ctx context.Context, resp *http.Response, err error) (bool, error) { - // It looks like seafile is using the 403 error code instead of the standard 401. - if resp != nil && (resp.StatusCode == 401 || resp.StatusCode == 403) { - fs.Debugf(f, "Unauthorized: %v", err) - // Reauth - authErr := f.authorizeAccount(ctx) - if authErr != nil { - err = authErr - } - return true, err - } - return f.shouldRetryNoReauth(resp, err) -} - func (f *Fs) shouldRetryUpload(ctx context.Context, resp *http.Response, err error) (bool, error) { if err != nil || (resp != nil && resp.StatusCode > 400) { return true, err diff --git a/backend/seafile/webapi.go b/backend/seafile/webapi.go index 84f00aa11..d751561f2 100644 --- a/backend/seafile/webapi.go +++ b/backend/seafile/webapi.go @@ -32,37 +32,44 @@ var ( // ==================== Seafile API ==================== func (f *Fs) getAuthorizationToken(ctx context.Context) (string, error) { - // API Socumentation + return getAuthorizationToken(ctx, f.srv, f.opt.User, f.opt.Password, "") +} + +// getAuthorizationToken can be called outside of a fs (during configuration of the remote to get the authentication token) +// it's doing a single call (no pacer involved) +func getAuthorizationToken(ctx context.Context, srv *rest.Client, user, password, oneTimeCode string) (string, error) { + // API Documentation // https://download.seafile.com/published/web-api/home.md#user-content-Quick%20Start opts := rest.Opts{ Method: "POST", Path: "api2/auth-token/", ExtraHeaders: map[string]string{"Authorization": ""}, // unset the Authorization for this request + IgnoreStatus: true, // so we can load the error messages back into result + } + + // 2FA + if oneTimeCode != "" { + opts.ExtraHeaders["X-SEAFILE-OTP"] = oneTimeCode } request := api.AuthenticationRequest{ - Username: f.opt.User, - Password: f.opt.Password, + Username: user, + Password: password, } result := api.AuthenticationResult{} - var resp *http.Response - var err error - err = f.pacer.Call(func() (bool, error) { - resp, err = f.srv.CallJSON(ctx, &opts, &request, &result) - return f.shouldRetryNoReauth(resp, err) - }) + _, err := srv.CallJSON(ctx, &opts, &request, &result) if err != nil { - if resp != nil { - if resp.StatusCode == 403 { - return "", fs.ErrorPermissionDenied - } - } + // This is only going to be http errors here return "", errors.Wrap(err, "failed to authenticate") } - if result.Errors != nil && len(result.Errors) > 1 { + if result.Errors != nil && len(result.Errors) > 0 { return "", errors.New(strings.Join(result.Errors, ", ")) } + if result.Token == "" { + // No error in "non_field_errors" field but still empty token + return "", errors.New("failed to authenticate") + } return result.Token, nil } @@ -79,11 +86,11 @@ func (f *Fs) getServerInfo(ctx context.Context) (account *api.ServerInfo, err er var resp *http.Response err = f.pacer.Call(func() (bool, error) { resp, err = f.srv.CallJSON(ctx, &opts, nil, &result) - return f.shouldRetry(ctx, resp, err) + return f.shouldRetry(resp, err) }) if err != nil { if resp != nil { - if resp.StatusCode == 403 { + if resp.StatusCode == 401 || resp.StatusCode == 403 { return nil, fs.ErrorPermissionDenied } } @@ -105,11 +112,11 @@ func (f *Fs) getUserAccountInfo(ctx context.Context) (account *api.AccountInfo, var resp *http.Response err = f.pacer.Call(func() (bool, error) { resp, err = f.srv.CallJSON(ctx, &opts, nil, &result) - return f.shouldRetry(ctx, resp, err) + return f.shouldRetry(resp, err) }) if err != nil { if resp != nil { - if resp.StatusCode == 403 { + if resp.StatusCode == 401 || resp.StatusCode == 403 { return nil, fs.ErrorPermissionDenied } } @@ -132,11 +139,11 @@ func (f *Fs) getLibraries(ctx context.Context) ([]api.Library, error) { var err error err = f.pacer.Call(func() (bool, error) { resp, err = f.srv.CallJSON(ctx, &opts, nil, &result) - return f.shouldRetry(ctx, resp, err) + return f.shouldRetry(resp, err) }) if err != nil { if resp != nil { - if resp.StatusCode == 403 { + if resp.StatusCode == 401 || resp.StatusCode == 403 { return nil, fs.ErrorPermissionDenied } } @@ -163,11 +170,11 @@ func (f *Fs) createLibrary(ctx context.Context, libraryName, password string) (l var resp *http.Response err = f.pacer.Call(func() (bool, error) { resp, err = f.srv.CallJSON(ctx, &opts, &request, &result) - return f.shouldRetry(ctx, resp, err) + return f.shouldRetry(resp, err) }) if err != nil { if resp != nil { - if resp.StatusCode == 403 { + if resp.StatusCode == 401 || resp.StatusCode == 403 { return nil, fs.ErrorPermissionDenied } } @@ -190,11 +197,11 @@ func (f *Fs) deleteLibrary(ctx context.Context, libraryID string) error { var err error err = f.pacer.Call(func() (bool, error) { resp, err = f.srv.CallJSON(ctx, &opts, nil, &result) - return f.shouldRetry(ctx, resp, err) + return f.shouldRetry(resp, err) }) if err != nil { if resp != nil { - if resp.StatusCode == 403 { + if resp.StatusCode == 401 || resp.StatusCode == 403 { return fs.ErrorPermissionDenied } } @@ -221,7 +228,7 @@ func (f *Fs) decryptLibrary(ctx context.Context, libraryID, password string) err var err error err = f.pacer.Call(func() (bool, error) { resp, err = f.srv.Call(ctx, &opts) - return f.shouldRetry(ctx, resp, err) + return f.shouldRetry(resp, err) }) if err != nil { if resp != nil { @@ -264,10 +271,13 @@ func (f *Fs) getDirectoryEntriesAPIv21(ctx context.Context, libraryID, dirPath s var err error err = f.pacer.Call(func() (bool, error) { resp, err = f.srv.CallJSON(ctx, &opts, nil, &result) - return f.shouldRetry(ctx, resp, err) + return f.shouldRetry(resp, err) }) if err != nil { if resp != nil { + if resp.StatusCode == 401 || resp.StatusCode == 403 { + return nil, fs.ErrorPermissionDenied + } if resp.StatusCode == 404 { return nil, fs.ErrorDirNotFound } @@ -306,11 +316,11 @@ func (f *Fs) getDirectoryDetails(ctx context.Context, libraryID, dirPath string) var err error err = f.pacer.Call(func() (bool, error) { resp, err = f.srv.CallJSON(ctx, &opts, nil, &result) - return f.shouldRetry(ctx, resp, err) + return f.shouldRetry(resp, err) }) if err != nil { if resp != nil { - if resp.StatusCode == 403 { + if resp.StatusCode == 401 || resp.StatusCode == 403 { return nil, fs.ErrorPermissionDenied } if resp.StatusCode == 404 { @@ -348,11 +358,11 @@ func (f *Fs) createDir(ctx context.Context, libraryID, dirPath string) error { var err error err = f.pacer.Call(func() (bool, error) { resp, err = f.srv.Call(ctx, &opts) - return f.shouldRetry(ctx, resp, err) + return f.shouldRetry(resp, err) }) if err != nil { if resp != nil { - if resp.StatusCode == 403 { + if resp.StatusCode == 401 || resp.StatusCode == 403 { return fs.ErrorPermissionDenied } } @@ -388,11 +398,11 @@ func (f *Fs) renameDir(ctx context.Context, libraryID, dirPath, newName string) var err error err = f.pacer.Call(func() (bool, error) { resp, err = f.srv.Call(ctx, &opts) - return f.shouldRetry(ctx, resp, err) + return f.shouldRetry(resp, err) }) if err != nil { if resp != nil { - if resp.StatusCode == 403 { + if resp.StatusCode == 401 || resp.StatusCode == 403 { return fs.ErrorPermissionDenied } } @@ -428,11 +438,11 @@ func (f *Fs) moveDir(ctx context.Context, srcLibraryID, srcDir, srcName, dstLibr var err error err = f.pacer.Call(func() (bool, error) { resp, err = f.srv.CallJSON(ctx, &opts, &request, nil) - return f.shouldRetry(ctx, resp, err) + return f.shouldRetry(resp, err) }) if err != nil { if resp != nil { - if resp.StatusCode == 403 { + if resp.StatusCode == 401 || resp.StatusCode == 403 { return fs.ErrorPermissionDenied } if resp.StatusCode == 404 { @@ -464,11 +474,11 @@ func (f *Fs) deleteDir(ctx context.Context, libraryID, filePath string) error { var err error err = f.pacer.Call(func() (bool, error) { resp, err = f.srv.CallJSON(ctx, &opts, nil, nil) - return f.shouldRetry(ctx, resp, err) + return f.shouldRetry(resp, err) }) if err != nil { if resp != nil { - if resp.StatusCode == 403 { + if resp.StatusCode == 401 || resp.StatusCode == 403 { return fs.ErrorPermissionDenied } } @@ -495,14 +505,14 @@ func (f *Fs) getFileDetails(ctx context.Context, libraryID, filePath string) (*a var err error err = f.pacer.Call(func() (bool, error) { resp, err = f.srv.CallJSON(ctx, &opts, nil, &result) - return f.shouldRetry(ctx, resp, err) + return f.shouldRetry(resp, err) }) if err != nil { if resp != nil { if resp.StatusCode == 404 { return nil, fs.ErrorObjectNotFound } - if resp.StatusCode == 403 { + if resp.StatusCode == 401 || resp.StatusCode == 403 { return nil, fs.ErrorPermissionDenied } } @@ -529,7 +539,7 @@ func (f *Fs) deleteFile(ctx context.Context, libraryID, filePath string) error { } err := f.pacer.Call(func() (bool, error) { resp, err := f.srv.CallJSON(ctx, &opts, nil, nil) - return f.shouldRetry(ctx, resp, err) + return f.shouldRetry(resp, err) }) if err != nil { return errors.Wrap(err, "failed to delete file") @@ -555,7 +565,7 @@ func (f *Fs) getDownloadLink(ctx context.Context, libraryID, filePath string) (s var err error err = f.pacer.Call(func() (bool, error) { resp, err = f.srv.CallJSON(ctx, &opts, nil, &result) - return f.shouldRetry(ctx, resp, err) + return f.shouldRetry(resp, err) }) if err != nil { if resp != nil { @@ -604,7 +614,7 @@ func (f *Fs) download(ctx context.Context, url string, size int64, options ...fs var err error err = f.pacer.Call(func() (bool, error) { resp, err = f.srv.Call(ctx, &opts) - return f.shouldRetry(ctx, resp, err) + return f.shouldRetry(resp, err) }) if err != nil { if resp != nil { @@ -649,11 +659,11 @@ func (f *Fs) getUploadLink(ctx context.Context, libraryID string) (string, error var err error err = f.pacer.Call(func() (bool, error) { resp, err = f.srv.CallJSON(ctx, &opts, nil, &result) - return f.shouldRetry(ctx, resp, err) + return f.shouldRetry(resp, err) }) if err != nil { if resp != nil { - if resp.StatusCode == 403 { + if resp.StatusCode == 401 || resp.StatusCode == 403 { return "", fs.ErrorPermissionDenied } } @@ -693,7 +703,7 @@ func (f *Fs) upload(ctx context.Context, in io.Reader, uploadLink, filePath stri }) if err != nil { if resp != nil { - if resp.StatusCode == 403 { + if resp.StatusCode == 401 || resp.StatusCode == 403 { return nil, fs.ErrorPermissionDenied } if resp.StatusCode == 500 { @@ -729,11 +739,11 @@ func (f *Fs) listShareLinks(ctx context.Context, libraryID, remote string) ([]ap var err error err = f.pacer.Call(func() (bool, error) { resp, err = f.srv.CallJSON(ctx, &opts, nil, &result) - return f.shouldRetry(ctx, resp, err) + return f.shouldRetry(resp, err) }) if err != nil { if resp != nil { - if resp.StatusCode == 403 { + if resp.StatusCode == 401 || resp.StatusCode == 403 { return nil, fs.ErrorPermissionDenied } if resp.StatusCode == 404 { @@ -767,11 +777,11 @@ func (f *Fs) createShareLink(ctx context.Context, libraryID, remote string) (*ap var err error err = f.pacer.Call(func() (bool, error) { resp, err = f.srv.CallJSON(ctx, &opts, &request, &result) - return f.shouldRetry(ctx, resp, err) + return f.shouldRetry(resp, err) }) if err != nil { if resp != nil { - if resp.StatusCode == 403 { + if resp.StatusCode == 401 || resp.StatusCode == 403 { return nil, fs.ErrorPermissionDenied } if resp.StatusCode == 404 { @@ -808,11 +818,11 @@ func (f *Fs) copyFile(ctx context.Context, srcLibraryID, srcPath, dstLibraryID, var err error err = f.pacer.Call(func() (bool, error) { resp, err = f.srv.CallJSON(ctx, &opts, &request, &result) - return f.shouldRetry(ctx, resp, err) + return f.shouldRetry(resp, err) }) if err != nil { if resp != nil { - if resp.StatusCode == 403 { + if resp.StatusCode == 401 || resp.StatusCode == 403 { return nil, fs.ErrorPermissionDenied } if resp.StatusCode == 404 { @@ -850,11 +860,11 @@ func (f *Fs) moveFile(ctx context.Context, srcLibraryID, srcPath, dstLibraryID, var err error err = f.pacer.Call(func() (bool, error) { resp, err = f.srv.CallJSON(ctx, &opts, &request, &result) - return f.shouldRetry(ctx, resp, err) + return f.shouldRetry(resp, err) }) if err != nil { if resp != nil { - if resp.StatusCode == 403 { + if resp.StatusCode == 401 || resp.StatusCode == 403 { return nil, fs.ErrorPermissionDenied } if resp.StatusCode == 404 { @@ -890,11 +900,11 @@ func (f *Fs) renameFile(ctx context.Context, libraryID, filePath, newname string var err error err = f.pacer.Call(func() (bool, error) { resp, err = f.srv.CallJSON(ctx, &opts, &request, &result) - return f.shouldRetry(ctx, resp, err) + return f.shouldRetry(resp, err) }) if err != nil { if resp != nil { - if resp.StatusCode == 403 { + if resp.StatusCode == 401 || resp.StatusCode == 403 { return nil, fs.ErrorPermissionDenied } if resp.StatusCode == 404 { @@ -928,11 +938,11 @@ func (f *Fs) emptyLibraryTrash(ctx context.Context, libraryID string) error { var err error err = f.pacer.Call(func() (bool, error) { resp, err = f.srv.CallJSON(ctx, &opts, nil, nil) - return f.shouldRetry(ctx, resp, err) + return f.shouldRetry(resp, err) }) if err != nil { if resp != nil { - if resp.StatusCode == 403 { + if resp.StatusCode == 401 || resp.StatusCode == 403 { return fs.ErrorPermissionDenied } if resp.StatusCode == 404 { @@ -966,10 +976,13 @@ func (f *Fs) getDirectoryEntriesAPIv2(ctx context.Context, libraryID, dirPath st var err error err = f.pacer.Call(func() (bool, error) { resp, err = f.srv.CallJSON(ctx, &opts, nil, &result) - return f.shouldRetry(ctx, resp, err) + return f.shouldRetry(resp, err) }) if err != nil { if resp != nil { + if resp.StatusCode == 401 || resp.StatusCode == 403 { + return nil, fs.ErrorPermissionDenied + } if resp.StatusCode == 404 { return nil, fs.ErrorDirNotFound } @@ -1017,11 +1030,11 @@ func (f *Fs) copyFileAPIv2(ctx context.Context, srcLibraryID, srcPath, dstLibrar var err error err = f.pacer.Call(func() (bool, error) { resp, err = f.srv.Call(ctx, &opts) - return f.shouldRetry(ctx, resp, err) + return f.shouldRetry(resp, err) }) if err != nil { if resp != nil { - if resp.StatusCode == 403 { + if resp.StatusCode == 401 || resp.StatusCode == 403 { return nil, fs.ErrorPermissionDenied } } @@ -1062,7 +1075,7 @@ func (f *Fs) renameFileAPIv2(ctx context.Context, libraryID, filePath, newname s var err error err = f.pacer.Call(func() (bool, error) { resp, err = f.srv.Call(ctx, &opts) - return f.shouldRetry(ctx, resp, err) + return f.shouldRetry(resp, err) }) if err != nil { if resp != nil { @@ -1070,7 +1083,7 @@ func (f *Fs) renameFileAPIv2(ctx context.Context, libraryID, filePath, newname s // This is the normal response from the server return nil } - if resp.StatusCode == 403 { + if resp.StatusCode == 401 || resp.StatusCode == 403 { return fs.ErrorPermissionDenied } if resp.StatusCode == 404 { diff --git a/docs/content/seafile.md b/docs/content/seafile.md index e517b6377..db845560c 100644 --- a/docs/content/seafile.md +++ b/docs/content/seafile.md @@ -1,16 +1,17 @@ --- title: "Seafile" description: "Seafile" -date: "2020-05-02" +date: "2020-05-19" --- Seafile ---------------------------------------- -This is a backend for the [Seafile](https://www.seafile.com/) storage service. -It works with both the free community edition, or the professional edition. -Seafile versions 6.x and 7.x are all supported. -Encrypted libraries are also supported. +This is a backend for the [Seafile](https://www.seafile.com/) storage service: +- It works with both the free community edition or the professional edition. +- Seafile versions 6.x and 7.x are all supported. +- Encrypted libraries are also supported. +- It supports 2FA enabled users ### Root mode vs Library mode ### @@ -18,11 +19,11 @@ There are two distinct modes you can setup your remote: - you point your remote to the **root of the server**, meaning you don't specify a library during the configuration: Paths are specified as `remote:library`. You may put subdirectories in too, eg `remote:library/path/to/dir`. - you point your remote to a specific library during the configuration: -Paths are specified as `remote:path/to/dir`. **This is the recommended mode when using encrypted libraries**. +Paths are specified as `remote:path/to/dir`. **This is the recommended mode when using encrypted libraries**. (_This mode is possibly slightly faster than the root mode_) ### Configuration in root mode ### -Here is an example of making a seafile configuration. First run +Here is an example of making a seafile configuration for a user with **no** two-factor authentication. First run rclone config @@ -52,17 +53,21 @@ Choose a number from below, or type in your own value 1 / Connect to cloud.seafile.com \ "https://cloud.seafile.com/" url> http://my.seafile.server/ -User name +User name (usually email address) Enter a string value. Press Enter for the default (""). user> me@example.com Password y) Yes type in my own password g) Generate random password +n) No leave this optional password blank (default) y/g> y Enter the password: password: Confirm the password: password: +Two-factor authentication ('true' if the account has 2FA enabled) +Enter a boolean value (true or false). Press Enter for the default ("false"). +2fa> false Name of the library. Leave blank to access all non-encrypted libraries. Enter a string value. Press Enter for the default (""). library> @@ -76,12 +81,14 @@ y) Yes n) No (default) y/n> n Remote config +Two-factor authentication is not enabled on this account. -------------------- [seafile] type = seafile url = http://my.seafile.server/ user = me@example.com -password = *** ENCRYPTED *** +pass = *** ENCRYPTED *** +2fa = false -------------------- y) Yes this is OK (default) e) Edit this remote @@ -89,7 +96,7 @@ d) Delete this remote y/e/d> y ``` -This remote is called `seafile`. It's pointing to the root of your seafile server and can now be used like this +This remote is called `seafile`. It's pointing to the root of your seafile server and can now be used like this: See all libraries @@ -110,6 +117,8 @@ excess files in the library. ### Configuration in library mode ### +Here's an example of a configuration in library mode with a user that has the two-factor authentication enabled. Your 2FA code will be asked at the end of the configuration, and will attempt to authenticate you: + ``` No remotes found - make a new one n) New remote @@ -133,17 +142,21 @@ Choose a number from below, or type in your own value 1 / Connect to cloud.seafile.com \ "https://cloud.seafile.com/" url> http://my.seafile.server/ -User name +User name (usually email address) Enter a string value. Press Enter for the default (""). user> me@example.com Password y) Yes type in my own password g) Generate random password +n) No leave this optional password blank (default) y/g> y Enter the password: password: Confirm the password: password: +Two-factor authentication ('true' if the account has 2FA enabled) +Enter a boolean value (true or false). Press Enter for the default ("false"). +2fa> true Name of the library. Leave blank to access all non-encrypted libraries. Enter a string value. Press Enter for the default (""). library> My Library @@ -157,12 +170,17 @@ y) Yes n) No (default) y/n> n Remote config +Two-factor authentication: please enter your 2FA code +2fa code> 123456 +Authenticating... +Success! -------------------- [seafile] type = seafile url = http://my.seafile.server/ user = me@example.com -password = *** ENCRYPTED *** +pass = +2fa = true library = My Library -------------------- y) Yes this is OK (default) @@ -171,6 +189,8 @@ d) Delete this remote y/e/d> y ``` +You'll notice your password is blank in the configuration. It's because we only need the password to authenticate you once. + You specified `My Library` during the configuration. The root of the remote is pointing at the root of the library `My Library`: @@ -246,6 +266,99 @@ Versions below 6.0 are not supported. Versions between 6.0 and 6.3 haven't been tested and might not work properly. +### Standard Options + +Here are the standard options specific to seafile (seafile). + +#### --seafile-url + +URL of seafile host to connect to + +- Config: url +- Env Var: RCLONE_SEAFILE_URL +- Type: string +- Default: "" +- Examples: + - "https://cloud.seafile.com/" + - Connect to cloud.seafile.com + +#### --seafile-user + +User name (usually email address) + +- Config: user +- Env Var: RCLONE_SEAFILE_USER +- Type: string +- Default: "" + +#### --seafile-pass + +Password + +- Config: pass +- Env Var: RCLONE_SEAFILE_PASS +- Type: string +- Default: "" + +#### --seafile-2fa + +Two-factor authentication ('true' if the account has 2FA enabled) + +- Config: 2fa +- Env Var: RCLONE_SEAFILE_2FA +- Type: bool +- Default: false + +#### --seafile-library + +Name of the library. Leave blank to access all non-encrypted libraries. + +- Config: library +- Env Var: RCLONE_SEAFILE_LIBRARY +- Type: string +- Default: "" + +#### --seafile-library-key + +Library password (for encrypted libraries only). Leave blank if you pass it through the command line. + +- Config: library_key +- Env Var: RCLONE_SEAFILE_LIBRARY_KEY +- Type: string +- Default: "" + +#### --seafile-auth-token + +Authentication token + +- Config: auth_token +- Env Var: RCLONE_SEAFILE_AUTH_TOKEN +- Type: string +- Default: "" + +### Advanced Options + +Here are the advanced options specific to seafile (seafile). + +#### --seafile-create-library + +Should rclone create a library if it doesn't exist + +- Config: create_library +- Env Var: RCLONE_SEAFILE_CREATE_LIBRARY +- Type: bool +- Default: false + +#### --seafile-encoding + +This sets the encoding for the backend. + +See: the [encoding section in the overview](/overview/#encoding) for more info. + +- Config: encoding +- Env Var: RCLONE_SEAFILE_ENCODING +- Type: MultiEncoder +- Default: Slash,DoubleQuote,BackSlash,Ctl,InvalidUtf8