Upload via GUI (452 Dateien)
This commit is contained in:
@@ -76,7 +76,14 @@ function ticket_snooze_wake_due(PDO $pdo): int
|
||||
} catch (Throwable $e) {
|
||||
return 0; // Spalte fehlt (Datenbank noch nicht aktualisiert)
|
||||
}
|
||||
$woken = 0;
|
||||
foreach ($due as $t) {
|
||||
// Erledigte Tickets (gelöst oder geschlossen) brauchen keine Wiedervorlage mehr: still aufräumen, keine Benachrichtigung
|
||||
if (in_array((string) $t['status'], ['resolved', 'closed'], true)) {
|
||||
$pdo->prepare('UPDATE tickets SET snoozed_until = NULL, snoozed_by = NULL WHERE id = ?')->execute([$t['id']]);
|
||||
continue;
|
||||
}
|
||||
$woken++;
|
||||
$pdo->prepare('UPDATE tickets SET snoozed_until = NULL, snoozed_by = NULL, updated_at = ? WHERE id = ?')->execute([db_now(), $t['id']]);
|
||||
log_activity($pdo, (int) $t['id'], null, 'Wiedervorlage fällig');
|
||||
$to = (int) ($t['snoozed_by'] ?: ($t['assigned_staff_id'] ?? 0));
|
||||
@@ -88,7 +95,7 @@ function ticket_snooze_wake_due(PDO $pdo): int
|
||||
}
|
||||
}
|
||||
}
|
||||
return count($due);
|
||||
return $woken;
|
||||
}
|
||||
|
||||
// ── Beobachter ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -81,3 +81,88 @@ function update_http_headers(): array
|
||||
[$token] = update_token_info();
|
||||
return $token !== '' ? ['Authorization: token ' . $token] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gehört diese Datei aus dem Update-Paket nicht auf die Installation? Plugins, Uploads, Backups und App-Dateien gehören dem System, die Plugin-Webseite
|
||||
* und Entwickler-Ordner gehören nicht auf Kunden-Installationen. Ausnahme: Das Handbuch (docs/DeskHub-Handbuch.pdf) kommt mit jedem Update (Adminportal → Hilfe).
|
||||
*/
|
||||
function update_path_is_skipped(string $relNorm): bool
|
||||
{
|
||||
// Die Konfiguration gehört der Installation: nichts aus config/ wird je überschrieben oder neu angelegt (auch nicht bei anderer Groß-/Kleinschreibung)
|
||||
if (preg_match('#^(\./)*config(/|$)#i', $relNorm) || preg_match('#(^|/)(\.env|config\.php)$#i', $relNorm) && !str_contains($relNorm, '/')) return true;
|
||||
if ($relNorm === 'docs/DeskHub-Handbuch.pdf' || $relNorm === 'tools/fix-permissions.sh') return false;
|
||||
return (bool) (preg_match('#^(plugins|uploads|backups|app_downloads)/#', $relNorm)
|
||||
|| preg_match('#^(plugin-webseite|tools|tests|docs|desktop|mobile|theme-packages|vorschaubilder|\.gitea|\.github)/#', $relNorm));
|
||||
}
|
||||
|
||||
|
||||
// ─── Updates von der Plugin-Webseite ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Adresse der Update-Schnittstelle auf der offiziellen Plugin-Webseite (app-release.php neben dem Katalog) oder '' ohne offiziellen Katalog.
|
||||
* Der Zugangsschlüssel wird nur an diesen Server geschickt (catalog_access_headers).
|
||||
*/
|
||||
function update_publisher_endpoint(array $query): string
|
||||
{
|
||||
$base = function_exists('catalog_defaults') ? (string) (catalog_defaults()['plugins_url'] ?? '') : '';
|
||||
$parts = parse_url($base);
|
||||
if (!$parts || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host'])) return '';
|
||||
$path = rtrim(str_replace('\\', '/', dirname((string) ($parts['path'] ?? '/'))), '/');
|
||||
return 'https://' . $parts['host'] . (isset($parts['port']) ? ':' . (int) $parts['port'] : '') . $path . '/app-release.php?' . http_build_query(['platform' => 'server'] + $query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die auf der Plugin-Webseite veröffentlichten System-Updates, neueste zuerst. Leer, wenn die Seite nicht antwortet oder nichts
|
||||
* veröffentlicht ist (dann greift die Ausweichquelle). Jeder Eintrag hat dieselben Felder wie ein Gitea-Release (tag_name, published_at, body)
|
||||
* plus sha256, size, signature und source = 'publisher'.
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
function update_publisher_releases(): array
|
||||
{
|
||||
static $cache = null;
|
||||
if ($cache !== null && !isset($GLOBALS['deskhub_http_get'])) return $cache;
|
||||
$url = update_publisher_endpoint(['list' => 1]);
|
||||
$out = [];
|
||||
if ($url !== '') {
|
||||
[$status, $body] = plugin_http_get($url, 1048576, 8);
|
||||
$data = $status === 200 && is_string($body) ? json_decode($body, true) : null;
|
||||
foreach ((array) ($data['releases'] ?? []) as $r) {
|
||||
$v = (string) ($r['version'] ?? '');
|
||||
if (!is_array($r) || !preg_match('/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/', $v) || !preg_match('/^[0-9a-f]{64}$/i', (string) ($r['sha256'] ?? ''))) continue;
|
||||
$out[] = ['tag_name' => $v, 'published_at' => (string) ($r['created_at'] ?? ''), 'body' => (string) ($r['notes'] ?? ''), 'sha256' => strtolower((string) $r['sha256']),
|
||||
'size' => (int) ($r['size'] ?? 0), 'signature' => (string) ($r['signature'] ?? ''), 'source' => 'publisher'];
|
||||
}
|
||||
usort($out, fn($a, $b) => version_compare($b['tag_name'], $a['tag_name']));
|
||||
}
|
||||
return $cache = $out;
|
||||
}
|
||||
|
||||
/** Ein Eintrag aus update_publisher_releases() zu einem Versions-Tag (mit oder ohne „v“) oder null. */
|
||||
function update_publisher_find(string $tag): ?array
|
||||
{
|
||||
$v = ltrim(trim($tag), 'vV');
|
||||
foreach (update_publisher_releases() as $r) if ($r['tag_name'] === $v) return $r;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt das Paket einer Version von der Plugin-Webseite und prüft Prüfsumme und (bei eingebautem Herausgeber-Schlüssel) Signatur.
|
||||
*
|
||||
* @return array{0:?string,1:?string} [Pfad zur ZIP-Datei, Fehlertext]; bei Fehler ist die Datei schon gelöscht
|
||||
*/
|
||||
function update_publisher_fetch(array $release): array
|
||||
{
|
||||
$url = update_publisher_endpoint(['version' => $release['tag_name'], 'download' => 1]);
|
||||
if ($url === '') return [null, 'Die Plugin-Webseite ist nicht eingetragen.'];
|
||||
[$status, $body, $err] = plugin_http_get($url, 300 * 1048576, 180);
|
||||
if ($status !== 200 || !is_string($body)) return [null, 'Download von der Plugin-Webseite fehlgeschlagen' . ($err ? ': ' . $err : " (HTTP $status).")];
|
||||
if (!hash_equals((string) $release['sha256'], hash('sha256', $body))) return [null, 'Die Prüfsumme des Pakets stimmt nicht. Es wird nicht installiert.'];
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'dh_') . '.zip';
|
||||
file_put_contents($tmp, $body);
|
||||
if (update_signature_required()) {
|
||||
$problem = update_verify_package((string) $release['tag_name'], $tmp, (string) $release['signature']);
|
||||
if ($problem !== null) { @unlink($tmp); return [null, $problem]; }
|
||||
}
|
||||
return [$tmp, null];
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ function vacation_hand_over(PDO $pdo, int $staffId): int
|
||||
$mail->execute([$to]);
|
||||
$addr = (string) $mail->fetchColumn();
|
||||
if ($addr !== '') send_mail($addr, 'Vertretung für ' . $name . ': ' . count($tickets) . ' Tickets', email_wrap(
|
||||
'<h2 style="margin:0 0 12px;font-size:20px;color:#1a202c">Sie vertreten ' . e($name) . '</h2>'
|
||||
'<h2 style="margin:0 0 14px;color:#0f172a;font-size:22px;line-height:1.3;font-weight:700;font-family:Segoe UI,Arial,sans-serif;">Sie vertreten ' . e($name) . '</h2>'
|
||||
. '<p style="margin:0 0 12px;color:#334155">' . count($tickets) . ' offene Tickets wurden Ihnen zugewiesen. Neue Tickets von ' . e($name) . ' kommen ebenfalls zu Ihnen, solange die Abwesenheit dauert.</p>'
|
||||
. '<ul style="color:#334155">' . $list . '</ul>'));
|
||||
}
|
||||
|
||||
@@ -27,13 +27,20 @@ function web_handoff_pages(array $staff): array
|
||||
['imap_accounts', 'Postfächer (E-Mail-Abruf)', '/staff/imap_accounts.php', 'Verwaltung', $perm('manage_email_addresses')],
|
||||
// Nur Administratoren
|
||||
['settings', 'Einstellungen', '/staff/settings.php', 'Verwaltung', $isAdmin],
|
||||
['recurring_tickets', 'Wiederkehrende Tickets', '/staff/recurring_tickets.php', 'Verwaltung', $isAdmin],
|
||||
['canned_responses', 'Textbausteine', '/staff/canned_responses.php', 'Verwaltung', $isAdmin],
|
||||
['custom_fields', 'Ticket-Felder', '/staff/custom_fields.php', 'Verwaltung', $isAdmin],
|
||||
['custom_lists', 'Benutzerdefinierte Listen', '/staff/custom_lists.php', 'Verwaltung', $isAdmin],
|
||||
['site_pages', 'Seiten (Impressum, Datenschutz …)', '/staff/site_pages.php', 'Verwaltung', $isAdmin],
|
||||
['ticket_templates', 'Ticket-Vorlagen', '/staff/ticket_templates.php', 'Verwaltung', $isAdmin],
|
||||
['status_page', 'Statusseite (Dienste, Meldungen)', '/staff/status_page.php', 'Verwaltung', $isAdmin],
|
||||
['recurring_tickets', 'Wiederkehrende Tickets', '/staff/recurring_tickets.php', 'Verwaltung', $perm('manage_automation')],
|
||||
['canned_responses', 'Textbausteine', '/staff/canned_responses.php', 'Verwaltung', $perm('manage_canned')],
|
||||
['custom_fields', 'Ticket-Felder', '/staff/custom_fields.php', 'Verwaltung', $perm('manage_custom_fields')],
|
||||
['custom_lists', 'Benutzerdefinierte Listen', '/staff/custom_lists.php', 'Verwaltung', $perm('manage_custom_fields')],
|
||||
['site_pages', 'Seiten (Impressum, Datenschutz …)', '/staff/site_pages.php', 'Verwaltung', $perm('manage_site_pages')],
|
||||
['ticket_templates', 'Ticket-Vorlagen', '/staff/ticket_templates.php', 'Verwaltung', $perm('manage_ticket_templates')],
|
||||
['status_page', 'Statusseite (Dienste, Meldungen)', '/staff/status_page.php', 'Verwaltung', $perm('manage_status_page')],
|
||||
['languages', 'Sprachen', '/staff/languages.php', 'Verwaltung', $perm('manage_languages')],
|
||||
['mail_queue', 'Mail-Warteschlange', '/staff/mail_queue.php', 'Verwaltung', $perm('view_mail_queue')],
|
||||
['login_log', 'Anmelde-Wächter', '/staff/login_log.php', 'System', $perm('view_login_guard')],
|
||||
['problems', 'Problemmanagement', '/staff/problems.php', 'Verwaltung', $perm('manage_problems')],
|
||||
['customers', 'Kundenverzeichnis', '/staff/customers.php', 'Verwaltung', $perm('manage_customers')],
|
||||
['banlist', 'Sperrliste', '/staff/banlist.php', 'Allgemein', $perm('manage_banlist')],
|
||||
['help', 'Hilfe und Handbuch', '/staff/help.php', 'System', $perm('view_help')],
|
||||
['webhooks', 'Webhooks', '/staff/webhooks.php', 'Verwaltung', $isAdmin],
|
||||
['api_keys', 'API-Schlüssel', '/staff/api_keys.php', 'Verwaltung', $isAdmin],
|
||||
['plugins', 'Plugins und Themes', '/staff/plugins.php', 'System', $isAdmin],
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
<?php
|
||||
/**
|
||||
* Passkeys (WebAuthn) für die Anmeldung der Mitarbeiter, ohne Fremdbibliothek: CBOR lesen, Registrierung und Anmeldung prüfen.
|
||||
*
|
||||
* Unterstützt: ES256 (ECDSA P-256) und RS256, Attestation „none“ (bei Passkeys üblich, die Herkunft des Geräts wird nicht geprüft).
|
||||
* Verlangt wird immer Nutzerprüfung (Fingerabdruck, Gesicht oder PIN): Ein Passkey ersetzt deshalb Passwort und 2FA-Code.
|
||||
* Relying-Party-ID und Origin kommen aus der eingestellten Website-URL (Einstellungen → Anzeigename & URL); Passkeys gelten nur dafür.
|
||||
*/
|
||||
|
||||
/** Passkeys eingeschaltet? (Adminportal → Anmelde-Wächter) */
|
||||
function webauthn_enabled(): bool
|
||||
{
|
||||
return get_setting('passkeys_enabled', '1') === '1';
|
||||
}
|
||||
|
||||
function webauthn_b64u_encode(string $raw): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
function webauthn_b64u_decode(string $s): string
|
||||
{
|
||||
$s = strtr($s, '-_', '+/');
|
||||
$pad = strlen($s) % 4;
|
||||
if ($pad) $s .= str_repeat('=', 4 - $pad);
|
||||
$r = base64_decode($s, true);
|
||||
return $r === false ? '' : $r;
|
||||
}
|
||||
|
||||
/** Minimaler CBOR-Leser (genug für Attestation-Objekt und COSE-Schlüssel). @return mixed */
|
||||
function webauthn_cbor_decode(string $d, int &$o = 0, int $depth = 0)
|
||||
{
|
||||
if ($depth > 8 || $o >= strlen($d)) throw new RuntimeException('CBOR ungültig');
|
||||
$b = ord($d[$o++]);
|
||||
$major = $b >> 5;
|
||||
$info = $b & 31;
|
||||
if ($major === 7) {
|
||||
if ($info === 20) return false;
|
||||
if ($info === 21) return true;
|
||||
if ($info === 22 || $info === 23) return null;
|
||||
throw new RuntimeException('CBOR: nicht unterstützter Wert');
|
||||
}
|
||||
if ($info < 24) {
|
||||
$n = $info;
|
||||
} elseif ($info === 24) {
|
||||
if ($o + 1 > strlen($d)) throw new RuntimeException('CBOR zu kurz');
|
||||
$n = ord($d[$o++]);
|
||||
} elseif ($info === 25) {
|
||||
if ($o + 2 > strlen($d)) throw new RuntimeException('CBOR zu kurz');
|
||||
$n = unpack('n', substr($d, $o, 2))[1]; $o += 2;
|
||||
} elseif ($info === 26) {
|
||||
if ($o + 4 > strlen($d)) throw new RuntimeException('CBOR zu kurz');
|
||||
$n = unpack('N', substr($d, $o, 4))[1]; $o += 4;
|
||||
} elseif ($info === 27) {
|
||||
if ($o + 8 > strlen($d)) throw new RuntimeException('CBOR zu kurz');
|
||||
$n = unpack('J', substr($d, $o, 8))[1]; $o += 8;
|
||||
if ($n < 0) throw new RuntimeException('CBOR: Zahl zu groß');
|
||||
} else {
|
||||
throw new RuntimeException('CBOR: unbestimmte Länge nicht unterstützt');
|
||||
}
|
||||
switch ($major) {
|
||||
case 0: return $n;
|
||||
case 1: return -1 - $n;
|
||||
case 2:
|
||||
case 3:
|
||||
if ($n > strlen($d) - $o) throw new RuntimeException('CBOR zu kurz');
|
||||
$s = substr($d, $o, $n); $o += $n;
|
||||
return $s;
|
||||
case 4:
|
||||
if ($n > 1000) throw new RuntimeException('CBOR: Liste zu lang');
|
||||
$a = [];
|
||||
for ($i = 0; $i < $n; $i++) $a[] = webauthn_cbor_decode($d, $o, $depth + 1);
|
||||
return $a;
|
||||
case 5:
|
||||
if ($n > 1000) throw new RuntimeException('CBOR: Tabelle zu lang');
|
||||
$m = [];
|
||||
for ($i = 0; $i < $n; $i++) {
|
||||
$k = webauthn_cbor_decode($d, $o, $depth + 1);
|
||||
$m[is_int($k) ? $k : (string) $k] = webauthn_cbor_decode($d, $o, $depth + 1);
|
||||
}
|
||||
return $m;
|
||||
case 6:
|
||||
return webauthn_cbor_decode($d, $o, $depth + 1);
|
||||
}
|
||||
throw new RuntimeException('CBOR ungültig');
|
||||
}
|
||||
|
||||
function webauthn_der_len(int $n): string
|
||||
{
|
||||
if ($n < 128) return chr($n);
|
||||
$b = ltrim(pack('N', $n), "\0");
|
||||
return chr(0x80 | strlen($b)) . $b;
|
||||
}
|
||||
|
||||
function webauthn_der(int $tag, string $content): string
|
||||
{
|
||||
return chr($tag) . webauthn_der_len(strlen($content)) . $content;
|
||||
}
|
||||
|
||||
function webauthn_der_int(string $raw): string
|
||||
{
|
||||
$raw = ltrim($raw, "\0");
|
||||
if ($raw === '' || (ord($raw[0]) & 0x80)) $raw = "\0" . $raw;
|
||||
return webauthn_der(0x02, $raw);
|
||||
}
|
||||
|
||||
function webauthn_pem(string $der): string
|
||||
{
|
||||
return "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($der), 64, "\n") . "-----END PUBLIC KEY-----\n";
|
||||
}
|
||||
|
||||
/** COSE-Schlüssel (ES256 oder RS256) in eine PEM-Datei für OpenSSL umwandeln. @return array{0:string,1:int} PEM und Algorithmus */
|
||||
function webauthn_cose_to_pem(array $cose): array
|
||||
{
|
||||
$kty = $cose[1] ?? null;
|
||||
$alg = $cose[3] ?? null;
|
||||
if ($kty === 2 && $alg === -7 && ($cose[-1] ?? null) === 1 && strlen((string) ($cose[-2] ?? '')) === 32 && strlen((string) ($cose[-3] ?? '')) === 32) {
|
||||
$spki = hex2bin('3059301306072a8648ce3d020106082a8648ce3d030107034200') . "\x04" . $cose[-2] . $cose[-3];
|
||||
return [webauthn_pem($spki), -7];
|
||||
}
|
||||
if ($kty === 3 && $alg === -257 && is_string($cose[-1] ?? null) && is_string($cose[-2] ?? null) && strlen($cose[-1]) >= 128) {
|
||||
$rsaKey = webauthn_der(0x30, webauthn_der_int($cose[-1]) . webauthn_der_int($cose[-2]));
|
||||
$alg = hex2bin('300d06092a864886f70d0101010500');
|
||||
$spki = webauthn_der(0x30, $alg . webauthn_der(0x03, "\0" . $rsaKey));
|
||||
return [webauthn_pem($spki), -257];
|
||||
}
|
||||
throw new RuntimeException('Dieser Passkey-Typ wird nicht unterstützt (erlaubt: ES256, RS256).');
|
||||
}
|
||||
|
||||
/**
|
||||
* Relying Party: ['id' => Domain, 'origin' => Schema://Domain[:Port], 'secure' => bool, 'name' => Anzeigename].
|
||||
* Grundlage ist die Website-URL (Einstellungen → Anzeigename & URL). Wird DeskHub unter einer Subdomain dieser Adresse aufgerufen
|
||||
* (Website-URL https://beispiel.de, aufgerufen unter https://support.beispiel.de), gilt die Adresse, unter der die Seite gerade aufgerufen wird:
|
||||
* Der Browser verlangt genau diese Domain. Fremde Domains werden ignoriert, dann gilt die Website-URL.
|
||||
*/
|
||||
function webauthn_rp(): array
|
||||
{
|
||||
$u = parse_url(base_url());
|
||||
$siteHost = strtolower((string) ($u['host'] ?? ''));
|
||||
$scheme = strtolower((string) ($u['scheme'] ?? 'https'));
|
||||
$host = $siteHost;
|
||||
$port = isset($u['port']) ? (int) $u['port'] : null;
|
||||
// Mögliche Adressen, unter denen die Seite aufgerufen wird. Der Origin-Kopf stammt vom Browser (nicht fälschbar durch die Seite) und ist
|
||||
// zuverlässig, auch wenn ein Reverse-Proxy den Host-Kopf verändert; X-Forwarded-Host zählt nur von einem vertrauenswürdigen Proxy.
|
||||
$candidates = [];
|
||||
$originHdr = trim((string) ($_SERVER['HTTP_ORIGIN'] ?? ''));
|
||||
if ($originHdr !== '' && ($oh = parse_url($originHdr)) && !empty($oh['host'])) $candidates[] = strtolower((string) $oh['host']) . (isset($oh['port']) ? ':' . (int) $oh['port'] : '');
|
||||
if (function_exists('request_from_trusted_proxy') && request_from_trusted_proxy() && !empty($_SERVER['HTTP_X_FORWARDED_HOST'])) {
|
||||
$candidates[] = strtolower(trim(explode(',', (string) $_SERVER['HTTP_X_FORWARDED_HOST'])[0]));
|
||||
}
|
||||
$candidates[] = strtolower(trim((string) ($_SERVER['HTTP_HOST'] ?? '')));
|
||||
foreach ($candidates as $req) {
|
||||
if ($req === '' || !preg_match('/^([a-z0-9]([a-z0-9.-]*[a-z0-9])?)(?::(\d{1,5}))?$/', $req, $m)) continue;
|
||||
$reqHost = $m[1];
|
||||
if ($siteHost !== '' && ($reqHost === $siteHost || str_ends_with($reqHost, '.' . $siteHost))) {
|
||||
$host = $reqHost;
|
||||
$port = isset($m[3]) && $m[3] !== '' ? (int) $m[3] : null;
|
||||
if ($port !== null && (($scheme === 'https' && $port === 443) || ($scheme === 'http' && $port === 80))) $port = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$origin = $scheme . '://' . $host . ($port !== null ? ':' . $port : '');
|
||||
$secure = $scheme === 'https' || $host === 'localhost';
|
||||
return ['id' => $host, 'origin' => $origin, 'secure' => $secure, 'name' => (string) get_setting('display_name', config()['site']['name'] ?? 'DeskHub')];
|
||||
}
|
||||
|
||||
/** Feste Kennung des Mitarbeiters im Passkey (keine Personendaten). */
|
||||
function webauthn_user_handle(int $staffId): string
|
||||
{
|
||||
return hash_hmac('sha256', 'deskhub-passkey-user-' . $staffId, (string) (config()['app_key'] ?? 'deskhub'), true);
|
||||
}
|
||||
|
||||
function webauthn_challenge_new(string $type, ?int $staffId = null): string
|
||||
{
|
||||
$c = random_bytes(32);
|
||||
$_SESSION['webauthn_challenge'] = ['v' => webauthn_b64u_encode($c), 'type' => $type, 'exp' => time() + 300, 'staff' => $staffId];
|
||||
return webauthn_b64u_encode($c);
|
||||
}
|
||||
|
||||
/** Merkt die Challenge nur einmal: Ein zweiter Versuch mit derselben Challenge scheitert. */
|
||||
function webauthn_challenge_take(string $type, ?int $staffId = null): ?string
|
||||
{
|
||||
$c = $_SESSION['webauthn_challenge'] ?? null;
|
||||
unset($_SESSION['webauthn_challenge']);
|
||||
if (!is_array($c) || ($c['type'] ?? '') !== $type || (int) ($c['exp'] ?? 0) < time()) return null;
|
||||
if ($staffId !== null && (int) ($c['staff'] ?? 0) !== $staffId) return null;
|
||||
return (string) $c['v'];
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
function webauthn_staff_passkeys(PDO $pdo, int $staffId): array
|
||||
{
|
||||
try {
|
||||
$s = $pdo->prepare('SELECT * FROM staff_passkeys WHERE staff_id = ? ORDER BY id');
|
||||
$s->execute([$staffId]);
|
||||
return $s->fetchAll();
|
||||
} catch (Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function webauthn_count_passkeys(PDO $pdo, int $staffId): int
|
||||
{
|
||||
return count(webauthn_staff_passkeys($pdo, $staffId));
|
||||
}
|
||||
|
||||
/** Optionen für die Registrierung (navigator.credentials.create). */
|
||||
function webauthn_creation_options(PDO $pdo, array $staff): array
|
||||
{
|
||||
$rp = webauthn_rp();
|
||||
$exclude = [];
|
||||
foreach (webauthn_staff_passkeys($pdo, (int) $staff['id']) as $k) $exclude[] = ['type' => 'public-key', 'id' => $k['credential_id']];
|
||||
return [
|
||||
'challenge' => webauthn_challenge_new('create', (int) $staff['id']),
|
||||
'rp' => ['id' => $rp['id'], 'name' => $rp['name']],
|
||||
'user' => ['id' => webauthn_b64u_encode(webauthn_user_handle((int) $staff['id'])), 'name' => (string) $staff['email'], 'displayName' => (string) $staff['name']],
|
||||
'pubKeyCredParams' => [['type' => 'public-key', 'alg' => -7], ['type' => 'public-key', 'alg' => -257]],
|
||||
'timeout' => 120000,
|
||||
'attestation' => 'none',
|
||||
'excludeCredentials' => $exclude,
|
||||
'authenticatorSelection' => ['residentKey' => 'required', 'requireResidentKey' => true, 'userVerification' => 'required'],
|
||||
];
|
||||
}
|
||||
|
||||
/** Optionen für die Anmeldung ohne Eingabe der Adresse (navigator.credentials.get). */
|
||||
function webauthn_request_options(): array
|
||||
{
|
||||
return [
|
||||
'challenge' => webauthn_challenge_new('get'),
|
||||
'rpId' => webauthn_rp()['id'],
|
||||
'timeout' => 120000,
|
||||
'userVerification' => 'required',
|
||||
'allowCredentials' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{0:bool,1:string} gemeinsame Prüfung von clientDataJSON */
|
||||
function webauthn_check_client_data(string $json, string $type, string $challenge): array
|
||||
{
|
||||
$cd = json_decode($json, true);
|
||||
if (!is_array($cd)) return [false, 'Antwort des Geräts ist ungültig.'];
|
||||
if (($cd['type'] ?? '') !== $type) return [false, 'Falscher Vorgang.'];
|
||||
if (!hash_equals($challenge, (string) ($cd['challenge'] ?? ''))) return [false, 'Die Anfrage ist abgelaufen. Bitte noch einmal versuchen.'];
|
||||
if (($cd['origin'] ?? '') !== webauthn_rp()['origin']) return [false, 'Die Adresse stimmt nicht mit der Website-URL aus den Einstellungen überein (' . webauthn_rp()['origin'] . ').'];
|
||||
if (!empty($cd['crossOrigin'])) return [false, 'Eingebettete Seiten sind nicht erlaubt.'];
|
||||
return [true, ''];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registrierung abschließen. $cred = ['id' => …, 'response' => ['clientDataJSON' => b64u, 'attestationObject' => b64u]].
|
||||
* @return array{ok:bool,error?:string,id?:int}
|
||||
*/
|
||||
function webauthn_register_finish(PDO $pdo, array $staff, array $cred, string $name): array
|
||||
{
|
||||
$challenge = webauthn_challenge_take('create', (int) $staff['id']);
|
||||
if ($challenge === null) return ['ok' => false, 'error' => 'Die Anfrage ist abgelaufen. Bitte noch einmal versuchen.'];
|
||||
try {
|
||||
$clientData = webauthn_b64u_decode((string) ($cred['response']['clientDataJSON'] ?? ''));
|
||||
[$ok, $err] = webauthn_check_client_data($clientData, 'webauthn.create', $challenge);
|
||||
if (!$ok) return ['ok' => false, 'error' => $err];
|
||||
$att = webauthn_cbor_decode(webauthn_b64u_decode((string) ($cred['response']['attestationObject'] ?? '')));
|
||||
if (!is_array($att) || !is_string($att['authData'] ?? null)) return ['ok' => false, 'error' => 'Antwort des Geräts ist ungültig.'];
|
||||
$ad = $att['authData'];
|
||||
if (strlen($ad) < 55) return ['ok' => false, 'error' => 'Antwort des Geräts ist ungültig.'];
|
||||
if (!hash_equals(hash('sha256', webauthn_rp()['id'], true), substr($ad, 0, 32))) return ['ok' => false, 'error' => 'Der Passkey gehört zu einer anderen Domain.'];
|
||||
$flags = ord($ad[32]);
|
||||
if (!($flags & 0x01)) return ['ok' => false, 'error' => 'Anwesenheit nicht bestätigt.'];
|
||||
if (!($flags & 0x04)) return ['ok' => false, 'error' => 'Das Gerät hat Sie nicht geprüft (Fingerabdruck, Gesicht oder PIN nötig).'];
|
||||
if (!($flags & 0x40)) return ['ok' => false, 'error' => 'Das Gerät hat keinen Schlüssel geliefert.'];
|
||||
$signCount = unpack('N', substr($ad, 33, 4))[1];
|
||||
$idLen = unpack('n', substr($ad, 53, 2))[1];
|
||||
if ($idLen < 16 || $idLen > 1023 || strlen($ad) < 55 + $idLen) return ['ok' => false, 'error' => 'Antwort des Geräts ist ungültig.'];
|
||||
$credId = substr($ad, 55, $idLen);
|
||||
$o = 55 + $idLen;
|
||||
$cose = webauthn_cbor_decode($ad, $o);
|
||||
if (!is_array($cose)) return ['ok' => false, 'error' => 'Antwort des Geräts ist ungültig.'];
|
||||
[$pem, $alg] = webauthn_cose_to_pem($cose);
|
||||
if (!openssl_pkey_get_public($pem)) return ['ok' => false, 'error' => 'Der öffentliche Schlüssel ist ungültig.'];
|
||||
$idText = webauthn_b64u_encode($credId);
|
||||
if (hash_equals($idText, (string) ($cred['id'] ?? '')) === false) return ['ok' => false, 'error' => 'Antwort des Geräts ist ungültig.'];
|
||||
$dup = $pdo->prepare('SELECT COUNT(*) FROM staff_passkeys WHERE credential_id = ?');
|
||||
$dup->execute([$idText]);
|
||||
if ((int) $dup->fetchColumn() > 0) return ['ok' => false, 'error' => 'Dieser Passkey ist schon registriert.'];
|
||||
$name = trim(mb_substr($name, 0, 80));
|
||||
if ($name === '') $name = 'Passkey ' . date('d.m.Y');
|
||||
$pdo->prepare('INSERT INTO staff_passkeys (staff_id, credential_id, public_key, alg, sign_count, name, created_at) VALUES (?,?,?,?,?,?,?)')
|
||||
->execute([(int) $staff['id'], $idText, $pem, $alg, $signCount, $name, db_now()]);
|
||||
return ['ok' => true, 'id' => (int) $pdo->lastInsertId()];
|
||||
} catch (Throwable $e) {
|
||||
return ['ok' => false, 'error' => $e instanceof RuntimeException ? $e->getMessage() : 'Der Passkey konnte nicht geprüft werden.'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Anmeldung prüfen. $cred = ['id', 'response' => ['clientDataJSON', 'authenticatorData', 'signature', 'userHandle']] (alles base64url).
|
||||
* @return array{ok:bool,error?:string,staff?:array<string,mixed>,passkey?:array<string,mixed>}
|
||||
*/
|
||||
function webauthn_login_finish(PDO $pdo, array $cred): array
|
||||
{
|
||||
$challenge = webauthn_challenge_take('get');
|
||||
if ($challenge === null) return ['ok' => false, 'error' => 'Die Anfrage ist abgelaufen. Bitte noch einmal versuchen.'];
|
||||
try {
|
||||
$st = $pdo->prepare('SELECT * FROM staff_passkeys WHERE credential_id = ?');
|
||||
$st->execute([(string) ($cred['id'] ?? '')]);
|
||||
$key = $st->fetch();
|
||||
if (!$key) return ['ok' => false, 'error' => 'Dieser Passkey ist hier nicht registriert.'];
|
||||
$clientData = webauthn_b64u_decode((string) ($cred['response']['clientDataJSON'] ?? ''));
|
||||
[$ok, $err] = webauthn_check_client_data($clientData, 'webauthn.get', $challenge);
|
||||
if (!$ok) return ['ok' => false, 'error' => $err];
|
||||
$ad = webauthn_b64u_decode((string) ($cred['response']['authenticatorData'] ?? ''));
|
||||
$sig = webauthn_b64u_decode((string) ($cred['response']['signature'] ?? ''));
|
||||
if (strlen($ad) < 37 || $sig === '') return ['ok' => false, 'error' => 'Antwort des Geräts ist ungültig.'];
|
||||
if (!hash_equals(hash('sha256', webauthn_rp()['id'], true), substr($ad, 0, 32))) return ['ok' => false, 'error' => 'Der Passkey gehört zu einer anderen Domain.'];
|
||||
$flags = ord($ad[32]);
|
||||
if (!($flags & 0x01) || !($flags & 0x04)) return ['ok' => false, 'error' => 'Das Gerät hat Sie nicht geprüft (Fingerabdruck, Gesicht oder PIN nötig).'];
|
||||
$pub = openssl_pkey_get_public((string) $key['public_key']);
|
||||
if (!$pub) return ['ok' => false, 'error' => 'Der gespeicherte Schlüssel ist ungültig.'];
|
||||
$data = $ad . hash('sha256', $clientData, true);
|
||||
if (openssl_verify($data, $sig, $pub, OPENSSL_ALGO_SHA256) !== 1) return ['ok' => false, 'error' => 'Die Signatur des Passkeys ist ungültig.'];
|
||||
$handle = (string) ($cred['response']['userHandle'] ?? '');
|
||||
if ($handle !== '' && !hash_equals(webauthn_b64u_encode(webauthn_user_handle((int) $key['staff_id'])), $handle)) {
|
||||
return ['ok' => false, 'error' => 'Der Passkey gehört zu einem anderen Konto.'];
|
||||
}
|
||||
// Zähler gegen kopierte Passkeys: Er muss wachsen, wenn das Gerät überhaupt zählt
|
||||
$count = unpack('N', substr($ad, 33, 4))[1];
|
||||
$old = (int) $key['sign_count'];
|
||||
if (($count !== 0 || $old !== 0) && $count <= $old) return ['ok' => false, 'error' => 'Der Passkey wurde möglicherweise kopiert und ist abgelehnt worden. Bitte einen Administrator informieren.'];
|
||||
$sd = $pdo->prepare('SELECT * FROM staff WHERE id = ? AND is_active = 1');
|
||||
$sd->execute([(int) $key['staff_id']]);
|
||||
$staff = $sd->fetch();
|
||||
if (!$staff) return ['ok' => false, 'error' => 'Das Konto ist nicht aktiv.'];
|
||||
$pdo->prepare('UPDATE staff_passkeys SET sign_count = ?, last_used_at = ? WHERE id = ?')->execute([$count, db_now(), (int) $key['id']]);
|
||||
return ['ok' => true, 'staff' => $staff, 'passkey' => $key];
|
||||
} catch (Throwable $e) {
|
||||
return ['ok' => false, 'error' => 'Der Passkey konnte nicht geprüft werden.'];
|
||||
}
|
||||
}
|
||||
|
||||
/** Alle Passkeys eines Mitarbeiters löschen. @return int Anzahl */
|
||||
function webauthn_remove_all(PDO $pdo, int $staffId): int
|
||||
{
|
||||
$n = webauthn_count_passkeys($pdo, $staffId);
|
||||
if ($n > 0) $pdo->prepare('DELETE FROM staff_passkeys WHERE staff_id = ?')->execute([$staffId]);
|
||||
return $n;
|
||||
}
|
||||
@@ -38,7 +38,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['status']) && !isset($
|
||||
}
|
||||
|
||||
// ── Schichtplan (Admin-Seite) ─────────────────────────────────────────────────
|
||||
$admin = $staff['role'] === 'admin' ? $staff : null;
|
||||
$admin = has_permission($staff, 'manage_availability') ? $staff : null;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$staff = require_staff_login();
|
||||
$staff = require_admin_or_permission('manage_banlist');
|
||||
$pdo = get_db();
|
||||
|
||||
$errors = [];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$admin = require_admin();
|
||||
$admin = require_admin_or_permission('manage_canned');
|
||||
$pdo = get_db();
|
||||
|
||||
$errors = [];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$admin = require_admin();
|
||||
$admin = require_admin_or_permission('manage_custom_fields');
|
||||
$pdo = get_db();
|
||||
|
||||
$errors = [];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
require_once __DIR__ . '/_ui.php';
|
||||
$staff = require_admin();
|
||||
$staff = require_admin_or_permission('manage_custom_fields');
|
||||
$pdo = get_db();
|
||||
|
||||
$errors = [];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$staff = require_staff_login();
|
||||
$staff = require_admin_or_permission('view_customers');
|
||||
$pdo = get_db();
|
||||
|
||||
$userId = (int) ($_GET['id'] ?? 0);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$staff = require_staff_login();
|
||||
$staff = require_admin_or_permission('manage_customers');
|
||||
$pdo = get_db();
|
||||
|
||||
$errors = [];
|
||||
@@ -10,7 +10,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
if ($action === 'assign_org' && $staff['role'] === 'admin') {
|
||||
if ($action === 'assign_org') {
|
||||
$userId = (int) $_POST['user_id'];
|
||||
$orgId = $_POST['organization_id'] !== '' ? (int) $_POST['organization_id'] : null;
|
||||
$pdo->prepare('UPDATE users SET organization_id = ? WHERE id = ?')->execute([$orgId, $userId]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$staff = require_admin();
|
||||
$staff = require_admin_or_permission('export_tickets');
|
||||
$pdo = get_db();
|
||||
$myDeptIds = staff_department_ids($staff); // null = Admin, alle Abteilungen
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
/**
|
||||
* Adminportal → Hilfe: Handbuch (PDF, kommt mit dem Update) herunterladen, dazu eine Kurzhilfe mit Sprungmarken.
|
||||
* Nur für echte Administratoren.
|
||||
*/
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
require_once __DIR__ . '/../includes/manual_file.php';
|
||||
require_once __DIR__ . '/_ui.php';
|
||||
$staff = require_admin_or_permission('view_help');
|
||||
|
||||
// Download
|
||||
if (isset($_GET['download'])) {
|
||||
$info = manual_info();
|
||||
if (!$info) { http_response_code(404); exit('Das Handbuch ist nicht hinterlegt.'); }
|
||||
session_write_close();
|
||||
while (ob_get_level() > 0) { @ob_end_clean(); }
|
||||
header('Content-Type: application/pdf');
|
||||
header('Content-Disposition: attachment; filename="' . MANUAL_FILE . '"');
|
||||
header('Content-Length: ' . $info['size']);
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Cache-Control: private, no-store');
|
||||
readfile(manual_path());
|
||||
exit;
|
||||
}
|
||||
|
||||
$info = manual_info();
|
||||
$pageTitle = 'Hilfe';
|
||||
$activePage = 'help';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
<h1 style="margin-top:0">Hilfe für Administratoren</h1>
|
||||
<p class="muted">Diese Seite sehen nur Administratoren: das Handbuch zum Herunterladen und eine Kurzhilfe für den Alltag.</p>
|
||||
|
||||
<div class="card" style="max-width:860px;margin-bottom:20px">
|
||||
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:8px">Handbuch</div>
|
||||
<?php if ($info): ?>
|
||||
<div style="font-size:1.3rem;font-weight:800">DeskHub-Handbuch (PDF)</div>
|
||||
<div class="muted" style="font-size:.82rem;margin:4px 0 12px">Zu DeskHub <?= e(DESKHUB_VERSION) ?> · <?= e(app_format_size($info['size'])) ?> · Stand <?= e(ui_date($info['modified_at'])) ?></div>
|
||||
<a class="btn" href="/staff/help.php?download=1">⬇ Handbuch herunterladen</a>
|
||||
<div class="muted" style="font-size:.72rem;margin-top:10px;word-break:break-all">SHA-256: <?= e($info['sha256']) ?></div>
|
||||
<div class="muted" style="font-size:.8rem;margin-top:10px">Das Handbuch beschreibt jede Funktion mit Bildern (Beispieldaten) und wird mit jedem Update aktualisiert.</div>
|
||||
<?php else: ?>
|
||||
<div style="font-size:1.1rem;font-weight:700">Das Handbuch ist in dieser Installation nicht vorhanden</div>
|
||||
<p class="muted" style="margin:6px 0 0">Es gehört zum Update-Paket (Datei <code>docs/<?= e(MANUAL_FILE) ?></code>). Spielt das nächste vollständige Update ein, dann erscheint es hier.</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="card" style="max-width:860px;margin-bottom:20px">
|
||||
<h3 style="margin-top:0">Erste Schritte</h3>
|
||||
<ol style="line-height:1.9;padding-left:20px;margin:0">
|
||||
<li><a href="/staff/departments.php">Abteilungen</a> anlegen – jedes Ticket gehört zu genau einer.</li>
|
||||
<li><a href="/staff/settings.php?tab=email">E-Mail-Versand (SMTP)</a> einrichten und eine Test-Mail senden.</li>
|
||||
<li><a href="/staff/imap_accounts.php">Postfach für den E-Mail-Abruf</a> einrichten und testen.</li>
|
||||
<li><a href="/staff/settings.php?tab=cron">Automatisierung (Cron-Jobs)</a> prüfen.</li>
|
||||
<li><a href="/staff/staff_manage.php">Mitarbeiter</a> anlegen und Rollen vergeben; Administratoren sollten 2FA oder einen Passkey nutzen.</li>
|
||||
<li><a href="/staff/security_check.php">Sicherheitsprüfung</a> ausführen und <a href="/staff/backup.php">Backups</a> einrichten.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="card" style="max-width:860px;margin-bottom:20px">
|
||||
<h3 style="margin-top:0">Wenn etwas nicht funktioniert</h3>
|
||||
<table style="width:100%;font-size:.9rem">
|
||||
<tr><td style="padding:6px 8px 6px 0;width:42%"><strong>Eine automatische Mail kommt nicht an</strong></td><td><a href="/staff/audit_log.php">Admin-Protokoll</a> (Fehlermeldung des Anbieters), <a href="/staff/mail_queue.php">Mail-Warteschlange</a>, Test-Mail unter Einstellungen → E-Mail.</td></tr>
|
||||
<tr><td style="padding:6px 8px 6px 0"><strong>E-Mails werden nicht zu Tickets</strong></td><td><a href="/staff/imap_accounts.php">Postfächer</a> → „Testen“ zeigt die Meldung des Mail-Servers.</td></tr>
|
||||
<tr><td style="padding:6px 8px 6px 0"><strong>Rotes oder gelbes Banner oben</strong></td><td>Störungsmeldung der Überwachung (Postfach, Abruf, Versand). Details unter <a href="/staff/email_addresses.php">E-Mail-Adressen</a>.</td></tr>
|
||||
<tr><td style="padding:6px 8px 6px 0"><strong>„Datenbank veraltet“</strong></td><td><a href="/staff/update.php">System-Update</a> → „Datenbank aktualisieren“.</td></tr>
|
||||
<tr><td style="padding:6px 8px 6px 0"><strong>Ein Plugin macht Probleme</strong></td><td>Unter <a href="/staff/plugins.php">Plugins</a> deaktivieren; geht die Seite nicht mehr auf, die Datei <code>plugins/.disabled</code> anlegen.</td></tr>
|
||||
<tr><td style="padding:6px 8px 6px 0"><strong>Ein Mitarbeiter ist gesperrt oder hat sein Gerät verloren</strong></td><td><a href="/staff/staff_manage.php">Mitarbeiter</a> → Menü ⋯: „Sperre aufheben“, „Überall abmelden“, „Passkeys entfernen“.</td></tr>
|
||||
<tr><td style="padding:6px 8px 6px 0"><strong>Anmeldungen und Sperren ansehen</strong></td><td><a href="/staff/login_log.php">Anmelde-Wächter</a> mit Protokoll und Einstellungen (auch Passkeys ein- und ausschalten).</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card" style="max-width:860px">
|
||||
<h3 style="margin-top:0">Diese Installation</h3>
|
||||
<table style="font-size:.9rem">
|
||||
<tr><td style="padding:3px 18px 3px 0;color:var(--muted)">DeskHub-Version</td><td><?= e(DESKHUB_VERSION) ?></td></tr>
|
||||
<tr><td style="padding:3px 18px 3px 0;color:var(--muted)">PHP</td><td><?= e(PHP_VERSION) ?></td></tr>
|
||||
<tr><td style="padding:3px 18px 3px 0;color:var(--muted)">Website-URL</td><td><?= e(base_url()) ?></td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
Reference in New Issue
Block a user