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:
@@ -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
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+69
-13
@@ -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]);
|
||||
}
|
||||
|
||||
|
||||
+8
-5
@@ -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 */ }
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@
|
||||
<!-- Title — left-aligned; grows to fill space -->
|
||||
<div class="header-title-wrap">
|
||||
<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>
|
||||
<!-- Update badge — shown alongside title, never replaces it -->
|
||||
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "EverShelf",
|
||||
"short_name": "EverShelf",
|
||||
"description": "Gestione completa della dispensa di casa con scansione barcode",
|
||||
"version": "1.7.5",
|
||||
"version": "1.7.6",
|
||||
"start_url": "/evershelf/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f0f4e8",
|
||||
|
||||
Reference in New Issue
Block a user