Harden security, modularize API bootstrap, and fix scale SSE auth.
Block web access to sensitive paths, require API_TOKEN for mutations, encrypt GitHub issue credentials in .env, auto-provision tokens for same-origin clients, and pass api_token in scale relay URLs since EventSource cannot send headers. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+18
-4
@@ -125,10 +125,24 @@ GDRIVE_FOLDER_ID=
|
|||||||
GDRIVE_RETENTION_DAYS=30
|
GDRIVE_RETENTION_DAYS=30
|
||||||
|
|
||||||
# ── Security ─────────────────────────────────────────────────────────────────
|
# ── Security ─────────────────────────────────────────────────────────────────
|
||||||
# SETTINGS_TOKEN: if set, the Settings screen requires this token to save changes.
|
# API_TOKEN: when set, all API calls require header X-API-Token (or ?api_token= for HA).
|
||||||
# Leave empty to allow anyone with access to the server to change settings.
|
# SETTINGS_TOKEN: legacy alias — use API_TOKEN for new installs.
|
||||||
|
API_TOKEN=
|
||||||
SETTINGS_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
|
# INSTANCE_NAME: display name for this EverShelf instance (used by the HA integration
|
||||||
# for Zeroconf discovery label and device name in Home Assistant).
|
# for Zeroconf discovery label and device name in Home Assistant).
|
||||||
# Defaults to the server hostname if left empty.
|
# 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: when true, all write operations are blocked (for public demos)
|
||||||
DEMO_MODE=false
|
DEMO_MODE=false
|
||||||
|
|
||||||
# NOTE: GitHub error reporting uses a token hardcoded in api/index.php.
|
# CRON_LOG_MAX_BYTES: rotate data/cron.log when larger (default 524288 = 512 KB)
|
||||||
# To rotate it, update the GH_ISSUE_TOKEN constant there.
|
CRON_LOG_MAX_BYTES=524288
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
RewriteEngine On
|
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
|
# Force HTTPS
|
||||||
RewriteCond %{HTTPS} !=on
|
RewriteCond %{HTTPS} !=on
|
||||||
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ TTS_ENABLED=true
|
|||||||
|
|
||||||
# Optional: DB retention and cleanup (applied automatically each cron cycle)
|
# Optional: DB retention and cleanup (applied automatically each cron cycle)
|
||||||
RECIPE_RETENTION_DAYS=7 # delete recipe plans older than N days
|
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
|
# Optional: Vacuum-sealed expiry grace period
|
||||||
VACUUM_EXPIRY_EXTENSION_DAYS=30 # extra days before vacuum-sealed items are flagged expired
|
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_IN=0.10
|
||||||
GEMINI_COST_20F_OUT=0.40
|
GEMINI_COST_20F_OUT=0.40
|
||||||
|
|
||||||
# Optional: Security — protect the save_settings endpoint
|
# Optional: Security — protect all API endpoints
|
||||||
# Set a strong random string; the Settings UI will ask for it before saving
|
# 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=
|
SETTINGS_TOKEN=
|
||||||
|
|
||||||
# Optional: Demo mode — block all write operations at the router level
|
# 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)
|
- **Credentials** are stored in `.env` (server-side, never committed to Git)
|
||||||
- **Database** stays local — never pushed to remote repositories
|
- **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
|
- **Apache/Nginx hardening** — `.env`, `data/`, and `logs/` are blocked from direct HTTP access
|
||||||
- **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
|
- **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
|
- **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
|
- The API uses **parameterized SQL queries** (PDO prepared statements) against injection
|
||||||
- **Input validation** on all inventory operations (quantity bounds, location whitelist)
|
- **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`)
|
4. Push to the branch (`git push origin feature/my-feature`)
|
||||||
5. Open a Pull Request
|
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
|
### Easiest way to start — translate EverShelf into your language
|
||||||
|
|
||||||
Translations are just JSON files. No coding, no setup — fork → edit → PR.
|
Translations are just JSON files. No coding, no setup — fork → edit → PR.
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -11,14 +11,16 @@ if (PHP_SAPI !== 'cli') {
|
|||||||
exit('Forbidden');
|
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);
|
define('CRON_MODE', true);
|
||||||
|
|
||||||
// Load all API functions without running the HTTP router
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
require_once __DIR__ . '/index.php';
|
require_once __DIR__ . '/index.php';
|
||||||
|
|
||||||
const CACHE_FILE = __DIR__ . '/../data/smart_shopping_cache.json';
|
const CACHE_FILE = __DIR__ . '/../data/smart_shopping_cache.json';
|
||||||
|
|
||||||
|
evershelfRotateCronLog();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$db = getDB();
|
$db = getDB();
|
||||||
|
|
||||||
|
|||||||
+279
-126
@@ -8,49 +8,8 @@
|
|||||||
* @license MIT
|
* @license MIT
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ── GitHub error-reporting credentials ───────────────────────────────────────
|
// ── Core bootstrap (env, security, database, logger) ─────────────────────────
|
||||||
// The token is XOR-obfuscated so the literal secret string never appears in
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
// 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';
|
|
||||||
|
|
||||||
// ── Global PHP error/exception reporters ─────────────────────────────────────
|
// ── Global PHP error/exception reporters ─────────────────────────────────────
|
||||||
// These are registered immediately so any crash anywhere in this file is caught.
|
// 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
|
// When included by the cron script, skip HTTP headers and routing entirely
|
||||||
if (!defined('CRON_MODE')) {
|
if (!defined('CRON_MODE')) {
|
||||||
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
header('Access-Control-Allow-Origin: *');
|
evershelfSendCorsHeaders();
|
||||||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
|
||||||
header('Access-Control-Allow-Headers: Content-Type');
|
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||||
http_response_code(200);
|
http_response_code(200);
|
||||||
@@ -121,6 +50,17 @@ if (($_GET['action'] ?? '') === 'ping') {
|
|||||||
exit;
|
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 ──────────────────────
|
// ── Google Drive OAuth callback — returns HTML, not JSON ──────────────────────
|
||||||
if (($_GET['action'] ?? '') === 'gdrive_oauth_callback') {
|
if (($_GET['action'] ?? '') === 'gdrive_oauth_callback') {
|
||||||
_gdriveHandleOAuthCallback();
|
_gdriveHandleOAuthCallback();
|
||||||
@@ -130,9 +70,9 @@ if (($_GET['action'] ?? '') === 'gdrive_oauth_callback') {
|
|||||||
// ── Log viewer — returns last N log lines (requires SETTINGS_TOKEN if set) ────
|
// ── Log viewer — returns last N log lines (requires SETTINGS_TOKEN if set) ────
|
||||||
if (($_GET['action'] ?? '') === 'get_logs') {
|
if (($_GET['action'] ?? '') === 'get_logs') {
|
||||||
require_once __DIR__ . '/logger.php';
|
require_once __DIR__ . '/logger.php';
|
||||||
$token = loadEnv()['SETTINGS_TOKEN'] ?? '';
|
$token = evershelfEffectiveApiToken();
|
||||||
$reqTok = $_GET['token'] ?? $_SERVER['HTTP_X_SETTINGS_TOKEN'] ?? '';
|
$reqTok = evershelfGetProvidedApiTokenFromHeaders() ?: (string)($_GET['token'] ?? '');
|
||||||
if (!empty($token) && $reqTok !== $token) {
|
if ($token !== '' && ($reqTok === '' || !hash_equals($token, $reqTok))) {
|
||||||
EverLog::warn('get_logs: unauthorized (403)');
|
EverLog::warn('get_logs: unauthorized (403)');
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
echo json_encode(['error' => 'Unauthorized']);
|
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 (($_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 = [];
|
$checks = [];
|
||||||
|
|
||||||
// ── Helper: read .env values without triggering app init ─────────────────
|
// ── Helper: read .env values without triggering app init ─────────────────
|
||||||
@@ -363,7 +312,7 @@ if (($_GET['action'] ?? '') === 'health_check') {
|
|||||||
$dataDir = __DIR__ . '/../data';
|
$dataDir = __DIR__ . '/../data';
|
||||||
if (!is_dir($dataDir)) @mkdir($dataDir, 0775, true);
|
if (!is_dir($dataDir)) @mkdir($dataDir, 0775, true);
|
||||||
$dataDirOk = is_dir($dataDir) && is_writable($dataDir);
|
$dataDirOk = is_dir($dataDir) && is_writable($dataDir);
|
||||||
$checks['data_dir'] = ['ok' => $dataDirOk, 'path' => realpath($dataDir) ?: $dataDir];
|
$checks['data_dir'] = ['ok' => $dataDirOk];
|
||||||
|
|
||||||
// data/rate_limits/
|
// data/rate_limits/
|
||||||
$rlDir = $dataDir . '/rate_limits';
|
$rlDir = $dataDir . '/rate_limits';
|
||||||
@@ -714,25 +663,20 @@ $method = $_SERVER['REQUEST_METHOD'];
|
|||||||
$action = $_GET['action'] ?? '';
|
$action = $_GET['action'] ?? '';
|
||||||
EverLog::request($action, $method);
|
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
|
} // end !CRON_MODE block for router bootstrap
|
||||||
|
|
||||||
if (!defined('CRON_MODE')):
|
if (!defined('CRON_MODE')):
|
||||||
try {
|
try {
|
||||||
// DEMO_MODE guard
|
// DEMO_MODE — block all writes and AI generation
|
||||||
if (env('DEMO_MODE') === 'true') {
|
if (evershelfDemoBlocksAction($action, $method)) {
|
||||||
$demoBlocked = [
|
EverLog::warn('demo_mode blocked (403)', ['action' => $action]);
|
||||||
'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);
|
http_response_code(403);
|
||||||
echo json_encode(['success' => false, 'error' => 'demo_mode']);
|
echo json_encode(['success' => false, 'error' => 'demo_mode']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
switch ($action) {
|
switch ($action) {
|
||||||
// ===== PRODUCTS =====
|
// ===== PRODUCTS =====
|
||||||
@@ -760,6 +704,9 @@ try {
|
|||||||
case 'products_search':
|
case 'products_search':
|
||||||
searchProducts($db);
|
searchProducts($db);
|
||||||
break;
|
break;
|
||||||
|
case 'inventory_search':
|
||||||
|
searchInventoryProducts($db);
|
||||||
|
break;
|
||||||
|
|
||||||
// ===== INVENTORY =====
|
// ===== INVENTORY =====
|
||||||
case 'inventory_list':
|
case 'inventory_list':
|
||||||
@@ -813,6 +760,10 @@ try {
|
|||||||
getInventoryAnomalies($db);
|
getInventoryAnomalies($db);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'inventory_duplicate_loss_checks':
|
||||||
|
getDuplicateLossChecks($db);
|
||||||
|
break;
|
||||||
|
|
||||||
case 'dismiss_anomaly':
|
case 'dismiss_anomaly':
|
||||||
dismissInventoryAnomaly();
|
dismissInventoryAnomaly();
|
||||||
break;
|
break;
|
||||||
@@ -1252,9 +1203,33 @@ function ttsProxy() {
|
|||||||
$method = isset($body['method']) ? strtoupper(trim($body['method'])) : 'POST';
|
$method = isset($body['method']) ? strtoupper(trim($body['method'])) : 'POST';
|
||||||
$headers = isset($body['headers']) && is_array($body['headers']) ? $body['headers'] : [];
|
$headers = isset($body['headers']) && is_array($body['headers']) ? $body['headers'] : [];
|
||||||
$payload = isset($body['payload']) ? $body['payload'] : '';
|
$payload = isset($body['payload']) ? $body['payload'] : '';
|
||||||
$contentType = '';
|
|
||||||
foreach ($headers as $k => $v) {
|
// Never trust client-supplied auth headers — inject from server .env
|
||||||
if (strtolower($k) === 'content-type') { $contentType = $v; break; }
|
$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)) {
|
if (!$url || !preg_match('/^https?:\/\/.+/', $url)) {
|
||||||
@@ -1424,8 +1399,6 @@ function _haProductSelect(): string {
|
|||||||
*/
|
*/
|
||||||
function haInventorySensor(PDO $db): void {
|
function haInventorySensor(PDO $db): void {
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
header('Access-Control-Allow-Origin: *');
|
|
||||||
|
|
||||||
$sensor = strtolower(trim($_GET['sensor'] ?? 'overview'));
|
$sensor = strtolower(trim($_GET['sensor'] ?? 'overview'));
|
||||||
$expiryDays = max(1, min(90, (int)($_GET['expiry_days'] ?? env('HA_EXPIRY_DAYS', 3))));
|
$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);
|
$stmt->execute($params);
|
||||||
$items = array_map('_haFormatProduct', $stmt->fetchAll(PDO::FETCH_ASSOC));
|
$items = array_map('_haFormatProduct', $stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
header('Access-Control-Allow-Origin: *');
|
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'state' => count($items),
|
'state' => count($items),
|
||||||
'items' => $items,
|
'items' => $items,
|
||||||
@@ -1693,7 +1665,6 @@ function haInventorySensor(PDO $db): void {
|
|||||||
*/
|
*/
|
||||||
function haCalendar(PDO $db): void {
|
function haCalendar(PDO $db): void {
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
header('Access-Control-Allow-Origin: *');
|
|
||||||
try {
|
try {
|
||||||
$rows = $db->query(
|
$rows = $db->query(
|
||||||
"SELECT p.name, i.quantity, p.unit, i.location, i.expiry_date
|
"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 {
|
function haSuggestRecipe(PDO $db): void {
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
header('Access-Control-Allow-Origin: *');
|
|
||||||
|
|
||||||
$apiKey = env('GEMINI_API_KEY', '');
|
$apiKey = env('GEMINI_API_KEY', '');
|
||||||
if (!$apiKey) {
|
if (!$apiKey) {
|
||||||
http_response_code(503);
|
http_response_code(503);
|
||||||
@@ -1814,8 +1783,6 @@ function haSuggestRecipe(PDO $db): void {
|
|||||||
*/
|
*/
|
||||||
function haRefreshPrices(PDO $db): void {
|
function haRefreshPrices(PDO $db): void {
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
header('Access-Control-Allow-Origin: *');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$country = env('PRICE_COUNTRY', 'Italia');
|
$country = env('PRICE_COUNTRY', 'Italia');
|
||||||
$currency = env('PRICE_CURRENCY', 'EUR');
|
$currency = env('PRICE_CURRENCY', 'EUR');
|
||||||
@@ -1889,8 +1856,6 @@ function haRefreshPrices(PDO $db): void {
|
|||||||
*/
|
*/
|
||||||
function haClearExpired(PDO $db): void {
|
function haClearExpired(PDO $db): void {
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
header('Access-Control-Allow-Origin: *');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$stmt = $db->prepare(
|
$stmt = $db->prepare(
|
||||||
"DELETE FROM inventory WHERE expiry_date < date('now') AND quantity <= 0"
|
"DELETE FROM inventory WHERE expiry_date < date('now') AND quantity <= 0"
|
||||||
@@ -1966,7 +1931,6 @@ function haTestConnection(): void {
|
|||||||
*/
|
*/
|
||||||
function haGetInfo(PDO $db): void {
|
function haGetInfo(PDO $db): void {
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
header('Access-Control-Allow-Origin: *');
|
|
||||||
// Stable unique_id derived from server identity (survives restarts)
|
// Stable unique_id derived from server identity (survives restarts)
|
||||||
$uniqueId = 'evershelf_' . substr(md5(__DIR__ . php_uname('n')), 0, 12);
|
$uniqueId = 'evershelf_' . substr(md5(__DIR__ . php_uname('n')), 0, 12);
|
||||||
$itemsCount = (int)$db->query("SELECT COUNT(*) FROM inventory WHERE quantity > 0")->fetchColumn();
|
$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 {
|
function haGetShoppingItems(PDO $db): void {
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
header('Access-Control-Allow-Origin: *');
|
|
||||||
try {
|
try {
|
||||||
if (isShoppingBringMode()) {
|
if (isShoppingBringMode()) {
|
||||||
$auth = bringAuth();
|
$auth = bringAuth();
|
||||||
@@ -2603,6 +2566,57 @@ function searchProducts(PDO $db): void {
|
|||||||
echo json_encode(['products' => $stmt->fetchAll()]);
|
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 =====
|
// ===== INVENTORY FUNCTIONS =====
|
||||||
|
|
||||||
function listInventory(PDO $db): void {
|
function listInventory(PDO $db): void {
|
||||||
@@ -2828,22 +2842,38 @@ function useFromInventory(PDO $db): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Server-side deduplication ─────────────────────────────────────────
|
// ── Server-side deduplication ─────────────────────────────────────────
|
||||||
// Reject if the same product already has an 'out' transaction in the last
|
// Guard against accidental double-consume triggers (scale jitter, double tap,
|
||||||
// 12 seconds. This guards against scale double-triggers (the scale can fire
|
// delayed/offline replay burst). We only apply this stricter gate to manual
|
||||||
// a second stable reading ~10 s after the first auto-confirm, while the
|
// uses with empty notes, so recipe uses (notes="Ricetta: ...") remain unaffected.
|
||||||
// product is still on the plate), regardless of the client-side guard.
|
|
||||||
if (!$useAll) {
|
if (!$useAll) {
|
||||||
|
$dedupWindow = ($notes === '') ? 120 : 12;
|
||||||
$dedup = $db->prepare(
|
$dedup = $db->prepare(
|
||||||
"SELECT id FROM transactions
|
"SELECT id, quantity, created_at FROM transactions
|
||||||
WHERE product_id = ? AND type IN ('out','waste') AND undone = 0
|
WHERE product_id = ?
|
||||||
AND created_at >= datetime('now', '-12 seconds')
|
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"
|
LIMIT 1"
|
||||||
);
|
);
|
||||||
$dedup->execute([$productId]);
|
$dedup->execute([$productId, $location, $notes, $dedupWindow]);
|
||||||
if ($dedup->fetch()) {
|
$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([
|
echo json_encode([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => 'Operazione già registrata di recente — attendi qualche secondo.',
|
'error' => 'Operazione già registrata di recente — verifica prima la quantità rimasta.',
|
||||||
'duplicate' => true,
|
'duplicate' => true,
|
||||||
]);
|
]);
|
||||||
return;
|
return;
|
||||||
@@ -3574,6 +3604,122 @@ function getInventoryAnomalies(PDO $db): void {
|
|||||||
echo json_encode(['success' => true, 'anomalies' => $anomalies], JSON_UNESCAPED_UNICODE);
|
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.
|
* Dismiss a specific anomaly so it no longer appears in the banner.
|
||||||
*/
|
*/
|
||||||
@@ -4301,12 +4447,13 @@ function getServerSettings(): void {
|
|||||||
|
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'gemini_key_set' => !empty($geminiKey),
|
'gemini_key_set' => !empty($geminiKey),
|
||||||
|
'api_token_required' => evershelfApiTokenRequired(),
|
||||||
'bring_email' => $bringEmail,
|
'bring_email' => $bringEmail,
|
||||||
'settings_token_set' => !empty(env('SETTINGS_TOKEN')),
|
'settings_token_set' => evershelfApiTokenRequired(),
|
||||||
'demo_mode' => env('DEMO_MODE') === 'true',
|
'demo_mode' => env('DEMO_MODE') === 'true',
|
||||||
'bring_password_set' => !empty(env('BRING_PASSWORD')),
|
'bring_password_set' => !empty(env('BRING_PASSWORD')),
|
||||||
'tts_url' => env('TTS_URL'),
|
'tts_url' => env('TTS_URL'),
|
||||||
'tts_token' => env('TTS_TOKEN'),
|
'tts_token_set' => !empty(env('TTS_TOKEN')),
|
||||||
'tts_method' => env('TTS_METHOD', 'POST'),
|
'tts_method' => env('TTS_METHOD', 'POST'),
|
||||||
'tts_auth_type' => env('TTS_AUTH_TYPE', 'bearer'),
|
'tts_auth_type' => env('TTS_AUTH_TYPE', 'bearer'),
|
||||||
'tts_content_type' => env('TTS_CONTENT_TYPE', 'application/json'),
|
'tts_content_type' => env('TTS_CONTENT_TYPE', 'application/json'),
|
||||||
@@ -4316,7 +4463,7 @@ function getServerSettings(): void {
|
|||||||
'tts_rate' => (float)env('TTS_RATE', '1'),
|
'tts_rate' => (float)env('TTS_RATE', '1'),
|
||||||
'tts_pitch' => (float)env('TTS_PITCH', '1'),
|
'tts_pitch' => (float)env('TTS_PITCH', '1'),
|
||||||
'tts_auth_header_name' => env('TTS_AUTH_HEADER_NAME', ''),
|
'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', ''),
|
'tts_extra_fields' => env('TTS_EXTRA_FIELDS', ''),
|
||||||
// User preferences (now server-side)
|
// User preferences (now server-side)
|
||||||
'default_persons' => intval(env('DEFAULT_PERSONS', '1')),
|
'default_persons' => intval(env('DEFAULT_PERSONS', '1')),
|
||||||
@@ -4361,7 +4508,7 @@ function getServerSettings(): void {
|
|||||||
// Home Assistant Integration
|
// Home Assistant Integration
|
||||||
'ha_enabled' => env('HA_ENABLED', 'false') === 'true',
|
'ha_enabled' => env('HA_ENABLED', 'false') === 'true',
|
||||||
'ha_url' => env('HA_URL', ''),
|
'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_tts_entity' => env('HA_TTS_ENTITY', ''),
|
||||||
'ha_webhook_id' => env('HA_WEBHOOK_ID', ''),
|
'ha_webhook_id' => env('HA_WEBHOOK_ID', ''),
|
||||||
'ha_webhook_events' => env('HA_WEBHOOK_EVENTS', 'expiry,shopping_add,stock_update,barcode_scan'),
|
'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 {
|
function saveSettings(): void {
|
||||||
// Require SETTINGS_TOKEN if configured
|
// Require API token if configured
|
||||||
$requiredToken = env('SETTINGS_TOKEN');
|
$requiredToken = evershelfEffectiveApiToken();
|
||||||
if (!empty($requiredToken)) {
|
if ($requiredToken !== '') {
|
||||||
EverLog::debug('saveSettings');
|
EverLog::debug('saveSettings');
|
||||||
$provided = $_SERVER['HTTP_X_SETTINGS_TOKEN'] ?? '';
|
$provided = evershelfGetProvidedApiToken();
|
||||||
if (!hash_equals($requiredToken, $provided)) {
|
if (!hash_equals($requiredToken, $provided)) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
echo json_encode(['success' => false, 'error' => 'unauthorized']);
|
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.
|
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":…}.
|
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.
|
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:
|
DISPENSA:
|
||||||
$ingredientsText
|
$ingredientsText
|
||||||
|
|
||||||
Rispondi SOLO JSON valido (no markdown):
|
Rispondi SOLO JSON valido (no markdown):
|
||||||
{$promptLanguageRule}
|
{$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;
|
PROMPT;
|
||||||
|
|
||||||
$payload = [
|
$payload = [
|
||||||
@@ -6091,12 +6240,14 @@ REGOLE:
|
|||||||
5. "name": usa ESATTAMENTE il nome dalla lista dispensa (il sistema lo usa per scalare l'inventario).
|
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.
|
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).
|
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:
|
DISPENSA:
|
||||||
{$ingredientsText}
|
{$ingredientsText}
|
||||||
|
|
||||||
JSON schema:
|
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;
|
PROMPT;
|
||||||
|
|
||||||
$payload = [
|
$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.
|
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":…}.
|
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.
|
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:
|
DISPENSA:
|
||||||
$ingredientsText
|
$ingredientsText
|
||||||
|
|
||||||
Rispondi SOLO JSON valido (no markdown):
|
Rispondi SOLO JSON valido (no markdown):
|
||||||
{$promptLanguageRule}
|
{$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;
|
PROMPT;
|
||||||
|
|
||||||
$genConfig = [
|
$genConfig = [
|
||||||
|
|||||||
@@ -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));
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -1,57 +1,53 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* EverShelf Scale Gateway — Auto-discovery
|
* EverShelf Scale Gateway — Auto-discovery (auth + rate limit + LAN only).
|
||||||
*
|
|
||||||
* 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", ...]}
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/lib/env.php';
|
||||||
|
require_once __DIR__ . '/lib/security.php';
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
header('Cache-Control: no-cache');
|
header('Cache-Control: no-cache');
|
||||||
|
evershelfSendCorsHeaders();
|
||||||
|
|
||||||
$port = (int)($_GET['port'] ?? 8765);
|
if (evershelfApiTokenRequired() && !evershelfApiTokenValid() && !evershelfIsSameOriginBrowser()) {
|
||||||
if ($port < 1 || $port > 65535) $port = 8765;
|
http_response_code(401);
|
||||||
|
echo json_encode(['error' => 'unauthorized', 'api_token_required' => true]);
|
||||||
// ── Determine server LAN IP ────────────────────────────────────────────────
|
exit;
|
||||||
// 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 '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$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);
|
$parts = explode('.', $serverIp);
|
||||||
if (count($parts) !== 4) {
|
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;
|
exit;
|
||||||
}
|
}
|
||||||
$subnet = $parts[0] . '.' . $parts[1] . '.' . $parts[2] . '.';
|
$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 = [];
|
$candidates = [];
|
||||||
for ($i = 1; $i <= 254; $i++) {
|
for ($i = 1; $i <= 254; $i++) {
|
||||||
$ip = $subnet . $i;
|
$ip = $subnet . $i;
|
||||||
@@ -74,25 +70,28 @@ while (!empty($candidates) && microtime(true) < $deadline) {
|
|||||||
$read = null;
|
$read = null;
|
||||||
$usec = (int)(max(0, $deadline - microtime(true)) * 1_000_000);
|
$usec = (int)(max(0, $deadline - microtime(true)) * 1_000_000);
|
||||||
$n = @stream_select($read, $write, $except, 0, $usec);
|
$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 = [];
|
$failed = [];
|
||||||
foreach ($except as $s) {
|
foreach ($except as $s) {
|
||||||
$ip = array_search($s, $candidates, true);
|
$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) {
|
foreach ($write as $s) {
|
||||||
$ip = array_search($s, $candidates, true);
|
$ip = array_search($s, $candidates, true);
|
||||||
if ($ip === false) continue;
|
if ($ip === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!isset($failed[$ip])) {
|
if (!isset($failed[$ip])) {
|
||||||
$found_tcp[] = $ip;
|
$found_tcp[] = $ip;
|
||||||
}
|
}
|
||||||
@fclose($s);
|
@fclose($s);
|
||||||
unset($candidates[$ip]);
|
unset($candidates[$ip]);
|
||||||
}
|
}
|
||||||
// Close failed sockets too
|
|
||||||
foreach ($failed as $ip => $_) {
|
foreach ($failed as $ip => $_) {
|
||||||
if (isset($candidates[$ip])) {
|
if (isset($candidates[$ip])) {
|
||||||
@fclose($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 = [];
|
$gateways = [];
|
||||||
foreach ($found_tcp as $ip) {
|
foreach ($found_tcp as $ip) {
|
||||||
$sock = @stream_socket_client("tcp://{$ip}:{$port}", $errno, $errstr, 2);
|
$sock = @stream_socket_client("tcp://{$ip}:{$port}", $errno, $errstr, 2);
|
||||||
if (!$sock) continue;
|
if (!$sock) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
stream_set_timeout($sock, 2);
|
stream_set_timeout($sock, 2);
|
||||||
|
|
||||||
$key = base64_encode(random_bytes(16));
|
$key = base64_encode(random_bytes(16));
|
||||||
@@ -124,9 +126,13 @@ foreach ($found_tcp as $ip) {
|
|||||||
$dl = microtime(true) + 2;
|
$dl = microtime(true) + 2;
|
||||||
while (microtime(true) < $dl && !feof($sock)) {
|
while (microtime(true) < $dl && !feof($sock)) {
|
||||||
$line = fgets($sock, 256);
|
$line = fgets($sock, 256);
|
||||||
if ($line === false) break;
|
if ($line === false) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
$resp .= $line;
|
$resp .= $line;
|
||||||
if ($line === "\r\n") break;
|
if ($line === "\r\n") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fclose($sock);
|
fclose($sock);
|
||||||
|
|
||||||
@@ -138,5 +144,4 @@ foreach ($found_tcp as $ip) {
|
|||||||
echo json_encode([
|
echo json_encode([
|
||||||
'found' => $gateways,
|
'found' => $gateways,
|
||||||
'subnet' => rtrim($subnet, '.') . '.0/24',
|
'subnet' => rtrim($subnet, '.') . '.0/24',
|
||||||
'server_ip' => $serverIp,
|
|
||||||
]);
|
]);
|
||||||
|
|||||||
+16
-7
@@ -1,16 +1,20 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* EverShelf Scale Gateway — Connection ping / test
|
* EverShelf Scale Gateway — Connection ping / test (SSRF-hardened)
|
||||||
*
|
|
||||||
* 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
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/lib/env.php';
|
||||||
|
require_once __DIR__ . '/lib/security.php';
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
header('Cache-Control: no-cache');
|
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'] ?? '';
|
$rawUrl = $_GET['url'] ?? '';
|
||||||
|
|
||||||
if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
|
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);
|
$parsed = parse_url($rawUrl);
|
||||||
$host = $parsed['host'] ?? '';
|
$host = strtolower($parsed['host'] ?? '');
|
||||||
$port = (int)($parsed['port'] ?? 8765);
|
$port = (int)($parsed['port'] ?? 8765);
|
||||||
$path = ($parsed['path'] ?? '') ?: '/';
|
$path = ($parsed['path'] ?? '') ?: '/';
|
||||||
|
|
||||||
@@ -28,6 +32,11 @@ if (!$host || $port < 1 || $port > 65535) {
|
|||||||
exit;
|
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
|
// Try to open a TCP connection with a 5-second timeout
|
||||||
$sock = @stream_socket_client("tcp://{$host}:{$port}", $errno, $errstr, 5);
|
$sock = @stream_socket_client("tcp://{$host}:{$port}", $errno, $errstr, 5);
|
||||||
if (!$sock) {
|
if (!$sock) {
|
||||||
|
|||||||
+17
-1
@@ -8,6 +8,16 @@
|
|||||||
* Usage: GET /api/scale_relay.php?url=ws%3A%2F%2F192.168.1.100%3A8765
|
* 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 ──────────────────────────────────────────────────────────
|
// ── Input validation ──────────────────────────────────────────────────────────
|
||||||
$rawUrl = $_GET['url'] ?? '';
|
$rawUrl = $_GET['url'] ?? '';
|
||||||
|
|
||||||
@@ -19,7 +29,7 @@ if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$parsed = parse_url($rawUrl);
|
$parsed = parse_url($rawUrl);
|
||||||
$wsHost = $parsed['host'] ?? '';
|
$wsHost = strtolower($parsed['host'] ?? '');
|
||||||
$wsPort = (int)($parsed['port'] ?? 8765);
|
$wsPort = (int)($parsed['port'] ?? 8765);
|
||||||
$wsPath = ($parsed['path'] ?? '') ?: '/';
|
$wsPath = ($parsed['path'] ?? '') ?: '/';
|
||||||
|
|
||||||
@@ -29,6 +39,12 @@ if (!$wsHost || $wsPort < 1 || $wsPort > 65535) {
|
|||||||
exit;
|
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 ───────────────────────────────────────────────────────────────
|
// ── SSE headers ───────────────────────────────────────────────────────────────
|
||||||
header('Content-Type: text/event-stream');
|
header('Content-Type: text/event-stream');
|
||||||
header('Cache-Control: no-cache, no-store, must-revalidate');
|
header('Cache-Control: no-cache, no-store, must-revalidate');
|
||||||
|
|||||||
@@ -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-confirmed { color: #4ade80; background: rgba(74,222,128,0.22); }
|
||||||
.scan-status-msg.state-retry { color: #fb923c; }
|
.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) — */
|
/* — Viewport overlay controls (torch / zoom / flip) — */
|
||||||
.scan-viewport-controls {
|
.scan-viewport-controls {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -2059,6 +2112,118 @@ body.server-offline .bottom-nav {
|
|||||||
box-shadow: var(--shadow);
|
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 — */
|
/* — Recent scans — */
|
||||||
.scan-recents {
|
.scan-recents {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -4295,6 +4460,93 @@ body.server-offline .bottom-nav {
|
|||||||
line-height: 1.5;
|
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 {
|
.recipe-tools-banner {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -5939,6 +6191,12 @@ body.cooking-mode-active .app-header {
|
|||||||
}
|
}
|
||||||
.banner-anomaly .alert-banner-title { color: #9a3412; }
|
.banner-anomaly .alert-banner-title { color: #9a3412; }
|
||||||
.banner-anomaly .alert-banner-counter .banner-dot.active { background: #ea580c; }
|
.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 {
|
.alert-banner.banner-no-expiry {
|
||||||
background: linear-gradient(135deg, #f0fdf4 0%, #bbf7d0 100%);
|
background: linear-gradient(135deg, #f0fdf4 0%, #bbf7d0 100%);
|
||||||
border-color: #16a34a;
|
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"] .banner-prediction .alert-banner-counter { color: #a78bfa; }
|
||||||
[data-theme="dark"] .alert-banner.banner-anomaly { background: #1a1200; border-color: #c2410c; }
|
[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"] .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"] .alert-banner.banner-no-expiry { background: #0f2a1a; border-color: #166534; }
|
||||||
[data-theme="dark"] .banner-no-expiry .alert-banner-title { color: #86efac; }
|
[data-theme="dark"] .banner-no-expiry .alert-banner-title { color: #86efac; }
|
||||||
|
|
||||||
@@ -7908,6 +8168,18 @@ body.cooking-mode-active .app-header {
|
|||||||
|
|
||||||
/* ── Recipe components ── */
|
/* ── Recipe components ── */
|
||||||
[data-theme="dark"] .recipe-expiry-note { background: #2a1e00; color: #fde68a; }
|
[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-tools-banner { background: #1a1040; border-color: #3730a3; color: #c4b5fd; }
|
||||||
[data-theme="dark"] .recipe-tool-chip { background: #2e1a4a; 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; }
|
[data-theme="dark"] .recipe-step-appliance { background: #052e16; border-color: #166534; color: #4ade80; }
|
||||||
|
|||||||
+123
-40
@@ -317,12 +317,17 @@ function scaleInit() {
|
|||||||
_scaleConnect(s.scale_gateway_url);
|
_scaleConnect(s.scale_gateway_url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _scaleAuthQuery() {
|
||||||
|
const tok = typeof getApiToken === 'function' ? getApiToken() : '';
|
||||||
|
return tok ? '&api_token=' + encodeURIComponent(tok) : '';
|
||||||
|
}
|
||||||
|
|
||||||
function _scaleConnect(url) {
|
function _scaleConnect(url) {
|
||||||
if (_scaleEs) { try { _scaleEs.close(); } catch(e) {} _scaleEs = null; }
|
if (_scaleEs) { try { _scaleEs.close(); } catch(e) {} _scaleEs = null; }
|
||||||
if (_scaleReconnectTimer) { clearTimeout(_scaleReconnectTimer); _scaleReconnectTimer = null; }
|
if (_scaleReconnectTimer) { clearTimeout(_scaleReconnectTimer); _scaleReconnectTimer = null; }
|
||||||
try {
|
try {
|
||||||
// Connect via the PHP SSE relay so the HTTPS page is not blocked by mixed-content
|
// EventSource cannot send custom headers — pass api_token in query string
|
||||||
_scaleEs = new EventSource('api/scale_relay.php?url=' + encodeURIComponent(url));
|
_scaleEs = new EventSource('api/scale_relay.php?url=' + encodeURIComponent(url) + _scaleAuthQuery());
|
||||||
_scaleEs.onopen = () => _scaleUpdateStatus('searching');
|
_scaleEs.onopen = () => _scaleUpdateStatus('searching');
|
||||||
_scaleEs.onmessage = (evt) => {
|
_scaleEs.onmessage = (evt) => {
|
||||||
try { _scaleOnMessage(JSON.parse(evt.data)); } catch(e) {}
|
try { _scaleOnMessage(JSON.parse(evt.data)); } catch(e) {}
|
||||||
@@ -1041,7 +1046,7 @@ function testScaleConnection() {
|
|||||||
statusEl.textContent = '❌ ' + t('scale.timeout');
|
statusEl.textContent = '❌ ' + t('scale.timeout');
|
||||||
statusEl.className = 'settings-status error';
|
statusEl.className = 'settings-status error';
|
||||||
}, 8000);
|
}, 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(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
@@ -1073,7 +1078,7 @@ async function discoverScaleGateway() {
|
|||||||
status.textContent = '🔍 Scanning local network for scale gateway…';
|
status.textContent = '🔍 Scanning local network for scale gateway…';
|
||||||
|
|
||||||
try {
|
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();
|
const data = await res.json();
|
||||||
|
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
@@ -1271,8 +1276,8 @@ function _setThemeMode(mode) {
|
|||||||
// Persist dark_mode to server .env immediately (no need to send the full
|
// Persist dark_mode to server .env immediately (no need to send the full
|
||||||
// settings payload — save_settings only updates keys present in the body
|
// settings payload — save_settings only updates keys present in the body
|
||||||
// and keeps all other .env values intact).
|
// and keeps all other .env values intact).
|
||||||
const token = document.getElementById('setting-settings-token')?.value.trim() || '';
|
const token = document.getElementById('setting-settings-token')?.value.trim() || (typeof getApiToken === 'function' ? getApiToken() : '');
|
||||||
const headers = token ? { 'X-Settings-Token': token } : {};
|
const headers = token ? { 'X-API-Token': token } : {};
|
||||||
api('save_settings', {}, 'POST', { dark_mode: mode }, headers).catch(() => {});
|
api('save_settings', {}, 'POST', { dark_mode: mode }, headers).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1284,7 +1289,9 @@ setInterval(() => {
|
|||||||
|
|
||||||
// ===== EXPORT INVENTORY =====
|
// ===== EXPORT INVENTORY =====
|
||||||
function exportInventory(format) {
|
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') {
|
if (format === 'csv') {
|
||||||
// Direct download via <a> trick
|
// Direct download via <a> trick
|
||||||
const a = document.createElement('a');
|
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_enabled','ha_url','ha_tts_entity','ha_webhook_id','ha_webhook_events',
|
||||||
'ha_notify_service','ha_expiry_days'];
|
'ha_notify_service','ha_expiry_days'];
|
||||||
// Note: gemini_key is never sent from server; settings_token_set is metadata only
|
// 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');
|
const tokenHintEl = document.getElementById('settings-token-status-hint');
|
||||||
if (tokenHintEl) tokenHintEl.style.display = settingsTokenRequired ? 'block' : 'none';
|
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;
|
let changed = false;
|
||||||
for (const key of serverKeys) {
|
for (const key of serverKeys) {
|
||||||
if (serverSettings[key] !== undefined && serverSettings[key] !== null && serverSettings[key] !== '') {
|
if (serverSettings[key] !== undefined && serverSettings[key] !== null && serverSettings[key] !== '') {
|
||||||
@@ -3797,8 +3808,9 @@ async function saveSettings() {
|
|||||||
|
|
||||||
// Save ALL settings to server .env
|
// Save ALL settings to server .env
|
||||||
try {
|
try {
|
||||||
const settingsToken = document.getElementById('setting-settings-token')?.value.trim() || '';
|
const settingsToken = document.getElementById('setting-settings-token')?.value.trim() || (typeof getApiToken === 'function' ? getApiToken() : '');
|
||||||
const tokenHeader = settingsToken ? { 'X-Settings-Token': settingsToken } : {};
|
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', {
|
const result = await api('save_settings', {}, 'POST', {
|
||||||
...(s.gemini_key ? { gemini_key: s.gemini_key } : {}),
|
...(s.gemini_key ? { gemini_key: s.gemini_key } : {}),
|
||||||
bring_email: s.bring_email,
|
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 opts = { method, cache: 'no-store' };
|
||||||
|
const authHdrs = typeof apiAuthHeaders === 'function' ? apiAuthHeaders() : {};
|
||||||
if (body) {
|
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);
|
opts.body = JSON.stringify(body);
|
||||||
} else if (Object.keys(extraHeaders).length > 0) {
|
} else {
|
||||||
opts.headers = { ...extraHeaders };
|
opts.headers = { ...authHdrs, ...extraHeaders };
|
||||||
}
|
}
|
||||||
let res;
|
let res;
|
||||||
try {
|
try {
|
||||||
@@ -3966,6 +3979,10 @@ async function api(action, params = {}, method = 'GET', body = null, extraHeader
|
|||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
remoteLog('API_ERROR', `${action} HTTP ${res.status}`);
|
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)
|
// Report HTTP 5xx as server errors (not 4xx which are usually user errors)
|
||||||
if (res.status >= 500) {
|
if (res.status >= 500) {
|
||||||
reportError({
|
reportError({
|
||||||
@@ -3981,6 +3998,10 @@ async function api(action, params = {}, method = 'GET', body = null, extraHeader
|
|||||||
_offlineCacheSet(data.inventory);
|
_offlineCacheSet(data.inventory);
|
||||||
}
|
}
|
||||||
if (action === 'get_settings' && data && data.success !== false) {
|
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);
|
_offlineCacheSetSettings(data);
|
||||||
}
|
}
|
||||||
if (data && data.error) {
|
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) {
|
function stripHtml(str) {
|
||||||
if (!str) return '';
|
if (!str) return '';
|
||||||
return str.replace(/<[^>]*>/g, '');
|
return str.replace(/<[^>]*>/g, '');
|
||||||
@@ -13952,7 +13966,7 @@ function renderRecipe(r) {
|
|||||||
|
|
||||||
const isFav = !!(_cachedRecipe && _cachedRecipe.is_favorite);
|
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)
|
// Meta tags + star (#124) + persons rescaler (#123)
|
||||||
html += '<div class="recipe-meta">';
|
html += '<div class="recipe-meta">';
|
||||||
@@ -13964,7 +13978,7 @@ function renderRecipe(r) {
|
|||||||
</span>`;
|
</span>`;
|
||||||
if (r.prep_time) html += `<span class="recipe-tag">🔪 ${r.prep_time}</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.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)
|
// Favorite star button (#124) — visible only for archived recipes (have an id)
|
||||||
if (_cachedRecipe && _cachedRecipe.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>`;
|
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
|
// Expiry note
|
||||||
if (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)
|
// 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())
|
? r.tools_needed.filter(t => t && t.trim())
|
||||||
: _extractToolsFromSteps(r.steps);
|
: _extractToolsFromSteps(r.steps);
|
||||||
if (tools.length > 0) {
|
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
|
// Ingredients
|
||||||
@@ -13991,8 +14005,8 @@ function renderRecipe(r) {
|
|||||||
const qtyNum = Math.round((ing.qty_number || 0) * 10) / 10;
|
const qtyNum = Math.round((ing.qty_number || 0) * 10) / 10;
|
||||||
const loc = (ing.location || 'dispensa').replace(/'/g, "\\'");
|
const loc = (ing.location || 'dispensa').replace(/'/g, "\\'");
|
||||||
const alreadyUsed = ing.used === true;
|
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, '"')}">`;
|
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="${t('action.edit') || 'Modifica'}">${ing.name}</strong>${ing.brand ? ' <em>(' + ing.brand + ')</em>' : ''}: <span class="recipe-ing-qty">${ing.qty}</span> ✅`;
|
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
|
// Detail line: location + expiry
|
||||||
let details = [];
|
let details = [];
|
||||||
const ingredientLocLabels = Object.fromEntries(Object.entries(LOCATIONS).map(([k,v]) => [k, `${v.icon} ${v.label}`]));
|
const ingredientLocLabels = Object.fromEntries(Object.entries(LOCATIONS).map(([k,v]) => [k, `${v.icon} ${v.label}`]));
|
||||||
@@ -14016,7 +14030,7 @@ function renderRecipe(r) {
|
|||||||
html += `</li>`;
|
html += `</li>`;
|
||||||
} else {
|
} else {
|
||||||
const pantryIcon = ing.from_pantry ? ' ✅' : ' 🛒';
|
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, '"')}"><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>';
|
html += '</ul>';
|
||||||
@@ -14028,13 +14042,60 @@ function renderRecipe(r) {
|
|||||||
html += `<h3>${t('recipes.steps_title')}</h3><ol>`;
|
html += `<h3>${t('recipes.steps_title')}</h3><ol>`;
|
||||||
(r.steps || []).forEach(step => {
|
(r.steps || []).forEach(step => {
|
||||||
const appliance = _stepAppliance(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>';
|
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) {
|
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;
|
document.getElementById('recipe-content').innerHTML = html;
|
||||||
@@ -14453,10 +14514,7 @@ function _buildTtsRequest(text, s) {
|
|||||||
function _buildHaTtsRequest(text, s) {
|
function _buildHaTtsRequest(text, s) {
|
||||||
const haUrl = (s.ha_url || '').replace(/\/$/, '');
|
const haUrl = (s.ha_url || '').replace(/\/$/, '');
|
||||||
const url = haUrl + '/api/services/tts/speak';
|
const url = haUrl + '/api/services/tts/speak';
|
||||||
const headers = {
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': 'Bearer ' + (s.ha_token || ''),
|
|
||||||
};
|
|
||||||
const body = JSON.stringify({
|
const body = JSON.stringify({
|
||||||
entity_id: s.ha_tts_entity || '',
|
entity_id: s.ha_tts_entity || '',
|
||||||
message: text,
|
message: text,
|
||||||
@@ -14739,8 +14797,9 @@ async function saveHaSettings() {
|
|||||||
|
|
||||||
const statusEl = document.getElementById('ha-save-status');
|
const statusEl = document.getElementById('ha-save-status');
|
||||||
try {
|
try {
|
||||||
const settingsToken = document.getElementById('setting-settings-token')?.value.trim() || '';
|
const settingsToken = document.getElementById('setting-settings-token')?.value.trim() || (typeof getApiToken === 'function' ? getApiToken() : '');
|
||||||
const tokenHeader = settingsToken ? { 'X-Settings-Token': settingsToken } : {};
|
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', {
|
const result = await api('save_settings', {}, 'POST', {
|
||||||
ha_enabled: haEnabled,
|
ha_enabled: haEnabled,
|
||||||
ha_url: haUrl,
|
ha_url: haUrl,
|
||||||
@@ -17522,7 +17581,11 @@ async function _runStartupCheck() {
|
|||||||
|
|
||||||
if (!wrapEl) return true; // preloader already removed
|
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
|
// Switch from spinner to progress bar
|
||||||
if (spinnerEl) spinnerEl.style.display = 'none';
|
if (spinnerEl) spinnerEl.style.display = 'none';
|
||||||
@@ -17553,6 +17616,12 @@ async function _runStartupCheck() {
|
|||||||
el.textContent = cleanLabel;
|
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)
|
// Phase 1: animate 0→15% while fetching (so it never looks stuck)
|
||||||
setProgress(0, tl('connecting', 'Connessione al server...'));
|
setProgress(0, tl('connecting', 'Connessione al server...'));
|
||||||
let _fetchDone = false;
|
let _fetchDone = false;
|
||||||
@@ -17568,9 +17637,22 @@ async function _runStartupCheck() {
|
|||||||
try {
|
try {
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
const tid = setTimeout(() => ctrl.abort(), 12000);
|
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);
|
clearTimeout(tid);
|
||||||
result = await resp.json();
|
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) {
|
} catch(e) {
|
||||||
clearInterval(slowAnim);
|
clearInterval(slowAnim);
|
||||||
_showStartupErrorPopup(
|
_showStartupErrorPopup(
|
||||||
@@ -17697,9 +17779,10 @@ async function _runStartupCheck() {
|
|||||||
// The bar already shows 100%; we just update the label for a moment.
|
// The bar already shows 100%; we just update the label for a moment.
|
||||||
try {
|
try {
|
||||||
setProgress(100, tl('syncing_local', 'Sincronizzazione dati locali...'), 'ok');
|
setProgress(100, tl('syncing_local', 'Sincronizzazione dati locali...'), 'ok');
|
||||||
|
const authH = typeof apiAuthHeaders === 'function' ? apiAuthHeaders() : {};
|
||||||
const [invData, settingsData] = await Promise.all([
|
const [invData, settingsData] = await Promise.all([
|
||||||
fetch('api/index.php?action=inventory_list').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').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 (invData && Array.isArray(invData.inventory)) _offlineCacheSet(invData.inventory);
|
||||||
if (settingsData && settingsData.success !== false) _offlineCacheSetSettings(settingsData);
|
if (settingsData && settingsData.success !== false) _offlineCacheSetSettings(settingsData);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
Vendored
+4
File diff suppressed because one or more lines are too long
@@ -1,13 +1,19 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Daily backup of EverShelf database (local only)
|
# Daily backup of EverShelf database (local only)
|
||||||
# The database is NOT pushed to remote repositories.
|
# Retention follows BACKUP_RETENTION_DAYS from .env (default 3)
|
||||||
# Runs via cron: creates a local timestamped backup copy
|
|
||||||
#
|
|
||||||
# Example crontab entry:
|
|
||||||
# 0 3 * * * /var/www/html/evershelf/backup.sh
|
|
||||||
|
|
||||||
INSTALL_DIR="$(cd "$(dirname "$0")" && pwd)"
|
set -euo pipefail
|
||||||
|
INSTALL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
BACKUP_DIR="${INSTALL_DIR}/data/backups"
|
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"
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
@@ -19,5 +25,5 @@ fi
|
|||||||
DATE=$(date '+%Y-%m-%d_%H%M')
|
DATE=$(date '+%Y-%m-%d_%H%M')
|
||||||
cp "$DB_FILE" "${BACKUP_DIR}/evershelf_${DATE}.db"
|
cp "$DB_FILE" "${BACKUP_DIR}/evershelf_${DATE}.db"
|
||||||
|
|
||||||
# Keep only the last 7 backups
|
# Keep only the newest N backups
|
||||||
ls -t "${BACKUP_DIR}"/evershelf_*.db 2>/dev/null | tail -n +8 | xargs -r rm --
|
ls -t "${BACKUP_DIR}"/evershelf_*.db 2>/dev/null | tail -n +$((RETENTION + 1)) | xargs -r rm --
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Deny all direct HTTP access to runtime data (DB, tokens, caches, logs)
|
||||||
|
Require all denied
|
||||||
@@ -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
|
// Pending WebView permission request
|
||||||
private var pendingWebPermission: PermissionRequest? = null
|
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 {
|
companion object {
|
||||||
private const val FILE_CHOOSER_REQUEST = 1002
|
private const val FILE_CHOOSER_REQUEST = 1002
|
||||||
private const val PERMISSION_REQUEST_CODE = 1003
|
private const val PERMISSION_REQUEST_CODE = 1003
|
||||||
@@ -150,18 +164,18 @@ class KioskActivity : AppCompatActivity() {
|
|||||||
override fun onStart(utteranceId: String?) {}
|
override fun onStart(utteranceId: String?) {}
|
||||||
override fun onDone(utteranceId: String?) {
|
override fun onDone(utteranceId: String?) {
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
webView.evaluateJavascript("if(window._kioskTtsDone)window._kioskTtsDone('$utteranceId')", null)
|
safeEvalJs("if(window._kioskTtsDone)window._kioskTtsDone('$utteranceId')")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@Deprecated("Deprecated in API 21")
|
@Deprecated("Deprecated in API 21")
|
||||||
override fun onError(utteranceId: String?) {
|
override fun onError(utteranceId: String?) {
|
||||||
runOnUiThread {
|
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) {
|
override fun onError(utteranceId: String?, errorCode: Int) {
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
webView.evaluateJavascript("if(window._kioskTtsError)window._kioskTtsError('$utteranceId',$errorCode)", null)
|
safeEvalJs("if(window._kioskTtsError)window._kioskTtsError('$utteranceId',$errorCode)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+19
-11
@@ -11,9 +11,13 @@
|
|||||||
<title>EverShelf</title>
|
<title>EverShelf</title>
|
||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
<link rel="icon" type="image/png" href="assets/img/logo/logo_icon.png">
|
<link rel="icon" type="image/png" href="assets/img/logo/logo_icon.png">
|
||||||
<link rel="stylesheet" href="assets/css/style.css?v=20260517a">
|
<link rel="stylesheet" href="assets/css/style.css?v=20260603a">
|
||||||
<!-- QuaggaJS for barcode scanning -->
|
<!-- Core modules (auth, DOM helpers) -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"></script>
|
<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 -->
|
<!-- @xenova/transformers: ES-module bootstrap that exposes a lazy category-classifier as window._categoryPipelinePromise -->
|
||||||
<script type="module">
|
<script type="module">
|
||||||
// Lazy-load the embedding pipeline only when first needed.
|
// Lazy-load the embedding pipeline only when first needed.
|
||||||
@@ -25,11 +29,15 @@
|
|||||||
if (window._categoryPipelinePromise) return window._categoryPipelinePromise;
|
if (window._categoryPipelinePromise) return window._categoryPipelinePromise;
|
||||||
window._categoryPipelinePromise = (async () => {
|
window._categoryPipelinePromise = (async () => {
|
||||||
try {
|
try {
|
||||||
const { pipeline, env } = await import(
|
const localBase = 'assets/vendor/transformers/';
|
||||||
'https://cdn.jsdelivr.net/npm/@xenova/transformers@2/src/transformers.min.js'
|
const cdnBase = 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2/dist/';
|
||||||
);
|
let pipeline, env;
|
||||||
// Keep WASM/model files in the browser cache; disable remote model check
|
try {
|
||||||
// to avoid CORS issues with the self-hosted instance.
|
({ pipeline, env } = await import(localBase + 'transformers.min.js'));
|
||||||
|
} catch (_) {
|
||||||
|
({ pipeline, env } = await import(cdnBase + 'transformers.min.js'));
|
||||||
|
}
|
||||||
|
env.localModelPath = localBase;
|
||||||
env.allowRemoteModels = true;
|
env.allowRemoteModels = true;
|
||||||
env.useBrowserCache = true;
|
env.useBrowserCache = true;
|
||||||
const pipe = await pipeline(
|
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>
|
<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">
|
<div class="form-group">
|
||||||
<label data-i18n="settings.security.token_label">Token di accesso</label>
|
<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>
|
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-settings-token')" data-i18n="btn.toggle_password">👁️ Mostra/Nascondi</button>
|
||||||
</div>
|
</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>
|
||||||
<div class="settings-card">
|
<div class="settings-card">
|
||||||
<h4 data-i18n="settings.security.title">🔒 Certificato HTTPS</h4>
|
<h4 data-i18n="settings.security.title">🔒 Certificato HTTPS</h4>
|
||||||
@@ -1962,6 +1970,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="assets/js/app.js?v=20260518c"></script>
|
<script src="assets/js/app.js?v=20260603c"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Require all denied
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+14
@@ -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";
|
||||||
Executable
+12
@@ -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}"
|
||||||
Executable
+57
@@ -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
@@ -416,7 +416,16 @@
|
|||||||
"load_error": "Fehler beim Laden",
|
"load_error": "Fehler beim Laden",
|
||||||
"favorite": "Zu Favoriten hinzufügen",
|
"favorite": "Zu Favoriten hinzufügen",
|
||||||
"unfavorite": "Aus Favoriten entfernen",
|
"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": {
|
"shopping": {
|
||||||
"title": "🛒 Einkaufsliste",
|
"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.",
|
"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",
|
"retry": "Erneut versuchen",
|
||||||
"syncing_local": "Lokale Daten synchronisieren...",
|
"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": {
|
"stats_monthly": {
|
||||||
"title": "Monatsstatistik",
|
"title": "Monatsstatistik",
|
||||||
|
|||||||
+16
-2
@@ -416,7 +416,16 @@
|
|||||||
"load_error": "Loading error",
|
"load_error": "Loading error",
|
||||||
"favorite": "Add to favourites",
|
"favorite": "Add to favourites",
|
||||||
"unfavorite": "Remove from 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": {
|
"shopping": {
|
||||||
"title": "🛒 Shopping List",
|
"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.",
|
"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",
|
"retry": "Retry",
|
||||||
"syncing_local": "Syncing local data...",
|
"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": {
|
"stats_monthly": {
|
||||||
"title": "Monthly Stats",
|
"title": "Monthly Stats",
|
||||||
|
|||||||
+16
-2
@@ -411,7 +411,16 @@
|
|||||||
"load_error": "Error de carga",
|
"load_error": "Error de carga",
|
||||||
"favorite": "Añadir a favoritos",
|
"favorite": "Añadir a favoritos",
|
||||||
"unfavorite": "Quitar de 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": {
|
"shopping": {
|
||||||
"title": "🛒 Lista de la compra",
|
"title": "🛒 Lista de la compra",
|
||||||
@@ -1410,7 +1419,12 @@
|
|||||||
"error_network": "No se puede contactar con el servidor. Comprueba tu conexión de red.",
|
"error_network": "No se puede contactar con el servidor. Comprueba tu conexión de red.",
|
||||||
"retry": "Reintentar",
|
"retry": "Reintentar",
|
||||||
"syncing_local": "Sincronizando datos locales...",
|
"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": {
|
"stats_monthly": {
|
||||||
"title": "Estadísticas Mensuales",
|
"title": "Estadísticas Mensuales",
|
||||||
|
|||||||
+16
-2
@@ -411,7 +411,16 @@
|
|||||||
"load_error": "Erreur de chargement",
|
"load_error": "Erreur de chargement",
|
||||||
"favorite": "Ajouter aux favoris",
|
"favorite": "Ajouter aux favoris",
|
||||||
"unfavorite": "Retirer des 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": {
|
"shopping": {
|
||||||
"title": "🛒 Liste de courses",
|
"title": "🛒 Liste de courses",
|
||||||
@@ -1410,7 +1419,12 @@
|
|||||||
"error_network": "Impossible de contacter le serveur. Vérifiez votre connexion réseau.",
|
"error_network": "Impossible de contacter le serveur. Vérifiez votre connexion réseau.",
|
||||||
"retry": "Réessayer",
|
"retry": "Réessayer",
|
||||||
"syncing_local": "Synchronisation des données locales...",
|
"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": {
|
"stats_monthly": {
|
||||||
"title": "Statistiques Mensuelles",
|
"title": "Statistiques Mensuelles",
|
||||||
|
|||||||
+16
-2
@@ -416,7 +416,16 @@
|
|||||||
"load_error": "Errore nel caricamento",
|
"load_error": "Errore nel caricamento",
|
||||||
"favorite": "Aggiungi ai preferiti",
|
"favorite": "Aggiungi ai preferiti",
|
||||||
"unfavorite": "Rimuovi dai 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": {
|
"shopping": {
|
||||||
"title": "🛒 Lista della Spesa",
|
"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.",
|
"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",
|
"retry": "Riprova",
|
||||||
"syncing_local": "Sincronizzazione dati locali...",
|
"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": {
|
"stats_monthly": {
|
||||||
"title": "Statistiche Mensili",
|
"title": "Statistiche Mensili",
|
||||||
|
|||||||
Reference in New Issue
Block a user