Files
nextcloud-workflow-deck-aut…/lib/Service/DeckIntegrationService.php
T
Patrick Niebeling b4057809ed
Build package / php-lint (8.2) (push) Successful in 49s
Build package / php-lint (8.3) (push) Successful in 42s
Build package / php-lint (8.4) (push) Successful in 37s
Build package / xml-lint (push) Successful in 13s
Build package / unit-tests (push) Successful in 44s
Build package / package (push) Successful in 59s
Run Deck as the workflow owner, and drop the job interval
Workflows were being switched off with "its board no longer exists or is
no longer available to you" for boards that were perfectly intact.

Deck does not read the session for permissions. PermissionService -- the
class behind every check, including the ones inside CardService::reorder()
-- takes the current user as a plain `private ?string $userId`, filled
from the app container's `userId` service (ISession::get('user_id'),
registered shared). Pimple resolves that once per process and caches it,
and ServerContainer caches Deck's app container just as long. In cron the
value is null whenever a Deck-owned job ran earlier in the same pass, and
otherwise the first workflow owner touched -- never the user being
impersonated. null fails every check, so Deck answered NoPermissionException
for an untouched board and findStackIds() read that as "the board is gone".

IUserSession::setUser() never had any effect on this path.

- DeckIntegrationService::beginUserContext()/endUserContext() pin
  PermissionService (mandatory), CardService, BoardService and
  ActivityManager (best effort) to the workflow owner, and restore them
  afterwards. If PermissionService cannot be pinned, the runner skips that
  user instead of acting under someone else's permissions.
- findStackIds() now takes the uid as an argument and is assembled from
  pieces that cannot answer for the wrong user: BoardMapper and StackMapper
  carry no user state, and getPermissions() is handed the uid explicitly.
  It no longer goes through StackService::findAll().
- NoPermissionException is no longer treated as "board missing". Unknown
  failures throw, which leaves the workflow enabled.

This also fixes the second half of the same defect: card moves silently
failed for every user except the first one processed in a cron pass.

Separately, RunWorkflowsJob is now a plain Job instead of a TimedJob and
runs on every cron pass. The workflow_deck_automation.interval config key
is gone. Time sensitivity is no longer merely declared but structurally
unreachable: JobList::add() leaves the column at its TIME_SENSITIVE
default, and the ratchet in setLastRun() only fires for TimedJob.
2026-08-13 22:40:57 +02:00

557 lines
18 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\WorkflowDeckAutomation\Service;
use OCA\Deck\Db\BoardMapper;
use OCA\Deck\Db\Card;
use OCA\Deck\Db\CardMapper;
use OCA\Deck\Db\StackMapper;
use OCA\Deck\Service\BoardService;
use OCA\Deck\Service\CardService;
use OCA\Deck\Service\PermissionService;
use OCA\Deck\Service\StackService;
use OCP\App\IAppManager;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\IGroupManager;
use OCP\IUser;
use OCP\IUserManager;
use OCP\Server;
use Psr\Log\LoggerInterface;
use ReflectionProperty;
use Throwable;
/**
* Single choke point for everything this app does with Deck.
*
* Deck's OCA\Deck\* classes are not a documented/stable public API (no
* @since markers, no <public> declaration in its info.xml). We use them
* directly anyway, per explicit product requirement (no HTTP/OCS calls
* against Deck are allowed), but every resolution and call is guarded so a
* Deck-side change degrades a single workflow instead of crashing the
* whole background run.
*/
class DeckIntegrationService {
private const DECK_APP_ID = 'deck';
/**
* Deck's Acl::PERMISSION_TYPE_* values, inlined so this class keeps
* working (degrading to "treat it as a user") if Deck ever moves or
* renames the constants.
*/
private const ACL_TYPE_USER = 0;
private const ACL_TYPE_GROUP = 1;
/**
* Deck's Acl::PERMISSION_READ, inlined for the same reason. It is the key
* under which PermissionService::getPermissions() reports read access.
*/
private const ACL_PERMISSION_READ = 0;
/**
* The property through which Deck carries "the current user" — a plain
* string frozen at construction rather than a session lookup. See
* beginUserContext().
*/
private const IDENTITY_PROPERTY = 'userId';
/**
* Further Deck classes holding that same frozen uid. Unlike
* PermissionService these do not decide access, only whose name ends up
* on an activity entry or whose unread-comment counts are computed, so
* failing to pin them is logged and shrugged off.
*/
private const OPTIONAL_IDENTITY_CLASSES = [
CardService::class,
BoardService::class,
'OCA\Deck\Activity\ActivityManager',
];
/**
* @var list<array{ReflectionProperty, object, ?string}>
*/
private array $identityRestore = [];
public function __construct(
private IAppManager $appManager,
private IUserManager $userManager,
private IGroupManager $groupManager,
private LoggerInterface $logger,
) {
}
/**
* Pins Deck to $user until endUserContext() — mandatory before any Deck
* call made on behalf of someone who is not the logged-in web user.
*
* IUserSession::setUser() alone is not enough, and that is not obvious:
* Deck's PermissionService — the class behind *every* permission check,
* including the ones inside CardService::reorder() — receives the current
* user as `private ?string $userId`. The app container fills that from
* `ISession::get('user_id')` through a **shared** service, so Pimple
* computes it once and caches it for the rest of the process, and
* ServerContainer keeps the app container just as long. Whoever was
* active when Deck's container first built PermissionService is therefore
* the user every later check is evaluated against.
*
* In the background job that is never the user we are impersonating: it
* is `null` whenever a Deck-owned job ran earlier in the same cron pass
* (cron.php works through many jobs in one process), and otherwise the
* first workflow owner we happened to touch. `null` fails every check,
* which is how intact boards started reporting NoPermissionException and
* got their workflows switched off.
*
* Deck exposes no API for this, so the frozen value is swapped directly
* and put back in endUserContext(). PermissionService is required: if it
* cannot be pinned this throws, and the caller has to skip the user
* rather than run them under someone else's permissions.
*
* @throws DeckUnavailableException
*/
public function beginUserContext(IUser $user): void {
if ($this->identityRestore !== []) {
throw new DeckUnavailableException('A Deck user context is already active');
}
$uid = $user->getUID();
try {
$this->pinDeckIdentity($this->resolve(PermissionService::class), $uid);
} catch (Throwable $e) {
$this->endUserContext();
throw new DeckUnavailableException(
'Could not run Deck as ' . $uid . ': ' . $e->getMessage(),
0,
$e,
);
}
foreach (self::OPTIONAL_IDENTITY_CLASSES as $class) {
try {
$this->pinDeckIdentity($this->resolve($class), $uid);
} catch (Throwable $e) {
$this->logger->debug('Could not pin ' . $class . ' to ' . $uid . ': ' . $e->getMessage(), [
'app' => 'workflow_deck_automation',
]);
}
}
}
/**
* Restores what beginUserContext() replaced. Idempotent, so it can be
* called unconditionally from a finally block.
*/
public function endUserContext(): void {
foreach (array_reverse($this->identityRestore) as [$property, $service, $previous]) {
try {
$property->setValue($service, $previous);
} catch (Throwable $e) {
$this->logger->warning('Could not restore Deck identity on ' . $service::class . ': ' . $e->getMessage(), [
'app' => 'workflow_deck_automation',
]);
}
}
$this->identityRestore = [];
}
private function pinDeckIdentity(object $service, string $uid): void {
$property = new ReflectionProperty($service, self::IDENTITY_PROPERTY);
$previous = $property->isInitialized($service) ? $property->getValue($service) : null;
$property->setValue($service, $uid);
$this->identityRestore[] = [$property, $service, is_string($previous) ? $previous : null];
}
public function assertDeckAvailable(IUser $user): void {
if (!$this->appManager->isEnabledForUser(self::DECK_APP_ID, $user)) {
throw new DeckUnavailableException('Deck is not enabled for user ' . $user->getUID());
}
if (!class_exists(CardService::class) || !class_exists(BoardService::class) || !class_exists(StackService::class)) {
throw new DeckUnavailableException('Deck internal classes are not available');
}
}
/**
* @return array<int, array{id: int, title: string}>
*
* Only ever call this from a *request*: getUserBoards() reads the uid
* BoardService froze at construction time, and beginUserContext() cannot
* help here — it repoints that field, but Deck may already have cached
* board lists behind it, and the job has no use for a board *list*
* anyway. It asks findStackIds() about one known board id instead.
*/
public function listBoardsForCurrentUser(): array {
$boards = $this->call(function () {
$boardService = $this->resolve(BoardService::class);
try {
// getUserBoards(?int $since, bool $includeArchived, …): with
// the default $includeArchived = true, Deck skips the
// `archived = false AND deleted_at = 0` conditions entirely,
// so archived *and* trashed boards come back. Ask for the
// filtered query instead of sorting them out afterwards.
return $boardService->getUserBoards(null, false);
} catch (Throwable $e) {
// Older/newer Deck with a different signature: fall back to
// the unfiltered call, isBoardHidden() below still filters.
return $boardService->getUserBoards();
}
}, []);
// Deck merges own/group/circle boards, so the same board can come
// back more than once; and boards in the trash or archived ones are
// no useful automation target. Keyed by id => deduplicated.
$result = [];
foreach ($boards as $board) {
$id = (int)$board->getId();
if (isset($result[$id]) || $this->isBoardHidden($board)) {
continue;
}
$title = $board->getTitle();
$result[$id] = [
'id' => $id,
'title' => (is_string($title) && $title !== '') ? $title : ('#' . $id),
];
}
return array_values($result);
}
private function isBoardHidden(mixed $board): bool {
if (method_exists($board, 'getDeletedAt') && (int)$board->getDeletedAt() > 0) {
return true;
}
return method_exists($board, 'getArchived') && $board->getArchived() === true;
}
/**
* @return array<int, array{id: int, title: string}>
*/
public function listStacks(int $boardId): array {
$stacks = $this->call(function () use ($boardId) {
return $this->resolve(StackService::class)->findAll($boardId);
}, []);
return array_map(
static fn ($stack) => ['id' => (int)$stack->getId(), 'title' => $stack->getTitle()],
$stacks,
);
}
/**
* Ids of the board's stacks, for deciding whether a stored workflow still
* points at anything real.
*
* Returns `null` only on positive knowledge that the board is gone, in the
* trash, or no longer readable for `$userId`. Every other failure throws,
* because "Deck is broken right now" must never be mistaken for "the user
* deleted this board" — the caller reacts by switching the workflow off
* and mailing its owner.
*
* Deliberately assembled from pieces that cannot answer for the wrong
* user. BoardMapper and StackMapper are plain QBMappers with no user
* state (StackMapper::findAll() filters `deleted_at = 0` itself), and
* getPermissions() is handed the uid explicitly instead of falling back
* to PermissionService's construction-time `$userId`. Leaning on that
* frozen value — which is exactly what `StackService::findAll()` does
* internally — is what disabled workflows whose board was perfectly
* intact, so this must not be "simplified" back to one service call even
* once beginUserContext() has pinned the identity.
*
* @return int[]|null
* @throws DeckUnavailableException
*/
public function findStackIds(int $boardId, string $userId): ?array {
try {
$board = $this->resolve(BoardMapper::class)->find($boardId);
if (method_exists($board, 'getDeletedAt') && (int)$board->getDeletedAt() > 0) {
return null;
}
$permissions = $this->resolve(PermissionService::class)->getPermissions($boardId, $userId);
if (!array_key_exists(self::ACL_PERMISSION_READ, $permissions)) {
// Unknown shape: say nothing rather than something wrong.
throw new DeckUnavailableException('Deck reported no read permission for board ' . $boardId);
}
if ($permissions[self::ACL_PERMISSION_READ] !== true) {
return null;
}
$stacks = $this->resolve(StackMapper::class)->findAll($boardId);
} catch (DeckUnavailableException $e) {
throw $e;
} catch (Throwable $e) {
if ($this->isMissing($e)) {
return null;
}
$this->logger->error('Could not list stacks of board ' . $boardId . ': ' . $e->getMessage(), [
'app' => 'workflow_deck_automation',
'exception' => $e,
]);
throw new DeckUnavailableException('Deck call failed: ' . $e->getMessage(), 0, $e);
}
return array_map(static fn ($stack) => (int)$stack->getId(), $stacks);
}
/**
* Deck's own exception classes are referenced by name: `is_a()` with a
* string simply returns false when the class does not exist, so a missing
* or renamed Deck degrades to "unknown error" instead of fataling.
*
* `OCA\Deck\NoPermissionException` is deliberately *not* treated as "the
* board is gone" here. It used to be, and that is what switched intact
* workflows off: Deck raises it for any check that fails, including every
* check evaluated against the wrong frozen uid — which says nothing at
* all about whether the board still exists.
*/
private function isMissing(Throwable $e): bool {
if ($e instanceof DoesNotExistException) {
return true;
}
return is_a($e, 'OCA\Deck\NotFoundException');
}
/**
* @return array<int, array{id: int, title: string, color: string|null}>
*/
public function listLabels(int $boardId): array {
$labels = $this->call(function () use ($boardId) {
$board = $this->resolve(BoardService::class)->find($boardId, true);
return $board->getLabels() ?? [];
}, []);
return array_map(
static fn ($label) => ['id' => $label->getId(), 'title' => $label->getTitle(), 'color' => $label->getColor()],
$labels,
);
}
/**
* Everyone who can hold a card on this board: the board owner (who is
* *not* part of the ACL — a private board has an empty ACL) plus every
* ACL entry, with group shares expanded to their members.
*
* @return array<int, array{uid: string, displayName: string}>
*/
public function listParticipants(int $boardId): array {
$uids = $this->call(function () use ($boardId) {
$board = $this->resolve(BoardService::class)->find($boardId, true);
$uids = [];
$owner = $this->unwrapUid($board->getOwner());
if ($owner !== null) {
$uids[] = $owner;
}
foreach ($board->getAcl() ?? [] as $entry) {
$principal = $this->extractParticipantUid($entry);
if ($principal === null) {
continue;
}
$type = method_exists($entry, 'getType') ? (int)$entry->getType() : self::ACL_TYPE_USER;
if ($type === self::ACL_TYPE_GROUP) {
array_push($uids, ...$this->groupMemberUids($principal));
} elseif ($type === self::ACL_TYPE_USER) {
$uids[] = $principal;
}
// Circles and federated shares are skipped: their members
// cannot be resolved to plain uids here.
}
return $uids;
}, []);
$participants = [];
foreach ($uids as $uid) {
if (isset($participants[$uid])) {
continue;
}
$participants[$uid] = [
'uid' => $uid,
'displayName' => $this->userManager->get($uid)?->getDisplayName() ?? $uid,
];
}
return array_values($participants);
}
/**
* @return string[]
*/
private function groupMemberUids(string $groupId): array {
$group = $this->groupManager->get($groupId);
if ($group === null) {
return [];
}
return array_map(static fn (IUser $user) => $user->getUID(), $group->getUsers());
}
/**
* Cards in the given stack that are neither archived, deleted nor marked
* done, enriched so getAssignedUsers()/getLabels() are populated.
*
* The `deletedAt` check is not redundant: Deck's
* `CardMapper::findAllByStack()` filters on `stack_id` and
* `archived = false` only, so cards sitting in the trash come back too —
* they are kept until Deck's own DeleteCron purges them. Without this we
* would move cards the user already deleted.
*
* @return Card[]
*/
public function getActiveCardsInStack(int $stackId): array {
return $this->call(function () use ($stackId) {
$cardMapper = $this->resolve(CardMapper::class);
$cardService = $this->resolve(CardService::class);
$cards = $cardMapper->findAllByStack($stackId);
$cards = $cardService->enrichCards($cards);
return array_values(array_filter($cards, static function (Card $card) {
if (method_exists($card, 'getDeletedAt') && (int)$card->getDeletedAt() > 0) {
return false;
}
return !$card->getArchived() && $card->getDone() === null;
}));
}, []);
}
/**
* @return string[]
*/
public function getCardAssignedUserIds(Card $card): array {
$assigned = $card->getAssignedUsers() ?? [];
$uids = [];
foreach ($assigned as $entry) {
$uid = $this->extractParticipantUid($entry);
if ($uid !== null) {
$uids[] = $uid;
}
}
return $uids;
}
/**
* @return int[]
*/
public function getCardLabelIds(Card $card): array {
$labels = $card->getLabels() ?? [];
$ids = [];
foreach ($labels as $label) {
if (method_exists($label, 'getId')) {
$ids[] = (int)$label->getId();
}
}
return $ids;
}
/**
* Moves a card to another stack using Deck's own reorder logic
* (the exact same code path behind Deck's "move card" action).
*/
public function moveCard(int $cardId, int $targetStackId): void {
$this->call(function () use ($cardId, $targetStackId) {
$this->resolve(CardService::class)->reorder($cardId, $targetStackId, 0);
return null;
}, null, true);
}
/**
* @template T
* @param callable(): T $callback
* @param T $fallback
* @return T
*/
private function call(callable $callback, mixed $fallback, bool $rethrow = false) {
try {
return $callback();
} catch (DeckUnavailableException $e) {
throw $e;
} catch (Throwable $e) {
$this->logger->error('Deck integration call failed: ' . $e->getMessage(), [
'app' => 'workflow_deck_automation',
'exception' => $e,
]);
if ($rethrow) {
throw new DeckUnavailableException('Deck call failed: ' . $e->getMessage(), 0, $e);
}
return $fallback;
}
}
/**
* @template T of object
* @param class-string<T> $class
* @return T
*/
private function resolve(string $class) {
if (!class_exists($class)) {
throw new DeckUnavailableException("Deck class {$class} does not exist");
}
return Server::get($class);
}
/**
* Deck's assignment/ACL entries are internal, undocumented value
* objects whose exact accessor shape has changed across versions, so
* we probe the common accessor names defensively instead of relying
* on one fixed method signature.
*/
private function extractParticipantUid(mixed $entry): ?string {
$uid = $this->unwrapUid($entry);
if ($uid !== null) {
return $uid;
}
if (!is_object($entry)) {
return null;
}
foreach (['getParticipant', 'getUid', 'getParticipantUid', 'getUserId'] as $method) {
if (method_exists($entry, $method)) {
$uid = $this->unwrapUid($entry->$method());
if ($uid !== null) {
return $uid;
}
}
}
return null;
}
/**
* Turns whatever Deck hands out for a "participant"/"owner" field into
* a plain uid.
*
* Enriched Deck entities do *not* return the raw uid string: their
* RelationalEntity base swaps resolved relations for a
* OCA\Deck\Db\RelationalObject wrapping a User/Group object, and its
* primary key is the uid we want. Unenriched entities still return the
* bare string, so both shapes are handled.
*/
private function unwrapUid(mixed $value): ?string {
if (is_string($value)) {
return $value !== '' ? $value : null;
}
if (!is_object($value)) {
return null;
}
foreach (['getPrimaryKey', 'getUID', 'getUid', 'getId'] as $method) {
if (method_exists($value, $method)) {
$inner = $value->$method();
if (is_string($inner) && $inner !== '') {
return $inner;
}
}
}
if (method_exists($value, 'getObject')) {
$inner = $value->getObject();
foreach (['getUID', 'getUid', 'getId'] as $method) {
if (is_object($inner) && method_exists($inner, $method)) {
$uid = $inner->$method();
if (is_string($uid) && $uid !== '') {
return $uid;
}
}
}
}
return null;
}
}