diff --git a/docs/modules/market_management/konzepte/market_management_hermes_mcp_api.md b/docs/modules/market_management/konzepte/market_management_hermes_mcp_api.md index ad8efe8..4a61df8 100644 --- a/docs/modules/market_management/konzepte/market_management_hermes_mcp_api.md +++ b/docs/modules/market_management/konzepte/market_management_hermes_mcp_api.md @@ -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= +Dokumentname ``` 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: Standmietvertrag_Naurua.pdf ``` -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 `` +für solche internen Dokumentlinks sowie `
` und `` 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 diff --git a/docs/modules/market_management/konzepte/market_management_hermes_mcp_konzept.md b/docs/modules/market_management/konzepte/market_management_hermes_mcp_konzept.md index 962bc13..b2a7fa4 100644 --- a/docs/modules/market_management/konzepte/market_management_hermes_mcp_konzept.md +++ b/docs/modules/market_management/konzepte/market_management_hermes_mcp_konzept.md @@ -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 `
`-Link im + Feld `notes` referenziert werden. Zusätzlich sind in mehrzeiligen Feldern + `
` und `` 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. diff --git a/modules/market_management/service.php b/modules/market_management/service.php index dc90280..60cbc49 100644 --- a/modules/market_management/service.php +++ b/modules/market_management/service.php @@ -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[^>]*>||<\/?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('/^$/iu', $token)) { + $result .= '
'; + continue; + } + if (preg_match('/^$/iu', $token)) { + $result .= ''; + continue; + } + if (preg_match('/^<\/strong\s*>$/iu', $token)) { + $result .= ''; + continue; + } + if (preg_match('/^<\/a\s*>$/iu', $token)) { + if ($anchorOpen) { + $result .= '
'; + $anchorOpen = false; + } else { + $result .= htmlspecialchars($token, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + continue; + } + if (preg_match('/^]*\bhref\s*=\s*(["\'])(.*?)\1[^>]*>$/isu', $token, $matches) + && market_management_is_allowed_document_url($matches[2])) { + if ($anchorOpen) { + $result .= ''; + } + $href = htmlspecialchars($matches[2], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $result .= ''; + $anchorOpen = true; + continue; + } + $result .= htmlspecialchars($token, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + if ($anchorOpen) { + $result .= ''; + } + + 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']); diff --git a/modules/shared/auth/ui/home.php b/modules/shared/auth/ui/home.php index 40dfc90..e0e9fbe 100644 --- a/modules/shared/auth/ui/home.php +++ b/modules/shared/auth/ui/home.php @@ -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) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[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) => '' + url + ''); return linked.replace(/\\r?\\n/g, '
'); };"; + echo " const marketDisplay = (value) => { if (value === null || value === undefined || value === '') { return 'Nicht hinterlegt'; } const escaped = marketEscape(value); const safeMarkup = escaped.replace(/<br>/g, '
').replace(/<strong>/g, '').replace(/<\\/strong>/g, '').replace(/<a class="sg-hyperlink" href="(https:\\/\\/erpnaurua\\.imhochrain\\.ch\\/files\\.php\\?path=[^"]+)" target="_blank" rel="noopener">/g, '').replace(/<\\/a>/g, ''); return safeMarkup.replace(/\\r?\\n/g, '
'); };"; 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) => '
' + marketEscape(label) + '
' + marketDisplay(value) + '
';"; + echo " const marketRow = (label, value, richText = false) => '
' + marketEscape(label) + '
' + (richText ? marketRichDisplay(value) : marketDisplay(value)) + '
';"; 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 = ''; 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 = '
' + title + '
' + (actions ? '
' + actions + '
' : '') + '
'; return '
' + header + '
' + body + '
' + (scope ? '' : '') + '
'; };";