Repository navigation
feat(contacts): show a contact person's name in Applications in use, on its page and in the organisation's list #1292
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
WilcoLouwerse
wants to merge
10
commits into
development
Choose a base branch
from
feat/woo586-usage-owner-names
base: development
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
fd49e61
feat(usage): show the business owner by name in the Applications in u…
WilcoLouwerse 9aaacca
fix(usage): look owner names up only in the caller's own address book…
WilcoLouwerse 36a0959
fix(usage): count a name only from the caller's own and shared addres…
WilcoLouwerse 756d4d9
fix(usage): load the settings before the risk signals read their objects
WilcoLouwerse 2ed63b6
feat(contacts): show a contact person's name on its page and in the o…
WilcoLouwerse cb48f55
fix(usage): keep the risk signal labels clear of their values
WilcoLouwerse 4fd9194
docs(spec): cover the contact person page and the list Name column in…
WilcoLouwerse e49e441
fix(contacts): say why a contact person has no name
WilcoLouwerse 7ca89b3
test(contacts): cover the contact person empty states and the risk si…
WilcoLouwerse afd1c6f
fix(contacts): keep the spinner until the contact person is loaded
WilcoLouwerse File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,256 @@ | ||
| <?php | ||
|
|
||
| /** | ||
| * Contact Names Controller for Stackiq | ||
| * | ||
| * Gives the display names of contact persons, read from the signed-in user's | ||
| * own Nextcloud address books, for the owner column of the usage list. | ||
| * | ||
| * @category Controller | ||
| * @package OCA\Stackiq\Controller | ||
| * @author Conduction b.v. <info@conduction.nl> | ||
| * @copyright 2026 Conduction B.V. <info@conduction.nl> | ||
| * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 | ||
| * @link https://github.com/ConductionNL/stackiq | ||
| * | ||
| * SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl> | ||
| * SPDX-License-Identifier: EUPL-1.2 | ||
| * | ||
| * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-003-a-usage-names-a-business-owner-and-a-technical-owner | ||
| */ | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace OCA\Stackiq\Controller; | ||
|
|
||
| use OCA\OpenRegister\Contract\ObjectServiceInterface; | ||
| use OCA\Stackiq\Service\SettingsService; | ||
| use OCA\Stackiq\Service\StackiqContactSyncService; | ||
| use OCP\AppFramework\Controller; | ||
| use OCP\AppFramework\Http; | ||
| use OCP\AppFramework\Http\Attribute\NoAdminRequired; | ||
| use OCP\AppFramework\Http\Attribute\NoCSRFRequired; | ||
| use OCP\AppFramework\Http\Attribute\UserRateLimit; | ||
| use OCP\AppFramework\Http\JSONResponse; | ||
| use OCP\IL10N; | ||
| use OCP\IRequest; | ||
| use OCP\IUserSession; | ||
| use Psr\Log\LoggerInterface; | ||
| use Throwable; | ||
|
|
||
| /** | ||
| * The display names of contact persons, for a signed-in user. | ||
| * | ||
| * A contact person in OpenRegister carries only a `contactsUid`; the name | ||
| * lives in Nextcloud Contacts (the CMDB import puts its owners in the | ||
| * importing admin's "Stackiq CMDB owners" address book). This endpoint reads | ||
| * the contact persons with the caller's own OpenRegister rights, then looks | ||
| * each `contactsUid` up, by exact UID, in the caller's own and shared address | ||
| * books only; the system principal's books (the local system address book | ||
| * and the books synced from trusted servers) never count, so a contactsUid | ||
| * set to a local or remote user id names nobody. A name is therefore returned only to someone who can | ||
| * already read both the contact person and the contact. Nothing is stored, | ||
| * and there is no public route. | ||
| * | ||
| * @category Controller | ||
| * @package OCA\Stackiq\Controller | ||
| * | ||
| * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-003-a-usage-names-a-business-owner-and-a-technical-owner | ||
| */ | ||
| class ContactNamesController extends Controller { | ||
|
|
||
| /** | ||
| * The most contact persons one request may name; a list page shows at most this many rows. | ||
| */ | ||
| public const MAX_IDS = 100; | ||
|
|
||
| /** | ||
| * Constructor for ContactNamesController. | ||
| * | ||
| * @param string $appName The app name. | ||
| * @param IRequest $request The request object. | ||
| * @param IUserSession $userSession The user session. | ||
| * @param IL10N $l10n Translations for the error messages. | ||
| * @param SettingsService $settingsService Gives the register and contact person schema. | ||
| * @param StackiqContactSyncService $contactSync Reads Nextcloud Contacts as the signed-in user. | ||
| * @param ObjectServiceInterface $objectService OpenRegister object access, with the caller's rights. | ||
| * @param LoggerInterface $logger The logger. | ||
| */ | ||
| public function __construct( | ||
| string $appName, | ||
| IRequest $request, | ||
| private readonly IUserSession $userSession, | ||
| private readonly IL10N $l10n, | ||
| private readonly SettingsService $settingsService, | ||
| private readonly StackiqContactSyncService $contactSync, | ||
| private readonly ObjectServiceInterface $objectService, | ||
| private readonly LoggerInterface $logger, | ||
| ) { | ||
| parent::__construct(appName: $appName, request: $request); | ||
| }//end __construct() | ||
|
|
||
| /** | ||
| * The display names of the given contact persons. | ||
| * | ||
| * API Endpoint: GET /api/contact-names?ids[]=<contact person uuid> | ||
| * | ||
| * Answers `{"names": {"<uuid>": "<display name>"}}`. The per-object | ||
| * check is OpenRegister's: the contact persons are read with the caller's | ||
| * RBAC and organisation scope, so one the caller may not read is left out, | ||
| * as is one whose contact is not in the caller's own or shared address | ||
| * books. At most 120 requests a minute per user (one per list page). | ||
| * | ||
| * @return JSONResponse 200 with the names, 400 for more than MAX_IDS ids, 401 when not signed in. | ||
| * | ||
| * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-003-a-usage-names-a-business-owner-and-a-technical-owner | ||
| */ | ||
| #[NoAdminRequired] | ||
| #[NoCSRFRequired] | ||
| #[UserRateLimit(limit: 120, period: 60)] | ||
| public function index(): JSONResponse { | ||
| if ($this->userSession->getUser() === null) { | ||
| return new JSONResponse( | ||
| ['message' => $this->l10n->t('Not signed in'), 'error' => 'not-authenticated'], | ||
| Http::STATUS_UNAUTHORIZED | ||
| ); | ||
| } | ||
|
|
||
| $ids = $this->requestedIds(); | ||
| if (count($ids) > self::MAX_IDS) { | ||
| return new JSONResponse( | ||
| ['message' => $this->l10n->t('At most %s contact persons per request', [self::MAX_IDS]), 'error' => 'too-many-ids'], | ||
| Http::STATUS_BAD_REQUEST | ||
| ); | ||
| } | ||
|
|
||
| if ($ids === [] || $this->contactSync->isAvailable() === false) { | ||
| return new JSONResponse(['names' => (object)[]], Http::STATUS_OK); | ||
| } | ||
|
|
||
| $names = []; | ||
| foreach ($this->contactsUidsOf(ids: $ids) as $uuid => $contactsUid) { | ||
| $contact = $this->contactSync->findOwnContactByUid(uid: $contactsUid); | ||
| $name = self::firstValue(value: ($contact['FN'] ?? '')); | ||
| if ($name !== '') { | ||
| $names[$uuid] = $name; | ||
| } | ||
| } | ||
|
|
||
| return new JSONResponse(['names' => (object)$names], Http::STATUS_OK); | ||
| }//end index() | ||
|
|
||
| /** | ||
| * The distinct, well-formed uuids in the `ids` query parameter, lower case. | ||
| * | ||
| * @return array<int, string> | ||
| */ | ||
| private function requestedIds(): array { | ||
| $raw = $this->request->getParam('ids', []); | ||
| if (is_string($raw) === true) { | ||
| $raw = [$raw]; | ||
| } | ||
|
|
||
| if (is_array($raw) === false) { | ||
| return []; | ||
| } | ||
|
|
||
| $ids = []; | ||
| foreach ($raw as $id) { | ||
| if (is_string($id) === true && preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $id) === 1) { | ||
| $ids[strtolower($id)] = true; | ||
| } | ||
| } | ||
|
|
||
| return array_keys($ids); | ||
| }//end requestedIds() | ||
|
|
||
| /** | ||
| * The `contactsUid` of each requested contact person the caller may read. | ||
| * | ||
| * Fails closed: without a configured register and contact person schema, | ||
| * or when OpenRegister cannot be read, no contact person is resolved. | ||
| * | ||
| * @param array<int, string> $ids Contact person uuids, lower case. | ||
| * | ||
| * @return array<string, string> uuid => contactsUid. | ||
| */ | ||
| private function contactsUidsOf(array $ids): array { | ||
| $config = $this->settingsService->getVoorzieningenConfig(); | ||
| $register = ($config['register'] ?? null); | ||
| $schema = ($config[SettingsService::LEGACY_SCHEMA_KEY['contactPerson']] ?? null); | ||
| if (empty($register) === true || empty($schema) === true) { | ||
| $this->logger->warning('[ContactNames] The register or the contact person schema is not configured; no names are given'); | ||
| return []; | ||
| } | ||
|
|
||
| try { | ||
| // RBAC and multitenancy stay at their defaults: the caller's own rights. | ||
| // The ids go in the query as `_ids`: OpenRegister's single | ||
| // register+schema path reads that key and ignores the `ids` argument. | ||
| $result = $this->objectService->searchObjectsPaginated( | ||
| query: [ | ||
| '@self' => ['register' => (int)$register, 'schema' => (int)$schema], | ||
| '_ids' => $ids, | ||
|
WilcoLouwerse marked this conversation as resolved.
|
||
| '_limit' => self::MAX_IDS, | ||
| ] | ||
| ); | ||
| } catch (Throwable $e) { | ||
| $this->logger->warning('[ContactNames] The contact persons could not be read', ['exception' => $e]); | ||
| return []; | ||
| } | ||
|
|
||
| $uids = []; | ||
| foreach (($result['results'] ?? []) as $entry) { | ||
| $data = self::objectData(entry: $entry); | ||
| $uuid = strtolower((string)($data['id'] ?? ($data['@self']['id'] ?? ''))); | ||
| $contactsUid = trim((string)($data['contactsUid'] ?? '')); | ||
| // The ids filter is OpenRegister's; a record it did not ask for is never named. | ||
| if ($contactsUid !== '' && in_array($uuid, $ids, true) === true) { | ||
| $uids[$uuid] = $contactsUid; | ||
| } | ||
| } | ||
|
|
||
| return $uids; | ||
| }//end contactsUidsOf() | ||
|
|
||
| /** | ||
| * The data of one search result, an entity or an array. | ||
| * | ||
| * @param mixed $entry The search result. | ||
| * | ||
| * @return array<string, mixed> | ||
| */ | ||
| private static function objectData(mixed $entry): array { | ||
| if (is_array($entry) === true) { | ||
| return $entry; | ||
| } | ||
|
|
||
| if (is_object($entry) === true && method_exists($entry, 'jsonSerialize') === true) { | ||
| $data = $entry->jsonSerialize(); | ||
| if (is_array($data) === true) { | ||
| return $data; | ||
| } | ||
| } | ||
|
|
||
| return []; | ||
| }//end objectData() | ||
|
|
||
| /** | ||
| * The first value of a vCard property, which the contacts manager may give as a list. | ||
| * | ||
| * @param mixed $value The property value. | ||
| * | ||
| * @return string | ||
| */ | ||
| private static function firstValue(mixed $value): string { | ||
| if (is_array($value) === true) { | ||
| $value = (reset($value) ?? ''); | ||
| } | ||
|
|
||
| if (is_scalar($value) === false) { | ||
| return ''; | ||
| } | ||
|
|
||
| return trim((string)$value); | ||
| }//end firstValue() | ||
| }//end class | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.