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,81 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Domain\Entity;
use App\Module\Integration\Infrastructure\Doctrine\DoctrineBookStackConfigurationRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[Auditable]
#[ORM\Entity(repositoryClass: DoctrineBookStackConfigurationRepository::class)]
class BookStackConfiguration implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255, nullable: true)]
#[Assert\Url]
private ?string $url = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $encryptedTokenId = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $encryptedTokenSecret = null;
public function getId(): ?int
{
return $this->id;
}
public function getUrl(): ?string
{
return $this->url;
}
public function setUrl(?string $url): static
{
$this->url = $url;
return $this;
}
public function getEncryptedTokenId(): ?string
{
return $this->encryptedTokenId;
}
public function setEncryptedTokenId(?string $encryptedTokenId): static
{
$this->encryptedTokenId = $encryptedTokenId;
return $this;
}
public function getEncryptedTokenSecret(): ?string
{
return $this->encryptedTokenSecret;
}
public function setEncryptedTokenSecret(?string $encryptedTokenSecret): static
{
$this->encryptedTokenSecret = $encryptedTokenSecret;
return $this;
}
public function hasToken(): bool
{
return null !== $this->encryptedTokenId && null !== $this->encryptedTokenSecret;
}
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Domain\Entity;
use App\Module\Integration\Infrastructure\Doctrine\DoctrineGiteaConfigurationRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[Auditable]
#[ORM\Entity(repositoryClass: DoctrineGiteaConfigurationRepository::class)]
class GiteaConfiguration implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255, nullable: true)]
#[Assert\Url]
private ?string $url = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $encryptedToken = null;
public function getId(): ?int
{
return $this->id;
}
public function getUrl(): ?string
{
return $this->url;
}
public function setUrl(?string $url): static
{
$this->url = $url;
return $this;
}
public function getEncryptedToken(): ?string
{
return $this->encryptedToken;
}
public function setEncryptedToken(?string $encryptedToken): static
{
$this->encryptedToken = $encryptedToken;
return $this;
}
public function hasToken(): bool
{
return null !== $this->encryptedToken;
}
}
@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Domain\Entity;
use App\Module\Integration\Infrastructure\Doctrine\DoctrineShareConfigurationRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\ORM\Mapping as ORM;
#[Auditable]
#[ORM\Entity(repositoryClass: DoctrineShareConfigurationRepository::class)]
class ShareConfiguration implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $host = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $shareName = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $basePath = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $domain = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $username = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $encryptedPassword = null;
#[ORM\Column(type: 'boolean')]
private bool $enabled = false;
public function getId(): ?int
{
return $this->id;
}
public function getHost(): ?string
{
return $this->host;
}
public function setHost(?string $host): static
{
$this->host = $host;
return $this;
}
public function getShareName(): ?string
{
return $this->shareName;
}
public function setShareName(?string $shareName): static
{
$this->shareName = $shareName;
return $this;
}
public function getBasePath(): ?string
{
return $this->basePath;
}
public function setBasePath(?string $basePath): static
{
$this->basePath = $basePath;
return $this;
}
public function getDomain(): ?string
{
return $this->domain;
}
public function setDomain(?string $domain): static
{
$this->domain = $domain;
return $this;
}
public function getUsername(): ?string
{
return $this->username;
}
public function setUsername(?string $username): static
{
$this->username = $username;
return $this;
}
public function getEncryptedPassword(): ?string
{
return $this->encryptedPassword;
}
public function setEncryptedPassword(?string $encryptedPassword): static
{
$this->encryptedPassword = $encryptedPassword;
return $this;
}
public function isEnabled(): bool
{
return $this->enabled;
}
public function setEnabled(bool $enabled): static
{
$this->enabled = $enabled;
return $this;
}
public function hasPassword(): bool
{
return null !== $this->encryptedPassword;
}
public function isUsable(): bool
{
return $this->enabled
&& null !== $this->host && '' !== $this->host
&& null !== $this->shareName && '' !== $this->shareName;
}
}
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Domain\Entity;
use App\Module\Integration\Infrastructure\Doctrine\DoctrineTaskBookStackLinkRepository;
use App\Shared\Domain\Contract\TaskInterface;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: DoctrineTaskBookStackLinkRepository::class)]
#[ORM\UniqueConstraint(name: 'UNIQ_task_bookstack_link', columns: ['task_id', 'bookstack_id', 'bookstack_type'])]
class TaskBookStackLink
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: TaskInterface::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private TaskInterface $task;
#[ORM\Column]
private int $bookstackId;
#[ORM\Column(length: 10)]
private string $bookstackType;
#[ORM\Column(length: 255)]
private string $title;
#[ORM\Column(length: 500)]
#[Assert\Url]
private string $url;
#[ORM\Column]
private DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getTask(): TaskInterface
{
return $this->task;
}
public function setTask(TaskInterface $task): static
{
$this->task = $task;
return $this;
}
public function getBookstackId(): int
{
return $this->bookstackId;
}
public function setBookstackId(int $bookstackId): static
{
$this->bookstackId = $bookstackId;
return $this;
}
public function getBookstackType(): string
{
return $this->bookstackType;
}
public function setBookstackType(string $bookstackType): static
{
$this->bookstackType = $bookstackType;
return $this;
}
public function getTitle(): string
{
return $this->title;
}
public function setTitle(string $title): static
{
$this->title = $title;
return $this;
}
public function getUrl(): string
{
return $this->url;
}
public function setUrl(string $url): static
{
$this->url = $url;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
}
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Domain\Entity;
use App\Module\Integration\Infrastructure\Doctrine\DoctrineZimbraConfigurationRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[Auditable]
#[ORM\Entity(repositoryClass: DoctrineZimbraConfigurationRepository::class)]
class ZimbraConfiguration implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255, nullable: true)]
#[Assert\Url]
private ?string $serverUrl = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $username = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $encryptedPassword = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $calendarPath = null;
#[ORM\Column(type: 'boolean')]
private bool $enabled = false;
public function getId(): ?int
{
return $this->id;
}
public function getServerUrl(): ?string
{
return $this->serverUrl;
}
public function setServerUrl(?string $serverUrl): static
{
$this->serverUrl = $serverUrl;
return $this;
}
public function getUsername(): ?string
{
return $this->username;
}
public function setUsername(?string $username): static
{
$this->username = $username;
return $this;
}
public function getEncryptedPassword(): ?string
{
return $this->encryptedPassword;
}
public function setEncryptedPassword(?string $encryptedPassword): static
{
$this->encryptedPassword = $encryptedPassword;
return $this;
}
public function getCalendarPath(): ?string
{
return $this->calendarPath;
}
public function setCalendarPath(?string $calendarPath): static
{
$this->calendarPath = $calendarPath;
return $this;
}
public function isEnabled(): bool
{
return $this->enabled;
}
public function setEnabled(bool $enabled): static
{
$this->enabled = $enabled;
return $this;
}
public function hasPassword(): bool
{
return null !== $this->encryptedPassword;
}
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Domain\Exception;
use RuntimeException;
final class BookStackApiException extends RuntimeException {}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Domain\Exception;
use RuntimeException;
final class GiteaApiException extends RuntimeException {}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Domain\Exception;
use RuntimeException;
final class InvalidPathException extends RuntimeException {}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Domain\Exception;
use RuntimeException;
final class ShareConnectionException extends RuntimeException {}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Domain\Exception;
use RuntimeException;
final class ShareNotConfiguredException extends RuntimeException {}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Domain\Repository;
use App\Module\Integration\Domain\Entity\BookStackConfiguration;
interface BookStackConfigurationRepositoryInterface
{
public function findSingleton(): ?BookStackConfiguration;
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Domain\Repository;
use App\Module\Integration\Domain\Entity\GiteaConfiguration;
interface GiteaConfigurationRepositoryInterface
{
public function findSingleton(): ?GiteaConfiguration;
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Domain\Repository;
use App\Module\Integration\Domain\Entity\ShareConfiguration;
interface ShareConfigurationRepositoryInterface
{
public function findSingleton(): ?ShareConfiguration;
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Domain\Repository;
use App\Module\Integration\Domain\Entity\TaskBookStackLink;
interface TaskBookStackLinkRepositoryInterface
{
public function findById(int $id): ?TaskBookStackLink;
/**
* @return TaskBookStackLink[]
*/
public function findByTaskId(int $taskId): array;
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Domain\Repository;
use App\Module\Integration\Domain\Entity\ZimbraConfiguration;
interface ZimbraConfigurationRepositoryInterface
{
public function findSingleton(): ?ZimbraConfiguration;
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Domain\Service;
final readonly class FileEntry
{
public function __construct(
public string $name,
public string $path,
public bool $isDir,
public int $size,
public ?int $modifiedAt,
public string $mimeType,
) {}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Domain\Service;
interface FileSource
{
/**
* @return FileEntry[] dossiers d'abord, puis fichiers, triés par nom
*/
public function dir(string $relativePath): array;
/**
* Recherche récursive, par fragment de nom (insensible à la casse), dans tout le partage.
*
* @return FileEntry[] dossiers d'abord, puis fichiers, triés par nom (limités)
*/
public function search(string $query, int $limit = 200): array;
/**
* @return resource flux binaire en lecture
*/
public function read(string $relativePath);
public function test(): ShareTestResult;
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Domain\Service;
use App\Module\Integration\Domain\Exception\InvalidPathException;
final class SharePathResolver
{
/**
* Normalise un chemin relatif et rejette toute tentative de sortie de racine.
*/
public function normalizeRelative(string $path): string
{
$path = str_replace('\\', '/', $path);
$segments = [];
foreach (explode('/', $path) as $segment) {
if ('' === $segment || '.' === $segment) {
continue;
}
if ('..' === $segment) {
throw new InvalidPathException('Path traversal is not allowed.');
}
$segments[] = $segment;
}
return implode('/', $segments);
}
/**
* Construit le chemin SMB absolu (toujours sous basePath).
*/
public function fullPath(string $basePath, string $relativePath): string
{
$base = trim(str_replace('\\', '/', $basePath), '/');
$relative = $this->normalizeRelative($relativePath);
$parts = array_values(array_filter([$base, $relative], static fn (string $p): bool => '' !== $p));
return '/'.implode('/', $parts);
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Domain\Service;
final readonly class ShareTestResult
{
public function __construct(
public bool $success,
public ?string $message = null,
) {}
}
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Post;
use App\Module\Integration\Domain\Entity\TaskBookStackLink;
use App\Module\Integration\Infrastructure\ApiPlatform\State\BookStackLinkProcessor;
use App\Module\Integration\Infrastructure\ApiPlatform\State\BookStackLinkProvider;
use App\Module\ProjectManagement\Domain\Entity\Task;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(
uriTemplate: '/tasks/{taskId}/bookstack/links',
uriVariables: [
'taskId' => new Link(fromClass: Task::class, identifiers: ['id']),
],
normalizationContext: ['groups' => ['bookstack_link:read']],
provider: BookStackLinkProvider::class,
security: "is_granted('IS_AUTHENTICATED_FULLY')",
),
new Post(
uriTemplate: '/tasks/{taskId}/bookstack/links',
uriVariables: [
'taskId' => new Link(fromClass: Task::class, identifiers: ['id']),
],
denormalizationContext: ['groups' => ['bookstack_link:write']],
normalizationContext: ['groups' => ['bookstack_link:read']],
provider: BookStackLinkProvider::class,
processor: BookStackLinkProcessor::class,
security: "is_granted('IS_AUTHENTICATED_FULLY')",
),
new Delete(
uriTemplate: '/tasks/{taskId}/bookstack/links/{id}',
uriVariables: [
'taskId' => new Link(fromClass: Task::class, identifiers: ['id']),
'id' => new Link(fromClass: TaskBookStackLink::class, identifiers: ['id']),
],
provider: BookStackLinkProvider::class,
processor: BookStackLinkProcessor::class,
security: "is_granted('IS_AUTHENTICATED_FULLY')",
),
],
)]
final class BookStackLink
{
#[Groups(['bookstack_link:read'])]
public ?int $id = null;
#[Groups(['bookstack_link:read', 'bookstack_link:write'])]
public int $bookstackId = 0;
#[Groups(['bookstack_link:read', 'bookstack_link:write'])]
public string $bookstackType = '';
#[Groups(['bookstack_link:read', 'bookstack_link:write'])]
public string $title = '';
#[Groups(['bookstack_link:read', 'bookstack_link:write'])]
public string $url = '';
#[Groups(['bookstack_link:read'])]
public ?string $createdAt = null;
}
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use App\Module\Integration\Infrastructure\ApiPlatform\State\BookStackSearchResultProvider;
use App\Module\ProjectManagement\Domain\Entity\Task;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(
uriTemplate: '/tasks/{taskId}/bookstack/search',
uriVariables: [
'taskId' => new Link(fromClass: Task::class, identifiers: ['id']),
],
normalizationContext: ['groups' => ['bookstack_search:read']],
provider: BookStackSearchResultProvider::class,
security: "is_granted('IS_AUTHENTICATED_FULLY')",
),
],
)]
final class BookStackSearchResult
{
#[Groups(['bookstack_search:read'])]
public int $id = 0;
#[Groups(['bookstack_search:read'])]
public string $type = '';
#[Groups(['bookstack_search:read'])]
public string $name = '';
#[Groups(['bookstack_search:read'])]
public string $url = '';
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Put;
use App\Module\Integration\Infrastructure\ApiPlatform\State\BookStackSettingsProcessor;
use App\Module\Integration\Infrastructure\ApiPlatform\State\BookStackSettingsProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/settings/bookstack',
normalizationContext: ['groups' => ['bookstack_settings:read']],
provider: BookStackSettingsProvider::class,
security: "is_granted('ROLE_ADMIN')",
),
new Put(
uriTemplate: '/settings/bookstack',
denormalizationContext: ['groups' => ['bookstack_settings:write']],
normalizationContext: ['groups' => ['bookstack_settings:read']],
provider: BookStackSettingsProvider::class,
processor: BookStackSettingsProcessor::class,
security: "is_granted('ROLE_ADMIN')",
),
],
)]
final class BookStackSettings
{
#[Groups(['bookstack_settings:read', 'bookstack_settings:write'])]
public ?string $url = null;
#[Groups(['bookstack_settings:write'])]
public ?string $tokenId = null;
#[Groups(['bookstack_settings:write'])]
public ?string $tokenSecret = null;
#[Groups(['bookstack_settings:read'])]
public bool $hasToken = false;
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use App\Module\Integration\Infrastructure\ApiPlatform\State\BookStackShelfProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(
uriTemplate: '/bookstack/shelves',
normalizationContext: ['groups' => ['bookstack_shelf:read']],
provider: BookStackShelfProvider::class,
security: "is_granted('ROLE_ADMIN')",
),
],
)]
final class BookStackShelf
{
#[Groups(['bookstack_shelf:read'])]
public int $id = 0;
#[Groups(['bookstack_shelf:read'])]
public string $name = '';
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\Module\Integration\Infrastructure\ApiPlatform\State\BookStackTestConnectionProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Post(
uriTemplate: '/settings/bookstack/test',
input: false,
normalizationContext: ['groups' => ['bookstack_test:read']],
provider: BookStackTestConnectionProvider::class,
processor: BookStackTestConnectionProvider::class,
security: "is_granted('ROLE_ADMIN')",
),
],
)]
final class BookStackTestConnection
{
#[Groups(['bookstack_test:read'])]
public bool $success = false;
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use App\Module\Integration\Infrastructure\ApiPlatform\State\GiteaBranchProcessor;
use App\Module\Integration\Infrastructure\ApiPlatform\State\GiteaBranchProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(
uriTemplate: '/tasks/{taskId}/gitea/branches',
normalizationContext: ['groups' => ['gitea_branch:read']],
provider: GiteaBranchProvider::class,
security: "is_granted('ROLE_USER')",
),
new Post(
uriTemplate: '/tasks/{taskId}/gitea/branches',
denormalizationContext: ['groups' => ['gitea_branch:write']],
normalizationContext: ['groups' => ['gitea_branch:read']],
provider: GiteaBranchProvider::class,
processor: GiteaBranchProcessor::class,
security: "is_granted('ROLE_USER')",
),
],
)]
final class GiteaBranch
{
#[Groups(['gitea_branch:read'])]
public string $name = '';
#[Groups(['gitea_branch:write'])]
public string $type = 'feature';
#[Groups(['gitea_branch:write'])]
public string $baseBranch = 'main';
/**
* @var array<array{sha: string, message: string, author: string, date: string}>
*/
#[Groups(['gitea_branch:read'])]
public array $commits = [];
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Module\Integration\Infrastructure\ApiPlatform\State\GiteaBranchNameProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/tasks/{taskId}/gitea/branch-name/{type}',
normalizationContext: ['groups' => ['gitea_branch_name:read']],
provider: GiteaBranchNameProvider::class,
security: "is_granted('ROLE_USER')",
),
],
)]
final class GiteaBranchName
{
#[Groups(['gitea_branch_name:read'])]
public string $name = '';
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use App\Module\Integration\Infrastructure\ApiPlatform\State\GiteaPullRequestProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(
uriTemplate: '/tasks/{taskId}/gitea/pull-requests',
normalizationContext: ['groups' => ['gitea_pr:read']],
provider: GiteaPullRequestProvider::class,
security: "is_granted('ROLE_USER')",
),
],
)]
final class GiteaPullRequest
{
#[Groups(['gitea_pr:read'])]
public int $number = 0;
#[Groups(['gitea_pr:read'])]
public string $title = '';
#[Groups(['gitea_pr:read'])]
public string $state = '';
#[Groups(['gitea_pr:read'])]
public bool $merged = false;
#[Groups(['gitea_pr:read'])]
public string $headBranch = '';
#[Groups(['gitea_pr:read'])]
public string $author = '';
#[Groups(['gitea_pr:read'])]
public string $url = '';
/**
* @var array<array{context: string, status: string, target_url: string}>
*/
#[Groups(['gitea_pr:read'])]
public array $ciStatuses = [];
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use App\Module\Integration\Infrastructure\ApiPlatform\State\GiteaRepositoryProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(
uriTemplate: '/gitea/repositories',
normalizationContext: ['groups' => ['gitea_repo:read']],
provider: GiteaRepositoryProvider::class,
security: "is_granted('ROLE_ADMIN')",
),
],
)]
final class GiteaRepository
{
#[Groups(['gitea_repo:read'])]
public string $fullName = '';
#[Groups(['gitea_repo:read'])]
public string $name = '';
#[Groups(['gitea_repo:read'])]
public string $owner = '';
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Put;
use App\Module\Integration\Infrastructure\ApiPlatform\State\GiteaSettingsProcessor;
use App\Module\Integration\Infrastructure\ApiPlatform\State\GiteaSettingsProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/settings/gitea',
normalizationContext: ['groups' => ['gitea_settings:read']],
provider: GiteaSettingsProvider::class,
security: "is_granted('ROLE_ADMIN')",
),
new Put(
uriTemplate: '/settings/gitea',
denormalizationContext: ['groups' => ['gitea_settings:write']],
normalizationContext: ['groups' => ['gitea_settings:read']],
provider: GiteaSettingsProvider::class,
processor: GiteaSettingsProcessor::class,
security: "is_granted('ROLE_ADMIN')",
),
],
)]
final class GiteaSettings
{
#[Groups(['gitea_settings:read', 'gitea_settings:write'])]
public ?string $url = null;
#[Groups(['gitea_settings:write'])]
public ?string $token = null;
#[Groups(['gitea_settings:read'])]
public bool $hasToken = false;
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\Module\Integration\Infrastructure\ApiPlatform\State\GiteaTestConnectionProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Post(
uriTemplate: '/settings/gitea/test',
input: false,
normalizationContext: ['groups' => ['gitea_test:read']],
provider: GiteaTestConnectionProvider::class,
processor: GiteaTestConnectionProvider::class,
security: "is_granted('ROLE_ADMIN')",
),
],
)]
final class GiteaTestConnection
{
#[Groups(['gitea_test:read'])]
public bool $success = false;
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Put;
use App\Module\Integration\Infrastructure\ApiPlatform\State\ShareSettingsProcessor;
use App\Module\Integration\Infrastructure\ApiPlatform\State\ShareSettingsProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/settings/share',
normalizationContext: ['groups' => ['share_settings:read']],
provider: ShareSettingsProvider::class,
security: "is_granted('ROLE_ADMIN')",
),
new Put(
uriTemplate: '/settings/share',
denormalizationContext: ['groups' => ['share_settings:write']],
normalizationContext: ['groups' => ['share_settings:read']],
provider: ShareSettingsProvider::class,
processor: ShareSettingsProcessor::class,
security: "is_granted('ROLE_ADMIN')",
),
],
)]
final class ShareSettings
{
#[Groups(['share_settings:read', 'share_settings:write'])]
public ?string $host = null;
#[Groups(['share_settings:read', 'share_settings:write'])]
public ?string $shareName = null;
#[Groups(['share_settings:read', 'share_settings:write'])]
public ?string $basePath = null;
#[Groups(['share_settings:read', 'share_settings:write'])]
public ?string $domain = null;
#[Groups(['share_settings:read', 'share_settings:write'])]
public ?string $username = null;
#[Groups(['share_settings:write'])]
public ?string $password = null;
#[Groups(['share_settings:read', 'share_settings:write'])]
public bool $enabled = false;
#[Groups(['share_settings:read'])]
public bool $hasPassword = false;
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\Module\Integration\Infrastructure\ApiPlatform\State\ShareTestConnectionProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Post(
uriTemplate: '/settings/share/test',
input: false,
normalizationContext: ['groups' => ['share_test:read']],
provider: ShareTestConnectionProvider::class,
processor: ShareTestConnectionProvider::class,
security: "is_granted('ROLE_ADMIN')",
),
],
)]
final class ShareTestConnection
{
#[Groups(['share_test:read'])]
public bool $success = false;
#[Groups(['share_test:read'])]
public ?string $message = null;
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Put;
use App\Module\Integration\Infrastructure\ApiPlatform\State\ZimbraSettingsProcessor;
use App\Module\Integration\Infrastructure\ApiPlatform\State\ZimbraSettingsProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/settings/zimbra',
normalizationContext: ['groups' => ['zimbra_settings:read']],
provider: ZimbraSettingsProvider::class,
security: "is_granted('ROLE_ADMIN')",
),
new Put(
uriTemplate: '/settings/zimbra',
denormalizationContext: ['groups' => ['zimbra_settings:write']],
normalizationContext: ['groups' => ['zimbra_settings:read']],
provider: ZimbraSettingsProvider::class,
processor: ZimbraSettingsProcessor::class,
security: "is_granted('ROLE_ADMIN')",
),
],
)]
final class ZimbraSettings
{
#[Groups(['zimbra_settings:read', 'zimbra_settings:write'])]
public ?string $serverUrl = null;
#[Groups(['zimbra_settings:read', 'zimbra_settings:write'])]
public ?string $username = null;
#[Groups(['zimbra_settings:read', 'zimbra_settings:write'])]
public ?string $calendarPath = null;
#[Groups(['zimbra_settings:write'])]
public ?string $password = null;
#[Groups(['zimbra_settings:read', 'zimbra_settings:write'])]
public bool $enabled = false;
#[Groups(['zimbra_settings:read'])]
public bool $hasPassword = false;
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\Module\Integration\Infrastructure\ApiPlatform\State\ZimbraTestConnectionProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Post(
uriTemplate: '/settings/zimbra/test',
input: false,
normalizationContext: ['groups' => ['zimbra_test:read']],
provider: ZimbraTestConnectionProvider::class,
processor: ZimbraTestConnectionProvider::class,
security: "is_granted('ROLE_ADMIN')",
),
],
)]
final class ZimbraTestConnection
{
#[Groups(['zimbra_test:read'])]
public bool $success = false;
}
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Integration\Domain\Entity\TaskBookStackLink;
use App\Module\Integration\Domain\Repository\TaskBookStackLinkRepositoryInterface;
use App\Module\Integration\Infrastructure\ApiPlatform\Resource\BookStackLink;
use App\Module\ProjectManagement\Domain\Repository\TaskRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final readonly class BookStackLinkProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $em,
private TaskBookStackLinkRepositoryInterface $linkRepository,
private TaskRepositoryInterface $taskRepository,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ?BookStackLink
{
if ($operation instanceof Delete) {
return $this->handleDelete($uriVariables);
}
return $this->handleCreate($data, $uriVariables);
}
private function handleCreate(mixed $data, array $uriVariables): BookStackLink
{
assert($data instanceof BookStackLink);
$taskId = $uriVariables['taskId'] ?? 0;
$task = $this->taskRepository->findById((int) $taskId);
if (null === $task) {
throw new NotFoundHttpException('Task not found.');
}
$link = new TaskBookStackLink();
$link->setTask($task);
$link->setBookstackId($data->bookstackId);
$link->setBookstackType($data->bookstackType);
$link->setTitle($data->title);
$link->setUrl($data->url);
$this->em->persist($link);
$this->em->flush();
$result = new BookStackLink();
$result->id = $link->getId();
$result->bookstackId = $link->getBookstackId();
$result->bookstackType = $link->getBookstackType();
$result->title = $link->getTitle();
$result->url = $link->getUrl();
$result->createdAt = $link->getCreatedAt()->format('c');
return $result;
}
private function handleDelete(array $uriVariables): null
{
$linkId = $uriVariables['id'] ?? 0;
$link = $this->linkRepository->findById((int) $linkId);
if (null === $link) {
throw new NotFoundHttpException('Link not found.');
}
$this->em->remove($link);
$this->em->flush();
return null;
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProviderInterface;
use App\Module\Integration\Domain\Entity\TaskBookStackLink;
use App\Module\Integration\Domain\Repository\TaskBookStackLinkRepositoryInterface;
use App\Module\Integration\Infrastructure\ApiPlatform\Resource\BookStackLink;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final readonly class BookStackLinkProvider implements ProviderInterface
{
public function __construct(
private TaskBookStackLinkRepositoryInterface $linkRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|BookStackLink
{
if ($operation instanceof Post) {
return new BookStackLink();
}
if ($operation instanceof Delete) {
$link = $this->linkRepository->find($uriVariables['id'] ?? 0);
if (null === $link) {
throw new NotFoundHttpException('Link not found.');
}
$dto = new BookStackLink();
$dto->id = $link->getId();
return $dto;
}
$taskId = $uriVariables['taskId'] ?? 0;
$links = $this->linkRepository->findByTaskId($taskId);
return array_map(static function (TaskBookStackLink $link): BookStackLink {
$dto = new BookStackLink();
$dto->id = $link->getId();
$dto->bookstackId = $link->getBookstackId();
$dto->bookstackType = $link->getBookstackType();
$dto->title = $link->getTitle();
$dto->url = $link->getUrl();
$dto->createdAt = $link->getCreatedAt()->format('c');
return $dto;
}, $links);
}
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Module\Integration\Domain\Exception\BookStackApiException;
use App\Module\Integration\Infrastructure\ApiPlatform\Resource\BookStackSearchResult;
use App\Module\Integration\Infrastructure\Service\BookStackApiService;
use App\Module\ProjectManagement\Domain\Repository\TaskRepositoryInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
final readonly class BookStackSearchResultProvider implements ProviderInterface
{
public function __construct(
private BookStackApiService $bookStackApiService,
private TaskRepositoryInterface $taskRepository,
private RequestStack $requestStack,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
$taskId = $uriVariables['taskId'] ?? 0;
$task = $this->taskRepository->findById((int) $taskId);
if (null === $task || null === $task->getProject()) {
return [];
}
$shelfId = $task->getProject()->getBookstackShelfId();
if (null === $shelfId) {
return [];
}
$request = $this->requestStack->getCurrentRequest();
$query = $request?->query->get('q', '') ?? '';
if ('' === trim($query)) {
return [];
}
try {
$results = $this->bookStackApiService->searchInShelf($shelfId, $query);
} catch (BookStackApiException $e) {
throw new BadRequestHttpException($e->getMessage(), $e);
}
return array_map(static function (array $item): BookStackSearchResult {
$dto = new BookStackSearchResult();
$dto->id = $item['id'];
$dto->type = $item['type'];
$dto->name = $item['name'];
$dto->url = $item['url'];
return $dto;
}, $results);
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Integration\Domain\Entity\BookStackConfiguration;
use App\Module\Integration\Domain\Repository\BookStackConfigurationRepositoryInterface;
use App\Module\Integration\Infrastructure\ApiPlatform\Resource\BookStackSettings;
use App\Service\TokenEncryptor;
use Doctrine\ORM\EntityManagerInterface;
final readonly class BookStackSettingsProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $em,
private BookStackConfigurationRepositoryInterface $configRepository,
private TokenEncryptor $tokenEncryptor,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): BookStackSettings
{
assert($data instanceof BookStackSettings);
$config = $this->configRepository->findSingleton();
if (null === $config) {
$config = new BookStackConfiguration();
}
$config->setUrl($data->url);
if (null !== $data->tokenId && '' !== $data->tokenId
&& null !== $data->tokenSecret && '' !== $data->tokenSecret) {
$config->setEncryptedTokenId($this->tokenEncryptor->encrypt($data->tokenId));
$config->setEncryptedTokenSecret($this->tokenEncryptor->encrypt($data->tokenSecret));
}
$this->em->persist($config);
$this->em->flush();
$result = new BookStackSettings();
$result->url = $config->getUrl();
$result->hasToken = $config->hasToken();
return $result;
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Module\Integration\Domain\Repository\BookStackConfigurationRepositoryInterface;
use App\Module\Integration\Infrastructure\ApiPlatform\Resource\BookStackSettings;
final readonly class BookStackSettingsProvider implements ProviderInterface
{
public function __construct(
private BookStackConfigurationRepositoryInterface $configRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): BookStackSettings
{
$config = $this->configRepository->findSingleton();
$dto = new BookStackSettings();
if (null !== $config) {
$dto->url = $config->getUrl();
$dto->hasToken = $config->hasToken();
}
return $dto;
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Module\Integration\Domain\Exception\BookStackApiException;
use App\Module\Integration\Infrastructure\ApiPlatform\Resource\BookStackShelf;
use App\Module\Integration\Infrastructure\Service\BookStackApiService;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
final readonly class BookStackShelfProvider implements ProviderInterface
{
public function __construct(
private BookStackApiService $bookStackApiService,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
try {
$shelves = $this->bookStackApiService->listShelves();
} catch (BookStackApiException $e) {
throw new BadRequestHttpException($e->getMessage(), $e);
}
return array_map(static function (array $shelf): BookStackShelf {
$dto = new BookStackShelf();
$dto->id = $shelf['id'] ?? 0;
$dto->name = $shelf['name'] ?? '';
return $dto;
}, $shelves);
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\State\ProviderInterface;
use App\Module\Integration\Infrastructure\ApiPlatform\Resource\BookStackTestConnection;
use App\Module\Integration\Infrastructure\Service\BookStackApiService;
final readonly class BookStackTestConnectionProvider implements ProviderInterface, ProcessorInterface
{
public function __construct(
private BookStackApiService $bookStackApiService,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): BookStackTestConnection
{
return new BookStackTestConnection();
}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): BookStackTestConnection
{
$result = new BookStackTestConnection();
$result->success = $this->bookStackApiService->testConnection();
return $result;
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Module\Integration\Infrastructure\ApiPlatform\Resource\GiteaBranchName;
use App\Module\Integration\Infrastructure\Service\GiteaApiService;
use App\Module\ProjectManagement\Domain\Repository\TaskRepositoryInterface;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final readonly class GiteaBranchNameProvider implements ProviderInterface
{
/** @see GiteaBranchProcessor::ALLOWED_TYPES */
private const array ALLOWED_TYPES = ['feature', 'fix', 'refactor', 'hotfix', 'chore'];
public function __construct(
private GiteaApiService $giteaApiService,
private TaskRepositoryInterface $taskRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): GiteaBranchName
{
$task = $this->taskRepository->findById((int) ($uriVariables['taskId'] ?? 0));
if (null === $task) {
throw new NotFoundHttpException('Task not found.');
}
$type = $uriVariables['type'] ?? 'feature';
if (!in_array($type, self::ALLOWED_TYPES, true)) {
throw new BadRequestHttpException('Invalid branch type.');
}
$dto = new GiteaBranchName();
$dto->name = $this->giteaApiService->generateBranchName($task, $type);
return $dto;
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Integration\Domain\Exception\GiteaApiException;
use App\Module\Integration\Infrastructure\ApiPlatform\Resource\GiteaBranch;
use App\Module\Integration\Infrastructure\Service\GiteaApiService;
use App\Module\ProjectManagement\Domain\Repository\TaskRepositoryInterface;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final readonly class GiteaBranchProcessor implements ProcessorInterface
{
private const array ALLOWED_TYPES = ['feature', 'fix', 'refactor', 'hotfix', 'chore'];
public function __construct(
private GiteaApiService $giteaApiService,
private TaskRepositoryInterface $taskRepository,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): GiteaBranch
{
assert($data instanceof GiteaBranch);
$task = $this->taskRepository->findById((int) ($uriVariables['taskId'] ?? 0));
if (null === $task || null === $task->getProject()) {
throw new NotFoundHttpException('Task not found.');
}
$project = $task->getProject();
if (!$project->hasGiteaRepo()) {
throw new BadRequestHttpException('Project has no Gitea repository.');
}
if (!in_array($data->type, self::ALLOWED_TYPES, true)) {
throw new BadRequestHttpException('Invalid branch type.');
}
try {
$branchName = $this->giteaApiService->createBranch($project, $task, $data->type, $data->baseBranch);
} catch (GiteaApiException $e) {
throw new BadRequestHttpException($e->getMessage());
}
$result = new GiteaBranch();
$result->name = $branchName;
return $result;
}
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProviderInterface;
use App\Module\Integration\Domain\Exception\GiteaApiException;
use App\Module\Integration\Infrastructure\ApiPlatform\Resource\GiteaBranch;
use App\Module\Integration\Infrastructure\Service\GiteaApiService;
use App\Module\ProjectManagement\Domain\Repository\TaskRepositoryInterface;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
final readonly class GiteaBranchProvider implements ProviderInterface
{
public function __construct(
private GiteaApiService $giteaApiService,
private TaskRepositoryInterface $taskRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|GiteaBranch
{
if ($operation instanceof Post) {
return new GiteaBranch();
}
$task = $this->taskRepository->findById((int) ($uriVariables['taskId'] ?? 0));
if (null === $task || null === $task->getProject()) {
return [];
}
$project = $task->getProject();
if (!$project->hasGiteaRepo()) {
return [];
}
$taskCode = $project->getCode().'-'.$task->getNumber();
try {
$branches = $this->giteaApiService->listBranches($project, $taskCode);
} catch (GiteaApiException $e) {
throw new BadRequestHttpException($e->getMessage(), $e);
}
$result = [];
foreach ($branches as $branch) {
$dto = new GiteaBranch();
$dto->name = $branch['name'];
try {
$commits = $this->giteaApiService->listBranchCommits($project, $branch['name']);
$dto->commits = array_map(static fn (array $c): array => [
'sha' => substr($c['sha'] ?? '', 0, 7),
'message' => $c['commit']['message'] ?? '',
'author' => $c['commit']['author']['name'] ?? '',
'date' => $c['commit']['author']['date'] ?? $c['created'] ?? '',
], $commits);
} catch (GiteaApiException) {
// Commits fetch failure should not block branch listing
$dto->commits = [];
}
$result[] = $dto;
}
return $result;
}
}
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Module\Integration\Domain\Exception\GiteaApiException;
use App\Module\Integration\Infrastructure\ApiPlatform\Resource\GiteaPullRequest;
use App\Module\Integration\Infrastructure\Service\GiteaApiService;
use App\Module\ProjectManagement\Domain\Repository\TaskRepositoryInterface;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
final readonly class GiteaPullRequestProvider implements ProviderInterface
{
public function __construct(
private GiteaApiService $giteaApiService,
private TaskRepositoryInterface $taskRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
$task = $this->taskRepository->findById((int) ($uriVariables['taskId'] ?? 0));
if (null === $task || null === $task->getProject()) {
return [];
}
$project = $task->getProject();
if (!$project->hasGiteaRepo()) {
return [];
}
$taskCode = $project->getCode().'-'.$task->getNumber();
try {
$prs = $this->giteaApiService->listPullRequests($project, $taskCode);
} catch (GiteaApiException $e) {
throw new BadRequestHttpException($e->getMessage(), $e);
}
return array_map(static function (array $pr): GiteaPullRequest {
$dto = new GiteaPullRequest();
$dto->number = $pr['number'] ?? 0;
$dto->title = $pr['title'] ?? '';
$dto->state = $pr['state'] ?? '';
$dto->merged = $pr['merged'] ?? false;
$dto->headBranch = $pr['head']['ref'] ?? '';
$dto->author = $pr['user']['login'] ?? '';
$dto->url = $pr['html_url'] ?? '';
$dto->ciStatuses = array_map(static fn (array $s): array => [
'context' => $s['context'] ?? '',
'status' => $s['status'] ?? '',
'target_url' => $s['target_url'] ?? '',
], $pr['ci_statuses'] ?? []);
return $dto;
}, $prs);
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Module\Integration\Domain\Exception\GiteaApiException;
use App\Module\Integration\Infrastructure\ApiPlatform\Resource\GiteaRepository;
use App\Module\Integration\Infrastructure\Service\GiteaApiService;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
final readonly class GiteaRepositoryProvider implements ProviderInterface
{
public function __construct(
private GiteaApiService $giteaApiService,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
try {
$repos = $this->giteaApiService->listRepositories();
} catch (GiteaApiException $e) {
throw new BadRequestHttpException($e->getMessage(), $e);
}
return array_map(static function (array $repo): GiteaRepository {
$dto = new GiteaRepository();
$dto->fullName = $repo['full_name'] ?? '';
$dto->name = $repo['name'] ?? '';
$dto->owner = $repo['owner']['login'] ?? '';
return $dto;
}, $repos);
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Integration\Domain\Entity\GiteaConfiguration;
use App\Module\Integration\Domain\Repository\GiteaConfigurationRepositoryInterface;
use App\Module\Integration\Infrastructure\ApiPlatform\Resource\GiteaSettings;
use App\Service\TokenEncryptor;
use Doctrine\ORM\EntityManagerInterface;
final readonly class GiteaSettingsProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $em,
private GiteaConfigurationRepositoryInterface $configRepository,
private TokenEncryptor $tokenEncryptor,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): GiteaSettings
{
assert($data instanceof GiteaSettings);
$config = $this->configRepository->findSingleton();
if (null === $config) {
$config = new GiteaConfiguration();
}
$config->setUrl($data->url);
if (null !== $data->token && '' !== $data->token) {
$config->setEncryptedToken($this->tokenEncryptor->encrypt($data->token));
}
$this->em->persist($config);
$this->em->flush();
$result = new GiteaSettings();
$result->url = $config->getUrl();
$result->hasToken = $config->hasToken();
return $result;
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Module\Integration\Domain\Repository\GiteaConfigurationRepositoryInterface;
use App\Module\Integration\Infrastructure\ApiPlatform\Resource\GiteaSettings;
final readonly class GiteaSettingsProvider implements ProviderInterface
{
public function __construct(
private GiteaConfigurationRepositoryInterface $configRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): GiteaSettings
{
$config = $this->configRepository->findSingleton();
$dto = new GiteaSettings();
if (null !== $config) {
$dto->url = $config->getUrl();
$dto->hasToken = $config->hasToken();
}
return $dto;
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\State\ProviderInterface;
use App\Module\Integration\Infrastructure\ApiPlatform\Resource\GiteaTestConnection;
use App\Module\Integration\Infrastructure\Service\GiteaApiService;
final readonly class GiteaTestConnectionProvider implements ProviderInterface, ProcessorInterface
{
public function __construct(
private GiteaApiService $giteaApiService,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): GiteaTestConnection
{
return new GiteaTestConnection();
}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): GiteaTestConnection
{
$result = new GiteaTestConnection();
$result->success = $this->giteaApiService->testConnection();
return $result;
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Integration\Domain\Entity\ShareConfiguration;
use App\Module\Integration\Domain\Repository\ShareConfigurationRepositoryInterface;
use App\Module\Integration\Infrastructure\ApiPlatform\Resource\ShareSettings;
use App\Service\TokenEncryptor;
use Doctrine\ORM\EntityManagerInterface;
final readonly class ShareSettingsProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $em,
private ShareConfigurationRepositoryInterface $configRepository,
private TokenEncryptor $tokenEncryptor,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ShareSettings
{
assert($data instanceof ShareSettings);
$config = $this->configRepository->findSingleton() ?? new ShareConfiguration();
$config->setHost($data->host);
$config->setShareName($data->shareName);
$config->setBasePath($data->basePath);
$config->setDomain($data->domain);
$config->setUsername($data->username);
$config->setEnabled($data->enabled);
if (null !== $data->password && '' !== $data->password) {
$config->setEncryptedPassword($this->tokenEncryptor->encrypt($data->password));
}
$this->em->persist($config);
$this->em->flush();
$result = new ShareSettings();
$result->host = $config->getHost();
$result->shareName = $config->getShareName();
$result->basePath = $config->getBasePath();
$result->domain = $config->getDomain();
$result->username = $config->getUsername();
$result->enabled = $config->isEnabled();
$result->hasPassword = $config->hasPassword();
return $result;
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Module\Integration\Domain\Repository\ShareConfigurationRepositoryInterface;
use App\Module\Integration\Infrastructure\ApiPlatform\Resource\ShareSettings;
final readonly class ShareSettingsProvider implements ProviderInterface
{
public function __construct(
private ShareConfigurationRepositoryInterface $configRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ShareSettings
{
$config = $this->configRepository->findSingleton();
$dto = new ShareSettings();
if (null !== $config) {
$dto->host = $config->getHost();
$dto->shareName = $config->getShareName();
$dto->basePath = $config->getBasePath();
$dto->domain = $config->getDomain();
$dto->username = $config->getUsername();
$dto->enabled = $config->isEnabled();
$dto->hasPassword = $config->hasPassword();
}
return $dto;
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\State\ProviderInterface;
use App\Module\Integration\Domain\Service\FileSource;
use App\Module\Integration\Infrastructure\ApiPlatform\Resource\ShareTestConnection;
final readonly class ShareTestConnectionProvider implements ProviderInterface, ProcessorInterface
{
public function __construct(
private FileSource $fileSource,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ShareTestConnection
{
return new ShareTestConnection();
}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ShareTestConnection
{
$result = $this->fileSource->test();
$dto = new ShareTestConnection();
$dto->success = $result->success;
$dto->message = $result->message;
return $dto;
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Integration\Domain\Entity\ZimbraConfiguration;
use App\Module\Integration\Domain\Repository\ZimbraConfigurationRepositoryInterface;
use App\Module\Integration\Infrastructure\ApiPlatform\Resource\ZimbraSettings;
use App\Service\TokenEncryptor;
use Doctrine\ORM\EntityManagerInterface;
final readonly class ZimbraSettingsProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $em,
private ZimbraConfigurationRepositoryInterface $configRepository,
private TokenEncryptor $tokenEncryptor,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ZimbraSettings
{
assert($data instanceof ZimbraSettings);
$config = $this->configRepository->findSingleton();
if (null === $config) {
$config = new ZimbraConfiguration();
}
$config->setServerUrl($data->serverUrl);
$config->setUsername($data->username);
$config->setCalendarPath($data->calendarPath);
$config->setEnabled($data->enabled);
if (null !== $data->password && '' !== $data->password) {
$config->setEncryptedPassword($this->tokenEncryptor->encrypt($data->password));
}
$this->em->persist($config);
$this->em->flush();
$result = new ZimbraSettings();
$result->serverUrl = $config->getServerUrl();
$result->username = $config->getUsername();
$result->calendarPath = $config->getCalendarPath();
$result->enabled = $config->isEnabled();
$result->hasPassword = $config->hasPassword();
return $result;
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Module\Integration\Domain\Repository\ZimbraConfigurationRepositoryInterface;
use App\Module\Integration\Infrastructure\ApiPlatform\Resource\ZimbraSettings;
final readonly class ZimbraSettingsProvider implements ProviderInterface
{
public function __construct(
private ZimbraConfigurationRepositoryInterface $configRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ZimbraSettings
{
$config = $this->configRepository->findSingleton();
$dto = new ZimbraSettings();
if (null !== $config) {
$dto->serverUrl = $config->getServerUrl();
$dto->username = $config->getUsername();
$dto->calendarPath = $config->getCalendarPath();
$dto->enabled = $config->isEnabled();
$dto->hasPassword = $config->hasPassword();
}
return $dto;
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\State\ProviderInterface;
use App\Module\Integration\Infrastructure\ApiPlatform\Resource\ZimbraTestConnection;
use App\Module\ProjectManagement\Infrastructure\Service\CalDavService;
use Throwable;
final readonly class ZimbraTestConnectionProvider implements ProviderInterface, ProcessorInterface
{
public function __construct(
private CalDavService $calDavService,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ZimbraTestConnection
{
return new ZimbraTestConnection();
}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ZimbraTestConnection
{
$result = new ZimbraTestConnection();
try {
$result->success = $this->calDavService->testConnection();
} catch (Throwable) {
$result->success = false;
}
return $result;
}
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\Controller;
use App\Module\Integration\Domain\Exception\InvalidPathException;
use App\Module\Integration\Domain\Exception\ShareConnectionException;
use App\Module\Integration\Domain\Exception\ShareNotConfiguredException;
use App\Module\Integration\Domain\Service\FileEntry;
use App\Module\Integration\Domain\Service\FileSource;
use App\Module\Integration\Domain\Service\SharePathResolver;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class ShareBrowseController extends AbstractController
{
public function __construct(
private readonly FileSource $fileSource,
private readonly SharePathResolver $pathResolver,
) {}
#[Route('/api/share/browse', name: 'share_browse', methods: ['GET'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function __invoke(Request $request): JsonResponse
{
$rawPath = (string) $request->query->get('path', '');
try {
$path = $this->pathResolver->normalizeRelative($rawPath);
} catch (InvalidPathException) {
return new JsonResponse(['error' => 'Invalid path.'], 400);
}
try {
$entries = $this->fileSource->dir($path);
} catch (ShareNotConfiguredException) {
return new JsonResponse(['error' => 'Share not configured.'], 409);
} catch (ShareConnectionException) {
return new JsonResponse(['error' => 'Unable to reach the file share.'], 502);
}
return new JsonResponse([
'path' => $path,
'breadcrumb' => $this->breadcrumb($path),
'entries' => array_map(static fn (FileEntry $e): array => [
'name' => $e->name,
'path' => $e->path,
'isDir' => $e->isDir,
'size' => $e->size,
'modifiedAt' => $e->modifiedAt,
'mimeType' => $e->mimeType,
], $entries),
]);
}
/**
* @return array<int, array{name: string, path: string}>
*/
private function breadcrumb(string $path): array
{
if ('' === $path) {
return [];
}
$crumbs = [];
$acc = '';
foreach (explode('/', $path) as $segment) {
$acc = '' === $acc ? $segment : $acc.'/'.$segment;
$crumbs[] = ['name' => $segment, 'path' => $acc];
}
return $crumbs;
}
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\Controller;
use App\Module\Integration\Domain\Exception\InvalidPathException;
use App\Module\Integration\Domain\Exception\ShareConnectionException;
use App\Module\Integration\Domain\Exception\ShareNotConfiguredException;
use App\Module\Integration\Domain\Service\FileSource;
use App\Module\Integration\Domain\Service\SharePathResolver;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Mime\MimeTypes;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use function is_resource;
class ShareDownloadController extends AbstractController
{
public function __construct(
private readonly FileSource $fileSource,
private readonly SharePathResolver $pathResolver,
) {}
#[Route('/api/share/download', name: 'share_download', methods: ['GET'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function __invoke(Request $request): Response
{
$rawPath = (string) $request->query->get('path', '');
try {
$path = $this->pathResolver->normalizeRelative($rawPath);
} catch (InvalidPathException) {
return new Response('Invalid path.', 400);
}
if ('' === $path) {
throw new NotFoundHttpException('No file requested.');
}
try {
$stream = $this->fileSource->read($path);
} catch (ShareNotConfiguredException) {
return new Response('Share not configured.', 409);
} catch (ShareConnectionException) {
throw new NotFoundHttpException('File not found.');
}
$name = basename($path);
$extension = pathinfo($name, PATHINFO_EXTENSION);
$mime = MimeTypes::getDefault()->getMimeTypes($extension)[0] ?? 'application/octet-stream';
// Anti-XSS : seuls des types non exécutables sont servis inline (images hors SVG, PDF).
// Tout le reste (HTML, SVG, octet-stream, etc.) est forcé en attachment, même si inline est demandé.
$inlineSafe = ('image/svg+xml' !== $mime && str_starts_with($mime, 'image/')) || 'application/pdf' === $mime;
$wantInline = 'attachment' !== $request->query->get('disposition');
$disposition = ($inlineSafe && $wantInline) ? HeaderUtils::DISPOSITION_INLINE : HeaderUtils::DISPOSITION_ATTACHMENT;
$response = new StreamedResponse(function () use ($stream): void {
if (is_resource($stream)) {
fpassthru($stream);
fclose($stream);
}
});
$response->headers->set('Content-Type', $mime);
$response->headers->set('Content-Disposition', HeaderUtils::makeDisposition($disposition, $name));
// Empêche le navigateur de "deviner" un type exécutable à partir du contenu.
$response->headers->set('X-Content-Type-Options', 'nosniff');
return $response;
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\Controller;
use App\Module\Integration\Domain\Exception\ShareConnectionException;
use App\Module\Integration\Domain\Exception\ShareNotConfiguredException;
use App\Module\Integration\Domain\Service\FileEntry;
use App\Module\Integration\Domain\Service\FileSource;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class ShareSearchController extends AbstractController
{
/** Longueur minimale du terme de recherche (évite un parcours global trop large). */
private const int MIN_QUERY_LENGTH = 2;
public function __construct(
private readonly FileSource $fileSource,
) {}
#[Route('/api/share/search', name: 'share_search', methods: ['GET'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function __invoke(Request $request): JsonResponse
{
$query = trim((string) $request->query->get('q', ''));
if (mb_strlen($query) < self::MIN_QUERY_LENGTH) {
return new JsonResponse(['query' => $query, 'entries' => []]);
}
try {
$entries = $this->fileSource->search($query);
} catch (ShareNotConfiguredException) {
return new JsonResponse(['error' => 'Share not configured.'], 409);
} catch (ShareConnectionException) {
return new JsonResponse(['error' => 'Unable to reach the file share.'], 502);
}
return new JsonResponse([
'query' => $query,
'entries' => array_map(static fn (FileEntry $e): array => [
'name' => $e->name,
'path' => $e->path,
'isDir' => $e->isDir,
'size' => $e->size,
'modifiedAt' => $e->modifiedAt,
'mimeType' => $e->mimeType,
], $entries),
]);
}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\Controller;
use App\Module\Integration\Domain\Repository\ShareConfigurationRepositoryInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class ShareStatusController extends AbstractController
{
public function __construct(
private readonly ShareConfigurationRepositoryInterface $configRepository,
) {}
#[Route('/api/share/status', name: 'share_status', methods: ['GET'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function __invoke(): JsonResponse
{
$config = $this->configRepository->findSingleton();
return new JsonResponse(['enabled' => null !== $config && $config->isUsable()]);
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\Doctrine;
use App\Module\Integration\Domain\Entity\BookStackConfiguration;
use App\Module\Integration\Domain\Repository\BookStackConfigurationRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<BookStackConfiguration>
*/
class DoctrineBookStackConfigurationRepository extends ServiceEntityRepository implements BookStackConfigurationRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, BookStackConfiguration::class);
}
public function findSingleton(): ?BookStackConfiguration
{
return $this->findOneBy([]);
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\Doctrine;
use App\Module\Integration\Domain\Entity\GiteaConfiguration;
use App\Module\Integration\Domain\Repository\GiteaConfigurationRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<GiteaConfiguration>
*/
class DoctrineGiteaConfigurationRepository extends ServiceEntityRepository implements GiteaConfigurationRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, GiteaConfiguration::class);
}
public function findSingleton(): ?GiteaConfiguration
{
return $this->findOneBy([]);
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\Doctrine;
use App\Module\Integration\Domain\Entity\ShareConfiguration;
use App\Module\Integration\Domain\Repository\ShareConfigurationRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ShareConfiguration>
*/
class DoctrineShareConfigurationRepository extends ServiceEntityRepository implements ShareConfigurationRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ShareConfiguration::class);
}
public function findSingleton(): ?ShareConfiguration
{
return $this->createQueryBuilder('s')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult()
;
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\Doctrine;
use App\Module\Integration\Domain\Entity\TaskBookStackLink;
use App\Module\Integration\Domain\Repository\TaskBookStackLinkRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<TaskBookStackLink>
*/
class DoctrineTaskBookStackLinkRepository extends ServiceEntityRepository implements TaskBookStackLinkRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TaskBookStackLink::class);
}
public function findById(int $id): ?TaskBookStackLink
{
return $this->find($id);
}
/**
* @return TaskBookStackLink[]
*/
public function findByTaskId(int $taskId): array
{
return $this->findBy(['task' => $taskId], ['createdAt' => 'DESC']);
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\Doctrine;
use App\Module\Integration\Domain\Entity\ZimbraConfiguration;
use App\Module\Integration\Domain\Repository\ZimbraConfigurationRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ZimbraConfiguration>
*/
class DoctrineZimbraConfigurationRepository extends ServiceEntityRepository implements ZimbraConfigurationRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ZimbraConfiguration::class);
}
public function findSingleton(): ?ZimbraConfiguration
{
return $this->createQueryBuilder('z')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult()
;
}
}
@@ -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);
}
}
}
@@ -0,0 +1,270 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\Service;
use App\Module\Integration\Domain\Entity\GiteaConfiguration;
use App\Module\Integration\Domain\Exception\GiteaApiException;
use App\Module\Integration\Domain\Repository\GiteaConfigurationRepositoryInterface;
use App\Module\ProjectManagement\Domain\Entity\Project;
use App\Module\ProjectManagement\Domain\Entity\Task;
use App\Service\TokenEncryptor;
use Symfony\Component\String\Slugger\AsciiSlugger;
use Symfony\Component\String\Slugger\SluggerInterface;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Throwable;
final readonly class GiteaApiService
{
private SluggerInterface $slugger;
public function __construct(
private HttpClientInterface $httpClient,
private GiteaConfigurationRepositoryInterface $configRepository,
private TokenEncryptor $tokenEncryptor,
) {
$this->slugger = new AsciiSlugger('fr');
}
public function testConnection(): bool
{
try {
$this->request('GET', '/api/v1/version');
return true;
} catch (GiteaApiException) {
return false;
}
}
/**
* @return array<array{full_name: string, name: string, owner: array{login: string}}>
*/
public function listRepositories(): array
{
$result = [];
$page = 1;
do {
$data = $this->request('GET', '/api/v1/repos/search', [
'query' => ['page' => $page, 'limit' => 50],
]);
$result = array_merge($result, $data['data'] ?? []);
++$page;
} while (!empty($data['data']) && 50 === count($data['data']));
return $result;
}
public function getDefaultBranch(Project $project): string
{
$this->assertProjectHasRepo($project);
$data = $this->request('GET', sprintf(
'/api/v1/repos/%s/%s',
$project->getGiteaOwner(),
$project->getGiteaRepo(),
));
return $data['default_branch'] ?? 'main';
}
public function createBranch(Project $project, Task $task, string $type, string $baseBranch): string
{
$this->assertProjectHasRepo($project);
$branchName = $this->generateBranchName($task, $type);
$this->request('POST', sprintf(
'/api/v1/repos/%s/%s/branches',
$project->getGiteaOwner(),
$project->getGiteaRepo(),
), [
'json' => [
'new_branch_name' => $branchName,
'old_branch_name' => $baseBranch,
],
]);
return $branchName;
}
public function generateBranchName(Task $task, string $type): string
{
$project = $task->getProject();
if (null === $project) {
throw new GiteaApiException('Task has no project.');
}
$slug = $this->slugger->slug($task->getTitle())->lower()->truncate(50)->toString();
$slug = rtrim($slug, '-');
return sprintf('%s/%s-%d-%s', $type, $project->getCode(), $task->getNumber(), $slug);
}
/**
* @return array<array{name: string, commit: array}>
*/
public function listBranches(Project $project, string $taskCode): array
{
$this->assertProjectHasRepo($project);
$allBranches = [];
$page = 1;
do {
$pageBranches = $this->request('GET', sprintf(
'/api/v1/repos/%s/%s/branches',
$project->getGiteaOwner(),
$project->getGiteaRepo(),
), [
'query' => ['page' => $page, 'limit' => 50],
]);
$allBranches = array_merge($allBranches, $pageBranches);
++$page;
} while (!empty($pageBranches) && 50 === count($pageBranches));
$regex = sprintf('#^[^/]+/%s($|-.+)#', preg_quote($taskCode, '#'));
return array_values(array_filter(
$allBranches,
static fn (array $branch): bool => 1 === preg_match($regex, $branch['name']),
));
}
/**
* @return array<array{sha: string, commit: array{message: string, author: array}, created: string}>
*/
public function listBranchCommits(Project $project, string $branch): array
{
$this->assertProjectHasRepo($project);
$defaultBranch = $this->getDefaultBranch($project);
$data = $this->request('GET', sprintf(
'/api/v1/repos/%s/%s/compare/%s...%s',
$project->getGiteaOwner(),
$project->getGiteaRepo(),
$defaultBranch,
urlencode($branch),
));
return $data['commits'] ?? [];
}
/**
* @return array<array{number: int, title: string, state: string, head: array, user: array, merged: bool}>
*/
public function listPullRequests(Project $project, string $taskCode): array
{
$this->assertProjectHasRepo($project);
$branches = $this->listBranches($project, $taskCode);
$prs = [];
foreach ($branches as $branch) {
$branchPrs = $this->request('GET', sprintf(
'/api/v1/repos/%s/%s/pulls',
$project->getGiteaOwner(),
$project->getGiteaRepo(),
), [
'query' => ['state' => 'all', 'head' => $branch['name']],
]);
$prs = array_merge($prs, $branchPrs);
}
// Fetch CI status for each PR
foreach ($prs as &$pr) {
$sha = $pr['head']['sha'] ?? null;
if (null !== $sha) {
try {
$pr['ci_statuses'] = $this->request('GET', sprintf(
'/api/v1/repos/%s/%s/commits/%s/statuses',
$project->getGiteaOwner(),
$project->getGiteaRepo(),
$sha,
));
} catch (GiteaApiException) {
$pr['ci_statuses'] = [];
}
}
}
return $prs;
}
private function getConfiguration(): GiteaConfiguration
{
$config = $this->configRepository->findSingleton();
if (null === $config) {
throw new GiteaApiException('Gitea is not configured.');
}
return $config;
}
private function getDecryptedToken(GiteaConfiguration $config): string
{
$encrypted = $config->getEncryptedToken();
if (null === $encrypted) {
throw new GiteaApiException('Gitea token is not set.');
}
try {
return $this->tokenEncryptor->decrypt($encrypted);
} catch (Throwable $e) {
throw new GiteaApiException('Failed to decrypt Gitea token: '.$e->getMessage(), 0, $e);
}
}
private function extractGiteaError(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 Gitea error';
} catch (ExceptionInterface) {
return 'Gitea API error (HTTP '.$e->getResponse()->getStatusCode().')';
}
}
private function assertProjectHasRepo(Project $project): void
{
if (!$project->hasGiteaRepo()) {
throw new GiteaApiException('Project has no Gitea repository configured.');
}
}
/**
* @param array<string, mixed> $options
*/
private function request(string $method, string $path, array $options = []): array
{
$config = $this->getConfiguration();
$token = $this->getDecryptedToken($config);
$options['headers'] = array_merge($options['headers'] ?? [], [
'Authorization' => 'token '.$token,
'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->extractGiteaError($e);
throw new GiteaApiException($message, $e->getResponse()->getStatusCode(), $e);
} catch (ExceptionInterface $e) {
throw new GiteaApiException('Gitea API error: '.$e->getMessage(), 0, $e);
}
}
}
@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration\Infrastructure\Service;
use App\Module\Integration\Domain\Entity\ShareConfiguration;
use App\Module\Integration\Domain\Exception\ShareConnectionException;
use App\Module\Integration\Domain\Exception\ShareNotConfiguredException;
use App\Module\Integration\Domain\Repository\ShareConfigurationRepositoryInterface;
use App\Module\Integration\Domain\Service\FileEntry;
use App\Module\Integration\Domain\Service\FileSource;
use App\Module\Integration\Domain\Service\SharePathResolver;
use App\Module\Integration\Domain\Service\ShareTestResult;
use App\Service\TokenEncryptor;
use Icewind\SMB\BasicAuth;
use Icewind\SMB\IFileInfo;
use Icewind\SMB\IShare;
use Icewind\SMB\ServerFactory;
use Symfony\Component\Mime\MimeTypes;
use Throwable;
use function count;
final class SmbFileSource implements FileSource
{
/** Garde-fou : nombre maximum de dossiers explorés par recherche (évite de bloquer sur un très gros partage). */
private const int SEARCH_MAX_DIRS = 2000;
public function __construct(
private readonly ShareConfigurationRepositoryInterface $configRepository,
private readonly TokenEncryptor $tokenEncryptor,
private readonly SharePathResolver $pathResolver,
) {}
public function dir(string $relativePath): array
{
$config = $this->requireUsableConfig();
$share = $this->connect($config);
$full = $this->pathResolver->fullPath((string) $config->getBasePath(), $relativePath);
try {
$infos = $share->dir($full);
} catch (Throwable $e) {
throw new ShareConnectionException($e->getMessage(), 0, $e);
}
$entries = array_map(fn (IFileInfo $i): FileEntry => $this->toEntry($i, $relativePath), $infos);
$this->sortEntries($entries);
return $entries;
}
public function search(string $query, int $limit = 200): array
{
$needle = trim($query);
if ('' === $needle) {
return [];
}
$config = $this->requireUsableConfig();
$share = $this->connect($config);
$base = (string) $config->getBasePath();
$results = [];
$queue = ['']; // chemins relatifs des dossiers à explorer, racine en premier (parcours en largeur)
$visitedDirs = 0;
while ([] !== $queue && count($results) < $limit && $visitedDirs < self::SEARCH_MAX_DIRS) {
$relative = array_shift($queue);
$full = $this->pathResolver->fullPath($base, $relative);
try {
$infos = $share->dir($full);
} catch (Throwable) {
continue; // dossier illisible (droits, lien mort…) : on l'ignore et on poursuit
}
++$visitedDirs;
foreach ($infos as $info) {
$entry = $this->toEntry($info, $relative);
if ($entry->isDir) {
$queue[] = $entry->path;
}
if (false !== mb_stripos($entry->name, $needle)) {
$results[] = $entry;
if (count($results) >= $limit) {
break;
}
}
}
}
$this->sortEntries($results);
return $results;
}
public function read(string $relativePath)
{
$config = $this->requireUsableConfig();
$share = $this->connect($config);
$full = $this->pathResolver->fullPath((string) $config->getBasePath(), $relativePath);
try {
return $share->read($full);
} catch (Throwable $e) {
throw new ShareConnectionException($e->getMessage(), 0, $e);
}
}
public function test(): ShareTestResult
{
try {
$config = $this->requireUsableConfig();
$share = $this->connect($config);
$share->dir($this->pathResolver->fullPath((string) $config->getBasePath(), ''));
return new ShareTestResult(true);
} catch (ShareNotConfiguredException $e) {
return new ShareTestResult(false, 'Configuration incomplète ou désactivée.');
} catch (Throwable $e) {
return new ShareTestResult(false, $e->getMessage());
}
}
private function requireUsableConfig(): ShareConfiguration
{
$config = $this->configRepository->findSingleton();
if (null === $config || !$config->isUsable()) {
throw new ShareNotConfiguredException('Share is not configured or disabled.');
}
return $config;
}
private function connect(ShareConfiguration $config): IShare
{
$password = null !== $config->getEncryptedPassword()
? $this->tokenEncryptor->decrypt($config->getEncryptedPassword())
: '';
$auth = new BasicAuth(
(string) $config->getUsername(),
$config->getDomain() ?: 'WORKGROUP',
$password,
);
$server = new ServerFactory()->createServer((string) $config->getHost(), $auth);
try {
return $server->getShare((string) $config->getShareName());
} catch (Throwable $e) {
throw new ShareConnectionException($e->getMessage(), 0, $e);
}
}
/**
* Trie en place : dossiers d'abord, puis tri alphabétique insensible à la casse.
*
* @param FileEntry[] $entries
*/
private function sortEntries(array &$entries): void
{
usort($entries, static function (FileEntry $a, FileEntry $b): int {
if ($a->isDir !== $b->isDir) {
return $a->isDir ? -1 : 1;
}
return strcasecmp($a->name, $b->name);
});
}
private function toEntry(IFileInfo $info, string $parentRelative): FileEntry
{
$parent = '' === $parentRelative ? '' : rtrim($parentRelative, '/').'/';
$path = $parent.$info->getName();
$isDir = $info->isDirectory();
$mime = 'application/octet-stream';
if (!$isDir) {
$guessed = MimeTypes::getDefault()->getMimeTypes(pathinfo($info->getName(), PATHINFO_EXTENSION));
$mime = $guessed[0] ?? 'application/octet-stream';
}
return new FileEntry(
name: $info->getName(),
path: $path,
isDir: $isDir,
size: $isDir ? 0 : $info->getSize(),
modifiedAt: $info->getMTime(),
mimeType: $mime,
);
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Module\Integration;
use App\Shared\Domain\Module\ModuleInterface;
final class IntegrationModule implements ModuleInterface
{
public static function id(): string
{
return 'integration';
}
public static function label(): string
{
return 'Intégrations';
}
public static function isRequired(): bool
{
return false;
}
/**
* Permissions RBAC fin du Module Integration (Gitea, BookStack, Zimbra, Share).
*
* Additif : alimente le catalogue RBAC. La sécurité des opérations API
* reste en ROLE_USER/ROLE_ADMIN (non recâblée ici).
*
* @return list<array{code: string, label: string}>
*/
public static function permissions(): array
{
return [
['code' => 'integration.settings.manage', 'label' => 'Gérer les intégrations externes'],
['code' => 'integration.share.access', 'label' => 'Accéder au partage de fichiers'],
];
}
}
@@ -6,14 +6,14 @@ namespace App\Module\ProjectManagement\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Integration\Domain\Exception\InvalidPathException;
use App\Module\Integration\Domain\Exception\ShareConnectionException;
use App\Module\Integration\Domain\Exception\ShareNotConfiguredException;
use App\Module\Integration\Domain\Service\FileEntry;
use App\Module\Integration\Domain\Service\FileSource;
use App\Module\Integration\Domain\Service\SharePathResolver;
use App\Module\ProjectManagement\Domain\Entity\Task;
use App\Module\ProjectManagement\Domain\Entity\TaskDocument;
use App\Service\Share\Exception\InvalidPathException;
use App\Service\Share\Exception\ShareConnectionException;
use App\Service\Share\Exception\ShareNotConfiguredException;
use App\Service\Share\FileEntry;
use App\Service\Share\FileSource;
use App\Service\Share\SharePathResolver;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Controller;
use App\Module\Integration\Domain\Exception\ShareConnectionException;
use App\Module\Integration\Domain\Exception\ShareNotConfiguredException;
use App\Module\Integration\Domain\Service\FileSource;
use App\Module\ProjectManagement\Domain\Entity\TaskDocument;
use App\Service\Share\Exception\ShareConnectionException;
use App\Service\Share\Exception\ShareNotConfiguredException;
use App\Service\Share\FileSource;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Service;
use App\Module\Integration\Domain\Repository\ZimbraConfigurationRepositoryInterface;
use App\Module\ProjectManagement\Domain\Entity\Task;
use App\Module\ProjectManagement\Domain\Entity\TaskRecurrence;
use App\Module\ProjectManagement\Domain\Enum\RecurrenceType;
use App\Repository\ZimbraConfigurationRepository;
use App\Service\TokenEncryptor;
use DateTimeZone;
use Psr\Log\LoggerInterface;
@@ -21,7 +21,7 @@ use const ENT_QUOTES;
final class CalDavService
{
public function __construct(
private readonly ZimbraConfigurationRepository $configRepository,
private readonly ZimbraConfigurationRepositoryInterface $configRepository,
private readonly TokenEncryptor $tokenEncryptor,
private readonly HttpClientInterface $httpClient,
private readonly LoggerInterface $logger,