Some checks are pending
CI / PHP Lint (push) Waiting to run
CI / PHP Lint (pull_request) Waiting to run
CI / PHP Lint-1 (pull_request) Waiting to run
CI / PHP Lint-1 (push) Waiting to run
CI / Unit Tests & Static Analysis (pull_request) Waiting to run
CI / Unit Tests & Static Analysis (push) Waiting to run
Jede Display-Seite bringt jetzt ihr eigenes Erscheinungsbild mit – der Empfang sieht anders aus als der Tagungsraum nebenan: - eigenes Logo (leer = Logo aus den Einstellungen) - formatfüllendes Hintergrundbild mit einstellbarer Abdunklung (0–90 %), damit die Karte auf hellen Fotos lesbar bleibt - eigene Akzentfarbe für den Knopf (leer = Farbe aus dem Design-Tab) - Karte wahlweise hell oder dunkel; auf Fotos wirkt dunkel meist ruhiger Logo und Hintergrund lassen sich hochladen oder als URL hinterlegen; beim Löschen einer Display-Seite verschwinden die hochgeladenen Dateien mit. Nebenbei aufgeräumt: das Bildfeld (Vorschau + Upload + URL + Entfernen) lag als Funktion in admin/settings.php und wird jetzt von beiden Seiten genutzt – Ui::imageField() für die Darstellung, Upload::resolveField() für die Auswertung. Sicherheit: die Akzentfarbe landet in einem style-Attribut, deshalb wird sie sowohl beim Speichern als auch beim Ausgeben auf eine echte Hex-Farbe geprüft; ein Test hält das fest. Migration 0006, database.sql nachgezogen, 4 neue Tests (42 gesamt), Screenshots ergänzt, Version 2.8.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
169 lines
6 KiB
PHP
169 lines
6 KiB
PHP
<?php
|
||
/**
|
||
* Öffentliche Display-Seiten ("Kiosk").
|
||
*
|
||
* Ein Kiosk gehört zu genau einer Site und ist über einen geheimen Link
|
||
* erreichbar. Gäste holen sich darüber mit einem Klick einen Zugangscode –
|
||
* ohne Anmeldung, aber begrenzt durch Tageslimit und Wartezeit.
|
||
*/
|
||
class Kiosk
|
||
{
|
||
/** Vorgaben für neue Kiosk-Seiten. */
|
||
public const DEFAULT_DAILY_LIMIT = 100;
|
||
public const DEFAULT_COOLDOWN = 20; // Sekunden zwischen zwei Codes
|
||
public const DEFAULT_DISPLAY_SECONDS = 90; // Anzeigedauer des Codes
|
||
|
||
/** Unrat-freier Zufallstoken für den öffentlichen Link. */
|
||
public static function newToken(): string
|
||
{
|
||
return bin2hex(random_bytes(16));
|
||
}
|
||
|
||
/** Token aus einer Anfrage säubern (Länge und Zeichen fest vorgegeben). */
|
||
public static function sanitizeToken(?string $token): string
|
||
{
|
||
$token = strtolower(trim((string)$token));
|
||
|
||
return preg_match('/^[0-9a-f]{32}$/', $token) ? $token : '';
|
||
}
|
||
|
||
/**
|
||
* Kiosk samt Site und Profil laden. Liefert null, wenn der Token nicht
|
||
* passt, der Kiosk deaktiviert ist oder die Site nicht mehr aktiv ist.
|
||
*/
|
||
public static function findByToken($db, string $token): ?array
|
||
{
|
||
$token = self::sanitizeToken($token);
|
||
if ($token === '') {
|
||
return null;
|
||
}
|
||
|
||
$row = $db->fetchOne(
|
||
"SELECT k.*, s.name AS site_name, s.is_active AS site_active,
|
||
t.name AS template_name, t.max_uses AS tpl_max_uses, t.expire_minutes AS tpl_expire_minutes,
|
||
t.qos_rate_max_down, t.qos_rate_max_up, t.qos_usage_quota
|
||
FROM kiosks k
|
||
INNER JOIN sites s ON s.id = k.site_id
|
||
LEFT JOIN voucher_templates t ON t.id = k.template_id
|
||
WHERE k.token = ? AND k.is_active = 1",
|
||
[$token]
|
||
);
|
||
|
||
if (!$row || (int)$row['site_active'] !== 1) {
|
||
return null;
|
||
}
|
||
|
||
return $row;
|
||
}
|
||
|
||
/** Wie viele Codes hat dieser Kiosk heute schon ausgegeben? */
|
||
public static function usedToday($db, int $kioskId): int
|
||
{
|
||
$row = $db->fetchOne(
|
||
"SELECT COUNT(*) AS c FROM vouchers WHERE kiosk_id = ? AND DATE(created_at) = CURDATE()",
|
||
[$kioskId]
|
||
);
|
||
|
||
return (int)($row['c'] ?? 0);
|
||
}
|
||
|
||
/**
|
||
* Darf gerade ein Code geholt werden?
|
||
*
|
||
* @return array{allowed:bool,reason:string,wait:int}
|
||
* reason: '' | 'cooldown' | 'daily_limit'
|
||
*/
|
||
public static function checkLimits($db, array $kiosk): array
|
||
{
|
||
$cooldown = max(0, (int)$kiosk['cooldown_seconds']);
|
||
if ($cooldown > 0 && !empty($kiosk['last_used_at'])) {
|
||
$elapsed = time() - strtotime((string)$kiosk['last_used_at']);
|
||
if ($elapsed >= 0 && $elapsed < $cooldown) {
|
||
return ['allowed' => false, 'reason' => 'cooldown', 'wait' => $cooldown - $elapsed];
|
||
}
|
||
}
|
||
|
||
$limit = max(0, (int)$kiosk['daily_limit']);
|
||
if ($limit > 0 && self::usedToday($db, (int)$kiosk['id']) >= $limit) {
|
||
return ['allowed' => false, 'reason' => 'daily_limit', 'wait' => 0];
|
||
}
|
||
|
||
return ['allowed' => true, 'reason' => '', 'wait' => 0];
|
||
}
|
||
|
||
/** Nach erfolgreicher Ausgabe den Zeitstempel fortschreiben. */
|
||
public static function markUsed($db, int $kioskId): void
|
||
{
|
||
$db->execute("UPDATE kiosks SET last_used_at = NOW() WHERE id = ?", [$kioskId]);
|
||
}
|
||
|
||
/**
|
||
* Voucher-Eckdaten eines Kiosks: entweder aus dem verknüpften Profil
|
||
* oder aus den globalen Standardwerten.
|
||
*/
|
||
public static function voucherSettings($db, array $kiosk): array
|
||
{
|
||
$maxUses = (int)($kiosk['tpl_max_uses'] ?? 0);
|
||
$expire = (int)($kiosk['tpl_expire_minutes'] ?? 0);
|
||
|
||
if ($maxUses < 1) {
|
||
$maxUses = max(1, (int)$db->getSetting('default_max_uses', 1));
|
||
}
|
||
if ($expire < 1) {
|
||
$expire = max(1, (int)$db->getSetting('default_expire_minutes', 480));
|
||
}
|
||
|
||
return [
|
||
'max_uses' => $maxUses,
|
||
'expire_minutes' => $expire,
|
||
'qos' => [
|
||
'down' => max(0, (int)($kiosk['qos_rate_max_down'] ?? 0)),
|
||
'up' => max(0, (int)($kiosk['qos_rate_max_up'] ?? 0)),
|
||
'quota_mb' => max(0, (int)($kiosk['qos_usage_quota'] ?? 0)),
|
||
],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Gestaltung einer Display-Seite: eigene Werte, sonst die des Systems.
|
||
*
|
||
* @return array{logo:string,background:string,overlay:float,accent:string,card:string}
|
||
*/
|
||
public static function appearance($db, array $kiosk): array
|
||
{
|
||
$accent = strtolower(trim((string)($kiosk['accent_color'] ?? '')));
|
||
if (!preg_match('/^#[0-9a-f]{6}$/', $accent)) {
|
||
$accent = '';
|
||
}
|
||
|
||
$overlay = (int)($kiosk['bg_overlay'] ?? 45);
|
||
$overlay = max(0, min(90, $overlay));
|
||
|
||
$logo = trim((string)($kiosk['logo_url'] ?? ''));
|
||
if ($logo === '' && $db) {
|
||
$logo = (string)$db->getSetting('logo_url', '');
|
||
}
|
||
|
||
return [
|
||
'logo' => $logo,
|
||
'background' => trim((string)($kiosk['background_url'] ?? '')),
|
||
'overlay' => (float)$overlay / 100, // immer float, auch bei 0
|
||
'accent' => $accent,
|
||
'card' => ($kiosk['card_style'] ?? 'light') === 'dark' ? 'dark' : 'light',
|
||
];
|
||
}
|
||
|
||
/** Öffentliche Adresse eines Kiosks. */
|
||
public static function publicUrl(string $token, string $baseUrl = ''): string
|
||
{
|
||
if ($baseUrl === '') {
|
||
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||
$path = dirname($_SERVER['SCRIPT_NAME'] ?? '/', 2);
|
||
$path = $path === '/' || $path === '\\' ? '' : $path;
|
||
$baseUrl = $protocol . '://' . $host . $path;
|
||
}
|
||
|
||
return rtrim($baseUrl, '/') . '/kiosk.php?k=' . $token;
|
||
}
|
||
}
|