From 872c6e715cfa873d77206420784aceac5e77127e Mon Sep 17 00:00:00 2001 From: Oliver Rasche Date: Fri, 2 Oct 2026 16:37:01 +0200 Subject: [PATCH] fe_comment v2: FE-user feedback tickets with summary, uploads and API - Replace the v1 submit endpoint with a full JSON API under /api/fe-comment/ (state, feedback create/update/delete, uploads, summary notes/recipients, submit), guarded by frontend login and a configurable FE user group; unauthorized callers get {"allowed":false} - New models: tx_fecomment_summary (one open ticket per user, status open/sent) and tx_fecomment_feedback (url, RTE note, FAL attachments), both editable in the backend on the ticket storage page - FeedbackApiMiddleware registered after frontend authentication (the frontend.user attribute does not exist earlier); uploads land in fileadmin/fe_comment/summary-/ with mime/extension check, 20 MB cap - Submit sends one HTML mail (author block, items with attachment links, reply-to = FE user mail) to selected recipients with mailTo fallback, then closes the summary - felogin wired React-compatible: felogin.pid=520 plus headless FeLogin JSON templates via site settings - felogin v14 and EXT:headless both claim templateRootPaths.10, the felogin.view.* settings feed exactly that index, so the structured JSON form (incl. __RequestToken) wins - Read the sys_file_reference insert id before syncAttachmentCount(): DBAL throws "no identity value" once the count UPDATE has run - migrations: drop_fecomment_tables.php (repairs the no-uid tables, only drops when empty), check_feuser.php (login diagnosis), fec_login_test.sh / fec_api_test.sh (curl chains incl. CSRF token flow) --- config/sites/vitec/settings.yaml | 7 + migrations/check_feuser.php | 61 +++ migrations/drop_fecomment_tables.php | 36 ++ migrations/fec_api_test.sh | 43 ++ migrations/fec_login_test.sh | 53 ++ .../Middleware/FeedbackApiMiddleware.php | 452 ++++++++++++++++++ .../Classes/Middleware/SubmitMiddleware.php | 91 ---- .../Classes/Service/ExtensionSettings.php | 31 ++ .../Configuration/RequestMiddlewares.php | 12 +- .../TCA/tx_fecomment_feedback.php | 46 ++ .../TCA/tx_fecomment_summary.php | 68 +++ .../Resources/Public/Icons/Extension.svg | 4 + packages/fe_comment/ext_conf_template.txt | 9 + packages/fe_comment/ext_tables.sql | 24 + 14 files changed, 842 insertions(+), 95 deletions(-) create mode 100644 migrations/check_feuser.php create mode 100644 migrations/drop_fecomment_tables.php create mode 100644 migrations/fec_api_test.sh create mode 100644 migrations/fec_login_test.sh create mode 100644 packages/fe_comment/Classes/Middleware/FeedbackApiMiddleware.php delete mode 100644 packages/fe_comment/Classes/Middleware/SubmitMiddleware.php create mode 100644 packages/fe_comment/Configuration/TCA/tx_fecomment_feedback.php create mode 100644 packages/fe_comment/Configuration/TCA/tx_fecomment_summary.php create mode 100644 packages/fe_comment/Resources/Public/Icons/Extension.svg create mode 100644 packages/fe_comment/ext_tables.sql diff --git a/config/sites/vitec/settings.yaml b/config/sites/vitec/settings.yaml index cae08f2..b8619e1 100644 --- a/config/sites/vitec/settings.yaml +++ b/config/sites/vitec/settings.yaml @@ -4,3 +4,10 @@ menu.footer.pageUids: '1,2,3' menu.meta.pageUids: '5' megamenu.contentUid: 10366 vitec.storyDetailPid: 26 +felogin.pid: 520 +# felogin v14 und EXT:headless belegen beide templateRootPaths.10 - je nach +# Set-Reihenfolge gewinnt das HTML-Template. Das Setting speist genau diesen +# Index, darum hier fest auf die Headless-JSON-Templates zeigen: +felogin.view.templateRootPath: 'EXT:headless/Resources/Private/FeLogin/Templates/' +felogin.view.partialRootPath: 'EXT:headless/Resources/Private/FeLogin/Partials/' +felogin.view.layoutRootPath: 'EXT:headless/Resources/Private/FeLogin/Layouts/' diff --git a/migrations/check_feuser.php b/migrations/check_feuser.php new file mode 100644 index 0000000..b221ecd --- /dev/null +++ b/migrations/check_feuser.php @@ -0,0 +1,61 @@ + '' + * Gibt KEINE Geheimnisse aus - nur Statusfelder und ein verify-Ergebnis. + */ +if ($argc < 2) { + fwrite(STDERR, "Aufruf: php migrations/check_feuser.php ['']\n"); + exit(1); +} +$username = $argv[1]; +$password = $argv[2] ?? null; + +$settings = require __DIR__ . '/../config/system/settings.php'; +$db = $settings['DB']['Connections']['Default']; +$pdo = new PDO( + 'mysql:host=' . $db['host'] . ';dbname=' . $db['dbname'] . ';charset=utf8mb4', + $db['user'], + $db['password'] +); +$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +$stmt = $pdo->prepare( + 'SELECT uid, pid, username, usergroup, disable, deleted, starttime, endtime, password + FROM fe_users WHERE username = ?' +); +$stmt->execute([$username]); +$rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + +if (!$rows) { + echo "KEIN fe_users-Datensatz mit username='$username' gefunden (auch kein geloeschter).\n"; + exit(0); +} + +$now = time(); +foreach ($rows as $r) { + echo "--- fe_users uid={$r['uid']} ---\n"; + echo 'pid: ' . $r['pid'] . ($r['pid'] == 520 ? ' (ok)' : ' <-- NICHT 520! Login-POST braucht pid=' . $r['pid']) . "\n"; + echo 'usergroup: "' . $r['usergroup'] . '"' . (in_array('1', array_map('trim', explode(',', (string)$r['usergroup'])), true) ? ' (enthaelt Gruppe 1, ok)' : ' <-- Gruppe 1 FEHLT') . "\n"; + echo 'deleted: ' . $r['deleted'] . ($r['deleted'] ? ' <-- GELOESCHT' : ' (ok)') . "\n"; + echo 'disable: ' . $r['disable'] . ($r['disable'] ? ' <-- DEAKTIVIERT' : ' (ok)') . "\n"; + echo 'starttime: ' . $r['starttime'] . ($r['starttime'] > $now ? ' <-- liegt in der ZUKUNFT' : ' (ok)') . "\n"; + echo 'endtime: ' . $r['endtime'] . (($r['endtime'] && $r['endtime'] < $now) ? ' <-- ABGELAUFEN' : ' (ok)') . "\n"; + $hash = (string)$r['password']; + $prefix = substr($hash, 0, strpos($hash, '$', 1) ?: 8); + echo 'hash-typ: ' . ($prefix !== '' ? $prefix : '(leer!)') . "\n"; + if ($password !== null) { + if (str_starts_with($hash, '$argon2') || str_starts_with($hash, '$2y$')) { + echo 'passwort: ' . (password_verify($password, $hash) ? 'VERIFIZIERT - stimmt mit dem Hash ueberein' : 'FALSCH - stimmt NICHT mit dem Hash ueberein') . "\n"; + } else { + echo "passwort: Hash-Format unbekannt, kein lokaler Verify moeglich\n"; + } + } +} + +// Gruppe 1 existiert und ist nicht versteckt/geloescht? +$g = $pdo->query('SELECT uid, pid, title, hidden, deleted FROM fe_groups WHERE uid = 1')->fetch(PDO::FETCH_ASSOC); +echo "--- fe_groups uid=1 ---\n"; +echo $g + ? "title: \"{$g['title']}\" pid: {$g['pid']} hidden: {$g['hidden']} deleted: {$g['deleted']}\n" + : "EXISTIERT NICHT!\n"; diff --git a/migrations/drop_fecomment_tables.php b/migrations/drop_fecomment_tables.php new file mode 100644 index 0000000..0bde5ab --- /dev/null +++ b/migrations/drop_fecomment_tables.php @@ -0,0 +1,36 @@ + PDO::ERRMODE_EXCEPTION] +); +foreach (['tx_fecomment_summary', 'tx_fecomment_feedback'] as $table) { + $count = 0; + try { $count = (int)$pdo->query('SELECT COUNT(*) FROM `' . $table . '`')->fetchColumn(); } catch (\Throwable) { echo "$table: existiert nicht - ok\n"; continue; } + if ($count > 0) { exit("$table enthaelt $count Zeilen - ABBRUCH, nicht droppen!\n"); } + $pdo->exec('DROP TABLE `' . $table . '`'); + echo "$table: geloescht (war leer)\n"; +} +echo "Fertig. Jetzt: vendor/bin/typo3 cache:flush && vendor/bin/typo3 extension:setup\n"; diff --git a/migrations/fec_api_test.sh b/migrations/fec_api_test.sh new file mode 100644 index 0000000..ce1f290 --- /dev/null +++ b/migrations/fec_api_test.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# fe_comment API-Testkette (braucht eingeloggte Session aus fec_login_test.sh) +# Aufruf: bash migrations/fec_api_test.sh +set -e +cd /usr/www/users/vitecevo/live +BASE=https://dev.vitec.com +JAR=/tmp/fec.jar +[ -s "$JAR" ] || { echo "Cookie-Jar fehlt - erst migrations/fec_login_test.sh laufen lassen."; exit 1; } + +echo '--- 1) Feedback anlegen (mit Sanitizer-Probe: script-Tag muss verschwinden, strong bleiben) ---' +curl -sS -b "$JAR" -H 'Content-Type: application/json' \ + -d '{"url":"https://dev.vitec.com/testseite","note":"

Erster Test mit fett

"}' \ + "$BASE/api/fe-comment/feedback" | tee /tmp/fec_fb.json; echo + +FB=$(php -r '$j=json_decode(file_get_contents("/tmp/fec_fb.json"),true);echo (int)($j["uid"]??0);') +echo "Feedback-UID: $FB" +[ "$FB" -gt 0 ] || { echo "Anlegen fehlgeschlagen."; exit 1; } + +echo '--- 2) Feedback aendern ---' +curl -sS -b "$JAR" -H 'Content-Type: application/json' \ + -d '{"note":"

Geaenderte Notiz, kursiv.

"}' \ + "$BASE/api/fe-comment/feedback/$FB"; echo + +echo '--- 3) Datei-Upload (Test-PNG vom Server) ---' +curl -sS -b "$JAR" \ + -F "file=@public/testimages/placeholder_1920x600.png;type=image/png" \ + -F "feedback=$FB" \ + "$BASE/api/fe-comment/upload"; echo + +echo '--- 4) General Notes an der Summary ---' +curl -sS -b "$JAR" -H 'Content-Type: application/json' \ + -d '{"generalNotes":"

Bitte alles einmal durchsehen.

","recipients":[]}' \ + "$BASE/api/fe-comment/summary"; echo + +echo '--- 5) Gesamtzustand ---' +curl -sS -b "$JAR" "$BASE/api/fe-comment/state"; echo + +read -rp "6) Submit ausloesen (Mail an die mailTo-Adresse)? [j/N] " yn +if [[ "$yn" =~ ^[jJyY] ]]; then + curl -sS -b "$JAR" -X POST "$BASE/api/fe-comment/submit"; echo + echo '--- state nach Submit (Summary sollte weg/neu sein) ---' + curl -sS -b "$JAR" "$BASE/api/fe-comment/state"; echo +fi diff --git a/migrations/fec_login_test.sh b/migrations/fec_login_test.sh new file mode 100644 index 0000000..993706b --- /dev/null +++ b/migrations/fec_login_test.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# fe_comment Login-Testkette: GET (Nonce+Token) -> Login-POST -> state +# Aufruf: bash migrations/fec_login_test.sh (fragt das Passwort unsichtbar ab) +set -e +cd /usr/www/users/vitecevo/live +BASE=https://dev.vitec.com + +read -rsp "Passwort fuer joe: " PASS; echo + +rm -f /tmp/fec.jar +curl -sS -c /tmp/fec.jar -o /tmp/fec_form.json "$BASE/_json/fe-user-profile" + +TOKEN=$(php -r ' +$j = json_decode(file_get_contents("/tmp/fec_form.json"), true); +$f = ""; +$w = function ($n) use (&$w, &$f) { + if (is_array($n)) { + // Variante 1: strukturiertes headless-Formular {name: "__RequestToken", value: "..."} + if (($n["name"] ?? "") === "__RequestToken" && isset($n["value"])) { + $f = (string)$n["value"]; + } + foreach ($n as $c) { $w($c); } + } elseif (is_string($n) && $f === "") { + // Variante 2: felogin rendert klassisches HTML als String im JSON + if (preg_match("/name=\"__RequestToken\" value=\"([^\"]+)\"/", $n, $m)) { + $f = $m[1]; + } + } +}; +$w($j); +echo $f; +') + +echo "Nonce-Cookies im Jar: $(grep -c typo3nonce /tmp/fec.jar || true)" +echo "Token-Laenge: ${#TOKEN}" +if [ -z "$TOKEN" ]; then + echo "KEIN __RequestToken im Seiten-JSON gefunden." + echo "Vorkommen von 'RequestToken' im JSON (zur Diagnose):" + grep -o 'RequestToken[^"]*"[^"]*"' /tmp/fec_form.json | head -n 3 || echo "(keine)" + exit 1 +fi + +curl -sS -b /tmp/fec.jar -c /tmp/fec.jar -o /tmp/fec_login.json \ + --data-urlencode "user=joe" \ + --data-urlencode "pass=$PASS" \ + --data-urlencode "logintype=login" \ + --data-urlencode "pid=520" \ + --data-urlencode "__RequestToken=$TOKEN" \ + "$BASE/_json/fe-user-profile" + +echo "Session-Cookies (fe_typo_user): $(grep -c fe_typo_user /tmp/fec.jar || true)" +echo '--- state ---' +curl -sS -b /tmp/fec.jar "$BASE/api/fe-comment/state"; echo diff --git a/packages/fe_comment/Classes/Middleware/FeedbackApiMiddleware.php b/packages/fe_comment/Classes/Middleware/FeedbackApiMiddleware.php new file mode 100644 index 0000000..ed2fd34 --- /dev/null +++ b/packages/fe_comment/Classes/Middleware/FeedbackApiMiddleware.php @@ -0,0 +1,452 @@ + 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, '