Erweiterte Features (2/2): Cleanup/DSGVO, Webhooks, REST-API, Backup, CI, Docker
- Auto-Cleanup/DSGVO: cron_cleanup.php (token-geschützt) löscht abgelaufene Voucher, Audit-Log, Login-Versuche und Reset-Tokens nach konfigurierbaren Fristen - Webhooks: includes/Notifier.php (Slack/Teams/generisch), Auslösung bei Voucher-Erstellung (Web + API) - REST-API: includes/ApiKey.php (SHA-256, Präfix-Lookup), api/bootstrap.php, api/vouchers.php (GET/POST), api/sites.php; admin/api_keys.php zur Verwaltung - Config-Backup/Restore: admin/backup.php (JSON Export/Import, Cron-Token geschützt) - admin/integrations.php: Trusted-Proxy, Webhook, Cleanup-Fristen - CI: .github/workflows/ci.yml (php -l auf 7.4 & 8.2, lang-Validierung) - Docker: Dockerfile, docker-compose.yml (MariaDB), entrypoint (config aus ENV), .dockerignore - Nav + i18n (DE/EN) für alle neuen Admin-Seiten
This commit is contained in:
parent
eec28f77b8
commit
9463486225
18 changed files with 972 additions and 0 deletions
9
.dockerignore
Normal file
9
.dockerignore
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
.git
|
||||
.github
|
||||
docs
|
||||
*.md
|
||||
config.php
|
||||
updater/storage/.version
|
||||
updater/storage/.maintenance
|
||||
updater/storage/.update-staging
|
||||
updater/storage/updater-settings.json
|
||||
34
.github/workflows/ci.yml
vendored
Normal file
34
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "**" ]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: PHP Lint
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
php: [ "7.4", "8.2" ]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP ${{ matrix.php }}
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
extensions: pdo, pdo_mysql, curl, mbstring, json
|
||||
coverage: none
|
||||
|
||||
- name: Syntax check all PHP files
|
||||
run: |
|
||||
set -e
|
||||
find . -name '*.git' -prune -o -name '*.php' -print | while read -r f; do
|
||||
php -l "$f"
|
||||
done
|
||||
|
||||
- name: Validate JSON language/migration assets
|
||||
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";'
|
||||
29
Dockerfile
Normal file
29
Dockerfile
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# UniFi Voucher Management System – Container-Image
|
||||
FROM php:8.2-apache
|
||||
|
||||
# PHP-Extensions
|
||||
RUN docker-php-ext-install pdo pdo_mysql \
|
||||
&& a2enmod rewrite headers
|
||||
|
||||
# Empfohlene PHP-Einstellungen
|
||||
RUN { \
|
||||
echo 'display_errors=0'; \
|
||||
echo 'log_errors=1'; \
|
||||
echo 'expose_php=0'; \
|
||||
echo 'upload_max_filesize=8M'; \
|
||||
echo 'post_max_size=8M'; \
|
||||
} > /usr/local/etc/php/conf.d/zz-voucher.ini
|
||||
|
||||
WORKDIR /var/www/html
|
||||
COPY . /var/www/html
|
||||
|
||||
# Laufzeit-Verzeichnis des Updaters beschreibbar machen
|
||||
RUN mkdir -p /var/www/html/updater/storage \
|
||||
&& chown -R www-data:www-data /var/www/html
|
||||
|
||||
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
EXPOSE 80
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
CMD ["apache2-foreground"]
|
||||
155
admin/api_keys.php
Normal file
155
admin/api_keys.php
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
<?php
|
||||
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/ApiKey.php';
|
||||
require_once __DIR__ . '/../includes/I18n.php';
|
||||
|
||||
$auth = new Auth();
|
||||
$auth->requireAdmin();
|
||||
I18n::init();
|
||||
|
||||
$db = Database::getInstance();
|
||||
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
|
||||
|
||||
$error = '';
|
||||
$success = '';
|
||||
$newKey = '';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_key'])) {
|
||||
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||
$error = __('error_csrf');
|
||||
} else {
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
if ($name === '') {
|
||||
$error = __('error_name_req');
|
||||
} else {
|
||||
$k = ApiKey::generate();
|
||||
$db->execute(
|
||||
"INSERT INTO api_keys (name, key_prefix, key_hash, created_by) VALUES (?, ?, ?, ?)",
|
||||
[$name, $k['prefix'], $k['hash'], $_SESSION['user_id']]
|
||||
);
|
||||
$auth->writeAuditLog($_SESSION['user_id'], 'api_key_create', 'api_key', null, "API-Key '$name' erstellt");
|
||||
$newKey = $k['plain'];
|
||||
$success = 'API-Schlüssel erstellt. Bitte JETZT kopieren – er wird nur einmal angezeigt!';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($_GET['toggle']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) {
|
||||
$row = $db->fetchOne("SELECT is_active FROM api_keys WHERE id = ?", [(int)$_GET['toggle']]);
|
||||
if ($row) {
|
||||
$db->query("UPDATE api_keys SET is_active = ? WHERE id = ?", [$row['is_active'] ? 0 : 1, (int)$_GET['toggle']]);
|
||||
$success = 'Status aktualisiert.';
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($_GET['delete']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) {
|
||||
$db->query("DELETE FROM api_keys WHERE id = ?", [(int)$_GET['delete']]);
|
||||
$auth->writeAuditLog($_SESSION['user_id'], 'api_key_delete', 'api_key', (int)$_GET['delete'], 'API-Key gelöscht');
|
||||
$success = 'API-Schlüssel gelöscht.';
|
||||
}
|
||||
|
||||
$keys = $db->fetchAll("SELECT k.*, u.name AS creator FROM api_keys k LEFT JOIN users u ON k.created_by = u.id ORDER BY k.created_at DESC");
|
||||
$csrf = $auth->getCsrfToken();
|
||||
$currentPage = 'api_keys';
|
||||
$adminBase = '';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="<?= I18n::getLanguage() ?>">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>API-Schlüssel – <?= htmlspecialchars($appTitle) ?></title>
|
||||
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||
<style>
|
||||
.card { background: var(--bg-card); border:1px solid var(--border-color); border-radius:14px; padding:24px; margin-bottom:22px; box-shadow:0 4px 14px var(--shadow); }
|
||||
.card h2 { font-size:16px; margin-bottom:16px; color:var(--text-primary); }
|
||||
table { width:100%; border-collapse:collapse; }
|
||||
th,td { text-align:left; padding:11px 8px; font-size:14px; border-bottom:1px solid var(--border-color); color:var(--text-primary); }
|
||||
th { color:var(--text-muted); font-weight:600; }
|
||||
code { font-family:monospace; background:var(--bg-hover); padding:2px 6px; border-radius:5px; }
|
||||
.btn { padding:10px 16px; border:none; border-radius:8px; font-weight:600; cursor:pointer; font-size:14px; text-decoration:none; display:inline-block; }
|
||||
.btn-primary { background:var(--accent,#667eea); color:#fff; }
|
||||
.input { width:100%; padding:11px; border:2px solid var(--border-color); border-radius:8px; background:var(--bg-input,#fff); color:var(--text-primary); font-size:14px; }
|
||||
.alert { padding:12px 14px; border-radius:9px; font-size:14px; margin-bottom:18px; }
|
||||
.alert-error { background:#fee; border:1px solid #fcc; color:#c33; }
|
||||
.alert-ok { background:#efe; border:1px solid #cfc; color:#2a7; }
|
||||
.keybox { background:#0f1117; color:#7CFFB2; font-family:monospace; padding:14px; border-radius:8px; word-break:break-all; font-size:15px; margin-top:10px; }
|
||||
.badge { padding:3px 9px; border-radius:6px; font-size:12px; font-weight:600; }
|
||||
.b-on { background:#e3f6ea; color:#2a7; } .b-off { background:#fdeaea; color:#c33; }
|
||||
.a-link { color:var(--accent,#667eea); text-decoration:none; margin-right:10px; font-size:13px; }
|
||||
.muted { color:var(--text-muted); font-size:13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 style="font-size:24px;margin-bottom:20px;color:var(--text-primary);">🔑 API-Schlüssel</h1>
|
||||
|
||||
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
||||
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
|
||||
|
||||
<?php if ($newKey): ?>
|
||||
<div class="card">
|
||||
<h2>Neuer Schlüssel</h2>
|
||||
<p class="muted">Kopieren Sie ihn jetzt – aus Sicherheitsgründen wird er nicht erneut angezeigt.</p>
|
||||
<div class="keybox"><?= htmlspecialchars($newKey) ?></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card">
|
||||
<h2>Neuen API-Schlüssel erstellen</h2>
|
||||
<form method="post" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||
<div style="flex:1;min-width:220px;">
|
||||
<label class="muted" style="display:block;margin-bottom:6px;">Bezeichnung</label>
|
||||
<input class="input" type="text" name="name" placeholder="z.B. Buchungssystem, Terminal Foyer" required>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit" name="create_key">Erstellen</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Vorhandene Schlüssel</h2>
|
||||
<?php if (empty($keys)): ?>
|
||||
<p class="muted">Noch keine API-Schlüssel angelegt.</p>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<tr><th>Name</th><th>Präfix</th><th>Status</th><th>Zuletzt genutzt</th><th>Erstellt von</th><th></th></tr>
|
||||
<?php foreach ($keys as $k): ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars($k['name']) ?></td>
|
||||
<td><code>uvt_<?= htmlspecialchars($k['key_prefix']) ?>…</code></td>
|
||||
<td><span class="badge <?= $k['is_active'] ? 'b-on' : 'b-off' ?>"><?= $k['is_active'] ? 'aktiv' : 'gesperrt' ?></span></td>
|
||||
<td class="muted"><?= $k['last_used_at'] ? htmlspecialchars($k['last_used_at']) : '–' ?></td>
|
||||
<td class="muted"><?= htmlspecialchars($k['creator'] ?? '–') ?></td>
|
||||
<td style="text-align:right;white-space:nowrap;">
|
||||
<a class="a-link" href="?toggle=<?= (int)$k['id'] ?>&token=<?= urlencode($csrf) ?>"><?= $k['is_active'] ? 'Sperren' : 'Aktivieren' ?></a>
|
||||
<a class="a-link" style="color:#e25555;" href="?delete=<?= (int)$k['id'] ?>&token=<?= urlencode($csrf) ?>" onclick="return confirm('Schlüssel löschen?');">Löschen</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Verwendung</h2>
|
||||
<p class="muted" style="margin-bottom:10px;">Authentifizierung per Header <code>Authorization: Bearer <key></code> oder <code>X-API-Key: <key></code>.</p>
|
||||
<pre class="keybox" style="color:#cdd3e0;white-space:pre-wrap;"># Voucher erstellen
|
||||
curl -X POST https://IHRE-DOMAIN/api/vouchers.php \
|
||||
-H "Authorization: Bearer uvt_…" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"site_id":1,"name":"API Gast","max_uses":1,"expire_minutes":480}'
|
||||
|
||||
# Sites auflisten
|
||||
curl https://IHRE-DOMAIN/api/sites.php -H "X-API-Key: uvt_…"</pre>
|
||||
</div>
|
||||
|
||||
</div><!-- /main-content -->
|
||||
<script src="../assets/global.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
159
admin/backup.php
Normal file
159
admin/backup.php
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
<?php
|
||||
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';
|
||||
|
||||
$auth = new Auth();
|
||||
$auth->requireAdmin();
|
||||
I18n::init();
|
||||
|
||||
$db = Database::getInstance();
|
||||
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
|
||||
|
||||
$error = '';
|
||||
$success = '';
|
||||
|
||||
// Export: JSON-Download
|
||||
if (isset($_GET['export']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) {
|
||||
$export = [
|
||||
'meta' => [
|
||||
'app' => 'unifi-voucher-tool',
|
||||
'version' => '2.2.0',
|
||||
'exported_at'=> date('c'),
|
||||
'note' => 'Site-Passwörter sind mit dem APP_KEY dieser Installation verschlüsselt.',
|
||||
],
|
||||
'settings' => $db->fetchAll("SELECT setting_key, setting_value FROM settings"),
|
||||
'sites' => $db->fetchAll("SELECT name, site_id, unifi_controller_url, unifi_username, unifi_password, is_active, public_access FROM sites"),
|
||||
'voucher_templates' => $db->fetchAll("SELECT name, max_uses, expire_minutes, description, qos_rate_max_down, qos_rate_max_up, qos_usage_quota, is_active FROM voucher_templates"),
|
||||
];
|
||||
$auth->writeAuditLog($_SESSION['user_id'], 'config_export', 'config', null, 'Konfiguration exportiert');
|
||||
header('Content-Type: application/json');
|
||||
header('Content-Disposition: attachment; filename="voucher-config-' . date('Y-m-d') . '.json"');
|
||||
echo json_encode($export, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Import
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['import'])) {
|
||||
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||
$error = __('error_csrf');
|
||||
} elseif (empty($_FILES['backup']['tmp_name'])) {
|
||||
$error = 'Bitte eine Backup-Datei auswählen.';
|
||||
} else {
|
||||
$raw = file_get_contents($_FILES['backup']['tmp_name']);
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data) || ($data['meta']['app'] ?? '') !== 'unifi-voucher-tool') {
|
||||
$error = 'Ungültige oder fremde Backup-Datei.';
|
||||
} else {
|
||||
$importSites = isset($_POST['import_sites']);
|
||||
$importTemplates = isset($_POST['import_templates']);
|
||||
$importSettings = isset($_POST['import_settings']);
|
||||
$counts = ['settings' => 0, 'sites' => 0, 'templates' => 0];
|
||||
|
||||
try {
|
||||
if ($importSettings && !empty($data['settings'])) {
|
||||
foreach ($data['settings'] as $s) {
|
||||
// Cron-Token NICHT überschreiben (Sicherheit der Ziel-Installation)
|
||||
if (($s['setting_key'] ?? '') === 'cron_token') continue;
|
||||
$db->setSetting($s['setting_key'], $s['setting_value']);
|
||||
$counts['settings']++;
|
||||
}
|
||||
}
|
||||
if ($importSites && !empty($data['sites'])) {
|
||||
foreach ($data['sites'] as $s) {
|
||||
$exists = $db->fetchOne("SELECT id FROM sites WHERE name = ? AND site_id = ?", [$s['name'], $s['site_id']]);
|
||||
if ($exists) {
|
||||
$db->query(
|
||||
"UPDATE sites SET unifi_controller_url=?, unifi_username=?, unifi_password=?, is_active=?, public_access=? WHERE id=?",
|
||||
[$s['unifi_controller_url'], $s['unifi_username'], $s['unifi_password'], (int)$s['is_active'], (int)$s['public_access'], $exists['id']]
|
||||
);
|
||||
} else {
|
||||
$db->query(
|
||||
"INSERT INTO sites (name, site_id, unifi_controller_url, unifi_username, unifi_password, is_active, public_access) VALUES (?,?,?,?,?,?,?)",
|
||||
[$s['name'], $s['site_id'], $s['unifi_controller_url'], $s['unifi_username'], $s['unifi_password'], (int)$s['is_active'], (int)$s['public_access']]
|
||||
);
|
||||
}
|
||||
$counts['sites']++;
|
||||
}
|
||||
}
|
||||
if ($importTemplates && !empty($data['voucher_templates'])) {
|
||||
foreach ($data['voucher_templates'] as $t) {
|
||||
$exists = $db->fetchOne("SELECT id FROM voucher_templates WHERE name = ?", [$t['name']]);
|
||||
if (!$exists) {
|
||||
$db->query(
|
||||
"INSERT INTO voucher_templates (name, max_uses, expire_minutes, description, qos_rate_max_down, qos_rate_max_up, qos_usage_quota, is_active) VALUES (?,?,?,?,?,?,?,?)",
|
||||
[$t['name'], (int)$t['max_uses'], (int)$t['expire_minutes'], $t['description'] ?? null,
|
||||
$t['qos_rate_max_down'] ?? null, $t['qos_rate_max_up'] ?? null, $t['qos_usage_quota'] ?? null, (int)($t['is_active'] ?? 1)]
|
||||
);
|
||||
$counts['templates']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
$auth->writeAuditLog($_SESSION['user_id'], 'config_import', 'config', null, 'Konfiguration importiert');
|
||||
$success = "Import abgeschlossen: {$counts['settings']} Einstellungen, {$counts['sites']} Sites, {$counts['templates']} Profile.";
|
||||
} catch (Exception $e) {
|
||||
$error = 'Import-Fehler: ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$csrf = $auth->getCsrfToken();
|
||||
$currentPage = 'backup';
|
||||
$adminBase = '';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="<?= I18n::getLanguage() ?>">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Backup & Restore – <?= htmlspecialchars($appTitle) ?></title>
|
||||
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||
<style>
|
||||
.card { background:var(--bg-card); border:1px solid var(--border-color); border-radius:14px; padding:24px; margin-bottom:22px; box-shadow:0 4px 14px var(--shadow); max-width:680px; }
|
||||
.card h2 { font-size:16px; margin-bottom:12px; color:var(--text-primary); }
|
||||
.muted { color:var(--text-muted); font-size:13px; margin-bottom:14px; }
|
||||
.btn { padding:11px 18px; border:none; border-radius:8px; font-weight:600; cursor:pointer; font-size:14px; text-decoration:none; display:inline-block; }
|
||||
.btn-primary { background:var(--accent,#667eea); color:#fff; }
|
||||
.btn-secondary { background:var(--bg-hover); color:var(--text-primary); border:1px solid var(--border-color); }
|
||||
.alert { padding:12px 14px; border-radius:9px; font-size:14px; margin-bottom:18px; max-width:680px; }
|
||||
.alert-error { background:#fee; border:1px solid #fcc; color:#c33; }
|
||||
.alert-ok { background:#efe; border:1px solid #cfc; color:#2a7; }
|
||||
label.chk { display:flex; align-items:center; gap:9px; margin:8px 0; color:var(--text-primary); font-size:14px; }
|
||||
input[type=file] { margin:10px 0; color:var(--text-primary); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 style="font-size:24px;margin-bottom:20px;color:var(--text-primary);">💾 Backup & Restore</h1>
|
||||
|
||||
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
||||
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
|
||||
|
||||
<div class="card">
|
||||
<h2>Export</h2>
|
||||
<p class="muted">Lädt Einstellungen, Sites und Voucher-Profile als JSON. Site-Passwörter bleiben mit dem <code>APP_KEY</code> dieser Installation verschlüsselt – ein Restore auf einer Installation mit anderem APP_KEY kann sie nicht entschlüsseln.</p>
|
||||
<a class="btn btn-primary" href="?export=1&token=<?= urlencode($csrf) ?>">Konfiguration exportieren</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Import / Restore</h2>
|
||||
<p class="muted">Vorhandene Sites werden anhand von Name + Site-ID aktualisiert, neue hinzugefügt. Profile werden nur angelegt, wenn der Name noch nicht existiert. Der Cron-Token wird nie überschrieben.</p>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||
<input type="file" name="backup" accept="application/json,.json" required><br>
|
||||
<label class="chk"><input type="checkbox" name="import_settings" checked> Einstellungen</label>
|
||||
<label class="chk"><input type="checkbox" name="import_sites" checked> Sites</label>
|
||||
<label class="chk"><input type="checkbox" name="import_templates" checked> Voucher-Profile</label>
|
||||
<button class="btn btn-primary" type="submit" name="import" style="margin-top:12px;" onclick="return confirm('Import jetzt durchführen?');">Importieren</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div><!-- /main-content -->
|
||||
<script src="../assets/global.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
118
admin/integrations.php
Normal file
118
admin/integrations.php
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
<?php
|
||||
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/Notifier.php';
|
||||
require_once __DIR__ . '/../includes/I18n.php';
|
||||
|
||||
$auth = new Auth();
|
||||
$auth->requireAdmin();
|
||||
I18n::init();
|
||||
|
||||
$db = Database::getInstance();
|
||||
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
|
||||
|
||||
$error = '';
|
||||
$success = '';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save'])) {
|
||||
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||
$error = __('error_csrf');
|
||||
} else {
|
||||
$db->setSetting('trusted_proxy', trim($_POST['trusted_proxy'] ?? ''));
|
||||
$db->setSetting('webhook_enabled', isset($_POST['webhook_enabled']) ? '1' : '0');
|
||||
$db->setSetting('webhook_url', trim($_POST['webhook_url'] ?? ''));
|
||||
$db->setSetting('cleanup_expired_days', max(0, (int)($_POST['cleanup_expired_days'] ?? 0)));
|
||||
$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.';
|
||||
}
|
||||
}
|
||||
|
||||
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).';
|
||||
}
|
||||
|
||||
$trustedProxy = $db->getSetting('trusted_proxy', '');
|
||||
$webhookEnabled = $db->getSetting('webhook_enabled', '0') === '1';
|
||||
$webhookUrl = $db->getSetting('webhook_url', '');
|
||||
$cleanupExpired = (int)$db->getSetting('cleanup_expired_days', 0);
|
||||
$cleanupAudit = (int)$db->getSetting('cleanup_audit_days', 0);
|
||||
$cleanupLogin = (int)$db->getSetting('cleanup_login_days', 30);
|
||||
$lastCleanup = $db->getSetting('last_cleanup', '');
|
||||
$csrf = $auth->getCsrfToken();
|
||||
$currentPage = 'integrations';
|
||||
$adminBase = '';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="<?= I18n::getLanguage() ?>">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Integration & Wartung – <?= htmlspecialchars($appTitle) ?></title>
|
||||
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||
<style>
|
||||
.card { background:var(--bg-card); border:1px solid var(--border-color); border-radius:14px; padding:24px; margin-bottom:22px; box-shadow:0 4px 14px var(--shadow); max-width:680px; }
|
||||
.card h2 { font-size:16px; margin-bottom:6px; color:var(--text-primary); }
|
||||
.muted { color:var(--text-muted); font-size:13px; margin-bottom:14px; }
|
||||
label { display:block; font-size:14px; color:var(--text-secondary); margin:14px 0 6px; }
|
||||
.input { width:100%; padding:11px; border:2px solid var(--border-color); border-radius:8px; background:var(--bg-input,#fff); color:var(--text-primary); font-size:14px; }
|
||||
.row { display:grid; grid-template-columns:1fr 1fr 1fr; gap:12px; }
|
||||
.chk { display:flex; align-items:center; gap:9px; margin-top:14px; color:var(--text-primary); font-size:14px; }
|
||||
.btn { padding:11px 18px; border:none; border-radius:8px; font-weight:600; cursor:pointer; font-size:14px; text-decoration:none; display:inline-block; }
|
||||
.btn-primary { background:var(--accent,#667eea); color:#fff; } .btn-secondary { background:var(--bg-hover); color:var(--text-primary); border:1px solid var(--border-color); }
|
||||
.alert { padding:12px 14px; border-radius:9px; font-size:14px; margin-bottom:18px; max-width:680px; }
|
||||
.alert-error { background:#fee; border:1px solid #fcc; color:#c33; } .alert-ok { background:#efe; border:1px solid #cfc; color:#2a7; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 style="font-size:24px;margin-bottom:20px;color:var(--text-primary);">🔧 Integration & Wartung</h1>
|
||||
|
||||
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
||||
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
|
||||
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||
|
||||
<div class="card">
|
||||
<h2>Reverse-Proxy</h2>
|
||||
<p class="muted">IP-Adressen vertrauenswürdiger Proxies (kommasepariert). Nur dann wird die echte Client-IP aus <code>X-Forwarded-For</code> für Rate-Limit & Audit verwendet.</p>
|
||||
<input class="input" type="text" name="trusted_proxy" value="<?= htmlspecialchars($trustedProxy) ?>" placeholder="z.B. 10.0.0.1, 172.18.0.1">
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Webhook-Benachrichtigungen</h2>
|
||||
<p class="muted">Slack-, Microsoft-Teams- oder generische JSON-Webhook-URL. Wird bei Voucher-Erstellung ausgelöst.</p>
|
||||
<label class="chk"><input type="checkbox" name="webhook_enabled" <?= $webhookEnabled ? 'checked' : '' ?>> Webhook aktiv</label>
|
||||
<label>Webhook-URL</label>
|
||||
<input class="input" type="url" name="webhook_url" value="<?= htmlspecialchars($webhookUrl) ?>" placeholder="https://hooks.slack.com/services/…">
|
||||
<div style="margin-top:12px;">
|
||||
<a class="btn btn-secondary" href="?test_webhook=1&token=<?= urlencode($csrf) ?>">Test senden</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Datenhaltung & Cleanup (DSGVO)</h2>
|
||||
<p class="muted">Aufbewahrungsfristen in Tagen (0 = deaktiviert). Ausführung per <code>cron_cleanup.php</code> (täglich empfohlen).
|
||||
<?php if ($lastCleanup): ?><br>Letzter Lauf: <?= htmlspecialchars($lastCleanup) ?><?php endif; ?>
|
||||
</p>
|
||||
<div class="row">
|
||||
<div><label>Abgelaufene Voucher</label><input class="input" type="number" min="0" name="cleanup_expired_days" value="<?= $cleanupExpired ?>"></div>
|
||||
<div><label>Audit-Log</label><input class="input" type="number" min="0" name="cleanup_audit_days" value="<?= $cleanupAudit ?>"></div>
|
||||
<div><label>Login-Versuche</label><input class="input" type="number" min="0" name="cleanup_login_days" value="<?= $cleanupLogin ?>"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" type="submit" name="save">Speichern</button>
|
||||
</form>
|
||||
|
||||
</div><!-- /main-content -->
|
||||
<script src="../assets/global.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
39
api/bootstrap.php
Normal file
39
api/bootstrap.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
/**
|
||||
* Gemeinsamer Bootstrap für die REST-API-Endpunkte.
|
||||
* Lädt die Basis, authentifiziert den API-Schlüssel und stellt JSON-Helfer bereit.
|
||||
*/
|
||||
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/UniFiController.php';
|
||||
require_once __DIR__ . '/../includes/ApiKey.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
function api_json($data, $status = 200) {
|
||||
http_response_code($status);
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
function api_body() {
|
||||
$raw = file_get_contents('php://input');
|
||||
if ($raw === '' || $raw === false) return [];
|
||||
$data = json_decode($raw, true);
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
} catch (Exception $e) {
|
||||
api_json(['error' => 'database_unavailable'], 503);
|
||||
}
|
||||
|
||||
$apiKeyRow = ApiKey::verify(ApiKey::fromRequest(), $db);
|
||||
if (!$apiKeyRow) {
|
||||
api_json(['error' => 'unauthorized', 'message' => 'Gültiger API-Schlüssel erforderlich (Authorization: Bearer …)'], 401);
|
||||
}
|
||||
14
api/sites.php
Normal file
14
api/sites.php
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/sites.php – Liste aktiver Sites (für API-Clients).
|
||||
*/
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
api_json(['error' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$sites = $db->fetchAll("SELECT id, name, site_id FROM sites WHERE is_active = 1 ORDER BY name");
|
||||
api_json(['sites' => array_map(function ($s) {
|
||||
return ['id' => (int)$s['id'], 'name' => $s['name'], 'site_id' => $s['site_id']];
|
||||
}, $sites)]);
|
||||
89
api/vouchers.php
Normal file
89
api/vouchers.php
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
/**
|
||||
* REST-Endpunkt für Voucher.
|
||||
*
|
||||
* GET /api/vouchers.php?site_id=<id> – Voucher einer Site auflisten (aus DB)
|
||||
* POST /api/vouchers.php – Voucher erstellen
|
||||
* Body (JSON): {
|
||||
* "site_id": 1, "name": "API Gast", "max_uses": 1,
|
||||
* "expire_minutes": 480,
|
||||
* "qos": { "down": 10000, "up": 2000, "quota_mb": 500 } (optional)
|
||||
* }
|
||||
*/
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once __DIR__ . '/../includes/Notifier.php';
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
if ($method === 'GET') {
|
||||
$siteId = (int)($_GET['site_id'] ?? 0);
|
||||
if ($siteId <= 0) {
|
||||
api_json(['error' => 'invalid_request', 'message' => 'site_id erforderlich'], 400);
|
||||
}
|
||||
$rows = $db->fetchAll(
|
||||
"SELECT voucher_code, voucher_name, max_uses, expire_minutes, status, used_count, created_at, expires_at
|
||||
FROM vouchers WHERE site_id = ? ORDER BY created_at DESC LIMIT 200",
|
||||
[$siteId]
|
||||
);
|
||||
api_json(['vouchers' => $rows]);
|
||||
}
|
||||
|
||||
if ($method === 'POST') {
|
||||
$body = api_body();
|
||||
$siteId = (int)($body['site_id'] ?? 0);
|
||||
$name = trim((string)($body['name'] ?? ''));
|
||||
$maxUses = (int)($body['max_uses'] ?? 1);
|
||||
$expireMinutes = (int)($body['expire_minutes'] ?? 480);
|
||||
|
||||
if ($siteId <= 0) api_json(['error' => 'invalid_request', 'message' => 'site_id erforderlich'], 400);
|
||||
if ($name === '') api_json(['error' => 'invalid_request', 'message' => 'name erforderlich'], 400);
|
||||
if ($maxUses < 1) $maxUses = 1;
|
||||
if ($expireMinutes < 1) $expireMinutes = 480;
|
||||
|
||||
$site = $db->fetchOne("SELECT * FROM sites WHERE id = ? AND is_active = 1", [$siteId]);
|
||||
if (!$site) {
|
||||
api_json(['error' => 'not_found', 'message' => 'Site nicht gefunden'], 404);
|
||||
}
|
||||
|
||||
$qosIn = is_array($body['qos'] ?? null) ? $body['qos'] : [];
|
||||
$qos = [
|
||||
'down' => max(0, (int)($qosIn['down'] ?? 0)),
|
||||
'up' => max(0, (int)($qosIn['up'] ?? 0)),
|
||||
'quota_mb' => max(0, (int)($qosIn['quota_mb'] ?? 0)),
|
||||
];
|
||||
|
||||
try {
|
||||
$fullName = date('Y-m-d') . '_' . $name;
|
||||
$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'])) {
|
||||
api_json(['error' => 'upstream_error', 'message' => 'UniFi lieferte keinen gültigen Voucher'], 502);
|
||||
}
|
||||
|
||||
$db->execute(
|
||||
"INSERT INTO vouchers (site_id, user_id, voucher_code, voucher_name, max_uses, expire_minutes, unifi_voucher_id)
|
||||
VALUES (?, NULL, ?, ?, ?, ?, ?)",
|
||||
[$siteId, $voucher['code'], $fullName, $maxUses, $expireMinutes, $voucher['unifi_id'] ?? null]
|
||||
);
|
||||
|
||||
Notifier::voucherCreated(1, $site['name'], 'API: ' . $apiKeyRow['name']);
|
||||
|
||||
api_json([
|
||||
'success' => true,
|
||||
'code' => $voucher['code'],
|
||||
'formatted_code' => $voucher['formatted_code'],
|
||||
'site' => $site['name'],
|
||||
'max_uses' => $maxUses,
|
||||
'expire_minutes' => $expireMinutes,
|
||||
], 201);
|
||||
} catch (Exception $e) {
|
||||
api_json(['error' => 'server_error', 'message' => $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
api_json(['error' => 'method_not_allowed'], 405);
|
||||
117
cron_cleanup.php
Normal file
117
cron_cleanup.php
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
<?php
|
||||
/**
|
||||
* Cron-Script für Aufräumarbeiten & Datenhaltung (DSGVO).
|
||||
*
|
||||
* Aufruf via URL: https://domain.de/cron_cleanup.php?token=DEIN_TOKEN
|
||||
* Aufruf via CLI: php cron_cleanup.php DEIN_TOKEN
|
||||
*
|
||||
* Empfohlenes Intervall: täglich.
|
||||
*
|
||||
* Gesteuert über Einstellungen (0 = deaktiviert):
|
||||
* cleanup_expired_days – abgelaufene Voucher nach N Tagen aus DB löschen
|
||||
* cleanup_audit_days – Audit-Log-Einträge nach N Tagen löschen
|
||||
* cleanup_login_days – Login-Versuche nach N Tagen löschen (Default 30)
|
||||
* Abgelaufene/benutzte Passwort-Reset-Tokens werden immer entfernt.
|
||||
*/
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 0);
|
||||
|
||||
$isCli = php_sapi_name() === 'cli';
|
||||
if (!$isCli) {
|
||||
header('Content-Type: application/json');
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/config.php';
|
||||
require_once __DIR__ . '/includes/Database.php';
|
||||
|
||||
function out($data, $isCli) {
|
||||
if ($isCli) {
|
||||
echo ($data['success'] ? 'SUCCESS' : 'ERROR') . ': ' . ($data['message'] ?? '') . PHP_EOL;
|
||||
if (!empty($data['deleted'])) {
|
||||
foreach ($data['deleted'] as $k => $v) echo " - $k: $v" . PHP_EOL;
|
||||
}
|
||||
} else {
|
||||
echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
} catch (Exception $e) {
|
||||
out(['success' => false, 'message' => 'DB-Fehler: ' . $e->getMessage()], $isCli);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Token prüfen (gleicher Token wie cron_sync)
|
||||
$cronToken = $db->getSetting('cron_token', '');
|
||||
$provided = $isCli ? ($argv[1] ?? '') : ($_GET['token'] ?? '');
|
||||
if (empty($cronToken)) {
|
||||
out(['success' => false, 'message' => 'Kein Cron-Token konfiguriert.'], $isCli);
|
||||
exit;
|
||||
}
|
||||
if (!hash_equals($cronToken, (string)$provided)) {
|
||||
out(['success' => false, 'message' => 'Ungültiger Token'], $isCli);
|
||||
exit;
|
||||
}
|
||||
|
||||
$expiredDays = (int)$db->getSetting('cleanup_expired_days', 0);
|
||||
$auditDays = (int)$db->getSetting('cleanup_audit_days', 0);
|
||||
$loginDays = (int)$db->getSetting('cleanup_login_days', 30);
|
||||
|
||||
$deleted = [];
|
||||
|
||||
try {
|
||||
// Abgelaufene Voucher aus der DB entfernen (nur lokale Historie)
|
||||
if ($expiredDays > 0) {
|
||||
$stmt = $db->query(
|
||||
"DELETE FROM vouchers WHERE status = 'expired'
|
||||
AND expires_at IS NOT NULL AND expires_at < DATE_SUB(NOW(), INTERVAL ? DAY)",
|
||||
[$expiredDays]
|
||||
);
|
||||
$deleted['vouchers_expired'] = $stmt->rowCount();
|
||||
}
|
||||
|
||||
// Audit-Log nach Aufbewahrungsfrist löschen
|
||||
if ($auditDays > 0) {
|
||||
try {
|
||||
$stmt = $db->query(
|
||||
"DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)",
|
||||
[$auditDays]
|
||||
);
|
||||
$deleted['audit_log'] = $stmt->rowCount();
|
||||
} catch (Exception $e) { /* Tabelle evtl. nicht vorhanden */ }
|
||||
}
|
||||
|
||||
// Alte Login-Versuche entfernen
|
||||
if ($loginDays > 0) {
|
||||
try {
|
||||
$stmt = $db->query(
|
||||
"DELETE FROM login_attempts WHERE attempted_at < DATE_SUB(NOW(), INTERVAL ? DAY)",
|
||||
[$loginDays]
|
||||
);
|
||||
$deleted['login_attempts'] = $stmt->rowCount();
|
||||
} catch (Exception $e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// Abgelaufene / benutzte Passwort-Reset-Tokens immer entfernen
|
||||
try {
|
||||
$stmt = $db->query(
|
||||
"DELETE FROM password_reset_tokens WHERE used = 1 OR expires_at < NOW()"
|
||||
);
|
||||
$deleted['reset_tokens'] = $stmt->rowCount();
|
||||
} catch (Exception $e) { /* Tabelle evtl. nicht vorhanden */ }
|
||||
|
||||
$db->query(
|
||||
"INSERT INTO settings (setting_key, setting_value) VALUES ('last_cleanup', NOW())
|
||||
ON DUPLICATE KEY UPDATE setting_value = NOW()"
|
||||
);
|
||||
|
||||
out([
|
||||
'success' => true,
|
||||
'message' => 'Cleanup abgeschlossen',
|
||||
'deleted' => $deleted,
|
||||
'timestamp' => date('Y-m-d H:i:s'),
|
||||
], $isCli);
|
||||
} catch (Exception $e) {
|
||||
out(['success' => false, 'message' => 'Fehler: ' . $e->getMessage(), 'deleted' => $deleted], $isCli);
|
||||
}
|
||||
38
docker-compose.yml
Normal file
38
docker-compose.yml
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "8080:80"
|
||||
environment:
|
||||
DB_HOST: db
|
||||
DB_NAME: unifi_voucher
|
||||
DB_USER: unifi_voucher
|
||||
DB_PASS: change_me
|
||||
# Dauerhaft setzen! Sonst koennen verschluesselte Werte nach Neustart
|
||||
# nicht mehr gelesen werden. Erzeugen: php -r "echo base64_encode(random_bytes(32));"
|
||||
APP_KEY: ""
|
||||
TZ: Europe/Berlin
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
db:
|
||||
image: mariadb:11
|
||||
environment:
|
||||
MARIADB_DATABASE: unifi_voucher
|
||||
MARIADB_USER: unifi_voucher
|
||||
MARIADB_PASSWORD: change_me
|
||||
MARIADB_ROOT_PASSWORD: change_me_root
|
||||
volumes:
|
||||
- db_data:/var/lib/mysql
|
||||
- ./database.sql:/docker-entrypoint-initdb.d/01_schema.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
29
docker/entrypoint.sh
Normal file
29
docker/entrypoint.sh
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
CONFIG=/var/www/html/config.php
|
||||
|
||||
# config.php aus Umgebungsvariablen erzeugen, falls noch nicht vorhanden und
|
||||
# DB-Variablen gesetzt sind. Sonst kann der Web-Installer (install.php) genutzt
|
||||
# werden. APP_KEY wird einmalig generiert und sollte als ENV persistiert werden.
|
||||
if [ ! -f "$CONFIG" ] && [ -n "$DB_HOST" ] && [ -n "$DB_NAME" ]; then
|
||||
if [ -z "$APP_KEY" ]; then
|
||||
APP_KEY=$(php -r 'echo base64_encode(random_bytes(32));')
|
||||
echo "[entrypoint] WARN: kein APP_KEY gesetzt – generiere einmaligen Schluessel."
|
||||
echo "[entrypoint] Fuer dauerhaften Betrieb APP_KEY als ENV setzen: $APP_KEY"
|
||||
fi
|
||||
cat > "$CONFIG" <<PHP
|
||||
<?php
|
||||
define('DB_HOST', getenv('DB_HOST') ?: '${DB_HOST}');
|
||||
define('DB_NAME', getenv('DB_NAME') ?: '${DB_NAME}');
|
||||
define('DB_USER', getenv('DB_USER') ?: '${DB_USER}');
|
||||
define('DB_PASS', getenv('DB_PASS') ?: '${DB_PASS}');
|
||||
define('APP_KEY', getenv('APP_KEY') ?: '${APP_KEY}');
|
||||
define('SESSION_LIFETIME', (int)(getenv('SESSION_LIFETIME') ?: 3600));
|
||||
date_default_timezone_set(getenv('TZ') ?: 'Europe/Berlin');
|
||||
PHP
|
||||
chown www-data:www-data "$CONFIG"
|
||||
echo "[entrypoint] config.php aus ENV erzeugt."
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
71
includes/ApiKey.php
Normal file
71
includes/ApiKey.php
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
/**
|
||||
* ApiKey – Erzeugung & Verifizierung von API-Schlüsseln für die REST-API.
|
||||
*
|
||||
* Schlüsselformat: uvt_<prefix(8)><secret(32)>
|
||||
* Gespeichert wird nur der SHA-256-Hash plus ein indexierbares Präfix – der
|
||||
* Klartext-Schlüssel wird dem Admin genau einmal bei der Erstellung gezeigt.
|
||||
*/
|
||||
class ApiKey {
|
||||
/** Neuen Schlüssel erzeugen. Gibt plain/prefix/hash zurück. */
|
||||
public static function generate() {
|
||||
$prefix = bin2hex(random_bytes(4)); // 8 Zeichen
|
||||
$secret = bin2hex(random_bytes(16)); // 32 Zeichen
|
||||
$plain = 'uvt_' . $prefix . $secret;
|
||||
return [
|
||||
'plain' => $plain,
|
||||
'prefix' => $prefix,
|
||||
'hash' => hash('sha256', $plain),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifiziert einen Klartext-Schlüssel gegen die DB. Aktualisiert
|
||||
* last_used_at und gibt den Key-Datensatz zurück – oder false.
|
||||
*/
|
||||
public static function verify($plain, $db) {
|
||||
if (!is_string($plain) || strpos($plain, 'uvt_') !== 0 || strlen($plain) < 44) {
|
||||
return false;
|
||||
}
|
||||
$prefix = substr($plain, 4, 8);
|
||||
$hash = hash('sha256', $plain);
|
||||
try {
|
||||
$row = $db->fetchOne(
|
||||
"SELECT * FROM api_keys WHERE key_prefix = ? AND is_active = 1",
|
||||
[$prefix]
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
if (!$row || !hash_equals($row['key_hash'], $hash)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
$db->query("UPDATE api_keys SET last_used_at = NOW() WHERE id = ?", [$row['id']]);
|
||||
} catch (\Exception $e) { /* ignore */ }
|
||||
return $row;
|
||||
}
|
||||
|
||||
/** Liest den Schlüssel aus dem Request (Authorization: Bearer / X-API-Key). */
|
||||
public static function fromRequest() {
|
||||
$headers = [];
|
||||
if (function_exists('getallheaders')) {
|
||||
foreach (getallheaders() as $k => $v) {
|
||||
$headers[strtolower($k)] = $v;
|
||||
}
|
||||
}
|
||||
if (!empty($headers['authorization']) && preg_match('/Bearer\s+(\S+)/i', $headers['authorization'], $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
if (!empty($headers['x-api-key'])) {
|
||||
return trim($headers['x-api-key']);
|
||||
}
|
||||
if (!empty($_SERVER['HTTP_X_API_KEY'])) {
|
||||
return trim($_SERVER['HTTP_X_API_KEY']);
|
||||
}
|
||||
if (!empty($_SERVER['HTTP_AUTHORIZATION']) && preg_match('/Bearer\s+(\S+)/i', $_SERVER['HTTP_AUTHORIZATION'], $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
53
includes/Notifier.php
Normal file
53
includes/Notifier.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
/**
|
||||
* Notifier – sendet Ereignis-Benachrichtigungen an einen konfigurierten
|
||||
* Webhook (Slack / Microsoft Teams / generischer JSON-Endpunkt).
|
||||
*
|
||||
* Gesteuert über Einstellungen:
|
||||
* webhook_enabled (0/1), webhook_url
|
||||
*
|
||||
* Sendet ein Slack/Teams-kompatibles { "text": "..." }-Payload plus ein
|
||||
* strukturiertes "event"-Feld. Fehler werden still ignoriert – eine
|
||||
* Benachrichtigung darf nie den eigentlichen Vorgang blockieren.
|
||||
*/
|
||||
class Notifier {
|
||||
/** Generisches Event senden. */
|
||||
public static function send($text, array $data = []) {
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
if ((string)$db->getSetting('webhook_enabled', '0') !== '1') {
|
||||
return;
|
||||
}
|
||||
$url = trim((string)$db->getSetting('webhook_url', ''));
|
||||
if ($url === '' || !filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
return;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = json_encode(array_merge(['text' => $text], $data ? ['event' => $data] : []));
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 5,
|
||||
CURLOPT_CONNECTTIMEOUT => 3,
|
||||
]);
|
||||
@curl_exec($ch);
|
||||
curl_close($ch);
|
||||
}
|
||||
|
||||
/** Bequemer Helfer für erstellte Voucher. */
|
||||
public static function voucherCreated($count, $siteName, $byUser = null) {
|
||||
$who = $byUser ? " von {$byUser}" : '';
|
||||
$what = $count > 1 ? "{$count} Voucher" : 'Ein Voucher';
|
||||
self::send(
|
||||
"🎫 {$what} für \"{$siteName}\"{$who} erstellt.",
|
||||
['type' => 'voucher_created', 'count' => $count, 'site' => $siteName, 'user' => $byUser]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -113,9 +113,18 @@ $lang = I18n::getLanguage();
|
|||
<li><a href="<?= $adminBase ?? '' ?>settings.php" class="<?= $currentPage === 'settings' ? 'active' : '' ?>">
|
||||
<i class="fas fa-cog"></i> <?= __('nav_settings') ?>
|
||||
</a></li>
|
||||
<li><a href="<?= $adminBase ?? '' ?>api_keys.php" class="<?= $currentPage === 'api_keys' ? 'active' : '' ?>">
|
||||
<i class="fas fa-key"></i> <?= __('nav_api_keys') ?>
|
||||
</a></li>
|
||||
<li><a href="<?= $adminBase ?? '' ?>security.php" class="<?= $currentPage === 'security' ? 'active' : '' ?>">
|
||||
<i class="fas fa-user-shield"></i> <?= __('nav_security') ?>
|
||||
</a></li>
|
||||
<li><a href="<?= $adminBase ?? '' ?>integrations.php" class="<?= $currentPage === 'integrations' ? 'active' : '' ?>">
|
||||
<i class="fas fa-plug"></i> <?= __('nav_integrations') ?>
|
||||
</a></li>
|
||||
<li><a href="<?= $adminBase ?? '' ?>backup.php" class="<?= $currentPage === 'backup' ? 'active' : '' ?>">
|
||||
<i class="fas fa-database"></i> <?= __('nav_backup') ?>
|
||||
</a></li>
|
||||
<li><a href="<?= $adminBase ?? '' ?>update.php" class="<?= $currentPage === 'update' ? 'active' : '' ?>">
|
||||
<i class="fas fa-sync-alt"></i> <?= __('nav_update') ?>
|
||||
</a></li>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ require_once __DIR__ . '/includes/Database.php';
|
|||
require_once __DIR__ . '/includes/Auth.php';
|
||||
require_once __DIR__ . '/includes/UniFiController.php';
|
||||
require_once __DIR__ . '/includes/Mailer.php';
|
||||
require_once __DIR__ . '/includes/Notifier.php';
|
||||
require_once __DIR__ . '/includes/I18n.php';
|
||||
|
||||
$auth = new Auth();
|
||||
|
|
@ -158,6 +159,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) {
|
|||
$voucherData = doCreateVoucher($db, $site, $voucherName, $maxUses, $expireMinutes, $userId, $qos);
|
||||
$voucherCode = $voucherData['code'];
|
||||
$voucherCreated = true;
|
||||
Notifier::voucherCreated(1, $site['name'], $_SESSION['user_name'] ?? null);
|
||||
|
||||
if ($sendEmail && !empty($recipientEmail)) {
|
||||
$mailer->sendVoucherEmail($recipientEmail, $voucherCode, $site['name'], $maxUses);
|
||||
|
|
@ -207,6 +209,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_bulk'])) {
|
|||
$bulkVouchers[] = doCreateVoucher($db, $site, $voucherName . '_' . ($i + 1), $maxUses, $expireMinutes, $userId, $qos);
|
||||
}
|
||||
|
||||
Notifier::voucherCreated($bulkCount, $site['name'], $_SESSION['user_name'] ?? null);
|
||||
$bulkCreated = true;
|
||||
$success = str_replace('{count}', $bulkCount, __('bulk_success'));
|
||||
} catch (Exception $e) {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ return [
|
|||
'nav_templates' => 'Voucher-Profile',
|
||||
'nav_audit_log' => 'Audit-Log',
|
||||
'nav_settings' => 'Einstellungen',
|
||||
'nav_api_keys' => 'API-Schlüssel',
|
||||
'nav_security' => 'Sicherheit (2FA)',
|
||||
'nav_integrations' => 'Integration & Wartung',
|
||||
'nav_backup' => 'Backup & Restore',
|
||||
'nav_update' => 'System-Update',
|
||||
'nav_back' => 'Zurück zur Startseite',
|
||||
'nav_administration'=> 'Administration',
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ return [
|
|||
'nav_templates' => 'Voucher Profiles',
|
||||
'nav_audit_log' => 'Audit Log',
|
||||
'nav_settings' => 'Settings',
|
||||
'nav_api_keys' => 'API Keys',
|
||||
'nav_security' => 'Security (2FA)',
|
||||
'nav_integrations' => 'Integration & Maintenance',
|
||||
'nav_backup' => 'Backup & Restore',
|
||||
'nav_update' => 'System Update',
|
||||
'nav_back' => 'Back to Home',
|
||||
'nav_administration'=> 'Administration',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue