diff --git a/webapp/migrations/Version20250829092248.php b/webapp/migrations/Version20250829092248.php new file mode 100644 index 0000000000..569ebb8831 --- /dev/null +++ b/webapp/migrations/Version20250829092248.php @@ -0,0 +1,46 @@ +addSql('ALTER TABLE problem_attachment ADD mime_type VARCHAR(255) NOT NULL COMMENT \'Mime type of attachment\''); + + // Load existing attachments + $attachments = $this->connection->fetchAllAssociative('SELECT attachmentid, content FROM problem_attachment INNER JOIN problem_attachment_content USING (attachmentid)'); + $finfo = new finfo(FILEINFO_MIME_TYPE); + foreach ($attachments as $attachment) { + $mime = $finfo->buffer($attachment['content']); + $mime = explode(';', $mime)[0]; + $this->addSql("UPDATE problem_attachment SET mime_type = :mime WHERE attachmentid = :attachmentid", ['mime' => $mime, 'attachmentid' => $attachment['attachmentid']]); + } + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE problem_attachment DROP mime_type'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/webapp/src/Controller/API/AccessController.php b/webapp/src/Controller/API/AccessController.php index 8f4196983e..08ba90b2d5 100644 --- a/webapp/src/Controller/API/AccessController.php +++ b/webapp/src/Controller/API/AccessController.php @@ -163,6 +163,7 @@ public function getStatusAction(Request $request): Access 'time_limit', 'test_data_count', 'statement', + 'attachments', // DOMjudge specific properties: 'probid', ], diff --git a/webapp/src/Controller/API/ProblemController.php b/webapp/src/Controller/API/ProblemController.php index 61e2ccb39f..479c96b105 100644 --- a/webapp/src/Controller/API/ProblemController.php +++ b/webapp/src/Controller/API/ProblemController.php @@ -8,6 +8,7 @@ use App\Entity\Contest; use App\Entity\ContestProblem; use App\Entity\Problem; +use App\Entity\ProblemAttachment; use App\Service\ConfigurationService; use App\Service\DOMJudgeService; use App\Service\EventLogService; @@ -436,6 +437,58 @@ public function statementAction(Request $request, string $id): Response return $contestProblem->getProblem()->getProblemStatementStreamedResponse(); } + /** + * Get an attachment for given problem for this contest. + * @throws NonUniqueResultException + */ + #[Rest\Get('/{id}/attachment/{filename}')] + #[OA\Response( + response: 200, + description: 'Returns the given problem attachment for this contest' + )] + #[OA\Parameter(ref: '#/components/parameters/id')] + #[OA\Parameter( + name: 'filename', + description: 'The filename of the attachment to get', + in: 'query', + required: true, + schema: new OA\Schema(type: 'string') + )] + public function attachmentAction(Request $request, string $id, string $filename): Response + { + $contestProblemData = $this + ->getQueryBuilder($request) + ->setParameter('id', $id) + ->andWhere(sprintf('%s = :id', $this->getIdField())) + ->getQuery() + ->getOneOrNullResult(); + + if ($contestProblemData === null) { + throw new NotFoundHttpException(sprintf('Object with ID \'%s\' not found', $id)); + } + + /** @var ContestProblem $contestProblem */ + $contestProblem = $contestProblemData[0]; + + /** @var ProblemAttachment|null $attachment */ + $attachment = $this->em->createQueryBuilder() + ->from(ProblemAttachment::class, 'a') + ->join('a.content', 'c') + ->select('a, c') + ->andWhere('a.problem = :problem') + ->andWhere('a.name = :filename') + ->setParameter('problem', $contestProblem->getProblem()) + ->setParameter('filename', $filename) + ->getQuery() + ->getOneOrNullResult(); + + if ($attachment === null) { + throw new NotFoundHttpException(sprintf('Attachment with filename \'%s\' not found for problem with ID \'%s\'', $filename, $id)); + } + + return $attachment->getStreamedResponse(); + } + protected function getQueryBuilder(Request $request): QueryBuilder { $contestId = $this->getContestId($request); diff --git a/webapp/src/Controller/Jury/ProblemController.php b/webapp/src/Controller/Jury/ProblemController.php index dfe2969d17..6fc0738aef 100644 --- a/webapp/src/Controller/Jury/ProblemController.php +++ b/webapp/src/Controller/Jury/ProblemController.php @@ -474,6 +474,7 @@ public function viewAction(Request $request, SubmissionService $submissionServic ->setProblem($problem) ->setName($name) ->setType($type) + ->setMimeType(mime_content_type($file->getRealPath())) ->setContent($attachmentContent); $this->em->persist($attachment); diff --git a/webapp/src/DataFixtures/Test/AddProblemAttachmentFixture.php b/webapp/src/DataFixtures/Test/AddProblemAttachmentFixture.php index c03b81fe90..13385bf9eb 100644 --- a/webapp/src/DataFixtures/Test/AddProblemAttachmentFixture.php +++ b/webapp/src/DataFixtures/Test/AddProblemAttachmentFixture.php @@ -39,7 +39,8 @@ public function load(ObjectManager $manager): void $problem = $manager->getRepository(Problem::class)->findOneBy(['externalid' => 'boolfind']); $attachment = (new ProblemAttachment()) ->setName('interactor') - ->setType('py'); + ->setType('py') + ->setMimeType('text/x-script.python'); $manager->persist($attachment); $content = (new ProblemAttachmentContent()) ->setContent($interactor) diff --git a/webapp/src/Entity/Problem.php b/webapp/src/Entity/Problem.php index 688063fec9..1c47973a21 100644 --- a/webapp/src/Entity/Problem.php +++ b/webapp/src/Entity/Problem.php @@ -200,6 +200,14 @@ class Problem extends BaseApiEntity implements #[Serializer\Exclude] private ?FileWithName $statementForApi = null; + /** + * @var FileWithName[] + */ + // This field gets filled by the contest problem visitor with an array of data transfer + // objects that represents the problem attachments. + #[Serializer\Exclude] + private array $attachmentsForApi = []; + /** * @var Collection @@ -635,6 +643,26 @@ public function getStatementForApi(): array return array_filter([$this->statementForApi]); } + + /** + * @param list $attachmentsForApi + */ + public function setAttachmentsForApi(array $attachmentsForApi = []): void + { + $this->attachmentsForApi = $attachmentsForApi; + } + + /** + * @return FileWithName[] + */ + #[Serializer\VirtualProperty] + #[Serializer\SerializedName('attachments')] + #[Serializer\Type('array')] + public function getAttachmentsForApi(): array + { + return $this->attachmentsForApi; + } + public function addLanguage(Language $language): Problem { $this->languages[] = $language; diff --git a/webapp/src/Entity/ProblemAttachment.php b/webapp/src/Entity/ProblemAttachment.php index 282d0f8e79..926c99751c 100644 --- a/webapp/src/Entity/ProblemAttachment.php +++ b/webapp/src/Entity/ProblemAttachment.php @@ -28,6 +28,9 @@ class ProblemAttachment #[ORM\Column(length: 4, options: ['comment' => 'File type of attachment'])] private ?string $type = null; + #[ORM\Column(options: ['comment' => 'Mime type of attachment'])] + private ?string $mimeType = null; + #[ORM\ManyToOne(inversedBy: 'attachments')] #[ORM\JoinColumn(name: 'probid', referencedColumnName: 'probid', onDelete: 'CASCADE')] private ?Problem $problem = null; @@ -86,6 +89,18 @@ public function setType(string $type): self return $this; } + public function getMimeType(): ?string + { + return $this->mimeType; + } + + public function setMimeType(string $mimeType): self + { + $this->mimeType = $mimeType; + + return $this; + } + public function getProblem(): ?Problem { return $this->problem; diff --git a/webapp/src/Serializer/ContestProblemVisitor.php b/webapp/src/Serializer/ContestProblemVisitor.php index e4ac6ca5db..127afa8686 100644 --- a/webapp/src/Serializer/ContestProblemVisitor.php +++ b/webapp/src/Serializer/ContestProblemVisitor.php @@ -53,5 +53,24 @@ public function onPreSerialize(ObjectEvent $event): void } else { $contestProblem->getProblem()->setStatementForApi(); } + + // Problem attachments + $attachments = []; + foreach ($contestProblem->getProblem()->getAttachments() as $attachment) { + $route = $this->dj->apiRelativeUrl( + 'v4_app_api_problem_attachment', + [ + 'cid' => $contestProblem->getContest()->getExternalid(), + 'id' => $contestProblem->getExternalId(), + 'filename' => $attachment->getName(), + ] + ); + $attachments[] = new FileWithName( + $route, + $attachment->getMimeType(), + $attachment->getName() + ); + } + $contestProblem->getProblem()->setAttachmentsForApi($attachments); } } diff --git a/webapp/src/Service/ImportProblemService.php b/webapp/src/Service/ImportProblemService.php index b9cbd94c6f..2e52bc8f9b 100644 --- a/webapp/src/Service/ImportProblemService.php +++ b/webapp/src/Service/ImportProblemService.php @@ -534,6 +534,10 @@ public function importZippedProblem( $type = 'txt'; } + $finfo = new \finfo(); + $mime = $finfo->buffer($content); + $mime = explode(';', $mime)[0]; + // Check if an attachment already exists, since then we overwrite it. $attachment = $existingAttachments[$name] ?? null; @@ -541,6 +545,9 @@ public function importZippedProblem( $attachmentContent = $attachment->getContent(); if ($content !== $attachmentContent->getContent()) { $attachmentContent->setContent($content); + $attachment + ->setType($type) + ->setMimeType($mime); $messages['info'][] = sprintf("Updated attachment '%s'", $name); $numAttachments++; } @@ -555,6 +562,7 @@ public function importZippedProblem( ->setProblem($problem) ->setName($name) ->setType($type) + ->setMimeType($mime) ->setContent($attachmentContent); $attachmentContent diff --git a/webapp/templates/jury/problem.html.twig b/webapp/templates/jury/problem.html.twig index 4646c6cc84..0679104872 100644 --- a/webapp/templates/jury/problem.html.twig +++ b/webapp/templates/jury/problem.html.twig @@ -214,6 +214,7 @@ Name + Mime type @@ -228,6 +229,7 @@ {% for attachment in problem.attachments %} {{ attachment.name }} + {{ attachment.mimeType }} @@ -247,7 +249,7 @@ {% if is_granted('ROLE_ADMIN') and not lockedProblem %} - + {{ form_errors(problemAttachmentForm.content) }} {{ form_widget(problemAttachmentForm.content) }} diff --git a/webapp/tests/Unit/Controller/API/ProblemControllerTest.php b/webapp/tests/Unit/Controller/API/ProblemControllerTest.php index dfd3cc8a60..2405650f12 100644 --- a/webapp/tests/Unit/Controller/API/ProblemControllerTest.php +++ b/webapp/tests/Unit/Controller/API/ProblemControllerTest.php @@ -2,6 +2,7 @@ namespace App\Tests\Unit\Controller\API; +use App\DataFixtures\Test\AddProblemAttachmentFixture; use App\DataFixtures\Test\DummyProblemFixture; use App\Entity\Problem; @@ -31,6 +32,13 @@ class ProblemControllerTest extends BaseTestCase 'filename' => 'C.pdf', ], ], + 'attachments' => [ + [ + 'href' => 'contests/demo/problems/boolfind/attachment/interactor', + 'mime' => 'text/x-script.python', + 'filename' => 'interactor', + ], + ], ], 'fltcmp' => [ "ordinal" => 1, @@ -48,6 +56,7 @@ class ProblemControllerTest extends BaseTestCase 'filename' => 'B.pdf', ], ], + 'attachments' => [], ], 'hello' => [ "ordinal" => 0, @@ -65,11 +74,18 @@ class ProblemControllerTest extends BaseTestCase 'filename' => 'A.pdf', ], ], + 'attachments' => [], ], ]; protected array $expectedAbsent = ['4242', 'nonexistent']; + protected function setUp(): void + { + parent::setUp(); + $this->loadFixture(AddProblemAttachmentFixture::class); + } + public function testDeleteNotAllowed(): void { if ($this->apiUser === 'admin') {