Files
VITEC-website/packages/fe_comment/Classes/Middleware/FeedbackApiMiddleware.php
T
orasche 74502e60a3 fe_comment: recipients are the FE users of the feedback group
- The selectable recipients are no longer a hand-maintained setting but
  every active frontend user of the feedback group (feUserGroup) with a
  valid e-mail address; the id is the fe_users uid, so a stored selection
  survives users joining or leaving. The `recipients` setting is marked
  unused
- Each recipient in the state payload carries label, email and selected
  (the stored choice), for a checkbox list with "select all" above the
  submit button
- submit accepts the checked ids directly ({"recipients": [uids]}),
  validates them against the group, stores and uses them, and reports
  the addresses used as sentTo; no selection falls back to mailTo
- migrations/check_fecomment_user.php: all summaries and items of one
  user incl. deleted ones, for diagnosis
2026-10-09 16:09:07 +02:00

425 lines
20 KiB
PHP

<?php
declare(strict_types=1);
namespace Evomedien\FeComment\Middleware;
use Evomedien\FeComment\Service\ExtensionSettings;
use Evomedien\FeComment\Service\FeedbackState;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Mail\MailMessage;
use TYPO3\CMS\Core\Resource\Enum\DuplicationBehavior;
use TYPO3\CMS\Core\Resource\StorageRepository;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\Authentication\FrontendUserAuthentication;
/**
* fe_comment v2 API (2026-10-02): server-side feedback with FE-user
* ownership. Every route requires a logged-in frontend user who is a member
* of the configured group - that is how the ticket carries its author.
*
* GET /api/fe-comment/state widget bootstrap: am I
* allowed, who am I, recipients, my open summary with items
* POST /api/fe-comment/feedback create {url, note}
* POST /api/fe-comment/feedback/<uid> update {url?, note}
* POST /api/fe-comment/feedback/<uid>/delete remove item
* POST /api/fe-comment/upload multipart: file +
* feedback=<uid>; attaches via FAL, returns {id, name, url}
* POST /api/fe-comment/attachment/<refUid>/delete
* POST /api/fe-comment/summary {generalNotes, recipients}
* POST /api/fe-comment/submit mail + close the summary
*
* Registered AFTER typo3/cms-frontend/authentication - earlier in the stack
* the frontend.user attribute does not exist yet. Records are written with
* plain inserts (no DataHandler in FE context); the TCA gives the backend
* read access. Incoming note HTML is passed through TYPO3's HTML sanitizer.
*/
final class FeedbackApiMiddleware implements MiddlewareInterface
{
private const PREFIX = '/api/fe-comment/';
private const T_SUMMARY = 'tx_fecomment_summary';
private const T_FEEDBACK = 'tx_fecomment_feedback';
private const MAX_NOTE = 100000;
private const MAX_UPLOAD = 20971520; // 20 MB
private ExtensionSettings $settings;
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$path = rtrim($request->getUri()->getPath(), '/');
if (!str_starts_with($path . '/', self::PREFIX) || in_array(substr($path, strlen(self::PREFIX)), ['widget.js', 'widget.css'], true)) {
return $handler->handle($request);
}
$route = substr($path, strlen(self::PREFIX));
try {
$this->settings = GeneralUtility::makeInstance(ExtensionSettings::class);
if (!$this->settings->enabled()) {
return new JsonResponse(['error' => 'disabled'], 403);
}
$user = $this->allowedUser($request);
if ($user === null) {
// The state route answers politely so the widget can hide
// itself; everything else refuses.
return $route === 'state'
? new JsonResponse(['allowed' => false])
: new JsonResponse(['error' => 'not allowed'], 403);
}
return match (true) {
$route === 'state' && $request->getMethod() === 'GET' => $this->state($user),
$route === 'feedback' => $this->createFeedback($request, $user),
(bool)preg_match('#^feedback/(\d+)$#', $route, $m) => $this->updateFeedback($request, $user, (int)$m[1]),
(bool)preg_match('#^feedback/(\d+)/delete$#', $route, $m) => $this->deleteFeedback($user, (int)$m[1]),
$route === 'upload' => $this->upload($request, $user),
(bool)preg_match('#^attachment/(\d+)/delete$#', $route, $m) => $this->deleteAttachment($user, (int)$m[1]),
$route === 'summary' => $this->updateSummary($request, $user),
$route === 'submit' => $this->submit($request, $user),
default => new JsonResponse(['error' => 'unknown route'], 404),
};
} catch (\Throwable $e) {
return new JsonResponse(['error' => $e->getMessage()], 500);
}
}
// ---- auth ---------------------------------------------------------------
/** @return array{uid:int,username:string,name:string,email:string}|null */
private function allowedUser(ServerRequestInterface $request): ?array
{
return $this->stateService()->allowedUser($request);
}
/** Shared with the "Feedback Overview" content element - one implementation. */
private function stateService(): FeedbackState
{
return GeneralUtility::makeInstance(FeedbackState::class, $this->settings);
}
// ---- routes -------------------------------------------------------------
/** @param array{uid:int,username:string,name:string,email:string} $user */
private function state(array $user): ResponseInterface
{
return new JsonResponse($this->stateService()->payload($user));
}
/** @param array{uid:int,username:string,name:string,email:string} $user */
private function createFeedback(ServerRequestInterface $request, array $user): ResponseInterface
{
$body = $this->jsonBody($request);
$summary = $this->openSummary($user['uid'], true);
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::T_FEEDBACK);
$connection->insert(self::T_FEEDBACK, [
'pid' => $this->settings->storagePid(),
'tstamp' => time(),
'crdate' => time(),
'summary' => (int)$summary['uid'],
'fe_user' => $user['uid'],
'url' => mb_substr(trim((string)($body['url'] ?? '')), 0, 2048),
'note' => $this->sanitize((string)($body['note'] ?? '')),
'sorting' => time(),
]);
$uid = (int)$connection->lastInsertId();
return new JsonResponse(['ok' => true, 'uid' => $uid, 'summary' => $this->summaryPayload($this->openSummary($user['uid'], true))]);
}
/** @param array{uid:int,username:string,name:string,email:string} $user */
private function updateFeedback(ServerRequestInterface $request, array $user, int $uid): ResponseInterface
{
$this->ownFeedback($user['uid'], $uid);
$body = $this->jsonBody($request);
$update = ['tstamp' => time()];
if (array_key_exists('note', $body)) {
$update['note'] = $this->sanitize((string)$body['note']);
}
if (array_key_exists('url', $body)) {
$update['url'] = mb_substr(trim((string)$body['url']), 0, 2048);
}
GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::T_FEEDBACK)
->update(self::T_FEEDBACK, $update, ['uid' => $uid]);
return new JsonResponse(['ok' => true]);
}
/** @param array{uid:int,username:string,name:string,email:string} $user */
private function deleteFeedback(array $user, int $uid): ResponseInterface
{
$this->ownFeedback($user['uid'], $uid);
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::T_FEEDBACK);
$connection->update(self::T_FEEDBACK, ['deleted' => 1, 'tstamp' => time()], ['uid' => $uid]);
return new JsonResponse(['ok' => true]);
}
/** @param array{uid:int,username:string,name:string,email:string} $user */
private function upload(ServerRequestInterface $request, array $user): ResponseInterface
{
$files = $request->getUploadedFiles();
$file = $files['file'] ?? null;
$feedbackUid = (int)($request->getParsedBody()['feedback'] ?? 0);
$feedback = $this->ownFeedback($user['uid'], $feedbackUid);
if ($file === null || $file->getError() !== UPLOAD_ERR_OK) {
return new JsonResponse(['error' => 'no file'], 400);
}
if ($file->getSize() > self::MAX_UPLOAD) {
return new JsonResponse(['error' => 'file too large (max 20 MB)'], 400);
}
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
$folderPath = 'fe_comment/summary-' . (int)$feedback['summary'];
$folder = $storage->hasFolder($folderPath) ? $storage->getFolder($folderPath) : $storage->createFolder($folderPath);
$temp = GeneralUtility::tempnam('fecomment_');
$file->moveTo($temp);
$clientName = $file->getClientFilename() ?: 'upload.bin';
$falFile = $storage->addFile($temp, $folder, $clientName, DuplicationBehavior::RENAME);
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_file_reference');
$connection->insert('sys_file_reference', [
'pid' => $this->settings->storagePid(),
'tstamp' => time(),
'crdate' => time(),
'uid_local' => $falFile->getUid(),
'uid_foreign' => $feedbackUid,
'tablenames' => self::T_FEEDBACK,
'fieldname' => 'attachments',
]);
// lastInsertId() must be read BEFORE any further statement on this
// connection - syncAttachmentCount() runs an UPDATE and DBAL then
// throws "No identity value was generated".
$refUid = (int)$connection->lastInsertId();
$this->syncAttachmentCount($feedbackUid);
return new JsonResponse(['ok' => true, 'attachment' => [
'refUid' => $refUid,
'name' => $falFile->getName(),
'url' => '/fileadmin' . $falFile->getIdentifier(),
]]);
}
/** @param array{uid:int,username:string,name:string,email:string} $user */
private function deleteAttachment(array $user, int $refUid): ResponseInterface
{
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_file_reference');
$reference = $connection->fetchAssociative(
'SELECT uid, uid_foreign FROM sys_file_reference WHERE uid = ? AND deleted = 0 AND tablenames = ?',
[$refUid, self::T_FEEDBACK]
);
if (!$reference) {
return new JsonResponse(['error' => 'not found'], 404);
}
$this->ownFeedback($user['uid'], (int)$reference['uid_foreign']);
$connection->update('sys_file_reference', ['deleted' => 1, 'tstamp' => time()], ['uid' => $refUid]);
$this->syncAttachmentCount((int)$reference['uid_foreign']);
return new JsonResponse(['ok' => true]);
}
/** @param array{uid:int,username:string,name:string,email:string} $user */
private function updateSummary(ServerRequestInterface $request, array $user): ResponseInterface
{
$body = $this->jsonBody($request);
$summary = $this->openSummary($user['uid'], true);
$update = ['tstamp' => time()];
if (array_key_exists('generalNotes', $body)) {
$update['general_notes'] = $this->sanitize((string)$body['generalNotes']);
}
if (array_key_exists('recipients', $body) && is_array($body['recipients'])) {
$valid = array_keys($this->recipientMap());
$chosen = array_values(array_intersect(array_map('intval', $body['recipients']), $valid));
$update['recipients'] = implode(',', $chosen);
}
GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::T_SUMMARY)
->update(self::T_SUMMARY, $update, ['uid' => (int)$summary['uid']]);
return new JsonResponse(['ok' => true]);
}
/** @param array{uid:int,username:string,name:string,email:string} $user */
private function submit(ServerRequestInterface $request, array $user): ResponseInterface
{
$summary = $this->openSummary($user['uid'], false);
if ($summary === null) {
return new JsonResponse(['error' => 'nothing to submit'], 400);
}
$payload = $this->summaryPayload($summary);
if ($payload['items'] === [] && trim(strip_tags((string)$payload['generalNotes'])) === '') {
return new JsonResponse(['error' => 'nothing to submit'], 400);
}
$recipientMap = $this->recipientMap();
// The checkbox selection may travel with the submit itself
// ({"recipients": [ids]}) - validated against the configured list,
// stored on the summary, then used. Without it the stored selection
// applies; an empty selection falls back to mailTo.
$body = $this->jsonBody($request);
if (array_key_exists('recipients', $body) && is_array($body['recipients'])) {
$chosenIds = array_values(array_intersect(array_map('intval', $body['recipients']), array_keys($recipientMap)));
$summary['recipients'] = implode(',', $chosenIds);
GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::T_SUMMARY)
->update(self::T_SUMMARY, ['recipients' => $summary['recipients'], 'tstamp' => time()], ['uid' => (int)$summary['uid']]);
}
$chosen = GeneralUtility::intExplode(',', (string)$summary['recipients'], true);
$to = [];
foreach ($chosen as $id) {
if (isset($recipientMap[$id])) {
$to[] = $recipientMap[$id]['email'];
}
}
if ($to === []) {
$to = $this->settings->mailTo();
}
if ($to === []) {
return new JsonResponse(['error' => 'no recipient configured'], 503);
}
// Inline images (Ctrl+V pastes) are embedded into the mail itself:
// the notes reference /fileadmin/... which a mail client cannot load
// (relative, and dev sits behind basic auth). $embeds collects
// cid => absolute file path while the HTML is assembled.
$embeds = [];
$base = 'https://' . ($_SERVER['HTTP_HOST'] ?? 'dev.vitec.com');
$html = '<h2>Page feedback from ' . htmlspecialchars($user['name']) . '</h2>'
. '<p><strong>Author:</strong> ' . htmlspecialchars($user['name'])
. ' &lt;' . htmlspecialchars($user['email']) . '&gt;</p>';
if (trim(strip_tags((string)$payload['generalNotes'])) !== '') {
$html .= '<h3>General notes</h3>' . $this->embedNoteImages((string)$payload['generalNotes'], $embeds);
}
foreach ($payload['items'] as $i => $item) {
$html .= '<hr><h3>' . ($i + 1) . ') ' . htmlspecialchars($item['url'] ?: '(no url)') . '</h3>'
. $this->embedNoteImages((string)$item['note'], $embeds);
if ($item['attachments'] !== []) {
$html .= '<p><strong>Attachments:</strong></p><ul>';
foreach ($item['attachments'] as $attachment) {
$html .= '<li><a href="' . htmlspecialchars($base . $attachment['url']) . '">' . htmlspecialchars($attachment['name']) . '</a></li>';
}
$html .= '</ul>';
}
}
$mail = GeneralUtility::makeInstance(MailMessage::class);
foreach ($embeds as $cid => $absolutePath) {
$mail->embedFromPath($absolutePath, $cid);
}
$mail->to(...$to)
->subject($this->settings->subject() . ' — ' . $user['name'] . ' (' . count($payload['items']) . ' item' . (count($payload['items']) === 1 ? '' : 's') . ')')
->html($html);
if ($this->settings->mailCc() !== []) {
$mail->cc(...$this->settings->mailCc());
}
if ($this->settings->mailBcc() !== []) {
$mail->bcc(...$this->settings->mailBcc());
}
if (GeneralUtility::validEmail($user['email'])) {
$mail->replyTo($user['email']);
}
GeneralUtility::makeInstance(\TYPO3\CMS\Core\Mail\Mailer::class)->send($mail);
GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::T_SUMMARY)
->update(self::T_SUMMARY, ['status' => 'sent', 'sent_at' => time(), 'tstamp' => time()], ['uid' => (int)$summary['uid']]);
// sentTo: the addresses actually used, for the confirmation message
return new JsonResponse(['ok' => true, 'sent' => count($payload['items']), 'sentTo' => array_values($to)]);
}
// ---- helpers ------------------------------------------------------------
/**
* Rewrites <img src="/fileadmin/..."> in a note to cid: references and
* collects the embeds (cid => absolute path) for the mail. Images whose
* file is gone keep their URL made absolute, so at least the link works.
*
* @param array<string,string> $embeds
*/
private function embedNoteImages(string $noteHtml, array &$embeds): string
{
return (string)preg_replace_callback(
'/src="(\/fileadmin\/[^"]+)"/',
function (array $match) use (&$embeds): string {
$absolute = \TYPO3\CMS\Core\Core\Environment::getPublicPath() . rawurldecode($match[1]);
if (!is_file($absolute)) {
return 'src="https://' . ($_SERVER['HTTP_HOST'] ?? 'dev.vitec.com') . $match[1] . '"';
}
$cid = array_search($absolute, $embeds, true);
if ($cid === false) {
$cid = 'fecimg' . (count($embeds) + 1);
$embeds[$cid] = $absolute;
}
return 'src="cid:' . $cid . '"';
},
$noteHtml
);
}
/** @return array<string,mixed>|null */
private function openSummary(int $userUid, bool $createIfMissing): ?array
{
return $this->stateService()->openSummary($userUid, $createIfMissing);
}
/**
* @param array<string,mixed> $summary
* @return array<string,mixed>
*/
private function summaryPayload(array $summary): array
{
return $this->stateService()->summaryPayload($summary);
}
/** @return array<string,mixed> the feedback row - throws when not the user's */
private function ownFeedback(int $userUid, int $feedbackUid): array
{
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::T_FEEDBACK);
$row = $connection->fetchAssociative(
'SELECT * FROM ' . self::T_FEEDBACK . ' WHERE deleted = 0 AND uid = ? AND fe_user = ?',
[$feedbackUid, $userUid]
);
if (!$row) {
throw new \RuntimeException('feedback not found or not yours', 1764600000);
}
return $row;
}
private function syncAttachmentCount(int $feedbackUid): void
{
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_file_reference');
$count = (int)$connection->fetchOne(
'SELECT COUNT(*) FROM sys_file_reference WHERE deleted = 0 AND tablenames = ? AND fieldname = ? AND uid_foreign = ?',
[self::T_FEEDBACK, 'attachments', $feedbackUid]
);
GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::T_FEEDBACK)
->update(self::T_FEEDBACK, ['attachments' => $count, 'tstamp' => time()], ['uid' => $feedbackUid]);
}
/** @return array<int,array{label:string,email:string}> fe_users uid => recipient (FE users of the feedback group) */
private function recipientMap(): array
{
return $this->stateService()->recipientMap();
}
/** @return array<string,mixed> */
private function jsonBody(ServerRequestInterface $request): array
{
$parsed = $request->getParsedBody();
if (is_array($parsed) && $parsed !== []) {
return $parsed;
}
$decoded = json_decode((string)$request->getBody(), true);
return is_array($decoded) ? $decoded : [];
}
/** TYPO3 HTML sanitizer; falls back to a hard strip when unavailable. */
private function sanitize(string $html): string
{
$html = mb_substr($html, 0, self::MAX_NOTE);
try {
$builder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Html\SanitizerBuilderFactory::class)->build('default');
return $builder->sanitize($html);
} catch (\Throwable) {
return strip_tags($html, '<p><br><b><strong><i><em><u><ul><ol><li><a><img><h3><h4>');
}
}
}