camera - (real) remote control, brandschutz - configurable course, dashboard - vbb licensed API (a real vbb license, how cool)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,443 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
const BRANDSCHUTZ_COURSE_SCHEMA_VERSION = 1;
|
||||
const BRANDSCHUTZ_MEDIA_MAX_BYTES = 8388608;
|
||||
const BRANDSCHUTZ_MEDIA_URL = '/intranet_repo/projects/brandschutz/uploads';
|
||||
|
||||
function brandschutz_media_directory(): string
|
||||
{
|
||||
return dirname(__DIR__) . '/uploads';
|
||||
}
|
||||
|
||||
function brandschutz_course_ensure_storage(PDO $pdo): void
|
||||
{
|
||||
$pdo->exec(
|
||||
'CREATE TABLE IF NOT EXISTS brandschutz_course_config ('
|
||||
. 'id SMALLINT PRIMARY KEY CHECK (id = 1), '
|
||||
. 'config JSONB NOT NULL, '
|
||||
. 'revision INTEGER NOT NULL DEFAULT 1, '
|
||||
. 'updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), '
|
||||
. 'updated_by BIGINT NULL)'
|
||||
);
|
||||
|
||||
$statement = $pdo->query('SELECT 1 FROM brandschutz_course_config WHERE id = 1');
|
||||
if ($statement !== false && $statement->fetchColumn() !== false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$initialConfig = brandschutz_course_initial_config();
|
||||
$insert = $pdo->prepare(
|
||||
'INSERT INTO brandschutz_course_config (id, config, revision) '
|
||||
. 'VALUES (1, CAST(? AS JSONB), 1) ON CONFLICT (id) DO NOTHING'
|
||||
);
|
||||
$insert->execute([json_encode($initialConfig, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)]);
|
||||
}
|
||||
|
||||
function brandschutz_course_initial_config(): array
|
||||
{
|
||||
return brandschutz_course_normalize([
|
||||
'version' => BRANDSCHUTZ_COURSE_SCHEMA_VERSION,
|
||||
'title' => 'Brandschutzunterweisung',
|
||||
'settings' => [],
|
||||
'chapters' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array{config: array, revision: int, updated_at: ?string} */
|
||||
function brandschutz_course_load(PDO $pdo): array
|
||||
{
|
||||
brandschutz_course_ensure_storage($pdo);
|
||||
$statement = $pdo->query('SELECT config, revision, updated_at FROM brandschutz_course_config WHERE id = 1');
|
||||
$row = $statement !== false ? $statement->fetch(PDO::FETCH_ASSOC) : false;
|
||||
if (!$row) {
|
||||
throw new RuntimeException('Die Schulungskonfiguration konnte nicht geladen werden.');
|
||||
}
|
||||
|
||||
try {
|
||||
$config = json_decode((string) $row['config'], true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException $exception) {
|
||||
throw new RuntimeException('Die gespeicherte Schulungskonfiguration ist beschädigt.', 0, $exception);
|
||||
}
|
||||
|
||||
return [
|
||||
'config' => brandschutz_course_normalize($config),
|
||||
'revision' => (int) $row['revision'],
|
||||
'updated_at' => isset($row['updated_at']) ? (string) $row['updated_at'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
function brandschutz_course_save(PDO $pdo, array $config, int $expectedRevision, int $userId): int
|
||||
{
|
||||
if ($expectedRevision < 1) {
|
||||
throw new InvalidArgumentException('Ungültiger Bearbeitungsstand. Bitte laden Sie die Seite neu.');
|
||||
}
|
||||
$normalized = brandschutz_course_normalize($config);
|
||||
$json = json_encode($normalized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||
|
||||
$statement = $pdo->prepare(
|
||||
'UPDATE brandschutz_course_config SET config = CAST(? AS JSONB), '
|
||||
. 'revision = revision + 1, updated_at = NOW(), updated_by = ? '
|
||||
. 'WHERE id = 1 AND revision = ? RETURNING revision'
|
||||
);
|
||||
$statement->execute([$json, $userId > 0 ? $userId : null, $expectedRevision]);
|
||||
$revision = $statement->fetchColumn();
|
||||
if ($revision === false) {
|
||||
throw new RuntimeException('Die Schulung wurde zwischenzeitlich geändert. Bitte laden Sie die Seite neu und prüfen Sie Ihre Änderungen.');
|
||||
}
|
||||
return (int) $revision;
|
||||
}
|
||||
|
||||
function brandschutz_course_string(mixed $value, int $maxLength, string $fallback = ''): string
|
||||
{
|
||||
$string = trim(is_scalar($value) ? (string) $value : '');
|
||||
if (mb_strlen($string, 'UTF-8') > $maxLength) {
|
||||
throw new InvalidArgumentException('Ein Inhalt überschreitet die maximal erlaubte Länge.');
|
||||
}
|
||||
return $string !== '' ? $string : $fallback;
|
||||
}
|
||||
|
||||
function brandschutz_course_id(mixed $value, string $prefix, array &$knownIds): string
|
||||
{
|
||||
$id = is_string($value) ? trim($value) : '';
|
||||
if (!preg_match('/^[a-zA-Z0-9_-]{1,80}$/', $id)) {
|
||||
$id = $prefix . '-' . bin2hex(random_bytes(8));
|
||||
}
|
||||
if (isset($knownIds[$id])) {
|
||||
throw new InvalidArgumentException('Die Konfiguration enthält doppelte interne IDs.');
|
||||
}
|
||||
$knownIds[$id] = true;
|
||||
return $id;
|
||||
}
|
||||
|
||||
function brandschutz_course_sanitize_html(mixed $value): string
|
||||
{
|
||||
$html = brandschutz_course_string($value, 30000);
|
||||
if ($html === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$document = new DOMDocument('1.0', 'UTF-8');
|
||||
$previous = libxml_use_internal_errors(true);
|
||||
$document->loadHTML(
|
||||
'<?xml encoding="UTF-8"><div id="brandschutz-sanitize-root">' . $html . '</div>',
|
||||
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
|
||||
);
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($previous);
|
||||
$root = $document->getElementById('brandschutz-sanitize-root');
|
||||
if (!$root instanceof DOMElement) {
|
||||
return htmlspecialchars(strip_tags($html), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
brandschutz_course_sanitize_children($root);
|
||||
$output = '';
|
||||
foreach ($root->childNodes as $child) {
|
||||
$output .= $document->saveHTML($child);
|
||||
}
|
||||
return trim($output);
|
||||
}
|
||||
|
||||
function brandschutz_course_sanitize_children(DOMNode $parent): void
|
||||
{
|
||||
$allowed = ['a', 'br', 'em', 'i', 'p', 'strong', 'b', 'u'];
|
||||
for ($node = $parent->firstChild; $node !== null;) {
|
||||
$next = $node->nextSibling;
|
||||
if ($node instanceof DOMComment) {
|
||||
$parent->removeChild($node);
|
||||
} elseif ($node instanceof DOMElement) {
|
||||
brandschutz_course_sanitize_children($node);
|
||||
$tag = strtolower($node->tagName);
|
||||
if (!in_array($tag, $allowed, true)) {
|
||||
while ($node->firstChild !== null) {
|
||||
$parent->insertBefore($node->firstChild, $node);
|
||||
}
|
||||
$parent->removeChild($node);
|
||||
} else {
|
||||
$href = $tag === 'a' ? trim($node->getAttribute('href')) : '';
|
||||
while ($node->attributes->length > 0) {
|
||||
$node->removeAttributeNode($node->attributes->item(0));
|
||||
}
|
||||
if ($tag === 'a' && brandschutz_course_safe_link($href)) {
|
||||
$node->setAttribute('href', $href);
|
||||
$node->setAttribute('target', '_blank');
|
||||
$node->setAttribute('rel', 'noopener noreferrer');
|
||||
} elseif ($tag === 'a') {
|
||||
while ($node->firstChild !== null) {
|
||||
$parent->insertBefore($node->firstChild, $node);
|
||||
}
|
||||
$parent->removeChild($node);
|
||||
}
|
||||
}
|
||||
}
|
||||
$node = $next;
|
||||
}
|
||||
}
|
||||
|
||||
function brandschutz_course_safe_link(string $href): bool
|
||||
{
|
||||
if ($href === '' || str_starts_with($href, '//')) {
|
||||
return false;
|
||||
}
|
||||
if (str_starts_with($href, '/')) {
|
||||
return !str_contains($href, "\n") && !str_contains($href, "\r");
|
||||
}
|
||||
$scheme = strtolower((string) parse_url($href, PHP_URL_SCHEME));
|
||||
return in_array($scheme, ['http', 'https', 'mailto'], true);
|
||||
}
|
||||
|
||||
function brandschutz_course_plain_list(mixed $value, int $limit = 100): array
|
||||
{
|
||||
if (!is_array($value) || count($value) > $limit) {
|
||||
if (is_array($value) && count($value) > $limit) {
|
||||
throw new InvalidArgumentException('Ein Element enthält zu viele Einträge.');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
$result = [];
|
||||
foreach ($value as $item) {
|
||||
$text = brandschutz_course_sanitize_html($item);
|
||||
if ($text !== '') {
|
||||
$result[] = $text;
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
function brandschutz_course_normalize(mixed $input): array
|
||||
{
|
||||
if (!is_array($input)) {
|
||||
throw new InvalidArgumentException('Die Schulungskonfiguration muss ein Objekt sein.');
|
||||
}
|
||||
$knownIds = [];
|
||||
$chaptersInput = $input['chapters'] ?? [];
|
||||
if (!is_array($chaptersInput) || count($chaptersInput) > 50) {
|
||||
throw new InvalidArgumentException('Die Schulung darf höchstens 50 Kapitel enthalten.');
|
||||
}
|
||||
|
||||
$settingsInput = is_array($input['settings'] ?? null) ? $input['settings'] : [];
|
||||
$passingScore = filter_var($settingsInput['passing_score'] ?? 70, FILTER_VALIDATE_INT);
|
||||
$maxAttempts = filter_var($settingsInput['max_attempts'] ?? 3, FILTER_VALIDATE_INT);
|
||||
$config = [
|
||||
'version' => BRANDSCHUTZ_COURSE_SCHEMA_VERSION,
|
||||
'title' => brandschutz_course_string($input['title'] ?? '', 160, 'Brandschutzunterweisung'),
|
||||
'settings' => [
|
||||
'passing_score' => $passingScore !== false ? max(1, min(100, (int) $passingScore)) : 70,
|
||||
'max_attempts' => $maxAttempts !== false ? max(1, min(10, (int) $maxAttempts)) : 3,
|
||||
'success_title' => brandschutz_course_string($settingsInput['success_title'] ?? '', 200, 'Schulung bestanden!'),
|
||||
'success_text' => brandschutz_course_sanitize_html($settingsInput['success_text'] ?? ''),
|
||||
'failure_title' => brandschutz_course_string($settingsInput['failure_title'] ?? '', 200, 'Schulung nicht bestanden'),
|
||||
'failure_text' => brandschutz_course_sanitize_html($settingsInput['failure_text'] ?? ''),
|
||||
'confirmation_text' => brandschutz_course_sanitize_html($settingsInput['confirmation_text'] ?? ''),
|
||||
],
|
||||
'chapters' => [],
|
||||
];
|
||||
|
||||
$pageCount = 0;
|
||||
$elementCount = 0;
|
||||
foreach ($chaptersInput as $chapterInput) {
|
||||
if (!is_array($chapterInput)) continue;
|
||||
$pagesInput = is_array($chapterInput['pages'] ?? null) ? $chapterInput['pages'] : [];
|
||||
$chapter = [
|
||||
'id' => brandschutz_course_id($chapterInput['id'] ?? '', 'chapter', $knownIds),
|
||||
'title' => brandschutz_course_string($chapterInput['title'] ?? '', 200, 'Unbenanntes Kapitel'),
|
||||
'short_title' => brandschutz_course_string($chapterInput['short_title'] ?? '', 80, 'Kapitel'),
|
||||
'pages' => [],
|
||||
];
|
||||
foreach ($pagesInput as $pageInput) {
|
||||
if (!is_array($pageInput)) continue;
|
||||
$pageCount++;
|
||||
if ($pageCount > 250) throw new InvalidArgumentException('Die Schulung darf höchstens 250 Seiten enthalten.');
|
||||
$elementsInput = is_array($pageInput['elements'] ?? null) ? $pageInput['elements'] : [];
|
||||
$page = [
|
||||
'id' => brandschutz_course_id($pageInput['id'] ?? '', 'page', $knownIds),
|
||||
'title' => brandschutz_course_string($pageInput['title'] ?? '', 200, 'Unbenannte Seite'),
|
||||
'elements' => [],
|
||||
];
|
||||
foreach ($elementsInput as $elementInput) {
|
||||
if (!is_array($elementInput)) continue;
|
||||
$elementCount++;
|
||||
if ($elementCount > 2000) throw new InvalidArgumentException('Die Schulung darf höchstens 2.000 Elemente enthalten.');
|
||||
$page['elements'][] = brandschutz_course_normalize_element($elementInput, $knownIds);
|
||||
}
|
||||
$chapter['pages'][] = $page;
|
||||
}
|
||||
$config['chapters'][] = $chapter;
|
||||
}
|
||||
return $config;
|
||||
}
|
||||
|
||||
function brandschutz_course_normalize_element(array $input, array &$knownIds): array
|
||||
{
|
||||
$types = ['text', 'infobox', 'warnbox', 'list', 'accordion', 'checklist', 'scenario', 'multiple_choice', 'fill_blank', 'matching', 'image'];
|
||||
$type = is_string($input['type'] ?? null) ? $input['type'] : '';
|
||||
if (!in_array($type, $types, true)) {
|
||||
throw new InvalidArgumentException('Die Konfiguration enthält einen unbekannten Elementtyp.');
|
||||
}
|
||||
$base = ['id' => brandschutz_course_id($input['id'] ?? '', 'element', $knownIds), 'type' => $type];
|
||||
|
||||
if ($type === 'text') {
|
||||
$base['content'] = brandschutz_course_sanitize_html($input['content'] ?? '');
|
||||
} elseif (in_array($type, ['infobox', 'warnbox', 'scenario'], true)) {
|
||||
$base['title'] = brandschutz_course_string($input['title'] ?? '', 200);
|
||||
$base['content'] = brandschutz_course_sanitize_html($input['content'] ?? '');
|
||||
} elseif (in_array($type, ['list', 'checklist'], true)) {
|
||||
if ($type === 'list') $base['ordered'] = !empty($input['ordered']);
|
||||
$base['items'] = brandschutz_course_plain_list($input['items'] ?? []);
|
||||
} elseif ($type === 'accordion') {
|
||||
$base['items'] = [];
|
||||
$items = is_array($input['items'] ?? null) ? $input['items'] : [];
|
||||
if (count($items) > 100) throw new InvalidArgumentException('Ein Akkordeon enthält zu viele Einträge.');
|
||||
foreach ($items as $item) {
|
||||
if (!is_array($item)) continue;
|
||||
$base['items'][] = [
|
||||
'title' => brandschutz_course_string($item['title'] ?? '', 200, 'Abschnitt'),
|
||||
'content' => brandschutz_course_sanitize_html($item['content'] ?? ''),
|
||||
];
|
||||
}
|
||||
} elseif ($type === 'multiple_choice') {
|
||||
$base += brandschutz_course_assessment_fields($input);
|
||||
$base['answers'] = [];
|
||||
$answers = is_array($input['answers'] ?? null) ? $input['answers'] : [];
|
||||
if (count($answers) > 50) throw new InvalidArgumentException('Eine Frage enthält zu viele Antworten.');
|
||||
foreach ($answers as $answer) {
|
||||
if (!is_array($answer)) continue;
|
||||
$text = brandschutz_course_string($answer['text'] ?? '', 1000);
|
||||
if ($text !== '') $base['answers'][] = ['text' => $text, 'correct' => !empty($answer['correct'])];
|
||||
}
|
||||
if ($base['answers'] === [] || !array_filter($base['answers'], static fn(array $answer): bool => $answer['correct'])) {
|
||||
throw new InvalidArgumentException('Jede Multiple-Choice-Frage benötigt Antworten und mindestens eine korrekte Lösung.');
|
||||
}
|
||||
} elseif ($type === 'fill_blank') {
|
||||
$base += brandschutz_course_assessment_fields($input);
|
||||
$base['text'] = brandschutz_course_string($input['text'] ?? '', 10000);
|
||||
$base['blanks'] = [];
|
||||
$blanks = is_array($input['blanks'] ?? null) ? $input['blanks'] : [];
|
||||
if (count($blanks) > 50) throw new InvalidArgumentException('Ein Lückentext enthält zu viele Lücken.');
|
||||
foreach ($blanks as $blank) {
|
||||
if (!is_array($blank)) continue;
|
||||
$answers = [];
|
||||
foreach (($blank['answers'] ?? []) as $answer) {
|
||||
$text = brandschutz_course_string($answer, 300);
|
||||
if ($text !== '') $answers[] = $text;
|
||||
}
|
||||
if ($answers !== []) $base['blanks'][] = ['answers' => array_values(array_unique($answers))];
|
||||
}
|
||||
if ($base['blanks'] === []) throw new InvalidArgumentException('Ein Lückentext benötigt mindestens eine erwartete Lösung.');
|
||||
foreach (array_keys($base['blanks']) as $index) {
|
||||
if (!str_contains($base['text'], '{{' . ($index + 1) . '}}')) {
|
||||
throw new InvalidArgumentException('Der Lückentext enthält nicht für jede Lösung einen Platzhalter wie {{1}}.');
|
||||
}
|
||||
}
|
||||
} elseif ($type === 'matching') {
|
||||
$base += brandschutz_course_assessment_fields($input);
|
||||
$base['choices'] = [];
|
||||
foreach ((is_array($input['choices'] ?? null) ? $input['choices'] : []) as $choice) {
|
||||
if (!is_array($choice)) continue;
|
||||
$value = brandschutz_course_string($choice['value'] ?? '', 80);
|
||||
$label = brandschutz_course_string($choice['label'] ?? '', 300);
|
||||
if ($value !== '' && $label !== '') $base['choices'][] = ['value' => $value, 'label' => $label];
|
||||
}
|
||||
$validChoices = array_column($base['choices'], 'value');
|
||||
$base['pairs'] = [];
|
||||
foreach ((is_array($input['pairs'] ?? null) ? $input['pairs'] : []) as $pair) {
|
||||
if (!is_array($pair)) continue;
|
||||
$item = brandschutz_course_string($pair['item'] ?? '', 500);
|
||||
$correct = brandschutz_course_string($pair['correct'] ?? '', 80);
|
||||
if ($item !== '' && in_array($correct, $validChoices, true)) $base['pairs'][] = ['item' => $item, 'correct' => $correct];
|
||||
}
|
||||
if ($base['choices'] === [] || $base['pairs'] === []) throw new InvalidArgumentException('Eine Zuordnung benötigt Auswahlmöglichkeiten und Aufgaben.');
|
||||
} elseif ($type === 'image') {
|
||||
$file = basename(brandschutz_course_string($input['file'] ?? '', 255));
|
||||
if ($file !== '' && !preg_match('/^[a-f0-9]{32}\.(?:jpg|jpeg|png|webp|gif)$/i', $file)) {
|
||||
throw new InvalidArgumentException('Ein Bildelement verweist auf eine ungültige Datei.');
|
||||
}
|
||||
$base['file'] = $file;
|
||||
$base['alt'] = brandschutz_course_string($input['alt'] ?? '', 500);
|
||||
$base['caption'] = brandschutz_course_string($input['caption'] ?? '', 1000);
|
||||
}
|
||||
return $base;
|
||||
}
|
||||
|
||||
function brandschutz_course_assessment_fields(array $input): array
|
||||
{
|
||||
return [
|
||||
'question' => brandschutz_course_string($input['question'] ?? '', 2000, 'Frage'),
|
||||
'feedback_correct' => brandschutz_course_string($input['feedback_correct'] ?? '', 3000, 'Richtig!'),
|
||||
'feedback_incorrect' => brandschutz_course_string($input['feedback_incorrect'] ?? '', 3000, 'Leider nicht richtig.'),
|
||||
'score' => !array_key_exists('score', $input) || !empty($input['score']),
|
||||
];
|
||||
}
|
||||
|
||||
function brandschutz_media_list(): array
|
||||
{
|
||||
$directory = brandschutz_media_directory();
|
||||
if (!is_dir($directory)) return [];
|
||||
$files = [];
|
||||
foreach (scandir($directory) ?: [] as $file) {
|
||||
if (!preg_match('/^[a-f0-9]{32}\.(?:jpg|jpeg|png|webp|gif)$/i', $file)) continue;
|
||||
$path = $directory . DIRECTORY_SEPARATOR . $file;
|
||||
if (!is_file($path)) continue;
|
||||
$files[] = ['file' => $file, 'url' => BRANDSCHUTZ_MEDIA_URL . '/' . rawurlencode($file), 'size' => filesize($path) ?: 0, 'modified' => filemtime($path) ?: 0];
|
||||
}
|
||||
usort($files, static fn(array $a, array $b): int => $b['modified'] <=> $a['modified']);
|
||||
return $files;
|
||||
}
|
||||
|
||||
function brandschutz_media_upload(array $upload): array
|
||||
{
|
||||
if (($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || !is_uploaded_file((string) ($upload['tmp_name'] ?? ''))) {
|
||||
throw new InvalidArgumentException('Das Bild konnte nicht hochgeladen werden.');
|
||||
}
|
||||
$size = (int) ($upload['size'] ?? 0);
|
||||
if ($size <= 0 || $size > BRANDSCHUTZ_MEDIA_MAX_BYTES) {
|
||||
throw new InvalidArgumentException('Das Bild darf höchstens 8 MB groß sein.');
|
||||
}
|
||||
$temporaryPath = (string) $upload['tmp_name'];
|
||||
$imageInfo = @getimagesize($temporaryPath);
|
||||
$mime = $imageInfo['mime'] ?? '';
|
||||
$extensions = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif'];
|
||||
if (!isset($extensions[$mime])) {
|
||||
throw new InvalidArgumentException('Erlaubt sind JPEG-, PNG-, WebP- und GIF-Bilder.');
|
||||
}
|
||||
if (($imageInfo[0] ?? 0) > 8000 || ($imageInfo[1] ?? 0) > 8000) {
|
||||
throw new InvalidArgumentException('Das Bild darf höchstens 8.000 × 8.000 Pixel groß sein.');
|
||||
}
|
||||
$directory = brandschutz_media_directory();
|
||||
if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) {
|
||||
throw new RuntimeException('Der Bildspeicher ist nicht verfügbar.');
|
||||
}
|
||||
$filename = bin2hex(random_bytes(16)) . '.' . $extensions[$mime];
|
||||
if (!move_uploaded_file($temporaryPath, $directory . DIRECTORY_SEPARATOR . $filename)) {
|
||||
throw new RuntimeException('Das Bild konnte nicht gespeichert werden.');
|
||||
}
|
||||
return ['file' => $filename, 'url' => BRANDSCHUTZ_MEDIA_URL . '/' . rawurlencode($filename), 'size' => $size, 'modified' => time()];
|
||||
}
|
||||
|
||||
function brandschutz_course_uses_image(array $config, string $filename): bool
|
||||
{
|
||||
foreach ($config['chapters'] ?? [] as $chapter) {
|
||||
foreach ($chapter['pages'] ?? [] as $page) {
|
||||
foreach ($page['elements'] ?? [] as $element) {
|
||||
if (($element['type'] ?? '') === 'image' && ($element['file'] ?? '') === $filename) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function brandschutz_media_delete(string $filename, array $config): void
|
||||
{
|
||||
$filename = basename($filename);
|
||||
if (!preg_match('/^[a-f0-9]{32}\.(?:jpg|jpeg|png|webp|gif)$/i', $filename)) {
|
||||
throw new InvalidArgumentException('Ungültige Bilddatei.');
|
||||
}
|
||||
if (brandschutz_course_uses_image($config, $filename)) {
|
||||
throw new InvalidArgumentException('Das Bild wird noch in der Schulung verwendet und kann nicht gelöscht werden.');
|
||||
}
|
||||
$path = brandschutz_media_directory() . DIRECTORY_SEPARATOR . $filename;
|
||||
if (is_file($path) && !unlink($path)) {
|
||||
throw new RuntimeException('Das Bild konnte nicht gelöscht werden.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/brandschutz_course.php';
|
||||
|
||||
function brandschutz_h(string $value): string
|
||||
{
|
||||
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
function brandschutz_render_element(array $element): void
|
||||
{
|
||||
$type = (string) ($element['type'] ?? '');
|
||||
$id = brandschutz_h((string) ($element['id'] ?? ''));
|
||||
|
||||
if ($type === 'text') {
|
||||
echo '<div class="bs-text">' . brandschutz_course_sanitize_html($element['content'] ?? '') . '</div>';
|
||||
return;
|
||||
}
|
||||
if ($type === 'infobox' || $type === 'warnbox') {
|
||||
$class = $type === 'warnbox' ? 'bs-warnbox' : 'bs-infobox';
|
||||
echo '<div class="' . $class . '">';
|
||||
if (($element['title'] ?? '') !== '') echo '<div class="' . $class . '-title">' . brandschutz_h((string) $element['title']) . '</div>';
|
||||
echo brandschutz_course_sanitize_html($element['content'] ?? '') . '</div>';
|
||||
return;
|
||||
}
|
||||
if ($type === 'list') {
|
||||
$tag = !empty($element['ordered']) ? 'ol' : 'ul';
|
||||
$class = $tag === 'ol' ? 'bs-list-numbered' : 'bs-list';
|
||||
echo '<' . $tag . ' class="' . $class . '">';
|
||||
foreach ($element['items'] ?? [] as $item) echo '<li>' . brandschutz_course_sanitize_html($item) . '</li>';
|
||||
echo '</' . $tag . '>';
|
||||
return;
|
||||
}
|
||||
if ($type === 'accordion') {
|
||||
foreach ($element['items'] ?? [] as $item) {
|
||||
echo '<div class="bs-accordion"><button type="button" class="bs-accordion-header" aria-expanded="false">';
|
||||
echo '<span>' . brandschutz_h((string) ($item['title'] ?? '')) . '</span><span class="bs-accordion-arrow" aria-hidden="true">▼</span></button>';
|
||||
echo '<div class="bs-accordion-content">' . brandschutz_course_sanitize_html($item['content'] ?? '') . '</div></div>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if ($type === 'checklist') {
|
||||
echo '<ul class="bs-checklist" data-checklist="true">';
|
||||
foreach ($element['items'] ?? [] as $item) {
|
||||
echo '<li><button type="button" class="bs-checklist-item"><span class="bs-check-box" aria-hidden="true">●</span><span>';
|
||||
echo brandschutz_course_sanitize_html($item) . '</span></button></li>';
|
||||
}
|
||||
echo '</ul>';
|
||||
return;
|
||||
}
|
||||
if ($type === 'scenario') {
|
||||
echo '<div class="bs-scenario"><div class="bs-scenario-header">' . brandschutz_h((string) ($element['title'] ?? '')) . '</div>';
|
||||
echo '<div class="bs-scenario-body"><div class="bs-scenario-text">' . brandschutz_course_sanitize_html($element['content'] ?? '') . '</div></div></div>';
|
||||
return;
|
||||
}
|
||||
if ($type === 'multiple_choice') {
|
||||
brandschutz_render_assessment_start($element, $id, 'multiple_choice');
|
||||
echo '<div class="bs-quiz-options">';
|
||||
foreach ($element['answers'] ?? [] as $answer) {
|
||||
echo '<label class="bs-quiz-option"><input type="checkbox" data-correct="' . (!empty($answer['correct']) ? 'true' : 'false') . '">';
|
||||
echo '<span class="bs-quiz-indicator" aria-hidden="true">●</span><span>' . brandschutz_h((string) ($answer['text'] ?? '')) . '</span></label>';
|
||||
}
|
||||
echo '</div>';
|
||||
brandschutz_render_assessment_end($id);
|
||||
return;
|
||||
}
|
||||
if ($type === 'fill_blank') {
|
||||
brandschutz_render_assessment_start($element, $id, 'fill_blank');
|
||||
echo '<div class="bs-text bs-fill-text">';
|
||||
$parts = preg_split('/(\{\{\d+\}\})/', (string) ($element['text'] ?? ''), -1, PREG_SPLIT_DELIM_CAPTURE) ?: [];
|
||||
foreach ($parts as $part) {
|
||||
if (preg_match('/^\{\{(\d+)\}\}$/', $part, $match)) {
|
||||
$index = (int) $match[1] - 1;
|
||||
$answers = $element['blanks'][$index]['answers'] ?? [];
|
||||
$answerJson = json_encode(array_values($answers), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||
echo '<input type="text" class="bs-fill-input" autocomplete="off" aria-label="Lücke ' . ($index + 1) . '" data-answers="' . brandschutz_h($answerJson) . '">';
|
||||
} else {
|
||||
echo nl2br(brandschutz_h($part));
|
||||
}
|
||||
}
|
||||
echo '</div>';
|
||||
brandschutz_render_assessment_end($id);
|
||||
return;
|
||||
}
|
||||
if ($type === 'matching') {
|
||||
brandschutz_render_assessment_start($element, $id, 'matching');
|
||||
echo '<div class="bs-dropdown-match">';
|
||||
foreach ($element['pairs'] ?? [] as $pair) {
|
||||
echo '<div class="bs-dropdown-match-row"><span class="bs-dropdown-match-label">' . brandschutz_h((string) ($pair['item'] ?? '')) . '</span>';
|
||||
echo '<select class="bs-dropdown-match-select" data-correct="' . brandschutz_h((string) ($pair['correct'] ?? '')) . '"><option value="">-- Bitte wählen --</option>';
|
||||
foreach ($element['choices'] ?? [] as $choice) {
|
||||
echo '<option value="' . brandschutz_h((string) ($choice['value'] ?? '')) . '">' . brandschutz_h((string) ($choice['label'] ?? '')) . '</option>';
|
||||
}
|
||||
echo '</select></div>';
|
||||
}
|
||||
echo '</div>';
|
||||
brandschutz_render_assessment_end($id);
|
||||
return;
|
||||
}
|
||||
if ($type === 'image') {
|
||||
$file = (string) ($element['file'] ?? '');
|
||||
if ($file === '') return;
|
||||
$path = brandschutz_media_directory() . DIRECTORY_SEPARATOR . $file;
|
||||
if (!is_file($path)) {
|
||||
echo '<div class="bs-warnbox"><div class="bs-warnbox-title">Bild nicht verfügbar</div>Das hinterlegte Bild konnte nicht geladen werden.</div>';
|
||||
return;
|
||||
}
|
||||
$url = BRANDSCHUTZ_MEDIA_URL . '/' . rawurlencode($file);
|
||||
echo '<figure class="bs-image"><img src="' . brandschutz_h($url) . '" alt="' . brandschutz_h((string) ($element['alt'] ?? '')) . '" loading="lazy">';
|
||||
if (($element['caption'] ?? '') !== '') echo '<figcaption>' . brandschutz_h((string) $element['caption']) . '</figcaption>';
|
||||
echo '</figure>';
|
||||
}
|
||||
}
|
||||
|
||||
function brandschutz_render_assessment_start(array $element, string $escapedId, string $kind): void
|
||||
{
|
||||
echo '<div class="bs-quiz bs-assessment" id="' . $escapedId . '" data-assessment-kind="' . $kind . '" data-score="' . (!empty($element['score']) ? 'true' : 'false') . '"';
|
||||
echo ' data-feedback-correct="' . brandschutz_h((string) ($element['feedback_correct'] ?? 'Richtig!')) . '"';
|
||||
echo ' data-feedback-incorrect="' . brandschutz_h((string) ($element['feedback_incorrect'] ?? 'Leider nicht richtig.')) . '">';
|
||||
echo '<div class="bs-quiz-question">' . brandschutz_h((string) ($element['question'] ?? '')) . '</div>';
|
||||
}
|
||||
|
||||
function brandschutz_render_assessment_end(string $escapedId): void
|
||||
{
|
||||
echo '<div class="bs-quiz-feedback" aria-live="polite"></div><div class="bs-quiz-btn-group">';
|
||||
echo '<button type="button" class="bs-btn bs-btn-primary bs-check-assessment" data-assessment-id="' . $escapedId . '">Antwort prüfen</button>';
|
||||
echo '</div></div>';
|
||||
}
|
||||
Reference in New Issue
Block a user