Files
tinker_tickets/api/audit_log.php
T
jaredandClaude Opus 4.8 4164f85051
Security / PHP Security (semgrep) (push) Successful in 1m14s
Lint / Deploy (push) Successful in 3s
Lint / PHP (phpcs PSR-12) (push) Successful in 19s
Lint / JS (eslint) (push) Successful in 8s
Lint / PHP requirements (version + extensions) (push) Successful in 24s
Lint / Notify on failure (push) Has been skipped
Fix bugs found in second multi-agent review
XSS / security:
- markdown.js: sanitize footnote labels to a safe slug before using them in
  id/href attributes. Labels are captured before the HTML-escape pass, so a
  label like x"><img onerror=...> broke out → stored XSS (the earlier quote-
  escape fix didn't cover this path). Verified neutralized.
- RateLimitMiddleware: only trust X-Forwarded-For / X-Real-IP when REMOTE_ADDR
  is a configured trusted proxy, and use the rightmost (proxy-appended) entry.
  Previously any client could rotate XFF to escape the per-IP rate limit.
- .env.example: document TRUSTED_PROXIES so fresh deploys aren't fail-open on
  the Authelia forward-auth spoofing protection.

Correctness:
- notifications.php: my previous assigned-to LIKE fix anchored only on '}', so
  BULK assignments (logged {"assigned_to":N,"bulk_operation_id":..}) produced
  no "assigned to you" notification — now matches both '}' and ',' delimiters.
- notifications.php: implement the documented @mention notifications (query
  action_type='mention' rows for the current user); they were never delivered.
- NotificationHelper::notifyWatchers: guard unchecked prepare() so a missing
  ticket_watchers table can't fatal the request after its DB write committed.
- AuditLogModel::getTicketTimeline: JSON_UNQUOTE the extracted ticket_id so
  comment events actually match (string vs JSON-number comparison never did).
- AuditLogModel/audit_log.php: CSV export no longer silently truncates to the
  1000-row UI cap; uses a dedicated higher export limit.
- DashboardView: quick-preview drawer read .ticket-link from the title cell
  (which has none), so the title was always blank — use the cell text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:00:36 -04:00

139 lines
4.4 KiB
PHP

<?php
/**
* Audit Log API Endpoint
* Handles fetching filtered audit logs and CSV export
* Admin-only access
*/
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check admin status - audit log viewing is admin-only
if (!$isAdmin) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Admin access required']);
exit;
}
$auditLogModel = new AuditLogModel($conn);
// GET - Fetch filtered audit logs or export to CSV
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
// Check for CSV export request
if (isset($_GET['export']) && $_GET['export'] === 'csv') {
// Build filters
$filters = [];
if (isset($_GET['action_type'])) {
$filters['action_type'] = $_GET['action_type'];
}
if (isset($_GET['entity_type'])) {
$filters['entity_type'] = $_GET['entity_type'];
}
if (isset($_GET['user_id'])) {
$filters['user_id'] = $_GET['user_id'];
}
if (isset($_GET['entity_id'])) {
$filters['entity_id'] = $_GET['entity_id'];
}
if (isset($_GET['date_from'])) {
$filters['date_from'] = $_GET['date_from'];
}
if (isset($_GET['date_to'])) {
$filters['date_to'] = $_GET['date_to'];
}
if (isset($_GET['ip_address'])) {
$filters['ip_address'] = $_GET['ip_address'];
}
// Get all matching logs for export. The forExport flag raises the cap
// (model clamps to its export limit) so the CSV isn't silently truncated
// to the 1000-row UI page limit.
$result = $auditLogModel->getFilteredLogs($filters, PHP_INT_MAX, 0, true);
$logs = $result['logs'];
// Set CSV headers
header('Content-Type: text/csv');
header('Content-Disposition: attachment; filename="audit_log_' . date('Y-m-d_His') . '.csv"');
// Output CSV
$output = fopen('php://output', 'w');
// Write CSV header
fputcsv($output, ['Log ID', 'Timestamp', 'User', 'Action', 'Entity Type', 'Entity ID', 'IP Address', 'Details']);
// Write data rows
foreach ($logs as $log) {
$details = '';
if (is_array($log['details'])) {
$details = json_encode($log['details']);
}
fputcsv($output, [
$log['log_id'],
$log['created_at'],
$log['display_name'] ?? $log['username'] ?? 'N/A',
$log['action_type'],
$log['entity_type'],
$log['entity_id'] ?? 'N/A',
$log['ip_address'] ?? 'N/A',
$details
]);
}
fclose($output);
exit;
}
// Normal JSON response for filtered logs
try {
// Get pagination parameters
$page = max(1, (int)($_GET['page'] ?? 1));
$limit = min(500, max(1, (int)($_GET['limit'] ?? 50)));
$offset = ($page - 1) * $limit;
// Build filters
$filters = [];
if (isset($_GET['action_type'])) {
$filters['action_type'] = $_GET['action_type'];
}
if (isset($_GET['entity_type'])) {
$filters['entity_type'] = $_GET['entity_type'];
}
if (isset($_GET['user_id'])) {
$filters['user_id'] = $_GET['user_id'];
}
if (isset($_GET['entity_id'])) {
$filters['entity_id'] = $_GET['entity_id'];
}
if (isset($_GET['date_from'])) {
$filters['date_from'] = $_GET['date_from'];
}
if (isset($_GET['date_to'])) {
$filters['date_to'] = $_GET['date_to'];
}
if (isset($_GET['ip_address'])) {
$filters['ip_address'] = $_GET['ip_address'];
}
// Get filtered logs
$result = $auditLogModel->getFilteredLogs($filters, $limit, $offset);
echo json_encode([
'success' => true,
'logs' => $result['logs'],
'total' => $result['total'],
'pages' => $result['pages'],
'current_page' => $page
]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to fetch audit logs']);
}
exit;
}
// Method not allowed
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);