From bd1f95bb96ee86c8db32ebd4e1593b3e4b4224d1 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 19 Aug 2026 14:49:54 +0200 Subject: [PATCH] fix(migration): map exported assignees to the importing user instead of failing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vikunja-file exports carry task assignees with user ids from the source instance. On import those ids were looked up on the target instance and permission-checked, which fails with "User does not have access to the project" whenever the id belongs to someone else (e.g. importing a self-hosted export into Vikunja Cloud). Since ba980b1b8 that error aborts the whole import. Match assignees against the importing user by email, then username, and drop everyone else – foreign user ids have no meaning on this instance. Fixes #3476 --- .../migration/create_from_structure.go | 30 ++++++++++++++- .../migration/create_from_structure_test.go | 38 +++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/pkg/modules/migration/create_from_structure.go b/pkg/modules/migration/create_from_structure.go index 820c91101..e69042939 100644 --- a/pkg/modules/migration/create_from_structure.go +++ b/pkg/modules/migration/create_from_structure.go @@ -20,6 +20,7 @@ import ( "bytes" "context" "math" + "strings" "xorm.io/xorm" @@ -34,11 +35,18 @@ import ( // InsertFromStructure takes a fully nested Vikunja data structure and a user and then creates everything for this user // (Projects, tasks, etc. Even attachments and relations.) -func InsertFromStructure(str []*models.ProjectWithTasksAndBuckets, user *user.User) (err error) { +func InsertFromStructure(str []*models.ProjectWithTasksAndBuckets, u *user.User) (err error) { s := db.NewSession() defer s.Close() - err = insertFromStructure(s, str, user) + // Callers may pass a user built from jwt claims; load the stored one so + // assignee matching sees the current email/username. + importer, err := user.GetUserWithEmail(s, &user.User{ID: u.ID}) + if err != nil { + return err + } + + err = insertFromStructure(s, str, importer) if err != nil { log.Errorf("[creating structure] Error while creating structure: %s", err.Error()) _ = s.Rollback() @@ -355,6 +363,7 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas t.ProjectID = project.ID originalBucketID := t.BucketID t.BucketID = 0 + t.Assignees = remapAssignees(t.Assignees, user) err = t.Create(s, user) if err != nil { if models.IsErrTaskCannotBeEmpty(err) { @@ -393,6 +402,7 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas rt.ProjectID = t.ProjectID originalBucketID := rt.BucketID rt.BucketID = 0 + rt.Assignees = remapAssignees(rt.Assignees, user) err = rt.Create(s, user) if err != nil { @@ -606,3 +616,19 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas return nil } + +// Exported assignees carry user ids from a foreign instance. Only the importing +// user can be matched (by email, then username); everyone else is dropped. +func remapAssignees(assignees []*user.User, importer *user.User) []*user.User { + for _, a := range assignees { + if a == nil { + continue + } + emailMatch := a.Email != "" && importer.Email != "" && strings.EqualFold(a.Email, importer.Email) + usernameMatch := a.Username != "" && strings.EqualFold(a.Username, importer.Username) + if emailMatch || usernameMatch { + return []*user.User{importer} + } + } + return nil +} diff --git a/pkg/modules/migration/create_from_structure_test.go b/pkg/modules/migration/create_from_structure_test.go index f9aab3d10..d89785294 100644 --- a/pkg/modules/migration/create_from_structure_test.go +++ b/pkg/modules/migration/create_from_structure_test.go @@ -277,4 +277,42 @@ func TestInsertFromStructure(t *testing.T) { assert.Equal(t, int64(4), count, "task %q must keep position %v in all views", title, position) } }) + t.Run("assignees from a foreign instance", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + + foreignID := int64(999) + require.NoError(t, InsertFromStructure([]*models.ProjectWithTasksAndBuckets{{ + Project: models.Project{Title: "Import project"}, + Tasks: []*models.TaskWithComments{ + {Task: models.Task{Title: "email match", Assignees: []*user.User{{ID: foreignID, Username: "someone-else", Email: "USER1@example.com"}}}}, + {Task: models.Task{Title: "username match", Assignees: []*user.User{{ID: foreignID, Username: "user1"}}}}, + {Task: models.Task{Title: "no match", Assignees: []*user.User{{ID: 2, Username: "other", Email: "other@example.com"}}}}, + {Task: models.Task{ + Title: "related", + RelatedTasks: map[models.RelationKind][]*models.Task{ + models.RelationKindSubtask: {{Title: "related match", Assignees: []*user.User{{ID: foreignID, Username: "user1"}}}}, + }, + }}, + }, + }}, u)) + + s := db.NewSession() + defer s.Close() + + for title, wantAssignee := range map[string]bool{"email match": true, "username match": true, "related match": true, "no match": false} { + task := &models.Task{} + exists, err := s.Where("title = ?", title).Get(task) + require.NoError(t, err) + require.True(t, exists, title) + + assignees := []*models.TaskAssginee{} + require.NoError(t, s.Where("task_id = ?", task.ID).Find(&assignees)) + if wantAssignee { + require.Len(t, assignees, 1, title) + assert.Equal(t, u.ID, assignees[0].UserID, title) + } else { + assert.Empty(t, assignees, title) + } + } + }) }