- New extension fe_comment (Evomedien, framework-agnostic): floating page

feedback widget served from /api/fe-comment/widget.js, note pad in
  localStorage, submissions mailed via /api/fe-comment/submit with
  recipients from the extension configuration; classic sites get the
  script injected via AssetCollector when the jsonOutput setting is off.
  An enabled master switch serves the widget EMPTY when off (no 404, no
  console errors) and rejects submissions - the go-live kill switch, no
  front-end build change needed
- URL swap finished: /_json prefix middleware, SPA fallback live with a
  dedicated root rule (REQUEST_FILENAME of / is a directory) and a
  query-string exception for the TYPO3 v14 install tool entry
  (?__typo3_install on the site root - the backend Settings/Environment
  modules depend on it); /api/ as a whole stays with TYPO3
- MailMessage::send() no longer exists in v14: fe_comment and the form
  EmailDelivery (latently broken since the core upgrade) now send through
  the Mailer service. Mail transport moved to SMTP against the local exim
  so delivery errors surface instead of bouncing into the blackhole -
  exim verifies the envelope sender, which therefore must be a routable
  existing address (transport config lives in the untracked settings.php)
- Related-product card texts editable on the product record (table rebuilt
  with uid/pid, TCA + inline field, one-time migration script); shared
  RteCompare for the module badge and the CLI comparison - batch dry-run
  now reports 0 writes instead of flagging every richtext field
- Workbook clarification cases executed: VisionIO-SD14, VisionSC-SD14 and
  both Diamond OG records imported (40 fields, live-verified); ArtioSign
  and ArtioView created hidden (uid 109/110) via
  create_artio_records.php - the product table has no sorting column;
  WallControl_10 mapping REVERTED and parked: the dry run revealed
  aetria-wallcontrol already carries its own agency workbook, which
  WallControl_10 would have overwritten. Manifest now 75 product /
  24 family / 1 open
This commit is contained in:
orasche committed 2026-09-30 13:59:55 +02:00
1 parent 6db2234851
commit f0b0694842
28 files changed
+1272 -54

No files matched your search

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Evomedien\FeComment\EventListener;
use Evomedien\FeComment\Service\ExtensionSettings;
use TYPO3\CMS\Core\Http\ApplicationType;
use TYPO3\CMS\Core\Page\Event\BeforeJavaScriptsRenderingEvent;
/**
* Classic-site mode (extension setting jsonOutput = 0): add the widget
* script to every TYPO3-rendered frontend page, so the feedback button
* appears with zero TypoScript. In headless mode (jsonOutput = 1, the
* default) this listener does nothing - the SPA embeds
* /api/fe-comment/widget.js itself.
*/
final class InjectWidget
{
public function __invoke(BeforeJavaScriptsRenderingEvent $event): void
{
if ($event->isInline() || $event->isPriority()) {
return;
}
try {
$request = $GLOBALS['TYPO3_REQUEST'] ?? null;
if ($request === null || !ApplicationType::fromRequest($request)->isFrontend()) {
return;
}
$settings = new ExtensionSettings();
if (!$settings->enabled() || $settings->jsonOutput()) {
return;
}
$event->getAssetCollector()->addJavaScript(
'fe-comment-widget',
'/api/fe-comment/widget.js',
['defer' => 'defer'],
['useNonce' => true]
);
} catch (\Throwable) {
// a broken feedback widget must never break the page
}
}
}
@@ -0,0 +1,91 @@
<?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);
}
}
}
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Evomedien\FeComment\Middleware;
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\Response;
use TYPO3\CMS\Core\Http\Stream;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Serves the widget assets under STABLE urls, independent of composer's
* hashed _assets publishing:
*
* /api/fe-comment/widget.js
* /api/fe-comment/widget.css
*
* One <script> tag is the whole integration - the JS injects its own CSS
* link. Short client cache so editors see fixes quickly; the widget is a
* review tool, not a high-traffic asset.
*/
final class WidgetAssetMiddleware implements MiddlewareInterface
{
private const FILES = [
'/api/fe-comment/widget.js' => ['EXT:fe_comment/Resources/Public/JavaScript/fe-comment.js', 'application/javascript; charset=utf-8'],
'/api/fe-comment/widget.css' => ['EXT:fe_comment/Resources/Public/Css/fe-comment.css', 'text/css; charset=utf-8'],
];
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$path = $request->getUri()->getPath();
$file = self::FILES[$path] ?? null;
if ($file === null) {
return $handler->handle($request);
}
// Kill switch: the go-live off state serves an EMPTY file instead of
// a 404, so a front end still embedding the script tag stays silent -
// no console errors, no build change needed.
if (!GeneralUtility::makeInstance(\Evomedien\FeComment\Service\ExtensionSettings::class)->enabled()) {
return (new Response())
->withHeader('Content-Type', $file[1])
->withHeader('Cache-Control', 'public, max-age=300')
->withBody(GeneralUtility::makeInstance(\TYPO3\CMS\Core\Http\StreamFactory::class)
->createStream('/* fe_comment is disabled (extension configuration) */'));
}
$absolute = GeneralUtility::getFileAbsFileName($file[0]);
if ($absolute === '' || !is_file($absolute)) {
return (new Response())->withStatus(404);
}
return (new Response())
->withHeader('Content-Type', $file[1])
->withHeader('Cache-Control', 'public, max-age=300')
->withBody(new Stream($absolute, 'rb'));
}
}
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Evomedien\FeComment\Service;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Typed access to the extension configuration (Settings > Extension
* Configuration > fe_comment). Every accessor is failure-tolerant: an
* unconfigured extension behaves like "everything empty, headless mode".
*/
final class ExtensionSettings
{
/** @var array<string,mixed> */
private array $settings;
public function __construct()
{
try {
$this->settings = (array)GeneralUtility::makeInstance(ExtensionConfiguration::class)->get('fe_comment');
} catch (\Throwable) {
$this->settings = [];
}
}
/** @return string[] */
public function mailTo(): array
{
return $this->addressList('mailTo');
}
/** @return string[] */
public function mailCc(): array
{
return $this->addressList('mailCc');
}
/** @return string[] */
public function mailBcc(): array
{
return $this->addressList('mailBcc');
}
public function subject(): string
{
$subject = trim((string)($this->settings['subject'] ?? ''));
return $subject !== '' ? $subject : 'Page feedback';
}
/** Master switch: off serves an empty widget and rejects submissions. */
public function enabled(): bool
{
return (bool)($this->settings['enabled'] ?? true);
}
/** true = headless: TYPO3 injects nothing, the front end embeds the widget itself. */
public function jsonOutput(): bool
{
return (bool)($this->settings['jsonOutput'] ?? true);
}
/** @return string[] valid mail addresses only */
private function addressList(string $key): array
{
$addresses = [];
foreach (GeneralUtility::trimExplode(',', (string)($this->settings[$key] ?? ''), true) as $address) {
if (GeneralUtility::validEmail($address)) {
$addresses[] = $address;
}
}
return $addresses;
}
}