Upload via GUI (290 Dateien)
This commit is contained in:
@@ -58,7 +58,7 @@ function api_key_verify(PDO $pdo, string $key, string $ip): ?array
|
||||
* @return array{number:string,id:int}
|
||||
* @throws AdminModelException bei ungültigen Eingaben
|
||||
*/
|
||||
function public_ticket_create(PDO $pdo, array $in, string $clientIp): array
|
||||
function public_ticket_create(PDO $pdo, array $in, string $clientIp, array $opts = []): array
|
||||
{
|
||||
$name = trim((string) ($in['name'] ?? ''));
|
||||
$email = trim((string) ($in['email'] ?? ''));
|
||||
@@ -110,7 +110,8 @@ function public_ticket_create(PDO $pdo, array $in, string $clientIp): array
|
||||
$ticketId = (int) $pdo->lastInsertId();
|
||||
$pdo->prepare('INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?,?,?,0,?)')
|
||||
->execute([$ticketId, 'customer', $message, $now]);
|
||||
log_activity($pdo, $ticketId, null, 'Ticket über die API erstellt');
|
||||
log_activity($pdo, $ticketId, null, (string) ($opts['activity'] ?? 'Ticket über die API erstellt'));
|
||||
if (!empty($opts['tag'])) apply_tag_to_ticket($pdo, $ticketId, (string) $opts['tag']);
|
||||
|
||||
$assignTo = $ruleResult['assign_staff'];
|
||||
if ($assignTo === null) {
|
||||
|
||||
@@ -318,6 +318,15 @@ function notify_customer_new_reply(array $ticket, string $message, string $signa
|
||||
}
|
||||
}
|
||||
|
||||
/** Standardtext der Erinnerung an Kunden (siehe cron/remind_customers.php). */
|
||||
function email_customer_reminder_default_body(): string
|
||||
{
|
||||
return '<h2 style="margin:0 0 12px;font-size:20px;color:#0f172a;">Brauchen Sie noch Hilfe?</h2>'
|
||||
. '<p style="margin:0 0 12px;color:#334155;font-size:15px;line-height:1.6;">Hallo {customer_name}, wir haben Ihnen zu Ihrem Ticket <strong>#{ticket_number}</strong> („{subject}“) geantwortet und noch nichts von Ihnen gehört. Hat sich Ihr Anliegen erledigt, brauchen Sie nichts zu tun. Haben Sie noch Fragen, antworten Sie einfach.</p>'
|
||||
. '{close_note}'
|
||||
. '<p style="margin:20px 0 0;"><a href="{ticket_url}" style="background:#0f6f6a;color:#ffffff;text-decoration:none;padding:11px 20px;border-radius:8px;font-weight:600;display:inline-block;">Ticket ansehen</a></p>';
|
||||
}
|
||||
|
||||
function notify_customer_ticket_created(array $ticket): void
|
||||
{
|
||||
[$subject, $body] = render_email_template('ticket_created', [
|
||||
|
||||
@@ -85,6 +85,15 @@ function notify_new_ticket_channels(array $ticket, ?string $departmentEmail): vo
|
||||
function notify_new_reply_channels(array $ticket, string $excerpt): void
|
||||
{
|
||||
fcm_notify_new_reply($ticket); // Push auf die Android-App (läuft nach dem Senden der Seite)
|
||||
try {
|
||||
// Antwort des Kunden holt das Ticket aus der Wiedervorlage und setzt die Erinnerung zurück; Beobachter erfahren davon
|
||||
$pdo = get_db();
|
||||
ticket_snooze_clear($pdo, (int) $ticket['id'], null, 'Wiedervorlage beendet (Kunde hat geantwortet)');
|
||||
$pdo->prepare('UPDATE tickets SET reminder_sent_at = NULL WHERE id = ?')->execute([$ticket['id']]);
|
||||
ticket_followers_notify($ticket, 'Neue Kundenantwort');
|
||||
} catch (Throwable $e) {
|
||||
// Datenbank noch nicht aktualisiert: nicht schlimm
|
||||
}
|
||||
$short = mb_strlen($excerpt) > 200 ? mb_substr($excerpt, 0, 200) . '…' : $excerpt;
|
||||
$text = "💬 Neue Kundenantwort zu Ticket #{$ticket['ticket_number']}\n"
|
||||
. "Betreff: {$ticket['subject']}\n"
|
||||
|
||||
@@ -38,6 +38,8 @@ function pseudo_cron_job_definitions(): array
|
||||
'process_recurring' => ['setting' => 'cron_process_recurring_last_run', 'interval' => 86400, 'file' => 'process_recurring.php', 'fn' => 'run_process_recurring'],
|
||||
'anonymize_data' => ['setting' => 'cron_anonymize_data_last_run', 'interval' => 86400, 'file' => 'anonymize_data.php', 'fn' => 'run_anonymization'],
|
||||
'auto_close_tickets'=> ['setting' => 'cron_auto_close_tickets_last_run','interval' => 86400, 'file' => 'auto_close_tickets.php','fn' => 'run_auto_close_tickets'],
|
||||
'process_snoozes' => ['setting' => 'cron_process_snoozes_last_run', 'interval' => 120, 'file' => 'process_snoozes.php', 'fn' => 'run_process_snoozes'],
|
||||
'remind_customers' => ['setting' => 'cron_remind_customers_last_run', 'interval' => 3600, 'file' => 'remind_customers.php', 'fn' => 'run_remind_customers'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,18 @@ function staff_set_status(PDO $pdo, int $staffId, string $status, string $note =
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist die aktuelle Anfrage das Laden einer Seite (im Gegensatz zu einem Abruf, den eine geöffnete Seite im Hintergrund per fetch stellt)?
|
||||
* Egal, ob der Nutzer sie ausgelöst hat oder der Browser selbst (Tab wird nach dem Ruhezustand neu geladen, Seite lädt sich per Skript neu).
|
||||
*/
|
||||
function staff_is_document_request(): bool
|
||||
{
|
||||
$mode = $_SERVER['HTTP_SEC_FETCH_MODE'] ?? '';
|
||||
if ($mode !== '') return $mode === 'navigate' || ($_SERVER['HTTP_SEC_FETCH_DEST'] ?? '') === 'document';
|
||||
// Ältere Browser ohne Sec-Fetch-Header: Seitenaufrufe erkennt man daran, dass kein X-Requested-With gesetzt ist und HTML erwartet wird
|
||||
return empty($_SERVER['HTTP_X_REQUESTED_WITH']) && str_contains((string) ($_SERVER['HTTP_ACCEPT'] ?? ''), 'text/html');
|
||||
}
|
||||
|
||||
/** Ist die aktuelle Anfrage eine echte Seitennavigation des Nutzers (und kein Hintergrund-Abruf per fetch)? */
|
||||
function staff_is_user_navigation(): bool
|
||||
{
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
/**
|
||||
* includes/ticket_extras.php
|
||||
*
|
||||
* Wiedervorlage (Ticket "auf später legen"), Beobachter (Mitarbeiter verfolgen ein Ticket, ohne es zugewiesen zu haben),
|
||||
* ähnliche Tickets und Artikel sowie die Erinnerung an Kunden vor dem automatischen Schließen.
|
||||
*/
|
||||
|
||||
// ── Wiedervorlage ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Auswahl im Dialog "Wiedervorlage" (Schlüssel => Beschriftung). */
|
||||
function ticket_snooze_options(): array
|
||||
{
|
||||
return ['1h' => 'In 1 Stunde', '3h' => 'In 3 Stunden', 'tomorrow' => 'Morgen früh (8:00 Uhr)', '3d' => 'In 3 Tagen', '1w' => 'In 1 Woche', 'custom' => 'Zu einem Zeitpunkt …'];
|
||||
}
|
||||
|
||||
/** Rechnet die Auswahl in einen Zeitpunkt um (Y-m-d H:i:s); null, wenn ungültig oder nicht in der Zukunft (höchstens 1 Jahr). */
|
||||
function ticket_snooze_until(string $choice, string $custom = ''): ?string
|
||||
{
|
||||
$now = time();
|
||||
$ts = match ($choice) {
|
||||
'1h' => $now + 3600,
|
||||
'3h' => $now + 3 * 3600,
|
||||
'tomorrow' => (int) strtotime('tomorrow 08:00'),
|
||||
'3d' => $now + 3 * 86400,
|
||||
'1w' => $now + 7 * 86400,
|
||||
'custom' => $custom !== '' ? (int) strtotime(str_replace('T', ' ', $custom)) : 0,
|
||||
default => 0,
|
||||
};
|
||||
if ($ts <= $now + 30 || $ts > $now + 366 * 86400) return null;
|
||||
return date('Y-m-d H:i:s', $ts);
|
||||
}
|
||||
|
||||
function ticket_snooze_set(PDO $pdo, int $ticketId, int $staffId, string $until): void
|
||||
{
|
||||
$now = db_now();
|
||||
$pdo->prepare('UPDATE tickets SET snoozed_until = ?, snoozed_by = ?, updated_at = updated_at WHERE id = ?')->execute([$until, $staffId, $ticketId]);
|
||||
log_activity($pdo, $ticketId, $staffId, 'Wiedervorlage bis ' . date('d.m.Y H:i', (int) strtotime($until)) . ' Uhr');
|
||||
}
|
||||
|
||||
function ticket_snooze_clear(PDO $pdo, int $ticketId, ?int $staffId, string $reason): void
|
||||
{
|
||||
$stmt = $pdo->prepare('UPDATE tickets SET snoozed_until = NULL, snoozed_by = NULL WHERE id = ? AND snoozed_until IS NOT NULL');
|
||||
$stmt->execute([$ticketId]);
|
||||
if ($stmt->rowCount() > 0) log_activity($pdo, $ticketId, $staffId, $reason);
|
||||
}
|
||||
|
||||
/** Gibt es die Spalte der Wiedervorlage schon? (Solange die Datenbank nicht aktualisiert ist, bleiben die Listen trotzdem benutzbar.) */
|
||||
function ticket_snooze_available(): bool
|
||||
{
|
||||
static $ok = null;
|
||||
if ($ok === null) {
|
||||
try { get_db()->query('SELECT snoozed_until FROM tickets LIMIT 1'); $ok = true; } catch (Throwable $e) { $ok = false; }
|
||||
}
|
||||
return $ok;
|
||||
}
|
||||
|
||||
/** SQL-Bedingung: Ticket ist gerade nicht in der Wiedervorlage (für Listen und Zähler). */
|
||||
function ticket_not_snoozed_sql(string $alias = 't'): string
|
||||
{
|
||||
if (!ticket_snooze_available()) return '1=1';
|
||||
return "({$alias}.snoozed_until IS NULL OR {$alias}.snoozed_until <= '" . db_now() . "')";
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt fällige Wiedervorlagen zurück: Das Ticket erscheint wieder in den Listen, der Mitarbeiter bekommt eine Benachrichtigung.
|
||||
*
|
||||
* @return int Anzahl der zurückgeholten Tickets
|
||||
*/
|
||||
function ticket_snooze_wake_due(PDO $pdo): int
|
||||
{
|
||||
try {
|
||||
$stmt = $pdo->prepare('SELECT * FROM tickets WHERE snoozed_until IS NOT NULL AND snoozed_until <= ?');
|
||||
$stmt->execute([db_now()]);
|
||||
$due = $stmt->fetchAll();
|
||||
} catch (Throwable $e) {
|
||||
return 0; // Spalte fehlt (Datenbank noch nicht aktualisiert)
|
||||
}
|
||||
foreach ($due as $t) {
|
||||
$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));
|
||||
if ($to > 0) {
|
||||
fcm_notify_staff([$to], fcm_data('ticket_snooze', $t), (int) $t['department_id']);
|
||||
if (file_exists(__DIR__ . '/push.php')) {
|
||||
require_once __DIR__ . '/push.php';
|
||||
push_to_staff($to, 'Wiedervorlage: #' . $t['ticket_number'], mb_strimwidth((string) $t['subject'], 0, 60, '…'), '/staff/ticket_view.php?n=' . urlencode((string) $t['ticket_number']));
|
||||
}
|
||||
}
|
||||
}
|
||||
return count($due);
|
||||
}
|
||||
|
||||
// ── Beobachter ───────────────────────────────────────────────────────────────
|
||||
|
||||
function ticket_follow(PDO $pdo, int $ticketId, int $staffId): void
|
||||
{
|
||||
$c = $pdo->prepare('SELECT COUNT(*) FROM ticket_followers WHERE ticket_id = ? AND staff_id = ?');
|
||||
$c->execute([$ticketId, $staffId]);
|
||||
if ((int) $c->fetchColumn() === 0) {
|
||||
$pdo->prepare('INSERT INTO ticket_followers (ticket_id, staff_id, created_at) VALUES (?,?,?)')->execute([$ticketId, $staffId, db_now()]);
|
||||
}
|
||||
}
|
||||
|
||||
function ticket_unfollow(PDO $pdo, int $ticketId, int $staffId): void
|
||||
{
|
||||
$pdo->prepare('DELETE FROM ticket_followers WHERE ticket_id = ? AND staff_id = ?')->execute([$ticketId, $staffId]);
|
||||
}
|
||||
|
||||
/** @return array<int,array{id:int,name:string}> */
|
||||
function ticket_followers(PDO $pdo, int $ticketId): array
|
||||
{
|
||||
try {
|
||||
$stmt = $pdo->prepare('SELECT s.id, s.name FROM ticket_followers f JOIN staff s ON s.id = f.staff_id WHERE f.ticket_id = ? AND s.is_active = 1 ORDER BY s.name');
|
||||
$stmt->execute([$ticketId]);
|
||||
return $stmt->fetchAll();
|
||||
} catch (Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Benachrichtigt die Beobachter eines Tickets (nicht den, der es ausgelöst hat, und nicht den zugewiesenen Mitarbeiter, der ohnehin
|
||||
* Bescheid bekommt). $what ist ein kurzer Text wie "Neue Kundenantwort".
|
||||
*/
|
||||
function ticket_followers_notify(array $ticket, string $what, ?int $actorId = null): void
|
||||
{
|
||||
if (empty($ticket['id'])) return;
|
||||
$pdo = get_db();
|
||||
$ids = array_map(fn($f) => (int) $f['id'], ticket_followers($pdo, (int) $ticket['id']));
|
||||
$skip = array_filter([$actorId, (int) ($ticket['assigned_staff_id'] ?? 0)]);
|
||||
$ids = array_values(array_diff($ids, $skip));
|
||||
if (!$ids) return;
|
||||
$data = fcm_data('ticket_follow', $ticket);
|
||||
$data['body'] = $what . ' · #' . ($ticket['ticket_number'] ?? '');
|
||||
fcm_notify_staff($ids, $data, (int) ($ticket['department_id'] ?? 0));
|
||||
if (file_exists(__DIR__ . '/push.php')) {
|
||||
require_once __DIR__ . '/push.php';
|
||||
foreach ($ids as $id) {
|
||||
push_to_staff($id, 'Beobachtet: #' . ($ticket['ticket_number'] ?? ''), $what, '/staff/ticket_view.php?n=' . urlencode((string) ($ticket['ticket_number'] ?? '')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ähnliche Tickets und Artikel ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Passende Artikel der Wissensdatenbank und bereits gelöste, ähnliche Tickets zu Betreff (und Kundentext) eines Tickets.
|
||||
*
|
||||
* @param int[]|null $deptIds sichtbare Abteilungen des Mitarbeiters (null = alle)
|
||||
* @return array{articles:array,tickets:array}
|
||||
*/
|
||||
function ticket_similar(PDO $pdo, array $ticket, ?array $deptIds = null, int $limit = 4): array
|
||||
{
|
||||
require_once __DIR__ . '/assistant.php';
|
||||
$q = assistant_tokens((string) ($ticket['subject'] ?? ''));
|
||||
$out = ['articles' => [], 'tickets' => []];
|
||||
if (count($q) < 1) return $out;
|
||||
$need = max(1, (int) ceil(count($q) / 2));
|
||||
|
||||
foreach ($pdo->query('SELECT id, title, slug, body FROM kb_articles WHERE is_published = 1 ORDER BY id DESC LIMIT 500')->fetchAll() as $a) {
|
||||
$title = array_flip(assistant_tokens($a['title']));
|
||||
$body = array_flip(assistant_tokens(mb_substr(strip_tags((string) $a['body']), 0, 20000)));
|
||||
$score = 0.0; $hit = 0;
|
||||
foreach ($q as $t) {
|
||||
if (isset($title[$t])) { $score += 4; $hit++; }
|
||||
elseif (isset($body[$t])) { $score += 1.5; $hit++; }
|
||||
}
|
||||
if ($hit >= $need && $score >= 2.5) $out['articles'][] = ['score' => $score, 'title' => $a['title'], 'url' => '/kb_article.php?slug=' . rawurlencode((string) $a['slug'])];
|
||||
}
|
||||
usort($out['articles'], fn($a, $b) => $b['score'] <=> $a['score']);
|
||||
$out['articles'] = array_slice($out['articles'], 0, 3);
|
||||
|
||||
$sql = "SELECT id, ticket_number, subject, status FROM tickets WHERE id <> ? AND is_spam = 0 AND status IN ('resolved','closed')";
|
||||
$params = [(int) $ticket['id']];
|
||||
if ($deptIds !== null) {
|
||||
if (!$deptIds) return $out;
|
||||
$sql .= ' AND department_id IN (' . implode(',', array_fill(0, count($deptIds), '?')) . ')';
|
||||
array_push($params, ...$deptIds);
|
||||
}
|
||||
$stmt = $pdo->prepare($sql . ' ORDER BY id DESC LIMIT 800');
|
||||
$stmt->execute($params);
|
||||
foreach ($stmt->fetchAll() as $t) {
|
||||
$words = array_flip(assistant_tokens((string) $t['subject']));
|
||||
$hit = 0;
|
||||
foreach ($q as $w) if (isset($words[$w])) $hit++;
|
||||
if ($hit >= max(2, $need) || ($hit >= 1 && count($q) === 1)) $out['tickets'][] = ['score' => $hit, 'number' => $t['ticket_number'], 'subject' => $t['subject'], 'status' => $t['status']];
|
||||
}
|
||||
usort($out['tickets'], fn($a, $b) => $b['score'] <=> $a['score']);
|
||||
$out['tickets'] = array_slice($out['tickets'], 0, $limit);
|
||||
return $out;
|
||||
}
|
||||
|
||||
// ── Erinnerung an Kunden ─────────────────────────────────────────────────────
|
||||
|
||||
function customer_reminder_enabled(): bool
|
||||
{
|
||||
return get_setting('remind_enabled', '0') === '1';
|
||||
}
|
||||
|
||||
function customer_reminder_days(): int
|
||||
{
|
||||
return max(1, min(60, (int) get_setting('remind_after_days', '3')));
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
/**
|
||||
* includes/web_handoff.php
|
||||
*
|
||||
* Übergabe von der App an die Webseite: Für alles, was nur am Bildschirm sinnvoll zu bedienen ist (Einstellungen, Assistent,
|
||||
* Backup, Plugins, Webhooks …), öffnen Android- und Windows-App die passende Seite der Weboberfläche. Die App holt dafür eine
|
||||
* einmalige Anmelde-Adresse (60 Sekunden gültig, nur einmal benutzbar); die Webseite prüft dann wie immer selbst die Rechte.
|
||||
* Die App zeigt nur die Seiten, die der Mitarbeiter laut dieser Liste überhaupt öffnen darf.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Seiten der Weboberfläche, die die Apps nicht selbst nachbauen.
|
||||
*
|
||||
* @return array<int,array{key:string,label:string,path:string,group:string}>
|
||||
*/
|
||||
function web_handoff_pages(array $staff): array
|
||||
{
|
||||
$isAdmin = ($staff['role'] ?? '') === 'admin';
|
||||
$perm = fn(string $p): bool => $isAdmin || has_permission($staff, $p);
|
||||
$all = [
|
||||
// Alle Mitarbeiter
|
||||
['mentions', 'Erwähnungen', '/staff/mentions.php', 'Allgemein', true],
|
||||
['agent_directory', 'Mitarbeiterverzeichnis', '/staff/agent_directory.php', 'Allgemein', true],
|
||||
// Mit Recht
|
||||
['assistant', 'Digitaler Assistent (Antworten, Fragen-Log)', '/staff/assistant.php', 'Verwaltung', $perm('manage_assistant')],
|
||||
['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],
|
||||
['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],
|
||||
['backup', 'Backup', '/staff/backup.php', 'System', $isAdmin],
|
||||
['update', 'System-Update', '/staff/update.php', 'System', $isAdmin],
|
||||
['security_check', 'Sicherheitsprüfung', '/staff/security_check.php', 'System', $isAdmin],
|
||||
['import_osticket', 'Import aus osTicket', '/staff/import_osticket.php', 'System', $isAdmin],
|
||||
];
|
||||
$out = [];
|
||||
foreach ($all as [$key, $label, $path, $group, $allowed]) {
|
||||
if ($allowed) $out[] = ['key' => $key, 'label' => $label, 'path' => $path, 'group' => $group];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** Darf diese Adresse aus der App geöffnet werden? (eine Seite der Liste oder ein einzelnes Ticket) */
|
||||
function web_handoff_path_allowed(array $staff, string $path): bool
|
||||
{
|
||||
if (preg_match('#^/staff/(ticket_view|ticket_print|ticket_split)\.php\?n=[A-Za-z0-9._-]{1,40}$#', $path)) return true;
|
||||
foreach (web_handoff_pages($staff) as $p) if ($p['path'] === $path) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Erzeugt die einmalige Anmelde-Adresse für die Seite $path. */
|
||||
function web_handoff_create(PDO $pdo, array $staff, string $path): ?string
|
||||
{
|
||||
if (!web_handoff_path_allowed($staff, $path)) return null;
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$now = db_now();
|
||||
$pdo->prepare('DELETE FROM web_login_tokens WHERE expires_at < ?')->execute([date('Y-m-d H:i:s', time() - 3600)]);
|
||||
$pdo->prepare('INSERT INTO web_login_tokens (staff_id, token_hash, path, created_at, expires_at) VALUES (?,?,?,?,?)')
|
||||
->execute([(int) $staff['id'], hash('sha256', $token), $path, $now, date('Y-m-d H:i:s', time() + 60)]);
|
||||
return rtrim(base_url(), '/') . '/staff/app_login.php?t=' . $token;
|
||||
}
|
||||
|
||||
/** Löst die Anmelde-Adresse ein (einmalig). @return array{staff:array,path:string}|null */
|
||||
function web_handoff_consume(PDO $pdo, string $token): ?array
|
||||
{
|
||||
if (!preg_match('/^[a-f0-9]{64}$/', $token)) return null;
|
||||
$hash = hash('sha256', $token);
|
||||
$upd = $pdo->prepare('UPDATE web_login_tokens SET used_at = ? WHERE token_hash = ? AND used_at IS NULL AND expires_at >= ?');
|
||||
$upd->execute([db_now(), $hash, db_now()]);
|
||||
if ($upd->rowCount() !== 1) return null; // schon benutzt, abgelaufen oder unbekannt
|
||||
$row = $pdo->prepare('SELECT t.path AS handoff_path, s.* FROM web_login_tokens t JOIN staff s ON s.id = t.staff_id WHERE t.token_hash = ? AND s.is_active = 1');
|
||||
$row->execute([$hash]);
|
||||
$r = $row->fetch();
|
||||
if (!$r) return null;
|
||||
$path = (string) $r['handoff_path'];
|
||||
unset($r['handoff_path']);
|
||||
return ['staff' => $r, 'path' => $path];
|
||||
}
|
||||
Reference in New Issue
Block a user