Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/Controllers/SettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
38 changes: 38 additions & 0 deletions app/Models/SettingsRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
24 changes: 24 additions & 0 deletions app/Support/EmailService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions app/Views/admin/cms-edit.php
Original file line number Diff line number Diff line change
Expand Up @@ -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: <?= json_encode(assetUrl('tinymce'), JSON_HEX_TAG | JSON_HEX_AMP) ?>,
suffix: '.min',
model: 'dom',
Expand Down
3 changes: 3 additions & 0 deletions app/Views/admin/settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -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: <?= json_encode(assetUrl("tinymce"), JSON_HEX_TAG | JSON_HEX_AMP) ?>,
suffix: '.min',
model: 'dom',
Expand Down
3 changes: 3 additions & 0 deletions app/Views/cms/edit-home.php
Original file line number Diff line number Diff line change
Expand Up @@ -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: <?= json_encode(assetUrl('tinymce'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>,
suffix: '.min',
model: 'dom',
Expand Down
3 changes: 3 additions & 0 deletions app/Views/events/form.php
Original file line number Diff line number Diff line change
Expand Up @@ -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: <?= json_encode(assetUrl("tinymce"), JSON_HEX_TAG | JSON_HEX_AMP) ?>,
suffix: '.min',
model: 'dom',
Expand Down
3 changes: 3 additions & 0 deletions app/Views/libri/partials/book_form.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 3 additions & 0 deletions app/Views/settings/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -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: <?= json_encode(assetUrl('tinymce'), JSON_HEX_TAG | JSON_HEX_AMP) ?>,
suffix: '.min',
model: 'dom',
Expand Down
72 changes: 72 additions & 0 deletions tests/email-template-url-prefix-299.unit.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);

/**
* Issue #299 — email templates double-prefixed after a first WYSIWYG edit.
*
* The TinyMCE editor (convert_urls default on) resolved placeholder URLs
* against the admin page, so `href="{{login_url}}"` was saved as
* `href="https://host/admin/{{login_url}}"`. Substituting the (already
* absolute) URL then produced `https://host/admin/https://host/accedi`.
*
* The editors now set convert_urls:false so this never happens again, but
* templates ALREADY saved on existing installs are still corrupt. EmailService
* heals them at render time by stripping an absolute `…/admin/` prefix sitting
* directly in front of a {{placeholder}} before substitution. This pins that.
*
* Run: php tests/email-template-url-prefix-299.unit.php (exit 0 iff all pass)
*/

require __DIR__ . '/../vendor/autoload.php';

use App\Support\EmailService;

$passed = 0;
$failed = 0;
$check = static function (bool $cond, string $label) use (&$passed, &$failed): void {
if ($cond) { $passed++; echo " OK {$label}\n"; return; }
$failed++; echo " FAIL {$label}\n";
};

// replaceVariables() uses only static constants (no $this->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('<a href="https://lib.example.org/admin/{{login_url}}">Log in</a>',
['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 === '<a href="https://lib.example.org/accedi">Log in</a>', 'exact healed output');

// 2) A clean template is unchanged by the healing + substitution.
$out = $render('<a href="{{login_url}}">Log in</a>', ['login_url' => 'https://lib.example.org/accedi']);
$check($out === '<a href="https://lib.example.org/accedi">Log in</a>', 'clean placeholder still substitutes correctly');

// 3) http (not https) prefixes are healed too.
$out = $render('<a href="http://x.test/admin/{{reset_url}}">Reset</a>', ['reset_url' => 'http://x.test/reset?t=abc']);
$check($out === '<a href="http://x.test/reset?t=abc">Reset</a>', 'http:// prefix healed');

// 4) A deeper base path (subdir install) is healed.
$out = $render('<a href="https://x.test/lib/admin/{{book_url}}">Book</a>', ['book_url' => 'https://x.test/lib/libro/5']);
$check($out === '<a href="https://x.test/lib/libro/5">Book</a>', '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('<a href="https://x.test/admin/users">Users</a>', []);
$check($out === '<a href="https://x.test/admin/users">Users</a>', '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(
'<a href="https://x.test/admin/{{book_url}}">B</a> <a href="https://x.test/admin/{{wishlist_url}}">W</a>',
['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);
136 changes: 136 additions & 0 deletions tests/tinymce-url-prefix-299.spec.js
Original file line number Diff line number Diff line change
@@ -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('<p><a href="{{login_url}}">x</a></p>');
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 = '<p><a href="http://localhost:8081/admin/{{login_url}}">Accedi</a></p>';
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)}`);
}
});
});
Loading