From fbec4b01af1e4658bcdefad7a6a00c421d73f555 Mon Sep 17 00:00:00 2001 From: Optic00 Date: Fri, 2 Oct 2026 19:42:59 +0200 Subject: [PATCH 1/2] Fix collection queue scope, inactive Zammad actors and stale mobile refreshes --- .../src/lib/mobile/MobileItemDetail.svelte | 1 + internal/repository/collection_repository.go | 52 +++++++++++-- internal/repository/queue_repository.go | 75 +++++++++++++++++-- .../collection_application_service.go | 3 + internal/services/item_support_queues.go | 42 +++++------ internal/services/zammad_service.go | 10 +++ 6 files changed, 146 insertions(+), 37 deletions(-) diff --git a/frontend/src/lib/mobile/MobileItemDetail.svelte b/frontend/src/lib/mobile/MobileItemDetail.svelte index 19a9d8123..aa0441a5e 100644 --- a/frontend/src/lib/mobile/MobileItemDetail.svelte +++ b/frontend/src/lib/mobile/MobileItemDetail.svelte @@ -295,6 +295,7 @@ if (token !== loadToken) return; item = { ...item, ...fresh }; } catch (err) { + if (token !== loadToken) return; // 404 => the item was deleted out from under us; leave the stale view. if (err?.status === 404) handleDeleted(); return; diff --git a/internal/repository/collection_repository.go b/internal/repository/collection_repository.go index 60bdcc7b7..d3b90ecd5 100644 --- a/internal/repository/collection_repository.go +++ b/internal/repository/collection_repository.go @@ -30,6 +30,10 @@ type CollectionRepository struct { db database.Database } +// ErrCollectionQueuesRequireWorkspace prevents orphaning queues when a +// collection is moved to the global scope, which has no queue storage scope. +var ErrCollectionQueuesRequireWorkspace = errors.New("collection queues require a workspace") + // NewCollectionRepository creates a collection repository. func NewCollectionRepository(db database.Database) *CollectionRepository { return &CollectionRepository{db: db} @@ -228,12 +232,48 @@ func (r *CollectionRepository) Create(collection *models.Collection, userID int) // Update replaces all editable collection fields. func (r *CollectionRepository) Update(id int, collection *models.Collection) error { - _, err := r.db.ExecWrite(` - UPDATE collections - SET name = ?, description = ?, ql_query = ?, filter_state = ?, is_public = ?, workspace_id = ?, category_id = ?, public_slug = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ? - `, collection.Name, collection.Description, collection.QLQuery, collection.FilterState, - collection.IsPublic, collection.WorkspaceID, collection.CategoryID, collection.PublicSlug, id) + err := database.WithTx(r.db, func(tx database.Tx) error { + // Serialize scope changes with queue creation before checking for queues. + // Updating only updated_at locks the row without firing the collection + // change trigger, which watches query, filter, and workspace columns. + var currentWorkspace sql.NullInt64 + err := tx.QueryRow(` + UPDATE collections SET updated_at = updated_at WHERE id = ? + RETURNING workspace_id + `, id).Scan(¤tWorkspace) + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + if collection.WorkspaceID == nil && currentWorkspace.Valid { + var queueCount int + if err := tx.QueryRow("SELECT COUNT(*) FROM queues WHERE collection_id = ?", id).Scan(&queueCount); err != nil { + return err + } + if queueCount > 0 { + return ErrCollectionQueuesRequireWorkspace + } + } + if _, err := tx.Exec(` + UPDATE collections + SET name = ?, description = ?, ql_query = ?, filter_state = ?, is_public = ?, workspace_id = ?, category_id = ?, public_slug = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, collection.Name, collection.Description, collection.QLQuery, collection.FilterState, + collection.IsPublic, collection.WorkspaceID, collection.CategoryID, collection.PublicSlug, id); err != nil { + return err + } + if collection.WorkspaceID != nil { + if _, err := tx.Exec(` + UPDATE queues SET workspace_id = ?, updated_at = CURRENT_TIMESTAMP + WHERE collection_id = ? AND workspace_id != ? + `, *collection.WorkspaceID, id, *collection.WorkspaceID); err != nil { + return err + } + } + return nil + }) if err != nil { return fmt.Errorf("update collection %d: %w", id, err) } diff --git a/internal/repository/queue_repository.go b/internal/repository/queue_repository.go index 8d2160c02..aaa870fd1 100644 --- a/internal/repository/queue_repository.go +++ b/internal/repository/queue_repository.go @@ -101,12 +101,17 @@ func (r *QueueRepository) Create(q *models.Queue) (*models.Queue, error) { now := time.Now() q.CreatedAt, q.UpdatedAt = now, now var id int - err := r.db.QueryRow(` - INSERT INTO queues - (workspace_id, collection_id, name, ql_query, filter_state, position, created_by, builtin_key, is_hidden, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id - `, q.WorkspaceID, q.CollectionID, q.Name, q.QLQuery, q.FilterState, q.Position, - q.CreatedBy, q.BuiltinKey, q.IsHidden, now, now).Scan(&id) + err := database.WithTx(r.db, func(tx database.Tx) error { + if err := lockQueueCollectionScope(tx, q.WorkspaceID, q.CollectionID); err != nil { + return err + } + return tx.QueryRow(` + INSERT INTO queues + (workspace_id, collection_id, name, ql_query, filter_state, position, created_by, builtin_key, is_hidden, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id + `, q.WorkspaceID, q.CollectionID, q.Name, q.QLQuery, q.FilterState, q.Position, + q.CreatedBy, q.BuiltinKey, q.IsHidden, now, now).Scan(&id) + }) if err != nil { if database.IsUniqueConstraintError(err) { return nil, ErrDuplicateEntry @@ -117,6 +122,64 @@ func (r *QueueRepository) Create(q *models.Queue) (*models.Queue, error) { return q, nil } +// lockQueueCollectionScope serializes queue writes with collection scope +// changes. A stale workspace is rejected after any in-flight move commits. +func lockQueueCollectionScope(tx database.Tx, workspaceID int, collectionID *int) error { + if collectionID == nil { + return nil + } + var currentWorkspace int + err := tx.QueryRow(` + UPDATE collections SET updated_at = updated_at + WHERE id = ? AND workspace_id = ? RETURNING workspace_id + `, *collectionID, workspaceID).Scan(¤tWorkspace) + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + return err +} + +// SetBuiltinHidden persists or removes a built-in queue dismissal while +// holding the collection scope lock through the whole operation. +func (r *QueueRepository) SetBuiltinHidden(q *models.Queue, hidden bool) error { + if q == nil || q.BuiltinKey == nil { + return errors.New("built-in queue key is required") + } + return database.WithTx(r.db, func(tx database.Tx) error { + if err := lockQueueCollectionScope(tx, q.WorkspaceID, q.CollectionID); err != nil { + return err + } + where, args := queueScopeClause(q.WorkspaceID, q.CollectionID) + args = append(args, *q.BuiltinKey) + if !hidden { + _, err := tx.Exec("DELETE FROM queues WHERE "+where+" AND builtin_key = ?", args...) + return err + } + var id int + var isHidden bool + err := tx.QueryRow("SELECT id, is_hidden FROM queues WHERE "+where+" AND builtin_key = ?", args...).Scan(&id, &isHidden) + if err == nil { + if isHidden { + return nil + } + _, err = tx.Exec("UPDATE queues SET is_hidden = ?, updated_at = ? WHERE id = ?", true, time.Now(), id) + return err + } + if !errors.Is(err, sql.ErrNoRows) { + return err + } + _, err = tx.Exec(` + INSERT INTO queues + (workspace_id, collection_id, name, ql_query, position, created_by, builtin_key, is_hidden, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `, q.WorkspaceID, q.CollectionID, q.Name, q.QLQuery, q.Position, q.CreatedBy, q.BuiltinKey, true) + if database.IsUniqueConstraintError(err) { + return ErrDuplicateEntry + } + return err + }) +} + // Update overwrites the mutable fields of a queue row. func (r *QueueRepository) Update(q *models.Queue) error { q.UpdatedAt = time.Now() diff --git a/internal/services/collection_application_service.go b/internal/services/collection_application_service.go index 7f3dc1f79..b387e5500 100644 --- a/internal/services/collection_application_service.go +++ b/internal/services/collection_application_service.go @@ -335,6 +335,9 @@ func (s *CollectionApplicationService) Update(actor AuditActor, id int, update C } } if err := s.repository.Update(id, collection); err != nil { + if errors.Is(err, repository.ErrCollectionQueuesRequireWorkspace) { + return nil, collectionValidation("remove collection queues before clearing its workspace") + } if database.IsUniqueConstraintError(err) { return nil, ErrCollectionConflict } diff --git a/internal/services/item_support_queues.go b/internal/services/item_support_queues.go index 8939d94ae..bdf9c951f 100644 --- a/internal/services/item_support_queues.go +++ b/internal/services/item_support_queues.go @@ -223,31 +223,16 @@ func (s *ItemApplicationService) SetBuiltinQueueHidden(actor AuditActor, workspa return repository.ErrNotFound } repo := repository.NewQueueRepository(s.db) - if !hidden { - return repo.DeleteByBuiltinKey(scope.WorkspaceID, scope.CollectionID, builtinKey) - } - existing, err := repo.GetByBuiltinKey(scope.WorkspaceID, scope.CollectionID, builtinKey) - if err == nil { - if existing.IsHidden { - return nil - } - existing.IsHidden = true - return repo.Update(existing) - } - if !errors.Is(err, repository.ErrNotFound) { - return err - } createdBy := actor.UserID - _, err = repo.Create(&models.Queue{ + err = repo.SetBuiltinHidden(&models.Queue{ WorkspaceID: scope.WorkspaceID, CollectionID: scope.CollectionID, Name: preset.Name, QLQuery: preset.QL, BuiltinKey: &preset.Key, - IsHidden: true, Position: builtinQueuePosition(preset.Key), CreatedBy: &createdBy, - }) + }, hidden) if errors.Is(err, repository.ErrDuplicateEntry) { return nil } @@ -367,20 +352,20 @@ func (s *ItemApplicationService) queueScope(workspaceID int, collectionID *int) return QueueScope{}, err } if collection.WorkspaceID == nil { - return QueueScope{}, queueValidation("collection is not bound to a workspace") + return QueueScope{}, repository.ErrNotFound } return QueueScope{WorkspaceID: *collection.WorkspaceID, CollectionID: collectionID}, nil } func (s *ItemApplicationService) authorizeQueueRead(userID int, scope QueueScope) error { + allowed, err := s.perm.HasWorkspacePermission(userID, scope.WorkspaceID, models.PermissionItemView) + if err != nil { + return err + } + if !allowed { + return repository.ErrNotFound + } if scope.CollectionID == nil { - allowed, err := s.perm.HasWorkspacePermission(userID, scope.WorkspaceID, models.PermissionItemView) - if err != nil { - return err - } - if !allowed { - return repository.ErrNotFound - } return nil } collection, err := repository.NewCollectionRepository(s.db).GetByID(*scope.CollectionID) @@ -404,6 +389,13 @@ func (s *ItemApplicationService) authorizeQueueWrite(userID int, scope QueueScop } return nil } + allowed, err := s.perm.HasWorkspacePermission(userID, scope.WorkspaceID, models.PermissionItemView) + if err != nil { + return err + } + if !allowed { + return repository.ErrNotFound + } collection, err := repository.NewCollectionRepository(s.db).GetByID(*scope.CollectionID) if err != nil { return err diff --git a/internal/services/zammad_service.go b/internal/services/zammad_service.go index 466c0b41d..4669f3247 100644 --- a/internal/services/zammad_service.go +++ b/internal/services/zammad_service.go @@ -1938,6 +1938,16 @@ func (s *ZammadService) completeWindshiftItem(ctx context.Context, link *models. if err != nil { return false, err } + actor, err := repository.NewUserRepository(s.db).GetActivationTarget(*connection.CreatedBy) + if errors.Is(err, repository.ErrNotFound) { + return false, errors.New("configured Zammad actor no longer exists") + } + if err != nil { + return false, err + } + if !actor.IsActive || actor.Offboarded { + return false, errors.New("configured Zammad actor is inactive or offboarded") + } allowed, err := s.permission.HasWorkspacePermission(*connection.CreatedBy, item.WorkspaceID, models.PermissionItemEdit) if err != nil { return false, err From ca5674382cb750b9d226b70067496f548f4937a0 Mon Sep 17 00:00:00 2001 From: Optic00 Date: Fri, 2 Oct 2026 19:42:59 +0200 Subject: [PATCH 2/2] Update DOMPurify and devalue security patches --- frontend/package-lock.json | 14 +++++++------- frontend/package.json | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fda1c5798..05812c53f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -31,7 +31,7 @@ "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0", "@xyflow/svelte": "^1.5.0", - "dompurify": "3.4.13", + "dompurify": "3.4.16", "marked": "^18.0.3", "mermaid": "^11.16.1", "react": "^18.3.1", @@ -5659,9 +5659,9 @@ "license": "MIT" }, "node_modules/devalue": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.2.tgz", - "integrity": "sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w==", + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", + "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", "license": "MIT" }, "node_modules/devlop": { @@ -5692,9 +5692,9 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.4.13", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", - "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", + "version": "3.4.16", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", + "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" diff --git a/frontend/package.json b/frontend/package.json index a1caddb22..ed8dabcde 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -78,7 +78,7 @@ "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0", "@xyflow/svelte": "^1.5.0", - "dompurify": "3.4.13", + "dompurify": "3.4.16", "marked": "^18.0.3", "mermaid": "^11.16.1", "react": "^18.3.1", @@ -106,7 +106,7 @@ "@milkdown/utils": { "nanoid": "^5.1.16" }, - "devalue": "^5.9.2", + "devalue": "^5.9.3", "lodash-es": "^4.18.1", "postcss": "8.5.23" }