chore: auto-merge develop → main

Triggered by: d33b0ca Harden security, modularize API bootstrap, and fix scale SSE auth.
This commit is contained in:
github-actions[bot]
2026-06-03 18:06:05 +00:00
34 changed files with 1619 additions and 277 deletions
+18 -4
View File
@@ -125,10 +125,24 @@ GDRIVE_FOLDER_ID=
GDRIVE_RETENTION_DAYS=30
# ── Security ─────────────────────────────────────────────────────────────────
# SETTINGS_TOKEN: if set, the Settings screen requires this token to save changes.
# Leave empty to allow anyone with access to the server to change settings.
# API_TOKEN: when set, all API calls require header X-API-Token (or ?api_token= for HA).
# SETTINGS_TOKEN: legacy alias — use API_TOKEN for new installs.
API_TOKEN=
SETTINGS_TOKEN=
# CORS_ORIGIN: comma-separated allowed origins (empty = same-origin only, no wildcard)
CORS_ORIGIN=
# GitHub automatic issue reporting (encrypted storage recommended)
# Option A — plain ( .env is gitignored ):
# GH_ISSUE_TOKEN=ghp_...
# Option B — encrypted (php scripts/encrypt-gh-token.php 'ghp_...' 'secret-key'):
GH_ISSUE_TOKEN=
GH_ISSUE_TOKEN_ENC=
GH_ISSUE_TOKEN_KEY=
# NOTE: Run `php scripts/migrate-env-security.php` once after upgrading to migrate legacy tokens.
# INSTANCE_NAME: display name for this EverShelf instance (used by the HA integration
# for Zeroconf discovery label and device name in Home Assistant).
# Defaults to the server hostname if left empty.
@@ -160,5 +174,5 @@ HA_EXPIRY_DAYS=3
# DEMO_MODE: when true, all write operations are blocked (for public demos)
DEMO_MODE=false
# NOTE: GitHub error reporting uses a token hardcoded in api/index.php.
# To rotate it, update the GH_ISSUE_TOKEN constant there.
# CRON_LOG_MAX_BYTES: rotate data/cron.log when larger (default 524288 = 512 KB)
CRON_LOG_MAX_BYTES=524288
+14
View File
@@ -1,5 +1,19 @@
RewriteEngine On
# Block sensitive files (Apache 2.4+)
<Files ".env">
Require all denied
</Files>
<Files ".env.example">
Require all denied
</Files>
<Files "backup.sh">
Require all denied
</Files>
<FilesMatch "^\.">
Require all denied
</FilesMatch>
# Force HTTPS
RewriteCond %{HTTPS} !=on
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
+11 -11
View File
@@ -236,7 +236,7 @@ TTS_ENABLED=true
# Optional: DB retention and cleanup (applied automatically each cron cycle)
RECIPE_RETENTION_DAYS=7 # delete recipe plans older than N days
TRANSACTION_RETENTION_DAYS=7 # delete stock transactions older than N days
TRANSACTION_RETENTION_DAYS=90 # delete stock transactions older than N days (min 30 enforced)
# Optional: Vacuum-sealed expiry grace period
VACUUM_EXPIRY_EXTENSION_DAYS=30 # extra days before vacuum-sealed items are flagged expired
@@ -247,8 +247,11 @@ GEMINI_COST_25F_OUT=0.60
GEMINI_COST_20F_IN=0.10
GEMINI_COST_20F_OUT=0.40
# Optional: Security — protect the save_settings endpoint
# Set a strong random string; the Settings UI will ask for it before saving
# Optional: Security — protect all API endpoints
# Set a strong random string; clients send it as X-API-Token header (or ?api_token= for HA)
API_TOKEN=
# Optional: Legacy alias for API_TOKEN (settings save only)
SETTINGS_TOKEN=
# Optional: Demo mode — block all write operations at the router level
@@ -416,8 +419,11 @@ evershelf-kiosk/ # 📺 Android kiosk app (add-on)
- **Credentials** are stored in `.env` (server-side, never committed to Git)
- **Database** stays local — never pushed to remote repositories
- **API keys are never exposed to the browser** — `get_settings` returns only boolean flags (`gemini_key_set`, `settings_token_set`), never raw key values
- **Settings write protection** — set `SETTINGS_TOKEN` in `.env` to require a secret token (`X-Settings-Token` header) for all `save_settings` calls; validated with `hash_equals` to prevent timing attacks
- **Apache/Nginx hardening** — `.env`, `data/`, and `logs/` are blocked from direct HTTP access
- **API token** — set `API_TOKEN` in `.env` to require `X-API-Token` on all API calls (Home Assistant: `?api_token=`)
- **API keys are never exposed to the browser** — `get_settings` returns only boolean flags (`gemini_key_set`, `ha_token_set`, …)
- **GitHub Issues token** — stored encrypted as `GH_ISSUE_TOKEN_ENC` + `GH_ISSUE_TOKEN_KEY` (see `scripts/encrypt-gh-token.php`)
- **Settings write protection** — `save_settings` requires the same API token when configured; validated with `hash_equals`
- **Demo / public mode** — set `DEMO_MODE=true` to block all write operations at the PHP router level before any business logic runs
- The API uses **parameterized SQL queries** (PDO prepared statements) against injection
- **Input validation** on all inventory operations (quantity bounds, location whitelist)
@@ -472,12 +478,6 @@ Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed g
4. Push to the branch (`git push origin feature/my-feature`)
5. Open a Pull Request
---
## 🤝 Contributing
EverShelf is a community project and contributions of any size are welcome!
### Easiest way to start — translate EverShelf into your language
Translations are just JSON files. No coding, no setup — fork → edit → PR.
+11
View File
@@ -0,0 +1,11 @@
<?php
/**
* EverShelf API bootstrap — shared by HTTP router and cron.
*/
require_once __DIR__ . '/lib/env.php';
require_once __DIR__ . '/lib/constants.php';
require_once __DIR__ . '/lib/github.php';
require_once __DIR__ . '/lib/security.php';
require_once __DIR__ . '/lib/cron_log.php';
require_once __DIR__ . '/logger.php';
require_once __DIR__ . '/database.php';
+4 -2
View File
@@ -11,14 +11,16 @@ if (PHP_SAPI !== 'cli') {
exit('Forbidden');
}
// Define CRON_MODE before loading index.php so the router is skipped
// Define CRON_MODE before loading bootstrap so the HTTP router is skipped
define('CRON_MODE', true);
// Load all API functions without running the HTTP router
require_once __DIR__ . '/bootstrap.php';
require_once __DIR__ . '/index.php';
const CACHE_FILE = __DIR__ . '/../data/smart_shopping_cache.json';
evershelfRotateCronLog();
try {
$db = getDB();
+282 -129
View File
@@ -8,49 +8,8 @@
* @license MIT
*/
// ── GitHub error-reporting credentials ───────────────────────────────────────
// The token is XOR-obfuscated so the literal secret string never appears in
// source or git history (prevents GitHub secret scanning from revoking it).
// Scoped only to Issues (R+W) on this single repository.
// Defined at the very top so the global exception handler can use it.
define('_GH_TK_ENC', '23580718460c2c444031290243627e7971622b29030a3e4d50001e45261659420b6e110a423f30447133205b425a577971561f32762b0b034e0b3e56106d5945020406254a3a4647592a1a611c66687a0b672043700f34757900014004');
define('_GH_TK_KEY', 'D1sp3ns4!Ev3r#26');
define('GH_REPO', 'dadaloop82/EverShelf');
define('PRICE_CACHE_PATH', __DIR__ . '/../data/shopping_price_cache.json');
define('CATEGORY_CACHE_PATH', __DIR__ . '/../data/category_ai_cache.json');
define('SHELF_CACHE_PATH', __DIR__ . '/../data/opened_shelf_cache.json');
define('FOODFACTS_CACHE_PATH', __DIR__ . '/../data/food_facts_cache.json');
define('SHOPPING_NAME_CACHE_PATH', __DIR__ . '/../data/shopping_name_cache.json');
define('BRING_TOKEN_PATH', __DIR__ . '/../data/bring_token.json');
define('AI_USAGE_PATH', __DIR__ . '/../data/ai_usage.json');
define('BACKUP_DIR', __DIR__ . '/../data/backups');
define('BACKUP_LAST_TS_PATH', __DIR__ . '/../data/backup_last_ts.json');
// Gemini pricing (USD per 1M tokens) — configurable in .env (GEMINI_COST_25F_IN etc.)
// Defaults: gemini-2.5-flash $0.15/M in · $0.60/M out — gemini-2.0-flash $0.10/M in · $0.40/M out
define('GEMINI_COST_25F_IN', (float)(getenv('GEMINI_COST_25F_IN') ?: 0.15));
define('GEMINI_COST_25F_OUT', (float)(getenv('GEMINI_COST_25F_OUT') ?: 0.60));
define('GEMINI_COST_20F_IN', (float)(getenv('GEMINI_COST_20F_IN') ?: 0.10));
define('GEMINI_COST_20F_OUT', (float)(getenv('GEMINI_COST_20F_OUT') ?: 0.40));
/** Decode the XOR-obfuscated GitHub token at runtime. */
function _ghToken(): string {
static $token = null;
if ($token !== null) return $token;
$enc = hex2bin(\constant('_GH_TK_ENC'));
$key = \constant('_GH_TK_KEY');
$kl = strlen($key);
$out = '';
for ($i = 0; $i < strlen($enc); $i++) {
$out .= chr(ord($enc[$i]) ^ ord($key[$i % $kl]));
}
$token = $out;
return $token;
}
// logger.php must be loaded BEFORE database.php so LoggingPDO class exists when getDB() runs
require_once __DIR__ . '/logger.php';
// database.php must always be loaded (used both by HTTP router and cron)
require_once __DIR__ . '/database.php';
// ── Core bootstrap (env, security, database, logger) ─────────────────────────
require_once __DIR__ . '/bootstrap.php';
// ── Global PHP error/exception reporters ─────────────────────────────────────
// These are registered immediately so any crash anywhere in this file is caught.
@@ -74,41 +33,11 @@ if (!defined('CRON_MODE')) {
});
}
/**
* Load environment variables from .env file.
* Returns associative array of key => value pairs.
*/
function loadEnv(): array {
static $cache = null;
if ($cache !== null) return $cache;
$envFile = __DIR__ . '/../.env';
$cache = [];
if (file_exists($envFile)) {
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (strpos($line, '#') === 0 || strpos($line, '=') === false) continue;
list($key, $val) = explode('=', $line, 2);
$cache[trim($key)] = trim($val);
}
}
return $cache;
}
/**
* Get a single environment variable, with optional default.
*/
function env(string $key, string $default = ''): string {
$vars = loadEnv();
return $vars[$key] ?? $default;
}
// When included by the cron script, skip HTTP headers and routing entirely
if (!defined('CRON_MODE')) {
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
evershelfSendCorsHeaders();
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
@@ -121,6 +50,17 @@ if (($_GET['action'] ?? '') === 'ping') {
exit;
}
// ── App bootstrap — same-origin browsers receive API token automatically ───────
if (($_GET['action'] ?? '') === 'app_bootstrap') {
$required = evershelfApiTokenRequired();
$out = ['api_token_required' => $required];
if ($required && evershelfIsSameOriginBrowser()) {
$out['api_token'] = evershelfEffectiveApiToken();
}
echo json_encode($out);
exit;
}
// ── Google Drive OAuth callback — returns HTML, not JSON ──────────────────────
if (($_GET['action'] ?? '') === 'gdrive_oauth_callback') {
_gdriveHandleOAuthCallback();
@@ -130,9 +70,9 @@ if (($_GET['action'] ?? '') === 'gdrive_oauth_callback') {
// ── Log viewer — returns last N log lines (requires SETTINGS_TOKEN if set) ────
if (($_GET['action'] ?? '') === 'get_logs') {
require_once __DIR__ . '/logger.php';
$token = loadEnv()['SETTINGS_TOKEN'] ?? '';
$reqTok = $_GET['token'] ?? $_SERVER['HTTP_X_SETTINGS_TOKEN'] ?? '';
if (!empty($token) && $reqTok !== $token) {
$token = evershelfEffectiveApiToken();
$reqTok = evershelfGetProvidedApiTokenFromHeaders() ?: (string)($_GET['token'] ?? '');
if ($token !== '' && ($reqTok === '' || !hash_equals($token, $reqTok))) {
EverLog::warn('get_logs: unauthorized (403)');
http_response_code(403);
echo json_encode(['error' => 'Unauthorized']);
@@ -323,8 +263,17 @@ if (($_GET['action'] ?? '') === 'gemini_usage') {
}
}
// ── Health check — startup diagnostic (no rate-limit, no auth required) ──────
// ── Health check — minimal public probe; full diagnostics require API token ──
if (($_GET['action'] ?? '') === 'health_check') {
if (evershelfApiTokenRequired() && !evershelfApiTokenValid()) {
header('Content-Type: application/json');
echo json_encode([
'ok' => true,
'public' => true,
'api_token_required' => true,
], JSON_UNESCAPED_UNICODE);
exit;
}
$checks = [];
// ── Helper: read .env values without triggering app init ─────────────────
@@ -363,7 +312,7 @@ if (($_GET['action'] ?? '') === 'health_check') {
$dataDir = __DIR__ . '/../data';
if (!is_dir($dataDir)) @mkdir($dataDir, 0775, true);
$dataDirOk = is_dir($dataDir) && is_writable($dataDir);
$checks['data_dir'] = ['ok' => $dataDirOk, 'path' => realpath($dataDir) ?: $dataDir];
$checks['data_dir'] = ['ok' => $dataDirOk];
// data/rate_limits/
$rlDir = $dataDir . '/rate_limits';
@@ -714,24 +663,19 @@ $method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
EverLog::request($action, $method);
// API token auth (when API_TOKEN or SETTINGS_TOKEN is configured)
evershelfRequireApiAuth($action, $method);
} // end !CRON_MODE block for router bootstrap
if (!defined('CRON_MODE')):
try {
// DEMO_MODE guard
if (env('DEMO_MODE') === 'true') {
$demoBlocked = [
'save_settings', 'product_save', 'product_delete', 'product_merge',
'inventory_add', 'inventory_use', 'inventory_update', 'inventory_remove',
'dismiss_anomaly', 'bring_add', 'bring_remove', 'bring_sync',
'backup_delete', 'backup_restore',
];
if (in_array($action, $demoBlocked, true)) {
EverLog::warn('demo_mode blocked (403)');
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'demo_mode']);
exit;
}
// DEMO_MODE — block all writes and AI generation
if (evershelfDemoBlocksAction($action, $method)) {
EverLog::warn('demo_mode blocked (403)', ['action' => $action]);
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'demo_mode']);
exit;
}
switch ($action) {
@@ -760,6 +704,9 @@ try {
case 'products_search':
searchProducts($db);
break;
case 'inventory_search':
searchInventoryProducts($db);
break;
// ===== INVENTORY =====
case 'inventory_list':
@@ -813,6 +760,10 @@ try {
getInventoryAnomalies($db);
break;
case 'inventory_duplicate_loss_checks':
getDuplicateLossChecks($db);
break;
case 'dismiss_anomaly':
dismissInventoryAnomaly();
break;
@@ -1252,9 +1203,33 @@ function ttsProxy() {
$method = isset($body['method']) ? strtoupper(trim($body['method'])) : 'POST';
$headers = isset($body['headers']) && is_array($body['headers']) ? $body['headers'] : [];
$payload = isset($body['payload']) ? $body['payload'] : '';
$contentType = '';
foreach ($headers as $k => $v) {
if (strtolower($k) === 'content-type') { $contentType = $v; break; }
// Never trust client-supplied auth headers — inject from server .env
$headers = array_filter($headers, static function ($k) {
$lk = strtolower((string)$k);
return !in_array($lk, ['authorization', 'x-api-key', 'x-auth-token'], true);
}, ARRAY_FILTER_USE_KEY);
$haBase = rtrim(env('HA_URL', ''), '/');
if ($haBase !== '' && str_starts_with($url, $haBase)) {
$haTok = env('HA_TOKEN');
if ($haTok !== '') {
$headers['Authorization'] = 'Bearer ' . $haTok;
}
} elseif ($url !== '' && $url === env('TTS_URL', '')) {
$authType = env('TTS_AUTH_TYPE', 'bearer');
if ($authType === 'bearer') {
$tok = env('TTS_TOKEN');
if ($tok !== '') {
$headers['Authorization'] = 'Bearer ' . $tok;
}
} elseif ($authType === 'header') {
$hn = env('TTS_AUTH_HEADER_NAME');
$hv = env('TTS_AUTH_HEADER_VALUE');
if ($hn !== '') {
$headers[$hn] = $hv;
}
}
}
if (!$url || !preg_match('/^https?:\/\/.+/', $url)) {
@@ -1424,8 +1399,6 @@ function _haProductSelect(): string {
*/
function haInventorySensor(PDO $db): void {
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
$sensor = strtolower(trim($_GET['sensor'] ?? 'overview'));
$expiryDays = max(1, min(90, (int)($_GET['expiry_days'] ?? env('HA_EXPIRY_DAYS', 3))));
@@ -1448,7 +1421,6 @@ function haInventorySensor(PDO $db): void {
$stmt->execute($params);
$items = array_map('_haFormatProduct', $stmt->fetchAll(PDO::FETCH_ASSOC));
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
echo json_encode([
'state' => count($items),
'items' => $items,
@@ -1693,7 +1665,6 @@ function haInventorySensor(PDO $db): void {
*/
function haCalendar(PDO $db): void {
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
try {
$rows = $db->query(
"SELECT p.name, i.quantity, p.unit, i.location, i.expiry_date
@@ -1728,8 +1699,6 @@ function haCalendar(PDO $db): void {
*/
function haSuggestRecipe(PDO $db): void {
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
$apiKey = env('GEMINI_API_KEY', '');
if (!$apiKey) {
http_response_code(503);
@@ -1814,8 +1783,6 @@ function haSuggestRecipe(PDO $db): void {
*/
function haRefreshPrices(PDO $db): void {
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
try {
$country = env('PRICE_COUNTRY', 'Italia');
$currency = env('PRICE_CURRENCY', 'EUR');
@@ -1889,8 +1856,6 @@ function haRefreshPrices(PDO $db): void {
*/
function haClearExpired(PDO $db): void {
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
try {
$stmt = $db->prepare(
"DELETE FROM inventory WHERE expiry_date < date('now') AND quantity <= 0"
@@ -1966,7 +1931,6 @@ function haTestConnection(): void {
*/
function haGetInfo(PDO $db): void {
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
// Stable unique_id derived from server identity (survives restarts)
$uniqueId = 'evershelf_' . substr(md5(__DIR__ . php_uname('n')), 0, 12);
$itemsCount = (int)$db->query("SELECT COUNT(*) FROM inventory WHERE quantity > 0")->fetchColumn();
@@ -1988,7 +1952,6 @@ function haGetInfo(PDO $db): void {
*/
function haGetShoppingItems(PDO $db): void {
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
try {
if (isShoppingBringMode()) {
$auth = bringAuth();
@@ -2603,6 +2566,57 @@ function searchProducts(PDO $db): void {
echo json_encode(['products' => $stmt->fetchAll()]);
}
function searchInventoryProducts(PDO $db): void {
EverLog::debug('searchInventoryProducts');
$q = trim((string)($_GET['q'] ?? ''));
$limit = (int)($_GET['limit'] ?? 3);
if ($limit < 1) $limit = 1;
if ($limit > 10) $limit = 10;
if ($q === '' || mb_strlen($q) < 2) {
echo json_encode(['items' => []]);
return;
}
$like = "%{$q}%";
$prefix = mb_strtolower($q) . '%';
$exact = mb_strtolower($q);
$sql = "
SELECT
p.id,
p.name,
p.brand,
p.category,
p.barcode,
p.image_url,
p.unit,
p.default_quantity,
p.package_unit,
p.notes,
SUM(i.quantity) AS total_qty,
GROUP_CONCAT(DISTINCT i.location) AS locations
FROM inventory i
JOIN products p ON p.id = i.product_id
WHERE i.quantity > 0
AND (p.name LIKE ? OR p.brand LIKE ?)
GROUP BY p.id
ORDER BY
CASE
WHEN lower(p.name) = ? THEN 0
WHEN lower(p.name) LIKE ? THEN 1
ELSE 2
END,
total_qty DESC,
p.name ASC
LIMIT {$limit}
";
$stmt = $db->prepare($sql);
$stmt->execute([$like, $like, $exact, $prefix]);
echo json_encode(['items' => $stmt->fetchAll()]);
}
// ===== INVENTORY FUNCTIONS =====
function listInventory(PDO $db): void {
@@ -2828,22 +2842,38 @@ function useFromInventory(PDO $db): void {
}
// ── Server-side deduplication ─────────────────────────────────────────
// Reject if the same product already has an 'out' transaction in the last
// 12 seconds. This guards against scale double-triggers (the scale can fire
// a second stable reading ~10 s after the first auto-confirm, while the
// product is still on the plate), regardless of the client-side guard.
// Guard against accidental double-consume triggers (scale jitter, double tap,
// delayed/offline replay burst). We only apply this stricter gate to manual
// uses with empty notes, so recipe uses (notes="Ricetta: ...") remain unaffected.
if (!$useAll) {
$dedupWindow = ($notes === '') ? 120 : 12;
$dedup = $db->prepare(
"SELECT id FROM transactions
WHERE product_id = ? AND type IN ('out','waste') AND undone = 0
AND created_at >= datetime('now', '-12 seconds')
"SELECT id, quantity, created_at FROM transactions
WHERE product_id = ?
AND location = ?
AND type IN ('out','waste')
AND undone = 0
AND COALESCE(notes, '') = ?
AND created_at >= datetime('now', '-' || ? || ' seconds')
ORDER BY id DESC
LIMIT 1"
);
$dedup->execute([$productId]);
if ($dedup->fetch()) {
$dedup->execute([$productId, $location, $notes, $dedupWindow]);
$recent = $dedup->fetch();
if ($recent) {
EverLog::warn('useFromInventory duplicate blocked', [
'product_id' => $productId,
'location' => $location,
'window_s' => $dedupWindow,
'recent_tx_id' => $recent['id'] ?? null,
'recent_qty' => $recent['quantity'] ?? null,
'recent_created_at' => $recent['created_at'] ?? null,
'requested_qty' => $quantity,
'notes' => $notes,
]);
echo json_encode([
'success' => false,
'error' => 'Operazione già registrata di recente — attendi qualche secondo.',
'error' => 'Operazione già registrata di recente — verifica prima la quantità rimasta.',
'duplicate' => true,
]);
return;
@@ -3574,6 +3604,122 @@ function getInventoryAnomalies(PDO $db): void {
echo json_encode(['success' => true, 'anomalies' => $anomalies], JSON_UNESCAPED_UNICODE);
}
/**
* Detect likely "double consume" losses:
* latest pair of out transactions for same product+location within 120s,
* empty notes, current inventory at 0, and last tx at that location is out.
*/
function getDuplicateLossChecks(PDO $db): void {
EverLog::info('getDuplicateLossChecks');
$sql = "
WITH out_tx AS (
SELECT
id,
product_id,
IFNULL(location, '') AS location,
quantity,
created_at,
COALESCE(notes, '') AS notes
FROM transactions
WHERE type = 'out' AND undone = 0
),
pairs AS (
SELECT
t1.product_id,
t1.location,
t1.id AS tx1,
t2.id AS tx2,
t1.quantity AS q1,
t2.quantity AS q2,
t2.created_at AS c2,
ROUND((julianday(t2.created_at) - julianday(t1.created_at)) * 86400.0, 1) AS dt_sec
FROM out_tx t1
JOIN out_tx t2
ON t2.product_id = t1.product_id
AND t2.location = t1.location
AND t2.id > t1.id
AND (julianday(t2.created_at) - julianday(t1.created_at)) * 86400.0 BETWEEN 0 AND 120
WHERE TRIM(t1.notes) = '' AND TRIM(t2.notes) = ''
),
latest_pair AS (
SELECT
p.*,
ROW_NUMBER() OVER (PARTITION BY p.product_id, p.location ORDER BY p.c2 DESC) AS rn
FROM pairs p
),
inv AS (
SELECT
product_id,
IFNULL(location, '') AS location,
MIN(id) AS inventory_id,
SUM(quantity) AS quantity
FROM inventory
GROUP BY product_id, IFNULL(location, '')
),
last_tx AS (
SELECT
product_id,
IFNULL(location, '') AS location,
type,
created_at,
ROW_NUMBER() OVER (PARTITION BY product_id, IFNULL(location, '') ORDER BY id DESC) AS rn
FROM transactions
WHERE undone = 0
)
SELECT
p.id AS product_id,
p.name,
p.brand,
p.unit,
p.default_quantity,
p.package_unit,
lp.location,
lp.tx1,
lp.q1,
lp.tx2,
lp.q2,
lp.dt_sec,
lp.c2 AS latest_pair_at,
IFNULL(inv.inventory_id, 0) AS inventory_id,
IFNULL(inv.quantity, 0) AS inv_qty_now
FROM latest_pair lp
JOIN products p ON p.id = lp.product_id
LEFT JOIN inv ON inv.product_id = lp.product_id AND inv.location = lp.location
LEFT JOIN last_tx lt ON lt.product_id = lp.product_id AND lt.location = lp.location AND lt.rn = 1
WHERE lp.rn = 1
AND IFNULL(inv.quantity, 0) = 0
AND lt.type = 'out'
ORDER BY lp.c2 DESC
LIMIT 30
";
$rows = $db->query($sql)->fetchAll(PDO::FETCH_ASSOC) ?: [];
$checks = array_map(function(array $r): array {
return [
'product_id' => (int)$r['product_id'],
'name' => (string)$r['name'],
'brand' => (string)($r['brand'] ?? ''),
'unit' => (string)($r['unit'] ?? 'pz'),
'default_quantity' => isset($r['default_quantity']) ? (float)$r['default_quantity'] : 0.0,
'package_unit' => (string)($r['package_unit'] ?? ''),
'location' => (string)($r['location'] ?? ''),
'tx1' => (int)$r['tx1'],
'q1' => (float)$r['q1'],
'tx2' => (int)$r['tx2'],
'q2' => (float)$r['q2'],
'dt_sec' => (float)$r['dt_sec'],
'latest_pair_at' => (string)$r['latest_pair_at'],
'inventory_id' => (int)$r['inventory_id'],
'inv_qty_now' => (float)$r['inv_qty_now'],
'dismiss_key' => 'dup_' . ((int)$r['product_id']) . '_' . md5((string)($r['location'] ?? '')),
];
}, $rows);
echo json_encode(['success' => true, 'checks' => $checks], JSON_UNESCAPED_UNICODE);
}
/**
* Dismiss a specific anomaly so it no longer appears in the banner.
*/
@@ -4301,12 +4447,13 @@ function getServerSettings(): void {
echo json_encode([
'gemini_key_set' => !empty($geminiKey),
'api_token_required' => evershelfApiTokenRequired(),
'bring_email' => $bringEmail,
'settings_token_set' => !empty(env('SETTINGS_TOKEN')),
'settings_token_set' => evershelfApiTokenRequired(),
'demo_mode' => env('DEMO_MODE') === 'true',
'bring_password_set' => !empty(env('BRING_PASSWORD')),
'tts_url' => env('TTS_URL'),
'tts_token' => env('TTS_TOKEN'),
'tts_token_set' => !empty(env('TTS_TOKEN')),
'tts_method' => env('TTS_METHOD', 'POST'),
'tts_auth_type' => env('TTS_AUTH_TYPE', 'bearer'),
'tts_content_type' => env('TTS_CONTENT_TYPE', 'application/json'),
@@ -4316,7 +4463,7 @@ function getServerSettings(): void {
'tts_rate' => (float)env('TTS_RATE', '1'),
'tts_pitch' => (float)env('TTS_PITCH', '1'),
'tts_auth_header_name' => env('TTS_AUTH_HEADER_NAME', ''),
'tts_auth_header_value' => env('TTS_AUTH_HEADER_VALUE', ''),
'tts_auth_header_value_set' => !empty(env('TTS_AUTH_HEADER_VALUE', '')),
'tts_extra_fields' => env('TTS_EXTRA_FIELDS', ''),
// User preferences (now server-side)
'default_persons' => intval(env('DEFAULT_PERSONS', '1')),
@@ -4361,7 +4508,7 @@ function getServerSettings(): void {
// Home Assistant Integration
'ha_enabled' => env('HA_ENABLED', 'false') === 'true',
'ha_url' => env('HA_URL', ''),
'ha_token' => env('HA_TOKEN', ''),
'ha_token_set' => !empty(env('HA_TOKEN', '')),
'ha_tts_entity' => env('HA_TTS_ENTITY', ''),
'ha_webhook_id' => env('HA_WEBHOOK_ID', ''),
'ha_webhook_events' => env('HA_WEBHOOK_EVENTS', 'expiry,shopping_add,stock_update,barcode_scan'),
@@ -4392,11 +4539,11 @@ function dbCleanup(?PDO $db = null): void {
}
function saveSettings(): void {
// Require SETTINGS_TOKEN if configured
$requiredToken = env('SETTINGS_TOKEN');
if (!empty($requiredToken)) {
// Require API token if configured
$requiredToken = evershelfEffectiveApiToken();
if ($requiredToken !== '') {
EverLog::debug('saveSettings');
$provided = $_SERVER['HTTP_X_SETTINGS_TOKEN'] ?? '';
$provided = evershelfGetProvidedApiToken();
if (!hash_equals($requiredToken, $provided)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'unauthorized']);
@@ -5660,13 +5807,15 @@ REGOLE:
8. `tools_needed`: array of kitchen tools/appliances actually required by this recipe (e.g. ["Forno","Frullateur"]). Use the same language as all other text fields. Empty array [] if only stovetop/knife/pan needed.
9. `steps`: array of PLAIN TEXT STRINGS only no objects, no JSON, no sub-fields. Each step is a single readable string. If appliances are used, include the appliance/mode information directly in the step text (e.g. "Nel Cookeo, modalità Rosolare: aggiungere la cipolla…"). NEVER output steps as objects like {"instruction":, "appliance_function":}.
10. NON confondere forme diverse dello stesso ingrediente di base: 'Pomodori'/'Pomodoro Piccadilly' (freschi, pz/g) 'Passata di pomodoro'/'Polpa di pomodoro'/'Sugo al pomodoro' (elaborato, conf/g); 'Latte fresco' 'Latte UHT' 'Panna'; 'Farina 00' 'Farina integrale'. Se la ricetta richiede un tipo di ingrediente che NON è disponibile nella forma giusta in lista, NON sostituirlo con una forma diversa: scegli una ricetta che usa gli ingredienti esattamente nella forma disponibile.
11. `nutrition`: object with estimated macro values PER SERVING for the finished dish: {"kcal":450,"protein_g":25,"carbs_g":40,"fat_g":15}. All values are integers. Estimate realistically based on the ingredients and quantities used.
12. `storage`: object describing how to store leftovers: {"where":"frigo","days":3,"tips":""}. `where` = one of: frigo / freezer / dispensa / temperatura ambiente (in target language). `days` = integer max days safe to keep. `tips` = one concise sentence in target language. If the dish is best eaten immediately, set days=0 and tips accordingly.
DISPENSA:
$ingredientsText
Rispondi SOLO JSON valido (no markdown):
{$promptLanguageRule}
{"title":"","meal":"$mealType","persons":$persons,"prep_time":"","cook_time":"","tags":[""],"expiry_note":"","tools_needed":[""],"ingredients":[{"name":"","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":""}
{"title":"","meal":"$mealType","persons":$persons,"prep_time":"","cook_time":"","tags":[""],"expiry_note":"","tools_needed":[""],"ingredients":[{"name":"","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":"","nutrition":{"kcal":450,"protein_g":25,"carbs_g":40,"fat_g":15},"storage":{"where":"frigo","days":3,"tips":""}}
PROMPT;
$payload = [
@@ -6091,12 +6240,14 @@ REGOLE:
5. "name": usa ESATTAMENTE il nome dalla lista dispensa (il sistema lo usa per scalare l'inventario).
6. "from_pantry": true se l'ingrediente è nella lista DISPENSA, false per acqua/sale/pepe/olio.
7. Language: {$langName} for all text fields. Keep "meal" as English meal key (colazione/pranzo/cena/snack/dolce/libero).
8. `nutrition`: object with estimated macro values PER SERVING for the finished dish: {"kcal":450,"protein_g":25,"carbs_g":40,"fat_g":15}. All values are integers.
9. `storage`: object describing how to store leftovers: {"where":"frigo","days":3,"tips":""}. `where` in target language (frigo / freezer / dispensa / temperatura ambiente). `days` = integer. `tips` = one concise sentence.
DISPENSA:
{$ingredientsText}
JSON schema:
{"title":"","meal":"libero","persons":{$persons},"prep_time":"","cook_time":"","tags":[""],"ingredients":[{"name":"","qty":"80 g","qty_number":80,"from_pantry":true}],"steps":[""],"nutrition_note":""}
{"title":"","meal":"libero","persons":{$persons},"prep_time":"","cook_time":"","tags":[""],"ingredients":[{"name":"","qty":"80 g","qty_number":80,"from_pantry":true}],"steps":[""],"nutrition_note":"","nutrition":{"kcal":450,"protein_g":25,"carbs_g":40,"fat_g":15},"storage":{"where":"frigo","days":3,"tips":""}}
PROMPT;
$payload = [
@@ -6608,13 +6759,15 @@ REGOLE:
9. `zero_waste_tips`: array of zero-waste tips for steps that generate reusable scraps (peels, leftover cooking water, egg whites, cheese rinds, bread crusts, vegetable tops, etc.). Each entry: {"step": 0-based_step_index, "scrap": "scrap name", "tip": "short practical reuse tip (max 20 words)"}. Use the same language as other text fields. Empty array [] if no reusable scraps are generated.
10. `steps`: array of PLAIN TEXT STRINGS only no objects, no JSON, no sub-fields. Each step is a single readable string. If appliances are used, include the appliance/mode information directly in the step text (e.g. "Nel Cookeo, modalità Rosolare: aggiungere la cipolla…"). NEVER output steps as objects like {"instruction":, "appliance_function":}.
11. NON confondere forme diverse dello stesso ingrediente di base: 'Pomodori'/'Pomodoro Piccadilly' (freschi, pz/g) 'Passata di pomodoro'/'Polpa di pomodoro'/'Sugo al pomodoro' (elaborato, conf/g); 'Latte fresco' 'Latte UHT' 'Panna'; 'Farina 00' 'Farina integrale'. Se la ricetta richiede un tipo di ingrediente che NON è disponibile nella forma giusta in lista, NON sostituirlo con una forma diversa: scegli una ricetta che usa gli ingredienti esattamente nella forma disponibile.
12. `nutrition`: object with estimated macro values PER SERVING for the finished dish: {"kcal":450,"protein_g":25,"carbs_g":40,"fat_g":15}. All values are integers. Estimate realistically based on the ingredients and quantities used.
13. `storage`: object describing how to store leftovers: {"where":"frigo","days":3,"tips":""}. `where` = one of: frigo / freezer / dispensa / temperatura ambiente (in target language). `days` = integer max days safe to keep. `tips` = one concise sentence in target language. If the dish is best eaten immediately, set days=0 and tips accordingly.
DISPENSA:
$ingredientsText
Rispondi SOLO JSON valido (no markdown):
{$promptLanguageRule}
{"title":"","meal":"$mealType","persons":$persons,"prep_time":"","cook_time":"","tags":[""],"expiry_note":"","tools_needed":[""],"ingredients":[{"name":"","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":"","zero_waste_tips":[{"step":0,"scrap":"","tip":""}]}
{"title":"","meal":"$mealType","persons":$persons,"prep_time":"","cook_time":"","tags":[""],"expiry_note":"","tools_needed":[""],"ingredients":[{"name":"","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":"","zero_waste_tips":[{"step":0,"scrap":"","tip":""}],"nutrition":{"kcal":450,"protein_g":25,"carbs_g":40,"fat_g":15},"storage":{"where":"frigo","days":3,"tips":""}}
PROMPT;
$genConfig = [
+22
View File
@@ -0,0 +1,22 @@
<?php
/**
* EverShelf — shared path constants.
*/
define('EVERSHELF_ROOT', dirname(__DIR__, 2));
define('GH_REPO', 'dadaloop82/EverShelf');
define('PRICE_CACHE_PATH', EVERSHELF_ROOT . '/data/shopping_price_cache.json');
define('CATEGORY_CACHE_PATH', EVERSHELF_ROOT . '/data/category_ai_cache.json');
define('SHELF_CACHE_PATH', EVERSHELF_ROOT . '/data/opened_shelf_cache.json');
define('FOODFACTS_CACHE_PATH', EVERSHELF_ROOT . '/data/food_facts_cache.json');
define('SHOPPING_NAME_CACHE_PATH', EVERSHELF_ROOT . '/data/shopping_name_cache.json');
define('BRING_TOKEN_PATH', EVERSHELF_ROOT . '/data/bring_token.json');
define('AI_USAGE_PATH', EVERSHELF_ROOT . '/data/ai_usage.json');
define('BACKUP_DIR', EVERSHELF_ROOT . '/data/backups');
define('BACKUP_LAST_TS_PATH', EVERSHELF_ROOT . '/data/backup_last_ts.json');
define('CRON_LOG_PATH', EVERSHELF_ROOT . '/data/cron.log');
define('GEMINI_COST_25F_IN', (float)(getenv('GEMINI_COST_25F_IN') ?: 0.15));
define('GEMINI_COST_25F_OUT', (float)(getenv('GEMINI_COST_25F_OUT') ?: 0.60));
define('GEMINI_COST_20F_IN', (float)(getenv('GEMINI_COST_20F_IN') ?: 0.10));
define('GEMINI_COST_20F_OUT', (float)(getenv('GEMINI_COST_20F_OUT') ?: 0.40));
+28
View File
@@ -0,0 +1,28 @@
<?php
/**
* Rotate data/cron.log — keep last N MB / lines.
*/
require_once __DIR__ . '/constants.php';
function evershelfRotateCronLog(?int $maxBytes = null, int $keepRotated = 3): void {
$path = CRON_LOG_PATH;
if (!file_exists($path)) {
return;
}
$maxBytes = $maxBytes ?? max(65536, (int)env('CRON_LOG_MAX_BYTES', '524288'));
$size = filesize($path);
if ($size === false || $size <= $maxBytes) {
return;
}
for ($i = $keepRotated; $i >= 1; $i--) {
$from = ($i === 1) ? $path : $path . '.' . ($i - 1);
$to = $path . '.' . $i;
if ($i === $keepRotated && file_exists($to)) {
@unlink($to);
}
if (file_exists($from)) {
@rename($from, $to);
}
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
/**
* EverShelf — environment variable loader (.env).
*/
function loadEnv(): array {
static $cache = null;
if ($cache !== null) {
return $cache;
}
$envFile = dirname(__DIR__, 2) . '/.env';
$cache = [];
if (file_exists($envFile)) {
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (strpos($line, '#') === 0 || strpos($line, '=') === false) {
continue;
}
[$key, $val] = explode('=', $line, 2);
$cache[trim($key)] = trim($val);
}
}
return $cache;
}
function env(string $key, string $default = ''): string {
$vars = loadEnv();
return $vars[$key] ?? $default;
}
/** Push a single key into the in-memory env cache (after .env write). */
function envCacheSet(string $key, string $value): void {
loadEnv();
// Force reload on next call — callers should use loadEnv() return for batch updates
}
+69
View File
@@ -0,0 +1,69 @@
<?php
/**
* EverShelf — GitHub issue reporting token (encrypted at rest in .env).
*
* Configure ONE of:
* GH_ISSUE_TOKEN=ghp_... (plain, .env is gitignored)
* GH_ISSUE_TOKEN_ENC=... + GH_ISSUE_TOKEN_KEY=... (AES-256-GCM, preferred)
*
* Generate encrypted value: php scripts/encrypt-gh-token.php 'ghp_xxx' 'your-secret-key'
*/
require_once __DIR__ . '/env.php';
function evershelfDecryptGhToken(string $encB64, string $key): string {
$raw = base64_decode($encB64, true);
if ($raw === false || strlen($raw) < 28) {
return '';
}
$iv = substr($raw, 0, 12);
$tag = substr($raw, 12, 16);
$cipher = substr($raw, 28);
$plain = openssl_decrypt(
$cipher,
'aes-256-gcm',
hash('sha256', $key, true),
OPENSSL_RAW_DATA,
$iv,
$tag
);
return ($plain !== false) ? $plain : '';
}
function evershelfEncryptGhToken(string $plain, string $key): string {
$iv = random_bytes(12);
$tag = '';
$cipher = openssl_encrypt(
$plain,
'aes-256-gcm',
hash('sha256', $key, true),
OPENSSL_RAW_DATA,
$iv,
$tag
);
return base64_encode($iv . $tag . $cipher);
}
/** Decode GitHub Issues token at runtime — never stored in source code. */
function _ghToken(): string {
static $token = null;
if ($token !== null) {
return $token;
}
$plain = env('GH_ISSUE_TOKEN');
if ($plain !== '') {
$token = $plain;
return $token;
}
$enc = env('GH_ISSUE_TOKEN_ENC');
$key = env('GH_ISSUE_TOKEN_KEY');
if ($enc !== '' && $key !== '') {
$token = evershelfDecryptGhToken($enc, $key);
return $token;
}
$token = '';
return $token;
}
+286
View File
@@ -0,0 +1,286 @@
<?php
/**
* EverShelf — authentication, CORS, demo mode, scale gateway allowlist.
*/
require_once __DIR__ . '/env.php';
/** Effective API token: API_TOKEN takes precedence over legacy SETTINGS_TOKEN. */
function evershelfEffectiveApiToken(): string {
$api = env('API_TOKEN');
if ($api !== '') {
return $api;
}
return env('SETTINGS_TOKEN', '');
}
function evershelfApiTokenRequired(): bool {
return evershelfEffectiveApiToken() !== '';
}
function evershelfGetProvidedApiToken(): string {
if (!empty($_SERVER['HTTP_X_API_TOKEN'])) {
return (string)$_SERVER['HTTP_X_API_TOKEN'];
}
if (!empty($_SERVER['HTTP_X_SETTINGS_TOKEN'])) {
return (string)$_SERVER['HTTP_X_SETTINGS_TOKEN'];
}
if (isset($_GET['api_token'])) {
return (string)$_GET['api_token'];
}
return evershelfGetProvidedApiTokenFromHeaders();
}
function evershelfApiTokenValid(): bool {
$required = evershelfEffectiveApiToken();
if ($required === '') {
return true;
}
$provided = evershelfGetProvidedApiToken();
return $provided !== '' && hash_equals($required, $provided);
}
function evershelfGetProvidedApiTokenFromHeaders(): string {
return (string)($_SERVER['HTTP_X_API_TOKEN'] ?? $_SERVER['HTTP_X_SETTINGS_TOKEN'] ?? '');
}
/** Actions reachable without API token (telemetry + public probes). */
function evershelfPublicActions(): array {
return [
'ping',
'app_bootstrap',
'check_update',
'report_error',
'report_bug',
'client_log',
'gdrive_oauth_callback',
];
}
/** GET actions that mutate state — require auth when token is configured. */
function evershelfMutatingGetActions(): array {
return ['db_cleanup', 'export_inventory'];
}
function evershelfDestructiveActions(): array {
return [
'save_settings', 'db_cleanup',
'backup_now', 'backup_delete', 'backup_restore',
'gdrive_push', 'gdrive_oauth_exchange',
'migrate_units',
];
}
function evershelfActionNeedsAuth(string $action, string $method): bool {
if (!evershelfApiTokenRequired()) {
return false;
}
if (in_array($action, evershelfPublicActions(), true)) {
return false;
}
if ($method === 'POST') {
return true;
}
if ($method === 'GET' && in_array($action, evershelfMutatingGetActions(), true)) {
return true;
}
if (in_array($action, ['get_logs', 'gemini_usage', 'get_client_log'], true)) {
return true;
}
if (in_array($action, evershelfDestructiveActions(), true)) {
return true;
}
// Protect all data reads when API token is set
return true;
}
function evershelfRequireApiAuth(string $action, string $method): void {
if (!evershelfActionNeedsAuth($action, $method)) {
return;
}
if (evershelfApiTokenValid()) {
return;
}
http_response_code(401);
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'success' => false,
'error' => 'unauthorized',
'api_token_required' => true,
]);
exit;
}
function evershelfRequireAuthForSensitive(string $action): void {
if (!evershelfApiTokenRequired()) {
return;
}
if (evershelfApiTokenValid()) {
return;
}
http_response_code(401);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['success' => false, 'error' => 'unauthorized', 'api_token_required' => true]);
exit;
}
function evershelfSendCorsHeaders(): void {
$configured = env('CORS_ORIGIN', '');
if ($configured === '') {
// Same-origin SPA — do not emit wildcard CORS
return;
}
if ($configured === '*') {
header('Access-Control-Allow-Origin: *');
} else {
$reqOrigin = $_SERVER['HTTP_ORIGIN'] ?? '';
$allowed = array_filter(array_map('trim', explode(',', $configured)));
if ($reqOrigin !== '' && in_array($reqOrigin, $allowed, true)) {
header('Access-Control-Allow-Origin: ' . $reqOrigin);
header('Vary: Origin');
}
}
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, X-EverShelf-Request, X-API-Token, X-Settings-Token');
}
/** Read-only actions allowed in DEMO_MODE. */
function evershelfDemoReadOnlyActions(): array {
return [
'ping', 'check_update', 'health_check', 'get_settings', 'gemini_usage',
'search_barcode', 'lookup_barcode', 'stock_for_name',
'product_get', 'products_list', 'products_search', 'inventory_search',
'inventory_list', 'inventory_summary', 'inventory_finished_items',
'transactions_list', 'stats', 'monthly_stats', 'macro_stats',
'consumption_predictions', 'inventory_anomalies', 'inventory_duplicate_loss_checks',
'recent_popular_products', 'expiry_history', 'food_facts', 'opened_shelf_life',
'bring_list', 'bring_suggest', 'shopping_list', 'shopping_suggest', 'smart_shopping',
'recipes_list', 'chat_list', 'app_settings_get',
'ha_sensor', 'ha_info', 'ha_shopping_items', 'ha_test', 'ha_calendar',
'guess_category', 'get_shopping_price', 'get_all_shopping_prices',
'backup_list', 'export_inventory',
];
}
function evershelfDemoBlocksAction(string $action, string $method): bool {
if (env('DEMO_MODE') !== 'true') {
return false;
}
if (in_array($action, evershelfDemoReadOnlyActions(), true)) {
return false;
}
// Block all AI generation in demo (cost + writes)
if (str_starts_with($action, 'gemini_') || in_array($action, [
'generate_recipe', 'generate_recipe_stream', 'chat_to_recipe', 'recipe_from_ingredient',
], true)) {
return true;
}
if ($method === 'POST') {
return true;
}
if (in_array($action, evershelfMutatingGetActions(), true)) {
return true;
}
return !in_array($action, evershelfDemoReadOnlyActions(), true);
}
/** Hosts allowed for scale WebSocket relay (SSRF guard). */
function evershelfAllowedScaleHosts(): array {
$hosts = ['127.0.0.1', 'localhost', '::1'];
$gw = env('SCALE_GATEWAY_URL', '');
if ($gw !== '') {
$p = parse_url($gw);
if (!empty($p['host'])) {
$hosts[] = strtolower($p['host']);
}
}
// Server's own LAN IP — gateway may bind here on kiosk LAN
if (function_exists('gethostname')) {
$lan = gethostbyname(gethostname());
if ($lan && filter_var($lan, FILTER_VALIDATE_IP)) {
$hosts[] = $lan;
}
}
return array_values(array_unique($hosts));
}
function evershelfScaleHostAllowed(string $host): bool {
$host = strtolower(trim($host));
if ($host === '') {
return false;
}
foreach (evershelfAllowedScaleHosts() as $allowed) {
if ($host === strtolower($allowed)) {
return true;
}
}
// Allow private /24 only when host matches server's subnet (kiosk on same LAN)
$serverIp = evershelfLocalLanIp();
if ($serverIp !== '') {
$subnet = implode('.', array_slice(explode('.', $serverIp), 0, 3));
if (str_starts_with($host, $subnet . '.')) {
return true;
}
}
return false;
}
function evershelfLocalLanIp(): string {
$sock = @socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
if ($sock) {
@socket_connect($sock, '8.8.8.8', 53);
@socket_getsockname($sock, $ip);
socket_close($sock);
if (isset($ip) && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return $ip;
}
}
return '';
}
/**
* True when the request comes from the EverShelf web UI on the same host.
* Used to auto-provision API_TOKEN to the browser without manual .env copy.
*/
function evershelfIsSameOriginBrowser(): bool {
$host = strtolower(explode(':', $_SERVER['HTTP_HOST'] ?? '')[0]);
if ($host === '') {
return false;
}
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if ($origin !== '') {
$oh = parse_url($origin, PHP_URL_HOST);
return $oh && strtolower($oh) === $host;
}
$referer = $_SERVER['HTTP_REFERER'] ?? '';
if ($referer !== '') {
$rh = parse_url($referer, PHP_URL_HOST);
return $rh && strtolower($rh) === $host;
}
$fetchSite = $_SERVER['HTTP_SEC_FETCH_SITE'] ?? '';
if (in_array($fetchSite, ['same-origin', 'same-site'], true)) {
return true;
}
return false;
}
/** Auth for scale endpoints — EventSource cannot send headers; allow query token or same-origin UI. */
function evershelfRequireScaleAccess(): void {
if (!evershelfApiTokenRequired()) {
return;
}
if (evershelfApiTokenValid()) {
return;
}
if (evershelfIsSameOriginBrowser()) {
return;
}
http_response_code(401);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'unauthorized', 'api_token_required' => true]);
exit;
}
+56 -51
View File
@@ -1,57 +1,53 @@
<?php
/**
* EverShelf Scale Gateway — Auto-discovery
*
* Scans the server's local /24 subnet for any host responding on the gateway
* port (default 8765) and confirms it with a WebSocket handshake.
*
* Returns: {"found": ["ws://192.168.1.100:8765", ...]}
* EverShelf Scale Gateway — Auto-discovery (auth + rate limit + LAN only).
*/
require_once __DIR__ . '/lib/env.php';
require_once __DIR__ . '/lib/security.php';
header('Content-Type: application/json');
header('Cache-Control: no-cache');
evershelfSendCorsHeaders();
$port = (int)($_GET['port'] ?? 8765);
if ($port < 1 || $port > 65535) $port = 8765;
// ── Determine server LAN IP ────────────────────────────────────────────────
// SERVER_ADDR may be 127.0.0.1 when accessed via internal vhost — fall back
// to a UDP trick (no actual packet sent) to find the default-route interface IP.
function localLanIp(): string {
$sock = @socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
if ($sock) {
@socket_connect($sock, '8.8.8.8', 53);
@socket_getsockname($sock, $ip);
socket_close($sock);
if (isset($ip) && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) return $ip;
}
// Fallback: parse /proc/net/route for default gateway interface then ip neigh
$ifaces = @net_get_interfaces();
if ($ifaces) {
foreach ($ifaces as $name => $info) {
if ($name === 'lo') continue;
foreach ($info['unicast'] ?? [] as $u) {
$ip = $u['address'] ?? '';
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE)) continue;
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) return $ip;
}
}
}
return '';
if (evershelfApiTokenRequired() && !evershelfApiTokenValid() && !evershelfIsSameOriginBrowser()) {
http_response_code(401);
echo json_encode(['error' => 'unauthorized', 'api_token_required' => true]);
exit;
}
$serverIp = localLanIp();
// Simple rate limit: max 6 scans per minute per IP
$rlDir = dirname(__DIR__) . '/data/rate_limits';
if (!is_dir($rlDir)) {
@mkdir($rlDir, 0755, true);
}
$rlFile = $rlDir . '/scale_discover_' . md5($_SERVER['REMOTE_ADDR'] ?? 'cli') . '.json';
$now = time();
$hits = [];
if (file_exists($rlFile)) {
$hits = array_filter(json_decode(file_get_contents($rlFile), true) ?: [], fn($t) => $t > $now - 60);
}
if (count($hits) >= 6) {
http_response_code(429);
echo json_encode(['error' => 'Too many discovery scans']);
exit;
}
$hits[] = $now;
@file_put_contents($rlFile, json_encode($hits), LOCK_EX);
$port = (int)($_GET['port'] ?? 8765);
if ($port < 1 || $port > 65535) {
$port = 8765;
}
$serverIp = evershelfLocalLanIp();
$parts = explode('.', $serverIp);
if (count($parts) !== 4) {
echo json_encode(['error' => 'Cannot determine local subnet', 'server_ip' => $serverIp]);
echo json_encode(['error' => 'Cannot determine local subnet', 'found' => []]);
exit;
}
$subnet = $parts[0] . '.' . $parts[1] . '.' . $parts[2] . '.';
// ── Phase 1: Async TCP connect to all 254 hosts ────────────────────────────
// Non-blocking stream_socket_client + stream_select to detect open ports quickly.
// Total scan budget: 1.5 seconds.
$candidates = [];
for ($i = 1; $i <= 254; $i++) {
$ip = $subnet . $i;
@@ -74,25 +70,28 @@ while (!empty($candidates) && microtime(true) < $deadline) {
$read = null;
$usec = (int)(max(0, $deadline - microtime(true)) * 1_000_000);
$n = @stream_select($read, $write, $except, 0, $usec);
if ($n === false || $n === 0) break;
if ($n === false || $n === 0) {
break;
}
// Sockets in $except = connection refused/error
$failed = [];
foreach ($except as $s) {
$ip = array_search($s, $candidates, true);
if ($ip !== false) $failed[$ip] = true;
if ($ip !== false) {
$failed[$ip] = true;
}
}
// Sockets in $write = connection complete (may overlap with $except on error)
foreach ($write as $s) {
$ip = array_search($s, $candidates, true);
if ($ip === false) continue;
if ($ip === false) {
continue;
}
if (!isset($failed[$ip])) {
$found_tcp[] = $ip;
}
@fclose($s);
unset($candidates[$ip]);
}
// Close failed sockets too
foreach ($failed as $ip => $_) {
if (isset($candidates[$ip])) {
@fclose($candidates[$ip]);
@@ -100,13 +99,16 @@ while (!empty($candidates) && microtime(true) < $deadline) {
}
}
}
foreach ($candidates as $s) @fclose($s); // close remaining (timeout)
foreach ($candidates as $s) {
@fclose($s);
}
// ── Phase 2: WebSocket handshake to confirm each TCP responder ─────────────
$gateways = [];
foreach ($found_tcp as $ip) {
$sock = @stream_socket_client("tcp://{$ip}:{$port}", $errno, $errstr, 2);
if (!$sock) continue;
if (!$sock) {
continue;
}
stream_set_timeout($sock, 2);
$key = base64_encode(random_bytes(16));
@@ -124,9 +126,13 @@ foreach ($found_tcp as $ip) {
$dl = microtime(true) + 2;
while (microtime(true) < $dl && !feof($sock)) {
$line = fgets($sock, 256);
if ($line === false) break;
if ($line === false) {
break;
}
$resp .= $line;
if ($line === "\r\n") break;
if ($line === "\r\n") {
break;
}
}
fclose($sock);
@@ -138,5 +144,4 @@ foreach ($found_tcp as $ip) {
echo json_encode([
'found' => $gateways,
'subnet' => rtrim($subnet, '.') . '.0/24',
'server_ip' => $serverIp,
]);
+16 -7
View File
@@ -1,16 +1,20 @@
<?php
/**
* EverShelf Scale Gateway — Connection ping / test
*
* Performs a WebSocket handshake with the gateway and returns
* {"ok":true} on success, {"ok":false,"error":"..."} on failure.
*
* Usage: GET /api/scale_ping.php?url=ws%3A%2F%2F192.168.1.100%3A8765
* EverShelf Scale Gateway — Connection ping / test (SSRF-hardened)
*/
require_once __DIR__ . '/lib/env.php';
require_once __DIR__ . '/lib/security.php';
header('Content-Type: application/json');
header('Cache-Control: no-cache');
if (evershelfApiTokenRequired() && !evershelfApiTokenValid() && !evershelfIsSameOriginBrowser()) {
http_response_code(401);
echo json_encode(['ok' => false, 'error' => 'unauthorized', 'api_token_required' => true]);
exit;
}
$rawUrl = $_GET['url'] ?? '';
if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
@@ -19,7 +23,7 @@ if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
}
$parsed = parse_url($rawUrl);
$host = $parsed['host'] ?? '';
$host = strtolower($parsed['host'] ?? '');
$port = (int)($parsed['port'] ?? 8765);
$path = ($parsed['path'] ?? '') ?: '/';
@@ -28,6 +32,11 @@ if (!$host || $port < 1 || $port > 65535) {
exit;
}
if (!evershelfScaleHostAllowed($host)) {
echo json_encode(['ok' => false, 'error' => 'Gateway host not allowed']);
exit;
}
// Try to open a TCP connection with a 5-second timeout
$sock = @stream_socket_client("tcp://{$host}:{$port}", $errno, $errstr, 5);
if (!$sock) {
+17 -1
View File
@@ -8,6 +8,16 @@
* Usage: GET /api/scale_relay.php?url=ws%3A%2F%2F192.168.1.100%3A8765
*/
require_once __DIR__ . '/lib/env.php';
require_once __DIR__ . '/lib/security.php';
if (evershelfApiTokenRequired() && !evershelfApiTokenValid() && !evershelfIsSameOriginBrowser()) {
header('Content-Type: application/json; charset=utf-8');
http_response_code(401);
echo json_encode(['error' => 'unauthorized', 'api_token_required' => true]);
exit;
}
// ── Input validation ──────────────────────────────────────────────────────────
$rawUrl = $_GET['url'] ?? '';
@@ -19,7 +29,7 @@ if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
}
$parsed = parse_url($rawUrl);
$wsHost = $parsed['host'] ?? '';
$wsHost = strtolower($parsed['host'] ?? '');
$wsPort = (int)($parsed['port'] ?? 8765);
$wsPath = ($parsed['path'] ?? '') ?: '/';
@@ -29,6 +39,12 @@ if (!$wsHost || $wsPort < 1 || $wsPort > 65535) {
exit;
}
if (!evershelfScaleHostAllowed($wsHost)) {
header('Content-Type: text/event-stream');
echo 'data: ' . json_encode(['type' => 'error', 'message' => 'Gateway host not allowed']) . "\n\n";
exit;
}
// ── SSE headers ───────────────────────────────────────────────────────────────
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache, no-store, must-revalidate');
+272
View File
@@ -2009,6 +2009,59 @@ body.server-offline .bottom-nav {
.scan-status-msg.state-confirmed { color: #4ade80; background: rgba(74,222,128,0.22); }
.scan-status-msg.state-retry { color: #fb923c; }
/* — AI processing overlay (full-viewport, shown during Gemini Vision call) — */
.scan-ai-overlay {
position: absolute;
inset: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.72);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
border-radius: var(--radius);
}
.scan-ai-overlay-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 24px 28px;
background: rgba(255,255,255,0.07);
border: 1.5px solid rgba(255,255,255,0.18);
border-radius: 16px;
}
.scan-ai-overlay-label {
font-size: 0.65rem;
color: rgba(255,255,255,0.5);
text-transform: uppercase;
letter-spacing: 0.1em;
font-family: monospace;
}
.scan-ai-overlay-msg {
font-size: 0.88rem;
color: #fff;
text-align: center;
max-width: 220px;
}
/* — AI retry button (shown below scanner after visual ID fails) — */
.scan-ai-retry-btn {
width: 100%;
margin-top: 10px;
font-size: 0.95rem;
padding: 12px;
border-radius: var(--radius);
border: 2px solid var(--accent);
background: rgba(124,58,237,0.1);
color: var(--accent);
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.scan-ai-retry-btn:active { background: rgba(124,58,237,0.22); }
/* — Viewport overlay controls (torch / zoom / flip) — */
.scan-viewport-controls {
position: absolute;
@@ -2059,6 +2112,118 @@ body.server-offline .bottom-nav {
box-shadow: var(--shadow);
}
.scan-ai-match-box {
display: flex;
flex-direction: column;
gap: 12px;
}
.scan-ai-match-head {
display: flex;
flex-direction: column;
gap: 4px;
}
.scan-ai-match-title {
font-size: 1rem;
font-weight: 700;
color: var(--text);
}
.scan-ai-match-subtitle {
font-size: 0.82rem;
color: var(--text-muted);
}
.scan-ai-match-list-wrap {
display: flex;
flex-direction: column;
gap: 8px;
}
.scan-ai-match-list-title {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
font-weight: 700;
}
.scan-ai-match-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.scan-ai-candidate-item {
border: 1px solid var(--border);
background: var(--bg-main);
border-radius: 12px;
padding: 10px;
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
text-align: left;
}
.scan-ai-candidate-item:active { transform: scale(0.99); }
.scan-ai-candidate-icon {
font-size: 1.3rem;
flex-shrink: 0;
}
.scan-ai-candidate-info {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.scan-ai-candidate-name {
font-size: 0.9rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.scan-ai-candidate-meta {
font-size: 0.76rem;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.scan-ai-candidate-cta {
font-size: 0.74rem;
color: var(--accent);
border: 1px solid var(--accent);
border-radius: 999px;
padding: 3px 8px;
flex-shrink: 0;
}
.scan-ai-match-empty {
font-size: 0.86rem;
color: var(--text-muted);
background: var(--bg-main);
border: 1px dashed var(--border);
border-radius: 10px;
padding: 10px 12px;
}
.scan-ai-add-btn {
width: 100%;
}
.scan-ai-detected-label {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted);
}
.scan-ai-detected-pill {
font-size: 0.8rem;
color: var(--text-muted);
background: var(--bg-main);
border-radius: 999px;
border: 1px solid var(--border);
padding: 6px 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* — Recent scans — */
.scan-recents {
display: flex;
@@ -4295,6 +4460,93 @@ body.server-offline .bottom-nav {
line-height: 1.5;
}
/* ===== RECIPE NUTRITION BLOCK ===== */
.recipe-nutrition-block {
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: var(--radius-sm);
padding: 12px 14px 8px;
margin-top: 16px;
}
.recipe-section-heading {
font-size: 0.85rem;
font-weight: 700;
color: #15803d;
margin: 0 0 10px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.recipe-nutrition-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
text-align: center;
}
.recipe-nutrition-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.recipe-nutrition-icon { font-size: 1.2rem; }
.recipe-nutrition-value {
font-size: 0.95rem;
font-weight: 700;
color: #15803d;
}
.recipe-nutrition-label {
font-size: 0.65rem;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.recipe-nutrition-note {
font-size: 0.7rem;
color: #94a3b8;
text-align: center;
margin: 6px 0 0;
}
.recipe-nutrition-footnote {
color: var(--text-muted);
font-size: 0.85rem;
margin-top: 12px;
}
/* ===== RECIPE STORAGE CARD ===== */
.recipe-storage-card {
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: var(--radius-sm);
padding: 12px 14px 8px;
margin-top: 12px;
}
.recipe-storage-card .recipe-section-heading { color: #b45309; }
.recipe-storage-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 6px;
}
.recipe-storage-badge {
background: #fef3c7;
border: 1px solid #fcd34d;
border-radius: 20px;
padding: 2px 12px;
font-size: 0.8rem;
font-weight: 600;
color: #92400e;
white-space: nowrap;
text-transform: capitalize;
}
.recipe-storage-days { background: #dbeafe; border-color: #93c5fd; color: #1d4ed8; }
.recipe-storage-now { background: #fee2e2; border-color: #fca5a5; color: #b91c1c; }
.recipe-storage-tips {
font-size: 0.82rem;
color: #78350f;
margin: 2px 0 0;
line-height: 1.4;
}
.recipe-tools-banner {
display: flex;
flex-wrap: wrap;
@@ -5939,6 +6191,12 @@ body.cooking-mode-active .app-header {
}
.banner-anomaly .alert-banner-title { color: #9a3412; }
.banner-anomaly .alert-banner-counter .banner-dot.active { background: #ea580c; }
.alert-banner.banner-dup-loss {
background: linear-gradient(135deg, #fef2f2 0%, #fecaca 100%);
border-color: #dc2626;
}
.banner-dup-loss .alert-banner-title { color: #991b1b; }
.banner-dup-loss .alert-banner-counter .banner-dot.active { background: #dc2626; }
.alert-banner.banner-no-expiry {
background: linear-gradient(135deg, #f0fdf4 0%, #bbf7d0 100%);
border-color: #16a34a;
@@ -7838,6 +8096,8 @@ body.cooking-mode-active .app-header {
[data-theme="dark"] .banner-prediction .alert-banner-counter { color: #a78bfa; }
[data-theme="dark"] .alert-banner.banner-anomaly { background: #1a1200; border-color: #c2410c; }
[data-theme="dark"] .banner-anomaly .alert-banner-title { color: #fdba74; }
[data-theme="dark"] .alert-banner.banner-dup-loss { background: #2a0808; border-color: #dc2626; }
[data-theme="dark"] .banner-dup-loss .alert-banner-title { color: #fca5a5; }
[data-theme="dark"] .alert-banner.banner-no-expiry { background: #0f2a1a; border-color: #166534; }
[data-theme="dark"] .banner-no-expiry .alert-banner-title { color: #86efac; }
@@ -7908,6 +8168,18 @@ body.cooking-mode-active .app-header {
/* ── Recipe components ── */
[data-theme="dark"] .recipe-expiry-note { background: #2a1e00; color: #fde68a; }
[data-theme="dark"] .recipe-nutrition-block { background: #052e16; border-color: #166534; }
[data-theme="dark"] .recipe-section-heading { color: #4ade80; }
[data-theme="dark"] .recipe-storage-card .recipe-section-heading { color: #fbbf24; }
[data-theme="dark"] .recipe-nutrition-value { color: #4ade80; }
[data-theme="dark"] .recipe-nutrition-label { color: #94a3b8; }
[data-theme="dark"] .recipe-nutrition-note { color: #64748b; }
[data-theme="dark"] .recipe-nutrition-footnote { color: var(--text-muted); }
[data-theme="dark"] .recipe-storage-card { background: #1c1400; border-color: #78350f; }
[data-theme="dark"] .recipe-storage-badge { background: #2a1e00; border-color: #92400e; color: #fde68a; }
[data-theme="dark"] .recipe-storage-days { background: #0c1a2e; border-color: #1d4ed8; color: #93c5fd; }
[data-theme="dark"] .recipe-storage-now { background: #2a0a0a; border-color: #b91c1c; color: #fca5a5; }
[data-theme="dark"] .recipe-storage-tips { color: #fde68a; }
[data-theme="dark"] .recipe-tools-banner { background: #1a1040; border-color: #3730a3; color: #c4b5fd; }
[data-theme="dark"] .recipe-tool-chip { background: #2e1a4a; color: #c4b5fd; }
[data-theme="dark"] .recipe-step-appliance { background: #052e16; border-color: #166534; color: #4ade80; }
+123 -40
View File
@@ -317,12 +317,17 @@ function scaleInit() {
_scaleConnect(s.scale_gateway_url);
}
function _scaleAuthQuery() {
const tok = typeof getApiToken === 'function' ? getApiToken() : '';
return tok ? '&api_token=' + encodeURIComponent(tok) : '';
}
function _scaleConnect(url) {
if (_scaleEs) { try { _scaleEs.close(); } catch(e) {} _scaleEs = null; }
if (_scaleReconnectTimer) { clearTimeout(_scaleReconnectTimer); _scaleReconnectTimer = null; }
try {
// Connect via the PHP SSE relay so the HTTPS page is not blocked by mixed-content
_scaleEs = new EventSource('api/scale_relay.php?url=' + encodeURIComponent(url));
// EventSource cannot send custom headers — pass api_token in query string
_scaleEs = new EventSource('api/scale_relay.php?url=' + encodeURIComponent(url) + _scaleAuthQuery());
_scaleEs.onopen = () => _scaleUpdateStatus('searching');
_scaleEs.onmessage = (evt) => {
try { _scaleOnMessage(JSON.parse(evt.data)); } catch(e) {}
@@ -1041,7 +1046,7 @@ function testScaleConnection() {
statusEl.textContent = '❌ ' + t('scale.timeout');
statusEl.className = 'settings-status error';
}, 8000);
fetch('api/scale_ping.php?url=' + encodeURIComponent(url), { signal: ac.signal })
fetch('api/scale_ping.php?url=' + encodeURIComponent(url) + _scaleAuthQuery(), { signal: ac.signal })
.then(r => r.json())
.then(data => {
clearTimeout(timeout);
@@ -1073,7 +1078,7 @@ async function discoverScaleGateway() {
status.textContent = '🔍 Scanning local network for scale gateway…';
try {
const res = await fetch('api/scale_discover.php', { signal: AbortSignal.timeout(8000) });
const res = await fetch('api/scale_discover.php', { signal: AbortSignal.timeout(8000), headers: { ...(typeof apiAuthHeaders === 'function' ? apiAuthHeaders() : {}) } });
const data = await res.json();
if (data.error) {
@@ -1271,8 +1276,8 @@ function _setThemeMode(mode) {
// Persist dark_mode to server .env immediately (no need to send the full
// settings payload — save_settings only updates keys present in the body
// and keeps all other .env values intact).
const token = document.getElementById('setting-settings-token')?.value.trim() || '';
const headers = token ? { 'X-Settings-Token': token } : {};
const token = document.getElementById('setting-settings-token')?.value.trim() || (typeof getApiToken === 'function' ? getApiToken() : '');
const headers = token ? { 'X-API-Token': token } : {};
api('save_settings', {}, 'POST', { dark_mode: mode }, headers).catch(() => {});
}
@@ -1284,7 +1289,9 @@ setInterval(() => {
// ===== EXPORT INVENTORY =====
function exportInventory(format) {
const url = `api/index.php?action=export_inventory&format=${encodeURIComponent(format)}&_t=${Date.now()}`;
const tok = typeof getApiToken === 'function' ? getApiToken() : '';
const tokParam = tok ? `&api_token=${encodeURIComponent(tok)}` : '';
const url = `api/index.php?action=export_inventory&format=${encodeURIComponent(format)}&_t=${Date.now()}${tokParam}`;
if (format === 'csv') {
// Direct download via <a> trick
const a = document.createElement('a');
@@ -3219,9 +3226,13 @@ async function loadSettingsUI() {
'ha_enabled','ha_url','ha_tts_entity','ha_webhook_id','ha_webhook_events',
'ha_notify_service','ha_expiry_days'];
// Note: gemini_key is never sent from server; settings_token_set is metadata only
const settingsTokenRequired = !!serverSettings.settings_token_set;
const settingsTokenRequired = !!(serverSettings.api_token_required || serverSettings.settings_token_set);
const tokenHintEl = document.getElementById('settings-token-status-hint');
if (tokenHintEl) tokenHintEl.style.display = settingsTokenRequired ? 'block' : 'none';
if (settingsTokenRequired && typeof setApiToken === 'function') {
const fieldTok = document.getElementById('setting-settings-token')?.value.trim();
if (fieldTok) setApiToken(fieldTok);
}
let changed = false;
for (const key of serverKeys) {
if (serverSettings[key] !== undefined && serverSettings[key] !== null && serverSettings[key] !== '') {
@@ -3797,8 +3808,9 @@ async function saveSettings() {
// Save ALL settings to server .env
try {
const settingsToken = document.getElementById('setting-settings-token')?.value.trim() || '';
const tokenHeader = settingsToken ? { 'X-Settings-Token': settingsToken } : {};
const settingsToken = document.getElementById('setting-settings-token')?.value.trim() || (typeof getApiToken === 'function' ? getApiToken() : '');
if (settingsToken && typeof setApiToken === 'function') setApiToken(settingsToken);
const tokenHeader = settingsToken ? { 'X-API-Token': settingsToken } : (typeof apiAuthHeaders === 'function' ? apiAuthHeaders() : {});
const result = await api('save_settings', {}, 'POST', {
...(s.gemini_key ? { gemini_key: s.gemini_key } : {}),
bring_email: s.bring_email,
@@ -3944,11 +3956,12 @@ async function api(action, params = {}, method = 'GET', body = null, extraHeader
});
}
const opts = { method, cache: 'no-store' };
const authHdrs = typeof apiAuthHeaders === 'function' ? apiAuthHeaders() : {};
if (body) {
opts.headers = { 'Content-Type': 'application/json', 'X-EverShelf-Request': '1', ...extraHeaders };
opts.headers = { 'Content-Type': 'application/json', 'X-EverShelf-Request': '1', ...authHdrs, ...extraHeaders };
opts.body = JSON.stringify(body);
} else if (Object.keys(extraHeaders).length > 0) {
opts.headers = { ...extraHeaders };
} else {
opts.headers = { ...authHdrs, ...extraHeaders };
}
let res;
try {
@@ -3966,6 +3979,10 @@ async function api(action, params = {}, method = 'GET', body = null, extraHeader
}
if (!res.ok) {
remoteLog('API_ERROR', `${action} HTTP ${res.status}`);
if (res.status === 401) {
window._apiTokenRequired = true;
if (typeof _promptApiTokenIfNeeded === 'function') _promptApiTokenIfNeeded();
}
// Report HTTP 5xx as server errors (not 4xx which are usually user errors)
if (res.status >= 500) {
reportError({
@@ -3981,6 +3998,10 @@ async function api(action, params = {}, method = 'GET', body = null, extraHeader
_offlineCacheSet(data.inventory);
}
if (action === 'get_settings' && data && data.success !== false) {
window._apiTokenRequired = !!data.api_token_required;
if (data.api_token_required && typeof _promptApiTokenIfNeeded === 'function') {
_promptApiTokenIfNeeded();
}
_offlineCacheSetSettings(data);
}
if (data && data.error) {
@@ -12821,13 +12842,6 @@ async function analyzeExpiryImage(dataUrl) {
}
}
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function stripHtml(str) {
if (!str) return '';
return str.replace(/<[^>]*>/g, '');
@@ -13952,7 +13966,7 @@ function renderRecipe(r) {
const isFav = !!(_cachedRecipe && _cachedRecipe.is_favorite);
let html = `<h2>${r.title}</h2>`;
let html = `<h2>${escapeHtml(r.title)}</h2>`;
// Meta tags + star (#124) + persons rescaler (#123)
html += '<div class="recipe-meta">';
@@ -13964,7 +13978,7 @@ function renderRecipe(r) {
</span>`;
if (r.prep_time) html += `<span class="recipe-tag">🔪 ${r.prep_time}</span>`;
if (r.cook_time) html += `<span class="recipe-tag">🔥 ${r.cook_time}</span>`;
if (r.tags) r.tags.forEach(t => { html += `<span class="recipe-tag">${t}</span>`; });
if (r.tags) r.tags.forEach(tag => { html += `<span class="recipe-tag">${escapeHtml(tag)}</span>`; });
// Favorite star button (#124) — visible only for archived recipes (have an id)
if (_cachedRecipe && _cachedRecipe.id) {
html += `<button class="btn-recipe-fav${isFav ? ' active' : ''}" onclick="toggleRecipeFavorite(this)" title="${isFav ? t('recipes.unfavorite') : t('recipes.favorite')}">${isFav ? '★' : '☆'}</button>`;
@@ -13973,7 +13987,7 @@ function renderRecipe(r) {
// Expiry note
if (r.expiry_note) {
html += `<div class="recipe-expiry-note">⚠️ ${r.expiry_note}</div>`;
html += `<div class="recipe-expiry-note">⚠️ ${escapeHtml(r.expiry_note)}</div>`;
}
// Tools/appliances banner (shown only when specific equipment is needed)
@@ -13981,7 +13995,7 @@ function renderRecipe(r) {
? r.tools_needed.filter(t => t && t.trim())
: _extractToolsFromSteps(r.steps);
if (tools.length > 0) {
html += `<div class="recipe-tools-banner">🔧 <strong>${t('recipes.tools_title')}:</strong> ${tools.map(t => `<span class="recipe-tool-chip">${t}</span>`).join('')}</div>`;
html += `<div class="recipe-tools-banner">🔧 <strong>${escapeHtml(t('recipes.tools_title'))}:</strong> ${tools.map(tool => `<span class="recipe-tool-chip">${escapeHtml(tool)}</span>`).join('')}</div>`;
}
// Ingredients
@@ -13991,8 +14005,8 @@ function renderRecipe(r) {
const qtyNum = Math.round((ing.qty_number || 0) * 10) / 10;
const loc = (ing.location || 'dispensa').replace(/'/g, "\\'");
const alreadyUsed = ing.used === true;
html += `<li class="recipe-ingredient${alreadyUsed ? ' recipe-ing-used' : ''}" id="recipe-ing-${idx}" data-base-qty="${ing.qty_number || 0}" data-base-qty-str="${(ing.qty || '').replace(/"/g, '&quot;')}">`;
html += `<span class="recipe-ing-text"><strong class="recipe-ing-name" onclick="openIngredientDetail(${ing.product_id}, '${loc}')" title="${t('action.edit') || 'Modifica'}">${ing.name}</strong>${ing.brand ? ' <em>(' + ing.brand + ')</em>' : ''}: <span class="recipe-ing-qty">${ing.qty}</span> ✅`;
html += `<li class="recipe-ingredient${alreadyUsed ? ' recipe-ing-used' : ''}" id="recipe-ing-${idx}" data-base-qty="${ing.qty_number || 0}" data-base-qty-str="${escapeHtml(ing.qty || '')}">`;
html += `<span class="recipe-ing-text"><strong class="recipe-ing-name" onclick="openIngredientDetail(${ing.product_id}, '${loc}')" title="${escapeHtml(t('action.edit') || 'Modifica')}">${escapeHtml(ing.name)}</strong>${ing.brand ? ' <em>(' + escapeHtml(ing.brand) + ')</em>' : ''}: <span class="recipe-ing-qty">${escapeHtml(ing.qty)}</span> ✅`;
// Detail line: location + expiry
let details = [];
const ingredientLocLabels = Object.fromEntries(Object.entries(LOCATIONS).map(([k,v]) => [k, `${v.icon} ${v.label}`]));
@@ -14016,7 +14030,7 @@ function renderRecipe(r) {
html += `</li>`;
} else {
const pantryIcon = ing.from_pantry ? ' ✅' : ' 🛒';
html += `<li class="recipe-ingredient" data-base-qty="${ing.qty_number || 0}" data-base-qty-str="${(ing.qty || '').replace(/"/g, '&quot;')}"><span class="recipe-ing-text"><strong>${ing.name}</strong>: <span class="recipe-ing-qty">${ing.qty}</span>${pantryIcon}</span></li>`;
html += `<li class="recipe-ingredient" data-base-qty="${ing.qty_number || 0}" data-base-qty-str="${escapeHtml(ing.qty || '')}"><span class="recipe-ing-text"><strong>${escapeHtml(ing.name)}</strong>: <span class="recipe-ing-qty">${escapeHtml(ing.qty)}</span>${pantryIcon}</span></li>`;
}
});
html += '</ul>';
@@ -14028,13 +14042,60 @@ function renderRecipe(r) {
html += `<h3>${t('recipes.steps_title')}</h3><ol>`;
(r.steps || []).forEach(step => {
const appliance = _stepAppliance(step);
html += `<li>${_stepStr(step)}${appliance ? ` <span class="recipe-step-appliance">${appliance}</span>` : ''}</li>`;
html += `<li>${escapeHtml(_stepStr(step))}${appliance ? ` <span class="recipe-step-appliance">${escapeHtml(appliance)}</span>` : ''}</li>`;
});
html += '</ol>';
// Nutrition note
// Nutritional values grid
if (r.nutrition && (r.nutrition.kcal || r.nutrition.protein_g || r.nutrition.carbs_g || r.nutrition.fat_g)) {
const n = r.nutrition;
html += `<div class="recipe-nutrition-block">
<h4 class="recipe-section-heading">📊 ${t('recipes.nutrition_title')}</h4>
<div class="recipe-nutrition-grid">
<div class="recipe-nutrition-item">
<span class="recipe-nutrition-icon">🔥</span>
<span class="recipe-nutrition-value">${n.kcal ?? '—'}</span>
<span class="recipe-nutrition-label">${t('recipes.nutrition_kcal')}</span>
</div>
<div class="recipe-nutrition-item">
<span class="recipe-nutrition-icon">🥩</span>
<span class="recipe-nutrition-value">${n.protein_g ?? '—'} g</span>
<span class="recipe-nutrition-label">${t('recipes.nutrition_protein')}</span>
</div>
<div class="recipe-nutrition-item">
<span class="recipe-nutrition-icon">🍞</span>
<span class="recipe-nutrition-value">${n.carbs_g ?? '—'} g</span>
<span class="recipe-nutrition-label">${t('recipes.nutrition_carbs')}</span>
</div>
<div class="recipe-nutrition-item">
<span class="recipe-nutrition-icon">🫒</span>
<span class="recipe-nutrition-value">${n.fat_g ?? '—'} g</span>
<span class="recipe-nutrition-label">${t('recipes.nutrition_fat')}</span>
</div>
</div>
<p class="recipe-nutrition-note">${t('recipes.nutrition_per_serving')}</p>
</div>`;
}
// Storage info
if (r.storage && (r.storage.where || r.storage.tips)) {
const s = r.storage;
const daysLabel = s.days > 0
? t('recipes.storage_days').replace('{n}', s.days)
: t('recipes.storage_immediately');
html += `<div class="recipe-storage-card">
<h4 class="recipe-section-heading">📦 ${t('recipes.storage_title')}</h4>
<div class="recipe-storage-row">
${s.where ? `<span class="recipe-storage-badge">${escapeHtml(s.where)}</span>` : ''}
${s.days > 0 ? `<span class="recipe-storage-badge recipe-storage-days">${escapeHtml(daysLabel)}</span>` : `<span class="recipe-storage-badge recipe-storage-now">${escapeHtml(daysLabel)}</span>`}
</div>
${s.tips ? `<p class="recipe-storage-tips">${escapeHtml(s.tips)}</p>` : ''}
</div>`;
}
// Nutrition note (legacy / AI extra note)
if (r.nutrition_note) {
html += `<p style="color:var(--text-muted);font-size:0.85rem;margin-top:12px">💡 ${r.nutrition_note}</p>`;
html += `<p class="recipe-nutrition-footnote">💡 ${escapeHtml(r.nutrition_note)}</p>`;
}
document.getElementById('recipe-content').innerHTML = html;
@@ -14453,10 +14514,7 @@ function _buildTtsRequest(text, s) {
function _buildHaTtsRequest(text, s) {
const haUrl = (s.ha_url || '').replace(/\/$/, '');
const url = haUrl + '/api/services/tts/speak';
const headers = {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + (s.ha_token || ''),
};
const headers = { 'Content-Type': 'application/json' };
const body = JSON.stringify({
entity_id: s.ha_tts_entity || '',
message: text,
@@ -14739,8 +14797,9 @@ async function saveHaSettings() {
const statusEl = document.getElementById('ha-save-status');
try {
const settingsToken = document.getElementById('setting-settings-token')?.value.trim() || '';
const tokenHeader = settingsToken ? { 'X-Settings-Token': settingsToken } : {};
const settingsToken = document.getElementById('setting-settings-token')?.value.trim() || (typeof getApiToken === 'function' ? getApiToken() : '');
if (settingsToken && typeof setApiToken === 'function') setApiToken(settingsToken);
const tokenHeader = settingsToken ? { 'X-API-Token': settingsToken } : (typeof apiAuthHeaders === 'function' ? apiAuthHeaders() : {});
const result = await api('save_settings', {}, 'POST', {
ha_enabled: haEnabled,
ha_url: haUrl,
@@ -17522,7 +17581,11 @@ async function _runStartupCheck() {
if (!wrapEl) return true; // preloader already removed
const tl = (key, fallback) => { try { return t('startup.' + key); } catch(e) { return fallback; } };
const tl = (key, fallback) => {
const full = 'startup.' + key;
const v = typeof t === 'function' ? t(full) : full;
return (v === full) ? fallback : v;
};
// Switch from spinner to progress bar
if (spinnerEl) spinnerEl.style.display = 'none';
@@ -17553,6 +17616,12 @@ async function _runStartupCheck() {
el.textContent = cleanLabel;
};
// Auto-provision API token for same-origin browser sessions
if (typeof ensureApiToken === 'function') {
setProgress(5, tl('token_autoconfig', 'Configurazione accesso...'), 'ok');
await ensureApiToken();
}
// Phase 1: animate 0→15% while fetching (so it never looks stuck)
setProgress(0, tl('connecting', 'Connessione al server...'));
let _fetchDone = false;
@@ -17568,9 +17637,22 @@ async function _runStartupCheck() {
try {
const ctrl = new AbortController();
const tid = setTimeout(() => ctrl.abort(), 12000);
const resp = await fetch('api/index.php?action=health_check', { signal: ctrl.signal });
const resp = await fetch('api/index.php?action=health_check', {
signal: ctrl.signal,
headers: { ...(typeof apiAuthHeaders === 'function' ? apiAuthHeaders() : {}) },
});
clearTimeout(tid);
result = await resp.json();
if (result.public && result.api_token_required && typeof getApiToken === 'function' && !getApiToken()) {
window._apiTokenRequired = true;
if (typeof _promptApiTokenIfNeeded === 'function') _promptApiTokenIfNeeded();
setProgress(100, tl('token_required', 'Token API richiesto'), 'warn');
return false;
}
if (result.public && result.api_token_required && typeof getApiToken === 'function' && getApiToken()) {
const resp2 = await fetch('api/index.php?action=health_check', { headers: apiAuthHeaders() });
result = await resp2.json();
}
} catch(e) {
clearInterval(slowAnim);
_showStartupErrorPopup(
@@ -17697,9 +17779,10 @@ async function _runStartupCheck() {
// The bar already shows 100%; we just update the label for a moment.
try {
setProgress(100, tl('syncing_local', 'Sincronizzazione dati locali...'), 'ok');
const authH = typeof apiAuthHeaders === 'function' ? apiAuthHeaders() : {};
const [invData, settingsData] = await Promise.all([
fetch('api/index.php?action=inventory_list').then(r => r.json()).catch(() => null),
fetch('api/index.php?action=get_settings').then(r => r.json()).catch(() => null),
fetch('api/index.php?action=inventory_list', { headers: authH }).then(r => r.json()).catch(() => null),
fetch('api/index.php?action=get_settings', { headers: authH }).then(r => r.json()).catch(() => null),
]);
if (invData && Array.isArray(invData.inventory)) _offlineCacheSet(invData.inventory);
if (settingsData && settingsData.success !== false) _offlineCacheSetSettings(settingsData);
+77
View File
@@ -0,0 +1,77 @@
/**
* EverShelf core — API token storage and auth headers.
*/
const EVERSHELF_TOKEN_KEY = 'evershelf_api_token';
function getApiToken() {
return localStorage.getItem(EVERSHELF_TOKEN_KEY) || '';
}
function setApiToken(token) {
const t = (token || '').trim();
if (t) {
localStorage.setItem(EVERSHELF_TOKEN_KEY, t);
} else {
localStorage.removeItem(EVERSHELF_TOKEN_KEY);
}
}
function apiAuthHeaders() {
const fromStorage = getApiToken();
const fromSettingsField = document.getElementById('setting-settings-token')?.value.trim() || '';
const token = fromSettingsField || fromStorage;
if (!token) return {};
return { 'X-API-Token': token };
}
/** Fetch API token from server when loading the UI from the same origin. */
async function ensureApiToken() {
if (getApiToken()) return true;
try {
const res = await fetch('api/index.php?action=app_bootstrap', { cache: 'no-store' });
if (!res.ok) return false;
const data = await res.json();
window._apiTokenRequired = !!data.api_token_required;
if (data.api_token) {
setApiToken(data.api_token);
return true;
}
} catch (_) { /* offline / network */ }
return !!getApiToken();
}
function _promptApiTokenIfNeeded() {
if (!window._apiTokenRequired) return;
if (getApiToken()) return;
const existing = document.getElementById('api-token-overlay');
if (existing) return;
const title = typeof t === 'function' ? t('startup.token_prompt_title') : '🔒 API Token';
const hint = typeof t === 'function' ? t('startup.token_prompt_hint') : 'Enter API_TOKEN from .env';
const btn = typeof t === 'function' ? t('startup.token_prompt_btn') : 'Continue';
const overlay = document.createElement('div');
overlay.id = 'api-token-overlay';
overlay.className = 'modal-overlay';
overlay.style.display = 'flex';
overlay.innerHTML = `
<div class="modal-content" style="max-width:420px;padding:20px">
<h3>${title}</h3>
<p class="settings-hint">${hint}</p>
<input type="password" id="api-token-input" class="form-input" placeholder="API token">
<button class="btn btn-primary full-width mt-2" id="api-token-save">${btn}</button>
</div>`;
document.body.appendChild(overlay);
document.getElementById('api-token-save').onclick = () => {
const v = document.getElementById('api-token-input').value.trim();
if (v) {
setApiToken(v);
overlay.remove();
location.reload();
}
};
}
window.getApiToken = getApiToken;
window.setApiToken = setApiToken;
window.apiAuthHeaders = apiAuthHeaders;
window.ensureApiToken = ensureApiToken;
window._promptApiTokenIfNeeded = _promptApiTokenIfNeeded;
+11
View File
@@ -0,0 +1,11 @@
/**
* EverShelf core — safe HTML escaping (loaded before app.js).
*/
function escapeHtml(str) {
if (str == null) return '';
const div = document.createElement('div');
div.textContent = String(str);
return div.innerHTML;
}
window.escapeHtml = escapeHtml;
File diff suppressed because one or more lines are too long
+14 -8
View File
@@ -1,13 +1,19 @@
#!/bin/bash
# Daily backup of EverShelf database (local only)
# The database is NOT pushed to remote repositories.
# Runs via cron: creates a local timestamped backup copy
#
# Example crontab entry:
# 0 3 * * * /var/www/html/evershelf/backup.sh
# Retention follows BACKUP_RETENTION_DAYS from .env (default 3)
INSTALL_DIR="$(cd "$(dirname "$0")" && pwd)"
set -euo pipefail
INSTALL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
BACKUP_DIR="${INSTALL_DIR}/data/backups"
ENV_FILE="${INSTALL_DIR}/.env"
RETENTION=3
if [ -f "$ENV_FILE" ]; then
val=$(grep -E '^BACKUP_RETENTION_DAYS=' "$ENV_FILE" | tail -1 | cut -d= -f2)
if [[ "$val" =~ ^[0-9]+$ ]] && [ "$val" -ge 1 ]; then
RETENTION="$val"
fi
fi
mkdir -p "$BACKUP_DIR"
@@ -19,5 +25,5 @@ fi
DATE=$(date '+%Y-%m-%d_%H%M')
cp "$DB_FILE" "${BACKUP_DIR}/evershelf_${DATE}.db"
# Keep only the last 7 backups
ls -t "${BACKUP_DIR}"/evershelf_*.db 2>/dev/null | tail -n +8 | xargs -r rm --
# Keep only the newest N backups
ls -t "${BACKUP_DIR}"/evershelf_*.db 2>/dev/null | tail -n +$((RETENTION + 1)) | xargs -r rm --
+2
View File
@@ -0,0 +1,2 @@
# Deny all direct HTTP access to runtime data (DB, tokens, caches, logs)
Require all denied
+38
View File
@@ -0,0 +1,38 @@
# EverShelf — Architecture (modular layout)
```
dispensa/
├── api/
│ ├── bootstrap.php # Shared init: env, security, DB, logger
│ ├── index.php # HTTP handlers + router (split planned per domain)
│ ├── database.php # SQLite schema & migrations
│ ├── logger.php # Rotating file logger (logs/)
│ ├── cron_smart_shopping.php # CLI cron (uses bootstrap + index handlers)
│ ├── lib/
│ │ ├── env.php # .env loader
│ │ ├── constants.php # Paths & pricing constants
│ │ ├── security.php # API auth, CORS, demo mode, scale allowlist
│ │ ├── github.php # Encrypted GitHub Issues token
│ │ └── cron_log.php # data/cron.log rotation
│ └── scale_*.php # Scale gateway helpers (auth + SSRF guards)
├── assets/
│ ├── js/
│ │ ├── core/ # auth.js, dom.js (loaded before app.js)
│ │ └── app.js # SPA logic (domain modules: future split)
│ └── vendor/ # Offline CDN fallbacks (quagga, transformers)
├── data/ # Runtime data (.htaccess: deny all)
├── logs/ # Application logs (.htaccess: deny all)
└── scripts/ # migrate-env-security, fix-permissions, encrypt-gh-token
```
## Security model
- **`API_TOKEN`** (or legacy **`SETTINGS_TOKEN`**): when set, every API action requires `X-API-Token` header or `?api_token=` (Home Assistant).
- Secrets (`HA_TOKEN`, `TTS_TOKEN`, `GEMINI_API_KEY`) stay in `.env`; `get_settings` exposes only `*_set` flags.
- **`GH_ISSUE_TOKEN_ENC`** + **`GH_ISSUE_TOKEN_KEY`**: AES-256-GCM encrypted GitHub Issues token.
## Planned refactors
1. Split `api/index.php` handlers into `api/handlers/{products,inventory,ai,shopping}.php`
2. Split `assets/js/app.js` into ES modules under `assets/js/features/`
3. Optional `npm run build` to minify JS/CSS (see `package.json`)
@@ -101,6 +101,20 @@ class KioskActivity : AppCompatActivity() {
// Pending WebView permission request
private var pendingWebPermission: PermissionRequest? = null
private fun safeEvalJs(script: String) {
if (!::webView.isInitialized) return
if (isFinishing || isDestroyed) return
if (webView.visibility != View.VISIBLE) return
runCatching { webView.evaluateJavascript(script, null) }
.onFailure {
ErrorReporter.reportMessage(
type = "webview-js-bridge-error",
message = "Failed to deliver JS callback to WebView",
extra = mapOf("error" to (it.message ?: "unknown"))
)
}
}
companion object {
private const val FILE_CHOOSER_REQUEST = 1002
private const val PERMISSION_REQUEST_CODE = 1003
@@ -150,18 +164,18 @@ class KioskActivity : AppCompatActivity() {
override fun onStart(utteranceId: String?) {}
override fun onDone(utteranceId: String?) {
runOnUiThread {
webView.evaluateJavascript("if(window._kioskTtsDone)window._kioskTtsDone('$utteranceId')", null)
safeEvalJs("if(window._kioskTtsDone)window._kioskTtsDone('$utteranceId')")
}
}
@Deprecated("Deprecated in API 21")
override fun onError(utteranceId: String?) {
runOnUiThread {
webView.evaluateJavascript("if(window._kioskTtsError)window._kioskTtsError('$utteranceId','error')", null)
safeEvalJs("if(window._kioskTtsError)window._kioskTtsError('$utteranceId','error')")
}
}
override fun onError(utteranceId: String?, errorCode: Int) {
runOnUiThread {
webView.evaluateJavascript("if(window._kioskTtsError)window._kioskTtsError('$utteranceId',$errorCode)", null)
safeEvalJs("if(window._kioskTtsError)window._kioskTtsError('$utteranceId',$errorCode)")
}
}
})
+19 -11
View File
@@ -11,9 +11,13 @@
<title>EverShelf</title>
<link rel="manifest" href="manifest.json">
<link rel="icon" type="image/png" href="assets/img/logo/logo_icon.png">
<link rel="stylesheet" href="assets/css/style.css?v=20260517a">
<!-- QuaggaJS for barcode scanning -->
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"></script>
<link rel="stylesheet" href="assets/css/style.css?v=20260603a">
<!-- Core modules (auth, DOM helpers) -->
<script src="assets/js/core/dom.js?v=20260603a"></script>
<script src="assets/js/core/auth.js?v=20260603b"></script>
<!-- QuaggaJS — local vendor with CDN fallback -->
<script src="assets/vendor/quagga/quagga.min.js?v=20260603a"></script>
<script>if(typeof Quagga==='undefined'){document.write('<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"><\\/script>');}</script>
<!-- @xenova/transformers: ES-module bootstrap that exposes a lazy category-classifier as window._categoryPipelinePromise -->
<script type="module">
// Lazy-load the embedding pipeline only when first needed.
@@ -25,11 +29,15 @@
if (window._categoryPipelinePromise) return window._categoryPipelinePromise;
window._categoryPipelinePromise = (async () => {
try {
const { pipeline, env } = await import(
'https://cdn.jsdelivr.net/npm/@xenova/transformers@2/src/transformers.min.js'
);
// Keep WASM/model files in the browser cache; disable remote model check
// to avoid CORS issues with the self-hosted instance.
const localBase = 'assets/vendor/transformers/';
const cdnBase = 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2/dist/';
let pipeline, env;
try {
({ pipeline, env } = await import(localBase + 'transformers.min.js'));
} catch (_) {
({ pipeline, env } = await import(cdnBase + 'transformers.min.js'));
}
env.localModelPath = localBase;
env.allowRemoteModels = true;
env.useBrowserCache = true;
const pipe = await pipeline(
@@ -1213,10 +1221,10 @@
<p class="settings-hint" data-i18n="settings.security.token_hint">Se <code>SETTINGS_TOKEN</code> è configurato nel <code>.env</code> server, inserisci qui il token prima di salvare le impostazioni. Lascia vuoto se non configurato.</p>
<div class="form-group">
<label data-i18n="settings.security.token_label">Token di accesso</label>
<input type="password" id="setting-settings-token" class="form-input" placeholder="(vuoto = nessuna protezione)" data-i18n-placeholder="settings.security.token_placeholder">
<input type="password" id="setting-settings-token" class="form-input" placeholder="API_TOKEN da .env" data-i18n-placeholder="settings.security.token_placeholder">
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-settings-token')" data-i18n="btn.toggle_password">👁️ Mostra/Nascondi</button>
</div>
<p class="settings-hint" id="settings-token-status-hint" style="display:none;color:var(--accent)" data-i18n="settings.security.token_required_hint">🔒 Questo server richiede un token per salvare le impostazioni.</p>
<p class="settings-hint" id="settings-token-status-hint" style="display:none;color:var(--accent)" data-i18n="settings.security.token_required_hint">🔒 Questo server richiede un token API (API_TOKEN nel file .env). Il token viene salvato nel browser.</p>
</div>
<div class="settings-card">
<h4 data-i18n="settings.security.title">🔒 Certificato HTTPS</h4>
@@ -1962,6 +1970,6 @@
</div>
</div>
<script src="assets/js/app.js?v=20260518c"></script>
<script src="assets/js/app.js?v=20260603c"></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
Require all denied
+9
View File
@@ -0,0 +1,9 @@
{
"name": "evershelf",
"private": true,
"scripts": {
"build:js": "npx --yes terser assets/js/app.js -c -m -o assets/js/app.min.js",
"build:css": "npx --yes clean-css-cli -o assets/css/style.min.css assets/css/style.css",
"build": "npm run build:js && npm run build:css"
}
}
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env php
<?php
/**
* Encrypt a GitHub Issues token for storage in .env as GH_ISSUE_TOKEN_ENC.
*
* Usage:
* php scripts/encrypt-gh-token.php 'ghp_xxxx' 'your-secret-key'
*/
if ($argc < 3) {
fwrite(STDERR, "Usage: php scripts/encrypt-gh-token.php <token> <key>\n");
exit(1);
}
require_once __DIR__ . '/../api/lib/github.php';
echo evershelfEncryptGhToken($argv[1], $argv[2]) . "\n";
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# Fix ownership and permissions for EverShelf runtime directories.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
WEB_USER="${WEB_USER:-www-data}"
chown -R "${WEB_USER}:${WEB_USER}" "${ROOT}/data" "${ROOT}/logs" 2>/dev/null || true
chmod 750 "${ROOT}/data" "${ROOT}/logs"
chmod 640 "${ROOT}/.env" 2>/dev/null || true
find "${ROOT}/data" -type f -exec chmod 660 {} \;
find "${ROOT}/logs" -type f -exec chmod 640 {} \;
echo "Permissions updated for ${WEB_USER}"
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env php
<?php
/**
* One-time security migration: GitHub token encrypted .env, optional API_TOKEN.
*/
require_once __DIR__ . '/../api/lib/env.php';
require_once __DIR__ . '/../api/lib/github.php';
$envFile = dirname(__DIR__) . '/.env';
if (!file_exists($envFile)) {
fwrite(STDERR, ".env not found\n");
exit(1);
}
$lines = file($envFile, FILE_IGNORE_NEW_LINES);
$vars = loadEnv();
$changed = false;
// Migrate legacy XOR token from previous index.php if still in git history
if (empty($vars['GH_ISSUE_TOKEN']) && empty($vars['GH_ISSUE_TOKEN_ENC'])) {
$legacyEnc = '23580718460c2c444031290243627e7971622b29030a3e4d50001e45261659420b6e110a423f30447133205b425a577971561f32762b0b034e0b3e56106d5945020406254a3a4647592a1a611c66687a0b672043700f34757900014004';
$legacyKey = 'D1sp3ns4!Ev3r#26';
$encBin = hex2bin($legacyEnc);
$plain = '';
if ($encBin) {
for ($i = 0; $i < strlen($encBin); $i++) {
$plain .= chr(ord($encBin[$i]) ^ ord($legacyKey[$i % strlen($legacyKey)]));
}
}
if ($plain !== '' && str_starts_with($plain, 'github_')) {
$newKey = bin2hex(random_bytes(16));
$enc = evershelfEncryptGhToken($plain, $newKey);
$lines[] = '';
$lines[] = '# GitHub Issues (migrated from legacy source — encrypted at rest)';
$lines[] = 'GH_ISSUE_TOKEN_ENC=' . $enc;
$lines[] = 'GH_ISSUE_TOKEN_KEY=' . $newKey;
$changed = true;
echo "Migrated GitHub token to GH_ISSUE_TOKEN_ENC\n";
}
}
if (empty($vars['API_TOKEN']) && empty($vars['SETTINGS_TOKEN'])) {
$token = bin2hex(random_bytes(24));
$lines[] = '';
$lines[] = '# API access token — required for all API calls when set (also used by kiosk/HA)';
$lines[] = 'API_TOKEN=' . $token;
$changed = true;
echo "Generated API_TOKEN (save this for your devices): {$token}\n";
}
if ($changed) {
file_put_contents($envFile, implode("\n", $lines) . "\n");
chmod($envFile, 0640);
echo "Updated .env\n";
} else {
echo "No migration needed\n";
}
+16 -2
View File
@@ -416,7 +416,16 @@
"load_error": "Fehler beim Laden",
"favorite": "Zu Favoriten hinzufügen",
"unfavorite": "Aus Favoriten entfernen",
"adjust_persons": "Personen"
"adjust_persons": "Personen",
"nutrition_title": "Nährwerte (pro Portion)",
"nutrition_kcal": "Kalorien",
"nutrition_protein": "Protein",
"nutrition_carbs": "Kohlenhydrate",
"nutrition_fat": "Fett",
"nutrition_per_serving": "Geschätzte Werte pro Portion",
"storage_title": "Aufbewahrung von Resten",
"storage_days": "{n} Tage",
"storage_immediately": "Am besten sofort verzehren"
},
"shopping": {
"title": "🛒 Einkaufsliste",
@@ -1467,7 +1476,12 @@
"error_network_detail": "Der Browser kann den PHP-Server nicht erreichen.\n\nMögliche Ursachen:\n• Apache/PHP-Server läuft nicht\n• Netzwerk- oder Firewall-Problem\n• Falsche App-URL\n\nBitte Server starten und erneut versuchen.",
"retry": "Erneut versuchen",
"syncing_local": "Lokale Daten synchronisieren...",
"sync_done": "Lokale Daten aktualisiert"
"sync_done": "Lokale Daten aktualisiert",
"token_required": "API-Token erforderlich",
"token_autoconfig": "Zugriff wird konfiguriert...",
"token_prompt_title": "🔒 API-Token",
"token_prompt_hint": "Geben Sie den API_TOKEN-Wert aus der .env-Datei des Servers ein.",
"token_prompt_btn": "Weiter"
},
"stats_monthly": {
"title": "Monatsstatistik",
+16 -2
View File
@@ -416,7 +416,16 @@
"load_error": "Loading error",
"favorite": "Add to favourites",
"unfavorite": "Remove from favourites",
"adjust_persons": "Persons"
"adjust_persons": "Persons",
"nutrition_title": "Nutritional values (per serving)",
"nutrition_kcal": "Calories",
"nutrition_protein": "Protein",
"nutrition_carbs": "Carbs",
"nutrition_fat": "Fat",
"nutrition_per_serving": "Estimated values per serving",
"storage_title": "How to store leftovers",
"storage_days": "{n} days",
"storage_immediately": "Best eaten immediately"
},
"shopping": {
"title": "🛒 Shopping List",
@@ -1467,7 +1476,12 @@
"error_network_detail": "The browser cannot reach the PHP server.\n\nPossible causes:\n• Apache/PHP server is not running\n• Network or firewall issue\n• Incorrect app URL\n\nMake sure the server is started and try again.",
"retry": "Retry",
"syncing_local": "Syncing local data...",
"sync_done": "Local data synced"
"sync_done": "Local data synced",
"token_required": "API token required",
"token_autoconfig": "Configuring access...",
"token_prompt_title": "🔒 API Token",
"token_prompt_hint": "Enter the API_TOKEN value from the server .env file.",
"token_prompt_btn": "Continue"
},
"stats_monthly": {
"title": "Monthly Stats",
+16 -2
View File
@@ -411,7 +411,16 @@
"load_error": "Error de carga",
"favorite": "Añadir a favoritos",
"unfavorite": "Quitar de favoritos",
"adjust_persons": "Personas"
"adjust_persons": "Personas",
"nutrition_title": "Valores nutricionales (por ración)",
"nutrition_kcal": "Calorías",
"nutrition_protein": "Proteínas",
"nutrition_carbs": "Carbohidratos",
"nutrition_fat": "Grasas",
"nutrition_per_serving": "Valores estimados por ración",
"storage_title": "Cómo conservar las sobras",
"storage_days": "{n} días",
"storage_immediately": "Mejor consumir de inmediato"
},
"shopping": {
"title": "🛒 Lista de la compra",
@@ -1410,7 +1419,12 @@
"error_network": "No se puede contactar con el servidor. Comprueba tu conexión de red.",
"retry": "Reintentar",
"syncing_local": "Sincronizando datos locales...",
"sync_done": "Datos locales sincronizados"
"sync_done": "Datos locales sincronizados",
"token_required": "Token API requerido",
"token_autoconfig": "Configurando acceso...",
"token_prompt_title": "🔒 Token API",
"token_prompt_hint": "Introduce el valor API_TOKEN del archivo .env del servidor.",
"token_prompt_btn": "Continuar"
},
"stats_monthly": {
"title": "Estadísticas Mensuales",
+16 -2
View File
@@ -411,7 +411,16 @@
"load_error": "Erreur de chargement",
"favorite": "Ajouter aux favoris",
"unfavorite": "Retirer des favoris",
"adjust_persons": "Personnes"
"adjust_persons": "Personnes",
"nutrition_title": "Valeurs nutritionnelles (par portion)",
"nutrition_kcal": "Calories",
"nutrition_protein": "Protéines",
"nutrition_carbs": "Glucides",
"nutrition_fat": "Lipides",
"nutrition_per_serving": "Valeurs estimées par portion",
"storage_title": "Comment conserver les restes",
"storage_days": "{n} jours",
"storage_immediately": "À consommer immédiatement"
},
"shopping": {
"title": "🛒 Liste de courses",
@@ -1410,7 +1419,12 @@
"error_network": "Impossible de contacter le serveur. Vérifiez votre connexion réseau.",
"retry": "Réessayer",
"syncing_local": "Synchronisation des données locales...",
"sync_done": "Données locales synchronisées"
"sync_done": "Données locales synchronisées",
"token_required": "Jeton API requis",
"token_autoconfig": "Configuration de l'accès...",
"token_prompt_title": "🔒 Jeton API",
"token_prompt_hint": "Saisissez la valeur API_TOKEN du fichier .env du serveur.",
"token_prompt_btn": "Continuer"
},
"stats_monthly": {
"title": "Statistiques Mensuelles",
+16 -2
View File
@@ -416,7 +416,16 @@
"load_error": "Errore nel caricamento",
"favorite": "Aggiungi ai preferiti",
"unfavorite": "Rimuovi dai preferiti",
"adjust_persons": "Persone"
"adjust_persons": "Persone",
"nutrition_title": "Valori nutrizionali (per porzione)",
"nutrition_kcal": "Calorie",
"nutrition_protein": "Proteine",
"nutrition_carbs": "Carboidrati",
"nutrition_fat": "Grassi",
"nutrition_per_serving": "Valori stimati per porzione",
"storage_title": "Come conservare gli avanzi",
"storage_days": "{n} giorni",
"storage_immediately": "Da consumare subito"
},
"shopping": {
"title": "🛒 Lista della Spesa",
@@ -1466,7 +1475,12 @@
"error_network_detail": "Il browser non riesce a raggiungere il server PHP.\n\nPossibili cause:\n• Il server Apache/PHP non è in esecuzione\n• Problema di rete o firewall\n• URL dell'app non corretta\n\nControlla che il server sia avviato e riprova.",
"retry": "Riprova",
"syncing_local": "Sincronizzazione dati locali...",
"sync_done": "Dati locali aggiornati"
"sync_done": "Dati locali aggiornati",
"token_required": "Token API richiesto",
"token_autoconfig": "Configurazione accesso...",
"token_prompt_title": "🔒 Token API",
"token_prompt_hint": "Inserisci il valore API_TOKEN dal file .env del server.",
"token_prompt_btn": "Continua"
},
"stats_monthly": {
"title": "Statistiche Mensili",