feat: accept form submissions from the websites over an API endpoint

This commit is contained in:
fromme committed 2026-09-24 16:35:06 +02:00
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 : [];
}
}
+347
View File
@@ -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(),
]);
+242
View File
@@ -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);
}
}