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
5 changes: 4 additions & 1 deletion lib/MForm/Handler/MFormValueHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,12 @@ public static function loadRexVars(): array
}

// thanks @dtpop
// Den Wert lesen, wie er gespeichert ist: Entities in den Feldwerten gehoeren
// zum Inhalt (<h2> als Text im Editor). Ein htmlspecialchars_decode() davor
// aendert nie, ob das JSON lesbar ist, sondern nur die Werte.
$rawValue = $result['value'][$i];
$rawStringValue = (is_scalar($rawValue) || null === $rawValue) ? (string) $rawValue : '';
$jsonResult = json_decode(htmlspecialchars_decode($rawStringValue, ENT_NOQUOTES), true); // wb
$jsonResult = json_decode($rawStringValue, true);

if (is_array($jsonResult)) {
$result['value_string'][$i] = $result['value'][$i];
Expand Down
2 changes: 1 addition & 1 deletion lib/MForm/Repeater/MFormRepeaterHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ public static function unwrap(array $decoded): array
*/
public static function dataVersion(string|array $source): int
{
$decoded = is_string($source) ? json_decode(html_entity_decode($source, ENT_QUOTES | ENT_HTML5, 'UTF-8'), true) : $source;
$decoded = is_string($source) ? MFormOutputHelper::decodeRaw($source) : $source;
if (is_array($decoded) && isset($decoded[self::DATA_VERSION_KEY]) && is_numeric($decoded[self::DATA_VERSION_KEY])) {
return (int) $decoded[self::DATA_VERSION_KEY];
}
Expand Down
14 changes: 12 additions & 2 deletions lib/MForm/Utils/MFormOutputHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,13 @@ public static function isRepeater(int|string $source): bool
}

/**
* Gemeinsame Roh-Aufbereitung: Slot-Id auflösen, Entities dekodieren und den
* <br>-Fallback anwenden. MFormRepeaterHelper::decode() nutzt dieselbe Logik.
* Gemeinsame Roh-Aufbereitung: Slot-Id auflösen und das JSON lesen.
* MFormRepeaterHelper::decode() nutzt dieselbe Logik.
*
* Der Wert wird zuerst so gelesen, wie er gespeichert ist: Entities in den Feldwerten
* (&quot; in einem Linktitel, &lt;h2&gt; als Text) gehören zum Inhalt. Nur wenn das
* scheitert, liegt der escapte Platzhalter vor ('REX_VALUE[1]' ohne output=html); dann
* werden Entities dekodiert und der <br>-Fallback angewendet.
*
* @param int|string $source Slot-Id oder Roh-Wert
* @return array<mixed>|null Dekodiertes Array, sonst null
Expand All @@ -126,6 +131,11 @@ public static function decodeRaw(int|string $source): ?array
return null;
}

$decoded = json_decode($raw, true);
if (is_array($decoded)) {
return $decoded;
}

$normalizedValue = html_entity_decode($raw, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$decoded = json_decode($normalizedValue, true);

Expand Down
86 changes: 86 additions & 0 deletions tests/Redaxo/Handler/ValueHandlerEntityTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

declare(strict_types=1);

namespace FriendsOfRedaxo\MForm\Tests\Redaxo\Handler;

use FriendsOfRedaxo\MForm;
use FriendsOfRedaxo\MForm\Handler\MFormValueHandler;
use PHPUnit\Framework\TestCase;
use rex;
use rex_sql;

/**
* Die Eingabemaske liest einen Slot-Wert so, wie er gespeichert ist. Entities in den
* Feldwerten duerfen beim Oeffnen eines Slices nicht zu Markup werden, sonst steht
* als Text getipptes &lt;h2&gt; als Ueberschrift im Editor und wird so gespeichert.
*/
final class ValueHandlerEntityTest extends TestCase
{
private const TEXT = '<p>Tag als Text: &lt;h2&gt;Titel&lt;/h2&gt; und &lt;script&gt;alert(1)&lt;/script&gt; Rest, Tom &amp; Jerry</p>';
private const LINK = '<p><a href="https://example.org" title="Sagt &quot;Hallo&quot;">Link</a></p>';

private int $sliceId = 0;

/** @var array<string, mixed> */
private array $request = [];

protected function setUp(): void
{
if (!MFORM_TESTS_REDAXO) {
self::markTestSkipped('MFORM_REDAXO_PATH / MFORM_REDAXO_BOOT nicht gesetzt.');
}

$sql = rex_sql::factory();
$sql->setTable(rex::getTable('article_slice'));
$sql->setValue('article_id', 0);
$sql->setValue('clang_id', 1);
$sql->setValue('ctype_id', 1);
$sql->setValue('module_id', 0);
$sql->setValue('priority', 1);
$sql->setValue('status', 1);
$sql->setValue('value1', (string) json_encode([['text' => self::TEXT], ['text' => self::LINK]], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$sql->setValue('value2', (string) json_encode(['1' => self::TEXT], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$sql->setValue('value3', 'einfacher Text mit &lt;b&gt;');
$sql->setValue('createdate', date('Y-m-d H:i:s'));
$sql->setValue('updatedate', date('Y-m-d H:i:s'));
$sql->setValue('createuser', 'phpunit');
$sql->setValue('updateuser', 'phpunit');
$sql->insert();
$this->sliceId = (int) $sql->getLastId();

$this->request = $_REQUEST;
$_REQUEST['slice_id'] = $this->sliceId;
$_REQUEST['function'] = 'edit';
}

protected function tearDown(): void
{
if (0 === $this->sliceId) {
return;
}
$_REQUEST = $this->request;
rex_sql::factory()->setQuery('DELETE FROM ' . rex::getTable('article_slice') . ' WHERE id = ?', [$this->sliceId]);
}

public function testStoredValuesAreLoadedUnchanged(): void
{
$result = MFormValueHandler::loadRexVars();

self::assertSame([['text' => self::TEXT], ['text' => self::LINK]], $result['value'][1]);
self::assertSame(['1' => self::TEXT], $result['value'][2]);
self::assertSame('einfacher Text mit &lt;b&gt;', $result['value'][3]);
}

public function testRepeaterInputKeepsEntitiesAsText(): void
{
$html = MForm::factory()
->addFlexRepeaterElement(1, MForm::factory()->addTextAreaField('text', ['label' => 'Text']))
->show();

// Der Wert steht einmal escaped im Hidden-Input: aus &lt; wird &amp;lt;, nie ein <.
self::assertStringContainsString('&amp;lt;h2&amp;gt;Titel&amp;lt;/h2&amp;gt;', $html);
self::assertStringContainsString('&amp;lt;script&amp;gt;alert(1)&amp;lt;/script&amp;gt; Rest', $html);
self::assertStringNotContainsString('&lt;h2&gt;Titel', $html);
}
}
91 changes: 91 additions & 0 deletions tests/Unit/Repeater/RepeaterEntityDecodeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

declare(strict_types=1);

namespace FriendsOfRedaxo\MForm\Tests\Unit\Repeater;

use FriendsOfRedaxo\MForm\Repeater\MFormRepeaterHelper;
use FriendsOfRedaxo\MForm\Utils\MFormOutputHelper;
use PHPUnit\Framework\TestCase;

/**
* Entities in Feldwerten gehoeren zum Inhalt: Ein Slot-Wert wird zuerst so gelesen, wie er
* gespeichert ist. Entities werden nur umgewandelt, wenn das scheitert - das ist der Aufruf
* mit dem escapten Platzhalter (decode('REX_VALUE[1]')).
*/
final class RepeaterEntityDecodeTest extends TestCase
{
/** Editor-HTML mit Anfuehrungszeichen im Attribut, so schreibt es TinyMCE. */
private const LINK = '<p><a href="https://example.org" title="Sagt &quot;Hallo&quot;">Link</a></p>';

/** Als Text getippte Tags. */
private const TEXT = '<p>Tag als Text: &lt;h2&gt;Titel&lt;/h2&gt;, a&lt;b, M&uuml;ller &amp; S&ouml;hne, &amp;amp;</p>';

/** @param array<mixed> $value */
private static function json(array $value): string
{
return (string) json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}

/** So setzt REDAXO REX_VALUE[n] ohne output=html ein (rex_escape() + nl2br()). */
private static function placeholder(string $raw): string
{
return nl2br(htmlspecialchars($raw, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'));
}

public function testQuoteEntityInValueDoesNotEmptyTheRepeater(): void
{
$raw = self::json([['title' => 'A', 'text' => self::LINK], ['title' => 'B']]);
$rows = MFormRepeaterHelper::decode($raw);

self::assertCount(2, $rows);
self::assertSame(self::LINK, $rows[0]['text']);
}

public function testEntitiesInValuesStayAsStored(): void
{
$raw = self::json([['title' => 'Tippt &quot; und &lt;b&gt;', 'text' => self::TEXT]]);
$rows = MFormRepeaterHelper::decode($raw);

self::assertSame(self::TEXT, $rows[0]['text']);
self::assertSame('Tippt &quot; und &lt;b&gt;', $rows[0]['title']);
}

public function testEnvelopeWithQuoteEntity(): void
{
$raw = self::json(['__v' => 2, 'items' => [['text' => self::LINK], ['text' => 'aus', '__disabled' => 1]]]);

self::assertSame([['text' => self::LINK]], MFormRepeaterHelper::decode($raw));
self::assertSame(2, MFormRepeaterHelper::dataVersion($raw));
self::assertTrue(MFormOutputHelper::isRepeater($raw));
}

public function testDotNotationValuesStayAsStored(): void
{
$raw = self::json(['1' => self::LINK, '2' => self::TEXT]);

self::assertSame(['1' => self::LINK, '2' => self::TEXT], MFormOutputHelper::values($raw));
self::assertSame(self::TEXT, MFormOutputHelper::value($raw, '2'));
}

public function testEscapedPlaceholderStillWorks(): void
{
// decode('REX_VALUE[1]'): Der ganze Wert ist einmal escaped, eine Umwandlung stellt ihn wieder her.
$raw = self::json([['title' => 'Tom & Jerry', 'text' => self::LINK], ['text' => self::TEXT]]);
$rows = MFormRepeaterHelper::decode(self::placeholder($raw));

self::assertCount(2, $rows);
self::assertSame('Tom & Jerry', $rows[0]['title']);
self::assertSame(self::LINK, $rows[0]['text']);
self::assertSame(self::TEXT, $rows[1]['text']);
self::assertSame(2, MFormRepeaterHelper::dataVersion(self::placeholder(self::json(['__v' => 2, 'items' => [['text' => self::LINK]]]))));
}

public function testEscapedPlaceholderWithLineBreaksStillWorks(): void
{
// nl2br() setzt <br /> zwischen die Zeilen eines mehrzeilig gespeicherten JSON.
$raw = (string) json_encode([['title' => 'A', 'text' => 'Zeile']], JSON_PRETTY_PRINT);

self::assertSame([['title' => 'A', 'text' => 'Zeile']], MFormRepeaterHelper::decode(self::placeholder($raw)));
}
}
Loading