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:
@@ -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 {}
|
||||
+12
@@ -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 = '';
|
||||
}
|
||||
+28
@@ -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);
|
||||
}
|
||||
}
|
||||
+61
@@ -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);
|
||||
}
|
||||
}
|
||||
+49
@@ -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);
|
||||
}
|
||||
}
|
||||
+31
@@ -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;
|
||||
}
|
||||
}
|
||||
+31
@@ -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;
|
||||
}
|
||||
}
|
||||
+34
@@ -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;
|
||||
}
|
||||
}
|
||||
+37
@@ -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()]);
|
||||
}
|
||||
}
|
||||
+26
@@ -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([]);
|
||||
}
|
||||
}
|
||||
+26
@@ -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([]);
|
||||
}
|
||||
}
|
||||
+30
@@ -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()
|
||||
;
|
||||
}
|
||||
}
|
||||
+34
@@ -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']);
|
||||
}
|
||||
}
|
||||
+30
@@ -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
-6
@@ -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;
|
||||
|
||||
+3
-3
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user