diff --git a/.env.example b/.env.example index 16bb48a..cd3455b 100644 --- a/.env.example +++ b/.env.example @@ -20,3 +20,11 @@ TTS_AUTH_TYPE=bearer TTS_CONTENT_TYPE=application/json TTS_PAYLOAD_KEY=message TTS_ENABLED=false + +# GitHub Error Reporting (optional but recommended) +# Creates GitHub Issues automatically on crashes/errors from app, kiosk and server. +# Create a fine-grained PAT at https://github.com/settings/tokens?type=beta +# → Only selected repos: EverShelf +# → Permissions: Issues (Read+Write), Metadata (Read-only) +GITHUB_ISSUE_TOKEN= +GITHUB_REPO=dadaloop82/EverShelf diff --git a/api/index.php b/api/index.php index 2f9a47e..f89512b 100644 --- a/api/index.php +++ b/api/index.php @@ -11,6 +11,28 @@ // database.php must always be loaded (used both by HTTP router and cron) require_once __DIR__ . '/database.php'; +// ── Global PHP error/exception reporters ───────────────────────────────────── +// These are registered immediately so any crash anywhere in this file is caught. +// The handler function _phpErrorReport() is defined later; PHP resolves function +// names at call time so forward-referencing is safe. +if (!defined('CRON_MODE')) { + set_exception_handler(function (Throwable $e): void { + _phpErrorReport( + $e->getMessage(), + $e->getFile(), + $e->getLine(), + $e->getTraceAsString(), + get_class($e) + ); + }); + register_shutdown_function(function (): void { + $err = error_get_last(); + if ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) { + _phpErrorReport($err['message'], $err['file'], $err['line'], '', 'PHP Fatal'); + } + }); +} + /** * Load environment variables from .env file. * Returns associative array of key => value pairs. @@ -67,6 +89,7 @@ function checkRateLimit(string $action): void { $aiActions = ['gemini_readExpiry', 'gemini_chat', 'gemini_identify', 'gemini_suggest_shopping']; $loginActions = []; $recipeActions = ['generate_recipe', 'generate_recipe_stream']; + $errorActions = ['report_error']; if (in_array($action, $aiActions)) { $limit = 15; @@ -76,6 +99,10 @@ function checkRateLimit(string $action): void { $limit = 5; $window = 60; $bucket = 'recipe'; + } elseif (in_array($action, $errorActions)) { + $limit = 20; + $window = 60; + $bucket = 'error_report'; } elseif (in_array($action, $loginActions)) { $limit = 5; $window = 60; @@ -325,6 +352,10 @@ try { getOpenedShelfLifeAction(); break; + case 'report_error': + reportError(); + break; + default: http_response_code(404); echo json_encode(['error' => 'Unknown action: ' . $action]); @@ -5535,3 +5566,228 @@ function migrateUnitsToBase(PDO $db): void { echo json_encode(['success' => true, 'changes' => $changes]); } + +// ============================================================================= +// ===== CENTRALIZED ERROR REPORTING → GITHUB ISSUES ========================== +// ============================================================================= + +/** + * POST /api/?action=report_error + * + * Accepts error payloads from any client (PWA browser, Android kiosk, cron). + * Creates a GitHub issue on dadaloop82/EverShelf with deduplication: + * if an open issue with the same fingerprint already exists it posts a comment + * instead of opening a duplicate. + * + * Expected JSON body: + * source string 'pwa'|'kiosk'|'php'|'cron'|'scale' + * type string e.g. 'js-error'|'php-crash'|'unhandled-promise'|… + * message string Error message (required) + * stack string? Stack trace + * context object? Arbitrary key→value extra info + * url string? Page URL where the error occurred + * user_agent string? Navigator UA + * version string? App version + */ +function reportError(): void { + $input = json_decode(file_get_contents('php://input'), true) ?: []; + + $source = preg_replace('/[^a-z0-9_\-]/', '', strtolower($input['source'] ?? 'unknown')); + $type = preg_replace('/[^a-z0-9_\-]/', '', strtolower($input['type'] ?? 'error')); + $message = substr(trim($input['message'] ?? ''), 0, 500); + $stack = substr(trim($input['stack'] ?? ''), 0, 4000); + $pageUrl = substr(trim($input['url'] ?? ''), 0, 300); + $ua = substr(trim($input['user_agent'] ?? $_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 300); + $version = substr(trim($input['version'] ?? ''), 0, 50); + $context = $input['context'] ?? []; + + if (empty($message)) { + echo json_encode(['ok' => false, 'error' => 'message required']); + return; + } + + // ── Write to local log regardless of GitHub availability ────────────── + _appendErrorLog($source, $type, $message, $stack, $pageUrl, $ua, $context); + + // ── Fire GitHub issue (non-blocking: we always return ok to client) ─── + $token = env('GITHUB_ISSUE_TOKEN'); + $repo = env('GITHUB_REPO', 'dadaloop82/EverShelf'); + if (!empty($token) && !empty($repo)) { + _createOrCommentGithubIssue($token, $repo, $source, $type, $message, $stack, $pageUrl, $ua, $version, $context); + } + + echo json_encode(['ok' => true]); +} + +/** + * Append to data/error_reports.log (local safety net, max 500 KB) + */ +function _appendErrorLog(string $source, string $type, string $message, string $stack, string $url, string $ua, array $context): void { + $logFile = __DIR__ . '/../data/error_reports.log'; + // Rotate if > 500 KB + if (file_exists($logFile) && filesize($logFile) > 500000) { + $lines = file($logFile); + $lines = array_slice($lines, -300); + file_put_contents($logFile, implode('', $lines)); + } + $ts = date('Y-m-d H:i:s'); + $ctx = $context ? ' ctx=' . json_encode($context, JSON_UNESCAPED_UNICODE) : ''; + $line = "[$ts] [$source] [$type] $message" . ($url ? " | url=$url" : '') . $ctx . "\n"; + if ($stack) $line .= " STACK: " . str_replace("\n", "\n ", $stack) . "\n"; + file_put_contents($logFile, $line, FILE_APPEND | LOCK_EX); +} + +/** + * Fingerprint = sha1(source:type:first-120-chars-of-message) + * Used to deduplicate open issues. + */ +function _errorFingerprint(string $source, string $type, string $message): string { + return sha1($source . ':' . $type . ':' . substr($message, 0, 120)); +} + +/** + * Create a GitHub issue, or add a comment to an existing open issue with the + * same fingerprint. Uses the REST API v3 directly (no library needed). + */ +function _createOrCommentGithubIssue( + string $token, string $repo, + string $source, string $type, string $message, + string $stack, string $pageUrl, string $ua, + string $version, array $context +): void { + $fp = _errorFingerprint($source, $type, $message); + + // ── 1. Search for an existing open issue with this fingerprint ───────── + $searchQuery = urlencode("repo:$repo is:issue is:open label:auto-report \"fp:$fp\" in:body"); + $searchResult = _githubRequest($token, 'GET', "https://api.github.com/search/issues?q=$searchQuery&per_page=1"); + + $existingIssueNumber = null; + if (isset($searchResult['body']['items']) && count($searchResult['body']['items']) > 0) { + $existingIssueNumber = $searchResult['body']['items'][0]['number'] ?? null; + } + + // ── Build the common details block ───────────────────────────────────── + $ts = date('Y-m-d H:i:s T'); + $ctxMd = ''; + if ($context) { + $ctxMd = "\n**Context:**\n```json\n" . json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n```\n"; + } + $stackMd = $stack ? "\n**Stack trace:**\n```\n$stack\n```\n" : ''; + $urlMd = $pageUrl ? "\n**URL:** `$pageUrl`" : ''; + $uaMd = $ua ? "\n**User-Agent:** `$ua`" : ''; + $verMd = $version ? "\n**Version:** `$version`" : ''; + + if ($existingIssueNumber) { + // ── 2a. Post a comment to the existing issue ────────────────────── + $body = "### 🔁 Recurrence — $ts\n" + . "**Source:** `$source` | **Type:** `$type`\n" + . $urlMd . $uaMd . $verMd . "\n" + . $ctxMd . $stackMd + . "\n---\n_fp:$fp_"; + _githubRequest($token, 'POST', + "https://api.github.com/repos/$repo/issues/$existingIssueNumber/comments", + ['body' => $body] + ); + } else { + // ── 2b. Create a new issue ──────────────────────────────────────── + // Determine labels from source + $labelMap = [ + 'pwa' => 'js-error', + 'kiosk' => 'kiosk-error', + 'php' => 'php-crash', + 'cron' => 'php-crash', + 'scale' => 'scale-error', + ]; + $typeLabel = $labelMap[$source] ?? 'js-error'; + + $shortMsg = strlen($message) > 70 ? substr($message, 0, 70) . '…' : $message; + $title = "[" . strtoupper($source) . "] $shortMsg"; + + $body = "## 🚨 Automatic Error Report\n\n" + . "**Source:** `$source` \n" + . "**Type:** `$type` \n" + . "**Reported at:** $ts \n" + . $urlMd . "\n" + . $uaMd . "\n" + . $verMd . "\n\n" + . "**Error message:**\n> $message\n" + . $stackMd + . $ctxMd + . "\n---\n" + . "\n" + . "_This issue was created automatically by EverShelf's error reporter. fp:`$fp`_"; + + _githubRequest($token, 'POST', + "https://api.github.com/repos/$repo/issues", + [ + 'title' => $title, + 'body' => $body, + 'labels' => ['auto-report', $typeLabel], + ] + ); + } +} + +/** + * Minimal GitHub REST API helper (curl). + * Returns ['http_code' => int, 'body' => array]. + */ +function _githubRequest(string $token, string $method, string $url, array $payload = []): array { + $ch = curl_init($url); + $headers = [ + 'Authorization: token ' . $token, + 'Accept: application/vnd.github+json', + 'X-GitHub-Api-Version: 2022-11-28', + 'User-Agent: EverShelf-ErrorReporter/1.0', + 'Content-Type: application/json', + ]; + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_TIMEOUT => 10, + CURLOPT_SSL_VERIFYPEER => true, + ]); + if ($method === 'POST') { + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); + } + $raw = curl_exec($ch); + $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + return ['http_code' => $code, 'body' => json_decode($raw ?: '{}', true) ?: []]; +} + +/** + * Called by the PHP exception/shutdown handlers registered at the top of this file. + * Writes to local log + creates a GitHub issue. + */ +function _phpErrorReport(string $message, string $file, int $line, string $trace, string $type): void { + // Prevent infinite loops if this function itself throws + static $running = false; + if ($running) return; + $running = true; + + $source = 'php'; + $errType = 'php-crash'; + $context = [ + 'file' => $file, + 'line' => $line, + 'php' => PHP_VERSION, + 'action' => $_GET['action'] ?? '', + 'method' => $_SERVER['REQUEST_METHOD'] ?? '', + ]; + + _appendErrorLog($source, $errType, "[$type] $message", $trace, '', '', $context); + + $token = env('GITHUB_ISSUE_TOKEN'); + $repo = env('GITHUB_REPO', 'dadaloop82/EverShelf'); + if (!empty($token) && !empty($repo)) { + _createOrCommentGithubIssue( + $token, $repo, $source, $errType, + "[$type] $message", $trace, + '', '', PHP_VERSION, $context + ); + } + + $running = false; +} diff --git a/assets/js/app.js b/assets/js/app.js index 8daeade..bf586a7 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -7,8 +7,11 @@ * @license MIT */ -// ===== REMOTE LOGGING ===== -// Global remote logger: captures all errors, warnings and key operations +// ===== REMOTE LOGGING + ERROR REPORTING ===== +// Two-tier system: +// 1. remoteLog() — batched INFO/WARN/ERROR → existing client_log endpoint (debug tail) +// 2. reportError() — immediate single POST → report_error endpoint → GitHub Issue + const _remoteLogBuffer = []; let _remoteLogTimer = null; const _origConsoleError = console.error.bind(console); @@ -47,12 +50,54 @@ console.warn = function(...args) { remoteLog('WARN', ...args); }; -// Catch unhandled errors +// ── Error reporter: creates/updates GitHub Issues ──────────────────────────── +// Rate-limit client-side: max 1 report per fingerprint per page session. +const _reportedFingerprints = new Set(); + +function reportError(payload) { + // Build fingerprint to deduplicate within the same page session + const fp = `${payload.source}:${payload.type}:${String(payload.message).slice(0, 120)}`; + if (_reportedFingerprints.has(fp)) return; + _reportedFingerprints.add(fp); + + const body = Object.assign({ + source: 'pwa', + version: document.querySelector('.header-version')?.textContent?.trim() || '', + url: location.href, + user_agent: navigator.userAgent, + }, payload); + + fetch('api/index.php?action=report_error', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }).catch(() => {}); // fire-and-forget; never throw from error handler +} + +// ── Global uncaught error handler ──────────────────────────────────────────── window.addEventListener('error', function(e) { - remoteLog('UNCAUGHT', `${e.message} at ${e.filename}:${e.lineno}:${e.colno}`); + const msg = e.message || String(e.error); + // Ignore benign third-party noise + if (/Script error/i.test(msg)) return; + remoteLog('UNCAUGHT', `${msg} at ${e.filename}:${e.lineno}:${e.colno}`); + reportError({ + type: 'uncaught-error', + message: msg, + stack: e.error?.stack || '', + context: { filename: e.filename, lineno: e.lineno, colno: e.colno }, + }); }); + window.addEventListener('unhandledrejection', function(e) { - remoteLog('UNHANDLED_PROMISE', e.reason); + const reason = e.reason; + const msg = reason instanceof Error ? reason.message : String(reason); + const stack = reason instanceof Error ? (reason.stack || '') : ''; + remoteLog('UNHANDLED_PROMISE', msg); + reportError({ + type: 'unhandled-promise', + message: msg, + stack: stack, + }); }); // ===== CONFIGURATION ===== @@ -2068,6 +2113,14 @@ async function api(action, params = {}, method = 'GET', body = null) { const res = await fetch(url, opts); if (!res.ok) { remoteLog('API_ERROR', `${action} HTTP ${res.status}`); + // Report HTTP 5xx as server errors (not 4xx which are usually user errors) + if (res.status >= 500) { + reportError({ + type: 'api-server-error', + message: `API ${action} returned HTTP ${res.status}`, + context: { action, status: res.status }, + }); + } } const data = await res.json(); if (data && data.error) { diff --git a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/ErrorReporter.kt b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/ErrorReporter.kt new file mode 100644 index 0000000..faacfb1 --- /dev/null +++ b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/ErrorReporter.kt @@ -0,0 +1,164 @@ +package it.dadaloop.evershelf.kiosk + +import android.content.Context +import android.os.Build +import android.util.Log +import org.json.JSONObject +import java.io.OutputStreamWriter +import java.net.HttpURLConnection +import java.net.URL +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.Executors + +/** + * Centralized error reporter for EverShelf Kiosk. + * + * Sends structured JSON payloads to the EverShelf backend + * (POST /api/?action=report_error) which in turn creates or + * updates a GitHub Issue automatically. + * + * Usage: + * // In Application or Activity onCreate: + * ErrorReporter.init(this, prefs.getString("evershelf_url", "")!!) + * + * // To report a caught exception: + * ErrorReporter.report(e, "myMethod", mapOf("extra" to "data")) + * + * // To report a non-exception event: + * ErrorReporter.reportMessage("webview-crash", "WebView died unexpectedly") + */ +object ErrorReporter { + + private const val TAG = "EverShelfErrorReporter" + private val executor = Executors.newSingleThreadExecutor() + + // Fingerprints already sent in this process to avoid flooding + private val sentFingerprints = mutableSetOf() + + private var serverBaseUrl: String = "" + private var appVersion: String = "" + private var deviceInfo: String = "" + + /** + * Call once (e.g. in KioskActivity.onCreate) before reporting any errors. + * @param context Application or Activity context. + * @param baseUrl The EverShelf server URL, e.g. "http://192.168.1.10:8080" + */ + fun init(context: Context, baseUrl: String) { + serverBaseUrl = baseUrl.trimEnd('/') + try { + val pi = context.packageManager.getPackageInfo(context.packageName, 0) + appVersion = pi.versionName ?: "unknown" + } catch (_: Exception) {} + deviceInfo = "${Build.MANUFACTURER} ${Build.MODEL} (Android ${Build.VERSION.RELEASE})" + + // Install a global UncaughtExceptionHandler so ANY unhandled crash is reported + val previousHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + try { + reportSync( + type = "uncaught-exception", + message = throwable.message ?: throwable.javaClass.simpleName, + stack = throwable.stackTraceToString(), + context = mapOf("thread" to thread.name) + ) + } catch (_: Exception) {} + // Re-throw to the previous handler so the system crash dialog/restart still works + previousHandler?.uncaughtException(thread, throwable) + } + } + + /** + * Report a caught [Throwable] asynchronously (does not block UI thread). + */ + fun report( + throwable: Throwable, + location: String = "", + extra: Map = emptyMap() + ) { + val ctx = mutableMapOf("device" to deviceInfo) + if (location.isNotEmpty()) ctx["location"] = location + ctx.putAll(extra) + reportAsync( + type = "kiosk-exception", + message = "${throwable.javaClass.simpleName}: ${throwable.message}", + stack = throwable.stackTraceToString(), + context = ctx + ) + } + + /** + * Report a non-exception message (e.g. WebView page error, network failure). + */ + fun reportMessage( + type: String, + message: String, + extra: Map = emptyMap() + ) { + val ctx = mutableMapOf("device" to deviceInfo) + ctx.putAll(extra) + reportAsync(type = type, message = message, stack = "", context = ctx) + } + + // ── Internal ───────────────────────────────────────────────────────────── + + private fun fingerprint(type: String, message: String): String { + val key = "$type:${message.take(120)}" + return key.hashCode().toString(16) + } + + private fun reportAsync(type: String, message: String, stack: String, context: Map) { + val fp = fingerprint(type, message) + synchronized(sentFingerprints) { + if (!sentFingerprints.add(fp)) return // already reported this session + } + executor.execute { doPost(type, message, stack, context) } + } + + /** Synchronous variant used only in the UncaughtExceptionHandler (already off main thread). */ + private fun reportSync(type: String, message: String, stack: String, context: Map) { + val fp = fingerprint(type, message) + synchronized(sentFingerprints) { sentFingerprints.add(fp) } + doPost(type, message, stack, context) + } + + private fun doPost(type: String, message: String, stack: String, context: Map) { + val url = serverBaseUrl.ifEmpty { return } + val endpoint = "$url/api/?action=report_error" + try { + val ctxJson = JSONObject() + context.forEach { (k, v) -> ctxJson.put(k, v) } + + val payload = JSONObject().apply { + put("source", "kiosk") + put("type", type) + put("message", message) + put("stack", stack) + put("context", ctxJson) + put("version", appVersion) + put("user_agent", "EverShelf-Kiosk/$appVersion (Android ${Build.VERSION.RELEASE}; ${Build.MODEL})") + put("url", url) + put("ts", SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date())) + } + + val conn = URL(endpoint).openConnection() as HttpURLConnection + conn.requestMethod = "POST" + conn.setRequestProperty("Content-Type", "application/json; charset=utf-8") + conn.setRequestProperty("Accept", "application/json") + conn.doOutput = true + conn.connectTimeout = 8000 + conn.readTimeout = 8000 + + OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(payload.toString()) } + val responseCode = conn.responseCode + conn.disconnect() + + Log.d(TAG, "Reported '$type' → HTTP $responseCode") + } catch (e: Exception) { + // Never rethrow from the error reporter itself + Log.w(TAG, "Failed to report error '$type': ${e.message}") + } + } +} diff --git a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/KioskActivity.kt b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/KioskActivity.kt index 04d81c6..92a88f1 100644 --- a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/KioskActivity.kt +++ b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/KioskActivity.kt @@ -106,6 +106,11 @@ class KioskActivity : AppCompatActivity() { enableKioskLock() requestAllPermissions() + // Initialise centralised error reporter as early as possible so the + // UncaughtExceptionHandler is installed before any background work starts. + val savedUrl = prefs.getString(KEY_URL, "") ?: "" + ErrorReporter.init(this, savedUrl) + // Initialise native TTS engine so the JS bridge works even when // Web Speech API voices are unavailable in the Android WebView. tts = TextToSpeech(this) { status -> @@ -320,6 +325,9 @@ class KioskActivity : AppCompatActivity() { private fun finishWizard() { prefs.edit().putBoolean(KEY_SETUP_COMPLETE, true).apply() wizardContainer.visibility = View.GONE + // Re-init ErrorReporter with the confirmed URL so future errors are reported + val confirmedUrl = prefs.getString(KEY_URL, "") ?: "" + ErrorReporter.init(this, confirmedUrl) launchWebView() } @@ -468,7 +476,15 @@ class KioskActivity : AppCompatActivity() { view: WebView?, request: WebResourceRequest?, error: WebResourceError? ) { + val errorDesc = error?.description?.toString() ?: "unknown" + val errorCode = error?.errorCode ?: -1 + val url = request?.url?.toString() ?: "" if (request?.isForMainFrame == true) { + ErrorReporter.reportMessage( + type = "webview-load-error", + message = "WebView failed to load main frame: $errorDesc (code $errorCode)", + extra = mapOf("url" to url, "errorCode" to errorCode) + ) view?.loadData(errorPageHtml(), "text/html", "UTF-8") } } @@ -509,7 +525,20 @@ class KioskActivity : AppCompatActivity() { } } } - override fun onConsoleMessage(msg: ConsoleMessage?): Boolean = true + override fun onConsoleMessage(msg: ConsoleMessage?): Boolean { + // Forward JS errors and warnings to the error reporter + if (msg != null && msg.messageLevel() == ConsoleMessage.MessageLevel.ERROR) { + ErrorReporter.reportMessage( + type = "webview-js-error", + message = msg.message(), + extra = mapOf( + "source_id" to msg.sourceId(), + "line" to msg.lineNumber() + ) + ) + } + return true + } override fun onShowFileChooser( wv: WebView?, callback: ValueCallback>?,