diff --git a/.gitignore b/.gitignore index 160ec4b..b4d7a5e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ valid_tokens.txt # Encrypted database, temp files, backups uploads/* !uploads/.gitkeep +!uploads/logs/ +!uploads/logs/** # Database lock files (just in case) uploads/.qdb_lock diff --git a/lib/api_log.php b/lib/api_log.php index 753e189..7b19ad5 100644 --- a/lib/api_log.php +++ b/lib/api_log.php @@ -10,7 +10,8 @@ * - web_change: POST/PUT/PATCH/DELETE from the management website (incl. translation labels) * - web_export: Website downloads (CSV/ZIP/JSON exports, translation bundle) * - * Set QDB_API_LOG=0 to disable. Logs live under logs/api/ (gitignored). + * Set QDB_API_LOG=0 to disable. Set QDB_API_LOG_DIR for a custom path. + * Default directory is uploads/logs/api (writable by PHP alongside the database). */ /** @var list */ @@ -28,11 +29,98 @@ function qdb_api_log_enabled(): bool function qdb_api_log_dir(): string { + static $resolved = null; + if ($resolved !== null) { + return $resolved; + } + $custom = qdb_env_get('QDB_API_LOG_DIR'); if ($custom !== null && $custom !== '') { - return rtrim($custom, '/\\'); + $resolved = rtrim($custom, '/\\'); + return $resolved; } - return dirname(__DIR__) . '/logs/api'; + + $root = dirname(__DIR__); + foreach ([$root . '/uploads/logs/api', $root . '/logs/api'] as $candidate) { + if (qdb_api_log_dir_is_usable($candidate)) { + $resolved = $candidate; + return $resolved; + } + } + + $resolved = $root . '/uploads/logs/api'; + return $resolved; +} + +function qdb_api_log_dir_is_usable(string $dir): bool +{ + if (is_dir($dir)) { + return is_writable($dir); + } + $parent = dirname($dir); + if (is_dir($parent) && is_writable($parent)) { + return true; + } + if (@mkdir($dir, 0775, true)) { + return is_writable($dir); + } + return is_dir($dir) && is_writable($dir); +} + +/** + * Diagnostics for admin UI (permissions, path, enabled flag). + * + * @return array + */ +function qdb_api_log_status(): array +{ + $dir = qdb_api_log_dir(); + $enabled = qdb_api_log_enabled(); + $exists = is_dir($dir); + $writable = false; + $probeError = null; + + if ($enabled) { + if (!$exists) { + if (!@mkdir($dir, 0775, true)) { + $probeError = 'Cannot create directory: ' . $dir; + } + $exists = is_dir($dir); + } + if ($exists && !is_writable($dir)) { + $probeError = 'Directory exists but is not writable by PHP: ' . $dir; + } elseif ($exists) { + $probe = $dir . '/.write_probe'; + if (@file_put_contents($probe, (string)time() . "\n", LOCK_EX) === false) { + $probeError = 'Cannot write files in: ' . $dir; + } else { + $writable = true; + @unlink($probe); + } + } + } + + $phpUser = null; + if (function_exists('posix_geteuid') && function_exists('posix_getpwuid')) { + $info = posix_getpwuid(posix_geteuid()); + if (is_array($info) && !empty($info['name'])) { + $phpUser = (string)$info['name']; + } + } + + return [ + 'enabled' => $enabled, + 'dir' => $dir, + 'exists' => $exists, + 'writable' => $writable, + 'error' => $probeError, + 'php_user' => $phpUser, + 'fix_hint' => $writable || !$enabled + ? null + : 'On the server run: mkdir -p ' . $dir . ' && chown -R www-data:www-data ' + . dirname($dir) . ' && chmod -R 775 ' . dirname($dir) + . ' (adjust www-data to your PHP user). Or set QDB_API_LOG_DIR in .env to a writable path.', + ]; } function qdb_api_log_max_bytes(): int @@ -421,13 +509,24 @@ function qdb_api_log_change_scalar(mixed $v): string|int|float|bool|null 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"); + if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) { + $err = error_get_last(); + throw new RuntimeException( + 'Cannot create log directory: ' . $dir + . ($err ? ' (' . ($err['message'] ?? '') . ')' : '') + ); + } + if (!is_writable($dir)) { + throw new RuntimeException('Log directory not writable: ' . $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"); + $err = error_get_last(); + throw new RuntimeException( + 'Cannot write API log: ' . $path + . ($err ? ' (' . ($err['message'] ?? '') . ')' : '') + ); } } @@ -507,6 +606,7 @@ function qdb_api_log_read_entries(string $date, string $activityFilter = '', int 'files' => array_map('basename', $files), 'truncated' => $truncated, 'kinds' => QDB_API_LOG_ACTIVITIES, + 'logStatus' => qdb_api_log_status(), ]; } diff --git a/uploads/logs/.gitkeep b/uploads/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/uploads/logs/api/.gitkeep b/uploads/logs/api/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/website/js/pages/dev.js b/website/js/pages/dev.js index 9791933..fa9c6c0 100644 --- a/website/js/pages/dev.js +++ b/website/js/pages/dev.js @@ -78,6 +78,7 @@ export function devPage() { +

@@ -250,6 +251,33 @@ export function devPage() { }); } +function renderActivityLogStatus(status) { + const el = document.getElementById('activityLogStatus'); + if (!el || !status) { + if (el) el.style.display = 'none'; + return; + } + if (!status.enabled) { + el.className = 'field-hint callout-warn'; + el.innerHTML = 'Activity logging is disabled (QDB_API_LOG=0 in .env).'; + el.style.display = ''; + return; + } + if (status.writable) { + el.className = 'field-hint'; + el.innerHTML = `Logging to ${escHtml(status.dir)}` + + (status.php_user ? ` (PHP user: ${escHtml(status.php_user)})` : ''); + el.style.display = ''; + return; + } + el.className = 'field-hint callout-danger'; + el.innerHTML = `Cannot write activity log. ${escHtml(status.error || 'Permission problem.')} +
Path: ${escHtml(status.dir)}` + + (status.php_user ? `
PHP runs as: ${escHtml(status.php_user)}` : '') + + (status.fix_hint ? `
${escHtml(status.fix_hint)}` : ''); + el.style.display = ''; +} + function wireActivityLog() { const dateEl = document.getElementById('activityLogDate'); const kindEl = document.getElementById('activityLogKind'); @@ -274,6 +302,8 @@ async function loadActivityLog() { const data = await apiGet(`activity-log?${q}`); if (!data.success) throw new Error(data.error || 'Failed to load activity log'); + renderActivityLogStatus(data.logStatus); + const entries = data.entries || []; const files = (data.files || []).join(', ') || 'none'; const trunc = data.truncated ? ' (newest 300 shown)' : '';