Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,8 @@

// User Profile endpoint
['name' => 'contactpersonen#getMe', 'url' => '/api/me', 'verb' => 'GET'],
// Display names of contact persons from the caller's own address books (owner columns).
['name' => 'contactNames#index', 'url' => '/api/contact-names', 'verb' => 'GET'],

// Contactpersonen Management endpoints
['name' => 'contactpersonen#getContactpersonen', 'url' => '/api/contactpersonen/organisation/{organisationId}', 'verb' => 'GET'],
Expand Down
8 changes: 7 additions & 1 deletion l10n/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -1132,7 +1132,13 @@ OC.L10N.register(
"The workbook holds more than {count} different texts, the most the import reads.": "The workbook holds more than {count} different texts, the most the import reads.",
"Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.",
"In development since": "In development since",
"In use since": "In use since"
"In use since": "In use since",
"Not signed in": "Not signed in",
"At most %s contact persons per request": "At most %s contact persons per request",
"Loading the name": "Loading the name",
"The name could not be loaded. Reload the page to try again.": "The name could not be loaded. Reload the page to try again.",
"This contact person is not linked to a Nextcloud contact.": "This contact person is not linked to a Nextcloud contact.",
"The name is kept in Nextcloud Contacts, in an address book you cannot read.": "The name is kept in Nextcloud Contacts, in an address book you cannot read."
},
"nplurals=2; plural=(n != 1);"
)
8 changes: 7 additions & 1 deletion l10n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1131,6 +1131,12 @@
"The workbook holds more than {count} different texts, the most the import reads.": "The workbook holds more than {count} different texts, the most the import reads.",
"Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.",
"In development since": "In development since",
"In use since": "In use since"
"In use since": "In use since",
"Not signed in": "Not signed in",
"At most %s contact persons per request": "At most %s contact persons per request",
"Loading the name": "Loading the name",
"The name could not be loaded. Reload the page to try again.": "The name could not be loaded. Reload the page to try again.",
"This contact person is not linked to a Nextcloud contact.": "This contact person is not linked to a Nextcloud contact.",
"The name is kept in Nextcloud Contacts, in an address book you cannot read.": "The name is kept in Nextcloud Contacts, in an address book you cannot read."
}
}
8 changes: 7 additions & 1 deletion l10n/nl.js
Original file line number Diff line number Diff line change
Expand Up @@ -1200,7 +1200,13 @@ OC.L10N.register(
"The workbook holds more than {count} different texts, the most the import reads.": "De werkmap bevat meer dan {count} verschillende teksten, het maximum dat de import leest.",
"Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Samen verwijzen de cellen van de werkmap naar meer dan {size} gedeelde tekst, het maximum dat de import leest.",
"In development since": "In ontwikkeling sinds",
"In use since": "In gebruik sinds"
"In use since": "In gebruik sinds",
"Not signed in": "Niet aangemeld",
"At most %s contact persons per request": "Ten hoogste %s contactpersonen per verzoek",
"Loading the name": "De naam wordt geladen",
"The name could not be loaded. Reload the page to try again.": "De naam kon niet worden geladen. Laad de pagina opnieuw om het nog eens te proberen.",
"This contact person is not linked to a Nextcloud contact.": "Deze contactpersoon is niet gekoppeld aan een Nextcloud-contact.",
"The name is kept in Nextcloud Contacts, in an address book you cannot read.": "De naam staat in Nextcloud Contacten, in een adresboek dat je niet kunt lezen."
},
"nplurals=2; plural=(n != 1);"
)
8 changes: 7 additions & 1 deletion l10n/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -1199,6 +1199,12 @@
"The workbook holds more than {count} different texts, the most the import reads.": "De werkmap bevat meer dan {count} verschillende teksten, het maximum dat de import leest.",
"Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Samen verwijzen de cellen van de werkmap naar meer dan {size} gedeelde tekst, het maximum dat de import leest.",
"In development since": "In ontwikkeling sinds",
"In use since": "In gebruik sinds"
"In use since": "In gebruik sinds",
"Not signed in": "Niet aangemeld",
"At most %s contact persons per request": "Ten hoogste %s contactpersonen per verzoek",
"Loading the name": "De naam wordt geladen",
"The name could not be loaded. Reload the page to try again.": "De naam kon niet worden geladen. Laad de pagina opnieuw om het nog eens te proberen.",
"This contact person is not linked to a Nextcloud contact.": "Deze contactpersoon is niet gekoppeld aan een Nextcloud-contact.",
"The name is kept in Nextcloud Contacts, in an address book you cannot read.": "De naam staat in Nextcloud Contacten, in een adresboek dat je niet kunt lezen."
}
}
256 changes: 256 additions & 0 deletions lib/Controller/ContactNamesController.php
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);
Comment thread
WilcoLouwerse marked this conversation as resolved.
$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,
Comment thread
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
Loading
Loading