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-<uid>/ 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)
This commit is contained in:
orasche committed 2026-10-02 16:37:01 +02:00
1 parent 78af83d2e3
commit 872c6e715c
14 files changed
+842 -95

No files matched your search

@@ -0,0 +1,452 @@
<?php
declare(strict_types=1);
namespace Evomedien\FeComment\Middleware;
use Evomedien\FeComment\Service\ExtensionSettings;
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($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 = '<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>' . $payload['generalNotes'];
}
foreach ($payload['items'] as $i => $item) {
$html .= '<hr><h3>' . ($i + 1) . ') ' . htmlspecialchars($item['url'] ?: '(no url)') . '</h3>' . $item['note'];
if ($item['attachments'] !== []) {
$html .= '<p><strong>Attachments:</strong></p><ul>';
foreach ($item['attachments'] as $attachment) {
$base = 'https://' . ($_SERVER['HTTP_HOST'] ?? 'dev.vitec.com');
$html .= '<li><a href="' . htmlspecialchars($base . $attachment['url']) . '">' . htmlspecialchars($attachment['name']) . '</a></li>';
}
$html .= '</ul>';
}
}
$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<string,mixed>|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<string,mixed> $summary
* @return array{uid:int,generalNotes:string,recipients:int[],items:array<int,array{uid:int,url:string,note:string,attachments:array<int,array{refUid:int,name:string,url:string}>}>}
*/
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<int,array{refUid:int,name:string,url:string}> */
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<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}> id => recipient */
private function recipientMap(): array
{
return $this->settings->recipients();
}
/** @return array<int,array{id:int,label:string}> */
private function recipientOptions(): array
{
$options = [];
foreach ($this->recipientMap() as $id => $recipient) {
$options[] = ['id' => $id, 'label' => $recipient['label']];
}
return $options;
}
/** @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>');
}
}
}
@@ -1,91 +0,0 @@
<?php
declare(strict_types=1);
namespace Evomedien\FeComment\Middleware;
use Evomedien\FeComment\Service\ExtensionSettings;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Mail\MailMessage;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* POST /api/fe-comment/submit - sends the note pad by mail.
*
* Payload: {"entries": [{"url": "/product/hx4", "comment": "..."}]}
* Response: {"success": true} or {"success": false, "error": "..."}
*
* Recipients (to/cc/bcc) and subject come from the extension configuration;
* without a configured "to" the endpoint refuses, so feedback can never
* silently vanish. The sender is the system default mail-from.
*/
final class SubmitMiddleware implements MiddlewareInterface
{
private const PATH = '/api/fe-comment/submit';
private const MAX_ENTRIES = 100;
private const MAX_URL_LENGTH = 2000;
private const MAX_COMMENT_LENGTH = 10000;
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
if (rtrim($request->getUri()->getPath(), '/') !== self::PATH) {
return $handler->handle($request);
}
if (strtoupper($request->getMethod()) !== 'POST') {
return new JsonResponse(['success' => false, 'error' => 'POST only'], 405);
}
try {
$settings = GeneralUtility::makeInstance(ExtensionSettings::class);
if (!$settings->enabled()) {
return new JsonResponse(['success' => false, 'error' => 'Feedback is disabled'], 403);
}
if ($settings->mailTo() === []) {
return new JsonResponse(['success' => false, 'error' => 'No recipient configured (extension configuration fe_comment)'], 503);
}
$payload = json_decode((string)$request->getBody(), true);
$entries = is_array($payload['entries'] ?? null) ? $payload['entries'] : [];
$clean = [];
foreach (array_slice($entries, 0, self::MAX_ENTRIES) as $entry) {
$url = mb_substr(trim((string)($entry['url'] ?? '')), 0, self::MAX_URL_LENGTH);
$comment = mb_substr(trim((string)($entry['comment'] ?? '')), 0, self::MAX_COMMENT_LENGTH);
if ($comment !== '') {
$clean[] = ['url' => $url, 'comment' => $comment];
}
}
if ($clean === []) {
return new JsonResponse(['success' => false, 'error' => 'No entries'], 400);
}
$host = $request->getUri()->getHost();
$lines = ['Page feedback from ' . $host, 'Submitted: ' . date('Y-m-d H:i'), str_repeat('-', 60), ''];
foreach ($clean as $i => $entry) {
$lines[] = ($i + 1) . ') ' . ($entry['url'] !== '' ? $entry['url'] : '(no url)');
$lines[] = $entry['comment'];
$lines[] = '';
}
$mail = GeneralUtility::makeInstance(MailMessage::class);
$mail->to(...$settings->mailTo())
->subject($settings->subject() . ' — ' . $host . ' (' . count($clean) . ')')
->text(implode("\n", $lines));
if ($settings->mailCc() !== []) {
$mail->cc(...$settings->mailCc());
}
if ($settings->mailBcc() !== []) {
$mail->bcc(...$settings->mailBcc());
}
// v14: MailMessage no longer has send() - the Mailer service does.
GeneralUtility::makeInstance(\TYPO3\CMS\Core\Mail\Mailer::class)->send($mail);
return new JsonResponse(['success' => true, 'sent' => count($clean)]);
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'error' => 'Send failed: ' . $e->getMessage()], 500);
}
}
}
@@ -56,6 +56,37 @@ final class ExtensionSettings
return (bool)($this->settings['enabled'] ?? true);
}
/** FE user group required to use the widget; 0 = any logged-in user. */
public function feUserGroup(): int
{
return (int)($this->settings['feUserGroup'] ?? 0);
}
/** Storage pid for the feedback records (backend visibility). */
public function storagePid(): int
{
return (int)($this->settings['storagePid'] ?? 0);
}
/**
* Selectable recipients, parsed from "Label <mail>; Label2 <mail2>".
* The index doubles as the id the widget sends back.
*
* @return array<int,array{label:string,email:string}>
*/
public function recipients(): array
{
$recipients = [];
foreach (GeneralUtility::trimExplode(';', (string)($this->settings['recipients'] ?? ''), true) as $entry) {
if (preg_match('/^(.*)<([^>]+)>\s*$/', $entry, $m) && GeneralUtility::validEmail(trim($m[2]))) {
$recipients[] = ['label' => trim($m[1]) ?: trim($m[2]), 'email' => trim($m[2])];
} elseif (GeneralUtility::validEmail(trim($entry))) {
$recipients[] = ['label' => trim($entry), 'email' => trim($entry)];
}
}
return $recipients;
}
/** true = headless: TYPO3 injects nothing, the front end embeds the widget itself. */
public function jsonOutput(): bool
{
@@ -17,13 +17,17 @@ return [
'typo3/cms-frontend/site',
],
],
'fecomment/submit' => [
'target' => \Evomedien\FeComment\Middleware\SubmitMiddleware::class,
// v2: the feedback API needs the logged-in FE user, so it runs
// AFTER frontend authentication (earlier the frontend.user
// attribute does not exist yet). Replaces the old anonymous
// submit endpoint.
'fecomment/feedback-api' => [
'target' => \Evomedien\FeComment\Middleware\FeedbackApiMiddleware::class,
'after' => [
'typo3/cms-core/normalized-params-attribute',
'typo3/cms-frontend/authentication',
],
'before' => [
'typo3/cms-frontend/site',
'typo3/cms-frontend/page-resolver',
],
],
],
@@ -0,0 +1,46 @@
<?php
/**
* One feedback item (page URL + rich-text note + attachments), owned by a
* FE user, belonging to a summary. Written from the front end.
*/
return [
'ctrl' => [
'title' => 'Feedback',
'label' => 'url',
'tstamp' => 'tstamp',
'crdate' => 'crdate',
'delete' => 'deleted',
'sortby' => 'sorting',
'hideTable' => true,
'iconfile' => 'EXT:fe_comment/Resources/Public/Icons/Extension.svg',
'security' => ['ignorePageTypeRestriction' => true],
],
'types' => [
'1' => ['showitem' => 'url, note, attachments, fe_user'],
],
'columns' => [
'summary' => [
'config' => ['type' => 'passthrough'],
],
'fe_user' => [
'label' => 'Author (frontend user)',
'config' => ['type' => 'group', 'allowed' => 'fe_users', 'maxitems' => 1, 'size' => 1],
],
'url' => [
'label' => 'Page URL',
'config' => ['type' => 'input', 'size' => 50, 'max' => 2048],
],
'note' => [
'label' => 'Note',
'config' => ['type' => 'text', 'rows' => 6, 'enableRichtext' => true],
],
'attachments' => [
'label' => 'Attachments',
'config' => [
'type' => 'file',
'maxitems' => 20,
],
],
],
];
@@ -0,0 +1,68 @@
<?php
/**
* One FE user's feedback pad. Created from the front end (plain inserts);
* this TCA exists so editors can READ and tidy submissions in the backend.
*/
return [
'ctrl' => [
'title' => 'Feedback summary',
'label' => 'status',
'label_alt' => 'fe_user,sent_at',
'label_alt_force' => true,
'tstamp' => 'tstamp',
'crdate' => 'crdate',
'delete' => 'deleted',
'default_sortby' => 'tstamp DESC',
'iconfile' => 'EXT:fe_comment/Resources/Public/Icons/Extension.svg',
'security' => ['ignorePageTypeRestriction' => true],
],
'types' => [
'1' => ['showitem' => 'status, fe_user, recipients, general_notes, feedback_items'],
],
'columns' => [
'status' => [
'label' => 'Status',
'config' => [
'type' => 'select',
'renderType' => 'selectSingle',
'items' => [
['label' => 'open', 'value' => 'open'],
['label' => 'sent', 'value' => 'sent'],
],
'default' => 'open',
],
],
'fe_user' => [
'label' => 'Author (frontend user)',
'config' => [
'type' => 'group',
'allowed' => 'fe_users',
'maxitems' => 1,
'size' => 1,
],
],
'general_notes' => [
'label' => 'General notes / instructions',
'config' => ['type' => 'text', 'rows' => 6, 'enableRichtext' => true],
],
'recipients' => [
'label' => 'Chosen recipients',
'config' => ['type' => 'input', 'size' => 50, 'readOnly' => true],
],
'sent_at' => [
'label' => 'Sent at',
'config' => ['type' => 'datetime', 'readOnly' => true, 'default' => 0],
],
'feedback_items' => [
'label' => 'Feedback items',
'config' => [
'type' => 'inline',
'foreign_table' => 'tx_fecomment_feedback',
'foreign_field' => 'summary',
'foreign_sortby' => 'sorting',
'appearance' => ['collapseAll' => true, 'expandSingle' => true],
],
],
],
];
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path fill="#666" d="M12.053 11.026c-.238.07-.427.095-.674.095-2.033 0-5.017-7.1-5.017-9.462 0-.87.207-1.16.497-1.41C4.373.54 1.39 1.452.435 2.613c-.207.29-.332.746-.332 1.326C.103 7.628 4.04 16 6.82 16c1.283 0 3.45-2.114 5.233-4.974M10.756 0c2.57 0 5.14.415 5.14 1.865 0 2.943-1.865 6.508-2.818 6.508-1.7 0-3.814-4.725-3.814-7.088C9.264.207 9.68 0 10.756 0"></path>
</svg>

After

Width:  |  Height:  |  Size: 476 B

@@ -15,3 +15,12 @@ subject = Page feedback
# cat=output//10; type=boolean; label=JSON output (headless mode): 1 = TYPO3 injects nothing, the front end embeds <script src="/api/fe-comment/widget.js"> itself (SPA / React). 0 = classic TYPO3 site, the widget script is injected into every rendered page automatically.
jsonOutput = 1
# cat=access//10; type=string; label=FE user group (uid): Only logged-in frontend users in this group see and use the widget. 0 = any logged-in user.
feUserGroup = 0
# cat=mail//50; type=string; label=Selectable recipients: who the feedback can be sent to, as "Label <mail@example.com>" entries separated by semicolons. Empty = only the Mail to address above.
recipients =
# cat=basic//20; type=string; label=Storage pid: page/folder uid the feedback records are stored on (backend visibility). 0 = root.
storagePid = 0
+24
View File
@@ -0,0 +1,24 @@
#
# fe_comment v2 (2026-10-02): server-side feedback with FE-user ownership.
# A summary is one visitor's note pad (open until submitted); feedback rows
# are its items. Written from the FRONTEND via plain inserts (no DataHandler
# available there) - TCA exists for backend read/edit access.
#
CREATE TABLE tx_fecomment_summary (
fe_user int(11) DEFAULT '0' NOT NULL,
status varchar(10) DEFAULT 'open' NOT NULL,
general_notes text,
recipients varchar(1024) DEFAULT '' NOT NULL,
sent_at int(11) DEFAULT '0' NOT NULL,
KEY feuser (fe_user, status)
);
CREATE TABLE tx_fecomment_feedback (
summary int(11) DEFAULT '0' NOT NULL,
fe_user int(11) DEFAULT '0' NOT NULL,
url varchar(2048) DEFAULT '' NOT NULL,
note mediumtext,
attachments int(11) DEFAULT '0' NOT NULL,
sorting int(11) DEFAULT '0' NOT NULL,
KEY summary (summary)
);