diff --git a/pkg/modules/migration/errors.go b/pkg/modules/migration/errors.go
index 3129c5da2..eef789c39 100644
--- a/pkg/modules/migration/errors.go
+++ b/pkg/modules/migration/errors.go
@@ -18,10 +18,33 @@ package migration
import (
"net/http"
+ "time"
"code.vikunja.io/api/pkg/web"
)
+// ErrMigrationAlreadyRunning is returned when a migration is started for a user
+// who already has one in progress (started but not yet finished).
+type ErrMigrationAlreadyRunning struct {
+ StartedAt time.Time
+}
+
+func (err *ErrMigrationAlreadyRunning) Error() string {
+ return "Migration already running"
+}
+
+// ErrCodeMigrationAlreadyRunning holds the unique world-error code of this error
+const ErrCodeMigrationAlreadyRunning = 14005
+
+// HTTPError holds the http error description
+func (err *ErrMigrationAlreadyRunning) HTTPError() web.HTTPError {
+ return web.HTTPError{
+ HTTPCode: http.StatusPreconditionFailed,
+ Code: ErrCodeMigrationAlreadyRunning,
+ Message: "Migration already running",
+ }
+}
+
// ErrNotAZipFile represents a "ErrNotAZipFile" kind of error.
type ErrNotAZipFile struct{}
diff --git a/pkg/modules/migration/handler/handler.go b/pkg/modules/migration/handler/handler.go
index 840dbacd6..5ef52d747 100644
--- a/pkg/modules/migration/handler/handler.go
+++ b/pkg/modules/migration/handler/handler.go
@@ -39,7 +39,7 @@ type MigrationWeb struct {
// AuthURL is returned to the user when requesting the auth url
type AuthURL struct {
- URL string `json:"url"`
+ URL string `json:"url" readOnly:"true" doc:"The OAuth authorization url the client should redirect the user to. After authorizing, the obtained code is passed back to the migrate endpoint."`
}
// RegisterMigrator registers all routes for migration
@@ -57,6 +57,28 @@ func (mw *MigrationWeb) AuthURL(c *echo.Context) error {
return c.JSON(http.StatusOK, &AuthURL{URL: ms.AuthURL()})
}
+// StartMigration kicks off a migration for the given user: it refuses with
+// migration.ErrMigrationAlreadyRunning if one is already in progress, then
+// dispatches the MigrationRequestedEvent that runs the migration asynchronously.
+// The migrator must already carry its request payload (e.g. the OAuth code).
+// Shared by the v1 and v2 HTTP layers so the orchestration lives in one place.
+func StartMigration(ms migration.Migrator, u *user2.User) error {
+ stats, err := migration.GetMigrationStatus(ms, u)
+ if err != nil {
+ return err
+ }
+
+ if !stats.StartedAt.IsZero() && stats.FinishedAt.IsZero() {
+ return &migration.ErrMigrationAlreadyRunning{StartedAt: stats.StartedAt}
+ }
+
+ return events.Dispatch(&MigrationRequestedEvent{
+ Migrator: ms,
+ MigratorKind: ms.Name(),
+ User: u,
+ })
+}
+
// Migrate calls the migration method
func (mw *MigrationWeb) Migrate(c *echo.Context) error {
ms := mw.MigrationStruct()
@@ -85,12 +107,7 @@ func (mw *MigrationWeb) Migrate(c *echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest, "No or invalid model provided: "+err.Error()).Wrap(err)
}
- err = events.Dispatch(&MigrationRequestedEvent{
- Migrator: ms,
- MigratorKind: ms.Name(),
- User: user,
- })
- if err != nil {
+ if err := StartMigration(ms, user); err != nil {
return err
}
diff --git a/pkg/modules/migration/migration_status.go b/pkg/modules/migration/migration_status.go
index a967c950c..419ac880c 100644
--- a/pkg/modules/migration/migration_status.go
+++ b/pkg/modules/migration/migration_status.go
@@ -25,11 +25,11 @@ import (
// Status represents this migration status
type Status struct {
- ID int64 `xorm:"bigint autoincr not null unique pk" json:"id"`
+ ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" readOnly:"true" doc:"The unique, numeric id of this migration status."`
UserID int64 `xorm:"bigint not null" json:"-"`
- MigratorName string `xorm:"varchar(255)" json:"migrator_name"`
- StartedAt time.Time `xorm:"not null" json:"started_at"`
- FinishedAt time.Time `xorm:"null" json:"finished_at"`
+ MigratorName string `xorm:"varchar(255)" json:"migrator_name" readOnly:"true" doc:"The name of the migrator this status belongs to, e.g. \"todoist\"."`
+ StartedAt time.Time `xorm:"not null" json:"started_at" readOnly:"true" doc:"When the last migration started. Zero value if the user never migrated from this service."`
+ FinishedAt time.Time `xorm:"null" json:"finished_at" readOnly:"true" doc:"When the last migration finished. Zero value while a migration is still running or was never run."`
}
// TableName holds the table name for the migration status table
diff --git a/pkg/routes/api/v2/migration_oauth.go b/pkg/routes/api/v2/migration_oauth.go
new file mode 100644
index 000000000..4d254632c
--- /dev/null
+++ b/pkg/routes/api/v2/migration_oauth.go
@@ -0,0 +1,167 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package apiv2
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/modules/migration"
+ migrationHandler "code.vikunja.io/api/pkg/modules/migration/handler"
+ microsofttodo "code.vikunja.io/api/pkg/modules/migration/microsoft-todo"
+ "code.vikunja.io/api/pkg/modules/migration/todoist"
+ "code.vikunja.io/api/pkg/modules/migration/trello"
+ "code.vikunja.io/api/pkg/user"
+
+ "github.com/danielgtaylor/huma/v2"
+)
+
+// migrationAuthURLBody is the response for the OAuth auth-url endpoint.
+type migrationAuthURLBody struct {
+ Body migrationHandler.AuthURL
+}
+
+// migrationStatusBody is the response for the migration status endpoint.
+type migrationStatusBody struct {
+ Body *migration.Status
+}
+
+// migrationMigrateBody carries the OAuth code obtained from the auth url back
+// to the server. It is applied onto the concrete migrator (whose field carries
+// json:"code") so it works across migrators regardless of their field name.
+type migrationMigrateBody struct {
+ Code string `json:"code" doc:"The OAuth code obtained after authorizing against the auth url."`
+}
+
+// migrationStartedBody confirms the migration was kicked off; the actual work
+// runs asynchronously.
+type migrationStartedBody struct {
+ Body struct {
+ Message string `json:"message" readOnly:"true" doc:"A confirmation message."`
+ }
+}
+
+// RegisterMigrationOAuthRoutes wires the OAuth-based migrators (Todoist, Trello,
+// Microsoft To-Do) onto the Huma API. Each migrator is gated behind its static
+// config flag and exposes the same three operations, so registration is driven
+// by one generic helper instead of three copy-pasted blocks.
+func RegisterMigrationOAuthRoutes(api huma.API) {
+ registerOAuthMigrator(api, config.MigrationTodoistEnable.GetBool(), func() migration.Migrator { return &todoist.Migration{} })
+ registerOAuthMigrator(api, config.MigrationTrelloEnable.GetBool(), func() migration.Migrator { return &trello.Migration{} })
+ registerOAuthMigrator(api, config.MigrationMicrosoftTodoEnable.GetBool(), func() migration.Migrator { return µsofttodo.Migration{} })
+}
+
+func init() { AddRouteRegistrar(RegisterMigrationOAuthRoutes) }
+
+// registerOAuthMigrator registers auth/status/migrate for a single OAuth
+// migrator. enabled gates the whole migrator (config early-return, no
+// middleware); factory produces a fresh migrator instance per request, matching
+// v1's MigrationStruct func so concurrent requests never share mutable state.
+func registerOAuthMigrator(api huma.API, enabled bool, factory func() migration.Migrator) {
+ if !enabled {
+ return
+ }
+
+ name := factory().Name()
+ tags := []string{"migration"}
+
+ Register(api, huma.Operation{
+ OperationID: "migration-" + name + "-auth",
+ Summary: "Get the auth url for " + name,
+ Description: "Returns the OAuth url the user needs to authenticate against. The code obtained there is passed back to the migrate endpoint.",
+ Method: http.MethodGet,
+ Path: "/migration/" + name + "/auth",
+ Tags: tags,
+ }, func(_ context.Context, _ *struct{}) (*migrationAuthURLBody, error) {
+ return &migrationAuthURLBody{Body: migrationHandler.AuthURL{URL: factory().AuthURL()}}, nil
+ })
+
+ Register(api, huma.Operation{
+ OperationID: "migration-" + name + "-status",
+ Summary: "Get the migration status for " + name,
+ Description: "Returns the migration status of the authenticated user for this service, i.e. whether and when they last migrated. Used to prevent starting a second migration while one is running.",
+ Method: http.MethodGet,
+ Path: "/migration/" + name + "/status",
+ Tags: tags,
+ }, func(ctx context.Context, _ *struct{}) (*migrationStatusBody, error) {
+ return migrationOAuthStatus(ctx, factory)
+ })
+
+ Register(api, huma.Operation{
+ OperationID: "migration-" + name + "-migrate",
+ Summary: "Migrate from " + name,
+ Description: "Starts a migration of the authenticated user's data from this service into Vikunja. The migration runs asynchronously; this returns once it has been queued. Refuses with 412 if a migration for this service is already running.",
+ Method: http.MethodPost,
+ Path: "/migration/" + name + "/migrate",
+ // POST kicks off a job rather than creating a REST resource, so it
+ // returns 200 with a confirmation, not the wrapper's 201.
+ DefaultStatus: http.StatusOK,
+ Tags: tags,
+ }, func(ctx context.Context, in *struct{ Body migrationMigrateBody }) (*migrationStartedBody, error) {
+ return migrationOAuthMigrate(ctx, factory, in.Body)
+ })
+}
+
+func migrationOAuthStatus(ctx context.Context, factory func() migration.Migrator) (*migrationStatusBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ u, err := user.GetFromAuth(a)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ status, err := migration.GetMigrationStatus(factory(), u)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &migrationStatusBody{Body: status}, nil
+}
+
+func migrationOAuthMigrate(ctx context.Context, factory func() migration.Migrator, body migrationMigrateBody) (*migrationStartedBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ u, err := user.GetFromAuth(a)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ ms := factory()
+ // Apply the request payload onto the concrete migrator the same way v1's
+ // c.Bind does, so migrator-specific field names (e.g. Trello's Token,
+ // json:"code") bind transparently.
+ raw, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+ if err := json.Unmarshal(raw, ms); err != nil {
+ return nil, huma.Error400BadRequest("invalid migration payload", err)
+ }
+
+ if err := migrationHandler.StartMigration(ms, u); err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ out := &migrationStartedBody{}
+ out.Body.Message = "Migration was started successfully."
+ return out, nil
+}
diff --git a/pkg/webtests/huma_migration_oauth_test.go b/pkg/webtests/huma_migration_oauth_test.go
new file mode 100644
index 000000000..7d15c576a
--- /dev/null
+++ b/pkg/webtests/huma_migration_oauth_test.go
@@ -0,0 +1,153 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package webtests
+
+import (
+ "net/http"
+ "testing"
+ "time"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/events"
+ "code.vikunja.io/api/pkg/modules/migration"
+ migrationHandler "code.vikunja.io/api/pkg/modules/migration/handler"
+ "code.vikunja.io/api/pkg/routes"
+
+ "github.com/labstack/echo/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// setupMigrationTestEnv builds a test env with the OAuth migrators enabled so
+// their v2 routes are registered (they are gated behind config flags that
+// default to false). setupTestEnv resets config to defaults, so the flags must
+// be set after it and the router rebuilt.
+func setupMigrationTestEnv(t *testing.T) *echo.Echo {
+ t.Helper()
+ _, err := setupTestEnv()
+ require.NoError(t, err)
+
+ // migration.Status is not part of models.GetTables() (pkg/models cannot
+ // import pkg/modules/migration without a cycle), so SetupTests never syncs
+ // migration_status. Create it here so the status/migrate handlers can query.
+ s := db.NewSession()
+ require.NoError(t, s.Sync2(&migration.Status{}))
+ require.NoError(t, s.Commit())
+ require.NoError(t, s.Close())
+
+ config.MigrationTodoistEnable.Set(true)
+ config.MigrationTrelloEnable.Set(true)
+ config.MigrationMicrosoftTodoEnable.Set(true)
+ t.Cleanup(func() {
+ config.MigrationTodoistEnable.Set(false)
+ config.MigrationTrelloEnable.Set(false)
+ config.MigrationMicrosoftTodoEnable.Set(false)
+ })
+
+ e := routes.NewEcho()
+ routes.RegisterRoutes(e)
+ return e
+}
+
+// TestHumaMigrationOAuth covers the three OAuth migrators' v2 endpoints. There
+// is no v1 webtest for these handlers to mirror, so this is the parity baseline.
+func TestHumaMigrationOAuth(t *testing.T) {
+ e := setupMigrationTestEnv(t)
+ token := humaTokenFor(t, &testuser1)
+
+ // The generic registration helper wires the same three ops for every
+ // migrator, so exercising each name guards against a copy-paste regression.
+ for _, name := range []string{"todoist", "trello", "microsoft-todo"} {
+ t.Run(name+" auth url", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/"+name+"/auth", "", token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"url":"http`, "auth url must be returned; body: %s", rec.Body.String())
+ })
+
+ t.Run(name+" status - never migrated", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/"+name+"/status", "", token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ // A user who never migrated has a zero-value status.
+ assert.Contains(t, rec.Body.String(), `"started_at":"0001-01-01T00:00:00Z"`, "body: %s", rec.Body.String())
+ })
+ }
+
+ t.Run("migrate kicks off the migration", func(t *testing.T) {
+ events.ClearDispatchedEvents()
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/migration/todoist/migrate", `{"code":"test-code"}`, token, "")
+ // 200, not the wrapper's POST default 201: this queues a job, it does
+ // not create a REST resource.
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"message":"Migration was started successfully."`)
+ events.AssertDispatched(t, &migrationHandler.MigrationRequestedEvent{})
+ })
+}
+
+// TestHumaMigrationOAuth_AlreadyRunning ports v1's guard: starting a migration
+// while one is already in progress (started, not finished) is refused with 412.
+func TestHumaMigrationOAuth_AlreadyRunning(t *testing.T) {
+ e := setupMigrationTestEnv(t)
+ token := humaTokenFor(t, &testuser1)
+
+ s := db.NewSession()
+ _, err := s.Insert(&migration.Status{
+ UserID: testuser1.ID,
+ MigratorName: "todoist",
+ StartedAt: time.Now(),
+ })
+ require.NoError(t, err)
+ require.NoError(t, s.Commit())
+ _ = s.Close()
+
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/migration/todoist/migrate", `{"code":"test-code"}`, token, "")
+ assert.Equal(t, http.StatusPreconditionFailed, rec.Code, "body: %s", rec.Body.String())
+}
+
+// TestHumaMigrationOAuth_Unauthenticated proves all three ops require auth.
+func TestHumaMigrationOAuth_Unauthenticated(t *testing.T) {
+ e := setupMigrationTestEnv(t)
+
+ t.Run("auth", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/todoist/auth", "", "", "")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String())
+ })
+ t.Run("status", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/todoist/status", "", "", "")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String())
+ })
+ t.Run("migrate", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/migration/todoist/migrate", `{"code":"x"}`, "", "")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String())
+ })
+}
+
+// TestHumaMigrationOAuth_Disabled proves a migrator's routes are absent when its
+// config flag is off.
+func TestHumaMigrationOAuth_Disabled(t *testing.T) {
+ _, err := setupTestEnv()
+ require.NoError(t, err)
+ // All migration flags default to false after InitDefaultConfig.
+
+ e := routes.NewEcho()
+ routes.RegisterRoutes(e)
+ token := humaTokenFor(t, &testuser1)
+
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/todoist/auth", "", token, "")
+ assert.Equal(t, http.StatusNotFound, rec.Code,
+ "migration routes must not be registered when the flag is off; body: %s", rec.Body.String())
+}