feat(integration) : migrate Gitea/BookStack/Zimbra/Share into module (back)

LST-68 (2.6) backend. Behaviour-preserving move of the external integrations
into src/Module/Integration/. All 26 routes and securities unchanged.

- 5 entities (4 *Configuration singletons + TaskBookStackLink) + 5 repositories
  (Domain interfaces + Doctrine impls, bound). TaskBookStackLink.task now
  references TaskInterface (contract).
- Domain (FileSource interface, SharePathResolver, share DTOs + exceptions);
  Infrastructure (GiteaApiService, BookStackApiService, SmbFileSource, 15
  ApiResources, 21 State, 4 Share controllers).
- Cross-module couplings via abstractions: CalDavService (PM) injects
  ZimbraConfigurationRepositoryInterface; PM TaskDocument consumers repointed
  to the module's FileSource/SharePathResolver; Gitea/BookStack State load
  tasks via TaskRepositoryInterface (concrete Project read for integration
  fields — documented). ZimbraTestConnection keeps CalDavService (no build
  cycle). TokenEncryptor stays shared.
- IntegrationModule registered; doctrine mapping added.
- #[Auditable] + Timestampable on the 4 Configuration entities (additive
  migration on the 4 *_configuration tables).

163 tests green, container compiles (no cycle), no route regression, cs-fixer clean.
This commit is contained in:
Matthieu
2026-06-20 20:16:20 +02:00
parent bb7d7e7953
commit 90682e809c
79 changed files with 589 additions and 284 deletions
@@ -0,0 +1,236 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\Service;
use App\Module\Integration\Domain\Entity\BookStackConfiguration;
use App\Module\Integration\Domain\Exception\BookStackApiException;
use App\Module\Integration\Domain\Repository\BookStackConfigurationRepositoryInterface;
use App\Service\TokenEncryptor;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Throwable;
final class BookStackApiService
{
/** @var array<int, int[]> */
private array $shelfBookCache = [];
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly BookStackConfigurationRepositoryInterface $configRepository,
private readonly TokenEncryptor $tokenEncryptor,
) {}
public function testConnection(): bool
{
try {
$this->request('GET', '/api/docs.json');
return true;
} catch (BookStackApiException) {
return false;
}
}
/**
* @return array<array{id: int, name: string}>
*/
public function listShelves(): array
{
$result = [];
$offset = 0;
$count = 100;
do {
$data = $this->request('GET', '/api/shelves', [
'query' => ['count' => $count, 'offset' => $offset],
]);
$items = $data['data'] ?? [];
$result = array_merge($result, $items);
$offset += $count;
} while (!empty($items) && $count === count($items));
return $result;
}
/**
* Search for pages and books within a specific shelf.
*
* Algorithm:
* 1. Fetch the shelf data (book IDs + slugs)
* 2. Run two search queries (one for pages, one for books)
* 3. Filter results: pages must belong to a book on the shelf, books must be on the shelf
*
* @return array<array{id: int, type: string, name: string, url: string}>
*/
public function searchInShelf(int $shelfId, string $query): array
{
$shelfData = $this->request('GET', sprintf('/api/shelves/%d', $shelfId));
$books = $shelfData['books'] ?? [];
if (empty($books)) {
return [];
}
$bookIds = array_map(static fn (array $book): int => $book['id'], $books);
$bookSlugs = [];
foreach ($books as $book) {
$bookSlugs[$book['id']] = $book['slug'] ?? '';
}
// Update cache for getShelfBookIds
$this->shelfBookCache[$shelfId] = $bookIds;
$config = $this->getConfiguration();
$baseUrl = rtrim($config->getUrl() ?? '', '/');
$trimmed = trim($query);
// BookStack search API accepts {type:X} for one type at a time -- run two queries
$pageResults = $this->request('GET', '/api/search', [
'query' => ['query' => $trimmed.' {type:page}', 'count' => 50],
]);
$bookResults = $this->request('GET', '/api/search', [
'query' => ['query' => $trimmed.' {type:book}', 'count' => 50],
]);
$allResults = array_merge($pageResults['data'] ?? [], $bookResults['data'] ?? []);
$filtered = [];
foreach ($allResults as $item) {
$type = $item['type'] ?? '';
if ('page' === $type) {
$bookId = $item['book_id'] ?? 0;
if (in_array($bookId, $bookIds, true)) {
$filtered[] = [
'id' => $item['id'],
'type' => 'page',
'name' => $item['name'] ?? '',
'url' => $baseUrl.'/books/'.($bookSlugs[$bookId] ?? '').'/page/'.$item['slug'],
];
}
} elseif ('book' === $type && in_array($item['id'], $bookIds, true)) {
$filtered[] = [
'id' => $item['id'],
'type' => 'book',
'name' => $item['name'] ?? '',
'url' => $baseUrl.'/books/'.$item['slug'],
];
}
}
return $filtered;
}
/**
* @return array{id: int, name: string, slug: string}
*/
public function getPage(int $id): array
{
return $this->request('GET', sprintf('/api/pages/%d', $id));
}
/**
* @return array{id: int, name: string, slug: string}
*/
public function getBook(int $id): array
{
return $this->request('GET', sprintf('/api/books/%d', $id));
}
/**
* @return int[]
*/
private function getShelfBookIds(int $shelfId): array
{
if (isset($this->shelfBookCache[$shelfId])) {
return $this->shelfBookCache[$shelfId];
}
$data = $this->request('GET', sprintf('/api/shelves/%d', $shelfId));
$books = $data['books'] ?? [];
$ids = array_map(static fn (array $book): int => $book['id'], $books);
$this->shelfBookCache[$shelfId] = $ids;
return $ids;
}
private function getConfiguration(): BookStackConfiguration
{
$config = $this->configRepository->findSingleton();
if (null === $config) {
throw new BookStackApiException('BookStack is not configured.');
}
return $config;
}
/**
* @return array{tokenId: string, tokenSecret: string}
*/
private function getDecryptedTokens(BookStackConfiguration $config): array
{
$encryptedId = $config->getEncryptedTokenId();
$encryptedSecret = $config->getEncryptedTokenSecret();
if (null === $encryptedId || null === $encryptedSecret) {
throw new BookStackApiException('BookStack tokens are not set.');
}
try {
return [
'tokenId' => $this->tokenEncryptor->decrypt($encryptedId),
'tokenSecret' => $this->tokenEncryptor->decrypt($encryptedSecret),
];
} catch (Throwable $e) {
throw new BookStackApiException('Failed to decrypt BookStack tokens: '.$e->getMessage(), 0, $e);
}
}
private function extractError(HttpExceptionInterface $e): string
{
try {
$body = $e->getResponse()->getContent(false);
$data = json_decode($body, true);
if (is_array($data)) {
return $data['message'] ?? $data['error'] ?? $body;
}
return $body ?: 'Unknown BookStack error';
} catch (ExceptionInterface) {
return 'BookStack API error (HTTP '.$e->getResponse()->getStatusCode().')';
}
}
/**
* @param array<string, mixed> $options
*/
private function request(string $method, string $path, array $options = []): array
{
$config = $this->getConfiguration();
$tokens = $this->getDecryptedTokens($config);
$options['headers'] = array_merge($options['headers'] ?? [], [
'Authorization' => sprintf('Token %s:%s', $tokens['tokenId'], $tokens['tokenSecret']),
'Accept' => 'application/json',
]);
$options['timeout'] = 10;
try {
$response = $this->httpClient->request($method, rtrim($config->getUrl(), '/').$path, $options);
return $response->toArray();
} catch (HttpExceptionInterface $e) {
$message = $this->extractError($e);
throw new BookStackApiException($message, $e->getResponse()->getStatusCode(), $e);
} catch (ExceptionInterface $e) {
throw new BookStackApiException('BookStack API error: '.$e->getMessage(), 0, $e);
}
}
}