Allow safe rich text in market multiline fields

This commit is contained in:
gitea_admin committed 2026-09-21 15:30:13 +02:00
1 parent 73e2144571
commit c25d3f1b3c
4 files changed
+141 -17

No files matched your search

@@ -25,14 +25,14 @@ Der Token wird nicht in dieser Dokumentation oder im Repository gespeichert.
## Dokumentlinks in Marktdurchführungen
Wenn Hermes Informationen aus einer Datei verarbeitet und diese in einer
Marktdurchführung speichert, muss Hermes die Quelldatei als vollständige
HTTPS-URL in das Feld `notes` aufnehmen. Der bestehende Text darf erhalten
bleiben; die Position im Freitext ist frei wählbar.
Marktdurchführung speichert, muss Hermes die Quelldatei als Link im Feld
`notes` aufnehmen. Der bestehende Text darf erhalten bleiben; die Position im
Freitext ist frei wählbar.
Die URL verwendet dieses Format:
Der Link verwendet dieses Format:
```text
https://erpnaurua.imhochrain.ch/files.php?path=<RFC-3986-url-encodierter-relativer-Pfad>
<a href="https://erpnaurua.imhochrain.ch/files.php?path=<RFC-3986-url-encodierter-relativer-Pfad>">Dokumentname</a>
```
Der Parameter `path` ist relativ zum Ordner `Hermes Naurua` und enthält nicht
@@ -41,11 +41,13 @@ Beispiel:
```text
Quelldatei: Hermes Naurua/Marktmanagement/2026 Bellevue Noël/Standmietvertrag_Naurua.pdf
Link: https://erpnaurua.imhochrain.ch/files.php?path=Marktmanagement%2F2026%20Bellevue%20No%C3%ABl%2FStandmietvertrag_Naurua.pdf
Link: <a href="https://erpnaurua.imhochrain.ch/files.php?path=Marktmanagement%2F2026%20Bellevue%20No%C3%ABl%2FStandmietvertrag_Naurua.pdf">Standmietvertrag_Naurua.pdf</a>
```
Leerzeichen, Umlaute und sonstige Sonderzeichen müssen vollständig URL-encodiert
werden. Die ERP-Anzeige macht ausschließlich diese Dokument-URLs klickbar. Der
Leerzeichen, Umlaute und sonstige Sonderzeichen im URL-Parameter müssen
vollständig URL-encodiert werden. Im Feld `notes` sind ausschließlich `<a>`
für solche internen Dokumentlinks sowie `<br>` und `<strong>` erlaubt. Andere
HTML-Tags, externe Links und nicht erlaubte URL-Schemata werden entfernt. Der
Link ist schreibgeschützt und erfordert eine aktive ERP-Anmeldung.
Die Route benötigt auf dem ERP-Webserver die Konfiguration
@@ -69,9 +69,10 @@ Modulberechtigung.
- Alle Schreibvorgänge laufen über die interne Modul-API.
- Hermes darf bei einem neuen Markt nur eine bestehende Kategorie referenzieren.
- Wenn Hermes Informationen aus einer Datei verarbeitet und in einer
Marktdurchführung speichert, muss die Quelldatei als vollständige HTTPS-URL
im Feld `notes` referenziert werden. Die Position im bestehenden Freitext ist
frei wählbar.
Marktdurchführung speichert, muss die Quelldatei als interner `<a>`-Link im
Feld `notes` referenziert werden. Zusätzlich sind in mehrzeiligen Feldern
`<br>` und `<strong>` erlaubt. Die Position im bestehenden Freitext ist frei
wählbar; andere HTML-Tags und externe Links sind nicht zulässig.
- Unsichere Erkennung wird nicht durch eine zusätzliche App-Freigabelogik
behandelt; die fachliche Qualitätssicherung liegt im Hermes-Training.
+125 -4
View File
@@ -18,6 +18,120 @@ const MARKET_OCCURRENCE_DAY_INHERITED_FIELDS = [
'delivery_start_time', 'delivery_end_time',
];
const MARKET_MANAGEMENT_MULTILINE_FIELDS = [
'address', 'notes', 'other_infrastructure', 'own_stand_requirements',
'travel_address', 'parking_options', 'delivery_access', 'storage_options',
'travel_notes', 'cancellation_terms', 'revenue_share_terms',
'other_participation_conditions', 'observations', 'cost_items_text',
'description',
];
function market_management_is_allowed_document_url(string $url): bool
{
$parts = parse_url($url);
if (!is_array($parts)
|| ($parts['scheme'] ?? '') !== 'https'
|| ($parts['host'] ?? '') !== 'erpnaurua.imhochrain.ch'
|| ($parts['path'] ?? '') !== '/files.php'
|| isset($parts['port'], $parts['user'], $parts['pass'], $parts['fragment'])
|| !preg_match('/^path=([^&]+)$/', (string) ($parts['query'] ?? ''), $matches)) {
return false;
}
$relativePath = rawurldecode($matches[1]);
if ($relativePath === '' || str_contains($relativePath, "\0") || str_contains($relativePath, '\\') || str_starts_with($relativePath, '/')) {
return false;
}
foreach (explode('/', $relativePath) as $segment) {
if ($segment === '' || $segment === '.' || $segment === '..') {
return false;
}
}
return true;
}
function market_management_sanitize_multiline_html(?string $value): ?string
{
if ($value === null || $value === '') {
return $value;
}
$tokens = preg_split('/(<\/?a\b[^>]*>|<br\s*\/?>|<\/?strong\s*>)/iu', $value, -1, PREG_SPLIT_DELIM_CAPTURE);
if ($tokens === false) {
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
$result = '';
$anchorOpen = false;
foreach ($tokens as $token) {
if (preg_match('/^<br\s*\/?>$/iu', $token)) {
$result .= '<br>';
continue;
}
if (preg_match('/^<strong\s*>$/iu', $token)) {
$result .= '<strong>';
continue;
}
if (preg_match('/^<\/strong\s*>$/iu', $token)) {
$result .= '</strong>';
continue;
}
if (preg_match('/^<\/a\s*>$/iu', $token)) {
if ($anchorOpen) {
$result .= '</a>';
$anchorOpen = false;
} else {
$result .= htmlspecialchars($token, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
continue;
}
if (preg_match('/^<a\b[^>]*\bhref\s*=\s*(["\'])(.*?)\1[^>]*>$/isu', $token, $matches)
&& market_management_is_allowed_document_url($matches[2])) {
if ($anchorOpen) {
$result .= '</a>';
}
$href = htmlspecialchars($matches[2], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$result .= '<a class="sg-hyperlink" href="' . $href . '" target="_blank" rel="noopener">';
$anchorOpen = true;
continue;
}
$result .= htmlspecialchars($token, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
if ($anchorOpen) {
$result .= '</a>';
}
return $result;
}
function market_management_sanitize_multiline_fields(array &$record): void
{
foreach (MARKET_MANAGEMENT_MULTILINE_FIELDS as $field) {
if (array_key_exists($field, $record) && is_string($record[$field])) {
$record[$field] = market_management_sanitize_multiline_html($record[$field]);
}
}
}
function market_management_sanitize_occurrence_payload(array &$occurrence): void
{
market_management_sanitize_multiline_fields($occurrence);
foreach ($occurrence['days'] ?? [] as &$day) {
if (is_array($day)) {
market_management_sanitize_multiline_fields($day);
}
}
unset($day);
foreach ($occurrence['cost_items'] ?? [] as &$costItem) {
if (is_array($costItem)) {
market_management_sanitize_multiline_fields($costItem);
}
}
unset($costItem);
}
function market_management_list_organizers(PDO $pdo): array
{
return $pdo->query(
@@ -482,6 +596,7 @@ function market_management_get_market(PDO $pdo, int $marketId, bool $includeArch
$costs = $pdo->prepare('SELECT * FROM market_occurrence_cost_item WHERE occurrence_id = :occurrence_id ORDER BY id ASC');
$costs->execute([':occurrence_id' => $occurrenceId]);
$occurrence['cost_items'] = $costs->fetchAll();
market_management_sanitize_occurrence_payload($occurrence);
}
unset($occurrence);
return $market;
@@ -721,6 +836,9 @@ function market_management_write_fields(array $input, array $definitions): array
'nullable_id' => market_management_optional_id($input, $field),
default => throw new LogicException('Unbekannter Feldtyp.'),
};
if ($type === 'string' && in_array($field, MARKET_MANAGEMENT_MULTILINE_FIELDS, true)) {
$value = market_management_sanitize_multiline_html($value);
}
$fields[] = $field . ' = :' . $field;
$params[':' . $field] = $value;
}
@@ -982,6 +1100,7 @@ function market_management_get_occurrence(PDO $pdo, int $occurrenceId, bool $inc
break;
}
}
market_management_sanitize_occurrence_payload($occurrence);
$costSummary = $pdo->prepare(
'SELECT COALESCE(SUM(gross_amount), 0) AS gross_amount,
COALESCE(SUM(net_amount), 0) AS net_amount,
@@ -1330,7 +1449,7 @@ function market_management_create_cost_item(PDO $pdo, array $input): array
$stmt->execute([
':occurrence_id' => $occurrenceId,
':cost_type_id' => $costTypeId,
':description' => market_management_optional_string($input, 'description'),
':description' => market_management_sanitize_multiline_html(market_management_optional_string($input, 'description')),
':gross_amount' => $grossAmount,
':tax_status' => $taxStatus,
':tax_rate' => $taxRate,
@@ -1381,7 +1500,9 @@ function market_management_update_cost_item(PDO $pdo, int $costItemId, array $in
$sets[] = $field . ' = :' . $field;
$params[':' . $field] = $type === 'number'
? market_management_optional_number($input, $field)
: market_management_optional_string($input, $field);
: ($field === 'description'
? market_management_sanitize_multiline_html(market_management_optional_string($input, $field))
: market_management_optional_string($input, $field));
}
if (isset($params[':tax_status']) && !in_array($params[':tax_status'], ['tax_free', 'taxable'], true)) {
throw new InvalidArgumentException('Ungültiger Steuerstatus.');
@@ -1418,7 +1539,7 @@ function market_management_update_occurrence_simple_costs(PDO $pdo, int $occurre
$item = market_management_create_cost_item($pdo, [
'occurrence_id' => $occurrenceId,
'cost_type_id' => market_management_get_cost_type_id($pdo, 'stand_fee'),
'description' => array_key_exists('cost_items_text', $input) ? market_management_optional_string($input, 'cost_items_text') : null,
'description' => array_key_exists('cost_items_text', $input) ? market_management_sanitize_multiline_html(market_management_optional_string($input, 'cost_items_text')) : null,
'gross_amount' => $grossAmount,
'tax_status' => $taxStatus,
'tax_rate' => $taxRate,
@@ -1429,7 +1550,7 @@ function market_management_update_occurrence_simple_costs(PDO $pdo, int $occurre
$costFields['gross_amount'] = market_management_optional_number($input, 'gross_cost');
}
if (array_key_exists('cost_items_text', $input)) {
$costFields['description'] = market_management_optional_string($input, 'cost_items_text');
$costFields['description'] = market_management_sanitize_multiline_html(market_management_optional_string($input, 'cost_items_text'));
}
if (array_key_exists('tax_status', $input)) {
$costFields['tax_status'] = trim((string) $input['tax_status']);
+2 -2
View File
@@ -1152,14 +1152,14 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $otcM
echo " const getCurrentLimit = () => parseInt(getTable()?.dataset.marketsLimit || '20', 10);";
echo " const getPageSize = () => parseInt(getTable()?.dataset.marketsPageSize || '20', 10);";
echo " const marketEscape = (value) => String(value ?? '').replace(/[&<>\"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[character] || character));";
echo " const marketDisplay = (value) => { if (value === null || value === undefined || value === '') { return 'Nicht hinterlegt'; } const escaped = marketEscape(value); const linked = escaped.replace(/https:\\/\\/erpnaurua\\.imhochrain\\.ch\\/files\\.php\\?path=[^\\s<]+/g, (url) => '<a class=\"sg-hyperlink\" href=\"' + url + '\" target=\"_blank\" rel=\"noopener\">' + url + '</a>'); return linked.replace(/\\r?\\n/g, '<br>'); };";
echo " const marketDisplay = (value) => { if (value === null || value === undefined || value === '') { return 'Nicht hinterlegt'; } const escaped = marketEscape(value); const safeMarkup = escaped.replace(/&lt;br&gt;/g, '<br>').replace(/&lt;strong&gt;/g, '<strong>').replace(/&lt;\\/strong&gt;/g, '</strong>').replace(/&lt;a class=&quot;sg-hyperlink&quot; href=&quot;(https:\\/\\/erpnaurua\\.imhochrain\\.ch\\/files\\.php\\?path=[^&quot;]+)&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;/g, '<a class=\"sg-hyperlink\" href=\"$1\" target=\"_blank\" rel=\"noopener\">').replace(/&lt;\\/a&gt;/g, '</a>'); return safeMarkup.replace(/\\r?\\n/g, '<br>'); };";
echo " const marketBoolean = (value) => value === true || value === 'true' || value === 1 || value === '1' ? 'Ja' : value === false || value === 'false' || value === 0 || value === '0' ? 'Nein' : 'Nicht hinterlegt';";
echo " const marketMoney = (value) => value === null || value === undefined || value === '' ? 'Nicht hinterlegt' : 'CHF ' + Number(value).toFixed(2);";
echo " const marketContactName = (contact) => { if (!contact) { return 'Nicht hinterlegt'; } return marketDisplay([contact.first_name, contact.last_name].filter(Boolean).join(' ')); };";
echo " const marketDate = (value) => { if (!value) { return 'Nicht hinterlegt'; } const parts = String(value).split('-'); return parts.length === 3 ? parts[2] + '.' + parts[1] + '.' + parts[0] : marketEscape(value); };";
echo " const marketStatusLabel = (value) => ({ open: 'Offen', waitlist: 'Warteliste', registered: 'Angemeldet', confirmation_pending: 'Bestätigung ausstehend', confirmed: 'Bestätigt', cancelled: 'Abgesagt', completed: 'Abgeschlossen' }[value] || value || 'Nicht hinterlegt');";
echo " const marketStatusSegment = (value) => ({ waitlist: 'sg-card-segment--signal-red', registered: 'sg-card-segment--signal-yellow', confirmation_pending: 'sg-card-segment--signal-yellow', confirmed: 'sg-card-segment--signal-green' }[value] || 'sg-card-segment--gray');";
echo " const marketRow = (label, value) => '<div class=\"market-detail-row\"><dt>' + marketEscape(label) + '</dt><dd>' + marketDisplay(value) + '</dd></div>';";
echo " const marketRow = (label, value, richText = false) => '<div class=\"market-detail-row\"><dt>' + marketEscape(label) + '</dt><dd>' + (richText ? marketRichDisplay(value) : marketDisplay(value)) + '</dd></div>';";
echo " const initializeMunicipalityFields = (scope) => { scope.querySelectorAll('select[name=\"municipality_reference_id\"]').forEach((select) => { if (!select.querySelector('option[value=\"__new__\"]')) { const option = document.createElement('option'); option.value = '__new__'; option.textContent = 'Neue Gemeinde'; select.append(option); } const form = select.closest('form'); if (!form) { return; } let fields = form.querySelector('[data-market-new-municipality]'); if (!fields) { fields = document.createElement('div'); fields.className = 'sg-form-sections-card__field-group'; fields.dataset.marketNewMunicipality = ''; fields.hidden = true; fields.innerHTML = '<label class=\"sg-labeled-input-row\"><span class=\"sg-label\">Gemeinde</span><input class=\"sg-interaction-element sg-input-single-line\" type=\"text\" name=\"municipality_name\" data-market-new-municipality-name></label>'; select.closest('.sg-form-sections-card__field-group')?.after(fields); } const sync = () => { const visible = select.value === '__new__'; fields.hidden = !visible; const input = fields.querySelector('[data-market-new-municipality-name]'); if (input) { input.disabled = !visible; input.setAttribute('aria-disabled', String(!visible)); } }; if (select.dataset.municipalityBound !== 'true') { select.addEventListener('change', sync); select.dataset.municipalityBound = 'true'; } sync(); }); };";
echo " const syncMunicipalityReadValue = (scope) => { const drawer = scope.querySelector('[data-market-drawer]'); if (!drawer?.dataset.marketPayload) { return; } let payload = {}; try { payload = JSON.parse(drawer.dataset.marketPayload); } catch (error) { return; } const value = payload.occurrence?.municipality_name || 'Nicht hinterlegt'; scope.querySelectorAll('.market-detail-row').forEach((row) => { if (row.querySelector('dt')?.textContent?.trim() !== 'Gemeinde') { return; } const output = row.querySelector('dd'); if (output && output.textContent !== value) { output.textContent = value; } }); };";
echo " const marketCard = (title, body, headerClass = 'sg-card-segment--darkgreen', actions = '', scope = '', id = '', values = null) => { const header = '<header class=\"sg-card-segment sg-card-segment--header ' + headerClass + ' sg-object-card__header\" data-component-part=\"card-header\"><div class=\"sg-strong sg-text-on-dark\">' + title + '</div>' + (actions ? '<div class=\"sg-component-row\">' + actions + '</div>' : '') + '</header>'; return '<article class=\"sg-card sg-card--content-card market-detail-card\" data-component=\"content-card\"' + (scope ? ' data-shared-editable-action-card' : '') + '>' + header + '<div class=\"sg-card-segment sg-card-segment--body\" data-component-part=\"card-body\"' + (scope ? ' data-shared-editable-card-read' : '') + '>' + body + '</div>' + (scope ? '<div class=\"sg-card-segment sg-card-segment--body sg-card-segment--gray\" data-shared-editable-card-form hidden>' + marketEditForm(scope, id, values) + '</div>' : '') + '</article>'; };";