From da394c5525e499d8e443be9ff2d4724542716bf7 Mon Sep 17 00:00:00 2001 From: "tom.hempel" Date: Mon, 1 Jun 2026 21:40:50 +0200 Subject: [PATCH] implement activity logging feature with UI for viewing logs --- .gitignore | 2 + api/index.php | 26 +- handlers/activity_log.php | 20 + lib/api_log.php | 820 ++++++++++++++++++++++++++++++++++++++ lib/response.php | 12 +- logs/api/.gitkeep | 0 website/css/style.css | 58 +++ website/js/pages/dev.js | 204 +++++++++- 8 files changed, 1129 insertions(+), 13 deletions(-) create mode 100644 handlers/activity_log.php create mode 100644 lib/api_log.php create mode 100644 logs/api/.gitkeep diff --git a/.gitignore b/.gitignore index def259b..160ec4b 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ uploads/*.lock # Logs / dumps / exports *.log +logs/api/* +!logs/api/.gitkeep *.sqlite *.db *.dump diff --git a/api/index.php b/api/index.php index 69ccf59..4fa8398 100644 --- a/api/index.php +++ b/api/index.php @@ -7,6 +7,20 @@ require_once __DIR__ . '/../common.php'; require_once __DIR__ . '/../db_init.php'; require_once __DIR__ . '/../lib/response.php'; require_once __DIR__ . '/../lib/validate.php'; +require_once __DIR__ . '/../lib/api_log.php'; + +// Parse route early (for access logs) +$requestUri = $_SERVER['REQUEST_URI'] ?? ''; +$path = parse_url($requestUri, PHP_URL_PATH); +$base = dirname($_SERVER['SCRIPT_NAME']); +$route = trim(substr($path, strlen($base)), '/'); +$route = preg_replace('/\.php$/', '', $route); +$route = strtolower($route); + +$method = $_SERVER['REQUEST_METHOD']; + +qdb_api_log_begin($method, $route !== '' ? $route : '(root)'); +register_shutdown_function('qdb_api_log_finish'); // CORS header('Access-Control-Allow-Origin: *'); @@ -20,16 +34,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { header('Content-Type: application/json; charset=UTF-8'); -// Parse route: strip /api/ prefix and .php suffix, normalize -$requestUri = $_SERVER['REQUEST_URI'] ?? ''; -$path = parse_url($requestUri, PHP_URL_PATH); -$base = dirname($_SERVER['SCRIPT_NAME']); -$route = trim(substr($path, strlen($base)), '/'); -$route = preg_replace('/\.php$/', '', $route); -$route = strtolower($route); - -$method = $_SERVER['REQUEST_METHOD']; - // Dispatch table: route -> handler file $routes = [ 'questionnaires' => __DIR__ . '/../handlers/questionnaires.php', @@ -51,6 +55,7 @@ $routes = [ 'dev' => __DIR__ . '/../handlers/dev.php', 'dev/import' => __DIR__ . '/../handlers/dev.php', 'health' => __DIR__ . '/../handlers/health.php', + 'activity-log' => __DIR__ . '/../handlers/activity_log.php', ]; try { @@ -66,6 +71,7 @@ try { require $handlerFile; } catch (Throwable $e) { + qdb_api_log_note_exception($e); error_log("Unhandled error on $method $route: " . $e->getMessage()); json_error('SERVER_ERROR', 'Internal server error', 500); } diff --git a/handlers/activity_log.php b/handlers/activity_log.php new file mode 100644 index 0000000..d86c78b --- /dev/null +++ b/handlers/activity_log.php @@ -0,0 +1,20 @@ + */ +const QDB_API_LOG_ACTIVITIES = ['app_sync', 'app_change', 'web_change']; + +function qdb_api_log_enabled(): bool +{ + $v = qdb_env_get('QDB_API_LOG'); + if ($v === null || $v === '') { + return true; + } + $v = strtolower(trim($v)); + return !in_array($v, ['0', 'false', 'no', 'off'], true); +} + +function qdb_api_log_dir(): string +{ + $custom = qdb_env_get('QDB_API_LOG_DIR'); + if ($custom !== null && $custom !== '') { + return rtrim($custom, '/\\'); + } + return dirname(__DIR__) . '/logs/api'; +} + +function qdb_api_log_max_bytes(): int +{ + $v = qdb_env_get('QDB_API_LOG_MAX_BYTES'); + if ($v !== null && $v !== '' && ctype_digit($v)) { + return max(1024 * 1024, (int)$v); + } + return 10 * 1024 * 1024; +} + +function qdb_api_log_body_max_chars(): int +{ + $v = qdb_env_get('QDB_API_LOG_BODY_MAX'); + if ($v !== null && $v !== '' && ctype_digit($v)) { + return max(256, (int)$v); + } + return 4096; +} + +/** + * Whether this request should be written to the activity log. + */ +function qdb_api_log_classify_activity(string $method, string $client, string $route): ?string +{ + $method = strtoupper($method); + $client = strtolower(trim($client)); + $route = strtolower(trim($route)); + + if ($method === 'OPTIONS' || $route === 'health' || $route === 'activity-log') { + return null; + } + + if (in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) { + return $client === 'web' ? 'web_change' : 'app_change'; + } + + if ($method === 'GET' && $route === 'app_questionnaires' && $client !== 'web') { + return 'app_sync'; + } + + return null; +} + +/** @param array $context */ +function qdb_api_log_begin(string $method, string $route, array $context = []): void +{ + if (!qdb_api_log_enabled()) { + return; + } + + $client = trim((string)($_SERVER['HTTP_X_QDB_CLIENT'] ?? '')); + $activity = qdb_api_log_classify_activity($method, $client, $route); + if ($activity === null) { + $GLOBALS['qdb_api_log_ctx'] = ['skip' => true]; + return; + } + + $GLOBALS['qdb_api_log_ctx'] = array_merge([ + 'started_at' => microtime(true), + 'method' => strtoupper($method), + 'route' => $route, + 'activity' => $activity, + 'uri' => (string)($_SERVER['REQUEST_URI'] ?? ''), + 'ip' => qdb_api_log_client_ip(), + 'client' => $client, + 'user_agent' => trim((string)($_SERVER['HTTP_USER_AGENT'] ?? '')), + ], $context); +} + +function qdb_api_log_note_exception(Throwable $e): void +{ + if (empty($GLOBALS['qdb_api_log_ctx']) || !empty($GLOBALS['qdb_api_log_ctx']['skip'])) { + return; + } + $GLOBALS['qdb_api_log_ctx']['exception'] = $e->getMessage(); + $GLOBALS['qdb_api_log_ctx']['exception_class'] = $e::class; +} + +function qdb_api_log_finish(): void +{ + $ctx = $GLOBALS['qdb_api_log_ctx'] ?? null; + unset($GLOBALS['qdb_api_log_ctx']); + if (!is_array($ctx) || !qdb_api_log_enabled() || !empty($ctx['skip'])) { + return; + } + + $started = (float)($ctx['started_at'] ?? microtime(true)); + $status = http_response_code(); + if ($status === false || $status === 0) { + $status = 200; + } + + $token = get_bearer_token(); + $auth = ['role' => null, 'userID' => null, 'token_hint' => null]; + if ($token !== null && $token !== '') { + $auth['token_hint'] = qdb_api_log_token_hint($token); + try { + $rec = token_get_record($token); + if (is_array($rec)) { + $auth['role'] = $rec['role'] ?? null; + $auth['userID'] = $rec['userID'] ?? null; + if (!empty($rec['temp'])) { + $auth['temp_token'] = true; + } + } + } catch (Throwable) { + $auth['token_lookup'] = 'failed'; + } + } + + $rawBody = function_exists('qdb_raw_request_body') ? qdb_raw_request_body() : ''; + $bodyLog = qdb_api_log_format_body($rawBody); + $queryLog = qdb_api_log_redact_query_string((string)($_SERVER['QUERY_STRING'] ?? '')); + + $actorUsername = qdb_api_log_lookup_username($auth['userID'] ?? null); + $targetUsername = qdb_api_log_extract_target_username($bodyLog, $queryLog); + + $entry = [ + 'ts' => gmdate('c'), + 'activity' => $ctx['activity'] ?? null, + 'method' => $ctx['method'] ?? '', + 'route' => $ctx['route'] ?? '', + 'status' => $status, + 'duration_ms' => (int)round((microtime(true) - $started) * 1000), + 'ip' => $ctx['ip'] ?? '', + 'client' => $ctx['client'] ?? '', + 'uri' => $ctx['uri'] ?? '', + 'query' => $queryLog, + 'role' => $auth['role'], + 'userID' => $auth['userID'], + 'actor_username' => $actorUsername, + 'target_username' => $targetUsername, + 'token_hint' => $auth['token_hint'], + 'temp_token' => $auth['temp_token'] ?? null, + 'changes' => qdb_api_log_build_changes( + (string)($ctx['method'] ?? ''), + (string)($ctx['route'] ?? ''), + $bodyLog, + $queryLog + ), + ]; + + if (!empty($ctx['exception'])) { + $entry['error'] = (string)$ctx['exception']; + if (!empty($ctx['exception_class'])) { + $entry['error_class'] = (string)$ctx['exception_class']; + } + } + + if (($ctx['user_agent'] ?? '') !== '') { + $entry['user_agent'] = $ctx['user_agent']; + } + + try { + $line = json_encode($entry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($line === false) { + return; + } + qdb_api_log_append_line($line . "\n"); + } catch (Throwable $e) { + error_log('api_log write failed: ' . $e->getMessage()); + } +} + +function qdb_api_log_client_ip(): string +{ + foreach (['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR'] as $key) { + $v = trim((string)($_SERVER[$key] ?? '')); + if ($v === '') { + continue; + } + if ($key === 'HTTP_X_FORWARDED_FOR') { + $v = trim(explode(',', $v)[0]); + } + return $v; + } + return ''; +} + +function qdb_api_log_token_hint(string $token): string +{ + $len = strlen($token); + if ($len <= 8) { + return '[token]'; + } + return substr($token, 0, 4) . '…' . substr($token, -4) . " ($len)"; +} + +function qdb_api_log_redact_query_string(string $qs): ?string +{ + if ($qs === '') { + return null; + } + parse_str($qs, $params); + if (!is_array($params) || $params === []) { + return $qs; + } + foreach ($params as $k => $_) { + if (in_array(strtolower((string)$k), ['token', 'password', 'authorization'], true)) { + $params[$k] = '[REDACTED]'; + } + } + return http_build_query($params); +} + +/** + * @return array|string|null + */ +function qdb_api_log_format_body(string $raw): array|string|null +{ + $raw = trim($raw); + if ($raw === '') { + return null; + } + + $max = qdb_api_log_body_max_chars(); + $decoded = json_decode($raw, true); + if (is_array($decoded)) { + $redacted = qdb_api_log_redact_value($decoded); + $json = json_encode($redacted, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json === false) { + return '[unencodable body]'; + } + if (strlen($json) > $max) { + return substr($json, 0, $max) . '…[truncated]'; + } + return $redacted; + } + + if (strlen($raw) > $max) { + return substr($raw, 0, $max) . '…[truncated]'; + } + return $raw; +} + +/** @param mixed $value @return mixed */ +function qdb_api_log_redact_value(mixed $value): mixed +{ + if (!is_array($value)) { + return $value; + } + + $isList = array_keys($value) === range(0, count($value) - 1); + $out = []; + foreach ($value as $k => $v) { + $lk = strtolower((string)$k); + if (in_array($lk, [ + 'password', 'old_password', 'new_password', 'passwordhash', + 'token', 'authorization', 'secret', + ], true)) { + $out[$k] = '[REDACTED]'; + } elseif ($lk === 'encrypted' && is_string($v)) { + $out[$k] = '[ENCRYPTED:' . strlen($v) . ' chars]'; + } elseif (is_array($v)) { + $out[$k] = qdb_api_log_redact_value($v); + } else { + $out[$k] = $v; + } + } + return $isList ? array_values($out) : $out; +} + +/** + * Field-level change list for the activity log UI (passwords stay redacted). + * + * @param mixed $body Redacted decoded body or null + * @return list + */ +function qdb_api_log_build_changes(string $method, string $route, mixed $body, ?string $queryString): array +{ + $changes = []; + $max = 100; + + if ($queryString !== null && $queryString !== '') { + parse_str($queryString, $params); + if (is_array($params)) { + foreach ($params as $k => $v) { + if (count($changes) >= $max) { + break; + } + if (!is_scalar($v)) { + continue; + } + $changes[] = [ + 'field' => 'query.' . (string)$k, + 'value' => qdb_api_log_change_scalar($v), + ]; + } + } + } + + if (is_array($body)) { + qdb_api_log_flatten_for_changes($body, '', $changes, 0, 4, $max); + } elseif (is_string($body) && $body !== '') { + $changes[] = ['field' => 'body', 'value' => qdb_api_log_change_scalar($body)]; + } + + if ($changes === [] && strtoupper($method) === 'DELETE') { + $changes[] = ['field' => '_action', 'value' => 'delete']; + } + + return $changes; +} + +/** + * @param list $out + */ +function qdb_api_log_flatten_for_changes( + array $data, + string $prefix, + array &$out, + int $depth, + int $maxDepth, + int $maxFields +): void { + if ($depth >= $maxDepth || count($out) >= $maxFields) { + return; + } + + foreach ($data as $k => $v) { + if (count($out) >= $maxFields) { + return; + } + $key = (string)$k; + $field = $prefix === '' ? $key : $prefix . '.' . $key; + + if (is_array($v)) { + $childList = array_keys($v) === range(0, count($v) - 1); + $n = count($v); + if ($n === 0) { + $out[] = ['field' => $field, 'value' => '[]']; + continue; + } + if ($childList && $n > 8) { + $out[] = ['field' => $field, 'value' => '[list: ' . $n . ' items]']; + continue; + } + if (!$childList && $depth + 1 >= $maxDepth) { + $out[] = ['field' => $field, 'value' => '[object: ' . $n . ' keys]']; + continue; + } + qdb_api_log_flatten_for_changes($v, $field, $out, $depth + 1, $maxDepth, $maxFields); + } else { + $out[] = ['field' => $field, 'value' => qdb_api_log_change_scalar($v)]; + } + } +} + +/** @return string|int|float|bool|null */ +function qdb_api_log_change_scalar(mixed $v): string|int|float|bool|null +{ + if ($v === null) { + return null; + } + if (is_bool($v) || is_int($v) || is_float($v)) { + return $v; + } + $s = is_string($v) ? $v : json_encode($v, JSON_UNESCAPED_UNICODE); + if ($s === false) { + return ''; + } + if (strlen($s) > 800) { + return substr($s, 0, 800) . '…'; + } + return $s; +} + +function qdb_api_log_append_line(string $line): void +{ + $dir = qdb_api_log_dir(); + if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) { + throw new RuntimeException("Cannot create log directory: $dir"); + } + + $path = qdb_api_log_resolve_write_path($dir); + if (@file_put_contents($path, $line, FILE_APPEND | LOCK_EX) === false) { + throw new RuntimeException("Cannot write API log: $path"); + } +} + +function qdb_api_log_resolve_write_path(string $dir): string +{ + $date = date('Y-m-d'); + $base = $dir . '/api-' . $date; + $max = qdb_api_log_max_bytes(); + + $primary = $base . '.log'; + if (!is_file($primary) || filesize($primary) < $max) { + return $primary; + } + + for ($part = 2; $part <= 999; $part++) { + $path = sprintf('%s-%03d.log', $base, $part); + if (!is_file($path) || filesize($path) < $max) { + return $path; + } + } + + return sprintf('%s-%03d.log', $base, 999); +} + +/** + * Read activity log entries for admin UI (newest first). + * + * @return array{date: string, activity: string, entries: list>, files: list, truncated: bool} + */ +function qdb_api_log_read_entries(string $date, string $activityFilter = '', int $limit = 200): array +{ + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { + $date = date('Y-m-d'); + } + $limit = max(1, min(1000, $limit)); + $activityFilter = strtolower(trim($activityFilter)); + if ($activityFilter !== '' && !in_array($activityFilter, QDB_API_LOG_ACTIVITIES, true)) { + $activityFilter = ''; + } + + $dir = qdb_api_log_dir(); + $files = qdb_api_log_files_for_date($dir, $date); + $entries = []; + + foreach ($files as $file) { + $lines = @file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + if ($lines === false) { + continue; + } + foreach ($lines as $line) { + $row = json_decode($line, true); + if (!is_array($row)) { + continue; + } + if ($activityFilter !== '' && ($row['activity'] ?? '') !== $activityFilter) { + continue; + } + $entries[] = qdb_api_log_entry_for_ui($row); + } + } + + usort($entries, static function (array $a, array $b): int { + return strcmp($b['ts'] ?? '', $a['ts'] ?? ''); + }); + + $truncated = count($entries) > $limit; + if ($truncated) { + $entries = array_slice($entries, 0, $limit); + } + + $entries = qdb_api_log_enrich_entries_usernames($entries); + + return [ + 'date' => $date, + 'activity' => $activityFilter, + 'entries' => $entries, + 'files' => array_map('basename', $files), + 'truncated' => $truncated, + 'kinds' => QDB_API_LOG_ACTIVITIES, + ]; +} + +/** @return list */ +function qdb_api_log_files_for_date(string $dir, string $date): array +{ + if (!is_dir($dir)) { + return []; + } + $pattern = $dir . '/api-' . $date . '*.log'; + $files = glob($pattern) ?: []; + sort($files); + return $files; +} + +/** @param array $row @return array */ +function qdb_api_log_entry_for_ui(array $row): array +{ + $changes = $row['changes'] ?? null; + if (!is_array($changes) || $changes === []) { + $changes = qdb_api_log_build_changes( + (string)($row['method'] ?? ''), + (string)($row['route'] ?? ''), + $row['body'] ?? null, + isset($row['query']) ? (string)$row['query'] : null + ); + } + $changes = qdb_api_log_sort_changes_for_display($changes); + + $actorUsername = $row['actor_username'] ?? null; + $targetUsername = $row['target_username'] ?? null; + + return [ + 'ts' => $row['ts'] ?? '', + 'activity' => $row['activity'] ?? '', + 'method' => $row['method'] ?? '', + 'route' => $row['route'] ?? '', + 'status' => $row['status'] ?? null, + 'duration_ms' => $row['duration_ms'] ?? null, + 'role' => $row['role'] ?? null, + 'userID' => $row['userID'] ?? null, + 'actor_username' => is_string($actorUsername) ? $actorUsername : null, + 'target_username' => is_string($targetUsername) ? $targetUsername : null, + 'client' => $row['client'] ?? '', + 'ip' => $row['ip'] ?? '', + 'query' => $row['query'] ?? null, + 'changes' => $changes, + 'summary' => qdb_api_log_summarize_changes($changes, $targetUsername), + 'error' => $row['error'] ?? null, + ]; +} + +function qdb_api_log_lookup_username(?string $userID): ?string +{ + if ($userID === null || $userID === '') { + return null; + } + try { + require_once __DIR__ . '/../db_init.php'; + [$pdo, $tmpDb, $lockFp] = qdb_open(false); + $stmt = $pdo->prepare('SELECT username FROM users WHERE userID = :uid'); + $stmt->execute([':uid' => $userID]); + $name = $stmt->fetchColumn(); + qdb_discard($tmpDb, $lockFp); + return $name !== false ? (string)$name : null; + } catch (Throwable) { + return null; + } +} + +function qdb_api_log_extract_target_username(mixed $body, ?string $queryString): ?string +{ + if (is_array($body) && !empty($body['username']) && is_scalar($body['username'])) { + return trim((string)$body['username']); + } + if ($queryString !== null && $queryString !== '') { + parse_str($queryString, $params); + if (!empty($params['username']) && is_scalar($params['username'])) { + return trim((string)$params['username']); + } + } + return null; +} + +/** + * Resolve userID / supervisorID in log rows to usernames for retraceability. + * + * @param list> $entries + * @return list> + */ +function qdb_api_log_enrich_entries_usernames(array $entries): array +{ + if ($entries === []) { + return $entries; + } + + $userIds = []; + $supervisorIds = []; + + foreach ($entries as $entry) { + if (!empty($entry['userID']) && is_string($entry['userID'])) { + $userIds[$entry['userID']] = true; + } + if (empty($entry['actor_username']) && !empty($entry['userID'])) { + $userIds[$entry['userID']] = true; + } + foreach ($entry['changes'] ?? [] as $change) { + if (!is_array($change)) { + continue; + } + $field = strtolower((string)($change['field'] ?? '')); + $value = $change['value'] ?? null; + if (!is_string($value) || $value === '' || str_starts_with($value, '[')) { + continue; + } + if ($field === 'userid' || str_ends_with($field, '.userid')) { + $userIds[$value] = true; + } + if ($field === 'supervisorid' || str_ends_with($field, '.supervisorid')) { + $supervisorIds[$value] = true; + } + } + } + + $userMap = qdb_api_log_load_user_map(array_keys($userIds)); + $svMap = qdb_api_log_load_supervisor_map(array_keys($supervisorIds)); + + foreach ($entries as $i => $entry) { + if (empty($entry['actor_username']) && !empty($entry['userID'])) { + $entry['actor_username'] = $userMap[$entry['userID']] ?? null; + } + if (empty($entry['target_username'])) { + $entry['target_username'] = qdb_api_log_target_from_changes($entry['changes'] ?? []); + } + + $enriched = []; + foreach ($entry['changes'] ?? [] as $change) { + if (!is_array($change)) { + continue; + } + $change['display'] = qdb_api_log_change_display_label($change, $userMap, $svMap); + $enriched[] = $change; + } + $entry['changes'] = qdb_api_log_sort_changes_for_display($enriched); + $entry['summary'] = qdb_api_log_summarize_changes( + $entry['changes'], + $entry['target_username'] ?? null + ); + $entries[$i] = $entry; + } + + return $entries; +} + +/** @param list $userIds @return array */ +function qdb_api_log_load_user_map(array $userIds): array +{ + $userIds = array_values(array_filter($userIds, static fn($id) => $id !== '')); + if ($userIds === []) { + return []; + } + try { + require_once __DIR__ . '/../db_init.php'; + [$pdo, $tmpDb, $lockFp] = qdb_open(false); + $placeholders = implode(',', array_fill(0, count($userIds), '?')); + $stmt = $pdo->prepare( + "SELECT userID, username FROM users WHERE userID IN ($placeholders)" + ); + $stmt->execute($userIds); + $map = []; + while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { + $map[(string)$row['userID']] = (string)$row['username']; + } + qdb_discard($tmpDb, $lockFp); + return $map; + } catch (Throwable) { + return []; + } +} + +/** @param list $supervisorIds @return array */ +function qdb_api_log_load_supervisor_map(array $supervisorIds): array +{ + $supervisorIds = array_values(array_filter($supervisorIds, static fn($id) => $id !== '')); + if ($supervisorIds === []) { + return []; + } + try { + require_once __DIR__ . '/../db_init.php'; + [$pdo, $tmpDb, $lockFp] = qdb_open(false); + $placeholders = implode(',', array_fill(0, count($supervisorIds), '?')); + $stmt = $pdo->prepare( + "SELECT supervisorID, username FROM supervisor WHERE supervisorID IN ($placeholders)" + ); + $stmt->execute($supervisorIds); + $map = []; + while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { + $map[(string)$row['supervisorID']] = (string)$row['username']; + } + qdb_discard($tmpDb, $lockFp); + return $map; + } catch (Throwable) { + return []; + } +} + +/** @param list $changes */ +function qdb_api_log_target_from_changes(array $changes): ?string +{ + foreach ($changes as $c) { + $field = strtolower((string)($c['field'] ?? '')); + if ($field === 'username' && isset($c['value']) && is_scalar($c['value'])) { + return trim((string)$c['value']); + } + } + return null; +} + +/** + * @param array $userMap + * @param array $svMap + */ +function qdb_api_log_change_display_label(array $change, array $userMap, array $svMap): string +{ + $field = strtolower((string)($change['field'] ?? '')); + $value = $change['value'] ?? null; + if ($value === null) { + return 'null'; + } + if (!is_scalar($value)) { + return ''; + } + $str = (string)$value; + + if ($field === 'username') { + return $str; + } + if ($field === 'userid' || str_ends_with($field, '.userid')) { + return $userMap[$str] ?? $str; + } + if ($field === 'supervisorid' || str_ends_with($field, '.supervisorid')) { + return $svMap[$str] ?? $str; + } + if ($field === 'clientcode' || str_ends_with($field, 'clientcode')) { + return $str; + } + if ($field === 'mustchangepassword') { + return ((int)$value === 1) ? 'yes (temporary password)' : 'no (permanent password)'; + } + if ($field === 'password' || str_contains($field, 'password')) { + return (string)$value; + } + + return $str; +} + +/** + * @param list $changes + * @return list + */ +function qdb_api_log_sort_changes_for_display(array $changes): array +{ + $priority = [ + 'username' => 0, + 'role' => 1, + 'clientcode' => 2, + 'action' => 3, + 'query.id' => 4, + 'query.clients' => 5, + 'query.clientcode' => 6, + 'query.answers' => 7, + 'query.translations' => 8, + 'supervisorid' => 9, + 'userid' => 10, + 'mustchangepassword' => 11, + 'password' => 12, + 'location' => 13, + ]; + + usort($changes, static function (array $a, array $b) use ($priority): int { + $fa = strtolower((string)($a['field'] ?? '')); + $fb = strtolower((string)($b['field'] ?? '')); + $pa = $priority[$fa] ?? 50; + $pb = $priority[$fb] ?? 50; + if ($pa !== $pb) { + return $pa <=> $pb; + } + return strcmp($fa, $fb); + }); + + return $changes; +} + +/** @param list $changes */ +function qdb_api_log_summarize_changes(array $changes, ?string $targetUsername = null): string +{ + if ($targetUsername !== null && $targetUsername !== '') { + return 'user=' . $targetUsername; + } + if ($changes === []) { + return ''; + } + $parts = []; + foreach (array_slice($changes, 0, 8) as $c) { + $field = (string)($c['field'] ?? ''); + if ($field === '') { + continue; + } + $label = (string)($c['display'] ?? ''); + $value = $c['value'] ?? ''; + if ($label !== '' && $label !== (string)$value) { + $parts[] = $field . '=' . $label; + } elseif ($value === null) { + $parts[] = $field . '=null'; + } elseif (is_bool($value)) { + $parts[] = $field . '=' . ($value ? 'true' : 'false'); + } else { + $parts[] = $field . '=' . (string)$value; + } + } + $summary = implode(', ', $parts); + if (count($changes) > 8) { + $summary .= ', …'; + } + if (strlen($summary) > 240) { + $summary = substr($summary, 0, 240) . '…'; + } + return $summary; +} diff --git a/lib/response.php b/lib/response.php index dcd1f68..3476562 100644 --- a/lib/response.php +++ b/lib/response.php @@ -16,8 +16,18 @@ function json_error(string $code, string $message, int $status = 400): never { exit; } +/** Raw request body (cached; php://input is single-read). */ +function qdb_raw_request_body(): string { + static $raw = null; + if ($raw === null) { + $read = file_get_contents('php://input'); + $raw = $read === false ? '' : $read; + } + return $raw; +} + function read_json_body(): array { - $raw = file_get_contents('php://input'); + $raw = qdb_raw_request_body(); $data = json_decode($raw, true); if (!is_array($data)) { json_error('INVALID_BODY', 'Request body must be valid JSON', 400); diff --git a/logs/api/.gitkeep b/logs/api/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/website/css/style.css b/website/css/style.css index 1ae20e4..6caea1a 100644 --- a/website/css/style.css +++ b/website/css/style.css @@ -323,6 +323,64 @@ code { .badge-completed { background: var(--badge-active-bg); color: var(--badge-active-fg); } .badge-in_progress { background: var(--badge-in-progress-bg); color: var(--badge-in-progress-fg); } .badge-pending { background: var(--badge-pending-bg); color: var(--badge-pending-fg); } +.badge-activity-app_sync { background: var(--badge-coach-bg); color: var(--badge-coach-fg); } +.badge-activity-app_change { background: var(--badge-in-progress-bg); color: var(--badge-in-progress-fg); } +.badge-activity-web_change { background: var(--badge-supervisor-bg); color: var(--badge-supervisor-fg); } +.activity-log-card .table-wrapper { max-height: 420px; overflow: auto; } +.activity-log-detail { + font-size: 0.8rem; + max-width: 280px; + word-break: break-word; +} +.activity-log-changes-cell { + min-width: 220px; + max-width: 420px; + vertical-align: top; +} +.activity-log-subject, +.activity-log-actor { + font-size: 0.85rem; + vertical-align: top; +} +.activity-log-subject strong, +.activity-log-actor strong { + color: var(--text); +} +.activity-log-role { + font-weight: 400; + color: var(--text-secondary); + font-size: 0.78rem; +} +.activity-change-raw { + font-size: 0.72rem; + color: var(--text-secondary); + margin-top: 2px; + font-family: var(--font-mono, ui-monospace, monospace); + word-break: break-all; +} +.activity-changes-table { + width: 100%; + border-collapse: collapse; + font-size: 0.78rem; +} +.activity-changes-table th { + text-align: left; + font-weight: 600; + color: var(--text-secondary); + padding: 2px 10px 2px 0; + vertical-align: top; + white-space: nowrap; +} +.activity-changes-table td { + padding: 2px 0; + word-break: break-word; + color: var(--text); +} +.activity-changes-table tr + tr th, +.activity-changes-table tr + tr td { + border-top: 1px solid var(--border); + padding-top: 4px; +} /* Page header */ .page-header { diff --git a/website/js/pages/dev.js b/website/js/pages/dev.js index 251a1b9..fbcc952 100644 --- a/website/js/pages/dev.js +++ b/website/js/pages/dev.js @@ -1,7 +1,13 @@ -import { apiPost } from '../api.js'; +import { apiGet, apiPost } from '../api.js'; import { getRole, pageHeaderHTML, showToast } from '../app.js'; import { navigate } from '../router.js'; +const ACTIVITY_LABELS = { + app_sync: 'App sync', + app_change: 'App change', + web_change: 'Website change', +}; + export function devPage() { if (getRole() !== 'admin') { navigate('#/'); @@ -9,6 +15,7 @@ export function devPage() { } const app = document.getElementById('app'); + const today = new Date().toISOString().slice(0, 10); app.innerHTML = ` ${pageHeaderHTML('Admin Settings')}
@@ -51,6 +58,46 @@ export function devPage() {

+
+

API activity log

+

+ App syncs (GET /app_questionnaires) and data changes + (POST, PUT, PATCH, DELETE). Routine website page loads are not logged. +

+
+ + + + + +
+

+
+ + + + + + + + + + + + + + + + + +
Time (UTC)KindSubjectMethodRouteStatusDurationValuesPerformed by
Loading…
+
+
`; let parsedFixture = null; @@ -163,6 +210,7 @@ export function devPage() { }); wireAdminZipExport(); + wireActivityLog(); document.getElementById('devImportBtn').addEventListener('click', async () => { if (!parsedFixture) return; @@ -200,6 +248,155 @@ export function devPage() { }); } +function wireActivityLog() { + const dateEl = document.getElementById('activityLogDate'); + const kindEl = document.getElementById('activityLogKind'); + document.getElementById('activityLogRefresh')?.addEventListener('click', loadActivityLog); + dateEl?.addEventListener('change', loadActivityLog); + kindEl?.addEventListener('change', loadActivityLog); + loadActivityLog(); +} + +async function loadActivityLog() { + const tbody = document.getElementById('activityLogBody'); + const meta = document.getElementById('activityLogMeta'); + if (!tbody) return; + + const date = document.getElementById('activityLogDate')?.value || new Date().toISOString().slice(0, 10); + const activity = document.getElementById('activityLogKind')?.value || ''; + tbody.innerHTML = 'Loading…'; + + try { + const q = new URLSearchParams({ date, limit: '300' }); + if (activity) q.set('activity', activity); + const data = await apiGet(`activity-log?${q}`); + if (!data.success) throw new Error(data.error || 'Failed to load activity log'); + + const entries = data.entries || []; + const files = (data.files || []).join(', ') || 'none'; + const trunc = data.truncated ? ' (newest 300 shown)' : ''; + if (meta) { + meta.textContent = entries.length + ? `${entries.length} entries from ${files}${trunc}` + : `No entries for this date/filter (files: ${files})`; + } + + if (!entries.length) { + tbody.innerHTML = 'No activity recorded'; + return; + } + + tbody.innerHTML = entries.map((e) => { + const kind = e.activity || ''; + const label = ACTIVITY_LABELS[kind] || kind || '—'; + const badgeClass = kind ? `badge-activity-${kind}` : ''; + const time = formatActivityTime(e.ts); + const status = e.status != null ? String(e.status) : '—'; + const dur = e.duration_ms != null ? `${e.duration_ms} ms` : '—'; + const subject = formatActivitySubject(e); + const performedBy = formatActivityPerformer(e); + const errPart = e.error + ? `
${escHtml(e.error)}
` + : ''; + return ` + ${escHtml(time)} + ${escHtml(label)} + ${subject} + ${escHtml(e.method || '')} + ${escHtml(e.route || '')} + ${escHtml(status)} + ${escHtml(dur)} + ${renderActivityChanges(e.changes)} + ${performedBy}${errPart} + `; + }).join(''); + } catch (err) { + if (meta) meta.textContent = ''; + tbody.innerHTML = `${escHtml(err.message)}`; + } +} + +function formatActivitySubject(entry) { + if (entry.target_username) { + return `${escHtml(entry.target_username)}`; + } + const changes = entry.changes || []; + const userChange = changes.find((c) => (c.field || '').toLowerCase() === 'username'); + if (userChange) { + const name = userChange.display || formatActivityChangeValue(userChange.value); + return `${escHtml(name)}`; + } + const client = changes.find((c) => (c.field || '').toLowerCase().includes('clientcode')); + if (client) { + const code = client.display || formatActivityChangeValue(client.value); + return `client ${escHtml(code)}`; + } + const qClient = changes.find((c) => (c.field || '') === 'query.clientCode'); + if (qClient) { + return `client ${escHtml(formatActivityChangeValue(qClient.value))}`; + } + return ''; +} + +function formatActivityPerformer(entry) { + if (entry.actor_username) { + const role = entry.role ? ` (${escHtml(entry.role)})` : ''; + return `${escHtml(entry.actor_username)}${role}`; + } + if (entry.role) { + return escHtml(entry.role); + } + return '—'; +} + +function renderActivityChanges(changes) { + const list = Array.isArray(changes) ? changes : []; + if (!list.length) { + return ''; + } + const rows = list.map((c) => { + const field = c.field ?? ''; + const raw = formatActivityChangeValue(c.value); + const display = (c.display != null && String(c.display) !== '') + ? String(c.display) + : raw; + const showRaw = display !== raw + && !raw.startsWith('[REDACTED]') + && !raw.startsWith('[ENCRYPTED'); + return ` + ${escHtml(field)} + + ${escHtml(display)} + ${showRaw ? `
${escHtml(raw)}
` : ''} + + `; + }).join(''); + return `${rows}
`; +} + +function formatActivityChangeValue(value) { + if (value === null || value === undefined) return 'null'; + if (typeof value === 'boolean') return value ? 'true' : 'false'; + return String(value); +} + +function formatActivityTime(iso) { + if (!iso) return '—'; + try { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toISOString().replace('T', ' ').slice(0, 19); + } catch { + return iso; + } +} + +function escHtml(s) { + const d = document.createElement('div'); + d.textContent = s ?? ''; + return d.innerHTML; +} + function filenameFromContentDisposition(res, fallback) { const disp = res.headers.get('Content-Disposition'); if (!disp) return fallback; @@ -210,7 +407,10 @@ function filenameFromContentDisposition(res, fallback) { async function downloadExportZip(url, defaultFilename) { const token = localStorage.getItem('qdb_token'); const res = await fetch(url, { - headers: { Authorization: `Bearer ${token}` }, + headers: { + Authorization: `Bearer ${token}`, + 'X-QDB-Client': 'web', + }, }); if (!res.ok) { const err = await res.json().catch(() => ({ error: 'Download failed' }));