diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php index 2fa2ffc33..29083f1b5 100644 --- a/app/Controllers/SettingsController.php +++ b/app/Controllers/SettingsController.php @@ -26,6 +26,10 @@ public function index(Request $request, Response $response, mysqli $db): Respons // l'invio li risolve per (name, locale_installazione), quindi seminare // sempre it_IT li renderebbe invisibili su installazioni non italiane. $repository->ensureEmailTemplates($this->templateDefaults(), \App\Support\I18n::getInstallationLocale()); + // #299: repair any stored template whose links were double-prefixed with + // the admin base by the old WYSIWYG behaviour. Idempotent, cheap (LIKE + // filter), runs before the templates are read for display below. + $repository->healCorruptedTemplateUrls(); $appSettings = $this->resolveAppSettings($repository); $emailSettings = $this->resolveEmailSettings($repository); diff --git a/app/Models/SettingsRepository.php b/app/Models/SettingsRepository.php index 386e15556..a4a852457 100644 --- a/app/Models/SettingsRepository.php +++ b/app/Models/SettingsRepository.php @@ -163,6 +163,44 @@ public function ensureEmailTemplates(array $templates, ?string $locale = null): } } + /** + * #299: repair email templates already stored with links double-prefixed by + * the admin base — the WYSIWYG editor used to resolve placeholder URLs (e.g. + * href="{{login_url}}") against the admin page, saving + * href="https://host/admin/{{login_url}}". The editor no longer does this + * (convert_urls:false), and EmailService heals at render time, but the stored + * value stays wrong until repaired. Runs on every install (all locales). + * + * Portable across MySQL 5.7+/MariaDB (no REGEXP_REPLACE): the LIKE narrows to + * the few possibly-affected rows, the fix is done in PHP. Idempotent. + * + * @return int number of template rows actually repaired + */ + public function healCorruptedTemplateUrls(): int + { + $stmt = $this->prepare("SELECT name, locale, body FROM email_templates WHERE body LIKE '%/admin/{{%'"); + $stmt->execute(); + $result = $stmt->get_result(); + $rows = []; + while ($row = $result->fetch_assoc()) { + $rows[] = $row; + } + $stmt->close(); + + $healed = 0; + foreach ($rows as $row) { + $fixed = \App\Support\EmailService::healPlaceholderUrls((string) $row['body']); + if ($fixed !== (string) $row['body']) { + $update = $this->prepare('UPDATE email_templates SET body = ? WHERE name = ? AND locale = ?'); + $update->bind_param('sss', $fixed, $row['name'], $row['locale']); + $update->execute(); + $update->close(); + $healed++; + } + } + return $healed; + } + public function getEmailTemplate(string $name, ?string $locale = null): ?array { $locale = $this->resolveTemplateLocale($locale); diff --git a/app/Support/EmailService.php b/app/Support/EmailService.php index a29072918..28ff7078c 100644 --- a/app/Support/EmailService.php +++ b/app/Support/EmailService.php @@ -300,6 +300,26 @@ private function getDefaultTemplate(string $templateName, ?string $locale = null * non HTML) i valori non vengono HTML-escaped ma vengono ripuliti da * CR/LF per impedire header injection. */ + /** + * #299: repair a template corrupted by a WYSIWYG editor that resolved a + * placeholder URL against the admin page — e.g. `href="{{login_url}}"` was + * saved as `href="https://host/admin/{{login_url}}"`, so substituting the + * (already absolute) URL produced a double-prefixed link. Strip an absolute + * `…/admin/` prefix sitting directly in front of a `{{placeholder}}`. The + * editor no longer does this (convert_urls:false); this heals values already + * saved, both at render time and when repairing stored templates in the DB. + * A legitimate template never puts a host+`/admin/` in front of a token, so + * this is safe. Returns the input unchanged when nothing matches. + */ + public static function healPlaceholderUrls(string $content): string + { + return preg_replace( + '#https?://[^"\'\s<>]*?/admin/(\{\{[a-zA-Z0-9_]+\}\})#', + '$1', + $content + ) ?? $content; + } + public function replaceVariables(string $content, array $variables, bool $escapeHtml = true): string { // Normalize English variable keys to Italian names for consistent processing // This ensures RAW_HTML_VARIABLES check works regardless of input key language @@ -315,6 +335,10 @@ public function replaceVariables(string $content, array $variables, bool $escape $italianToEnglish = array_flip(self::PLACEHOLDER_ALIASES); } + // #299: heal templates corrupted by a WYSIWYG editor that resolved a + // placeholder URL against the admin page (see healPlaceholderUrls). + $content = self::healPlaceholderUrls($content); + foreach ($normalizedVariables as $key => $value) { if ($escapeHtml) { $replacement = in_array($key, self::RAW_HTML_VARIABLES, true) diff --git a/app/Views/admin/cms-edit.php b/app/Views/admin/cms-edit.php index 0a02eb4cd..3d160c18d 100644 --- a/app/Views/admin/cms-edit.php +++ b/app/Views/admin/cms-edit.php @@ -177,6 +177,9 @@ class="rounded border-gray-300 text-gray-900 focus:ring-gray-500" if (window.tinymce) { tinymce.init({ selector: '#tinymce-editor', + // #299: never rewrite URLs against the admin page's base_url — it + // prefixes links/placeholders with /admin and double-prefixes on re-edit. + convert_urls: false, base_url: , suffix: '.min', model: 'dom', diff --git a/app/Views/admin/settings.php b/app/Views/admin/settings.php index 683398ce4..8ffafc3f0 100644 --- a/app/Views/admin/settings.php +++ b/app/Views/admin/settings.php @@ -401,6 +401,9 @@ function initTinyMCE() { // TinyMCE Configuration tinymce.init({ selector: '#template-body', + // #299: never rewrite URLs against the admin page's base_url — it prefixed + // email links/placeholders with /admin and double-prefixed on re-edit. + convert_urls: false, base_url: , suffix: '.min', model: 'dom', diff --git a/app/Views/cms/edit-home.php b/app/Views/cms/edit-home.php index 07cfe2512..8c6562131 100644 --- a/app/Views/cms/edit-home.php +++ b/app/Views/cms/edit-home.php @@ -988,6 +988,9 @@ function toggleAccordion(id) { if (window.tinymce) { tinymce.init({ selector: '#text_content_body', + // #299: never rewrite URLs against the admin page's base_url — it + // prefixes links/placeholders with /admin and double-prefixes on re-edit. + convert_urls: false, base_url: , suffix: '.min', model: 'dom', diff --git a/app/Views/events/form.php b/app/Views/events/form.php index a44ed461e..bb882063f 100644 --- a/app/Views/events/form.php +++ b/app/Views/events/form.php @@ -397,6 +397,9 @@ class="block w-full rounded-xl border-gray-300 focus:border-purple-500 focus:rin if (window.tinymce) { tinymce.init({ selector: '#event_content', + // #299: never rewrite URLs against the admin page's base_url — it + // prefixes links/placeholders with /admin and double-prefixes on re-edit. + convert_urls: false, base_url: , suffix: '.min', model: 'dom', diff --git a/app/Views/libri/partials/book_form.php b/app/Views/libri/partials/book_form.php index 2630710ca..bf6d25477 100644 --- a/app/Views/libri/partials/book_form.php +++ b/app/Views/libri/partials/book_form.php @@ -4884,6 +4884,9 @@ function initBookTinyMCE() { tinymce.init({ selector: '#descrizione', + // #299: never rewrite URLs against the admin page's base_url — it + // prefixes links/placeholders with /admin and double-prefixes on re-edit. + convert_urls: false, base_url: TINYMCE_BASE, suffix: '.min', model: 'dom', diff --git a/app/Views/settings/index.php b/app/Views/settings/index.php index 72c8fb565..6b6d814c2 100644 --- a/app/Views/settings/index.php +++ b/app/Views/settings/index.php @@ -993,6 +993,9 @@ function activateTab(tabName) { if (window.tinymce) { tinymce.init({ selector: 'textarea.tinymce-editor', + // #299: never rewrite URLs against the admin page's base_url — it + // prefixes links/placeholders with /admin and double-prefixes on re-edit. + convert_urls: false, base_url: , suffix: '.min', model: 'dom', diff --git a/tests/email-template-url-prefix-299.unit.php b/tests/email-template-url-prefix-299.unit.php new file mode 100644 index 000000000..1761e5767 --- /dev/null +++ b/tests/email-template-url-prefix-299.unit.php @@ -0,0 +1,72 @@ +db / mailer), so we +// can exercise it without touching the DB or PHPMailer. +$svc = (new ReflectionClass(EmailService::class))->newInstanceWithoutConstructor(); +$render = static fn(string $body, array $vars): string => $svc->replaceVariables($body, $vars); + +// 1) The bug: a corrupted href must render as the plain absolute URL, once. +$out = $render('Log in', + ['login_url' => 'https://lib.example.org/accedi']); +$check(strpos($out, 'https://lib.example.org/accedi') !== false, 'corrupted href resolves to the real URL'); +$check(strpos($out, '/admin/https://') === false, 'no double /admin/ + absolute-URL prefix remains'); +$check($out === 'Log in', 'exact healed output'); + +// 2) A clean template is unchanged by the healing + substitution. +$out = $render('Log in', ['login_url' => 'https://lib.example.org/accedi']); +$check($out === 'Log in', 'clean placeholder still substitutes correctly'); + +// 3) http (not https) prefixes are healed too. +$out = $render('Reset', ['reset_url' => 'http://x.test/reset?t=abc']); +$check($out === 'Reset', 'http:// prefix healed'); + +// 4) A deeper base path (subdir install) is healed. +$out = $render('Book', ['book_url' => 'https://x.test/lib/libro/5']); +$check($out === 'Book', 'subdir base + /admin/ prefix healed'); + +// 5) A legitimate URL that merely CONTAINS /admin/ but is NOT followed by a +// placeholder must be left untouched. +$out = $render('Users', []); +$check($out === 'Users', 'real /admin/ URL without a token is untouched'); + +// 6) A non-URL placeholder is never affected by the healing. +$out = $render('Ciao {{nome}}', ['nome' => 'Mario']); +$check($out === 'Ciao Mario', 'non-URL placeholder substitutes normally'); + +// 7) Two corrupted links in one body both heal. +$out = $render( + 'B W', + ['book_url' => 'https://x.test/libro/1', 'wishlist_url' => 'https://x.test/wishlist']); +$check(strpos($out, '/admin/https://') === false && strpos($out, 'https://x.test/libro/1') !== false + && strpos($out, 'https://x.test/wishlist') !== false, 'multiple corrupted links all heal'); + +echo "\n{$passed} passed, {$failed} failed\n"; +exit($failed === 0 ? 0 : 1); diff --git a/tests/tinymce-url-prefix-299.spec.js b/tests/tinymce-url-prefix-299.spec.js new file mode 100644 index 000000000..5191c49ca --- /dev/null +++ b/tests/tinymce-url-prefix-299.spec.js @@ -0,0 +1,136 @@ +// @ts-check +// Issue #299 — the WYSIWYG editors double-prefixed URLs. TinyMCE's convert_urls +// (on by default) resolved a placeholder href like {{login_url}} against the +// admin page's base, so it was saved as https://host/admin/{{login_url}} and +// the rendered email link came out double-prefixed. The fix sets +// convert_urls:false on EVERY TinyMCE instance so the editor never rewrites URLs. +// +// This drives a real browser: for each editor, it feeds an href with a +// placeholder through TinyMCE and asserts the round-tripped content keeps the +// placeholder verbatim with NO /admin/ prefix — the behaviour that was broken. +// +// Run: /tmp/run-e2e.sh tests/tinymce-url-prefix-299.spec.js --config=tests/playwright.config.js --workers=1 +const { test, expect } = require('@playwright/test'); +const { execFileSync } = require('child_process'); + +const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081'; +const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || ''; +const ADMIN_PASS = process.env.E2E_ADMIN_PASS || ''; +const DB_USER = process.env.E2E_DB_USER || ''; +const DB_PASS = process.env.E2E_DB_PASS || ''; +const DB_SOCKET = process.env.E2E_DB_SOCKET || ''; +const DB_NAME = process.env.E2E_DB_NAME || ''; + +test.skip(!ADMIN_EMAIL || !ADMIN_PASS, 'E2E credentials not configured'); + +function db(sql) { + const args = []; + if (DB_SOCKET) args.push('-S', DB_SOCKET); + args.push('-u', DB_USER, DB_NAME, '-N', '-B', '-e', sql); + return execFileSync('mysql', args, { encoding: 'utf-8', timeout: 10000, env: { ...process.env, MYSQL_PWD: DB_PASS } }).trim(); +} +function sqlq(s) { return "'" + String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'"; } + +async function login(page) { + await page.goto(`${BASE}/accedi`); + await page.fill('input[name="email"]', ADMIN_EMAIL); + await page.fill('input[name="password"]', ADMIN_PASS); + await page.locator('button[type="submit"]').click(); + await page.waitForFunction( + () => !location.pathname.includes('accedi') && !location.pathname.includes('login'), + { timeout: 15000 }, + ); +} + +// Each editor: the page URL and how to grab its TinyMCE instance. The email +// editor uses a class (multiple instances); the rest use a stable id. Selection +// is a plain if/else inside the page callback — no eval, no new Function. +const CASES = [ + { name: 'email template (settings/index)', url: '/admin/settings?tab=templates', kind: 'class', sel: 'tinymce-editor' }, + { name: 'book description (book_form)', url: '/admin/books/create', kind: 'id', sel: 'descrizione' }, + { name: 'event content (events/form)', url: '/admin/cms/events/create', kind: 'id', sel: 'event_content' }, + { name: 'home text (cms/edit-home)', url: '/admin/cms/home', kind: 'id', sel: 'text_content_body' }, +]; + +test.describe.serial('#299 TinyMCE does not prefix URLs (convert_urls:false)', () => { + for (const c of CASES) { + test(c.name, async ({ page }) => { + await login(page); + await page.goto(`${BASE}${c.url}`); + + // Wait until the target editor exists and is initialised (this is the + // real readiness signal — no networkidle, which is flaky on pages that + // poll or hold connections open). For the + // class-based email editor, resolve the textarea in the DOM and look the + // editor up by the id TinyMCE assigns it (avoids relying on tm.editors). + await page.waitForFunction( + ({ kind, sel }) => { + const tm = window.tinymce; + if (!tm) return false; + let ed = null; + if (kind === 'class') { + const ta = document.querySelector('textarea.' + sel); + ed = ta && ta.id ? tm.get(ta.id) : null; + } else { + ed = tm.get(sel); + } + return !!ed && ed.initialized === true; + }, + { kind: c.kind, sel: c.sel }, + { timeout: 20000 }, + ); + + const result = await page.evaluate(({ kind, sel }) => { + const tm = window.tinymce; + let ed = null; + if (kind === 'class') { + const ta = document.querySelector('textarea.' + sel); + ed = ta && ta.id ? tm.get(ta.id) : null; + } else { + ed = tm.get(sel); + } + ed.setContent('

x

'); + return { convertUrls: ed.options.get('convert_urls'), content: ed.getContent() }; + }, { kind: c.kind, sel: c.sel }); + + // The knob is off… + expect(result.convertUrls, `${c.name}: convert_urls must be false`).toBe(false); + // …and behaviourally: the placeholder survives untouched, no /admin/ prefix, + // no URL-encoded braces (%7B%7B) — both symptoms of a rewrite. + expect(result.content, `${c.name}: placeholder kept verbatim`).toContain('{{login_url}}'); + expect(result.content, `${c.name}: no /admin/ prefix injected`).not.toMatch(/\/admin\/\{\{|\/admin\/%7B/); + expect(result.content, `${c.name}: braces not URL-encoded`).not.toContain('%7B%7B'); + }); + } +}); + +// The DB-repair half of the fix: an install whose template was ALREADY saved +// corrupted must be healed. SettingsController runs healCorruptedTemplateUrls() +// when the settings page is rendered, so opening it repairs the stored value. +test.describe('#299 stored templates are healed on settings open', () => { + test('a template corrupted with an /admin/ prefix is repaired in the DB', async ({ page }) => { + test.skip(!DB_USER || !DB_NAME, 'DB not configured'); + const NAME = '__heal299_test__'; + const LOCALE = 'it_IT'; + const corrupted = '

Accedi

'; + db(`INSERT INTO email_templates (name, locale, subject, body, active) + VALUES (${sqlq(NAME)}, ${sqlq(LOCALE)}, 'Heal test', ${sqlq(corrupted)}, 1) + ON DUPLICATE KEY UPDATE body=VALUES(body), active=1`); + try { + // Sanity: the corrupt prefix is really in the DB before we open settings. + expect(db(`SELECT body FROM email_templates WHERE name=${sqlq(NAME)} AND locale=${sqlq(LOCALE)}`)) + .toContain('/admin/{{login_url}}'); + + await login(page); + // Rendering the settings page runs the server-side heal. + await page.goto(`${BASE}/admin/settings?tab=templates`); + await page.waitForLoadState('domcontentloaded'); + + const body = db(`SELECT body FROM email_templates WHERE name=${sqlq(NAME)} AND locale=${sqlq(LOCALE)}`); + expect(body, 'placeholder is preserved').toContain('{{login_url}}'); + expect(body, 'admin prefix stripped from the stored value').not.toContain('/admin/{{login_url}}'); + } finally { + db(`DELETE FROM email_templates WHERE name=${sqlq(NAME)} AND locale=${sqlq(LOCALE)}`); + } + }); +});