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($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 { $frontendUser = $request->getAttribute('frontend.user'); if (!$frontendUser instanceof FrontendUserAuthentication || empty($frontendUser->user['uid'])) { return null; } $requiredGroup = $this->settings->feUserGroup(); if ($requiredGroup > 0) { $groups = GeneralUtility::intExplode(',', (string)($frontendUser->user['usergroup'] ?? ''), true); if (!in_array($requiredGroup, $groups, true)) { return null; } } return [ 'uid' => (int)$frontendUser->user['uid'], 'username' => (string)($frontendUser->user['username'] ?? ''), 'name' => trim((string)($frontendUser->user['name'] ?? '')) ?: (string)($frontendUser->user['username'] ?? ''), 'email' => (string)($frontendUser->user['email'] ?? ''), ]; } // ---- routes ------------------------------------------------------------- /** @param array{uid:int,username:string,name:string,email:string} $user */ private function state(array $user): ResponseInterface { $summary = $this->openSummary($user['uid'], false); return new JsonResponse([ 'allowed' => true, 'user' => ['name' => $user['name'], 'email' => $user['email']], 'recipients' => $this->recipientOptions(), 'summary' => $summary === null ? null : $this->summaryPayload($summary), ]); } /** @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(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(); $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); } $html = '

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

' . '

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

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

General notes

' . $payload['generalNotes']; } foreach ($payload['items'] as $i => $item) { $html .= '

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

' . $item['note']; if ($item['attachments'] !== []) { $html .= '

Attachments:

'; } } $mail = GeneralUtility::makeInstance(MailMessage::class); $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']]); return new JsonResponse(['ok' => true, 'sent' => count($payload['items'])]); } // ---- helpers ------------------------------------------------------------ /** @return array|null */ private function openSummary(int $userUid, bool $createIfMissing): ?array { $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::T_SUMMARY); $summary = $connection->fetchAssociative( 'SELECT * FROM ' . self::T_SUMMARY . " WHERE deleted = 0 AND status = 'open' AND fe_user = ? ORDER BY uid DESC", [$userUid] ); if ($summary) { return $summary; } if (!$createIfMissing) { return null; } $connection->insert(self::T_SUMMARY, [ 'pid' => $this->settings->storagePid(), 'tstamp' => time(), 'crdate' => time(), 'fe_user' => $userUid, 'status' => 'open', ]); return $this->openSummary($userUid, false); } /** * @param array $summary * @return array{uid:int,generalNotes:string,recipients:int[],items:array}>} */ private function summaryPayload(array $summary): array { $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::T_FEEDBACK); $items = []; foreach ($connection->fetchAllAssociative( 'SELECT uid, url, note FROM ' . self::T_FEEDBACK . ' WHERE deleted = 0 AND summary = ? ORDER BY sorting', [(int)$summary['uid']] ) as $row) { $items[] = [ 'uid' => (int)$row['uid'], 'url' => (string)$row['url'], 'note' => (string)$row['note'], 'attachments' => $this->attachments((int)$row['uid']), ]; } return [ 'uid' => (int)$summary['uid'], 'generalNotes' => (string)($summary['general_notes'] ?? ''), 'recipients' => GeneralUtility::intExplode(',', (string)($summary['recipients'] ?? ''), true), 'items' => $items, ]; } /** @return array */ private function attachments(int $feedbackUid): array { $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_file_reference'); $rows = $connection->fetchAllAssociative( 'SELECT r.uid AS ref_uid, f.name, f.identifier FROM sys_file_reference r JOIN sys_file f ON f.uid = r.uid_local WHERE r.deleted = 0 AND r.tablenames = ? AND r.fieldname = ? AND r.uid_foreign = ? ORDER BY r.uid', [self::T_FEEDBACK, 'attachments', $feedbackUid] ); return array_map(static fn(array $r): array => [ 'refUid' => (int)$r['ref_uid'], 'name' => (string)$r['name'], 'url' => '/fileadmin' . $r['identifier'], ], $rows); } /** @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 id => recipient */ private function recipientMap(): array { return $this->settings->recipients(); } /** @return array */ private function recipientOptions(): array { $options = []; foreach ($this->recipientMap() as $id => $recipient) { $options[] = ['id' => $id, 'label' => $recipient['label']]; } return $options; } /** @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, '