web - refactor core modules, harden authorization, simplify endpoints
This commit is contained in:
@@ -0,0 +1,405 @@
|
||||
<?php
|
||||
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
|
||||
const KALLI_CKAL_HEADERS = [
|
||||
'ID',
|
||||
'Lang-ID',
|
||||
'Termin-Typ',
|
||||
'Von-Datum',
|
||||
'Von-Zeit',
|
||||
'Bis-Datum',
|
||||
'Bis-Zeit',
|
||||
'Zeitzusatz',
|
||||
'Titel',
|
||||
'Text',
|
||||
'Ort',
|
||||
'Planungsstatus',
|
||||
'Anzeigeziel',
|
||||
'Gruppenzuordnung',
|
||||
'Zyklusart',
|
||||
'Zyklusintervall',
|
||||
'Zyklusdetail',
|
||||
'Notiz',
|
||||
'Verantwortlich',
|
||||
'Hintergrundfarbe',
|
||||
'BucherID',
|
||||
'Benutzerdefinierte Felder',
|
||||
'Dokument-Titel',
|
||||
'Anzeigeziel Ort',
|
||||
'Anzeigeziel Notiz',
|
||||
'Anzeigeziel Verantwortlicher',
|
||||
'Erzeugt',
|
||||
'Aktualisiert',
|
||||
];
|
||||
|
||||
const KALLI_CUSTOM_PUBLIC = 'oeffentlich';
|
||||
const KALLI_CUSTOM_INTERNAL = 'intern';
|
||||
|
||||
function kalliHtml($value): string
|
||||
{
|
||||
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
function kalliPermissionEnabled($value): bool
|
||||
{
|
||||
return $value === true || $value === 1 || $value === '1' || $value === 't' || $value === 'true';
|
||||
}
|
||||
|
||||
function kalliCsrfToken(): string
|
||||
{
|
||||
if (empty($_SESSION['kalli_csrf'])) {
|
||||
$_SESSION['kalli_csrf'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
return $_SESSION['kalli_csrf'];
|
||||
}
|
||||
|
||||
function kalliCsrfIsValid($postedToken): bool
|
||||
{
|
||||
$sessionToken = $_SESSION['kalli_csrf'] ?? '';
|
||||
|
||||
return is_string($postedToken)
|
||||
&& is_string($sessionToken)
|
||||
&& $sessionToken !== ''
|
||||
&& hash_equals($sessionToken, $postedToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt Kategorien und Terminarten in einer Abfrage. Die Berechtigungsfilterung
|
||||
* erfolgt anschließend zentral in kalliBuildCatalog().
|
||||
*/
|
||||
function kalliFetchCatalogRows(PDO $pdo): array
|
||||
{
|
||||
$sql = <<<'SQL'
|
||||
SELECT
|
||||
c.id AS category_id,
|
||||
c.name AS category_name,
|
||||
c.permission_role,
|
||||
t.id AS type_id,
|
||||
t.name AS type_name,
|
||||
t.display_label,
|
||||
t.planungsstatus,
|
||||
t.anzeigeziel,
|
||||
t.gruppenzuordnung,
|
||||
t.anzeigeziel_ort,
|
||||
t.anzeigeziel_notiz,
|
||||
t.anzeigeziel_verantwortlicher,
|
||||
t.default_text
|
||||
FROM kalli_categories c
|
||||
LEFT JOIN kalli_appointment_types t ON t.category_id = c.id
|
||||
ORDER BY c.sort_order, c.name, t.sort_order, t.name
|
||||
SQL;
|
||||
|
||||
return $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt die einzige Berechtigungs- und Preset-Sicht für UI und POST-Verarbeitung.
|
||||
*/
|
||||
function kalliBuildCatalog(array $rows, array $groups): array
|
||||
{
|
||||
$isAdmin = kalliPermissionEnabled($groups['Admin'] ?? null);
|
||||
$categories = [];
|
||||
$typesByName = [];
|
||||
$allowCustom = $isAdmin;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$role = (string)($row['permission_role'] ?? '');
|
||||
$allowed = $isAdmin || kalliPermissionEnabled($groups[$role] ?? null);
|
||||
if (!$allowed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Entspricht dem bisherigen Verhalten: Wer mindestens eine Kalli-Kategorie
|
||||
// sehen darf, darf auch einen benutzerdefinierten Termin anlegen.
|
||||
$allowCustom = true;
|
||||
|
||||
if ($row['type_id'] === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$categoryId = (string)$row['category_id'];
|
||||
if (!isset($categories[$categoryId])) {
|
||||
$categories[$categoryId] = [
|
||||
'id' => $row['category_id'],
|
||||
'name' => (string)$row['category_name'],
|
||||
'types' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$type = [
|
||||
'id' => $row['type_id'],
|
||||
'name' => (string)$row['type_name'],
|
||||
'display_label' => (string)($row['display_label'] ?? ''),
|
||||
'planungsstatus' => (string)($row['planungsstatus'] ?? ''),
|
||||
'anzeigeziel' => (string)($row['anzeigeziel'] ?? ''),
|
||||
'gruppenzuordnung' => (string)($row['gruppenzuordnung'] ?? ''),
|
||||
'anzeigeziel_ort' => (string)($row['anzeigeziel_ort'] ?? ''),
|
||||
'anzeigeziel_notiz' => (string)($row['anzeigeziel_notiz'] ?? ''),
|
||||
'anzeigeziel_verantwortlicher' => (string)($row['anzeigeziel_verantwortlicher'] ?? ''),
|
||||
'default_text' => (string)($row['default_text'] ?? ''),
|
||||
];
|
||||
|
||||
$categories[$categoryId]['types'][] = $type;
|
||||
|
||||
// Das Formular übermittelt aus Kompatibilitätsgründen weiterhin den Namen.
|
||||
// Bei versehentlichen Dubletten entspricht der erste sortierte Treffer dem
|
||||
// deterministischen Ersatz für das frühere "LIMIT 1" ohne ORDER BY.
|
||||
if (!isset($typesByName[$type['name']])) {
|
||||
$typesByName[$type['name']] = $type;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'categories' => array_values($categories),
|
||||
'types_by_name' => $typesByName,
|
||||
'allow_custom' => $allowCustom,
|
||||
];
|
||||
}
|
||||
|
||||
function kalliFormValues(array $source = []): array
|
||||
{
|
||||
$keys = [
|
||||
'group',
|
||||
'userinput',
|
||||
'date_s',
|
||||
'date_e',
|
||||
'time_s',
|
||||
'time_e',
|
||||
'further_text',
|
||||
'location',
|
||||
'responsibility',
|
||||
];
|
||||
|
||||
$values = [];
|
||||
foreach ($keys as $key) {
|
||||
$value = $source[$key] ?? '';
|
||||
$values[$key] = is_scalar($value) ? trim((string)$value) : '';
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
function kalliParseDate(string $value, string $label, bool $required = false): ?DateTimeImmutable
|
||||
{
|
||||
if ($value === '') {
|
||||
if ($required) {
|
||||
throw new InvalidArgumentException($label . ' fehlt.');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $value);
|
||||
$errors = DateTimeImmutable::getLastErrors();
|
||||
$hasErrors = $errors !== false && ($errors['warning_count'] > 0 || $errors['error_count'] > 0);
|
||||
|
||||
if (!$date || $hasErrors || $date->format('Y-m-d') !== $value) {
|
||||
throw new InvalidArgumentException($label . ' ist ungültig.');
|
||||
}
|
||||
|
||||
return $date;
|
||||
}
|
||||
|
||||
function kalliParseTime(string $value, string $label): ?string
|
||||
{
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/D', $value)) {
|
||||
throw new InvalidArgumentException($label . ' ist ungültig. Bitte hh:mm verwenden.');
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validiert einen vollständigen Formular-POST und löst die CKalender-Presets auf.
|
||||
*/
|
||||
function kalliValidateEntry(array $source, array $catalog): array
|
||||
{
|
||||
$values = kalliFormValues($source);
|
||||
$group = $values['group'];
|
||||
|
||||
if ($group === '') {
|
||||
throw new InvalidArgumentException('Bitte wählen Sie eine Terminart aus.');
|
||||
}
|
||||
|
||||
$preset = $catalog['types_by_name'][$group] ?? null;
|
||||
if ($preset !== null) {
|
||||
$title = $preset['name'];
|
||||
} elseif (in_array($group, [KALLI_CUSTOM_PUBLIC, KALLI_CUSTOM_INTERNAL], true)) {
|
||||
if (!$catalog['allow_custom']) {
|
||||
throw new InvalidArgumentException('Für benutzerdefinierte Termine fehlt die Berechtigung.');
|
||||
}
|
||||
|
||||
$title = $values['userinput'];
|
||||
if ($title === '') {
|
||||
throw new InvalidArgumentException('Bitte geben Sie einen Titel für den benutzerdefinierten Termin ein.');
|
||||
}
|
||||
|
||||
$isPublic = $group === KALLI_CUSTOM_PUBLIC;
|
||||
$preset = [
|
||||
'planungsstatus' => 'fix',
|
||||
'anzeigeziel' => $isPublic ? 'öffentlich' : 'intern',
|
||||
'gruppenzuordnung' => 'AN ALLE',
|
||||
'anzeigeziel_ort' => $isPublic ? 'öffentlich' : 'intern',
|
||||
'anzeigeziel_notiz' => 'intern',
|
||||
'anzeigeziel_verantwortlicher' => 'intern',
|
||||
'default_text' => '',
|
||||
];
|
||||
} else {
|
||||
throw new InvalidArgumentException('Die gewählte Terminart ist ungültig oder nicht freigegeben.');
|
||||
}
|
||||
|
||||
$dateStart = kalliParseDate($values['date_s'], 'Das Startdatum', true);
|
||||
$dateEnd = kalliParseDate($values['date_e'], 'Das Enddatum');
|
||||
if ($dateEnd !== null && $dateEnd < $dateStart) {
|
||||
throw new InvalidArgumentException('Das Enddatum darf nicht vor dem Startdatum liegen.');
|
||||
}
|
||||
|
||||
$timeStart = kalliParseTime($values['time_s'], 'Die Startzeit');
|
||||
$timeEnd = kalliParseTime($values['time_e'], 'Die Endzeit');
|
||||
if ($timeStart !== null && $timeEnd !== null && ($dateEnd === null || $dateEnd == $dateStart) && $timeEnd < $timeStart) {
|
||||
throw new InvalidArgumentException('Die Endzeit darf am selben Tag nicht vor der Startzeit liegen.');
|
||||
}
|
||||
|
||||
$text = $values['further_text'];
|
||||
if ($text === '' && $preset['default_text'] !== '') {
|
||||
$text = $preset['default_text'];
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => $title,
|
||||
'date_start' => $dateStart->format('d.m.Y'),
|
||||
'date_end' => $dateEnd?->format('d.m.Y') ?? '',
|
||||
'time_start' => $timeStart ?? '',
|
||||
'time_end' => $timeEnd ?? '',
|
||||
'text' => $text,
|
||||
'location' => $values['location'],
|
||||
'responsibility' => $values['responsibility'],
|
||||
'planungsstatus' => $preset['planungsstatus'],
|
||||
'anzeigeziel' => $preset['anzeigeziel'],
|
||||
'gruppenzuordnung' => $preset['gruppenzuordnung'],
|
||||
'anzeigeziel_ort' => $preset['anzeigeziel_ort'],
|
||||
'anzeigeziel_notiz' => $preset['anzeigeziel_notiz'],
|
||||
'anzeigeziel_verantwortlicher' => $preset['anzeigeziel_verantwortlicher'],
|
||||
];
|
||||
}
|
||||
|
||||
function kalliCalendarRow(array $entry, array $user): array
|
||||
{
|
||||
$userName = trim((string)($user['first_name'] ?? '') . ' ' . (string)($user['last_name'] ?? ''));
|
||||
$info = 'Von ' . $userName . ' eingetragen (Eintragungsassistent)';
|
||||
|
||||
return [
|
||||
'', // ID
|
||||
'', // Lang-ID
|
||||
'Einzel', // Termin-Typ
|
||||
$entry['date_start'], // Von-Datum
|
||||
$entry['time_start'], // Von-Zeit
|
||||
$entry['date_end'], // Bis-Datum
|
||||
$entry['time_end'], // Bis-Zeit
|
||||
'', // Zeitzusatz
|
||||
$entry['title'], // Titel
|
||||
$entry['text'], // Text
|
||||
$entry['location'], // Ort
|
||||
$entry['planungsstatus'], // Planungsstatus
|
||||
$entry['anzeigeziel'], // Anzeigeziel
|
||||
$entry['gruppenzuordnung'], // Gruppenzuordnung
|
||||
'einmalig', // Zyklusart
|
||||
'', // Zyklusintervall
|
||||
'', // Zyklusdetail
|
||||
$info, // Notiz
|
||||
$entry['responsibility'], // Verantwortlich
|
||||
'', // Hintergrundfarbe
|
||||
'', // BucherID
|
||||
'', // Benutzerdefinierte Felder
|
||||
'', // Dokument-Titel
|
||||
$entry['anzeigeziel_ort'],
|
||||
$entry['anzeigeziel_notiz'],
|
||||
$entry['anzeigeziel_verantwortlicher'],
|
||||
'', // Erzeugt
|
||||
'', // Aktualisiert
|
||||
];
|
||||
}
|
||||
|
||||
function kalliSafeFilenamePart(string $value, string $fallback): string
|
||||
{
|
||||
$safe = preg_replace('/[^\p{L}\p{N}._ -]+/u', '_', $value);
|
||||
$safe = trim((string)$safe, " ._-\t\n\r\0\x0B");
|
||||
|
||||
return $safe !== '' ? $safe : $fallback;
|
||||
}
|
||||
|
||||
function kalliReserveExportPath(string $directory, array $user): array
|
||||
{
|
||||
$displayName = trim((string)($user['first_name'] ?? '') . ' ' . (string)($user['last_name'] ?? ''));
|
||||
$fallback = 'user-' . (string)($user['user_id'] ?? $_SESSION['user_id'] ?? 'unknown');
|
||||
$safeName = kalliSafeFilenamePart($displayName, $fallback);
|
||||
$base = $directory . '/' . $safeName . '-' . date('Ymd-His');
|
||||
|
||||
for ($suffix = 1; $suffix <= 1000; $suffix++) {
|
||||
$target = $base . ($suffix === 1 ? '' : '-' . $suffix) . '.xlsx';
|
||||
$lockPath = $target . '.lock';
|
||||
$lockHandle = @fopen($lockPath, 'x');
|
||||
if ($lockHandle === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (file_exists($target)) {
|
||||
fclose($lockHandle);
|
||||
@unlink($lockPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
return [$target, $lockPath, $lockHandle];
|
||||
}
|
||||
|
||||
throw new RuntimeException('Kein freier Dateiname für den CKalender-Export verfügbar.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt die XLSX zunächst unter einer Nicht-XLSX-Endung und veröffentlicht sie
|
||||
* anschließend per rename(). So kann ein Ordner-Watcher keine halbfertige Datei lesen.
|
||||
*/
|
||||
function kalliWriteCalendarFile(array $entry, array $user, string $directory): string
|
||||
{
|
||||
$directory = rtrim($directory, '/');
|
||||
if ($directory === '') {
|
||||
throw new RuntimeException('CKalender-XLSX-Verzeichnis ist nicht konfiguriert.');
|
||||
}
|
||||
|
||||
if (!is_dir($directory) && !mkdir($directory, 0770, true) && !is_dir($directory)) {
|
||||
throw new RuntimeException('CKalender-XLSX-Verzeichnis kann nicht angelegt werden: ' . $directory);
|
||||
}
|
||||
if (!is_writable($directory)) {
|
||||
throw new RuntimeException('CKalender-XLSX-Verzeichnis ist nicht beschreibbar: ' . $directory);
|
||||
}
|
||||
|
||||
[$target, $lockPath, $lockHandle] = kalliReserveExportPath($directory, $user);
|
||||
$temporary = $target . '.tmp';
|
||||
$spreadsheet = new Spreadsheet();
|
||||
|
||||
try {
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->fromArray(KALLI_CKAL_HEADERS, null, 'A1');
|
||||
$sheet->fromArray(kalliCalendarRow($entry, $user), null, 'A2');
|
||||
|
||||
(new Xlsx($spreadsheet))->save($temporary);
|
||||
if (!rename($temporary, $target)) {
|
||||
throw new RuntimeException('CKalender-XLSX-Datei konnte nicht veröffentlicht werden.');
|
||||
}
|
||||
} finally {
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
if (is_file($temporary)) {
|
||||
@unlink($temporary);
|
||||
}
|
||||
fclose($lockHandle);
|
||||
@unlink($lockPath);
|
||||
}
|
||||
|
||||
return $target;
|
||||
}
|
||||
Reference in New Issue
Block a user