Compare commits
2
Commits
78af83d2e3
...
d496877a53
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d496877a53 | ||
|
|
872c6e715c |
No files matched your search
@@ -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/'
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
/**
|
||||
* Diagnose: warum schlaegt der FE-Login fehl?
|
||||
* Aufruf: php migrations/check_feuser.php <username> '<passwort>'
|
||||
* Gibt KEINE Geheimnisse aus - nur Statusfelder und ein verify-Ergebnis.
|
||||
*/
|
||||
if ($argc < 2) {
|
||||
fwrite(STDERR, "Aufruf: php migrations/check_feuser.php <username> ['<passwort>']\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";
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* One-time repair 2026-10-02: the fe_comment tables were created while the
|
||||
* TCA cache was stale, so TYPO3's schema enrichment (uid/pid/...) never ran
|
||||
* and MySQL cannot add an auto-increment PK afterwards. Both tables are
|
||||
* still EMPTY - drop them and let extension:setup recreate them properly.
|
||||
*
|
||||
* Usage: php migrations/drop_fecomment_tables.php
|
||||
* (then: vendor/bin/typo3 cache:flush && vendor/bin/typo3 extension:setup)
|
||||
*/
|
||||
|
||||
$root = null;
|
||||
$dir = __DIR__;
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; }
|
||||
$dir = dirname($dir);
|
||||
}
|
||||
if ($root === null) { exit("Could not locate project root above " . __DIR__ . "\n"); }
|
||||
$settings = require $root . '/config/system/settings.php';
|
||||
$db = $settings['DB']['Connections']['Default'];
|
||||
$pdo = new PDO(
|
||||
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
|
||||
$db['user'] ?? '', $db['password'] ?? '',
|
||||
[PDO::ATTR_ERRMODE => 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";
|
||||
@@ -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":"<p>Erster Test <script>alert(1)</script> mit <strong>fett</strong></p>"}' \
|
||||
"$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":"<p>Geaenderte Notiz, <em>kursiv</em>.</p>"}' \
|
||||
"$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":"<p>Bitte alles einmal durchsehen.</p>","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
|
||||
@@ -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
|
||||
@@ -0,0 +1,506 @@
|
||||
<?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(),
|
||||
'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
|
||||
{
|
||||
$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);
|
||||
}
|
||||
|
||||
// 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'])
|
||||
. ' <' . htmlspecialchars($user['email']) . '></p>';
|
||||
if (trim(strip_tags((string)$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>'
|
||||
. $this->embedNoteImages((string)$item['note'], $embeds);
|
||||
if ($item['attachments'] !== []) {
|
||||
$html .= '<p><strong>Attachments:</strong></p><ul>';
|
||||
foreach ($item['attachments'] as $attachment) {
|
||||
$html .= '<li><a href="' . htmlspecialchars($base . $attachment['url']) . '">' . htmlspecialchars($attachment['name']) . '</a></li>';
|
||||
}
|
||||
$html .= '</ul>';
|
||||
}
|
||||
}
|
||||
|
||||
$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);
|
||||
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 ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$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,43 @@ 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);
|
||||
}
|
||||
|
||||
/** 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.
|
||||
*
|
||||
* @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],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -1,4 +1,4 @@
|
||||
/* EXT:fe_comment widget — isolated styles, .evofc-prefixed, high z-index. */
|
||||
/* EXT:fe_comment v2 widget — isolated styles, .evofc-prefixed, high z-index. */
|
||||
|
||||
.evofc {
|
||||
position: fixed;
|
||||
@@ -50,7 +50,7 @@
|
||||
right: 52px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 320px;
|
||||
width: 340px;
|
||||
max-width: calc(100vw - 70px);
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
@@ -64,21 +64,74 @@
|
||||
.evofc-title { font-weight: 700; margin-bottom: 10px; }
|
||||
.evofc-label { display: block; font-size: 12px; color: #5a6572; margin: 8px 0 2px; }
|
||||
|
||||
.evofc-url,
|
||||
.evofc-text {
|
||||
.evofc-url {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #c9d1da;
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: #fff;
|
||||
background: #f3f5f7;
|
||||
color: #5a6572;
|
||||
}
|
||||
.evofc-url { background: #f3f5f7; color: #5a6572; }
|
||||
.evofc-text { resize: vertical; }
|
||||
|
||||
.evofc-row { display: flex; gap: 8px; align-items: center; margin-top: 10px; }
|
||||
/* Mini toolbar + rich-text area */
|
||||
.evofc-toolbar { display: flex; gap: 4px; margin-bottom: 4px; }
|
||||
.evofc-tool {
|
||||
min-width: 28px;
|
||||
height: 26px;
|
||||
border: 1px solid #c9d1da;
|
||||
border-radius: 5px;
|
||||
background: #f6f8fa;
|
||||
color: #1c2430;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
padding: 0 6px;
|
||||
}
|
||||
.evofc-tool:hover { background: #eef1f4; }
|
||||
|
||||
.evofc-rte {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 96px;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
border: 1px solid #c9d1da;
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
background: #fff;
|
||||
outline: none;
|
||||
}
|
||||
.evofc-rte:focus { border-color: #1f6feb; }
|
||||
.evofc-rte:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: #9aa4af;
|
||||
pointer-events: none;
|
||||
}
|
||||
.evofc-rte img { max-width: 100%; height: auto; border-radius: 4px; }
|
||||
.evofc-rte ul { margin: 4px 0; padding-left: 20px; }
|
||||
|
||||
/* Attachment chips */
|
||||
.evofc-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
|
||||
.evofc-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
border: 1px solid #e2e7ec;
|
||||
border-radius: 12px;
|
||||
background: #f6f8fa;
|
||||
padding: 2px 6px 2px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.evofc-chip-name {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.evofc-row { display: flex; gap: 8px; align-items: center; margin-top: 10px; flex-wrap: wrap; }
|
||||
|
||||
.evofc-button {
|
||||
border: 1px solid #c9d1da;
|
||||
@@ -91,25 +144,16 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
.evofc-button:hover { background: #eef1f4; }
|
||||
.evofc-button:disabled { opacity: .6; cursor: default; }
|
||||
.evofc-primary { background: #1f6feb; border-color: #1f6feb; color: #fff; }
|
||||
.evofc-primary:hover { background: #1a5fd0; }
|
||||
.evofc-primary:disabled { opacity: .6; cursor: default; }
|
||||
|
||||
.evofc-hint { color: #2f9e44; font-size: 12px; }
|
||||
.evofc-footer { margin-top: 10px; }
|
||||
.evofc-empty, .evofc-status { margin: 8px 0 0; font-size: 13px; }
|
||||
.evofc-status { font-weight: 600; }
|
||||
.evofc-status { margin: 8px 0 0; font-size: 13px; font-weight: 600; color: #d9463e; }
|
||||
.evofc-status:empty { display: none; }
|
||||
|
||||
.evofc-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.evofc-item { border: 1px solid #e2e7ec; border-radius: 8px; padding: 8px; }
|
||||
.evofc-item-head { display: flex; justify-content: space-between; align-items: center; gap: 6px; margin-bottom: 6px; }
|
||||
.evofc-item-url {
|
||||
font-size: 12px;
|
||||
color: #5a6572;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.evofc-remove {
|
||||
border: 0;
|
||||
background: none;
|
||||
|
||||
@@ -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 |
@@ -1,11 +1,15 @@
|
||||
/**
|
||||
* EXT:fe_comment — page feedback widget (framework-agnostic, vanilla JS).
|
||||
* EXT:fe_comment v2 — page feedback widget (framework-agnostic, vanilla JS).
|
||||
*
|
||||
* One floating button at the right edge opens a small form: current page
|
||||
* URL + a comment field. Saved notes land on a local note pad
|
||||
* (localStorage, survives navigation — also SPA route changes, the URL is
|
||||
* read at save time). The pad lists every note (URL + editable text) and
|
||||
* submits all of them to POST /api/fe-comment/submit, which mails them.
|
||||
* Server-backed: everything lives in the fe_comment API (/api/fe-comment/),
|
||||
* bound to the logged-in FE user. The widget only renders for users the API
|
||||
* accepts (GET state -> allowed) — for everyone else it stays completely
|
||||
* invisible, so the script tag is safe on public pages.
|
||||
*
|
||||
* Form: current page URL + a small rich-text area (bold/italic/list) with
|
||||
* file attachments — including Ctrl+V image paste straight into the text.
|
||||
* Saving adds the note to the user's open summary ("note pad"); the badge
|
||||
* mirrors the server count and the pad button links to the overview page.
|
||||
*
|
||||
* Integration: <script src="/api/fe-comment/widget.js" defer></script>
|
||||
* (classic TYPO3 sites get this injected by the extension automatically
|
||||
@@ -16,42 +20,69 @@
|
||||
if (window.__evofcLoaded) { return; }
|
||||
window.__evofcLoaded = true;
|
||||
|
||||
var ENDPOINT = '/api/fe-comment/submit';
|
||||
var API = '/api/fe-comment/';
|
||||
var CSS_URL = '/api/fe-comment/widget.css';
|
||||
var STORAGE_KEY = 'evoFeCommentNotes';
|
||||
var MAX_UPLOAD = 20 * 1024 * 1024;
|
||||
|
||||
var T = {
|
||||
open: 'Seite kommentieren',
|
||||
formTitle: 'Anmerkung zu dieser Seite',
|
||||
urlLabel: 'Seite',
|
||||
commentLabel: 'Anmerkung',
|
||||
placeholder: 'Was ist dir aufgefallen?',
|
||||
save: 'Auf den Merkzettel',
|
||||
showPad: 'Merkzettel',
|
||||
padTitle: 'Merkzettel',
|
||||
padEmpty: 'Noch keine Anmerkungen.',
|
||||
remove: 'Entfernen',
|
||||
submit: 'Absenden',
|
||||
submitting: 'Sende …',
|
||||
back: 'Zurück',
|
||||
savedHint: 'Gespeichert ✓',
|
||||
sentOk: 'Danke! Die Anmerkungen wurden gesendet.',
|
||||
sentFail: 'Senden fehlgeschlagen: '
|
||||
// ---- i18n (English is the default, German when the page says so) -------
|
||||
var LANG = (document.documentElement.lang || 'en').toLowerCase().indexOf('de') === 0 ? 'de' : 'en';
|
||||
var TEXTS = {
|
||||
en: {
|
||||
open: 'Comment on this page',
|
||||
formTitle: 'Feedback on this page',
|
||||
urlLabel: 'Page',
|
||||
noteLabel: 'Note',
|
||||
placeholder: 'What did you notice? Paste screenshots right here (Ctrl+V).',
|
||||
bold: 'Bold', italic: 'Italic', list: 'List',
|
||||
attach: 'Attach file',
|
||||
save: 'Add to note pad',
|
||||
saving: 'Saving …',
|
||||
savedHint: 'Saved ✓',
|
||||
pad: 'Note pad',
|
||||
uploadTooBig: 'File is larger than 20 MB.',
|
||||
uploadFail: 'Upload failed: ',
|
||||
saveFail: 'Saving failed: ',
|
||||
removeAttachment: 'Remove attachment'
|
||||
},
|
||||
de: {
|
||||
open: 'Seite kommentieren',
|
||||
formTitle: 'Anmerkung zu dieser Seite',
|
||||
urlLabel: 'Seite',
|
||||
noteLabel: 'Anmerkung',
|
||||
placeholder: 'Was ist dir aufgefallen? Screenshots einfach hier einfügen (Strg+V).',
|
||||
bold: 'Fett', italic: 'Kursiv', list: 'Liste',
|
||||
attach: 'Datei anhängen',
|
||||
save: 'Auf den Merkzettel',
|
||||
saving: 'Speichere …',
|
||||
savedHint: 'Gespeichert ✓',
|
||||
pad: 'Merkzettel',
|
||||
uploadTooBig: 'Datei ist größer als 20 MB.',
|
||||
uploadFail: 'Upload fehlgeschlagen: ',
|
||||
saveFail: 'Speichern fehlgeschlagen: ',
|
||||
removeAttachment: 'Anhang entfernen'
|
||||
}
|
||||
};
|
||||
var T = TEXTS[LANG];
|
||||
|
||||
function loadNotes() {
|
||||
try {
|
||||
var raw = localStorage.getItem(STORAGE_KEY);
|
||||
var list = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(list) ? list : [];
|
||||
} catch (e) { return []; }
|
||||
// ---- API helpers --------------------------------------------------------
|
||||
function api(path, options) {
|
||||
options = options || {};
|
||||
options.credentials = 'same-origin';
|
||||
return fetch(API + path, options).then(function (response) {
|
||||
return response.json();
|
||||
}).then(function (result) {
|
||||
if (result && result.error) { throw new Error(result.error); }
|
||||
return result;
|
||||
});
|
||||
}
|
||||
function saveNotes(notes) {
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(notes)); } catch (e) { /* private mode */ }
|
||||
}
|
||||
function currentUrl() {
|
||||
return location.pathname + location.search;
|
||||
function apiJson(path, payload) {
|
||||
return api(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
function el(tag, className, text) {
|
||||
var node = document.createElement(tag);
|
||||
if (className) { node.className = className; }
|
||||
@@ -59,200 +90,265 @@
|
||||
return node;
|
||||
}
|
||||
|
||||
// ---- styles ------------------------------------------------------------
|
||||
var link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = CSS_URL;
|
||||
document.head.appendChild(link);
|
||||
// ---- state --------------------------------------------------------------
|
||||
var serverState = null; // last GET state payload
|
||||
var draftUid = 0; // feedback uid of the form's unsaved draft (0 = none)
|
||||
var draftAttachments = []; // [{refUid, name}] of the draft
|
||||
|
||||
// ---- structure ---------------------------------------------------------
|
||||
var root = el('div', 'evofc');
|
||||
root.setAttribute('data-evofc', '');
|
||||
function itemCount() {
|
||||
return serverState && serverState.summary ? serverState.summary.items.length : 0;
|
||||
}
|
||||
|
||||
var toggle = el('button', 'evofc-toggle');
|
||||
toggle.type = 'button';
|
||||
toggle.title = T.open;
|
||||
toggle.setAttribute('aria-label', T.open);
|
||||
toggle.innerHTML =
|
||||
'<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">' +
|
||||
'<path fill="currentColor" d="M4 3h16a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H8.4L4 21.6A1 1 0 0 1 2.4 20.8V5a2 2 0 0 1 2-2Zm2.6 5.2h11m-11 4h7"' +
|
||||
' stroke="currentColor" stroke-width="1.6" stroke-linecap="round" fill="none"/></svg>';
|
||||
var badge = el('span', 'evofc-badge');
|
||||
badge.hidden = true;
|
||||
toggle.appendChild(badge);
|
||||
function refreshState() {
|
||||
return api('state').then(function (state) {
|
||||
serverState = state;
|
||||
refreshBadge();
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
var panel = el('div', 'evofc-panel');
|
||||
panel.hidden = true;
|
||||
// ---- bootstrap: only build the UI for allowed users ----------------------
|
||||
api('state').then(function (state) {
|
||||
if (!state || !state.allowed) { return; }
|
||||
serverState = state;
|
||||
injectCss();
|
||||
buildUi();
|
||||
}).catch(function () { /* API unreachable - stay invisible */ });
|
||||
|
||||
root.appendChild(toggle);
|
||||
root.appendChild(panel);
|
||||
function injectCss() {
|
||||
var link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = CSS_URL;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
// ---- views -------------------------------------------------------------
|
||||
var view = 'form'; // 'form' | 'pad'
|
||||
// ---- UI -------------------------------------------------------------------
|
||||
var root, toggle, badge, panel;
|
||||
|
||||
function buildUi() {
|
||||
root = el('div', 'evofc');
|
||||
root.setAttribute('data-evofc', '');
|
||||
|
||||
toggle = el('button', 'evofc-toggle');
|
||||
toggle.type = 'button';
|
||||
toggle.title = T.open;
|
||||
toggle.setAttribute('aria-label', T.open);
|
||||
toggle.innerHTML =
|
||||
'<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">' +
|
||||
'<path fill="currentColor" d="M4 3h16a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H8.4L4 21.6A1 1 0 0 1 2.4 20.8V5a2 2 0 0 1 2-2Zm2.6 5.2h11m-11 4h7"' +
|
||||
' stroke="currentColor" stroke-width="1.6" stroke-linecap="round" fill="none"/></svg>';
|
||||
badge = el('span', 'evofc-badge');
|
||||
badge.hidden = true;
|
||||
toggle.appendChild(badge);
|
||||
|
||||
panel = el('div', 'evofc-panel');
|
||||
panel.hidden = true;
|
||||
|
||||
toggle.addEventListener('click', function () {
|
||||
if (panel.hidden) {
|
||||
renderForm();
|
||||
panel.hidden = false;
|
||||
} else {
|
||||
panel.hidden = true;
|
||||
}
|
||||
});
|
||||
|
||||
root.appendChild(toggle);
|
||||
root.appendChild(panel);
|
||||
refreshBadge();
|
||||
if (document.body) {
|
||||
document.body.appendChild(root);
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', function () { document.body.appendChild(root); });
|
||||
}
|
||||
}
|
||||
|
||||
function refreshBadge() {
|
||||
var count = loadNotes().length;
|
||||
if (!badge) { return; }
|
||||
var count = itemCount();
|
||||
badge.textContent = String(count);
|
||||
badge.hidden = count === 0;
|
||||
}
|
||||
|
||||
function renderForm() {
|
||||
view = 'form';
|
||||
panel.innerHTML = '';
|
||||
panel.appendChild(el('div', 'evofc-title', T.formTitle));
|
||||
|
||||
var urlLabel = el('label', 'evofc-label', T.urlLabel);
|
||||
panel.appendChild(el('label', 'evofc-label', T.urlLabel));
|
||||
var urlInput = el('input', 'evofc-url');
|
||||
urlInput.type = 'text';
|
||||
urlInput.readOnly = true;
|
||||
urlInput.value = currentUrl();
|
||||
urlInput.value = location.href;
|
||||
panel.appendChild(urlInput);
|
||||
|
||||
var commentLabel = el('label', 'evofc-label', T.commentLabel);
|
||||
var textarea = el('textarea', 'evofc-text');
|
||||
textarea.rows = 4;
|
||||
textarea.placeholder = T.placeholder;
|
||||
panel.appendChild(el('label', 'evofc-label', T.noteLabel));
|
||||
|
||||
// Mini toolbar + contenteditable rich-text area
|
||||
var toolbar = el('div', 'evofc-toolbar');
|
||||
var editor = el('div', 'evofc-rte');
|
||||
editor.contentEditable = 'true';
|
||||
editor.setAttribute('data-placeholder', T.placeholder);
|
||||
[['bold', '<b>B</b>', T.bold],
|
||||
['italic', '<i>I</i>', T.italic],
|
||||
['insertUnorderedList', '☰', T.list]].forEach(function (def) {
|
||||
var button = el('button', 'evofc-tool');
|
||||
button.type = 'button';
|
||||
button.innerHTML = def[1];
|
||||
button.title = def[2];
|
||||
// mousedown + preventDefault keeps the editor selection alive
|
||||
button.addEventListener('mousedown', function (event) {
|
||||
event.preventDefault();
|
||||
editor.focus();
|
||||
document.execCommand(def[0], false, null);
|
||||
});
|
||||
toolbar.appendChild(button);
|
||||
});
|
||||
panel.appendChild(toolbar);
|
||||
panel.appendChild(editor);
|
||||
|
||||
var status = el('div', 'evofc-status');
|
||||
var chips = el('div', 'evofc-chips');
|
||||
panel.appendChild(chips);
|
||||
renderChips(chips, status);
|
||||
|
||||
// Ctrl+V: image in the clipboard -> upload + inline <img>
|
||||
editor.addEventListener('paste', function (event) {
|
||||
var items = (event.clipboardData || {}).items || [];
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (items[i].kind === 'file' && items[i].type.indexOf('image/') === 0) {
|
||||
event.preventDefault();
|
||||
var file = items[i].getAsFile();
|
||||
uploadFile(file, status, chips, function (attachment) {
|
||||
editor.focus();
|
||||
document.execCommand('insertHTML', false,
|
||||
'<img src="' + attachment.url + '" alt="' + attachment.name.replace(/"/g, '') + '">');
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Attach button + hidden file input
|
||||
var fileInput = el('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.hidden = true;
|
||||
fileInput.addEventListener('change', function () {
|
||||
if (fileInput.files && fileInput.files[0]) {
|
||||
uploadFile(fileInput.files[0], status, chips, null);
|
||||
fileInput.value = '';
|
||||
}
|
||||
});
|
||||
var attachButton = el('button', 'evofc-button', T.attach);
|
||||
attachButton.type = 'button';
|
||||
attachButton.addEventListener('click', function () { fileInput.click(); });
|
||||
|
||||
var saveButton = el('button', 'evofc-button evofc-primary', T.save);
|
||||
saveButton.type = 'button';
|
||||
var hint = el('span', 'evofc-hint');
|
||||
|
||||
saveButton.addEventListener('click', function () {
|
||||
var comment = textarea.value.trim();
|
||||
if (comment === '') { textarea.focus(); return; }
|
||||
var notes = loadNotes();
|
||||
notes.push({ url: urlInput.value, comment: comment, added: Date.now() });
|
||||
saveNotes(notes);
|
||||
textarea.value = '';
|
||||
refreshBadge();
|
||||
renderFooter(footer);
|
||||
hint.textContent = T.savedHint;
|
||||
setTimeout(function () { hint.textContent = ''; }, 1800);
|
||||
var note = editor.innerHTML;
|
||||
var isEmpty = editor.textContent.trim() === '' && editor.querySelector('img') === null;
|
||||
if (isEmpty && draftUid === 0) { editor.focus(); return; }
|
||||
saveButton.disabled = true;
|
||||
saveButton.textContent = T.saving;
|
||||
var request = draftUid > 0
|
||||
? apiJson('feedback/' + draftUid, { note: note, url: urlInput.value })
|
||||
: apiJson('feedback', { note: note, url: urlInput.value });
|
||||
request.then(function () {
|
||||
draftUid = 0;
|
||||
draftAttachments = [];
|
||||
editor.innerHTML = '';
|
||||
renderChips(chips, status);
|
||||
return refreshState();
|
||||
}).then(function () {
|
||||
renderFooter(footer);
|
||||
hint.textContent = T.savedHint;
|
||||
setTimeout(function () { hint.textContent = ''; }, 1800);
|
||||
}).catch(function (error) {
|
||||
status.textContent = T.saveFail + error.message;
|
||||
}).finally(function () {
|
||||
saveButton.disabled = false;
|
||||
saveButton.textContent = T.save;
|
||||
});
|
||||
});
|
||||
|
||||
var footer = el('div', 'evofc-footer');
|
||||
renderFooter(footer);
|
||||
|
||||
panel.appendChild(urlLabel);
|
||||
panel.appendChild(urlInput);
|
||||
panel.appendChild(commentLabel);
|
||||
panel.appendChild(textarea);
|
||||
var row = el('div', 'evofc-row');
|
||||
row.appendChild(attachButton);
|
||||
row.appendChild(saveButton);
|
||||
row.appendChild(hint);
|
||||
panel.appendChild(row);
|
||||
panel.appendChild(fileInput);
|
||||
panel.appendChild(status);
|
||||
|
||||
var footer = el('div', 'evofc-footer');
|
||||
renderFooter(footer);
|
||||
panel.appendChild(footer);
|
||||
}
|
||||
|
||||
/** The draft row must exist before the first upload (uploads attach to it). */
|
||||
function ensureDraft(urlValue) {
|
||||
if (draftUid > 0) { return Promise.resolve(draftUid); }
|
||||
return apiJson('feedback', { url: urlValue, note: '' }).then(function (result) {
|
||||
draftUid = result.uid;
|
||||
return refreshState().then(function () { return draftUid; });
|
||||
});
|
||||
}
|
||||
|
||||
function uploadFile(file, status, chips, onDone) {
|
||||
if (!file) { return; }
|
||||
if (file.size > MAX_UPLOAD) {
|
||||
status.textContent = T.uploadTooBig;
|
||||
return;
|
||||
}
|
||||
status.textContent = '';
|
||||
ensureDraft(location.href).then(function (uid) {
|
||||
var body = new FormData();
|
||||
body.append('file', file, file.name || 'pasted-image.png');
|
||||
body.append('feedback', String(uid));
|
||||
return api('upload', { method: 'POST', body: body });
|
||||
}).then(function (result) {
|
||||
draftAttachments.push({ refUid: result.attachment.refUid, name: result.attachment.name });
|
||||
renderChips(chips, status);
|
||||
if (onDone) { onDone(result.attachment); }
|
||||
}).catch(function (error) {
|
||||
status.textContent = T.uploadFail + error.message;
|
||||
});
|
||||
}
|
||||
|
||||
function renderChips(chips, status) {
|
||||
chips.innerHTML = '';
|
||||
draftAttachments.forEach(function (attachment, index) {
|
||||
var chip = el('span', 'evofc-chip');
|
||||
chip.appendChild(el('span', 'evofc-chip-name', attachment.name));
|
||||
var removeButton = el('button', 'evofc-remove', '×');
|
||||
removeButton.type = 'button';
|
||||
removeButton.title = T.removeAttachment;
|
||||
removeButton.addEventListener('click', function () {
|
||||
apiJson('attachment/' + attachment.refUid + '/delete', {}).then(function () {
|
||||
draftAttachments.splice(index, 1);
|
||||
renderChips(chips, status);
|
||||
}).catch(function (error) {
|
||||
status.textContent = error.message;
|
||||
});
|
||||
});
|
||||
chip.appendChild(removeButton);
|
||||
chips.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
function renderFooter(footer) {
|
||||
footer.innerHTML = '';
|
||||
var count = loadNotes().length;
|
||||
if (count > 0) {
|
||||
var padButton = el('button', 'evofc-button', T.showPad + ' (' + count + ')');
|
||||
padButton.type = 'button';
|
||||
padButton.addEventListener('click', renderPad);
|
||||
footer.appendChild(padButton);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPad() {
|
||||
view = 'pad';
|
||||
panel.innerHTML = '';
|
||||
panel.appendChild(el('div', 'evofc-title', T.padTitle));
|
||||
|
||||
var notes = loadNotes();
|
||||
if (notes.length === 0) {
|
||||
panel.appendChild(el('p', 'evofc-empty', T.padEmpty));
|
||||
}
|
||||
|
||||
var list = el('div', 'evofc-list');
|
||||
notes.forEach(function (note, index) {
|
||||
var item = el('div', 'evofc-item');
|
||||
var head = el('div', 'evofc-item-head');
|
||||
head.appendChild(el('span', 'evofc-item-url', note.url || '(ohne URL)'));
|
||||
var removeButton = el('button', 'evofc-remove', '×');
|
||||
removeButton.type = 'button';
|
||||
removeButton.title = T.remove;
|
||||
removeButton.addEventListener('click', function () {
|
||||
var current = loadNotes();
|
||||
current.splice(index, 1);
|
||||
saveNotes(current);
|
||||
refreshBadge();
|
||||
renderPad();
|
||||
var count = itemCount();
|
||||
if (count === 0) { return; }
|
||||
var padButton = el('button', 'evofc-button', T.pad + ' (' + count + ')');
|
||||
padButton.type = 'button';
|
||||
if (serverState.overviewUrl) {
|
||||
padButton.addEventListener('click', function () {
|
||||
location.href = serverState.overviewUrl;
|
||||
});
|
||||
head.appendChild(removeButton);
|
||||
|
||||
var textarea = el('textarea', 'evofc-text');
|
||||
textarea.rows = 3;
|
||||
textarea.value = note.comment;
|
||||
textarea.addEventListener('input', function () {
|
||||
var current = loadNotes();
|
||||
if (current[index]) {
|
||||
current[index].comment = textarea.value;
|
||||
saveNotes(current);
|
||||
}
|
||||
});
|
||||
|
||||
item.appendChild(head);
|
||||
item.appendChild(textarea);
|
||||
list.appendChild(item);
|
||||
});
|
||||
panel.appendChild(list);
|
||||
|
||||
var status = el('p', 'evofc-status');
|
||||
var row = el('div', 'evofc-row');
|
||||
var backButton = el('button', 'evofc-button', T.back);
|
||||
backButton.type = 'button';
|
||||
backButton.addEventListener('click', renderForm);
|
||||
row.appendChild(backButton);
|
||||
|
||||
if (notes.length > 0) {
|
||||
var submitButton = el('button', 'evofc-button evofc-primary', T.submit);
|
||||
submitButton.type = 'button';
|
||||
submitButton.addEventListener('click', function () {
|
||||
submitButton.disabled = true;
|
||||
submitButton.textContent = T.submitting;
|
||||
var entries = loadNotes().map(function (note) {
|
||||
return { url: note.url, comment: note.comment };
|
||||
});
|
||||
fetch(ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ entries: entries })
|
||||
}).then(function (response) { return response.json(); }).then(function (result) {
|
||||
if (result && result.success) {
|
||||
saveNotes([]);
|
||||
refreshBadge();
|
||||
renderPad();
|
||||
status.textContent = T.sentOk;
|
||||
panel.appendChild(status);
|
||||
} else {
|
||||
throw new Error((result && result.error) || 'unknown');
|
||||
}
|
||||
}).catch(function (error) {
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = T.submit;
|
||||
status.textContent = T.sentFail + error.message;
|
||||
panel.appendChild(status);
|
||||
});
|
||||
});
|
||||
row.appendChild(submitButton);
|
||||
}
|
||||
panel.appendChild(row);
|
||||
}
|
||||
|
||||
toggle.addEventListener('click', function () {
|
||||
if (panel.hidden) {
|
||||
renderForm();
|
||||
panel.hidden = false;
|
||||
} else {
|
||||
panel.hidden = true;
|
||||
padButton.disabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
refreshBadge();
|
||||
if (document.body) {
|
||||
document.body.appendChild(root);
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', function () { document.body.appendChild(root); });
|
||||
footer.appendChild(padButton);
|
||||
}
|
||||
})();
|
||||
@@ -15,3 +15,15 @@ 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
|
||||
|
||||
# cat=basic//30; type=string; label=Overview page (uid): page showing the feedback overview; its URL is delivered to the widget as the note pad link. 0 = no link.
|
||||
overviewPid = 0
|
||||
@@ -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)
|
||||
);
|
||||
@@ -0,0 +1,90 @@
|
||||
<!doctype html>
|
||||
<!-- fe_comment v2 Testseite: FE-Login (Referenz-Implementierung der React-
|
||||
Strecke) + Widget-Einbindung. Nur fuer Dev-Tests, kann jederzeit weg. -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>fe_comment widget test</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, "Segoe UI", Roboto, Arial, sans-serif; max-width: 640px; margin: 40px auto; padding: 0 16px; color: #1c2430; }
|
||||
h1 { font-size: 20px; }
|
||||
label { display: block; margin: 10px 0 2px; font-size: 13px; color: #5a6572; }
|
||||
input { padding: 6px 8px; border: 1px solid #c9d1da; border-radius: 6px; font: inherit; width: 240px; }
|
||||
button { margin-top: 12px; padding: 7px 14px; border: 1px solid #1f6feb; border-radius: 6px; background: #1f6feb; color: #fff; font: inherit; font-weight: 600; cursor: pointer; }
|
||||
#status { margin-top: 14px; font-weight: 600; }
|
||||
.ok { color: #2f9e44; } .err { color: #d9463e; }
|
||||
p.hint { color: #5a6572; font-size: 13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>fe_comment v2 — Widget-Testseite</h1>
|
||||
<p class="hint">Diese Seite tut, was spaeter die React-App tut: felogin-Formular
|
||||
per JSON holen (inkl. CSRF-Token), Login-POST, dann erscheint rechts das
|
||||
Feedback-Widget (nur fuer berechtigte FE-User).</p>
|
||||
|
||||
<div id="login">
|
||||
<label>User</label><input id="user" autocomplete="username">
|
||||
<label>Passwort</label><input id="pass" type="password" autocomplete="current-password">
|
||||
<button id="loginBtn">Login</button>
|
||||
<button id="logoutBtn" style="background:#f6f8fa;color:#1c2430;border-color:#c9d1da">Logout</button>
|
||||
</div>
|
||||
<div id="status"></div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var statusBox = document.getElementById('status');
|
||||
function say(text, ok) {
|
||||
statusBox.textContent = text;
|
||||
statusBox.className = ok ? 'ok' : 'err';
|
||||
}
|
||||
|
||||
function checkState() {
|
||||
return fetch('/api/fe-comment/state', { credentials: 'same-origin' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (s) {
|
||||
if (s.allowed) { say('Eingeloggt als ' + s.user.name + ' - Widget aktiv (rechts).', true); }
|
||||
else { say('Nicht eingeloggt / nicht berechtigt (allowed:false).', false); }
|
||||
return s;
|
||||
});
|
||||
}
|
||||
|
||||
// Die React-Login-Strecke in Kurzform:
|
||||
// 1. Seite als JSON holen -> felogin-Element -> form (setzt Nonce-Cookie)
|
||||
// 2. Alle Formfelder + user/pass urlencoded an '/_json' + form.action posten
|
||||
function login(user, pass) {
|
||||
return fetch('/_json/fe-user-profile', { credentials: 'same-origin' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (page) {
|
||||
var el = (page.content.colPos0 || []).filter(function (e) { return e.type === 'felogin_login'; })[0];
|
||||
if (!el || !el.content || !el.content.data.form) { throw new Error('felogin-Formular nicht im JSON'); }
|
||||
var form = el.content.data.form;
|
||||
var body = new URLSearchParams();
|
||||
form.elements.forEach(function (f) { if (f.name) { body.set(f.name, f.value || ''); } });
|
||||
body.set('user', user);
|
||||
body.set('pass', pass);
|
||||
return fetch('/_json' + form.action, { method: 'POST', body: body, credentials: 'same-origin' });
|
||||
})
|
||||
.then(checkState);
|
||||
}
|
||||
|
||||
function logout() {
|
||||
var body = new URLSearchParams();
|
||||
body.set('logintype', 'logout');
|
||||
return fetch('/_json/fe-user-profile', { method: 'POST', body: body, credentials: 'same-origin' })
|
||||
.then(function () { location.reload(); });
|
||||
}
|
||||
|
||||
document.getElementById('loginBtn').addEventListener('click', function () {
|
||||
login(document.getElementById('user').value, document.getElementById('pass').value)
|
||||
.then(function (s) { if (s.allowed) { location.reload(); } })
|
||||
.catch(function (e) { say('Fehler: ' + e.message, false); });
|
||||
});
|
||||
document.getElementById('logoutBtn').addEventListener('click', logout);
|
||||
|
||||
checkState();
|
||||
})();
|
||||
</script>
|
||||
<script src="/api/fe-comment/widget.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user