diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5aaaad9..e88d61a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -29,9 +29,40 @@ jobs:
php -l "$f"
done
- - name: Validate JSON language/migration assets
+ - name: Validate language files
run: |
- php -r 'foreach (glob("lang/*.php") as $f) { $a = require $f; if (!is_array($a)) { fwrite(STDERR, "Bad lang file: $f\n"); exit(1);} } echo "lang OK\n";'
+ php -r '
+ $de = require "lang/de.php"; $en = require "lang/en.php";
+ if (!is_array($de) || !is_array($en)) { fwrite(STDERR, "Bad lang file\n"); exit(1); }
+ $missingEn = array_diff(array_keys($de), array_keys($en));
+ $missingDe = array_diff(array_keys($en), array_keys($de));
+ if ($missingEn || $missingDe) {
+ fwrite(STDERR, "Fehlend in en: " . implode(", ", $missingEn) . "\n");
+ fwrite(STDERR, "Fehlend in de: " . implode(", ", $missingDe) . "\n");
+ exit(1);
+ }
+ echo "lang OK (" . count($de) . " Schluessel)\n";'
+
+ - name: Check that every used translation key exists
+ run: |
+ php -r '
+ $de = require "lang/de.php";
+ $missing = [];
+ $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(".", FilesystemIterator::SKIP_DOTS));
+ foreach ($it as $file) {
+ $path = $file->getPathname();
+ if (substr($path, -4) !== ".php") continue;
+ if (strpos($path, "/vendor/") !== false || strpos($path, "/tools/") !== false) continue;
+ preg_match_all("/__\(\s*\x27([a-z0-9_]+)\x27/", file_get_contents($path), $m);
+ foreach ($m[1] as $key) {
+ if (!isset($de[$key]) && substr($key, -1) !== "_") { $missing[$key] = $path; }
+ }
+ }
+ if ($missing) {
+ foreach ($missing as $key => $path) { fwrite(STDERR, "Unbekannter Schluessel $key in $path\n"); }
+ exit(1);
+ }
+ echo "Alle verwendeten Schluessel vorhanden\n";'
test:
name: Unit Tests & Static Analysis
diff --git a/.htaccess b/.htaccess
new file mode 100644
index 0000000..2654739
--- /dev/null
+++ b/.htaccess
@@ -0,0 +1,26 @@
+# ---------------------------------------------------------------------------
+# Sicherheits-Header und Zugriffsschutz (Apache)
+# Nginx-Entsprechung siehe Readme.md, Abschnitt "Sicherheit".
+# ---------------------------------------------------------------------------
+
+
+
+
+Zugänge fĂźr externe Systeme â mit Scope und Rate-Limit.
+= __('api_subtitle') ?>
Kopieren Sie ihn jetzt â aus SicherheitsgrĂźnden wird er nicht erneut angezeigt.
+= __('api_new_key_hint') ?>
Noch keine API-SchlĂźssel angelegt.
+= __('api_none') ?>
| Name | Präfix | Scope | Limit | Status | Zuletzt genutzt | Erstellt von | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| = __('label_name') ?> | = __('api_col_prefix') ?> | = __('api_col_scope') ?> | = __('api_col_limit') ?> | = __('label_status') ?> | = __('api_col_last_used') ?> | = __('api_col_created_by') ?> | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| = htmlspecialchars($k['name']) ?> | -uvt_= htmlspecialchars($k['key_prefix']) ?>⌠|
- = ($k['scope'] ?? 'write') === 'read' ? 'nur Lesen' : 'Lesen+Erstellen' ?> | -= (int)($k['rate_limit'] ?? 0) === 0 ? 'â' : (int)$k['rate_limit'] . '/min' ?> | -= $k['is_active'] ? 'aktiv' : 'gesperrt' ?> | -= $k['last_used_at'] ? date('d.m.Y H:i', strtotime($k['last_used_at'])) : 'â' ?> | -= htmlspecialchars($k['creator'] ?? 'â') ?> | += htmlspecialchars($k['name']) ?> | +uvt_= htmlspecialchars($k['key_prefix']) ?>⌠|
+ = ($k['scope'] ?? 'write') === 'read' ? __('api_scope_read_short') : __('api_scope_write_short') ?> | += (int)($k['rate_limit'] ?? 0) === 0 ? 'â' : (int)$k['rate_limit'] . '/min' ?> | += $k['is_active'] ? __('api_state_active') : __('api_state_blocked') ?> | += $k['last_used_at'] ? date('d.m.Y H:i', strtotime($k['last_used_at'])) : 'â' ?> | += htmlspecialchars($k['creator'] ?? 'â') ?> | - = $k['is_active'] ? 'Sperren' : 'Aktivieren' ?> - LĂśschen + = $k['is_active'] ? __('api_action_block') : __('api_action_unblock') ?> + = __('btn_delete') ?> | ||||||||||||||||||||
| = __('audit_time') ?> | @@ -136,34 +127,34 @@ $actionLabels = [||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| + |
= date('d.m.Y', strtotime($log['created_at'])) ?> = date('H:i:s', strtotime($log['created_at'])) ?> |
- + | = htmlspecialchars($actionLabels[$log['action']] ?? $log['action']) ?> | -+ |
= htmlspecialchars($log['user_name']) ?> = htmlspecialchars($log['user_email'] ?? '') ?> - System/Anonym + = __('audit_system_anon') ?> |
- + |
= htmlspecialchars($log['entity_type']) ?>:= htmlspecialchars($log['entity_id'] ?? '') ?>
-
|
- + | = htmlspecialchars(mb_strimwidth($log['details'] ?? '-', 0, 80, 'âŚ')) ?> | -= htmlspecialchars($log['ip_address'] ?? '-') ?> | += htmlspecialchars($log['ip_address'] ?? '-') ?> | |||||||||||||||||||||||
| Name | Code / Fehler | Status | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| = __('label_name') ?> | = __('import_col_code') ?> | = __('label_status') ?> | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| = htmlspecialchars($r['name']) ?> | = htmlspecialchars($r['code']) ?> | = $r['ok'] ? 'â ' : 'â' ?> | ||||||||||||||||||||||||||||||||
| = __('label_created') ?> | = __('label_code') ?> | = __('label_site') ?> | = __('label_status') ?> | |||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| = date('d.m H:i', strtotime($v['created_at'])) ?> | -= htmlspecialchars($v['voucher_code']) ?> |
- = htmlspecialchars($v['site_name']??'') ?> | -+ | = date('d.m H:i', strtotime($v['created_at'])) ?> | += htmlspecialchars($v['voucher_code']) ?> |
+ = htmlspecialchars($v['site_name']??'') ?> | += __('status_'.$st) ?> | @@ -254,7 +255,7 @@ $currentPage = 'dashboard'; - + diff --git a/admin/integrations.php b/admin/integrations.php index 683c5ad..cfc7c83 100644 --- a/admin/integrations.php +++ b/admin/integrations.php @@ -49,13 +49,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save'])) { $db->setSetting('cleanup_audit_days', max(0, (int)($_POST['cleanup_audit_days'] ?? 0))); $db->setSetting('cleanup_login_days', max(0, (int)($_POST['cleanup_login_days'] ?? 30))); $auth->writeAuditLog($_SESSION['user_id'], 'settings_update', 'config', null, 'Integration/Wartung gespeichert'); - $success = 'Einstellungen gespeichert.'; + $success = __('settings_saved'); } } if (isset($_GET['test_webhook']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) { Notifier::send('â Test-Benachrichtigung vom UniFi Voucher System.', ['type' => 'test']); - $success = 'Test-Benachrichtigung gesendet (sofern Webhook aktiv & URL gĂźltig).'; + $success = __('int_webhook_test_sent'); } $enforce2fa = $db->getSetting('enforce_2fa_admins', '0') === '1'; @@ -92,12 +92,12 @@ $adminBase = ''; -|||||||||||||||||||||||||||
| Site | Gesamt | GĂźltig | Verwendet | Abgelaufen |
|---|---|---|---|---|
| = __('label_site') ?> | = __('label_total') ?> | = __('status_valid') ?> | = __('status_used') ?> | = __('status_expired') ?> |
|---|---|---|---|---|
| = htmlspecialchars($r['name']) ?> | = (int)$r['total'] ?> | = (int)$r['valid'] ?> | = (int)$r['used'] ?> | = (int)$r['expired'] ?> |
| Benutzer | Voucher erstellt |
|---|---|
| = __('label_user') ?> | = __('rep_col_created') ?> |
|---|---|
| = htmlspecialchars($r['name'] ?? 'â') ?> | = (int)$r['c'] ?> |
| Keine Daten | |
| = __('rep_no_data') ?> | |
Konto: = htmlspecialchars($user['email']) ?>
+= __('sec_account') ?> = htmlspecialchars($user['email']) ?>
Bewahren Sie diese sicher auf. Jeder Code funktioniert einmal, falls Sie keinen Zugriff auf Ihre App haben.
+ = __('sec_recovery_codes') ?> += __('sec_recovery_hint') ?>
Ihr Konto meldet sich Ăźber Microsoft 365 an. 2FA wird dort in Ihrem Microsoft-Konto verwaltet.
+= __('sec_m365_hint') ?>
-Bei jeder Anmeldung wird zusätzlich ein Code aus Ihrer Authenticator-App abgefragt.
- Verbleibende Recovery-Codes: = (int)$auth->backupCodesRemaining($user) ?>
= __('sec_active_hint') ?>
+ = __('sec_codes_left') ?> = (int)$auth->backupCodesRemaining($user) ?>
= __('settings_branding_intro') ?>
+= __('settings_login_intro') ?>
-Der Cron-Job synchronisiert automatisch alle Voucher von Ihren UniFi Controllern in die lokale Datenbank.
Kein Token konfiguriert.
+Kein Token konfiguriert.
Redirect URI: = $protocol . '://' . $host . $scriptPath ?>/m365_callback.php
= $autoDetectedUrl ?>{VOUCHER_CODE}{SITE_NAME}{MAX_USES}{APP_TITLE}{INSTRUCTIONS}{VOUCHER_CARD}{VOUCHER_CODE}{SITE_NAME}{MAX_USES}{APP_TITLE}{INSTRUCTIONS}= __('settings_card_hint') ?>
{USER_NAME}{CHANGES}{APP_TITLE}{SYSTEM_URL}{USER_NAME}{CHANGES}{APP_TITLE}{SYSTEM_URL}Kostenlosen API Key: tiny.cloud/signup
+= __('settings_editor_hint') ?> assets/vendor/ = __('settings_editor_hint2') ?>
{VOUCHER_CODE}{EXPIRY_DATE}{EXPIRY_TIME}{SITE_NAME}{MAX_USES}{APP_TITLE}{INSTRUCTIONS}{QR_CODE}{VOUCHER_CODE}{EXPIRY_DATE}{EXPIRY_TIME}{SITE_NAME}{MAX_USES}{APP_TITLE}{INSTRUCTIONS}= __('settings_qr_hint') ?>
= __('sites_none') ?>
| ' + . ' |
| '
+ . ' '
+ . htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8') . ' '
+ . ''
+ . htmlspecialchars($code, ENT_QUOTES, 'UTF-8') . ' '
+ . ''
+ . htmlspecialchars((string)$maxUses, ENT_QUOTES, 'UTF-8') . ' '
+ . htmlspecialchars(function_exists('__') ? __('label_devices') : 'Geräte', ENT_QUOTES, 'UTF-8') . ' '
+ . ' |