- Block SVG MIME type in TaskDocumentProcessor upload validation - Serve existing SVG files as attachment (defense-in-depth) in download controller - Block ROLE_CLIENT from uploading documents to tasks (only allowed via portal tickets) - Add Doctrine extension to filter projects by allowedProjects for ROLE_CLIENT Tickets: T-003, T-005, T-006 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
154 lines
6.3 KiB
PHP
154 lines
6.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\State;
|
|
|
|
use ApiPlatform\Metadata\Operation;
|
|
use ApiPlatform\State\ProcessorInterface;
|
|
use App\Entity\ClientTicket;
|
|
use App\Entity\Task;
|
|
use App\Entity\TaskDocument;
|
|
use DateTimeImmutable;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Symfony\Bundle\SecurityBundle\Security;
|
|
use Symfony\Component\HttpFoundation\RequestStack;
|
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
|
use Symfony\Component\Uid\Uuid;
|
|
|
|
/**
|
|
* @implements ProcessorInterface<TaskDocument, TaskDocument>
|
|
*/
|
|
final readonly class TaskDocumentProcessor implements ProcessorInterface
|
|
{
|
|
private const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
|
|
|
|
private const ALLOWED_MIME_TYPES = [
|
|
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
|
'application/pdf',
|
|
'application/msword',
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
'application/vnd.ms-excel',
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
'application/vnd.ms-powerpoint',
|
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
'text/plain', 'text/csv',
|
|
'application/zip', 'application/x-rar-compressed', 'application/gzip',
|
|
'application/json', 'application/xml', 'text/xml',
|
|
];
|
|
|
|
private const MIME_TO_EXTENSION = [
|
|
'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif',
|
|
'image/webp' => 'webp',
|
|
'application/pdf' => 'pdf',
|
|
'application/msword' => 'doc',
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
|
|
'application/vnd.ms-excel' => 'xls',
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
|
|
'application/vnd.ms-powerpoint' => 'ppt',
|
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx',
|
|
'text/plain' => 'txt', 'text/csv' => 'csv',
|
|
'application/zip' => 'zip', 'application/x-rar-compressed' => 'rar', 'application/gzip' => 'gz',
|
|
'application/json' => 'json', 'application/xml' => 'xml', 'text/xml' => 'xml',
|
|
];
|
|
|
|
public function __construct(
|
|
private EntityManagerInterface $entityManager,
|
|
private Security $security,
|
|
private RequestStack $requestStack,
|
|
private string $uploadDir,
|
|
) {}
|
|
|
|
/**
|
|
* @param TaskDocument $data
|
|
*/
|
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): TaskDocument
|
|
{
|
|
$request = $this->requestStack->getCurrentRequest();
|
|
|
|
if (null === $request) {
|
|
throw new BadRequestHttpException('No request available.');
|
|
}
|
|
|
|
$file = $request->files->get('file');
|
|
|
|
if (null === $file || !$file->isValid()) {
|
|
throw new BadRequestHttpException('No valid file uploaded.');
|
|
}
|
|
|
|
if ($file->getSize() > self::MAX_FILE_SIZE) {
|
|
throw new BadRequestHttpException('File size exceeds 50 MB limit.');
|
|
}
|
|
|
|
$taskIri = $request->request->get('task', '');
|
|
$clientTicketIri = $request->request->get('clientTicket', '');
|
|
|
|
if ('' === $taskIri && '' === $clientTicketIri) {
|
|
throw new BadRequestHttpException('Either task or clientTicket IRI is required.');
|
|
}
|
|
|
|
$task = null;
|
|
$clientTicket = null;
|
|
|
|
if ('' !== $taskIri) {
|
|
// ROLE_CLIENT (without ROLE_ADMIN) cannot upload documents directly to tasks
|
|
if ($this->security->isGranted('ROLE_CLIENT') && !$this->security->isGranted('ROLE_ADMIN')) {
|
|
throw new AccessDeniedHttpException('Clients can only upload documents to client tickets.');
|
|
}
|
|
|
|
$task = $this->entityManager->getRepository(Task::class)->find((int) basename($taskIri));
|
|
|
|
if (null === $task) {
|
|
throw new BadRequestHttpException('Task not found.');
|
|
}
|
|
}
|
|
|
|
if ('' !== $clientTicketIri) {
|
|
$clientTicket = $this->entityManager->getRepository(ClientTicket::class)->find((int) basename($clientTicketIri));
|
|
|
|
if (null === $clientTicket) {
|
|
throw new BadRequestHttpException('Client ticket not found.');
|
|
}
|
|
|
|
if (!$this->security->isGranted('ROLE_ADMIN') && $clientTicket->getSubmittedBy() !== $this->security->getUser()) {
|
|
throw new AccessDeniedHttpException('You can only upload documents to your own tickets.');
|
|
}
|
|
}
|
|
|
|
// Use server-detected MIME type (finfo), not the client-supplied one
|
|
$originalName = $file->getClientOriginalName();
|
|
$mimeType = $file->getMimeType() ?: 'application/octet-stream';
|
|
$fileSize = $file->getSize();
|
|
|
|
if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) {
|
|
throw new BadRequestHttpException(sprintf('File type "%s" is not allowed.', $mimeType));
|
|
}
|
|
|
|
$extension = self::MIME_TO_EXTENSION[$mimeType] ?? 'bin';
|
|
$uuid = Uuid::v4()->toRfc4122();
|
|
$fileName = $uuid.'.'.$extension;
|
|
|
|
if (!is_dir($this->uploadDir)) {
|
|
mkdir($this->uploadDir, 0o775, true);
|
|
}
|
|
|
|
$file->move($this->uploadDir, $fileName);
|
|
|
|
$document = new TaskDocument();
|
|
$document->setTask($task);
|
|
$document->setClientTicket($clientTicket);
|
|
$document->setOriginalName($originalName);
|
|
$document->setFileName($fileName);
|
|
$document->setMimeType($mimeType);
|
|
$document->setSize($fileSize);
|
|
$document->setCreatedAt(new DateTimeImmutable());
|
|
$document->setUploadedBy($this->security->getUser());
|
|
|
|
$this->entityManager->persist($document);
|
|
$this->entityManager->flush();
|
|
|
|
return $document;
|
|
}
|
|
}
|