diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 7e20944..0000000 --- a/.gitattributes +++ /dev/null @@ -1,15 +0,0 @@ -# Zeilenenden nicht anfassen – einige Dateien liegen bewusst mit CRLF vor. -* -text - -# Dateien, die nicht ins Release-ZIP gehoeren. -# `git archive` (siehe .github/workflows/release.yml) wertet export-ignore aus. -/.gitattributes export-ignore -/.gitignore export-ignore -/.dockerignore export-ignore -/.github export-ignore -/docs export-ignore -/tests export-ignore -/tools export-ignore -/phpunit.xml.dist export-ignore -/phpstan.neon export-ignore -/composer.lock export-ignore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 3fc3001..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,152 +0,0 @@ -name: Release-Paket - -# Bei jedem Merge nach main entsteht ein installierbares ZIP und landet als -# Vorab-Release "latest-main" im Repository. Wird ein Tag v* gepusht, wird -# daraus ein regulaeres Release mit derselben Mechanik. -on: - push: - branches: [ main ] - tags: [ 'v*' ] - workflow_dispatch: - -jobs: - package: - name: ZIP bauen und veröffentlichen - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Version und Dateinamen bestimmen - id: meta - run: | - set -eu - VERSION="$(tr -d ' \r\n' < VERSION)" - SHORT_SHA="$(git rev-parse --short HEAD)" - BUILD_DATE="$(date -u +%Y-%m-%d)" - - if [ "${GITHUB_REF_TYPE:-branch}" = "tag" ]; then - TAG="${GITHUB_REF_NAME}" - NAME="unifi-voucher-tool-${TAG}" - TITLE="Version ${TAG}" - PRERELEASE="false" - else - TAG="latest-main" - NAME="unifi-voucher-tool-${VERSION}+${BUILD_DATE}.${SHORT_SHA}" - TITLE="Aktueller Stand von main – ${VERSION} (${SHORT_SHA})" - PRERELEASE="true" - fi - - { - echo "version=${VERSION}" - echo "short_sha=${SHORT_SHA}" - echo "build_date=${BUILD_DATE}" - echo "tag=${TAG}" - echo "name=${NAME}" - echo "title=${TITLE}" - echo "prerelease=${PRERELEASE}" - } >> "$GITHUB_OUTPUT" - - - name: ZIP erzeugen - run: | - set -eu - mkdir -p dist - # git archive wertet die export-ignore-Regeln aus .gitattributes aus, - # docs/, tests/, tools/ und CI-Dateien bleiben also draußen. - git archive --format=zip -9 \ - --prefix="unifi-voucher-tool/" \ - -o "dist/${{ steps.meta.outputs.name }}.zip" HEAD - cd dist - sha256sum "${{ steps.meta.outputs.name }}.zip" > "${{ steps.meta.outputs.name }}.zip.sha256" - ls -lh - - - name: Inhalt kurz prüfen - run: | - set -eu - # Ein paar Dateien muessen enthalten sein, sonst ist das Paket kaputt. - for required in \ - unifi-voucher-tool/index.php \ - unifi-voucher-tool/install.php \ - unifi-voucher-tool/database.sql \ - unifi-voucher-tool/assets/global.css \ - unifi-voucher-tool/assets/vendor/inter/inter.css \ - unifi-voucher-tool/includes/Ui.php - do - if ! unzip -l "dist/${{ steps.meta.outputs.name }}.zip" | grep -q "$required"; then - echo "Fehlt im Paket: $required" >&2 - exit 1 - fi - done - echo "Paket vollständig." - - - name: Als Build-Artefakt sichern - uses: actions/upload-artifact@v3 - continue-on-error: true # Artefakt-Speicher ist optional - with: - name: ${{ steps.meta.outputs.name }} - path: dist/* - retention-days: 30 - - - name: Release anlegen bzw. auffrischen - env: - # Forgejo stellt den Token automatisch bereit; FORGEJO_TOKEN - # (persönlicher Token) dient als Ausweichweg. - TOKEN: ${{ secrets.GITHUB_TOKEN || secrets.FORGEJO_TOKEN }} - API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} - TAG: ${{ steps.meta.outputs.tag }} - NAME: ${{ steps.meta.outputs.name }} - TITLE: ${{ steps.meta.outputs.title }} - PRERELEASE: ${{ steps.meta.outputs.prerelease }} - VERSION: ${{ steps.meta.outputs.version }} - SHORT_SHA: ${{ steps.meta.outputs.short_sha }} - BUILD_DATE: ${{ steps.meta.outputs.build_date }} - run: | - set -eu - if [ -z "${TOKEN:-}" ]; then - echo "Kein Token vorhanden – Release wird übersprungen." >&2 - exit 0 - fi - AUTH="Authorization: token ${TOKEN}" - - # Rollendes Vorab-Release durch ein frisches ersetzen, damit der - # Download-Link stabil bleibt und auf den aktuellen Stand zeigt. - if [ "$TAG" = "latest-main" ]; then - OLD_ID="$(curl -sf -H "$AUTH" "${API}/releases/tags/${TAG}" \ - | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)" - if [ -n "${OLD_ID:-}" ]; then - curl -sf -X DELETE -H "$AUTH" "${API}/releases/${OLD_ID}" || true - curl -sf -X DELETE -H "$AUTH" "${API}/tags/${TAG}" || true - fi - fi - - # Release-Text bewusst ohne Anfuehrungszeichen und Backslashes, - # damit er ohne jq direkt in den JSON-Body passt (\n bleibt literal). - BODY="Automatisch gebaut aus Commit ${SHORT_SHA}.\n\n" - BODY="${BODY}| | |\n|---|---|\n" - BODY="${BODY}| Version | ${VERSION} |\n" - BODY="${BODY}| Commit | ${SHORT_SHA} |\n" - BODY="${BODY}| Gebaut am | ${BUILD_DATE} |\n\n" - BODY="${BODY}**Neuinstallation:** ZIP entpacken, Dateien auf den Webserver legen, install.php aufrufen.\n\n" - BODY="${BODY}**Update einer bestehenden Installation:** config.php, uploads/ und updater/storage/ nicht ueberschreiben " - BODY="${BODY}- oder gleich den eingebauten Updater unter Administration, System-Update verwenden.\n\n" - BODY="${BODY}Pruefsumme: siehe beigelegte .sha256-Datei." - - RELEASE_ID="$(curl -sf -X POST -H "$AUTH" -H 'Content-Type: application/json' \ - -d "{\"tag_name\":\"${TAG}\",\"target_commitish\":\"${GITHUB_SHA}\",\"name\":\"${TITLE}\",\"body\":\"${BODY}\",\"draft\":false,\"prerelease\":${PRERELEASE}}" \ - "${API}/releases" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)" - - if [ -z "${RELEASE_ID:-}" ]; then - echo "Release konnte nicht angelegt werden." >&2 - exit 1 - fi - - for file in "dist/${NAME}.zip" "dist/${NAME}.zip.sha256"; do - curl -sf -X POST -H "$AUTH" \ - -F "attachment=@${file}" \ - "${API}/releases/${RELEASE_ID}/assets?name=$(basename "$file")" > /dev/null - echo "Angehängt: $(basename "$file")" - done - - echo "Release ${TITLE} steht bereit: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${TAG}" diff --git a/Readme.md b/Readme.md index 46a8a84..89353eb 100644 --- a/Readme.md +++ b/Readme.md @@ -10,7 +10,7 @@ Entwickelt von **[Loheide.eu](https://loheide.eu)** ![MySQL](https://img.shields.io/badge/MySQL-5.7%2B%20%2F%20MariaDB-4479A1?logo=mysql&logoColor=white) ![UniFi OS](https://img.shields.io/badge/UniFi%20OS-7.0%2B-0559C9?logo=ubiquiti&logoColor=white) ![License](https://img.shields.io/badge/Lizenz-MIT-green) -![Version](https://img.shields.io/badge/Version-2.8.0-blueviolet) +![Version](https://img.shields.io/badge/Version-2.6.0-blueviolet) ![Tests](https://img.shields.io/badge/Tests-PHPUnit%20%2B%20PHPStan-brightgreen) [![Repository](https://img.shields.io/badge/Code-git.loheide.cloud-4b3ec4)](https://git.loheide.cloud/friloo/Unifi-Voucher-Tool) @@ -28,8 +28,6 @@ Entwickelt von **[Loheide.eu](https://loheide.eu)** ## ✨ Features - 🎟️ **Voucher-Erstellung** mit sofortiger QR-Code-Anzeige, Druckvorlage und E-Mail-Versand -- 🖥️ **Display-Seiten (Kiosk)** – öffentliche Seite je Site, an der Gäste sich mit einem Klick selbst einen Zugang holen -- 🎨 **Jede Display-Seite eigenständig gestaltbar** – Logo, Hintergrundbild, Akzentfarbe, helle oder dunkle Karte - 📦 **Bulk-Erstellung** – bis zu 20 Vouchers auf einmal, inkl. Sammeldruck-Layout - 🧩 **Voucher-Profile/Templates** – vordefinierte Laufzeiten & Gerätelimits per Schnellauswahl - 🏢 **Multi-Site-Support** – beliebig viele UniFi-Standorte zentral verwalten @@ -85,18 +83,6 @@ Entwickelt von **[Loheide.eu](https://loheide.eu)** Einstellungen der Login-Seite -### Display-Seite für Gäste - -
- Display-Seite im Ruhezustand - Display-Seite mit eigenem Bild und Farben -
- -
- Ausgegebener Zugangscode auf dem Display - Display-Seite einrichten -
-
Voucher-Ergebnis mit QR-Code Bulk-Voucher-Erstellung @@ -228,64 +214,6 @@ Während eines Updates wird die Anwendung kurz in den **Wartungsmodus** versetzt --- -## 🖥️ Display-Seiten für Gäste - -Für Empfang, Lobby oder Tagungsraum lässt sich je Site eine **öffentliche Seite** -anlegen, die auf einem Bildschirm oder Tablet läuft. Gäste tippen auf einen -Knopf und bekommen sofort einen eigenen Zugangscode – ohne Anmeldung, ohne -Personal am Tresen. - -**Anlegen:** Administration → **Display-Seiten** → *Display-Seite anlegen* - -
- Verwaltung der Display-Seiten -
- -| Einstellung | Wirkung | -|---|---| -| Site | für welchen Standort die Codes erzeugt werden | -| Voucher-Profil | Laufzeit, Geräteanzahl und Bandbreite der Codes (leer = Standardwerte) | -| Überschrift / Text | was auf dem Bildschirm steht | -| Codes pro Tag | Obergrenze je Kalendertag (0 = unbegrenzt) | -| Wartezeit | Abstand zwischen zwei Codes an diesem Display | -| Anzeigedauer | danach springt der Bildschirm automatisch zurück | - -Jede Seite hat einen **eigenen, geheimen Link** (`kiosk.php?k=…`). Er lässt sich -kopieren, als QR-Code anzeigen (praktisch, um ihn am Tablet zu öffnen) und -jederzeit erneuern – der alte Link ist dann sofort ungültig. Den Link nicht -öffentlich verbreiten: wer ihn hat, kann im Rahmen der Limits Codes ziehen. - -Auf dem Startbildschirm steht zusätzlich ein QR-Code, der auf dieselbe Seite -zeigt. Gäste können sie damit **am eigenen Handy** öffnen – praktisch bei -Bildschirmen ohne Touch. - -### Jede Seite eigenständig gestalten - -Jede Display-Seite bringt ihr eigenes Erscheinungsbild mit – das Hotel am -Empfang sieht anders aus als der Tagungsraum nebenan: - -| Einstellung | Wirkung | -|---|---| -| Logo | eigenes Logo auf der Karte (leer = Logo aus den Einstellungen) | -| Hintergrundbild | formatfüllend hinter der Karte, z. B. ein Foto des Hauses | -| Abdunklung | 0–90 % dunkle Ebene über dem Bild, damit die Karte lesbar bleibt | -| Akzentfarbe | färbt den Knopf dieser Seite (leer = Farbe aus dem Design-Tab) | -| Karte | hell oder dunkel – auf Fotos wirkt die dunkle Karte meist ruhiger | - -Logo und Hintergrund lassen sich direkt hochladen (PNG, JPG, WEBP, GIF, SVG bis -3 MB) oder als URL hinterlegen; beim Löschen einer Display-Seite verschwinden -die hochgeladenen Dateien mit. - -Die ausgegebenen Codes erscheinen normal in *Live Vouchers*, im *Reporting* und -im *Audit-Log* (Aktion „Voucher am Display geholt"), sodass jederzeit -nachvollziehbar bleibt, woher ein Zugang stammt. - -> Display-Seiten funktionieren unabhängig vom globalen öffentlichen Modus – der -> geheime Link ist der Zugang. Webhook-Benachrichtigungen werden für diese Codes -> bewusst **nicht** ausgelöst, sonst wäre der Slack-Kanal voll. - ---- - ## ⚙️ Konfiguration ### `config.php` @@ -591,10 +519,6 @@ vendor/bin/phpunit # 29 Tests (Crypto, TOTP, API-Keys, Upload, Ui) vendor/bin/phpstan analyse # Level 5 ``` -Die Versionsnummer steht in der Datei **`VERSION`** im Projektstamm. Sie wird -im Admin-Bereich unten in der Seitenleiste angezeigt und benennt das -Release-Paket – für eine neue Version also dort (und im Badge oben) anheben. - Die Pipeline (`.github/workflows/ci.yml`) führt zusätzlich einen Syntax-Check über alle PHP-Dateien aus und prüft, ob `lang/de.php` und `lang/en.php` dieselben Schlüssel enthalten und jeder im Code verwendete @@ -616,28 +540,6 @@ git clone https://git.loheide.cloud/friloo/Unifi-Voucher-Tool.git git clone ssh://git@git.loheide.cloud:2222/friloo/Unifi-Voucher-Tool.git ``` -### Fertige Pakete - -Jeder Merge nach `main` erzeugt automatisch ein installierbares ZIP -(`.github/workflows/release.yml`) und hängt es an das rollende Vorab-Release -**`latest-main`**: - -**** - -Das Paket enthält nur die Laufzeit-Dateien – `docs/`, `tests/`, `tools/` und die -CI-Konfiguration bleiben draußen (rund 1,4 MB). Wird ein Tag `v*` gepusht, -entsteht daraus ein reguläres Release mit derselben Mechanik. - -> Beim **Update einer bestehenden Installation** `config.php`, `uploads/` und -> `updater/storage/` nicht überschreiben – oder gleich den eingebauten Updater -> verwenden, der genau diese Pfade schützt. - -Voraussetzung ist ein registrierter **Forgejo-Actions-Runner**; ohne Runner -bleiben die Workflows in der Warteschlange stehen. Compose-Datei und Anleitung -dafür liegen in [`tools/runner/`](tools/runner/README.md). - -### Mitwirken - Fehlerberichte und Änderungsvorschläge laufen über die **Issues** und **Pull Requests** dort. Für die Kommandozeile eignet sich [`tea`](https://gitea.com/gitea/tea), die Gitea-/Forgejo-CLI: @@ -677,13 +579,12 @@ dem Git-Hoster. - [x] Branding über die Oberfläche (Farben, Logo, Login-Seite) - [x] Assets lokal ausliefern (keine Drittanbieter-CDNs) - [x] Vollständige englische Übersetzung des Admin-Bereichs -- [x] Display-Seiten: Selbstbedienung für Gäste am Bildschirm ---
-**Version 2.8.0** · Autor: **Friederich Loheide** · Lizenz: **MIT** +**Version 2.6.0** · Autor: **Friederich Loheide** · Lizenz: **MIT** Entwickelt von **[Loheide.eu](https://loheide.eu)** diff --git a/VERSION b/VERSION deleted file mode 100644 index 834f262..0000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -2.8.0 diff --git a/admin/audit_log.php b/admin/audit_log.php index 8edff87..52d2937 100644 --- a/admin/audit_log.php +++ b/admin/audit_log.php @@ -50,8 +50,7 @@ $actionLabels = []; foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_created', 'user_updated', 'user_deleted', 'site_added', 'site_updated', 'site_deleted', 'settings_saved', 'password_reset', 'template_created', 'template_updated', - 'template_deleted', 'voucher_kiosk', 'kiosk_created', 'kiosk_updated', - 'kiosk_deleted'] as $action) { + 'template_deleted'] as $action) { $actionLabels[$action] = __('audit_action_' . $action); } ?> diff --git a/admin/kiosks.php b/admin/kiosks.php deleted file mode 100644 index 50019af..0000000 --- a/admin/kiosks.php +++ /dev/null @@ -1,500 +0,0 @@ -requireAdmin(); -I18n::init(); - -$db = Database::getInstance(); -$appTitle = $db->getSetting('app_title', 'UniFi Voucher System'); - -$error = ''; -$success = ''; - -/** Formularwerte einsammeln – für Anlegen und Bearbeiten identisch. */ -function kioskInput(): array -{ - return [ - 'site_id' => (int)($_POST['site_id'] ?? 0), - 'template_id' => (int)($_POST['template_id'] ?? 0) ?: null, - 'name' => trim((string)($_POST['name'] ?? '')), - 'headline' => trim((string)($_POST['headline'] ?? '')), - 'subline' => trim((string)($_POST['subline'] ?? '')), - 'daily_limit' => max(0, (int)($_POST['daily_limit'] ?? Kiosk::DEFAULT_DAILY_LIMIT)), - 'cooldown_seconds' => max(0, min(3600, (int)($_POST['cooldown_seconds'] ?? Kiosk::DEFAULT_COOLDOWN))), - 'display_seconds' => max(10, min(600, (int)($_POST['display_seconds'] ?? Kiosk::DEFAULT_DISPLAY_SECONDS))), - 'is_active' => isset($_POST['is_active']) ? 1 : 0, - 'bg_overlay' => max(0, min(90, (int)($_POST['bg_overlay'] ?? 45))), - 'accent_color' => self_accent($_POST['accent_color'] ?? ''), - 'card_style' => ($_POST['card_style'] ?? 'light') === 'dark' ? 'dark' : 'light', - ]; -} - -/** Nur echte Hex-Farben durchlassen – der Wert landet in einem style-Attribut. */ -function self_accent($value): ?string -{ - $value = strtolower(trim((string)$value)); - - return preg_match('/^#[0-9a-f]{6}$/', $value) ? $value : null; -} - -if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_kiosk'])) { - if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { - $error = __('error_csrf'); - } else { - try { - $in = kioskInput(); - if ($in['name'] === '') throw new Exception(__('error_name_req')); - if ($in['site_id'] <= 0) throw new Exception(__('error_site_req')); - - $logo = Upload::resolveField('logo_url', '', 'image'); - $bg = Upload::resolveField('background_url', '', 'image'); - - $db->execute( - "INSERT INTO kiosks (site_id, template_id, name, token, headline, subline, - logo_url, background_url, bg_overlay, accent_color, card_style, - daily_limit, cooldown_seconds, display_seconds, is_active, created_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)", - [$in['site_id'], $in['template_id'], $in['name'], Kiosk::newToken(), - $in['headline'], $in['subline'], $logo, $bg, $in['bg_overlay'], - $in['accent_color'], $in['card_style'], $in['daily_limit'], - $in['cooldown_seconds'], $in['display_seconds'], $_SESSION['user_id']] - ); - $auth->writeAuditLog($_SESSION['user_id'], 'kiosk_created', 'kiosk', null, $in['name']); - $success = __('kiosks_added'); - } catch (Exception $e) { - $error = $e->getMessage(); - } - } -} - -if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['edit_kiosk'])) { - if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { - $error = __('error_csrf'); - } else { - try { - $id = (int)($_POST['kiosk_id'] ?? 0); - $in = kioskInput(); - if ($in['name'] === '') throw new Exception(__('error_name_req')); - if ($in['site_id'] <= 0) throw new Exception(__('error_site_req')); - - $current = $db->fetchOne("SELECT logo_url, background_url FROM kiosks WHERE id = ?", [$id]) ?: []; - $logo = Upload::resolveField('logo_url', (string)($current['logo_url'] ?? ''), 'image'); - $bg = Upload::resolveField('background_url', (string)($current['background_url'] ?? ''), 'image'); - - $db->execute( - "UPDATE kiosks SET site_id=?, template_id=?, name=?, headline=?, subline=?, - logo_url=?, background_url=?, bg_overlay=?, accent_color=?, card_style=?, - daily_limit=?, cooldown_seconds=?, display_seconds=?, is_active=? - WHERE id=?", - [$in['site_id'], $in['template_id'], $in['name'], $in['headline'], $in['subline'], - $logo, $bg, $in['bg_overlay'], $in['accent_color'], $in['card_style'], - $in['daily_limit'], $in['cooldown_seconds'], $in['display_seconds'], $in['is_active'], $id] - ); - $auth->writeAuditLog($_SESSION['user_id'], 'kiosk_updated', 'kiosk', $id, $in['name']); - $success = __('kiosks_updated'); - } catch (Exception $e) { - $error = $e->getMessage(); - } - } -} - -// Neuen Link erzeugen – der alte gilt damit sofort nicht mehr. -if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['renew_token'])) { - if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { - $error = __('error_csrf'); - } else { - $id = (int)($_POST['kiosk_id'] ?? 0); - $db->execute("UPDATE kiosks SET token = ? WHERE id = ?", [Kiosk::newToken(), $id]); - $auth->writeAuditLog($_SESSION['user_id'], 'kiosk_updated', 'kiosk', $id, 'Link erneuert'); - $success = __('kiosks_token_renewed'); - } -} - -if (isset($_GET['delete'], $_GET['token'])) { - if ($auth->validateCsrfToken($_GET['token'])) { - $old = $db->fetchOne("SELECT logo_url, background_url FROM kiosks WHERE id = ?", [(int)$_GET['delete']]); - if ($old) { - Upload::delete((string)($old['logo_url'] ?? '')); - Upload::delete((string)($old['background_url'] ?? '')); - } - $db->execute("DELETE FROM kiosks WHERE id = ?", [(int)$_GET['delete']]); - $auth->writeAuditLog($_SESSION['user_id'], 'kiosk_deleted', 'kiosk', (int)$_GET['delete'], ''); - $success = __('kiosks_deleted'); - } else { - $error = __('error_csrf'); - } -} - -$sites = $db->fetchAll("SELECT id, name FROM sites WHERE is_active = 1 ORDER BY name"); -$templates = $db->fetchAll("SELECT id, name, max_uses, expire_minutes FROM voucher_templates WHERE is_active = 1 ORDER BY name"); -$kiosks = $db->fetchAll( - "SELECT k.*, s.name AS site_name, t.name AS template_name, - (SELECT COUNT(*) FROM vouchers v WHERE v.kiosk_id = k.id) AS total_vouchers, - (SELECT COUNT(*) FROM vouchers v WHERE v.kiosk_id = k.id AND DATE(v.created_at) = CURDATE()) AS today_vouchers - FROM kiosks k - INNER JOIN sites s ON s.id = k.site_id - LEFT JOIN voucher_templates t ON t.id = k.template_id - ORDER BY k.is_active DESC, k.name" -); - -$csrf = $auth->getCsrfToken(); -$currentPage = 'kiosks'; -$adminBase = ''; -?> - - - - - -<?= __('kiosks_title') ?> – <?= htmlspecialchars($appTitle) ?> - - - - - -
-
- - -
-
-

- - - -
- -
-
-

- -
- -
- - -
-
-
-
-
-
- - - -
- -
-
- - -
-
- - 0 ? ' / ' . (int)$k['daily_limit'] : '' ?> - -
-
- - -
-
- - - - -
- - - - - -
- - - -
- - - -
-
- -
- - - - - - - - - - -
- - - - diff --git a/admin/settings.php b/admin/settings.php index d5763aa..1f191b4 100644 --- a/admin/settings.php +++ b/admin/settings.php @@ -42,6 +42,33 @@ if (isset($_POST['ajax_smtp_test'])) { $error = ''; $success = ''; +/** + * Liefert den neuen Wert eines Bildfeldes: Upload schlaegt URL, und ein + * gesetzter Entfernen-Schalter loescht die bisherige Datei. + */ +function resolveImageField(string $name, Database $db, string $kind): string +{ + $current = (string)$db->getSetting($name, ''); + + $uploaded = Upload::store($_FILES[$name . '_file'] ?? [], $kind); + if ($uploaded !== '') { + Upload::delete($current); + return $uploaded; + } + + if (!empty($_POST[$name . '_remove'])) { + Upload::delete($current); + return ''; + } + + $value = trim($_POST[$name] ?? ''); + if ($value !== $current && Upload::isLocal($current) && !Upload::isLocal($value)) { + Upload::delete($current); + } + + return $value; +} + // Einstellungen speichern if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) { if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { @@ -53,8 +80,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) { if ($formType === 'general') { $settings['app_title'] = trim($_POST['app_title'] ?? ''); - $settings['logo_url'] = Upload::resolveField('logo_url', (string)$db->getSetting('logo_url', ''), 'image'); - $settings['favicon_url'] = Upload::resolveField('favicon_url', (string)$db->getSetting('favicon_url', ''), 'favicon'); + $settings['logo_url'] = resolveImageField('logo_url', $db, 'image'); + $settings['favicon_url'] = resolveImageField('favicon_url', $db, 'favicon'); $settings['instruction_header'] = trim($_POST['instruction_header'] ?? ''); $settings['instruction_text'] = $_POST['instruction_text'] ?? ''; $settings['public_access'] = isset($_POST['public_access']) ? '1' : '0'; @@ -72,12 +99,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) { if ($formType === 'login') { $settings['login_panel_enabled'] = isset($_POST['login_panel_enabled']) ? '1' : '0'; $settings['login_brand_name'] = trim($_POST['login_brand_name'] ?? ''); - $settings['login_logo_url'] = Upload::resolveField('login_logo_url', (string)$db->getSetting('login_logo_url', ''), 'image'); + $settings['login_logo_url'] = resolveImageField('login_logo_url', $db, 'image'); $settings['login_claim_title'] = trim($_POST['login_claim_title'] ?? ''); $settings['login_claim_text'] = trim($_POST['login_claim_text'] ?? ''); $settings['login_features'] = trim($_POST['login_features'] ?? ''); $settings['login_footer'] = trim($_POST['login_footer'] ?? ''); - $settings['login_bg_image'] = Upload::resolveField('login_bg_image', (string)$db->getSetting('login_bg_image', ''), 'image'); + $settings['login_bg_image'] = resolveImageField('login_bg_image', $db, 'image'); $settings['login_bg_from'] = trim($_POST['login_bg_from'] ?? ''); $settings['login_bg_to'] = trim($_POST['login_bg_to'] ?? ''); $overlay = (int)($_POST['login_bg_overlay'] ?? 40); @@ -236,6 +263,41 @@ $cs = [ 'last_cron_sync' => $db->getSetting('last_cron_sync', ''), ]; +/** + * Bildfeld: Vorschau, Upload, alternativ URL – plus Entfernen-Schalter. + */ +function imageField(string $name, string $label, ?string $value, string $hint = '', string $accept = 'image/*'): void +{ + $value = (string)$value; + $preview = Ui::mediaUrl($value, '../'); + ?> +
+ +
+
+ + + + + +
+
+ + + + + +
+
+
+
+ @@ -288,8 +350,8 @@ $adminBase = '';
- - + +

@@ -423,7 +485,7 @@ $adminBase = '';
- +

@@ -448,7 +510,7 @@ $adminBase = '';
- +
diff --git a/assets/global.css b/assets/global.css index 9923e3b..ce41678 100644 --- a/assets/global.css +++ b/assets/global.css @@ -1610,174 +1610,6 @@ input.search-bar, .site-selector .search-bar { min-width: 240px; width: auto; fl } } -/* ========================================================================= - 16. KIOSK – öffentliche Display-Seite - Große Typografie: der Code muss aus einigen Metern Entfernung lesbar sein. - ========================================================================= */ -.kiosk-body { - min-height: 100vh; - display: flex; - flex-direction: column; - background: - radial-gradient(900px 500px at 12% -10%, var(--accent-soft), transparent 62%), - radial-gradient(700px 460px at 100% 0%, rgba(139,92,246,.10), transparent 64%), - var(--bg-body); -} -.kiosk-stage { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - padding: 40px 24px; -} -.kiosk-card { - width: 100%; - max-width: 680px; - padding: 48px 44px; - text-align: center; - background: var(--bg-card); - border: 1px solid var(--border-color); - border-radius: var(--r-xl); - box-shadow: var(--shadow-xl); -} -.kiosk-logo { max-height: 84px; max-width: 320px; margin: 0 auto 26px; display: block; } -.kiosk-logo-sm { max-height: 52px; margin-bottom: 20px; } - -/* Eigenes Hintergrundbild der Display-Seite: Bild formatfüllend, darüber - eine abdunkelnde Ebene, damit die Karte sich abhebt. */ -.kiosk-body.has-background { - --kiosk-overlay: 0.45; - position: relative; - background: var(--kiosk-bg) center / cover no-repeat fixed; -} -.kiosk-body.has-background::before { - content: ''; - position: fixed; - inset: 0; - background: rgba(8, 11, 18, var(--kiosk-overlay)); - pointer-events: none; -} -.kiosk-body.has-background > * { position: relative; z-index: 1; } -.kiosk-body.has-background .kiosk-card { box-shadow: 0 40px 80px -30px rgba(0,0,0,.6); } -.kiosk-body.has-background .kiosk-footer .app-credit, -.kiosk-body.has-background .kiosk-footer .app-credit a { color: rgba(255,255,255,.65); } - -/* Dunkle Karte – wirkt auf Fotos oft ruhiger als eine weiße Fläche. */ -.kiosk-dark .kiosk-card { - background: rgba(12,15,22,.86); - border-color: rgba(255,255,255,.12); - backdrop-filter: blur(6px); - color: #f4f6fb; -} -.kiosk-dark .kiosk-card h1, -.kiosk-dark .kiosk-code { color: #ffffff; } -.kiosk-dark .kiosk-subline, -.kiosk-dark .kiosk-qr-label { color: rgba(255,255,255,.76); } -.kiosk-dark .kiosk-countdown { color: rgba(255,255,255,.5); } -.kiosk-dark .kiosk-meta span { - background: rgba(255,255,255,.10); - border-color: rgba(255,255,255,.16); - color: rgba(255,255,255,.88); -} -.kiosk-dark .kiosk-phone { border-top-color: rgba(255,255,255,.14); color: rgba(255,255,255,.7); } -.kiosk-dark .kiosk-eyebrow { background: rgba(74,222,128,.16); border-color: rgba(74,222,128,.32); color: #86efac; } -.kiosk-dark .btn-secondary { - background: rgba(255,255,255,.10); - border-color: rgba(255,255,255,.18); - color: #f4f6fb; -} -.kiosk-dark .btn-secondary:hover { background: rgba(255,255,255,.18); color: #fff; } -.kiosk-mark { width: 62px; height: 62px; margin: 0 auto 26px; font-size: 26px; } -.kiosk-headline { - font-size: clamp(30px, 4.6vw, 46px); - line-height: 1.15; - letter-spacing: -0.03em; -} -.kiosk-subline { - margin-top: 14px; - font-size: clamp(16px, 1.8vw, 20px); - color: var(--text-secondary); -} -.kiosk-alert { justify-content: center; margin: 24px 0 0; text-align: left; } -.kiosk-button { - width: 100%; - margin-top: 34px; - padding: 26px 32px; - gap: 14px; - font-size: clamp(20px, 2.4vw, 26px); - font-weight: 620; - border-radius: var(--r-lg); -} -.kiosk-button i { font-size: 0.95em; } -.kiosk-phone { - display: flex; - align-items: center; - justify-content: center; - gap: 16px; - margin-top: 34px; - padding-top: 26px; - border-top: 1px solid var(--border-color); - color: var(--text-muted); - font-size: 13.5px; -} -.kiosk-phone-qr { line-height: 0; } -.kiosk-phone-qr img, .kiosk-phone-qr canvas { border-radius: var(--r-sm); } - -.kiosk-result { max-width: 880px; } -.kiosk-eyebrow { - display: inline-flex; align-items: center; gap: 9px; - padding: 6px 16px; - border-radius: var(--r-pill); - background: var(--success-soft); - border: 1px solid var(--success-border); - color: var(--success); - font-size: 14px; font-weight: 600; -} -.kiosk-code { - margin: 26px 0 18px; - font-family: var(--font-mono); - /* Muss aus einigen Metern lesbar sein, aber in einer Zeile bleiben. */ - font-size: clamp(38px, 7vw, 76px); - font-weight: 700; - letter-spacing: .06em; - white-space: nowrap; - line-height: 1.05; - color: var(--text-primary); - word-break: break-word; -} -.kiosk-meta { - display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; - font-size: 15px; color: var(--text-secondary); -} -.kiosk-meta span { - display: inline-flex; align-items: center; gap: 8px; - padding: 6px 14px; - background: var(--bg-subtle); - border: 1px solid var(--border-color); - border-radius: var(--r-pill); -} -.kiosk-qr { margin: 30px 0 8px; } -.kiosk-qr #qrcode { - display: inline-block; - padding: 16px; - background: #fff; - border-radius: var(--r-lg); - box-shadow: var(--shadow-sm); - line-height: 0; -} -.kiosk-qr-label { margin-top: 14px; font-size: 15px; color: var(--text-secondary); } -.kiosk-countdown { margin: 18px 0 22px; font-size: 13.5px; color: var(--text-muted); } -.kiosk-footer { padding: 0 24px 22px; text-align: center; } - -/* Link-Zeile in der Kiosk-Verwaltung */ -.kiosk-link-row { display: flex; gap: 8px; align-items: center; } -.kiosk-link-row .input { font-family: var(--font-mono); font-size: 12px; } - -@media (max-width: 560px) { - .kiosk-card { padding: 32px 22px; } - .kiosk-phone { flex-direction: column; } -} - /* ========================================================================= 13. RESPONSIVE ========================================================================= */ diff --git a/database.sql b/database.sql index 52e6a0b..6dfa5d4 100644 --- a/database.sql +++ b/database.sql @@ -66,34 +66,6 @@ CREATE TABLE IF NOT EXISTS `voucher_templates` ( FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -CREATE TABLE IF NOT EXISTS `kiosks` ( - `id` INT PRIMARY KEY AUTO_INCREMENT, - `site_id` INT NOT NULL, - `template_id` INT NULL, - `name` VARCHAR(255) NOT NULL, - `token` VARCHAR(64) NOT NULL, - `headline` VARCHAR(255) NULL, - `subline` VARCHAR(500) NULL, - `logo_url` VARCHAR(500) NULL, - `background_url` VARCHAR(500) NULL, - `bg_overlay` TINYINT NOT NULL DEFAULT 45, - `accent_color` VARCHAR(7) NULL, - `card_style` ENUM('light','dark') NOT NULL DEFAULT 'light', - `is_active` TINYINT(1) NOT NULL DEFAULT 1, - `daily_limit` INT NOT NULL DEFAULT 100, - `cooldown_seconds` INT NOT NULL DEFAULT 20, - `display_seconds` INT NOT NULL DEFAULT 90, - `last_used_at` TIMESTAMP NULL, - `created_by` INT NULL, - `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - UNIQUE KEY `uniq_token` (`token`), - INDEX `idx_site` (`site_id`), - FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE, - FOREIGN KEY (`template_id`) REFERENCES `voucher_templates`(`id`) ON DELETE SET NULL, - FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - CREATE TABLE IF NOT EXISTS `api_keys` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `name` VARCHAR(255) NOT NULL, @@ -121,7 +93,6 @@ CREATE TABLE IF NOT EXISTS `vouchers` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `site_id` INT NOT NULL, `user_id` INT, - `kiosk_id` INT NULL, `voucher_code` VARCHAR(50) NOT NULL, `voucher_name` VARCHAR(255) NOT NULL, `max_uses` INT NOT NULL, @@ -138,8 +109,7 @@ CREATE TABLE IF NOT EXISTS `vouchers` ( INDEX `idx_site` (`site_id`), INDEX `idx_created` (`created_at`), INDEX `idx_unifi_id` (`unifi_voucher_id`), - INDEX `idx_status` (`status`), - INDEX `idx_kiosk` (`kiosk_id`) + INDEX `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS `sessions` ( diff --git a/docs/screenshots/admin-dashboard-dark.png b/docs/screenshots/admin-dashboard-dark.png index bddf25e..b615914 100644 Binary files a/docs/screenshots/admin-dashboard-dark.png and b/docs/screenshots/admin-dashboard-dark.png differ diff --git a/docs/screenshots/admin-dashboard.png b/docs/screenshots/admin-dashboard.png index d2d2073..bfb3012 100644 Binary files a/docs/screenshots/admin-dashboard.png and b/docs/screenshots/admin-dashboard.png differ diff --git a/docs/screenshots/api-keys.png b/docs/screenshots/api-keys.png index e625ae6..98e3dd0 100644 Binary files a/docs/screenshots/api-keys.png and b/docs/screenshots/api-keys.png differ diff --git a/docs/screenshots/integrations.png b/docs/screenshots/integrations.png index 060706d..41fb539 100644 Binary files a/docs/screenshots/integrations.png and b/docs/screenshots/integrations.png differ diff --git a/docs/screenshots/kiosk-branded.png b/docs/screenshots/kiosk-branded.png deleted file mode 100644 index 06cca2a..0000000 Binary files a/docs/screenshots/kiosk-branded.png and /dev/null differ diff --git a/docs/screenshots/kiosk-code.png b/docs/screenshots/kiosk-code.png deleted file mode 100644 index 4a6d7a8..0000000 Binary files a/docs/screenshots/kiosk-code.png and /dev/null differ diff --git a/docs/screenshots/kiosk-display.png b/docs/screenshots/kiosk-display.png deleted file mode 100644 index 06cca2a..0000000 Binary files a/docs/screenshots/kiosk-display.png and /dev/null differ diff --git a/docs/screenshots/kiosks-admin.png b/docs/screenshots/kiosks-admin.png deleted file mode 100644 index 7cbb1f4..0000000 Binary files a/docs/screenshots/kiosks-admin.png and /dev/null differ diff --git a/docs/screenshots/kiosks-form.png b/docs/screenshots/kiosks-form.png deleted file mode 100644 index 4654091..0000000 Binary files a/docs/screenshots/kiosks-form.png and /dev/null differ diff --git a/docs/screenshots/settings-branding.png b/docs/screenshots/settings-branding.png index c27f6d2..cec6e04 100644 Binary files a/docs/screenshots/settings-branding.png and b/docs/screenshots/settings-branding.png differ diff --git a/docs/screenshots/settings-login.png b/docs/screenshots/settings-login.png index 2004b02..3c7d00f 100644 Binary files a/docs/screenshots/settings-login.png and b/docs/screenshots/settings-login.png differ diff --git a/docs/screenshots/settings.png b/docs/screenshots/settings.png index 4a468cf..cb6f489 100644 Binary files a/docs/screenshots/settings.png and b/docs/screenshots/settings.png differ diff --git a/docs/screenshots/two-factor.png b/docs/screenshots/two-factor.png index d743781..7d9704b 100644 Binary files a/docs/screenshots/two-factor.png and b/docs/screenshots/two-factor.png differ diff --git a/docs/screenshots/vouchers.png b/docs/screenshots/vouchers.png index e878ec8..230dc97 100644 Binary files a/docs/screenshots/vouchers.png and b/docs/screenshots/vouchers.png differ diff --git a/includes/Kiosk.php b/includes/Kiosk.php deleted file mode 100644 index ef25a56..0000000 --- a/includes/Kiosk.php +++ /dev/null @@ -1,169 +0,0 @@ -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; - } -} diff --git a/includes/Ui.php b/includes/Ui.php index b7faa7a..40bff32 100644 --- a/includes/Ui.php +++ b/includes/Ui.php @@ -116,61 +116,6 @@ class Ui . ''; } - /** - * Bildfeld mit Vorschau, Upload und URL-Eingabe. - * Wird von den Einstellungen und der Kiosk-Verwaltung genutzt. - */ - public static function imageField( - string $name, - string $label, - ?string $value, - string $hint = '', - string $accept = 'image/*', - string $base = '../' - ): string { - $value = (string)$value; - $preview = self::mediaUrl($value, $base); - $esc = static fn ($text) => htmlspecialchars((string)$text, ENT_QUOTES); - $t = static fn ($key, $fallback) => function_exists('__') ? __($key) : $fallback; - - $thumb = $preview !== '' - ? '' - : ''; - - $remove = $value !== '' - ? '' - : ''; - - return '
' - . '' - . '
' - . '
' . $thumb . '
' - . '
' - . '' - . '' - . $remove - . '
' - . ($hint !== '' ? '
' . $esc($hint) . '
' : '') - . '
'; - } - - /** - * Version aus der Datei VERSION im Projektstamm. - * Damit tragen Oberfläche und Release-Paket dieselbe Nummer. - */ - public static function version(): string - { - static $version = null; - if ($version === null) { - $file = self::root() . '/VERSION'; - $version = is_file($file) ? trim((string)file_get_contents($file)) : ''; - } - - return $version; - } - /** Entwicklerhinweis – bewusst an einer Stelle gepflegt. */ public const CREDIT_NAME = 'Loheide.eu'; public const CREDIT_URL = 'https://loheide.eu'; @@ -178,16 +123,11 @@ class Ui /** * Dezenter Hinweis auf den Entwickler, wie er im Seitenfuß erscheint. */ - public static function credit(bool $withVersion = false): string + public static function credit(): string { $label = function_exists('__') ? __('credit_by') : 'Entwickelt von'; - $prefix = ''; - if ($withVersion && self::version() !== '') { - $prefix = 'v' . htmlspecialchars(self::version()) . ' · '; - } - - return '

' . $prefix . htmlspecialchars($label) . ' ' + return '

' . htmlspecialchars($label) . ' ' . '' . self::CREDIT_NAME . '

'; } diff --git a/includes/Upload.php b/includes/Upload.php index 2a97b90..39713bf 100644 --- a/includes/Upload.php +++ b/includes/Upload.php @@ -120,39 +120,6 @@ class Upload return 'uploads/' . $name; } - /** - * Neuer Wert eines Bildfeldes aus dem Formular. - * - * Reihenfolge: hochgeladene Datei schlaegt alles, danach der - * Entfernen-Schalter, sonst gilt das URL-Feld. Wird eine zuvor - * hochgeladene Datei ersetzt oder entfernt, verschwindet sie auch - * von der Platte. - * - * @param string $name Feldname (erwartet , _file, _remove) - * @param string $current bisher gespeicherter Wert - * @param string $kind 'image' oder 'favicon' - */ - public static function resolveField(string $name, string $current, string $kind = 'image'): string - { - $uploaded = self::store($_FILES[$name . '_file'] ?? [], $kind); - if ($uploaded !== '') { - self::delete($current); - return $uploaded; - } - - if (!empty($_POST[$name . '_remove'])) { - self::delete($current); - return ''; - } - - $value = trim((string)($_POST[$name] ?? '')); - if ($value !== $current && self::isLocal($current) && !self::isLocal($value)) { - self::delete($current); - } - - return $value; - } - /** * Entfernt aktive Inhalte aus SVG-Dateien (Skripte, Event-Handler, * externe Verweise). Lieber eine Grafik verlieren als eine XSS-Luecke. diff --git a/includes/VoucherService.php b/includes/VoucherService.php deleted file mode 100644 index 9ddcaa7..0000000 --- a/includes/VoucherService.php +++ /dev/null @@ -1,63 +0,0 @@ - kbit, 'up' => kbit, 'quota_mb' => MB] - * @param int|null $userId angemeldeter Benutzer, sonst null - * @param int|null $kioskId Herkunft, falls ueber eine Display-Seite geholt - * - * @return array{code:string,site_name:string,max_uses:int,expire_min:int,expiry_date:string,expiry_time:string} - * @throws Exception wenn der Controller keinen gueltigen Voucher liefert - */ - public static function create( - $db, - array $site, - string $voucherName, - int $maxUses, - int $expireMinutes, - ?int $userId = null, - array $qos = [], - ?int $kioskId = null - ): array { - $fullName = date('Y-m-d') . '_' . $voucherName; - - $controller = new UniFiController( - $site['unifi_controller_url'], - $site['unifi_username'], - Crypto::decrypt($site['unifi_password']), - $site['site_id'] - ); - - $voucher = $controller->createVoucher($fullName, $maxUses, $expireMinutes, $qos); - if (!is_array($voucher) || empty($voucher['formatted_code'])) { - throw new Exception(function_exists('__') ? __('error_voucher_invalid') : 'Ungueltige Antwort des Controllers'); - } - - $db->execute( - "INSERT INTO vouchers (site_id, user_id, kiosk_id, voucher_code, voucher_name, max_uses, expire_minutes, unifi_voucher_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - [$site['id'], $userId, $kioskId, $voucher['code'], $fullName, $maxUses, $expireMinutes, $voucher['unifi_id'] ?? null] - ); - - $expiryTs = time() + ($expireMinutes * 60); - - return [ - 'code' => $voucher['formatted_code'], - 'site_name' => $site['name'], - 'max_uses' => $maxUses, - 'expire_min' => $expireMinutes, - 'expiry_date' => date('d.m.Y', $expiryTs), - 'expiry_time' => date('H:i', $expiryTs), - ]; - } -} diff --git a/includes/admin_nav.php b/includes/admin_nav.php index 167c9ff..6d4b671 100644 --- a/includes/admin_nav.php +++ b/includes/admin_nav.php @@ -23,7 +23,6 @@ $navGroups = [ ['vouchers', 'vouchers.php', 'fa-ticket', 'nav_vouchers'], ['templates', 'templates.php', 'fa-layer-group', 'nav_templates'], ['import', 'import.php', 'fa-file-arrow-up', 'nav_import'], - ['kiosks', 'kiosks.php', 'fa-display', 'nav_kiosks'], ['sites', 'sites.php', 'fa-location-dot', 'nav_sites'], ['users', 'users.php', 'fa-users', 'nav_users'], ], @@ -90,7 +89,7 @@ foreach ($navGroups as $items) {
- +
diff --git a/index.php b/index.php index 020c796..5b0b516 100644 --- a/index.php +++ b/index.php @@ -20,7 +20,6 @@ require_once __DIR__ . '/includes/Notifier.php'; require_once __DIR__ . '/includes/Captcha.php'; require_once __DIR__ . '/includes/Sms.php'; require_once __DIR__ . '/includes/Ui.php'; -require_once __DIR__ . '/includes/VoucherService.php'; require_once __DIR__ . '/includes/I18n.php'; $auth = new Auth(); @@ -116,9 +115,34 @@ if ($auth->isLoggedIn()) { $autoSelectSite = (count($sites) === 1) ? $sites[0]['id'] : 0; -// Voucher-Erstellung liegt gebuendelt in includes/VoucherService.php. +// Helper: create one voucher and save to DB function doCreateVoucher($db, $site, $voucherName, $maxUses, $expireMinutes, $userId, $qos = []) { - return VoucherService::create($db, $site, $voucherName, $maxUses, $expireMinutes, $userId, $qos); + $datum = date('Y-m-d'); + $fullName = $datum . '_' . $voucherName; + $controller = new UniFiController( + $site['unifi_controller_url'], + $site['unifi_username'], + Crypto::decrypt($site['unifi_password']), + $site['site_id'] + ); + $voucher = $controller->createVoucher($fullName, $maxUses, $expireMinutes, $qos); + if (!is_array($voucher) || empty($voucher['formatted_code'])) { + throw new Exception(__('error_voucher_invalid')); + } + $db->execute( + "INSERT INTO vouchers (site_id, user_id, voucher_code, voucher_name, max_uses, expire_minutes, unifi_voucher_id) + VALUES (?, ?, ?, ?, ?, ?, ?)", + [$site['id'], $userId, $voucher['code'], $fullName, $maxUses, $expireMinutes, $voucher['unifi_id'] ?? null] + ); + $expiryTs = time() + ($expireMinutes * 60); + return [ + 'code' => $voucher['formatted_code'], + 'site_name' => $site['name'], + 'max_uses' => $maxUses, + 'expire_min' => $expireMinutes, + 'expiry_date' => date('d.m.Y', $expiryTs), + 'expiry_time' => date('H:i', $expiryTs), + ]; } // Single voucher diff --git a/kiosk.php b/kiosk.php deleted file mode 100644 index 9118ce4..0000000 --- a/kiosk.php +++ /dev/null @@ -1,245 +0,0 @@ - - * - * Gedacht für ein Tablet oder einen Bildschirm im Empfangsbereich: ein großer - * Knopf, ein Klick, ein Zugangscode. Gäste ohne Zugriff auf den Bildschirm - * können denselben Link über den QR-Code am Handy öffnen. - */ -error_reporting(E_ALL); -ini_set('display_errors', 0); -ini_set('log_errors', 1); - -require_once __DIR__ . '/config.php'; -require_once __DIR__ . '/includes/Database.php'; -require_once __DIR__ . '/includes/Auth.php'; -require_once __DIR__ . '/includes/I18n.php'; -require_once __DIR__ . '/includes/Ui.php'; -require_once __DIR__ . '/includes/Kiosk.php'; -require_once __DIR__ . '/includes/VoucherService.php'; - -I18n::init(); - -try { - $db = Database::getInstance(); - $auth = new Auth(); -} catch (Exception $e) { - http_response_code(500); - die('Datenbankfehler'); -} - -$appTitle = $db->getSetting('app_title', 'UniFi Voucher System'); -$token = Kiosk::sanitizeToken($_GET['k'] ?? ''); -$kiosk = $token !== '' ? Kiosk::findByToken($db, $token) : null; - -if (!$kiosk) { - http_response_code(404); - $notFound = true; -} else { - $notFound = false; -} - -$voucher = null; // erzeugter Code -$error = ''; -$waitSecs = 0; - -if (!$notFound && $_SERVER['REQUEST_METHOD'] === 'POST') { - if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { - $error = __('error_csrf'); - } else { - $limits = Kiosk::checkLimits($db, $kiosk); - if (!$limits['allowed']) { - $waitSecs = (int)$limits['wait']; - $error = $limits['reason'] === 'cooldown' - ? str_replace('{seconds}', (string)$waitSecs, __('kiosk_error_cooldown')) - : __('kiosk_error_limit'); - } else { - try { - $site = $db->fetchOne("SELECT * FROM sites WHERE id = ? AND is_active = 1", [(int)$kiosk['site_id']]); - if (!$site) { - throw new Exception(__('error_site_not_found')); - } - - $settings = Kiosk::voucherSettings($db, $kiosk); - $voucher = VoucherService::create( - $db, - $site, - $kiosk['name'], - $settings['max_uses'], - $settings['expire_minutes'], - null, - $settings['qos'], - (int)$kiosk['id'] - ); - - Kiosk::markUsed($db, (int)$kiosk['id']); - $auth->writeAuditLog(null, 'voucher_kiosk', 'kiosk', (int)$kiosk['id'], - $kiosk['name'] . ' · ' . $voucher['code']); - } catch (Exception $e) { - error_log('Kiosk-Fehler: ' . $e->getMessage()); - $error = __('kiosk_error_generic'); - } - } - } -} - -$headline = trim((string)($kiosk['headline'] ?? '')) ?: __('kiosk_default_headline'); -$subline = trim((string)($kiosk['subline'] ?? '')) ?: __('kiosk_default_subline'); -$display = max(10, (int)($kiosk['display_seconds'] ?? Kiosk::DEFAULT_DISPLAY_SECONDS)); -$selfUrl = $kiosk ? Kiosk::publicUrl($kiosk['token']) : ''; - -// Gestaltung dieser Display-Seite (eigene Werte, sonst die des Systems) -$look = $kiosk ? Kiosk::appearance($db, $kiosk) : ['logo'=>'','background'=>'','overlay'=>0.45,'accent'=>'','card'=>'light']; -$logoUrl = $look['logo']; -$bodyClass = 'kiosk-body'; -$bodyStyle = ''; -if ($look['background'] !== '') { - $bodyClass .= ' has-background'; - $bodyStyle .= "--kiosk-bg:url('" . htmlspecialchars(Ui::mediaUrl($look['background']), ENT_QUOTES) . "');" - . '--kiosk-overlay:' . $look['overlay'] . ';'; -} -if ($look['card'] === 'dark') { - $bodyClass .= ' kiosk-dark'; -} -if ($look['accent'] !== '') { - // Nur die Akzentfarbe dieser Seite überschreiben – der Rest bleibt Design-System. - $bodyStyle .= '--accent:' . $look['accent'] . ';' - . '--accent-hover:color-mix(in srgb, ' . $look['accent'] . ' 84%, #000);' - . '--accent-soft:color-mix(in srgb, ' . $look['accent'] . ' 14%, #fff);' - . '--accent-border:color-mix(in srgb, ' . $look['accent'] . ' 32%, #fff);'; -} -?> - - - - - - - <?= htmlspecialchars($appTitle) ?> - - - - - -> - - -
-
-
-
-

-

-
-
-
- -
-
- - - -

-
-
- - - -
-
-
-

-
-

- ' . $display . '', __('kiosk_reset_in')) ?> -

- -
-
- -
-
- - - - - - -

-

- - -
- - -
- - -
- -
-
-

-
-
-
- - - - - - - - - diff --git a/lang/de.php b/lang/de.php index 7b07a87..3a84eab 100644 --- a/lang/de.php +++ b/lang/de.php @@ -470,73 +470,6 @@ return [ 'print_valid_until' => 'Gültig bis', 'print_devices' => 'Geräte', 'credit_by' => 'Entwickelt von', - 'kiosk_default_headline' => 'Kostenloses Gäste-WLAN', - 'kiosk_default_subline' => 'Tippen Sie auf den Knopf – Sie erhalten sofort einen persönlichen Zugangscode.', - 'kiosk_button' => 'Zugangscode holen', - 'kiosk_working' => 'Einen Moment …', - 'kiosk_ready' => 'Ihr Zugangscode', - 'kiosk_scan_code' => 'QR-Code scannen oder Code eintippen', - 'kiosk_reset_in' => 'Der Bildschirm wird in {seconds} Sekunden zurückgesetzt.', - 'kiosk_done' => 'Fertig', - 'kiosk_phone_hint' => 'Oder mit dem Handy scannen und dort öffnen', - 'kiosk_error_cooldown' => 'Gerade wurde ein Code ausgegeben. Bitte {seconds} Sekunden warten.', - 'kiosk_error_limit' => 'Für heute sind keine Zugänge mehr verfügbar. Bitte wenden Sie sich an den Empfang.', - 'kiosk_error_generic' => 'Der Zugang konnte gerade nicht erstellt werden. Bitte erneut versuchen.', - 'kiosk_unknown' => 'Diese Seite ist nicht verfügbar', - 'kiosk_unknown_hint' => 'Der Link ist ungültig oder wurde deaktiviert.', - 'audit_action_voucher_kiosk' => 'Voucher am Display geholt', - 'nav_kiosks' => 'Display-Seiten', - 'kiosks_title' => 'Display-Seiten', - 'kiosks_subtitle' => 'Öffentliche Seiten für Bildschirme und Tablets – Gäste holen sich den Zugang selbst.', - 'kiosks_add' => 'Display-Seite anlegen', - 'kiosks_edit' => 'Display-Seite bearbeiten', - 'kiosks_empty' => 'Noch keine Display-Seite angelegt.', - 'kiosks_no_sites' => 'Legen Sie zuerst eine Site an, dann können Sie dafür eine Display-Seite erstellen.', - 'kiosks_name' => 'Bezeichnung', - 'kiosks_name_placeholder' => 'z.B. Empfang Erdgeschoss', - 'kiosks_name_hint' => 'Erscheint im Audit-Log und als Voucher-Name.', - 'kiosks_template' => 'Voucher-Profil', - 'kiosks_no_template' => 'Standardwerte verwenden', - 'kiosks_template_hint' => 'Bestimmt Laufzeit, Geräteanzahl und Bandbreite der ausgegebenen Codes.', - 'kiosks_headline' => 'Überschrift auf dem Bildschirm', - 'kiosks_subline' => 'Text darunter', - 'kiosks_daily_limit' => 'Codes pro Tag', - 'kiosks_daily_limit_hint' => '0 = unbegrenzt. Schützt vor Missbrauch, wenn der Link weitergegeben wird.', - 'kiosks_cooldown' => 'Wartezeit (Sekunden)', - 'kiosks_cooldown_hint' => 'Abstand zwischen zwei Codes an diesem Display.', - 'kiosks_display' => 'Anzeigedauer (Sekunden)', - 'kiosks_display_hint' => 'Danach springt der Bildschirm zurück auf den Startbildschirm.', - 'kiosks_active' => 'Display-Seite aktiv', - 'kiosks_link' => 'Öffentlicher Link', - 'kiosks_open' => 'Öffnen', - 'kiosks_qr' => 'QR-Code', - 'kiosks_qr_hint' => 'Am Bildschirm aufhängen oder abfotografieren, um die Seite auf einem Tablet zu öffnen.', - 'kiosks_today' => 'heute', - 'kiosks_total' => 'insgesamt', - 'kiosks_renew' => 'Link erneuern', - 'kiosks_renew_confirm' => 'Neuen Link erzeugen? Der bisherige Link funktioniert danach nicht mehr.', - 'kiosks_delete_confirm' => 'Display-Seite wirklich löschen?', - 'kiosks_added' => 'Display-Seite angelegt.', - 'kiosks_updated' => 'Display-Seite gespeichert.', - 'kiosks_deleted' => 'Display-Seite gelöscht.', - 'kiosks_token_renewed' => 'Neuer Link erzeugt – der alte ist ab sofort ungültig.', - 'audit_action_kiosk_created' => 'Display-Seite angelegt', - 'audit_action_kiosk_updated' => 'Display-Seite geändert', - 'audit_action_kiosk_deleted' => 'Display-Seite gelöscht', - 'kiosks_appearance' => 'Erscheinungsbild', - 'kiosks_logo' => 'Logo', - 'kiosks_logo_hint' => 'Leer = allgemeines Logo aus den Einstellungen.', - 'kiosks_background' => 'Hintergrundbild', - 'kiosks_background_hint' => 'Formatfüllend hinter der Karte – z.B. ein Foto des Hauses.', - 'kiosks_overlay' => 'Abdunklung des Bildes (%)', - 'kiosks_overlay_hint' => '0–90 %. Höhere Werte machen die Karte auf hellen Bildern lesbarer.', - 'kiosks_accent' => 'Akzentfarbe', - 'kiosks_accent_default' => 'Standardfarbe verwenden', - 'kiosks_accent_hint' => 'Färbt den Knopf auf dieser Seite. Leer = Farbe aus dem Design-Tab.', - 'kiosks_card_style' => 'Karte', - 'kiosks_card_light' => 'Hell', - 'kiosks_card_dark' => 'Dunkel', - 'kiosks_card_hint' => 'Auf Fotos wirkt die dunkle Karte meist ruhiger.', 'settings_tab_general' => 'Allgemein', 'settings_tab_defaults' => 'Voucher-Standards', 'settings_tab_cron' => 'Cron-Sync', diff --git a/lang/en.php b/lang/en.php index b3bf315..7a0ee39 100644 --- a/lang/en.php +++ b/lang/en.php @@ -470,73 +470,6 @@ return [ 'print_valid_until' => 'Valid until', 'print_devices' => 'devices', 'credit_by' => 'Developed by', - 'kiosk_default_headline' => 'Free guest Wi-Fi', - 'kiosk_default_subline' => 'Tap the button – you will get your personal access code right away.', - 'kiosk_button' => 'Get access code', - 'kiosk_working' => 'One moment…', - 'kiosk_ready' => 'Your access code', - 'kiosk_scan_code' => 'Scan the QR code or type the code', - 'kiosk_reset_in' => 'This screen resets in {seconds} seconds.', - 'kiosk_done' => 'Done', - 'kiosk_phone_hint' => 'Or scan with your phone and open it there', - 'kiosk_error_cooldown' => 'A code was just issued. Please wait {seconds} seconds.', - 'kiosk_error_limit' => 'No more access codes available today. Please ask at the reception desk.', - 'kiosk_error_generic' => 'The access code could not be created. Please try again.', - 'kiosk_unknown' => 'This page is not available', - 'kiosk_unknown_hint' => 'The link is invalid or has been deactivated.', - 'audit_action_voucher_kiosk' => 'Voucher taken at display', - 'nav_kiosks' => 'Display pages', - 'kiosks_title' => 'Display pages', - 'kiosks_subtitle' => 'Public pages for screens and tablets – guests get their access themselves.', - 'kiosks_add' => 'Add display page', - 'kiosks_edit' => 'Edit display page', - 'kiosks_empty' => 'No display page created yet.', - 'kiosks_no_sites' => 'Create a site first, then you can add a display page for it.', - 'kiosks_name' => 'Label', - 'kiosks_name_placeholder' => 'e.g. reception ground floor', - 'kiosks_name_hint' => 'Appears in the audit log and as the voucher name.', - 'kiosks_template' => 'Voucher profile', - 'kiosks_no_template' => 'Use default values', - 'kiosks_template_hint' => 'Defines duration, device count and bandwidth of the codes issued.', - 'kiosks_headline' => 'Headline on screen', - 'kiosks_subline' => 'Text below', - 'kiosks_daily_limit' => 'Codes per day', - 'kiosks_daily_limit_hint' => '0 = unlimited. Protects against misuse if the link gets shared.', - 'kiosks_cooldown' => 'Cooldown (seconds)', - 'kiosks_cooldown_hint' => 'Delay between two codes on this display.', - 'kiosks_display' => 'Display duration (seconds)', - 'kiosks_display_hint' => 'After that the screen returns to the start screen.', - 'kiosks_active' => 'Display page active', - 'kiosks_link' => 'Public link', - 'kiosks_open' => 'Open', - 'kiosks_qr' => 'QR code', - 'kiosks_qr_hint' => 'Put it up next to the screen or photograph it to open the page on a tablet.', - 'kiosks_today' => 'today', - 'kiosks_total' => 'in total', - 'kiosks_renew' => 'Renew link', - 'kiosks_renew_confirm' => 'Generate a new link? The previous link will stop working.', - 'kiosks_delete_confirm' => 'Really delete this display page?', - 'kiosks_added' => 'Display page created.', - 'kiosks_updated' => 'Display page saved.', - 'kiosks_deleted' => 'Display page deleted.', - 'kiosks_token_renewed' => 'New link generated – the old one is no longer valid.', - 'audit_action_kiosk_created' => 'Display page created', - 'audit_action_kiosk_updated' => 'Display page updated', - 'audit_action_kiosk_deleted' => 'Display page deleted', - 'kiosks_appearance' => 'Appearance', - 'kiosks_logo' => 'Logo', - 'kiosks_logo_hint' => 'Empty = general logo from the settings.', - 'kiosks_background' => 'Background image', - 'kiosks_background_hint' => 'Displayed full-bleed behind the card – e.g. a photo of the building.', - 'kiosks_overlay' => 'Image dimming (%)', - 'kiosks_overlay_hint' => '0–90%. Higher values keep the card readable on bright images.', - 'kiosks_accent' => 'Accent colour', - 'kiosks_accent_default' => 'Use default colour', - 'kiosks_accent_hint' => 'Colours the button on this page. Empty = colour from the Design tab.', - 'kiosks_card_style' => 'Card', - 'kiosks_card_light' => 'Light', - 'kiosks_card_dark' => 'Dark', - 'kiosks_card_hint' => 'On photos the dark card usually looks calmer.', 'settings_tab_general' => 'General', 'settings_tab_defaults' => 'Voucher Defaults', 'settings_tab_cron' => 'Cron Sync', diff --git a/phpstan.neon b/phpstan.neon index 2d305ab..95edbd1 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -9,4 +9,3 @@ parameters: - includes/ApiKey.php - includes/Ui.php - includes/Upload.php - - includes/Kiosk.php diff --git a/tests/KioskTest.php b/tests/KioskTest.php deleted file mode 100644 index 51280a9..0000000 --- a/tests/KioskTest.php +++ /dev/null @@ -1,205 +0,0 @@ - */ - private array $settings; - - /** @param array $settings */ - public function __construct(array $settings = []) - { - $this->settings = $settings; - } - - public function fetchOne($sql, $params = []) - { - return ['c' => $this->usedToday]; - } - - public function getSetting($key, $default = null) - { - return $this->settings[$key] ?? $default; - } -} - -/** - * Die Grenzen der Kiosk-Seite sind das, was sie vor Missbrauch schützt – - * der Link ist öffentlich, also muss diese Logik stimmen. - */ -class KioskTest extends TestCase -{ - /** @param array $overrides */ - private function kiosk(array $overrides = []): array - { - return array_merge([ - 'id' => 1, - 'daily_limit' => 100, - 'cooldown_seconds' => 20, - 'last_used_at' => null, - ], $overrides); - } - - public function testTokenHasFixedShape(): void - { - $token = \Kiosk::newToken(); - - $this->assertMatchesRegularExpression('/^[0-9a-f]{32}$/', $token); - $this->assertNotSame($token, \Kiosk::newToken(), 'Tokens dürfen sich nicht wiederholen'); - } - - public function testSanitizeTokenRejectsAnythingElse(): void - { - $valid = \Kiosk::newToken(); - - $this->assertSame($valid, \Kiosk::sanitizeToken($valid)); - $this->assertSame($valid, \Kiosk::sanitizeToken(strtoupper($valid))); - $this->assertSame('', \Kiosk::sanitizeToken('kurz')); - $this->assertSame('', \Kiosk::sanitizeToken("' OR 1=1 --")); - $this->assertSame('', \Kiosk::sanitizeToken(null)); - $this->assertSame('', \Kiosk::sanitizeToken($valid . 'ff')); - } - - public function testCooldownBlocksSecondCode(): void - { - $db = new FakeKioskDb(); - $kiosk = $this->kiosk(['last_used_at' => date('Y-m-d H:i:s', time() - 5)]); - - $result = \Kiosk::checkLimits($db, $kiosk); - - $this->assertFalse($result['allowed']); - $this->assertSame('cooldown', $result['reason']); - $this->assertGreaterThan(0, $result['wait']); - $this->assertLessThanOrEqual(20, $result['wait']); - } - - public function testCooldownExpires(): void - { - $db = new FakeKioskDb(); - $kiosk = $this->kiosk(['last_used_at' => date('Y-m-d H:i:s', time() - 60)]); - - $this->assertTrue(\Kiosk::checkLimits($db, $kiosk)['allowed']); - } - - public function testDailyLimitBlocks(): void - { - $db = new FakeKioskDb(); - $db->usedToday = 100; - - $result = \Kiosk::checkLimits($db, $this->kiosk()); - - $this->assertFalse($result['allowed']); - $this->assertSame('daily_limit', $result['reason']); - } - - public function testZeroMeansUnlimited(): void - { - $db = new FakeKioskDb(); - $db->usedToday = 5000; - - $kiosk = $this->kiosk(['daily_limit' => 0, 'cooldown_seconds' => 0]); - - $this->assertTrue(\Kiosk::checkLimits($db, $kiosk)['allowed']); - } - - public function testVoucherSettingsPreferTemplate(): void - { - $db = new FakeKioskDb(['default_max_uses' => '1', 'default_expire_minutes' => '480']); - $kiosk = $this->kiosk([ - 'tpl_max_uses' => 5, - 'tpl_expire_minutes' => 240, - 'qos_rate_max_down' => 20000, - 'qos_rate_max_up' => 5000, - 'qos_usage_quota' => 1024, - ]); - - $settings = \Kiosk::voucherSettings($db, $kiosk); - - $this->assertSame(5, $settings['max_uses']); - $this->assertSame(240, $settings['expire_minutes']); - $this->assertSame(20000, $settings['qos']['down']); - $this->assertSame(1024, $settings['qos']['quota_mb']); - } - - public function testVoucherSettingsFallBackToDefaults(): void - { - $db = new FakeKioskDb(['default_max_uses' => '3', 'default_expire_minutes' => '120']); - - $settings = \Kiosk::voucherSettings($db, $this->kiosk()); - - $this->assertSame(3, $settings['max_uses']); - $this->assertSame(120, $settings['expire_minutes']); - $this->assertSame(0, $settings['qos']['down']); - } - - public function testAppearanceFallsBackToSystemLogo(): void - { - $db = new FakeKioskDb(['logo_url' => 'uploads/global.png']); - - $look = \Kiosk::appearance($db, $this->kiosk()); - - $this->assertSame('uploads/global.png', $look['logo']); - $this->assertSame('', $look['background']); - $this->assertSame('', $look['accent'], 'Ohne eigene Farbe bleibt das Design-System zuständig'); - $this->assertSame('light', $look['card']); - } - - public function testAppearanceUsesOwnValues(): void - { - $db = new FakeKioskDb(['logo_url' => 'uploads/global.png']); - $kiosk = $this->kiosk([ - 'logo_url' => 'uploads/hotel.svg', - 'background_url' => 'uploads/lobby.jpg', - 'bg_overlay' => 60, - 'accent_color' => '#0F766E', - 'card_style' => 'dark', - ]); - - $look = \Kiosk::appearance($db, $kiosk); - - $this->assertSame('uploads/hotel.svg', $look['logo']); - $this->assertSame('uploads/lobby.jpg', $look['background']); - $this->assertSame(0.6, $look['overlay']); - $this->assertSame('#0f766e', $look['accent']); - $this->assertSame('dark', $look['card']); - } - - public function testAppearanceRejectsUnsafeColour(): void - { - $db = new FakeKioskDb(); - - // Der Wert landet in einem style-Attribut – nur echte Hex-Farben durch. - $look = \Kiosk::appearance($db, $this->kiosk(['accent_color' => 'red;background:url(evil)'])); - - $this->assertSame('', $look['accent']); - } - - public function testAppearanceClampsOverlay(): void - { - $db = new FakeKioskDb(); - - $this->assertSame(0.9, \Kiosk::appearance($db, $this->kiosk(['bg_overlay' => 250]))['overlay']); - $this->assertSame(0.0, \Kiosk::appearance($db, $this->kiosk(['bg_overlay' => -10]))['overlay']); - } - - public function testPublicUrl(): void - { - $token = \Kiosk::newToken(); - - $this->assertSame( - 'https://wlan.example.com/kiosk.php?k=' . $token, - \Kiosk::publicUrl($token, 'https://wlan.example.com/') - ); - } -} diff --git a/tools/README.md b/tools/README.md index f3f431f..b3ece02 100644 --- a/tools/README.md +++ b/tools/README.md @@ -38,11 +38,5 @@ php -S 127.0.0.1:8123 -t /tmp/uvt-demo & CHROME_BIN=/usr/bin/chromium python3 tools/screenshots.py ``` -## Actions-Runner (`tools/runner/`) - -Compose-Datei und Anleitung, um einen Forgejo-Actions-Runner einzurichten. -Ohne Runner bleiben `ci.yml` und `release.yml` in der Warteschlange stehen. -Details: [`tools/runner/README.md`](runner/README.md). - Die Skripte sind Hilfsmittel für die Entwicklung – im Betrieb werden sie nicht benötigt und sind per `.htaccess` nicht über HTTP erreichbar. diff --git a/tools/demo/build.py b/tools/demo/build.py index 27eb2e0..565c069 100755 --- a/tools/demo/build.py +++ b/tools/demo/build.py @@ -89,21 +89,6 @@ document.addEventListener('DOMContentLoaded', function () { patch(os.path.join(target, 'admin', 'api_keys.php'), '$keys = $db->fetchAll(', "if (($_GET['demo'] ?? '') === 'new') { $newKey = 'uvt_3f9a2c7d41e8b60592af18cc4d7e0b3a95f2617c'; }\n$keys = $db->fetchAll(") - # Kiosk: ausgegebenen Code zeigen, ohne echten Controller (?demo=code) - patch(os.path.join(target, 'kiosk.php'), - "$headline = trim((string)($kiosk['headline'] ?? ''))", - '''if (($_GET['demo'] ?? '') === 'code' && $kiosk) { - $voucher = ['code' => '4829-17364', 'site_name' => $kiosk['site_name'], 'max_uses' => 2, - 'expire_min' => 480, 'expiry_date' => '24.09.2026', 'expiry_time' => '08:00']; -} - -$headline = trim((string)($kiosk['headline'] ?? ''))''') - - # Kiosk-Verwaltung: Formular fuer den Screenshot geoeffnet zeigen - patch(os.path.join(target, 'admin', 'kiosks.php'), - '