diff --git a/drivers/115_open/driver.go b/drivers/115_open/driver.go index 13673eb95a..46829fa8cb 100644 --- a/drivers/115_open/driver.go +++ b/drivers/115_open/driver.go @@ -40,17 +40,45 @@ func (d *Open115) GetAddition() driver.Additional { return &d.Addition } +// onTokenRefreshed persists the rotated token pair. 115 Open refresh tokens are +// single use: once a new one is issued the previous one is rejected, so a lost +// write would leave the storage broken after a restart. +// +// The SDK assigns the response to its own fields before invoking this callback, +// so an empty refresh token in the response would also clear the value used by +// the next refresh request. Restore both states from the addition, which keeps +// the last usable token when the field is missing. +func (d *Open115) onTokenRefreshed(accessToken, refreshToken string) { + if accessToken != "" { + d.Addition.AccessToken = accessToken + } + if refreshToken != "" { + d.Addition.RefreshToken = refreshToken + } + if d.client != nil { + d.client.SetAccessToken(d.Addition.AccessToken) + d.client.SetRefreshToken(d.Addition.RefreshToken) + } + op.MustSaveDriverStorage(d) +} + func (d *Open115) Init(ctx context.Context) error { + if d.Addition.AccessToken == "" && d.Addition.RefreshToken == "" { + return errors.New("access token or refresh token is required") + } d.client = sdk.New(sdk.WithRefreshToken(d.Addition.RefreshToken), sdk.WithAccessToken(d.Addition.AccessToken), - sdk.WithOnRefreshToken(func(s1, s2 string) { - d.Addition.AccessToken = s1 - d.Addition.RefreshToken = s2 - op.MustSaveDriverStorage(d) - })) + sdk.WithOnRefreshToken(d.onTokenRefreshed)) if flags.Debug || flags.Dev { d.client.SetDebug(true) } + if d.Addition.AccessToken == "" { + // Without an access token the SDK would send an unauthenticated request + // first and only then refresh, surfacing a misleading "40100000 参数错误". + if _, err := d.client.RefreshToken(ctx); err != nil { + return err + } + } _, err := d.client.UserInfo(ctx) if err != nil { return err diff --git a/drivers/115_open/driver_test.go b/drivers/115_open/driver_test.go new file mode 100644 index 0000000000..d15e83eb17 --- /dev/null +++ b/drivers/115_open/driver_test.go @@ -0,0 +1,129 @@ +package _115_open + +import ( + "encoding/json" + "fmt" + "reflect" + "sync" + "testing" + + sdk "github.com/OpenListTeam/115-sdk-go" + "github.com/OpenListTeam/OpenList/v4/internal/conf" + "github.com/OpenListTeam/OpenList/v4/internal/db" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +var ( + setupDBOnce sync.Once + setupDBErr error + rowSeq int64 +) + +// setupTestDB mirrors internal/op/storage_test.go: an in-memory SQLite database +// behind internal/db, so op.MustSaveDriverStorage really persists and the tests +// can assert on the stored row. +func setupTestDB(t *testing.T) { + t.Helper() + setupDBOnce.Do(func() { + var gormDB *gorm.DB + gormDB, setupDBErr = gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if setupDBErr != nil { + return + } + conf.Conf = conf.DefaultConfig("testdata") + db.Init(gormDB) + }) + if setupDBErr != nil { + t.Fatalf("failed to set up test database: %v", setupDBErr) + } +} + +func newTestDriver(t *testing.T, accessToken, refreshToken string) *Open115 { + t.Helper() + setupTestDB(t) + rowSeq++ + addition, err := json.Marshal(Addition{AccessToken: accessToken, RefreshToken: refreshToken}) + if err != nil { + t.Fatalf("failed to marshal addition: %v", err) + } + row := &model.Storage{ + Driver: "115 Open", + MountPath: fmt.Sprintf("/115open-test-%d", rowSeq), + Addition: string(addition), + } + if err := db.CreateStorage(row); err != nil { + t.Fatalf("failed to create storage row: %v", err) + } + d := &Open115{Storage: *row} + d.Addition.AccessToken = accessToken + d.Addition.RefreshToken = refreshToken + d.client = sdk.New(sdk.WithAccessToken(accessToken), sdk.WithRefreshToken(refreshToken)) + d.client.SetOnRefreshToken(d.onTokenRefreshed) + return d +} + +func persistedRefreshToken(t *testing.T, id uint) string { + t.Helper() + st, err := db.GetStorageById(id) + if err != nil { + t.Fatalf("failed to read storage back: %v", err) + } + var a Addition + if err := json.Unmarshal([]byte(st.Addition), &a); err != nil { + t.Fatalf("failed to decode persisted addition %q: %v", st.Addition, err) + } + return a.RefreshToken +} + +func clientField(t *testing.T, c *sdk.Client, name string) string { + t.Helper() + v := reflect.ValueOf(c).Elem().FieldByName(name) + if !v.IsValid() { + t.Fatalf("field %s not found on sdk.Client", name) + } + return v.String() +} + +// The SDK assigns the response to its own fields before invoking the callback, +// so an empty refresh token in the response must not survive in either the +// persisted addition or the running client. +func TestOnTokenRefreshedKeepsLastRefreshTokenWhenResponseOmitsIt(t *testing.T) { + d := newTestDriver(t, "old-access", "old-refresh") + + // state the SDK leaves behind after a response without refresh_token + d.client.SetAccessToken("") + d.client.SetRefreshToken("") + + d.onTokenRefreshed("new-access", "") + + if got := persistedRefreshToken(t, d.ID); got != "old-refresh" { + t.Errorf("persisted refresh token = %q, want %q", got, "old-refresh") + } + if got := d.Addition.RefreshToken; got != "old-refresh" { + t.Errorf("addition refresh token = %q, want %q", got, "old-refresh") + } + if got := clientField(t, d.client, "refreshToken"); got != "old-refresh" { + t.Errorf("sdk refresh token = %q, want %q", got, "old-refresh") + } + if got := clientField(t, d.client, "accessToken"); got != "new-access" { + t.Errorf("sdk access token = %q, want %q", got, "new-access") + } +} + +func TestOnTokenRefreshedStoresRotatedPair(t *testing.T) { + d := newTestDriver(t, "old-access", "old-refresh") + + d.onTokenRefreshed("new-access", "new-refresh") + + if got := persistedRefreshToken(t, d.ID); got != "new-refresh" { + t.Errorf("persisted refresh token = %q, want %q", got, "new-refresh") + } + if got := clientField(t, d.client, "refreshToken"); got != "new-refresh" { + t.Errorf("sdk refresh token = %q, want %q", got, "new-refresh") + } + if got := clientField(t, d.client, "accessToken"); got != "new-access" { + t.Errorf("sdk access token = %q, want %q", got, "new-access") + } +} diff --git a/internal/bootstrap/db.go b/internal/bootstrap/db.go index 7b91769f9c..7673693181 100644 --- a/internal/bootstrap/db.go +++ b/internal/bootstrap/db.go @@ -50,8 +50,7 @@ func InitDB() { if !(strings.HasSuffix(database.DBFile, ".db") && len(database.DBFile) > 3) { log.Fatalf("db name error.") } - dB, err = gorm.Open(openSQLite(fmt.Sprintf("%s?_journal=WAL&_vacuum=incremental", - database.DBFile)), gormConfig) + dB, err = gorm.Open(openSQLite(database.DBFile), gormConfig) } case "mysql": { @@ -86,3 +85,20 @@ func InitDB() { } db.Init(dB) } + +// appendDSNParams appends SQLite DSN query parameters while keeping the ones +// already present. The accepted pragma syntax differs between the pure-Go and +// the cgo builds, so each build supplies its own parameter list. +func appendDSNParams(dsn, params string) string { + if params == "" { + return dsn + } + switch { + case strings.HasSuffix(dsn, "?"), strings.HasSuffix(dsn, "&"): + return dsn + params + case strings.Contains(dsn, "?"): + return dsn + "&" + params + default: + return dsn + "?" + params + } +} diff --git a/internal/bootstrap/sqlite_driver_glebarez.go b/internal/bootstrap/sqlite_driver_glebarez.go index a45a8baeb2..dc03e8dc41 100644 --- a/internal/bootstrap/sqlite_driver_glebarez.go +++ b/internal/bootstrap/sqlite_driver_glebarez.go @@ -8,5 +8,11 @@ import ( ) func openSQLite(dsn string) gorm.Dialector { - return sqlite.Open(dsn) + // glebarez/go-sqlite only understands "_pragma=" parameters; the mattn style + // "_journal=", "_busy_timeout=" ones are silently ignored, which left the + // database in rollback-journal mode with the driver default 5s busy timeout. + // Readers and the single writer then block each other, so concurrent saves + // (e.g. a rotated 115 Open refresh token) fail with SQLITE_BUSY. + return sqlite.Open(appendDSNParams(dsn, + "_pragma=journal_mode(WAL)&_pragma=busy_timeout(10000)&_pragma=synchronous(NORMAL)")) } diff --git a/internal/bootstrap/sqlite_driver_gorm.go b/internal/bootstrap/sqlite_driver_gorm.go index e69630eac4..fd5b74ddee 100644 --- a/internal/bootstrap/sqlite_driver_gorm.go +++ b/internal/bootstrap/sqlite_driver_gorm.go @@ -8,5 +8,6 @@ import ( ) func openSQLite(dsn string) gorm.Dialector { - return sqlite.Open(dsn) + return sqlite.Open(appendDSNParams(dsn, + "_journal=WAL&_vacuum=incremental&_busy_timeout=10000&_synchronous=NORMAL")) } diff --git a/internal/op/storage.go b/internal/op/storage.go index ca886e5538..1ffbe70111 100644 --- a/internal/op/storage.go +++ b/internal/op/storage.go @@ -296,6 +296,40 @@ func MustSaveDriverStorage(driver driver.Driver) { } } +// Retrying is required because several drivers persist rotating credentials +// here (115 Open, PikPak, OneDrive...). The server invalidates the previous +// token as soon as a new one is issued, so a lost write leaves the storage with +// a permanently unusable token once the process restarts. +const ( + storageSaveAttempts = 5 + storageSaveBaseDelay = 200 * time.Millisecond +) + +func isTransientDBError(err error) bool { + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "database is locked") || + strings.Contains(msg, "sqlite_busy") || + strings.Contains(msg, "database table is locked") +} + +func updateStorageWithRetry(storage *model.Storage) error { + var err error + delay := storageSaveBaseDelay + for attempt := 0; attempt < storageSaveAttempts; attempt++ { + if err = db.UpdateStorage(storage); err == nil { + return nil + } + if !isTransientDBError(err) { + return err + } + if attempt < storageSaveAttempts-1 { + time.Sleep(delay) + delay *= 2 + } + } + return err +} + func saveDriverStorage(driver driver.Driver) error { storage := driver.GetStorage() addition := driver.GetAddition() @@ -304,7 +338,7 @@ func saveDriverStorage(driver driver.Driver) error { return errors.Wrap(err, "error while marshal addition") } storage.Addition = str - err = db.UpdateStorage(storage) + err = updateStorageWithRetry(storage) if err != nil { return errors.WithMessage(err, "failed update storage in database") }