Steuerkram/src/View.php
fds2610-bot 3e5810990c Fix #217: Content-Disposition-Header gegen Parameter-Injection maskieren
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-06 15:20:21 +02:00

158 lines
5.3 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace Steuerkram;
use Steuerkram\Auth;
use Steuerkram\Models\EventLog;
class View
{
private string $viewDir;
private array $flash = [];
public function __construct()
{
$this->viewDir = dirname(__DIR__) . '/src/Views';
// Session-based flash messages
Auth::startSession();
if (isset($_SESSION['flash'])) {
$this->flash = $_SESSION['flash'];
unset($_SESSION['flash']);
}
}
public function render(string $template, array $data = []): void
{
$data['flash'] = $this->flash;
$data['currentPath'] = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
$data['h'] ??= fn($v) => self::h($v);
$data['fmt'] ??= fn($v) => self::formatMoney($v);
extract($data, EXTR_SKIP);
include $this->viewDir . '/' . $template;
}
public static function redirect(string $path, string $message = '', string $type = 'success'): void
{
Auth::startSession();
if ($message !== '') {
$_SESSION['flash'][] = ['type' => $type, 'message' => $message];
self::logFlash($type, $message);
}
header('Location: ' . $path);
exit;
}
/**
* Fehler- und Warnhinweise, die dem Nutzer angezeigt werden, wandern
* zusätzlich in die Ereignisliste – dort sind sie auch dann noch
* nachvollziehbar, wenn die Meldung längst weggeklickt ist (Issue #165).
*/
private static function logFlash(string $type, string $message): void
{
$level = match ($type) {
'error' => EventLog::LEVEL_ERROR,
'warning' => EventLog::LEVEL_WARNING,
default => null,
};
if ($level === null) {
return;
}
$pfad = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH);
EventLog::tryAdd(
'meldungen',
$pfad ? "{$message} ({$pfad})" : $message,
$level
);
}
public static function h(mixed $value): string
{
return htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
public static function formatMoney(mixed $value): string
{
// Geschütztes Leerzeichen: in schmalen Tabellenspalten rutscht das
// Währungszeichen sonst allein in die nächste Zeile.
return number_format((float)$value, 2, ',', '.') . "\u{00A0}€";
}
public static function formatDate(?string $date): string
{
if (!$date) return '–';
$d = \DateTime::createFromFormat('Y-m-d', $date);
return $d ? $d->format('d.m.Y') : $date;
}
/**
* Baut den Wert des Content-Disposition-Headers für einen (ggf. vom Nutzer
* beim Upload frei wählbaren) Dateinamen. PATHINFO_BASENAME entfernt zwar
* Verzeichnisanteile, nicht aber Anführungszeichen/Backslashes – ohne
* Maskierung könnte ein präparierter Dateiname aus dem filename="..."-Parameter
* ausbrechen und einen zusätzlichen filename*=-Parameter einschleusen, der von
* vielen Browsern bevorzugt ausgewertet wird (Issue #217).
*/
public static function contentDispositionHeader(string $disposition, string $filename): string
{
$safe = (string)preg_replace('/[\x00-\x1f"\\\\]/', '_', $filename);
return $disposition . '; filename="' . $safe . '"; filename*=UTF-8\'\'' . rawurlencode($filename);
}
public static function buildQuery(array $params): string
{
$safe = [];
foreach ($params as $k => $v) {
$safe[rawurlencode((string)$k)] = rawurlencode((string)$v);
}
return http_build_query($safe);
}
/**
* Lesbare Textfarbe (dunkel/hell) für einen Hex-Hintergrund ermitteln.
* Ungültige oder zu kurze Werte liefern die Standardfarbe statt einer
* stillen Rechnung auf 0 (siehe Issue #158).
*/
public static function kontrastfarbe(string $hex, string $dunkel = '#1e293b', string $hell = '#ffffff'): string
{
if (preg_match('/^#([0-9a-fA-F]{3})$/', $hex, $m)) {
$hex = '#' . $m[1][0] . $m[1][0] . $m[1][1] . $m[1][1] . $m[1][2] . $m[1][2];
}
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $hex)) {
return $dunkel;
}
$helligkeit = 0.299*hexdec(substr($hex,1,2)) + 0.587*hexdec(substr($hex,3,2)) + 0.114*hexdec(substr($hex,5,2));
return $helligkeit > 128 ? $dunkel : $hell;
}
/**
* Konten nach kontenart gruppieren (für <optgroup>).
* Bei $nurZahlungskonten1200 werden Zahlungskonten außerhalb 1200–1299
* (Kasse, Verrechnungskonto, USt-Konten) ausgeschlossen – das ist die
* Konvention für Buchungsformulare (siehe BuchungsvorlageController,
* belege/_buchungsform.php): dort sind nur reine Bankkonten als
* Zahlungskonto wählbar.
*/
public static function gruppiereKonten(array $accounts, bool $nurZahlungskonten1200 = false): array
{
$gruppen = [];
foreach ($accounts as $acc) {
if ($nurZahlungskonten1200 && $acc['kontenart'] === 'Zahlungskonto') {
$nr = (int)$acc['kontonummer'];
if ($nr < 1200 || $nr > 1299) {
continue;
}
}
$gruppen[$acc['kontenart']][] = $acc;
}
return $gruppen;
}
}