update {url?, note} * POST /api/fe-comment/feedback//delete remove item * POST /api/fe-comment/upload multipart: file + * feedback=; attaches via FAL, returns {id, name, url} * POST /api/fe-comment/attachment//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 = '

Page feedback from ' . htmlspecialchars($user['name']) . '

' . '

Author: ' . htmlspecialchars($user['name']) . ' <' . htmlspecialchars($user['email']) . '>

'; if (trim(strip_tags((string)$payload['generalNotes'])) !== '') { $html .= '

General notes

' . $this->embedNoteImages((string)$payload['generalNotes'], $embeds); } foreach ($payload['items'] as $i => $item) { $html .= '

' . ($i + 1) . ') ' . htmlspecialchars($item['url'] ?: '(no url)') . '

' . $this->embedNoteImages((string)$item['note'], $embeds); if ($item['attachments'] !== []) { $html .= '

Attachments:

'; } } $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 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 $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|null */ private function openSummary(int $userUid, bool $createIfMissing): ?array { return $this->stateService()->openSummary($userUid, $createIfMissing); } /** * @param array $summary * @return array */ private function summaryPayload(array $summary): array { return $this->stateService()->summaryPayload($summary); } /** @return array 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 fe_users uid => recipient (FE users of the feedback group) */ private function recipientMap(): array { return $this->stateService()->recipientMap(); } /** @return array */ 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, '