feat: accept form submissions from the websites over an API endpoint
This commit is contained in:
1 parent
f48e53fdd0
commit
30ad5e8488
17 files changed
+1350
-4
No files matched your search
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Api;
|
||||
|
||||
use App\Entity\ApplicationFormSubmission;
|
||||
use App\Model\ApplicationFormSubmissionDto;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Receives a form submitted on one of the public websites, posted by the TYPO3
|
||||
* ApplicationApiFinisher. Authenticated by the API key of the stateless "api" firewall.
|
||||
*
|
||||
* The visitor waits for this response inside their own form submission and TYPO3 gives up after
|
||||
* a few seconds without retrying, so this stores the row and answers - notification mail and any
|
||||
* matching against known people belong in a worker, not here
|
||||
* (.ddev/plans/MYEP-APPLICATION-ENDPOINT.md §6).
|
||||
*/
|
||||
class ApplicationFormController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A failed mapping or validation answers 400 rather than the resolver's default 422: the
|
||||
* contract names 400 for an unusable payload, and the TYPO3 side logs the response body.
|
||||
* Errors are English - the only caller is a machine.
|
||||
*/
|
||||
#[Route('/api/application-forms', name: 'app_api_application_form_submit', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_API')]
|
||||
public function submit(
|
||||
Request $request,
|
||||
#[MapRequestPayload(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
|
||||
ApplicationFormSubmissionDto $dto,
|
||||
): JsonResponse {
|
||||
$submission = $this->store($dto, $request);
|
||||
|
||||
$this->logger->info('Application form submission received', [
|
||||
'uuid' => $submission->getUuid(),
|
||||
'form' => $submission->getForm(),
|
||||
'formLabel' => $submission->getFormLabel(),
|
||||
'pageUid' => $submission->getPageUid(),
|
||||
'submittedAt' => $submission->getSubmittedAt()->format(\DateTimeInterface::ATOM),
|
||||
// the labels and the answers are personal data (§7), their number is not
|
||||
'detailCount' => count($submission->getDetails()),
|
||||
]);
|
||||
|
||||
return new JsonResponse(
|
||||
['status' => 'accepted', 'uuid' => $submission->getUuid()],
|
||||
Response::HTTP_ACCEPTED
|
||||
);
|
||||
}
|
||||
|
||||
private function store(ApplicationFormSubmissionDto $dto, Request $request): ApplicationFormSubmission
|
||||
{
|
||||
$submission = ApplicationFormSubmission::fromDto($dto, $this->decodePayload($request));
|
||||
|
||||
$this->entityManager->persist($submission);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $submission;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function decodePayload(Request $request): array
|
||||
{
|
||||
$payload = json_decode($request->getContent(), true);
|
||||
|
||||
return is_array($payload) ? $payload : [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Traits\TimestampableEntity;
|
||||
use App\Model\ApplicationFormSubmissionDto;
|
||||
use App\Repository\ApplicationFormSubmissionRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* A form submitted on one of the public websites and posted here by TYPO3 - job applications
|
||||
* first, other "collect it and let a human read it" forms later. Unrelated to
|
||||
* App\Entity\Application, which is a teamer applying for an assignment.
|
||||
*
|
||||
* Deliberately neither Blameable nor SoftDeletable: nothing in this application creates these
|
||||
* rows, and the retention rule for job applications asks for real deletion, so this is the one
|
||||
* entity here that gets an actual DELETE. A purge has to cover "details" and "payload" as
|
||||
* well - a json column is exactly what a deletion job forgets.
|
||||
*
|
||||
* The typed columns are the mapped core agreed with the TYPO3 side and all nullable: a form is
|
||||
* configured without a field as often as with it. "details" is everything else the visitor
|
||||
* filled in, keyed by the German field label in the order the form asked - display only, never
|
||||
* a schema, never queried on (see .ddev/plans/MYEP-APPLICATION-ENDPOINT.md §3.3).
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: ApplicationFormSubmissionRepository::class)]
|
||||
// What a list and a dedupe check ask for: the submissions of one form, newest first.
|
||||
#[ORM\Index(name: 'IDX_F47CE995B5265E72', columns: ['form', 'submitted_at'])]
|
||||
class ApplicationFormSubmission implements TimestampableEntityInterface
|
||||
{
|
||||
use TimestampableEntity;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\Column(length: 64)]
|
||||
private string $form;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private string $formLabel;
|
||||
|
||||
#[ORM\Column]
|
||||
private \DateTimeImmutable $submittedAt;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?int $pageUid = null;
|
||||
|
||||
#[ORM\Column(length: 2048, nullable: true)]
|
||||
private ?string $pageUrl = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $firstName = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $lastName = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $email = null;
|
||||
|
||||
#[ORM\Column(length: 64, nullable: true)]
|
||||
private ?string $phone = null;
|
||||
|
||||
#[ORM\Column(length: 64, nullable: true)]
|
||||
private ?string $mobile = null;
|
||||
|
||||
/**
|
||||
* A string, not a date: the forms use a text field with a regex rather than a date picker,
|
||||
* so this is whatever the visitor typed - usually dd.mm.yyyy. Parse it for display, store
|
||||
* what arrived.
|
||||
*/
|
||||
#[ORM\Column(length: 64, nullable: true)]
|
||||
private ?string $birthDate = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $street = null;
|
||||
|
||||
#[ORM\Column(length: 32, nullable: true)]
|
||||
private ?string $zipCode = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $city = null;
|
||||
|
||||
/**
|
||||
* Everything the visitor filled in beyond the mapped core, keyed by the German field label.
|
||||
* Display only: the labels are staff-written and change on a reword, so nothing may query
|
||||
* this, build a column from it or migrate old rows to a new spelling (§3.3).
|
||||
*
|
||||
* Note the labels come back in MySQL's order, not the form's - a json column normalises an
|
||||
* object by sorting its keys by length. Render it in whatever order suits the reviewer; the
|
||||
* form's own order is still in $payload, which is read as one blob and never sorted by us.
|
||||
*
|
||||
* @var array<string, string|bool|list<string>>
|
||||
*/
|
||||
#[ORM\Column(type: Types::JSON)]
|
||||
private array $details = [];
|
||||
|
||||
/**
|
||||
* The request body as received. Mapped keys this application does not know yet are ignored
|
||||
* on the way in (§3.2) and the TYPO3 side widens a form's mapping without a release here,
|
||||
* so without this copy those answers would be lost for good instead of backfillable.
|
||||
*
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
#[ORM\Column(type: Types::JSON)]
|
||||
private array $payload = [];
|
||||
|
||||
public function __construct(string $form, string $formLabel, \DateTimeImmutable $submittedAt)
|
||||
{
|
||||
$this->uuid = Uuid::v4();
|
||||
$this->form = $form;
|
||||
$this->formLabel = $formLabel;
|
||||
$this->submittedAt = $submittedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* The validated payload as a row. Called with a DTO that passed validation - submittedAt is
|
||||
* parseable by then, which is why the constructor can insist on a date.
|
||||
*
|
||||
* The raw payload is passed in separately because the DTO no longer has it: the resolver
|
||||
* drops every key the DTO has no property for, and those are the mapped fields the TYPO3
|
||||
* side has added and this application does not know yet (§3.2). Keeping the body is what
|
||||
* makes them backfillable instead of lost.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public static function fromDto(ApplicationFormSubmissionDto $dto, array $payload = []): self
|
||||
{
|
||||
$submission = new self($dto->form, $dto->formLabel, $dto->getSubmittedAt());
|
||||
|
||||
return $submission
|
||||
->setPageUid($dto->getPageUid())
|
||||
->setPageUrl($dto->pageUrl)
|
||||
->setFirstName($dto->firstName)
|
||||
->setLastName($dto->lastName)
|
||||
->setEmail($dto->email)
|
||||
->setPhone($dto->phone)
|
||||
->setMobile($dto->mobile)
|
||||
->setBirthDate($dto->birthDate)
|
||||
->setStreet($dto->street)
|
||||
->setZipCode($dto->zipCode)
|
||||
->setCity($dto->city)
|
||||
->setDetails($dto->getNormalizedDetails())
|
||||
->setPayload($payload)
|
||||
;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUuid(): string
|
||||
{
|
||||
return $this->uuid;
|
||||
}
|
||||
|
||||
public function getForm(): string
|
||||
{
|
||||
return $this->form;
|
||||
}
|
||||
|
||||
public function getFormLabel(): string
|
||||
{
|
||||
return $this->formLabel;
|
||||
}
|
||||
|
||||
public function getSubmittedAt(): \DateTimeImmutable
|
||||
{
|
||||
return $this->submittedAt;
|
||||
}
|
||||
|
||||
public function getPageUid(): ?int
|
||||
{
|
||||
return $this->pageUid;
|
||||
}
|
||||
|
||||
public function setPageUid(?int $pageUid): static
|
||||
{
|
||||
$this->pageUid = $pageUid;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPageUrl(): ?string
|
||||
{
|
||||
return $this->pageUrl;
|
||||
}
|
||||
|
||||
public function setPageUrl(?string $pageUrl): static
|
||||
{
|
||||
$this->pageUrl = $pageUrl;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFirstName(): ?string
|
||||
{
|
||||
return $this->firstName;
|
||||
}
|
||||
|
||||
public function setFirstName(?string $firstName): static
|
||||
{
|
||||
$this->firstName = $firstName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLastName(): ?string
|
||||
{
|
||||
return $this->lastName;
|
||||
}
|
||||
|
||||
public function setLastName(?string $lastName): static
|
||||
{
|
||||
$this->lastName = $lastName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEmail(): ?string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
public function setEmail(?string $email): static
|
||||
{
|
||||
// Same normalization as Contact and Embeddable\Communication: a later match against
|
||||
// existing people has to compare like with like.
|
||||
$this->email = null === $email ? null : mb_strtolower(trim($email));
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPhone(): ?string
|
||||
{
|
||||
return $this->phone;
|
||||
}
|
||||
|
||||
public function setPhone(?string $phone): static
|
||||
{
|
||||
$this->phone = $phone;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMobile(): ?string
|
||||
{
|
||||
return $this->mobile;
|
||||
}
|
||||
|
||||
public function setMobile(?string $mobile): static
|
||||
{
|
||||
$this->mobile = $mobile;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBirthDate(): ?string
|
||||
{
|
||||
return $this->birthDate;
|
||||
}
|
||||
|
||||
public function setBirthDate(?string $birthDate): static
|
||||
{
|
||||
$this->birthDate = $birthDate;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStreet(): ?string
|
||||
{
|
||||
return $this->street;
|
||||
}
|
||||
|
||||
public function setStreet(?string $street): static
|
||||
{
|
||||
$this->street = $street;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getZipCode(): ?string
|
||||
{
|
||||
return $this->zipCode;
|
||||
}
|
||||
|
||||
public function setZipCode(?string $zipCode): static
|
||||
{
|
||||
$this->zipCode = $zipCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCity(): ?string
|
||||
{
|
||||
return $this->city;
|
||||
}
|
||||
|
||||
public function setCity(?string $city): static
|
||||
{
|
||||
$this->city = $city;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string|bool|list<string>>
|
||||
*/
|
||||
public function getDetails(): array
|
||||
{
|
||||
return $this->details;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string|bool|list<string>> $details
|
||||
*/
|
||||
public function setDetails(array $details): static
|
||||
{
|
||||
$this->details = $details;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getPayload(): array
|
||||
{
|
||||
return $this->payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public function setPayload(array $payload): static
|
||||
{
|
||||
$this->payload = $payload;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,13 @@ class AccessDeniedListener
|
||||
return;
|
||||
}
|
||||
|
||||
// The api firewall is stateless and answers machines: a redirect to a login page would
|
||||
// be read as success there, and the flash message below needs a session this request
|
||||
// has none of.
|
||||
if (str_starts_with($request->getPathInfo(), '/api/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->warning('Access denied', [
|
||||
'uri' => $request->getRequestUri(),
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
/**
|
||||
* One submitted TYPO3 form, mapped from the request body by #[MapRequestPayload].
|
||||
*
|
||||
* Two rules from the contract (.ddev/plans/MYEP-APPLICATION-ENDPOINT.md §3.2, §3.3) shape this
|
||||
* class and must survive any rewrite:
|
||||
*
|
||||
* - **Unknown keys are ignored, never rejected.** The mapped vocabulary grows on the TYPO3
|
||||
* side without a release here, so nothing may switch the serializer's ALLOW_EXTRA_ATTRIBUTES
|
||||
* off: it would turn every later mapping change into a 400, silent because the finisher only
|
||||
* logs it.
|
||||
* - **Everything but the four basics is optional.** A form is configured without a field as
|
||||
* often as with it, so an absent key means "not part of this form", never "empty" - hence the
|
||||
* defaults, which also let the serializer construct the object from a payload of four keys.
|
||||
*
|
||||
* The lengths are the storage limits of ApplicationFormSubmission, not an opinion about what a
|
||||
* visitor may type: they exist so an oversized value is a 400 the TYPO3 side logs, and not a
|
||||
* database error after the response has been promised.
|
||||
*/
|
||||
class ApplicationFormSubmissionDto
|
||||
{
|
||||
public const MAX_DETAIL_LABEL_LENGTH = 255;
|
||||
|
||||
public const MAX_DETAIL_VALUE_LENGTH = 65535;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $details
|
||||
*/
|
||||
public function __construct(
|
||||
#[Assert\NotBlank(message: 'form must not be empty.')]
|
||||
#[Assert\Length(max: 64, maxMessage: 'form must not exceed {{ limit }} characters.')]
|
||||
public readonly ?string $form = null,
|
||||
#[Assert\NotBlank(message: 'formLabel must not be empty.')]
|
||||
#[Assert\Length(max: 255, maxMessage: 'formLabel must not exceed {{ limit }} characters.')]
|
||||
public readonly ?string $formLabel = null,
|
||||
#[Assert\NotBlank(message: 'submittedAt must not be empty.')]
|
||||
public readonly ?string $submittedAt = null,
|
||||
// int|string because a page id is unmistakable as "1421": rejecting the quoted form
|
||||
// would cost a whole submission over a distinction nobody can see in a form editor.
|
||||
// Read it back through getPageUid().
|
||||
public readonly int|string|null $pageUid = null,
|
||||
#[Assert\Length(max: 2048, maxMessage: 'pageUrl must not exceed {{ limit }} characters.')]
|
||||
public readonly ?string $pageUrl = null,
|
||||
#[Assert\Length(max: 255)]
|
||||
public readonly ?string $firstName = null,
|
||||
#[Assert\Length(max: 255)]
|
||||
public readonly ?string $lastName = null,
|
||||
#[Assert\Email(message: 'email is not a valid email address.')]
|
||||
#[Assert\Length(max: 255)]
|
||||
public readonly ?string $email = null,
|
||||
#[Assert\Length(max: 64)]
|
||||
public readonly ?string $phone = null,
|
||||
#[Assert\Length(max: 64)]
|
||||
public readonly ?string $mobile = null,
|
||||
#[Assert\Length(max: 64)]
|
||||
public readonly ?string $birthDate = null,
|
||||
#[Assert\Length(max: 255)]
|
||||
public readonly ?string $street = null,
|
||||
#[Assert\Length(max: 32)]
|
||||
public readonly ?string $zipCode = null,
|
||||
#[Assert\Length(max: 255)]
|
||||
public readonly ?string $city = null,
|
||||
public readonly array $details = [],
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The value is generated with \DateTimeInterface::ATOM on the TYPO3 side, but it is still
|
||||
* outside input: it is validated here rather than trusted, and read back through
|
||||
* getSubmittedAt() so the endpoint never stores a date it could not parse.
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validateSubmittedAt(ExecutionContextInterface $executionContext): void
|
||||
{
|
||||
if (null === $this->submittedAt || '' === $this->submittedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (null === $this->getSubmittedAt()) {
|
||||
$executionContext
|
||||
->buildViolation('submittedAt is not a valid ISO 8601 date and time.')
|
||||
->atPath('submittedAt')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
#[Assert\Callback]
|
||||
public function validatePageUid(ExecutionContextInterface $executionContext): void
|
||||
{
|
||||
if (null === $this->pageUid || '' === $this->pageUid) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (null === $this->getPageUid()) {
|
||||
$executionContext
|
||||
->buildViolation('pageUid must be a positive integer.')
|
||||
->atPath('pageUid')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* details is the only half with no schema: the keys are staff-written German field labels,
|
||||
* so anything a person can type is a legal key - dots, slashes, umlauts and typos included.
|
||||
* Only the shape is checked, because that is all the json column and the display can carry:
|
||||
* a label with content, and a scalar or a flat list as the answer.
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validateDetails(ExecutionContextInterface $executionContext): void
|
||||
{
|
||||
foreach ($this->details as $label => $value) {
|
||||
$label = (string) $label;
|
||||
|
||||
if ('' === trim($label) || mb_strlen($label) > self::MAX_DETAIL_LABEL_LENGTH) {
|
||||
$this->violate(
|
||||
$executionContext,
|
||||
sprintf('details contains an empty or overlong label ("%s").', mb_substr($label, 0, 32))
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_array($value) && false === array_is_list($value)) {
|
||||
$this->violate($executionContext, sprintf('The value of "%s" must be a flat list.', $label));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (is_array($value) ? $value : [$value] as $single) {
|
||||
if (null === $single || is_scalar($single)) {
|
||||
if (is_string($single) && mb_strlen($single) > self::MAX_DETAIL_VALUE_LENGTH) {
|
||||
$this->violate(
|
||||
$executionContext,
|
||||
sprintf('The value of "%s" exceeds %d characters.', $label, self::MAX_DETAIL_VALUE_LENGTH)
|
||||
);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->violate(
|
||||
$executionContext,
|
||||
sprintf('The value of "%s" must be a string, a boolean or a list of strings.', $label)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function getPageUid(): ?int
|
||||
{
|
||||
if (null === $this->pageUid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_string($this->pageUid) && 1 !== preg_match('/^\d+$/', $this->pageUid)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$pageUid = (int) $this->pageUid;
|
||||
|
||||
return $pageUid > 0 ? $pageUid : null;
|
||||
}
|
||||
|
||||
public function getSubmittedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
if (null === $this->submittedAt || '' === $this->submittedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Anchored on a date: \DateTimeImmutable happily reads "now" or "monday", and a
|
||||
// submission timestamped by a relative expression is not a submission time at all.
|
||||
if (1 !== preg_match('/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/', $this->submittedAt)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new \DateTimeImmutable($this->submittedAt);
|
||||
} catch (\Exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The blob as it is stored: numbers arrive as strings from the form editor but nothing stops
|
||||
* a mapping from sending one unquoted, and an empty answer is dropped rather than kept -
|
||||
* TYPO3 strips those before sending (§3.3), so a label present in the blob always has
|
||||
* content. The received order is the form's order and is never touched.
|
||||
*
|
||||
* @return array<string, string|bool|list<string>>
|
||||
*/
|
||||
public function getNormalizedDetails(): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($this->details as $label => $value) {
|
||||
if (null === $value || '' === $value || [] === $value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[(string) $label] = is_array($value)
|
||||
? array_values(array_map($this->normalizeListValue(...), $value))
|
||||
: $this->normalizeValue($value);
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function violate(ExecutionContextInterface $executionContext, string $message): void
|
||||
{
|
||||
$executionContext
|
||||
->buildViolation($message)
|
||||
->atPath('details')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
|
||||
private function normalizeValue(mixed $value): string|bool
|
||||
{
|
||||
return is_bool($value) ? $value : (string) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* A multi-select answer is a list of the labels the visitor picked, never a list of
|
||||
* true/false: casting keeps the blob readable as what was chosen.
|
||||
*/
|
||||
private function normalizeListValue(mixed $value): string
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'true' : 'false';
|
||||
}
|
||||
|
||||
return (string) $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\ApplicationFormSubmission;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<ApplicationFormSubmission>
|
||||
*
|
||||
* @method ApplicationFormSubmission|null find($id, $lockMode = null, $lockVersion = null)
|
||||
* @method ApplicationFormSubmission|null findOneBy(array $criteria, array $orderBy = null)
|
||||
* @method ApplicationFormSubmission[] findAll()
|
||||
* @method ApplicationFormSubmission[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
|
||||
*/
|
||||
class ApplicationFormSubmissionRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, ApplicationFormSubmission::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Security\AccessToken;
|
||||
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Security\Http\AccessToken\AccessTokenExtractorInterface;
|
||||
|
||||
/**
|
||||
* Reads the API key of the stateless "api" firewall from its own header. The built-in header
|
||||
* extractor expects "Authorization: Bearer", which would claim a shared secret is an OAuth
|
||||
* access token - this application is the client of a real OAuth provider (MYEP_OAUTH2_*), and
|
||||
* the two must not look alike in a request.
|
||||
*/
|
||||
class ApiKeyExtractor implements AccessTokenExtractorInterface
|
||||
{
|
||||
public const HEADER = 'X-Api-Key';
|
||||
|
||||
public function extractAccessToken(Request $request): ?string
|
||||
{
|
||||
$key = trim((string) $request->headers->get(self::HEADER, ''));
|
||||
|
||||
return '' === $key ? null : $key;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Security\AccessToken;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
|
||||
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||
|
||||
/**
|
||||
* Validates the shared key a sibling website posts with. There is no person to log in and no
|
||||
* account to look up: the key is the identity, and the user it resolves to is the in-memory
|
||||
* "api" user of the api_user_provider - nothing about it may reach the user table, where a row
|
||||
* would be a login to this application's UI.
|
||||
*/
|
||||
class ApiKeyTokenHandler implements AccessTokenHandlerInterface
|
||||
{
|
||||
public const USER_IDENTIFIER = 'api';
|
||||
|
||||
public function __construct(
|
||||
private readonly string $apiKey,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getUserBadgeFrom(string $accessToken): UserBadge
|
||||
{
|
||||
// An unconfigured key locks the endpoint instead of opening it: hash_equals('', '')
|
||||
// is true, so without this check a deployment that forgot the secret would accept
|
||||
// an empty key from anyone.
|
||||
if ('' === $this->apiKey) {
|
||||
$this->logger->error('API key authentication attempted while no key is configured');
|
||||
|
||||
throw new BadCredentialsException('No API key configured.');
|
||||
}
|
||||
|
||||
if (false === hash_equals($this->apiKey, $accessToken)) {
|
||||
$this->logger->warning('API key authentication failed');
|
||||
|
||||
throw new BadCredentialsException('Invalid API key.');
|
||||
}
|
||||
|
||||
return new UserBadge(self::USER_IDENTIFIER);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user