diff --git a/CHANGELOG.md b/CHANGELOG.md index 34bb534..9ab7d83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to EverShelf will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.7.6] - 2026-05-10 + +### Fixed +- **`shopping_name` troncato (Piadina)** — Il prodotto "Piadine medie" aveva `shopping_name='Pi'` (troncato), non veniva aggruppato correttamente nella famiglia. Corretto in `Piadina`. +- **Family merges DB** — Grana Padano ora sotto `Formaggio` (era `Grana` singleton), Prosciutto cotto ora sotto `Affettato`, Panna acida ora sotto `Panna`. +- **`daily_rate` su periodo effettivo** — Il tasso di consumo giornaliero usava `first_in → now` come finestra, diluendo il rate con periodi in cui il prodotto era già esaurito (es. aglio esaurito a 34gg veniva calcolato su 60+). Ora usa `first_in → last_activity` (ultimo acquisto o ultimo uso), più preciso per le previsioni di riordino. +- **Anomaly dismiss key stabile** — La chiave di dismiss usava `product_id + round(expected)` che cambiava ad ogni nuova transazione, causando la ricomparsa delle anomalie già chiuse. Ora usa `product_id + direction` (phantom/missing/untracked) — stabile finché la direzione non cambia. +- **Smart shopping: prodotti esauriti < 14 giorni** — Prodotti terminati negli ultimi 14 giorni non vengono più soppressi dal check token-coverage o shopping_name-family: se li hai appena finiti, è probabile tu voglia ricomprarli indipendentemente dalla presenza di equivalenti in stock. +- **Chat pruning** — `chatSave()` ora esegue `DELETE` dei messaggi oltre i 200 più recenti dopo ogni salvataggio, evitando crescita illimitata della tabella `chat_messages`. +- **`getStats()` query consolidate** — Le 5 query separate (COUNT products, SUM inventory, COUNT locations, COUNT recent_in, COUNT recent_out) sono ora una sola query con subselect, riducendo i round-trip SQLite da 5 a 1. +- **Bring! cleanup rate-limiting** — Aggiunto `usleep(300ms)` tra le rimozioni multiple per evitare di sovraccaricare l'API Bring! in burst. +- **Indici compositi su `transactions`** — Aggiunti `idx_transactions_type_date(type, created_at)` (per `getStats`) e `idx_transactions_pid_type_undone(product_id, type, undone)` (per `smartShopping`), con migration automatica per DB esistenti. + +### Security +- **CSRF protection** — Le action di scrittura (inventory_add, bring_add, product_save, ecc.) richiedono ora `X-EverShelf-Request: 1` oppure `Content-Type: application/json`. Il frontend `api()` invia sempre il header su POST. Questo previene attacchi CSRF cross-site tramite form HTML. + ## [1.7.5] - 2026-05-10 ### Added diff --git a/api/database.php b/api/database.php index 1941873..6f9eb24 100644 --- a/api/database.php +++ b/api/database.php @@ -74,6 +74,11 @@ function initializeDB(PDO $db): void { CREATE INDEX IF NOT EXISTS idx_inventory_location ON inventory(location); CREATE INDEX IF NOT EXISTS idx_transactions_product ON transactions(product_id); CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(created_at); + -- Composite indexes for hot queries + -- getStats(): WHERE type IN (...) AND created_at >= ... + CREATE INDEX IF NOT EXISTS idx_transactions_type_date ON transactions(type, created_at); + -- smartShopping(): GROUP BY product_id filtering on type+undone + CREATE INDEX IF NOT EXISTS idx_transactions_pid_type_undone ON transactions(product_id, type, undone); "); } @@ -108,6 +113,8 @@ function migrateDB(PDO $db): void { $db->exec("DROP TABLE transactions_old"); $db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_product ON transactions(product_id)"); $db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(created_at)"); + $db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_type_date ON transactions(type, created_at)"); + $db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_pid_type_undone ON transactions(product_id, type, undone)"); } // --- New shared tables --- @@ -192,6 +199,10 @@ function migrateDB(PDO $db): void { if (!in_array('undone', $txColNames)) { $db->exec("ALTER TABLE transactions ADD COLUMN undone INTEGER DEFAULT 0"); } + + // Ensure composite indexes exist (added in v1.7.5 for performance) + $db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_type_date ON transactions(type, created_at)"); + $db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_pid_type_undone ON transactions(product_id, type, undone)"); } /** diff --git a/api/index.php b/api/index.php index 949bd57..1790aa8 100644 --- a/api/index.php +++ b/api/index.php @@ -183,6 +183,27 @@ if ($rateLimitAction) { checkRateLimit($rateLimitAction); } +// CSRF guard for write actions: POST requests that modify data must include +// either X-EverShelf-Request: 1 (webapp) or Content-Type: application/json. +// This prevents cross-site HTML form submissions from triggering mutations. +// JSON Content-Type already requires a CORS preflight which provides a baseline; +// the explicit header is an additional defence-in-depth check for POST writes. +$_writeActions = [ + 'inventory_add','inventory_use','inventory_update','inventory_remove', + 'product_save','product_delete','product_merge', + 'bring_add','bring_remove','bring_sync','bring_set_spec','bring_migrate_names', + 'dismiss_anomaly','save_settings', +]; +if ($_SERVER['REQUEST_METHOD'] === 'POST' && in_array($rateLimitAction, $_writeActions, true)) { + $csrfHeader = $_SERVER['HTTP_X_EVERSHELF_REQUEST'] ?? ''; + $contentType = $_SERVER['CONTENT_TYPE'] ?? ''; + if ($csrfHeader !== '1' && stripos($contentType, 'application/json') === false) { + http_response_code(403); + echo json_encode(['success' => false, 'error' => 'csrf_rejected']); + exit; + } +} + try { $db = getDB(); } catch (Exception $e) { @@ -1715,8 +1736,13 @@ function getInventoryAnomalies(PDO $db): void { $threshold = max(1.0, $invQty * 0.20); if (abs($diff) <= $threshold || abs($diff) <= 50) continue; - // Dismiss key: product_id + rounded expected (so re-adding stock resets the alert) - $key = 'a_' . $r['product_id'] . '_' . round($expected); + // Dismiss key: stable identifier based on product_id + direction. + // Previously used round($expected) which changed whenever transactions were added, + // causing dismissed anomalies to reappear. Now anchored to direction only, + // so it stays dismissed until the user explicitly resets or the direction changes. + // An inventory correction (bringing qty closer to expected) will flip the direction + // or drop below threshold — naturally clearing the dismissed state. + $key = 'a_' . $r['product_id'] . '_' . $direction; if (!empty($dismissed[$key])) continue; $direction = $diff > 0 ? 'phantom' : 'missing'; @@ -1769,11 +1795,22 @@ function dismissInventoryAnomaly(): void { } function getStats(PDO $db): void { - $totalProducts = $db->query("SELECT COUNT(*) FROM products")->fetchColumn(); - $totalItems = $db->query("SELECT COALESCE(SUM(quantity), 0) FROM inventory")->fetchColumn(); - $locations = $db->query("SELECT COUNT(DISTINCT location) FROM inventory")->fetchColumn(); - $recentIn = $db->query("SELECT COUNT(*) FROM transactions WHERE type='in' AND created_at >= datetime('now', '-7 days')")->fetchColumn(); - $recentOut = $db->query("SELECT COUNT(*) FROM transactions WHERE type='out' AND created_at >= datetime('now', '-7 days')")->fetchColumn(); + // Consolidated summary query: totals + 7-day activity in a single round-trip + $summary = $db->query(" + SELECT + (SELECT COUNT(*) FROM products) AS total_products, + (SELECT COALESCE(SUM(quantity),0) FROM inventory) AS total_items, + (SELECT COUNT(DISTINCT location) FROM inventory) AS total_locations, + (SELECT COUNT(*) FROM transactions + WHERE type='in' AND created_at >= datetime('now','-7 days')) AS recent_in, + (SELECT COUNT(*) FROM transactions + WHERE type='out' AND created_at >= datetime('now','-7 days')) AS recent_out + ")->fetch(PDO::FETCH_ASSOC); + $totalProducts = (int)$summary['total_products']; + $totalItems = (float)$summary['total_items']; + $locations = (int)$summary['total_locations']; + $recentIn = (int)$summary['recent_in']; + $recentOut = (int)$summary['recent_out']; // Expiring soonest (next 4 items to expire) $expiring = $db->query(" @@ -4909,7 +4946,10 @@ function bringCleanupObsolete(PDO $db): array { } if ($result !== null) $removed++; - else $errors++; + else { $errors++; } + + // Small delay between removals to avoid hammering the Bring! API + if (count($toRemove) > 3) usleep(300_000); // 300ms } return ['candidates' => count($toRemove), 'removed' => $removed, 'errors' => $errors]; @@ -5530,8 +5570,15 @@ function smartShopping(PDO $db): void { $lastOut = $tx && $tx['last_out'] ? strtotime($tx['last_out']) : null; $daysSinceFirst = $firstIn ? max(1, ($now - $firstIn) / 86400) : 999; - // Average daily consumption rate - $dailyRate = $daysSinceFirst < 999 && $totalUsed > 0 ? $totalUsed / $daysSinceFirst : 0; + // Average daily consumption rate. + // Use the "effective tracking period" (first purchase → last activity) rather than + // first purchase → now, so idle periods after last use don't deflate the rate. + // Example: Aglio bought 60 days ago but last used 34 days ago → use 34-day window. + $lastActivity = max($lastIn ?? 0, $lastOut ?? 0); + $effectiveDays = ($firstIn && $lastActivity > $firstIn) + ? max(1, ($lastActivity - $firstIn) / 86400) + : $daysSinceFirst; + $dailyRate = $effectiveDays < 999 && $totalUsed > 0 ? $totalUsed / $effectiveDays : 0; // Days of stock remaining $daysLeft = ($dailyRate > 0 && $qty > 0) ? $qty / $dailyRate : ($qty > 0 ? 999 : 0); @@ -5595,14 +5642,21 @@ function smartShopping(PDO $db): void { 'bianco','rosso','nero','giallo','verde','misto','dolce','light']; $pToks = array_diff($nameTokens($p['name']), $coverageGeneric); $coveredByEquivalent = false; - foreach ($pToks as $tok) { - if (($stockByAnyToken[$tok] ?? 0) > 0) { $coveredByEquivalent = true; break; } + // Products exhausted within the last 14 days bypass token-based suppression: + // if the user just finished a specific product, they likely need to restock it + // regardless of whether a vague equivalent token exists in another product. + $recentlyExhausted = $lastOut && ($now - $lastOut) / 86400 <= 14; + if (!$recentlyExhausted) { + foreach ($pToks as $tok) { + if (($stockByAnyToken[$tok] ?? 0) > 0) { $coveredByEquivalent = true; break; } + } } // Also check shopping_name coverage: if this depleted product has a generic name // (e.g. "Formaggio") and there's stock of ANY product with the same generic name, // the need is covered. This catches "Bel Paese" → covered by "Formaggio Gouda" in stock, // "Biscotti Pastefrolle" → covered by "Frollini..." (both shopping_name="Biscotti"), etc. - if (!$coveredByEquivalent) { + // Exception: recently exhausted products (< 14 days) skip this suppression too. + if (!$coveredByEquivalent && !$recentlyExhausted) { $sName = strtolower(trim($p['shopping_name'] ?? '')); if ($sName !== '' && ($stockByShoppingName[$sName] ?? 0) > 0) { $coveredByEquivalent = true; @@ -6213,6 +6267,8 @@ function chatSave(PDO $db): void { $stmt->execute([$msg['role'], $msg['text']]); } } + // Prune: keep only the last 200 messages (cap to avoid unbounded growth) + $db->exec("DELETE FROM chat_messages WHERE id NOT IN (SELECT id FROM chat_messages ORDER BY id DESC LIMIT 200)"); echo json_encode(['success' => true]); } diff --git a/assets/js/app.js b/assets/js/app.js index fac167d..f905b94 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -2502,7 +2502,7 @@ async function api(action, params = {}, method = 'GET', body = null, extraHeader } const opts = { method, cache: 'no-store' }; if (body) { - opts.headers = { 'Content-Type': 'application/json', ...extraHeaders }; + opts.headers = { 'Content-Type': 'application/json', 'X-EverShelf-Request': '1', ...extraHeaders }; opts.body = JSON.stringify(body); } else if (Object.keys(extraHeaders).length > 0) { opts.headers = { ...extraHeaders }; @@ -9264,10 +9264,13 @@ async function cleanupObsoleteBringItems() { function logOperation(action, details) { try { const log = JSON.parse(localStorage.getItem('_opLog') || '[]'); - log.push({ ts: new Date().toISOString(), action, details }); - // Keep last 200 entries - if (log.length > 200) log.splice(0, log.length - 200); - localStorage.setItem('_opLog', JSON.stringify(log)); + const now = Date.now(); + log.push({ ts: new Date(now).toISOString(), action, details }); + // Prune: keep only last 200 entries AND entries newer than 30 days + const cutoff = now - 30 * 24 * 60 * 60 * 1000; + const pruned = log.filter(e => new Date(e.ts).getTime() >= cutoff); + const final = pruned.length > 200 ? pruned.slice(pruned.length - 200) : pruned; + localStorage.setItem('_opLog', JSON.stringify(final)); } catch (e) { /* ignore */ } } diff --git a/index.html b/index.html index c23776d..4b9ed51 100644 --- a/index.html +++ b/index.html @@ -67,7 +67,7 @@