fix: codebase audit fixes — indexes, daily_rate, anomaly key, CSRF, chat pruning, shopping_name

## v1.7.6

- DB: fix shopping_name Pi→Piadina, Grana→Formaggio, Prosciutto cotto→Affettato, Panna acida→Panna
- DB: composite indexes idx_transactions_type_date + idx_transactions_pid_type_undone (+ migration)
- PHP: daily_rate uses first_in→last_activity window (not first_in→now)
- PHP: anomaly dismiss key uses product_id+direction (stable, not product_id+round(expected))
- PHP: smart shopping — products exhausted within 14 days bypass token/family suppression
- PHP: chat pruning — DELETE messages beyond 200 after each chatSave()
- PHP: getStats() — 5 queries → 1 consolidated query with subselects
- PHP: bringCleanupObsolete — 300ms delay between bulk removals
- PHP: CSRF guard — POST write actions require X-EverShelf-Request:1 or Content-Type:application/json
- JS: api() — sends X-EverShelf-Request:1 on all POST requests
- JS: _opLog — prunes entries older than 30 days in addition to 200-entry cap
This commit is contained in:
dadaloop82
2026-05-10 11:26:10 +00:00
parent f65fb4365c
commit ed447d5811
6 changed files with 106 additions and 20 deletions
+16
View File
@@ -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/), 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). 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 ## [1.7.5] - 2026-05-10
### Added ### Added
+11
View File
@@ -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_inventory_location ON inventory(location);
CREATE INDEX IF NOT EXISTS idx_transactions_product ON transactions(product_id); CREATE INDEX IF NOT EXISTS idx_transactions_product ON transactions(product_id);
CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(created_at); 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("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_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_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 --- // --- New shared tables ---
@@ -192,6 +199,10 @@ function migrateDB(PDO $db): void {
if (!in_array('undone', $txColNames)) { if (!in_array('undone', $txColNames)) {
$db->exec("ALTER TABLE transactions ADD COLUMN undone INTEGER DEFAULT 0"); $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)");
} }
/** /**
+69 -13
View File
@@ -183,6 +183,27 @@ if ($rateLimitAction) {
checkRateLimit($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 { try {
$db = getDB(); $db = getDB();
} catch (Exception $e) { } catch (Exception $e) {
@@ -1715,8 +1736,13 @@ function getInventoryAnomalies(PDO $db): void {
$threshold = max(1.0, $invQty * 0.20); $threshold = max(1.0, $invQty * 0.20);
if (abs($diff) <= $threshold || abs($diff) <= 50) continue; if (abs($diff) <= $threshold || abs($diff) <= 50) continue;
// Dismiss key: product_id + rounded expected (so re-adding stock resets the alert) // Dismiss key: stable identifier based on product_id + direction.
$key = 'a_' . $r['product_id'] . '_' . round($expected); // 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; if (!empty($dismissed[$key])) continue;
$direction = $diff > 0 ? 'phantom' : 'missing'; $direction = $diff > 0 ? 'phantom' : 'missing';
@@ -1769,11 +1795,22 @@ function dismissInventoryAnomaly(): void {
} }
function getStats(PDO $db): void { function getStats(PDO $db): void {
$totalProducts = $db->query("SELECT COUNT(*) FROM products")->fetchColumn(); // Consolidated summary query: totals + 7-day activity in a single round-trip
$totalItems = $db->query("SELECT COALESCE(SUM(quantity), 0) FROM inventory")->fetchColumn(); $summary = $db->query("
$locations = $db->query("SELECT COUNT(DISTINCT location) FROM inventory")->fetchColumn(); SELECT
$recentIn = $db->query("SELECT COUNT(*) FROM transactions WHERE type='in' AND created_at >= datetime('now', '-7 days')")->fetchColumn(); (SELECT COUNT(*) FROM products) AS total_products,
$recentOut = $db->query("SELECT COUNT(*) FROM transactions WHERE type='out' AND created_at >= datetime('now', '-7 days')")->fetchColumn(); (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 soonest (next 4 items to expire)
$expiring = $db->query(" $expiring = $db->query("
@@ -4909,7 +4946,10 @@ function bringCleanupObsolete(PDO $db): array {
} }
if ($result !== null) $removed++; 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]; 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; $lastOut = $tx && $tx['last_out'] ? strtotime($tx['last_out']) : null;
$daysSinceFirst = $firstIn ? max(1, ($now - $firstIn) / 86400) : 999; $daysSinceFirst = $firstIn ? max(1, ($now - $firstIn) / 86400) : 999;
// Average daily consumption rate // Average daily consumption rate.
$dailyRate = $daysSinceFirst < 999 && $totalUsed > 0 ? $totalUsed / $daysSinceFirst : 0; // 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 // Days of stock remaining
$daysLeft = ($dailyRate > 0 && $qty > 0) ? $qty / $dailyRate : ($qty > 0 ? 999 : 0); $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']; 'bianco','rosso','nero','giallo','verde','misto','dolce','light'];
$pToks = array_diff($nameTokens($p['name']), $coverageGeneric); $pToks = array_diff($nameTokens($p['name']), $coverageGeneric);
$coveredByEquivalent = false; $coveredByEquivalent = false;
foreach ($pToks as $tok) { // Products exhausted within the last 14 days bypass token-based suppression:
if (($stockByAnyToken[$tok] ?? 0) > 0) { $coveredByEquivalent = true; break; } // 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 // 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, // (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, // the need is covered. This catches "Bel Paese" → covered by "Formaggio Gouda" in stock,
// "Biscotti Pastefrolle" → covered by "Frollini..." (both shopping_name="Biscotti"), etc. // "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'] ?? '')); $sName = strtolower(trim($p['shopping_name'] ?? ''));
if ($sName !== '' && ($stockByShoppingName[$sName] ?? 0) > 0) { if ($sName !== '' && ($stockByShoppingName[$sName] ?? 0) > 0) {
$coveredByEquivalent = true; $coveredByEquivalent = true;
@@ -6213,6 +6267,8 @@ function chatSave(PDO $db): void {
$stmt->execute([$msg['role'], $msg['text']]); $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]); echo json_encode(['success' => true]);
} }
+8 -5
View File
@@ -2502,7 +2502,7 @@ async function api(action, params = {}, method = 'GET', body = null, extraHeader
} }
const opts = { method, cache: 'no-store' }; const opts = { method, cache: 'no-store' };
if (body) { 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); opts.body = JSON.stringify(body);
} else if (Object.keys(extraHeaders).length > 0) { } else if (Object.keys(extraHeaders).length > 0) {
opts.headers = { ...extraHeaders }; opts.headers = { ...extraHeaders };
@@ -9264,10 +9264,13 @@ async function cleanupObsoleteBringItems() {
function logOperation(action, details) { function logOperation(action, details) {
try { try {
const log = JSON.parse(localStorage.getItem('_opLog') || '[]'); const log = JSON.parse(localStorage.getItem('_opLog') || '[]');
log.push({ ts: new Date().toISOString(), action, details }); const now = Date.now();
// Keep last 200 entries log.push({ ts: new Date(now).toISOString(), action, details });
if (log.length > 200) log.splice(0, log.length - 200); // Prune: keep only last 200 entries AND entries newer than 30 days
localStorage.setItem('_opLog', JSON.stringify(log)); 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 */ } } catch (e) { /* ignore */ }
} }
+1 -1
View File
@@ -67,7 +67,7 @@
<!-- Title — left-aligned; grows to fill space --> <!-- Title — left-aligned; grows to fill space -->
<div class="header-title-wrap"> <div class="header-title-wrap">
<h1 class="header-title" onclick="showPage('dashboard')"> <h1 class="header-title" onclick="showPage('dashboard')">
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.5</span> <img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.6</span>
</h1> </h1>
<!-- Update badge — shown alongside title, never replaces it --> <!-- Update badge — shown alongside title, never replaces it -->
<span class="header-update-badge" id="header-update-badge" style="display:none"></span> <span class="header-update-badge" id="header-update-badge" style="display:none"></span>
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "EverShelf", "name": "EverShelf",
"short_name": "EverShelf", "short_name": "EverShelf",
"description": "Gestione completa della dispensa di casa con scansione barcode", "description": "Gestione completa della dispensa di casa con scansione barcode",
"version": "1.7.5", "version": "1.7.6",
"start_url": "/evershelf/", "start_url": "/evershelf/",
"display": "standalone", "display": "standalone",
"background_color": "#f0f4e8", "background_color": "#f0f4e8",