fe_comment: widget v2, overview link setting and inline mail images

- Rebuild the widget server-backed: it renders only when GET state
  allows (logged-in FE user of the configured group), so the script
  tag is safe on public pages; form with mini rich text (bold/italic/
  list), file attachment chips and Ctrl+V image paste - a paste lazily
  creates the draft feedback, uploads and inserts the image inline
- Note pad badge mirrors the server item count; the pad button links
  to the overview page via new extension setting overviewPid, exposed
  as overviewUrl in GET state (slug lookup)
- Embed /fileadmin images of notes into the feedback mail as CID
  parts: relative srcs are dead in mail clients and dev sits behind
  basic auth; attachment links stay absolute URLs
- public/widget-test.html: dev test page, doubles as the reference
  implementation of the React login flow (fetch felogin JSON form
  incl. __RequestToken, urlencoded POST to /_json + form.action)
- Docs section 5x (fe_comment v2 incl. CSRF login lesson,
  templateRootPaths.10 collision, cache:flush before extension:setup)
This commit is contained in:
orasche committed 2026-10-02 16:57:52 +02:00
1 parent 872c6e715c
commit d496877a53
6 files changed
+510 -217

No files matched your search

@@ -120,10 +120,28 @@ final class FeedbackApiMiddleware implements MiddlewareInterface
'allowed' => true,
'user' => ['name' => $user['name'], 'email' => $user['email']],
'recipients' => $this->recipientOptions(),
'overviewUrl' => $this->overviewUrl(),
'summary' => $summary === null ? null : $this->summaryPayload($summary),
]);
}
/**
* Visitor-facing URL of the feedback overview page (extension setting
* overviewPid), resolved via the page slug. Empty string when unset.
*/
private function overviewUrl(): string
{
$pid = $this->settings->overviewPid();
if ($pid <= 0) {
return '';
}
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages');
$slug = $queryBuilder->select('slug')->from('pages')
->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($pid, \Doctrine\DBAL\ParameterType::INTEGER)))
->executeQuery()->fetchOne();
return is_string($slug) ? $slug : '';
}
/** @param array{uid:int,username:string,name:string,email:string} $user */
private function createFeedback(ServerRequestInterface $request, array $user): ResponseInterface
{
@@ -278,18 +296,24 @@ final class FeedbackApiMiddleware implements MiddlewareInterface
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>' . $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>' . $item['note'];
$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) {
$base = 'https://' . ($_SERVER['HTTP_HOST'] ?? 'dev.vitec.com');
$html .= '<li><a href="' . htmlspecialchars($base . $attachment['url']) . '">' . htmlspecialchars($attachment['name']) . '</a></li>';
}
$html .= '</ul>';
@@ -297,6 +321,9 @@ final class FeedbackApiMiddleware implements MiddlewareInterface
}
$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);
@@ -318,6 +345,33 @@ final class FeedbackApiMiddleware implements MiddlewareInterface
// ---- 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
{
@@ -68,6 +68,12 @@ final class ExtensionSettings
return (int)($this->settings['storagePid'] ?? 0);
}
/** Page uid of the feedback overview page; 0 = no overview link. */
public function overviewPid(): int
{
return (int)($this->settings['overviewPid'] ?? 0);
}
/**
* Selectable recipients, parsed from "Label <mail>; Label2 <mail2>".
* The index doubles as the id the widget sends back.