diff --git a/.env.example b/.env.example index a44f0a9..555557c 100644 --- a/.env.example +++ b/.env.example @@ -125,10 +125,24 @@ GDRIVE_FOLDER_ID= GDRIVE_RETENTION_DAYS=30 # ── Security ───────────────────────────────────────────────────────────────── -# SETTINGS_TOKEN: if set, the Settings screen requires this token to save changes. -# Leave empty to allow anyone with access to the server to change settings. +# API_TOKEN: when set, all API calls require header X-API-Token (or ?api_token= for HA). +# SETTINGS_TOKEN: legacy alias — use API_TOKEN for new installs. +API_TOKEN= SETTINGS_TOKEN= +# CORS_ORIGIN: comma-separated allowed origins (empty = same-origin only, no wildcard) +CORS_ORIGIN= + +# GitHub automatic issue reporting (encrypted storage recommended) +# Option A — plain ( .env is gitignored ): +# GH_ISSUE_TOKEN=ghp_... +# Option B — encrypted (php scripts/encrypt-gh-token.php 'ghp_...' 'secret-key'): +GH_ISSUE_TOKEN= +GH_ISSUE_TOKEN_ENC= +GH_ISSUE_TOKEN_KEY= + +# NOTE: Run `php scripts/migrate-env-security.php` once after upgrading to migrate legacy tokens. + # INSTANCE_NAME: display name for this EverShelf instance (used by the HA integration # for Zeroconf discovery label and device name in Home Assistant). # Defaults to the server hostname if left empty. @@ -160,5 +174,5 @@ HA_EXPIRY_DAYS=3 # DEMO_MODE: when true, all write operations are blocked (for public demos) DEMO_MODE=false -# NOTE: GitHub error reporting uses a token hardcoded in api/index.php. -# To rotate it, update the GH_ISSUE_TOKEN constant there. +# CRON_LOG_MAX_BYTES: rotate data/cron.log when larger (default 524288 = 512 KB) +CRON_LOG_MAX_BYTES=524288 diff --git a/.htaccess b/.htaccess index 342fffe..d7dfd05 100644 --- a/.htaccess +++ b/.htaccess @@ -1,5 +1,19 @@ RewriteEngine On +# Block sensitive files (Apache 2.4+) + + Require all denied + + + Require all denied + + + Require all denied + + + Require all denied + + # Force HTTPS RewriteCond %{HTTPS} !=on RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] diff --git a/README.md b/README.md index 1665701..8013406 100644 --- a/README.md +++ b/README.md @@ -236,7 +236,7 @@ TTS_ENABLED=true # Optional: DB retention and cleanup (applied automatically each cron cycle) RECIPE_RETENTION_DAYS=7 # delete recipe plans older than N days -TRANSACTION_RETENTION_DAYS=7 # delete stock transactions older than N days +TRANSACTION_RETENTION_DAYS=90 # delete stock transactions older than N days (min 30 enforced) # Optional: Vacuum-sealed expiry grace period VACUUM_EXPIRY_EXTENSION_DAYS=30 # extra days before vacuum-sealed items are flagged expired @@ -247,8 +247,11 @@ GEMINI_COST_25F_OUT=0.60 GEMINI_COST_20F_IN=0.10 GEMINI_COST_20F_OUT=0.40 -# Optional: Security — protect the save_settings endpoint -# Set a strong random string; the Settings UI will ask for it before saving +# Optional: Security — protect all API endpoints +# Set a strong random string; clients send it as X-API-Token header (or ?api_token= for HA) +API_TOKEN= + +# Optional: Legacy alias for API_TOKEN (settings save only) SETTINGS_TOKEN= # Optional: Demo mode — block all write operations at the router level @@ -416,8 +419,11 @@ evershelf-kiosk/ # 📺 Android kiosk app (add-on) - **Credentials** are stored in `.env` (server-side, never committed to Git) - **Database** stays local — never pushed to remote repositories -- **API keys are never exposed to the browser** — `get_settings` returns only boolean flags (`gemini_key_set`, `settings_token_set`), never raw key values -- **Settings write protection** — set `SETTINGS_TOKEN` in `.env` to require a secret token (`X-Settings-Token` header) for all `save_settings` calls; validated with `hash_equals` to prevent timing attacks +- **Apache/Nginx hardening** — `.env`, `data/`, and `logs/` are blocked from direct HTTP access +- **API token** — set `API_TOKEN` in `.env` to require `X-API-Token` on all API calls (Home Assistant: `?api_token=`) +- **API keys are never exposed to the browser** — `get_settings` returns only boolean flags (`gemini_key_set`, `ha_token_set`, …) +- **GitHub Issues token** — stored encrypted as `GH_ISSUE_TOKEN_ENC` + `GH_ISSUE_TOKEN_KEY` (see `scripts/encrypt-gh-token.php`) +- **Settings write protection** — `save_settings` requires the same API token when configured; validated with `hash_equals` - **Demo / public mode** — set `DEMO_MODE=true` to block all write operations at the PHP router level before any business logic runs - The API uses **parameterized SQL queries** (PDO prepared statements) against injection - **Input validation** on all inventory operations (quantity bounds, location whitelist) @@ -472,12 +478,6 @@ Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed g 4. Push to the branch (`git push origin feature/my-feature`) 5. Open a Pull Request ---- - -## 🤝 Contributing - -EverShelf is a community project and contributions of any size are welcome! - ### Easiest way to start — translate EverShelf into your language Translations are just JSON files. No coding, no setup — fork → edit → PR. diff --git a/api/bootstrap.php b/api/bootstrap.php new file mode 100644 index 0000000..bf8658b --- /dev/null +++ b/api/bootstrap.php @@ -0,0 +1,11 @@ + value pairs. - */ -function loadEnv(): array { - static $cache = null; - if ($cache !== null) return $cache; - $envFile = __DIR__ . '/../.env'; - $cache = []; - if (file_exists($envFile)) { - $lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); - foreach ($lines as $line) { - if (strpos($line, '#') === 0 || strpos($line, '=') === false) continue; - list($key, $val) = explode('=', $line, 2); - $cache[trim($key)] = trim($val); - } - } - return $cache; -} - -/** - * Get a single environment variable, with optional default. - */ -function env(string $key, string $default = ''): string { - $vars = loadEnv(); - return $vars[$key] ?? $default; -} - // When included by the cron script, skip HTTP headers and routing entirely if (!defined('CRON_MODE')) { header('Content-Type: application/json; charset=utf-8'); -header('Access-Control-Allow-Origin: *'); -header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS'); -header('Access-Control-Allow-Headers: Content-Type'); +evershelfSendCorsHeaders(); if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); @@ -121,6 +50,17 @@ if (($_GET['action'] ?? '') === 'ping') { exit; } +// ── App bootstrap — same-origin browsers receive API token automatically ─────── +if (($_GET['action'] ?? '') === 'app_bootstrap') { + $required = evershelfApiTokenRequired(); + $out = ['api_token_required' => $required]; + if ($required && evershelfIsSameOriginBrowser()) { + $out['api_token'] = evershelfEffectiveApiToken(); + } + echo json_encode($out); + exit; +} + // ── Google Drive OAuth callback — returns HTML, not JSON ────────────────────── if (($_GET['action'] ?? '') === 'gdrive_oauth_callback') { _gdriveHandleOAuthCallback(); @@ -130,9 +70,9 @@ if (($_GET['action'] ?? '') === 'gdrive_oauth_callback') { // ── Log viewer — returns last N log lines (requires SETTINGS_TOKEN if set) ──── if (($_GET['action'] ?? '') === 'get_logs') { require_once __DIR__ . '/logger.php'; - $token = loadEnv()['SETTINGS_TOKEN'] ?? ''; - $reqTok = $_GET['token'] ?? $_SERVER['HTTP_X_SETTINGS_TOKEN'] ?? ''; - if (!empty($token) && $reqTok !== $token) { + $token = evershelfEffectiveApiToken(); + $reqTok = evershelfGetProvidedApiTokenFromHeaders() ?: (string)($_GET['token'] ?? ''); + if ($token !== '' && ($reqTok === '' || !hash_equals($token, $reqTok))) { EverLog::warn('get_logs: unauthorized (403)'); http_response_code(403); echo json_encode(['error' => 'Unauthorized']); @@ -323,8 +263,17 @@ if (($_GET['action'] ?? '') === 'gemini_usage') { } } -// ── Health check — startup diagnostic (no rate-limit, no auth required) ────── +// ── Health check — minimal public probe; full diagnostics require API token ── if (($_GET['action'] ?? '') === 'health_check') { + if (evershelfApiTokenRequired() && !evershelfApiTokenValid()) { + header('Content-Type: application/json'); + echo json_encode([ + 'ok' => true, + 'public' => true, + 'api_token_required' => true, + ], JSON_UNESCAPED_UNICODE); + exit; + } $checks = []; // ── Helper: read .env values without triggering app init ───────────────── @@ -363,7 +312,7 @@ if (($_GET['action'] ?? '') === 'health_check') { $dataDir = __DIR__ . '/../data'; if (!is_dir($dataDir)) @mkdir($dataDir, 0775, true); $dataDirOk = is_dir($dataDir) && is_writable($dataDir); - $checks['data_dir'] = ['ok' => $dataDirOk, 'path' => realpath($dataDir) ?: $dataDir]; + $checks['data_dir'] = ['ok' => $dataDirOk]; // data/rate_limits/ $rlDir = $dataDir . '/rate_limits'; @@ -714,24 +663,19 @@ $method = $_SERVER['REQUEST_METHOD']; $action = $_GET['action'] ?? ''; EverLog::request($action, $method); +// API token auth (when API_TOKEN or SETTINGS_TOKEN is configured) +evershelfRequireApiAuth($action, $method); + } // end !CRON_MODE block for router bootstrap if (!defined('CRON_MODE')): try { - // DEMO_MODE guard - if (env('DEMO_MODE') === 'true') { - $demoBlocked = [ - 'save_settings', 'product_save', 'product_delete', 'product_merge', - 'inventory_add', 'inventory_use', 'inventory_update', 'inventory_remove', - 'dismiss_anomaly', 'bring_add', 'bring_remove', 'bring_sync', - 'backup_delete', 'backup_restore', - ]; - if (in_array($action, $demoBlocked, true)) { - EverLog::warn('demo_mode blocked (403)'); - http_response_code(403); - echo json_encode(['success' => false, 'error' => 'demo_mode']); - exit; - } + // DEMO_MODE — block all writes and AI generation + if (evershelfDemoBlocksAction($action, $method)) { + EverLog::warn('demo_mode blocked (403)', ['action' => $action]); + http_response_code(403); + echo json_encode(['success' => false, 'error' => 'demo_mode']); + exit; } switch ($action) { @@ -760,6 +704,9 @@ try { case 'products_search': searchProducts($db); break; + case 'inventory_search': + searchInventoryProducts($db); + break; // ===== INVENTORY ===== case 'inventory_list': @@ -813,6 +760,10 @@ try { getInventoryAnomalies($db); break; + case 'inventory_duplicate_loss_checks': + getDuplicateLossChecks($db); + break; + case 'dismiss_anomaly': dismissInventoryAnomaly(); break; @@ -1252,9 +1203,33 @@ function ttsProxy() { $method = isset($body['method']) ? strtoupper(trim($body['method'])) : 'POST'; $headers = isset($body['headers']) && is_array($body['headers']) ? $body['headers'] : []; $payload = isset($body['payload']) ? $body['payload'] : ''; - $contentType = ''; - foreach ($headers as $k => $v) { - if (strtolower($k) === 'content-type') { $contentType = $v; break; } + + // Never trust client-supplied auth headers — inject from server .env + $headers = array_filter($headers, static function ($k) { + $lk = strtolower((string)$k); + return !in_array($lk, ['authorization', 'x-api-key', 'x-auth-token'], true); + }, ARRAY_FILTER_USE_KEY); + + $haBase = rtrim(env('HA_URL', ''), '/'); + if ($haBase !== '' && str_starts_with($url, $haBase)) { + $haTok = env('HA_TOKEN'); + if ($haTok !== '') { + $headers['Authorization'] = 'Bearer ' . $haTok; + } + } elseif ($url !== '' && $url === env('TTS_URL', '')) { + $authType = env('TTS_AUTH_TYPE', 'bearer'); + if ($authType === 'bearer') { + $tok = env('TTS_TOKEN'); + if ($tok !== '') { + $headers['Authorization'] = 'Bearer ' . $tok; + } + } elseif ($authType === 'header') { + $hn = env('TTS_AUTH_HEADER_NAME'); + $hv = env('TTS_AUTH_HEADER_VALUE'); + if ($hn !== '') { + $headers[$hn] = $hv; + } + } } if (!$url || !preg_match('/^https?:\/\/.+/', $url)) { @@ -1424,8 +1399,6 @@ function _haProductSelect(): string { */ function haInventorySensor(PDO $db): void { header('Content-Type: application/json; charset=utf-8'); - header('Access-Control-Allow-Origin: *'); - $sensor = strtolower(trim($_GET['sensor'] ?? 'overview')); $expiryDays = max(1, min(90, (int)($_GET['expiry_days'] ?? env('HA_EXPIRY_DAYS', 3)))); @@ -1448,7 +1421,6 @@ function haInventorySensor(PDO $db): void { $stmt->execute($params); $items = array_map('_haFormatProduct', $stmt->fetchAll(PDO::FETCH_ASSOC)); header('Content-Type: application/json; charset=utf-8'); - header('Access-Control-Allow-Origin: *'); echo json_encode([ 'state' => count($items), 'items' => $items, @@ -1693,7 +1665,6 @@ function haInventorySensor(PDO $db): void { */ function haCalendar(PDO $db): void { header('Content-Type: application/json; charset=utf-8'); - header('Access-Control-Allow-Origin: *'); try { $rows = $db->query( "SELECT p.name, i.quantity, p.unit, i.location, i.expiry_date @@ -1728,8 +1699,6 @@ function haCalendar(PDO $db): void { */ function haSuggestRecipe(PDO $db): void { header('Content-Type: application/json; charset=utf-8'); - header('Access-Control-Allow-Origin: *'); - $apiKey = env('GEMINI_API_KEY', ''); if (!$apiKey) { http_response_code(503); @@ -1814,8 +1783,6 @@ function haSuggestRecipe(PDO $db): void { */ function haRefreshPrices(PDO $db): void { header('Content-Type: application/json; charset=utf-8'); - header('Access-Control-Allow-Origin: *'); - try { $country = env('PRICE_COUNTRY', 'Italia'); $currency = env('PRICE_CURRENCY', 'EUR'); @@ -1889,8 +1856,6 @@ function haRefreshPrices(PDO $db): void { */ function haClearExpired(PDO $db): void { header('Content-Type: application/json; charset=utf-8'); - header('Access-Control-Allow-Origin: *'); - try { $stmt = $db->prepare( "DELETE FROM inventory WHERE expiry_date < date('now') AND quantity <= 0" @@ -1966,7 +1931,6 @@ function haTestConnection(): void { */ function haGetInfo(PDO $db): void { header('Content-Type: application/json; charset=utf-8'); - header('Access-Control-Allow-Origin: *'); // Stable unique_id derived from server identity (survives restarts) $uniqueId = 'evershelf_' . substr(md5(__DIR__ . php_uname('n')), 0, 12); $itemsCount = (int)$db->query("SELECT COUNT(*) FROM inventory WHERE quantity > 0")->fetchColumn(); @@ -1988,7 +1952,6 @@ function haGetInfo(PDO $db): void { */ function haGetShoppingItems(PDO $db): void { header('Content-Type: application/json; charset=utf-8'); - header('Access-Control-Allow-Origin: *'); try { if (isShoppingBringMode()) { $auth = bringAuth(); @@ -2603,6 +2566,57 @@ function searchProducts(PDO $db): void { echo json_encode(['products' => $stmt->fetchAll()]); } +function searchInventoryProducts(PDO $db): void { + EverLog::debug('searchInventoryProducts'); + $q = trim((string)($_GET['q'] ?? '')); + $limit = (int)($_GET['limit'] ?? 3); + if ($limit < 1) $limit = 1; + if ($limit > 10) $limit = 10; + + if ($q === '' || mb_strlen($q) < 2) { + echo json_encode(['items' => []]); + return; + } + + $like = "%{$q}%"; + $prefix = mb_strtolower($q) . '%'; + $exact = mb_strtolower($q); + + $sql = " + SELECT + p.id, + p.name, + p.brand, + p.category, + p.barcode, + p.image_url, + p.unit, + p.default_quantity, + p.package_unit, + p.notes, + SUM(i.quantity) AS total_qty, + GROUP_CONCAT(DISTINCT i.location) AS locations + FROM inventory i + JOIN products p ON p.id = i.product_id + WHERE i.quantity > 0 + AND (p.name LIKE ? OR p.brand LIKE ?) + GROUP BY p.id + ORDER BY + CASE + WHEN lower(p.name) = ? THEN 0 + WHEN lower(p.name) LIKE ? THEN 1 + ELSE 2 + END, + total_qty DESC, + p.name ASC + LIMIT {$limit} + "; + + $stmt = $db->prepare($sql); + $stmt->execute([$like, $like, $exact, $prefix]); + echo json_encode(['items' => $stmt->fetchAll()]); +} + // ===== INVENTORY FUNCTIONS ===== function listInventory(PDO $db): void { @@ -2828,22 +2842,38 @@ function useFromInventory(PDO $db): void { } // ── Server-side deduplication ───────────────────────────────────────── - // Reject if the same product already has an 'out' transaction in the last - // 12 seconds. This guards against scale double-triggers (the scale can fire - // a second stable reading ~10 s after the first auto-confirm, while the - // product is still on the plate), regardless of the client-side guard. + // Guard against accidental double-consume triggers (scale jitter, double tap, + // delayed/offline replay burst). We only apply this stricter gate to manual + // uses with empty notes, so recipe uses (notes="Ricetta: ...") remain unaffected. if (!$useAll) { + $dedupWindow = ($notes === '') ? 120 : 12; $dedup = $db->prepare( - "SELECT id FROM transactions - WHERE product_id = ? AND type IN ('out','waste') AND undone = 0 - AND created_at >= datetime('now', '-12 seconds') + "SELECT id, quantity, created_at FROM transactions + WHERE product_id = ? + AND location = ? + AND type IN ('out','waste') + AND undone = 0 + AND COALESCE(notes, '') = ? + AND created_at >= datetime('now', '-' || ? || ' seconds') + ORDER BY id DESC LIMIT 1" ); - $dedup->execute([$productId]); - if ($dedup->fetch()) { + $dedup->execute([$productId, $location, $notes, $dedupWindow]); + $recent = $dedup->fetch(); + if ($recent) { + EverLog::warn('useFromInventory duplicate blocked', [ + 'product_id' => $productId, + 'location' => $location, + 'window_s' => $dedupWindow, + 'recent_tx_id' => $recent['id'] ?? null, + 'recent_qty' => $recent['quantity'] ?? null, + 'recent_created_at' => $recent['created_at'] ?? null, + 'requested_qty' => $quantity, + 'notes' => $notes, + ]); echo json_encode([ 'success' => false, - 'error' => 'Operazione già registrata di recente — attendi qualche secondo.', + 'error' => 'Operazione già registrata di recente — verifica prima la quantità rimasta.', 'duplicate' => true, ]); return; @@ -3574,6 +3604,122 @@ function getInventoryAnomalies(PDO $db): void { echo json_encode(['success' => true, 'anomalies' => $anomalies], JSON_UNESCAPED_UNICODE); } +/** + * Detect likely "double consume" losses: + * latest pair of out transactions for same product+location within 120s, + * empty notes, current inventory at 0, and last tx at that location is out. + */ +function getDuplicateLossChecks(PDO $db): void { + EverLog::info('getDuplicateLossChecks'); + + $sql = " + WITH out_tx AS ( + SELECT + id, + product_id, + IFNULL(location, '') AS location, + quantity, + created_at, + COALESCE(notes, '') AS notes + FROM transactions + WHERE type = 'out' AND undone = 0 + ), + pairs AS ( + SELECT + t1.product_id, + t1.location, + t1.id AS tx1, + t2.id AS tx2, + t1.quantity AS q1, + t2.quantity AS q2, + t2.created_at AS c2, + ROUND((julianday(t2.created_at) - julianday(t1.created_at)) * 86400.0, 1) AS dt_sec + FROM out_tx t1 + JOIN out_tx t2 + ON t2.product_id = t1.product_id + AND t2.location = t1.location + AND t2.id > t1.id + AND (julianday(t2.created_at) - julianday(t1.created_at)) * 86400.0 BETWEEN 0 AND 120 + WHERE TRIM(t1.notes) = '' AND TRIM(t2.notes) = '' + ), + latest_pair AS ( + SELECT + p.*, + ROW_NUMBER() OVER (PARTITION BY p.product_id, p.location ORDER BY p.c2 DESC) AS rn + FROM pairs p + ), + inv AS ( + SELECT + product_id, + IFNULL(location, '') AS location, + MIN(id) AS inventory_id, + SUM(quantity) AS quantity + FROM inventory + GROUP BY product_id, IFNULL(location, '') + ), + last_tx AS ( + SELECT + product_id, + IFNULL(location, '') AS location, + type, + created_at, + ROW_NUMBER() OVER (PARTITION BY product_id, IFNULL(location, '') ORDER BY id DESC) AS rn + FROM transactions + WHERE undone = 0 + ) + SELECT + p.id AS product_id, + p.name, + p.brand, + p.unit, + p.default_quantity, + p.package_unit, + lp.location, + lp.tx1, + lp.q1, + lp.tx2, + lp.q2, + lp.dt_sec, + lp.c2 AS latest_pair_at, + IFNULL(inv.inventory_id, 0) AS inventory_id, + IFNULL(inv.quantity, 0) AS inv_qty_now + FROM latest_pair lp + JOIN products p ON p.id = lp.product_id + LEFT JOIN inv ON inv.product_id = lp.product_id AND inv.location = lp.location + LEFT JOIN last_tx lt ON lt.product_id = lp.product_id AND lt.location = lp.location AND lt.rn = 1 + WHERE lp.rn = 1 + AND IFNULL(inv.quantity, 0) = 0 + AND lt.type = 'out' + ORDER BY lp.c2 DESC + LIMIT 30 + "; + + $rows = $db->query($sql)->fetchAll(PDO::FETCH_ASSOC) ?: []; + + $checks = array_map(function(array $r): array { + return [ + 'product_id' => (int)$r['product_id'], + 'name' => (string)$r['name'], + 'brand' => (string)($r['brand'] ?? ''), + 'unit' => (string)($r['unit'] ?? 'pz'), + 'default_quantity' => isset($r['default_quantity']) ? (float)$r['default_quantity'] : 0.0, + 'package_unit' => (string)($r['package_unit'] ?? ''), + 'location' => (string)($r['location'] ?? ''), + 'tx1' => (int)$r['tx1'], + 'q1' => (float)$r['q1'], + 'tx2' => (int)$r['tx2'], + 'q2' => (float)$r['q2'], + 'dt_sec' => (float)$r['dt_sec'], + 'latest_pair_at' => (string)$r['latest_pair_at'], + 'inventory_id' => (int)$r['inventory_id'], + 'inv_qty_now' => (float)$r['inv_qty_now'], + 'dismiss_key' => 'dup_' . ((int)$r['product_id']) . '_' . md5((string)($r['location'] ?? '')), + ]; + }, $rows); + + echo json_encode(['success' => true, 'checks' => $checks], JSON_UNESCAPED_UNICODE); +} + /** * Dismiss a specific anomaly so it no longer appears in the banner. */ @@ -4301,12 +4447,13 @@ function getServerSettings(): void { echo json_encode([ 'gemini_key_set' => !empty($geminiKey), + 'api_token_required' => evershelfApiTokenRequired(), 'bring_email' => $bringEmail, - 'settings_token_set' => !empty(env('SETTINGS_TOKEN')), + 'settings_token_set' => evershelfApiTokenRequired(), 'demo_mode' => env('DEMO_MODE') === 'true', 'bring_password_set' => !empty(env('BRING_PASSWORD')), 'tts_url' => env('TTS_URL'), - 'tts_token' => env('TTS_TOKEN'), + 'tts_token_set' => !empty(env('TTS_TOKEN')), 'tts_method' => env('TTS_METHOD', 'POST'), 'tts_auth_type' => env('TTS_AUTH_TYPE', 'bearer'), 'tts_content_type' => env('TTS_CONTENT_TYPE', 'application/json'), @@ -4316,7 +4463,7 @@ function getServerSettings(): void { 'tts_rate' => (float)env('TTS_RATE', '1'), 'tts_pitch' => (float)env('TTS_PITCH', '1'), 'tts_auth_header_name' => env('TTS_AUTH_HEADER_NAME', ''), - 'tts_auth_header_value' => env('TTS_AUTH_HEADER_VALUE', ''), + 'tts_auth_header_value_set' => !empty(env('TTS_AUTH_HEADER_VALUE', '')), 'tts_extra_fields' => env('TTS_EXTRA_FIELDS', ''), // User preferences (now server-side) 'default_persons' => intval(env('DEFAULT_PERSONS', '1')), @@ -4361,7 +4508,7 @@ function getServerSettings(): void { // Home Assistant Integration 'ha_enabled' => env('HA_ENABLED', 'false') === 'true', 'ha_url' => env('HA_URL', ''), - 'ha_token' => env('HA_TOKEN', ''), + 'ha_token_set' => !empty(env('HA_TOKEN', '')), 'ha_tts_entity' => env('HA_TTS_ENTITY', ''), 'ha_webhook_id' => env('HA_WEBHOOK_ID', ''), 'ha_webhook_events' => env('HA_WEBHOOK_EVENTS', 'expiry,shopping_add,stock_update,barcode_scan'), @@ -4392,11 +4539,11 @@ function dbCleanup(?PDO $db = null): void { } function saveSettings(): void { - // Require SETTINGS_TOKEN if configured - $requiredToken = env('SETTINGS_TOKEN'); - if (!empty($requiredToken)) { + // Require API token if configured + $requiredToken = evershelfEffectiveApiToken(); + if ($requiredToken !== '') { EverLog::debug('saveSettings'); - $provided = $_SERVER['HTTP_X_SETTINGS_TOKEN'] ?? ''; + $provided = evershelfGetProvidedApiToken(); if (!hash_equals($requiredToken, $provided)) { http_response_code(403); echo json_encode(['success' => false, 'error' => 'unauthorized']); @@ -5660,13 +5807,15 @@ REGOLE: 8. `tools_needed`: array of kitchen tools/appliances actually required by this recipe (e.g. ["Forno","Frullateur"]). Use the same language as all other text fields. Empty array [] if only stovetop/knife/pan needed. 9. `steps`: array of PLAIN TEXT STRINGS only — no objects, no JSON, no sub-fields. Each step is a single readable string. If appliances are used, include the appliance/mode information directly in the step text (e.g. "Nel Cookeo, modalità Rosolare: aggiungere la cipolla…"). NEVER output steps as objects like {"instruction":…, "appliance_function":…}. 10. NON confondere forme diverse dello stesso ingrediente di base: 'Pomodori'/'Pomodoro Piccadilly' (freschi, pz/g) ≠ 'Passata di pomodoro'/'Polpa di pomodoro'/'Sugo al pomodoro' (elaborato, conf/g); 'Latte fresco' ≠ 'Latte UHT' ≠ 'Panna'; 'Farina 00' ≠ 'Farina integrale'. Se la ricetta richiede un tipo di ingrediente che NON è disponibile nella forma giusta in lista, NON sostituirlo con una forma diversa: scegli una ricetta che usa gli ingredienti esattamente nella forma disponibile. +11. `nutrition`: object with estimated macro values PER SERVING for the finished dish: {"kcal":450,"protein_g":25,"carbs_g":40,"fat_g":15}. All values are integers. Estimate realistically based on the ingredients and quantities used. +12. `storage`: object describing how to store leftovers: {"where":"frigo","days":3,"tips":"…"}. `where` = one of: frigo / freezer / dispensa / temperatura ambiente (in target language). `days` = integer max days safe to keep. `tips` = one concise sentence in target language. If the dish is best eaten immediately, set days=0 and tips accordingly. DISPENSA: $ingredientsText Rispondi SOLO JSON valido (no markdown): {$promptLanguageRule} -{"title":"…","meal":"$mealType","persons":$persons,"prep_time":"…","cook_time":"…","tags":["…"],"expiry_note":"…","tools_needed":["…"],"ingredients":[{"name":"…","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":"…"} +{"title":"…","meal":"$mealType","persons":$persons,"prep_time":"…","cook_time":"…","tags":["…"],"expiry_note":"…","tools_needed":["…"],"ingredients":[{"name":"…","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":"…","nutrition":{"kcal":450,"protein_g":25,"carbs_g":40,"fat_g":15},"storage":{"where":"frigo","days":3,"tips":"…"}} PROMPT; $payload = [ @@ -6091,12 +6240,14 @@ REGOLE: 5. "name": usa ESATTAMENTE il nome dalla lista dispensa (il sistema lo usa per scalare l'inventario). 6. "from_pantry": true se l'ingrediente è nella lista DISPENSA, false per acqua/sale/pepe/olio. 7. Language: {$langName} for all text fields. Keep "meal" as English meal key (colazione/pranzo/cena/snack/dolce/libero). +8. `nutrition`: object with estimated macro values PER SERVING for the finished dish: {"kcal":450,"protein_g":25,"carbs_g":40,"fat_g":15}. All values are integers. +9. `storage`: object describing how to store leftovers: {"where":"frigo","days":3,"tips":"…"}. `where` in target language (frigo / freezer / dispensa / temperatura ambiente). `days` = integer. `tips` = one concise sentence. DISPENSA: {$ingredientsText} JSON schema: -{"title":"…","meal":"libero","persons":{$persons},"prep_time":"…","cook_time":"…","tags":["…"],"ingredients":[{"name":"…","qty":"80 g","qty_number":80,"from_pantry":true}],"steps":["…"],"nutrition_note":"…"} +{"title":"…","meal":"libero","persons":{$persons},"prep_time":"…","cook_time":"…","tags":["…"],"ingredients":[{"name":"…","qty":"80 g","qty_number":80,"from_pantry":true}],"steps":["…"],"nutrition_note":"…","nutrition":{"kcal":450,"protein_g":25,"carbs_g":40,"fat_g":15},"storage":{"where":"frigo","days":3,"tips":"…"}} PROMPT; $payload = [ @@ -6608,13 +6759,15 @@ REGOLE: 9. `zero_waste_tips`: array of zero-waste tips for steps that generate reusable scraps (peels, leftover cooking water, egg whites, cheese rinds, bread crusts, vegetable tops, etc.). Each entry: {"step": 0-based_step_index, "scrap": "scrap name", "tip": "short practical reuse tip (max 20 words)"}. Use the same language as other text fields. Empty array [] if no reusable scraps are generated. 10. `steps`: array of PLAIN TEXT STRINGS only — no objects, no JSON, no sub-fields. Each step is a single readable string. If appliances are used, include the appliance/mode information directly in the step text (e.g. "Nel Cookeo, modalità Rosolare: aggiungere la cipolla…"). NEVER output steps as objects like {"instruction":…, "appliance_function":…}. 11. NON confondere forme diverse dello stesso ingrediente di base: 'Pomodori'/'Pomodoro Piccadilly' (freschi, pz/g) ≠ 'Passata di pomodoro'/'Polpa di pomodoro'/'Sugo al pomodoro' (elaborato, conf/g); 'Latte fresco' ≠ 'Latte UHT' ≠ 'Panna'; 'Farina 00' ≠ 'Farina integrale'. Se la ricetta richiede un tipo di ingrediente che NON è disponibile nella forma giusta in lista, NON sostituirlo con una forma diversa: scegli una ricetta che usa gli ingredienti esattamente nella forma disponibile. +12. `nutrition`: object with estimated macro values PER SERVING for the finished dish: {"kcal":450,"protein_g":25,"carbs_g":40,"fat_g":15}. All values are integers. Estimate realistically based on the ingredients and quantities used. +13. `storage`: object describing how to store leftovers: {"where":"frigo","days":3,"tips":"…"}. `where` = one of: frigo / freezer / dispensa / temperatura ambiente (in target language). `days` = integer max days safe to keep. `tips` = one concise sentence in target language. If the dish is best eaten immediately, set days=0 and tips accordingly. DISPENSA: $ingredientsText Rispondi SOLO JSON valido (no markdown): {$promptLanguageRule} -{"title":"…","meal":"$mealType","persons":$persons,"prep_time":"…","cook_time":"…","tags":["…"],"expiry_note":"…","tools_needed":["…"],"ingredients":[{"name":"…","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":"…","zero_waste_tips":[{"step":0,"scrap":"…","tip":"…"}]} +{"title":"…","meal":"$mealType","persons":$persons,"prep_time":"…","cook_time":"…","tags":["…"],"expiry_note":"…","tools_needed":["…"],"ingredients":[{"name":"…","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":"…","zero_waste_tips":[{"step":0,"scrap":"…","tip":"…"}],"nutrition":{"kcal":450,"protein_g":25,"carbs_g":40,"fat_g":15},"storage":{"where":"frigo","days":3,"tips":"…"}} PROMPT; $genConfig = [ diff --git a/api/lib/constants.php b/api/lib/constants.php new file mode 100644 index 0000000..d7a4fcd --- /dev/null +++ b/api/lib/constants.php @@ -0,0 +1,22 @@ += 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); + } + } +} diff --git a/api/lib/env.php b/api/lib/env.php new file mode 100644 index 0000000..e0c0db1 --- /dev/null +++ b/api/lib/env.php @@ -0,0 +1,35 @@ + 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; +} diff --git a/api/scale_discover.php b/api/scale_discover.php index 5225bb1..c82b8bc 100644 --- a/api/scale_discover.php +++ b/api/scale_discover.php @@ -1,57 +1,53 @@ 65535) $port = 8765; - -// ── Determine server LAN IP ──────────────────────────────────────────────── -// SERVER_ADDR may be 127.0.0.1 when accessed via internal vhost — fall back -// to a UDP trick (no actual packet sent) to find the default-route interface IP. -function localLanIp(): string { - $sock = @socket_create(AF_INET, SOCK_DGRAM, SOL_UDP); - if ($sock) { - @socket_connect($sock, '8.8.8.8', 53); - @socket_getsockname($sock, $ip); - socket_close($sock); - if (isset($ip) && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) return $ip; - } - // Fallback: parse /proc/net/route for default gateway interface then ip neigh - $ifaces = @net_get_interfaces(); - if ($ifaces) { - foreach ($ifaces as $name => $info) { - if ($name === 'lo') continue; - foreach ($info['unicast'] ?? [] as $u) { - $ip = $u['address'] ?? ''; - if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE)) continue; - if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) return $ip; - } - } - } - return ''; +if (evershelfApiTokenRequired() && !evershelfApiTokenValid() && !evershelfIsSameOriginBrowser()) { + http_response_code(401); + echo json_encode(['error' => 'unauthorized', 'api_token_required' => true]); + exit; } -$serverIp = localLanIp(); +// Simple rate limit: max 6 scans per minute per IP +$rlDir = dirname(__DIR__) . '/data/rate_limits'; +if (!is_dir($rlDir)) { + @mkdir($rlDir, 0755, true); +} +$rlFile = $rlDir . '/scale_discover_' . md5($_SERVER['REMOTE_ADDR'] ?? 'cli') . '.json'; +$now = time(); +$hits = []; +if (file_exists($rlFile)) { + $hits = array_filter(json_decode(file_get_contents($rlFile), true) ?: [], fn($t) => $t > $now - 60); +} +if (count($hits) >= 6) { + http_response_code(429); + echo json_encode(['error' => 'Too many discovery scans']); + exit; +} +$hits[] = $now; +@file_put_contents($rlFile, json_encode($hits), LOCK_EX); + +$port = (int)($_GET['port'] ?? 8765); +if ($port < 1 || $port > 65535) { + $port = 8765; +} + +$serverIp = evershelfLocalLanIp(); $parts = explode('.', $serverIp); if (count($parts) !== 4) { - echo json_encode(['error' => 'Cannot determine local subnet', 'server_ip' => $serverIp]); + echo json_encode(['error' => 'Cannot determine local subnet', 'found' => []]); exit; } $subnet = $parts[0] . '.' . $parts[1] . '.' . $parts[2] . '.'; -// ── Phase 1: Async TCP connect to all 254 hosts ──────────────────────────── -// Non-blocking stream_socket_client + stream_select to detect open ports quickly. -// Total scan budget: 1.5 seconds. - $candidates = []; for ($i = 1; $i <= 254; $i++) { $ip = $subnet . $i; @@ -74,25 +70,28 @@ while (!empty($candidates) && microtime(true) < $deadline) { $read = null; $usec = (int)(max(0, $deadline - microtime(true)) * 1_000_000); $n = @stream_select($read, $write, $except, 0, $usec); - if ($n === false || $n === 0) break; + if ($n === false || $n === 0) { + break; + } - // Sockets in $except = connection refused/error $failed = []; foreach ($except as $s) { $ip = array_search($s, $candidates, true); - if ($ip !== false) $failed[$ip] = true; + if ($ip !== false) { + $failed[$ip] = true; + } } - // Sockets in $write = connection complete (may overlap with $except on error) foreach ($write as $s) { $ip = array_search($s, $candidates, true); - if ($ip === false) continue; + if ($ip === false) { + continue; + } if (!isset($failed[$ip])) { $found_tcp[] = $ip; } @fclose($s); unset($candidates[$ip]); } - // Close failed sockets too foreach ($failed as $ip => $_) { if (isset($candidates[$ip])) { @fclose($candidates[$ip]); @@ -100,13 +99,16 @@ while (!empty($candidates) && microtime(true) < $deadline) { } } } -foreach ($candidates as $s) @fclose($s); // close remaining (timeout) +foreach ($candidates as $s) { + @fclose($s); +} -// ── Phase 2: WebSocket handshake to confirm each TCP responder ───────────── $gateways = []; foreach ($found_tcp as $ip) { $sock = @stream_socket_client("tcp://{$ip}:{$port}", $errno, $errstr, 2); - if (!$sock) continue; + if (!$sock) { + continue; + } stream_set_timeout($sock, 2); $key = base64_encode(random_bytes(16)); @@ -124,9 +126,13 @@ foreach ($found_tcp as $ip) { $dl = microtime(true) + 2; while (microtime(true) < $dl && !feof($sock)) { $line = fgets($sock, 256); - if ($line === false) break; + if ($line === false) { + break; + } $resp .= $line; - if ($line === "\r\n") break; + if ($line === "\r\n") { + break; + } } fclose($sock); @@ -138,5 +144,4 @@ foreach ($found_tcp as $ip) { echo json_encode([ 'found' => $gateways, 'subnet' => rtrim($subnet, '.') . '.0/24', - 'server_ip' => $serverIp, ]); diff --git a/api/scale_ping.php b/api/scale_ping.php index 5b399dc..e0e1342 100644 --- a/api/scale_ping.php +++ b/api/scale_ping.php @@ -1,16 +1,20 @@ false, 'error' => 'unauthorized', 'api_token_required' => true]); + exit; +} + $rawUrl = $_GET['url'] ?? ''; if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) { @@ -19,7 +23,7 @@ if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) { } $parsed = parse_url($rawUrl); -$host = $parsed['host'] ?? ''; +$host = strtolower($parsed['host'] ?? ''); $port = (int)($parsed['port'] ?? 8765); $path = ($parsed['path'] ?? '') ?: '/'; @@ -28,6 +32,11 @@ if (!$host || $port < 1 || $port > 65535) { exit; } +if (!evershelfScaleHostAllowed($host)) { + echo json_encode(['ok' => false, 'error' => 'Gateway host not allowed']); + exit; +} + // Try to open a TCP connection with a 5-second timeout $sock = @stream_socket_client("tcp://{$host}:{$port}", $errno, $errstr, 5); if (!$sock) { diff --git a/api/scale_relay.php b/api/scale_relay.php index 2e90935..78d977f 100644 --- a/api/scale_relay.php +++ b/api/scale_relay.php @@ -8,6 +8,16 @@ * Usage: GET /api/scale_relay.php?url=ws%3A%2F%2F192.168.1.100%3A8765 */ +require_once __DIR__ . '/lib/env.php'; +require_once __DIR__ . '/lib/security.php'; + +if (evershelfApiTokenRequired() && !evershelfApiTokenValid() && !evershelfIsSameOriginBrowser()) { + header('Content-Type: application/json; charset=utf-8'); + http_response_code(401); + echo json_encode(['error' => 'unauthorized', 'api_token_required' => true]); + exit; +} + // ── Input validation ────────────────────────────────────────────────────────── $rawUrl = $_GET['url'] ?? ''; @@ -19,7 +29,7 @@ if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) { } $parsed = parse_url($rawUrl); -$wsHost = $parsed['host'] ?? ''; +$wsHost = strtolower($parsed['host'] ?? ''); $wsPort = (int)($parsed['port'] ?? 8765); $wsPath = ($parsed['path'] ?? '') ?: '/'; @@ -29,6 +39,12 @@ if (!$wsHost || $wsPort < 1 || $wsPort > 65535) { exit; } +if (!evershelfScaleHostAllowed($wsHost)) { + header('Content-Type: text/event-stream'); + echo 'data: ' . json_encode(['type' => 'error', 'message' => 'Gateway host not allowed']) . "\n\n"; + exit; +} + // ── SSE headers ─────────────────────────────────────────────────────────────── header('Content-Type: text/event-stream'); header('Cache-Control: no-cache, no-store, must-revalidate'); diff --git a/assets/css/style.css b/assets/css/style.css index ad16b02..a9498dd 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -2009,6 +2009,59 @@ body.server-offline .bottom-nav { .scan-status-msg.state-confirmed { color: #4ade80; background: rgba(74,222,128,0.22); } .scan-status-msg.state-retry { color: #fb923c; } +/* — AI processing overlay (full-viewport, shown during Gemini Vision call) — */ +.scan-ai-overlay { + position: absolute; + inset: 0; + z-index: 20; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0,0,0,0.72); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + border-radius: var(--radius); +} +.scan-ai-overlay-inner { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 24px 28px; + background: rgba(255,255,255,0.07); + border: 1.5px solid rgba(255,255,255,0.18); + border-radius: 16px; +} +.scan-ai-overlay-label { + font-size: 0.65rem; + color: rgba(255,255,255,0.5); + text-transform: uppercase; + letter-spacing: 0.1em; + font-family: monospace; +} +.scan-ai-overlay-msg { + font-size: 0.88rem; + color: #fff; + text-align: center; + max-width: 220px; +} + +/* — AI retry button (shown below scanner after visual ID fails) — */ +.scan-ai-retry-btn { + width: 100%; + margin-top: 10px; + font-size: 0.95rem; + padding: 12px; + border-radius: var(--radius); + border: 2px solid var(--accent); + background: rgba(124,58,237,0.1); + color: var(--accent); + font-weight: 600; + cursor: pointer; + transition: background 0.15s; +} +.scan-ai-retry-btn:active { background: rgba(124,58,237,0.22); } + /* — Viewport overlay controls (torch / zoom / flip) — */ .scan-viewport-controls { position: absolute; @@ -2059,6 +2112,118 @@ body.server-offline .bottom-nav { box-shadow: var(--shadow); } +.scan-ai-match-box { + display: flex; + flex-direction: column; + gap: 12px; +} +.scan-ai-match-head { + display: flex; + flex-direction: column; + gap: 4px; +} +.scan-ai-match-title { + font-size: 1rem; + font-weight: 700; + color: var(--text); +} +.scan-ai-match-subtitle { + font-size: 0.82rem; + color: var(--text-muted); +} +.scan-ai-match-list-wrap { + display: flex; + flex-direction: column; + gap: 8px; +} +.scan-ai-match-list-title { + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + font-weight: 700; +} +.scan-ai-match-list { + display: flex; + flex-direction: column; + gap: 6px; +} +.scan-ai-candidate-item { + border: 1px solid var(--border); + background: var(--bg-main); + border-radius: 12px; + padding: 10px; + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + text-align: left; +} +.scan-ai-candidate-item:active { transform: scale(0.99); } +.scan-ai-candidate-icon { + font-size: 1.3rem; + flex-shrink: 0; +} +.scan-ai-candidate-info { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; +} +.scan-ai-candidate-name { + font-size: 0.9rem; + font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.scan-ai-candidate-meta { + font-size: 0.76rem; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.scan-ai-candidate-cta { + font-size: 0.74rem; + color: var(--accent); + border: 1px solid var(--accent); + border-radius: 999px; + padding: 3px 8px; + flex-shrink: 0; +} +.scan-ai-match-empty { + font-size: 0.86rem; + color: var(--text-muted); + background: var(--bg-main); + border: 1px dashed var(--border); + border-radius: 10px; + padding: 10px 12px; +} +.scan-ai-add-btn { + width: 100%; +} +.scan-ai-detected-label { + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-muted); +} +.scan-ai-detected-pill { + font-size: 0.8rem; + color: var(--text-muted); + background: var(--bg-main); + border-radius: 999px; + border: 1px solid var(--border); + padding: 6px 10px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + /* — Recent scans — */ .scan-recents { display: flex; @@ -4295,6 +4460,93 @@ body.server-offline .bottom-nav { line-height: 1.5; } +/* ===== RECIPE NUTRITION BLOCK ===== */ +.recipe-nutrition-block { + background: #f0fdf4; + border: 1px solid #bbf7d0; + border-radius: var(--radius-sm); + padding: 12px 14px 8px; + margin-top: 16px; +} +.recipe-section-heading { + font-size: 0.85rem; + font-weight: 700; + color: #15803d; + margin: 0 0 10px; + text-transform: uppercase; + letter-spacing: 0.03em; +} +.recipe-nutrition-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; + text-align: center; +} +.recipe-nutrition-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} +.recipe-nutrition-icon { font-size: 1.2rem; } +.recipe-nutrition-value { + font-size: 0.95rem; + font-weight: 700; + color: #15803d; +} +.recipe-nutrition-label { + font-size: 0.65rem; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.recipe-nutrition-note { + font-size: 0.7rem; + color: #94a3b8; + text-align: center; + margin: 6px 0 0; +} +.recipe-nutrition-footnote { + color: var(--text-muted); + font-size: 0.85rem; + margin-top: 12px; +} + +/* ===== RECIPE STORAGE CARD ===== */ +.recipe-storage-card { + background: #fffbeb; + border: 1px solid #fde68a; + border-radius: var(--radius-sm); + padding: 12px 14px 8px; + margin-top: 12px; +} +.recipe-storage-card .recipe-section-heading { color: #b45309; } +.recipe-storage-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 6px; +} +.recipe-storage-badge { + background: #fef3c7; + border: 1px solid #fcd34d; + border-radius: 20px; + padding: 2px 12px; + font-size: 0.8rem; + font-weight: 600; + color: #92400e; + white-space: nowrap; + text-transform: capitalize; +} +.recipe-storage-days { background: #dbeafe; border-color: #93c5fd; color: #1d4ed8; } +.recipe-storage-now { background: #fee2e2; border-color: #fca5a5; color: #b91c1c; } +.recipe-storage-tips { + font-size: 0.82rem; + color: #78350f; + margin: 2px 0 0; + line-height: 1.4; +} + .recipe-tools-banner { display: flex; flex-wrap: wrap; @@ -5939,6 +6191,12 @@ body.cooking-mode-active .app-header { } .banner-anomaly .alert-banner-title { color: #9a3412; } .banner-anomaly .alert-banner-counter .banner-dot.active { background: #ea580c; } +.alert-banner.banner-dup-loss { + background: linear-gradient(135deg, #fef2f2 0%, #fecaca 100%); + border-color: #dc2626; +} +.banner-dup-loss .alert-banner-title { color: #991b1b; } +.banner-dup-loss .alert-banner-counter .banner-dot.active { background: #dc2626; } .alert-banner.banner-no-expiry { background: linear-gradient(135deg, #f0fdf4 0%, #bbf7d0 100%); border-color: #16a34a; @@ -7838,6 +8096,8 @@ body.cooking-mode-active .app-header { [data-theme="dark"] .banner-prediction .alert-banner-counter { color: #a78bfa; } [data-theme="dark"] .alert-banner.banner-anomaly { background: #1a1200; border-color: #c2410c; } [data-theme="dark"] .banner-anomaly .alert-banner-title { color: #fdba74; } +[data-theme="dark"] .alert-banner.banner-dup-loss { background: #2a0808; border-color: #dc2626; } +[data-theme="dark"] .banner-dup-loss .alert-banner-title { color: #fca5a5; } [data-theme="dark"] .alert-banner.banner-no-expiry { background: #0f2a1a; border-color: #166534; } [data-theme="dark"] .banner-no-expiry .alert-banner-title { color: #86efac; } @@ -7908,6 +8168,18 @@ body.cooking-mode-active .app-header { /* ── Recipe components ── */ [data-theme="dark"] .recipe-expiry-note { background: #2a1e00; color: #fde68a; } +[data-theme="dark"] .recipe-nutrition-block { background: #052e16; border-color: #166534; } +[data-theme="dark"] .recipe-section-heading { color: #4ade80; } +[data-theme="dark"] .recipe-storage-card .recipe-section-heading { color: #fbbf24; } +[data-theme="dark"] .recipe-nutrition-value { color: #4ade80; } +[data-theme="dark"] .recipe-nutrition-label { color: #94a3b8; } +[data-theme="dark"] .recipe-nutrition-note { color: #64748b; } +[data-theme="dark"] .recipe-nutrition-footnote { color: var(--text-muted); } +[data-theme="dark"] .recipe-storage-card { background: #1c1400; border-color: #78350f; } +[data-theme="dark"] .recipe-storage-badge { background: #2a1e00; border-color: #92400e; color: #fde68a; } +[data-theme="dark"] .recipe-storage-days { background: #0c1a2e; border-color: #1d4ed8; color: #93c5fd; } +[data-theme="dark"] .recipe-storage-now { background: #2a0a0a; border-color: #b91c1c; color: #fca5a5; } +[data-theme="dark"] .recipe-storage-tips { color: #fde68a; } [data-theme="dark"] .recipe-tools-banner { background: #1a1040; border-color: #3730a3; color: #c4b5fd; } [data-theme="dark"] .recipe-tool-chip { background: #2e1a4a; color: #c4b5fd; } [data-theme="dark"] .recipe-step-appliance { background: #052e16; border-color: #166534; color: #4ade80; } diff --git a/assets/js/app.js b/assets/js/app.js index c0749ca..900eaf6 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -317,12 +317,17 @@ function scaleInit() { _scaleConnect(s.scale_gateway_url); } +function _scaleAuthQuery() { + const tok = typeof getApiToken === 'function' ? getApiToken() : ''; + return tok ? '&api_token=' + encodeURIComponent(tok) : ''; +} + function _scaleConnect(url) { if (_scaleEs) { try { _scaleEs.close(); } catch(e) {} _scaleEs = null; } if (_scaleReconnectTimer) { clearTimeout(_scaleReconnectTimer); _scaleReconnectTimer = null; } try { - // Connect via the PHP SSE relay so the HTTPS page is not blocked by mixed-content - _scaleEs = new EventSource('api/scale_relay.php?url=' + encodeURIComponent(url)); + // EventSource cannot send custom headers — pass api_token in query string + _scaleEs = new EventSource('api/scale_relay.php?url=' + encodeURIComponent(url) + _scaleAuthQuery()); _scaleEs.onopen = () => _scaleUpdateStatus('searching'); _scaleEs.onmessage = (evt) => { try { _scaleOnMessage(JSON.parse(evt.data)); } catch(e) {} @@ -1041,7 +1046,7 @@ function testScaleConnection() { statusEl.textContent = '❌ ' + t('scale.timeout'); statusEl.className = 'settings-status error'; }, 8000); - fetch('api/scale_ping.php?url=' + encodeURIComponent(url), { signal: ac.signal }) + fetch('api/scale_ping.php?url=' + encodeURIComponent(url) + _scaleAuthQuery(), { signal: ac.signal }) .then(r => r.json()) .then(data => { clearTimeout(timeout); @@ -1073,7 +1078,7 @@ async function discoverScaleGateway() { status.textContent = '🔍 Scanning local network for scale gateway…'; try { - const res = await fetch('api/scale_discover.php', { signal: AbortSignal.timeout(8000) }); + const res = await fetch('api/scale_discover.php', { signal: AbortSignal.timeout(8000), headers: { ...(typeof apiAuthHeaders === 'function' ? apiAuthHeaders() : {}) } }); const data = await res.json(); if (data.error) { @@ -1271,8 +1276,8 @@ function _setThemeMode(mode) { // Persist dark_mode to server .env immediately (no need to send the full // settings payload — save_settings only updates keys present in the body // and keeps all other .env values intact). - const token = document.getElementById('setting-settings-token')?.value.trim() || ''; - const headers = token ? { 'X-Settings-Token': token } : {}; + const token = document.getElementById('setting-settings-token')?.value.trim() || (typeof getApiToken === 'function' ? getApiToken() : ''); + const headers = token ? { 'X-API-Token': token } : {}; api('save_settings', {}, 'POST', { dark_mode: mode }, headers).catch(() => {}); } @@ -1284,7 +1289,9 @@ setInterval(() => { // ===== EXPORT INVENTORY ===== function exportInventory(format) { - const url = `api/index.php?action=export_inventory&format=${encodeURIComponent(format)}&_t=${Date.now()}`; + const tok = typeof getApiToken === 'function' ? getApiToken() : ''; + const tokParam = tok ? `&api_token=${encodeURIComponent(tok)}` : ''; + const url = `api/index.php?action=export_inventory&format=${encodeURIComponent(format)}&_t=${Date.now()}${tokParam}`; if (format === 'csv') { // Direct download via trick const a = document.createElement('a'); @@ -3219,9 +3226,13 @@ async function loadSettingsUI() { 'ha_enabled','ha_url','ha_tts_entity','ha_webhook_id','ha_webhook_events', 'ha_notify_service','ha_expiry_days']; // Note: gemini_key is never sent from server; settings_token_set is metadata only - const settingsTokenRequired = !!serverSettings.settings_token_set; + const settingsTokenRequired = !!(serverSettings.api_token_required || serverSettings.settings_token_set); const tokenHintEl = document.getElementById('settings-token-status-hint'); if (tokenHintEl) tokenHintEl.style.display = settingsTokenRequired ? 'block' : 'none'; + if (settingsTokenRequired && typeof setApiToken === 'function') { + const fieldTok = document.getElementById('setting-settings-token')?.value.trim(); + if (fieldTok) setApiToken(fieldTok); + } let changed = false; for (const key of serverKeys) { if (serverSettings[key] !== undefined && serverSettings[key] !== null && serverSettings[key] !== '') { @@ -3797,8 +3808,9 @@ async function saveSettings() { // Save ALL settings to server .env try { - const settingsToken = document.getElementById('setting-settings-token')?.value.trim() || ''; - const tokenHeader = settingsToken ? { 'X-Settings-Token': settingsToken } : {}; + const settingsToken = document.getElementById('setting-settings-token')?.value.trim() || (typeof getApiToken === 'function' ? getApiToken() : ''); + if (settingsToken && typeof setApiToken === 'function') setApiToken(settingsToken); + const tokenHeader = settingsToken ? { 'X-API-Token': settingsToken } : (typeof apiAuthHeaders === 'function' ? apiAuthHeaders() : {}); const result = await api('save_settings', {}, 'POST', { ...(s.gemini_key ? { gemini_key: s.gemini_key } : {}), bring_email: s.bring_email, @@ -3944,11 +3956,12 @@ async function api(action, params = {}, method = 'GET', body = null, extraHeader }); } const opts = { method, cache: 'no-store' }; + const authHdrs = typeof apiAuthHeaders === 'function' ? apiAuthHeaders() : {}; if (body) { - opts.headers = { 'Content-Type': 'application/json', 'X-EverShelf-Request': '1', ...extraHeaders }; + opts.headers = { 'Content-Type': 'application/json', 'X-EverShelf-Request': '1', ...authHdrs, ...extraHeaders }; opts.body = JSON.stringify(body); - } else if (Object.keys(extraHeaders).length > 0) { - opts.headers = { ...extraHeaders }; + } else { + opts.headers = { ...authHdrs, ...extraHeaders }; } let res; try { @@ -3966,6 +3979,10 @@ async function api(action, params = {}, method = 'GET', body = null, extraHeader } if (!res.ok) { remoteLog('API_ERROR', `${action} HTTP ${res.status}`); + if (res.status === 401) { + window._apiTokenRequired = true; + if (typeof _promptApiTokenIfNeeded === 'function') _promptApiTokenIfNeeded(); + } // Report HTTP 5xx as server errors (not 4xx which are usually user errors) if (res.status >= 500) { reportError({ @@ -3981,6 +3998,10 @@ async function api(action, params = {}, method = 'GET', body = null, extraHeader _offlineCacheSet(data.inventory); } if (action === 'get_settings' && data && data.success !== false) { + window._apiTokenRequired = !!data.api_token_required; + if (data.api_token_required && typeof _promptApiTokenIfNeeded === 'function') { + _promptApiTokenIfNeeded(); + } _offlineCacheSetSettings(data); } if (data && data.error) { @@ -12821,13 +12842,6 @@ async function analyzeExpiryImage(dataUrl) { } } -function escapeHtml(str) { - if (!str) return ''; - const div = document.createElement('div'); - div.textContent = str; - return div.innerHTML; -} - function stripHtml(str) { if (!str) return ''; return str.replace(/<[^>]*>/g, ''); @@ -13952,7 +13966,7 @@ function renderRecipe(r) { const isFav = !!(_cachedRecipe && _cachedRecipe.is_favorite); - let html = `

${r.title}

`; + let html = `

${escapeHtml(r.title)}

`; // Meta tags + star (#124) + persons rescaler (#123) html += '
'; @@ -13964,7 +13978,7 @@ function renderRecipe(r) { `; if (r.prep_time) html += `🔪 ${r.prep_time}`; if (r.cook_time) html += `🔥 ${r.cook_time}`; - if (r.tags) r.tags.forEach(t => { html += `${t}`; }); + if (r.tags) r.tags.forEach(tag => { html += `${escapeHtml(tag)}`; }); // Favorite star button (#124) — visible only for archived recipes (have an id) if (_cachedRecipe && _cachedRecipe.id) { html += ``; @@ -13973,7 +13987,7 @@ function renderRecipe(r) { // Expiry note if (r.expiry_note) { - html += `
⚠️ ${r.expiry_note}
`; + html += `
⚠️ ${escapeHtml(r.expiry_note)}
`; } // Tools/appliances banner (shown only when specific equipment is needed) @@ -13981,7 +13995,7 @@ function renderRecipe(r) { ? r.tools_needed.filter(t => t && t.trim()) : _extractToolsFromSteps(r.steps); if (tools.length > 0) { - html += `
🔧 ${t('recipes.tools_title')}: ${tools.map(t => `${t}`).join('')}
`; + html += `
🔧 ${escapeHtml(t('recipes.tools_title'))}: ${tools.map(tool => `${escapeHtml(tool)}`).join('')}
`; } // Ingredients @@ -13991,8 +14005,8 @@ function renderRecipe(r) { const qtyNum = Math.round((ing.qty_number || 0) * 10) / 10; const loc = (ing.location || 'dispensa').replace(/'/g, "\\'"); const alreadyUsed = ing.used === true; - html += `
  • `; - html += `${ing.name}${ing.brand ? ' (' + ing.brand + ')' : ''}: ${ing.qty} ✅`; + html += `
  • `; + html += `${escapeHtml(ing.name)}${ing.brand ? ' (' + escapeHtml(ing.brand) + ')' : ''}: ${escapeHtml(ing.qty)} ✅`; // Detail line: location + expiry let details = []; const ingredientLocLabels = Object.fromEntries(Object.entries(LOCATIONS).map(([k,v]) => [k, `${v.icon} ${v.label}`])); @@ -14016,7 +14030,7 @@ function renderRecipe(r) { html += `
  • `; } else { const pantryIcon = ing.from_pantry ? ' ✅' : ' 🛒'; - html += `
  • ${ing.name}: ${ing.qty}${pantryIcon}
  • `; + html += `
  • ${escapeHtml(ing.name)}: ${escapeHtml(ing.qty)}${pantryIcon}
  • `; } }); html += ''; @@ -14028,13 +14042,60 @@ function renderRecipe(r) { html += `

    ${t('recipes.steps_title')}

      `; (r.steps || []).forEach(step => { const appliance = _stepAppliance(step); - html += `
    1. ${_stepStr(step)}${appliance ? ` ${appliance}` : ''}
    2. `; + html += `
    3. ${escapeHtml(_stepStr(step))}${appliance ? ` ${escapeHtml(appliance)}` : ''}
    4. `; }); html += '
    '; - // 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 += `
    +

    📊 ${t('recipes.nutrition_title')}

    +
    +
    + 🔥 + ${n.kcal ?? '—'} + ${t('recipes.nutrition_kcal')} +
    +
    + 🥩 + ${n.protein_g ?? '—'} g + ${t('recipes.nutrition_protein')} +
    +
    + 🍞 + ${n.carbs_g ?? '—'} g + ${t('recipes.nutrition_carbs')} +
    +
    + 🫒 + ${n.fat_g ?? '—'} g + ${t('recipes.nutrition_fat')} +
    +
    +

    ${t('recipes.nutrition_per_serving')}

    +
    `; + } + + // 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 += `
    +

    📦 ${t('recipes.storage_title')}

    +
    + ${s.where ? `${escapeHtml(s.where)}` : ''} + ${s.days > 0 ? `${escapeHtml(daysLabel)}` : `${escapeHtml(daysLabel)}`} +
    + ${s.tips ? `

    ${escapeHtml(s.tips)}

    ` : ''} +
    `; + } + + // Nutrition note (legacy / AI extra note) if (r.nutrition_note) { - html += `

    💡 ${r.nutrition_note}

    `; + html += `

    💡 ${escapeHtml(r.nutrition_note)}

    `; } document.getElementById('recipe-content').innerHTML = html; @@ -14453,10 +14514,7 @@ function _buildTtsRequest(text, s) { function _buildHaTtsRequest(text, s) { const haUrl = (s.ha_url || '').replace(/\/$/, ''); const url = haUrl + '/api/services/tts/speak'; - const headers = { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + (s.ha_token || ''), - }; + const headers = { 'Content-Type': 'application/json' }; const body = JSON.stringify({ entity_id: s.ha_tts_entity || '', message: text, @@ -14739,8 +14797,9 @@ async function saveHaSettings() { const statusEl = document.getElementById('ha-save-status'); try { - const settingsToken = document.getElementById('setting-settings-token')?.value.trim() || ''; - const tokenHeader = settingsToken ? { 'X-Settings-Token': settingsToken } : {}; + const settingsToken = document.getElementById('setting-settings-token')?.value.trim() || (typeof getApiToken === 'function' ? getApiToken() : ''); + if (settingsToken && typeof setApiToken === 'function') setApiToken(settingsToken); + const tokenHeader = settingsToken ? { 'X-API-Token': settingsToken } : (typeof apiAuthHeaders === 'function' ? apiAuthHeaders() : {}); const result = await api('save_settings', {}, 'POST', { ha_enabled: haEnabled, ha_url: haUrl, @@ -17522,7 +17581,11 @@ async function _runStartupCheck() { if (!wrapEl) return true; // preloader already removed - const tl = (key, fallback) => { try { return t('startup.' + key); } catch(e) { return fallback; } }; + const tl = (key, fallback) => { + const full = 'startup.' + key; + const v = typeof t === 'function' ? t(full) : full; + return (v === full) ? fallback : v; + }; // Switch from spinner to progress bar if (spinnerEl) spinnerEl.style.display = 'none'; @@ -17553,6 +17616,12 @@ async function _runStartupCheck() { el.textContent = cleanLabel; }; + // Auto-provision API token for same-origin browser sessions + if (typeof ensureApiToken === 'function') { + setProgress(5, tl('token_autoconfig', 'Configurazione accesso...'), 'ok'); + await ensureApiToken(); + } + // Phase 1: animate 0→15% while fetching (so it never looks stuck) setProgress(0, tl('connecting', 'Connessione al server...')); let _fetchDone = false; @@ -17568,9 +17637,22 @@ async function _runStartupCheck() { try { const ctrl = new AbortController(); const tid = setTimeout(() => ctrl.abort(), 12000); - const resp = await fetch('api/index.php?action=health_check', { signal: ctrl.signal }); + const resp = await fetch('api/index.php?action=health_check', { + signal: ctrl.signal, + headers: { ...(typeof apiAuthHeaders === 'function' ? apiAuthHeaders() : {}) }, + }); clearTimeout(tid); result = await resp.json(); + if (result.public && result.api_token_required && typeof getApiToken === 'function' && !getApiToken()) { + window._apiTokenRequired = true; + if (typeof _promptApiTokenIfNeeded === 'function') _promptApiTokenIfNeeded(); + setProgress(100, tl('token_required', 'Token API richiesto'), 'warn'); + return false; + } + if (result.public && result.api_token_required && typeof getApiToken === 'function' && getApiToken()) { + const resp2 = await fetch('api/index.php?action=health_check', { headers: apiAuthHeaders() }); + result = await resp2.json(); + } } catch(e) { clearInterval(slowAnim); _showStartupErrorPopup( @@ -17697,9 +17779,10 @@ async function _runStartupCheck() { // The bar already shows 100%; we just update the label for a moment. try { setProgress(100, tl('syncing_local', 'Sincronizzazione dati locali...'), 'ok'); + const authH = typeof apiAuthHeaders === 'function' ? apiAuthHeaders() : {}; const [invData, settingsData] = await Promise.all([ - fetch('api/index.php?action=inventory_list').then(r => r.json()).catch(() => null), - fetch('api/index.php?action=get_settings').then(r => r.json()).catch(() => null), + fetch('api/index.php?action=inventory_list', { headers: authH }).then(r => r.json()).catch(() => null), + fetch('api/index.php?action=get_settings', { headers: authH }).then(r => r.json()).catch(() => null), ]); if (invData && Array.isArray(invData.inventory)) _offlineCacheSet(invData.inventory); if (settingsData && settingsData.success !== false) _offlineCacheSetSettings(settingsData); diff --git a/assets/js/core/auth.js b/assets/js/core/auth.js new file mode 100644 index 0000000..cb54b7a --- /dev/null +++ b/assets/js/core/auth.js @@ -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 = ` + `; + 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; diff --git a/assets/js/core/dom.js b/assets/js/core/dom.js new file mode 100644 index 0000000..5707e51 --- /dev/null +++ b/assets/js/core/dom.js @@ -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; diff --git a/assets/vendor/quagga/quagga.min.js b/assets/vendor/quagga/quagga.min.js new file mode 100644 index 0000000..f5930e1 --- /dev/null +++ b/assets/vendor/quagga/quagga.min.js @@ -0,0 +1,4 @@ +!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.Quagga=e():t.Quagga=e()}(window,(function(){return function(t){var e={};function n(r){if(e[r])return e[r].exports;var o=e[r]={i:r,l:!1,exports:{}};return t[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=t,n.c=e,n.d=function(t,e,r){n.o(t,e)||Object.defineProperty(t,e,{enumerable:!0,get:r})},n.r=function(t){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},n.t=function(t,e){if(1&e&&(t=n(t)),8&e)return t;if(4&e&&"object"==typeof t&&t&&t.__esModule)return t;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:t}),2&e&&"string"!=typeof t)for(var o in t)n.d(r,o,function(e){return t[e]}.bind(null,o));return r},n.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return n.d(e,"a",e),e},n.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},n.p="/",n(n.s=73)}([function(t,e,n){var r=n(61);t.exports=function(t,e,n){return(e=r(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t},t.exports.__esModule=!0,t.exports.default=t.exports},function(t,e){t.exports=function(t){if(void 0===t)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return t},t.exports.__esModule=!0,t.exports.default=t.exports},function(t,e){function n(e){return t.exports=n=Object.setPrototypeOf?Object.getPrototypeOf.bind():function(t){return t.__proto__||Object.getPrototypeOf(t)},t.exports.__esModule=!0,t.exports.default=t.exports,n(e)}t.exports=n,t.exports.__esModule=!0,t.exports.default=t.exports},function(t,e){t.exports=function(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")},t.exports.__esModule=!0,t.exports.default=t.exports},function(t,e,n){var r=n(61);function o(t,e){for(var n=0;n0&&(o=1/Math.sqrt(o)),t[0]=e[0]*o,t[1]=e[1]*o,t}function st(t,e){return t[0]*e[0]+t[1]*e[1]}function ft(t,e,n){var r=e[0]*n[1]-e[1]*n[0];return t[0]=t[1]=0,t[2]=r,t}function lt(t,e,n,r){var o=e[0],i=e[1];return t[0]=o+r*(n[0]-o),t[1]=i+r*(n[1]-i),t}function dt(t,e){e=e||1;var n=2*s()*Math.PI;return t[0]=Math.cos(n)*e,t[1]=Math.sin(n)*e,t}function ht(t,e,n){var r=e[0],o=e[1];return t[0]=n[0]*r+n[2]*o,t[1]=n[1]*r+n[3]*o,t}function vt(t,e,n){var r=e[0],o=e[1];return t[0]=n[0]*r+n[2]*o+n[4],t[1]=n[1]*r+n[3]*o+n[5],t}function pt(t,e,n){var r=e[0],o=e[1];return t[0]=n[0]*r+n[3]*o+n[6],t[1]=n[1]*r+n[4]*o+n[7],t}function yt(t,e,n){var r=e[0],o=e[1];return t[0]=n[0]*r+n[4]*o+n[12],t[1]=n[1]*r+n[5]*o+n[13],t}function gt(t,e,n,r){var o=e[0]-n[0],i=e[1]-n[1],a=Math.sin(r),u=Math.cos(r);return t[0]=o*u-i*a+n[0],t[1]=o*a+i*u+n[1],t}function xt(t,e){var n=t[0],r=t[1],o=e[0],i=e[1],a=Math.sqrt(n*n+r*r)*Math.sqrt(o*o+i*i),u=a&&(n*o+r*i)/a;return Math.acos(Math.min(Math.max(u,-1),1))}function mt(t){return t[0]=0,t[1]=0,t}function _t(t){return"vec2("+t[0]+", "+t[1]+")"}function bt(t,e){return t[0]===e[0]&&t[1]===e[1]}function wt(t,e){var n=t[0],r=t[1],o=e[0],i=e[1];return Math.abs(n-o)<=u*Math.max(1,Math.abs(n),Math.abs(o))&&Math.abs(r-i)<=u*Math.max(1,Math.abs(r),Math.abs(i))}var Ot,Rt=ot,Mt=H,Ct=X,Et=Q,At=nt,St=rt,kt=it,Pt=(Ot=F(),function(t,e,n,r,o,i){var a,u;for(e||(e=2),n||(n=0),u=r?Math.min(r*e+n,t.length):t.length,a=n;a0&&(i=1/Math.sqrt(i)),t[0]=e[0]*i,t[1]=e[1]*i,t[2]=e[2]*i,t}function ee(t,e){return t[0]*e[0]+t[1]*e[1]+t[2]*e[2]}function ne(t,e,n){var r=e[0],o=e[1],i=e[2],a=n[0],u=n[1],c=n[2];return t[0]=o*c-i*u,t[1]=i*a-r*c,t[2]=r*u-o*a,t}function re(t,e,n,r){var o=e[0],i=e[1],a=e[2];return t[0]=o+r*(n[0]-o),t[1]=i+r*(n[1]-i),t[2]=a+r*(n[2]-a),t}function oe(t,e,n,r,o,i){var a=i*i,u=a*(2*i-3)+1,c=a*(i-2)+i,s=a*(i-1),f=a*(3-2*i);return t[0]=e[0]*u+n[0]*c+r[0]*s+o[0]*f,t[1]=e[1]*u+n[1]*c+r[1]*s+o[1]*f,t[2]=e[2]*u+n[2]*c+r[2]*s+o[2]*f,t}function ie(t,e,n,r,o,i){var a=1-i,u=a*a,c=i*i,s=u*a,f=3*i*u,l=3*c*a,d=c*i;return t[0]=e[0]*s+n[0]*f+r[0]*l+o[0]*d,t[1]=e[1]*s+n[1]*f+r[1]*l+o[1]*d,t[2]=e[2]*s+n[2]*f+r[2]*l+o[2]*d,t}function ae(t,e){e=e||1;var n=2*s()*Math.PI,r=2*s()-1,o=Math.sqrt(1-r*r)*e;return t[0]=Math.cos(n)*o,t[1]=Math.sin(n)*o,t[2]=r*e,t}function ue(t,e,n){var r=e[0],o=e[1],i=e[2],a=n[3]*r+n[7]*o+n[11]*i+n[15];return a=a||1,t[0]=(n[0]*r+n[4]*o+n[8]*i+n[12])/a,t[1]=(n[1]*r+n[5]*o+n[9]*i+n[13])/a,t[2]=(n[2]*r+n[6]*o+n[10]*i+n[14])/a,t}function ce(t,e,n){var r=e[0],o=e[1],i=e[2];return t[0]=r*n[0]+o*n[3]+i*n[6],t[1]=r*n[1]+o*n[4]+i*n[7],t[2]=r*n[2]+o*n[5]+i*n[8],t}function se(t,e,n){var r=n[0],o=n[1],i=n[2],a=n[3],u=e[0],c=e[1],s=e[2],f=o*s-i*c,l=i*u-r*s,d=r*c-o*u,h=o*d-i*l,v=i*f-r*d,p=r*l-o*f,y=2*a;return f*=y,l*=y,d*=y,h*=2,v*=2,p*=2,t[0]=u+f+h,t[1]=c+l+v,t[2]=s+d+p,t}function fe(t,e,n,r){var o=[],i=[];return o[0]=e[0]-n[0],o[1]=e[1]-n[1],o[2]=e[2]-n[2],i[0]=o[0],i[1]=o[1]*Math.cos(r)-o[2]*Math.sin(r),i[2]=o[1]*Math.sin(r)+o[2]*Math.cos(r),t[0]=i[0]+n[0],t[1]=i[1]+n[1],t[2]=i[2]+n[2],t}function le(t,e,n,r){var o=[],i=[];return o[0]=e[0]-n[0],o[1]=e[1]-n[1],o[2]=e[2]-n[2],i[0]=o[2]*Math.sin(r)+o[0]*Math.cos(r),i[1]=o[1],i[2]=o[2]*Math.cos(r)-o[0]*Math.sin(r),t[0]=i[0]+n[0],t[1]=i[1]+n[1],t[2]=i[2]+n[2],t}function de(t,e,n,r){var o=[],i=[];return o[0]=e[0]-n[0],o[1]=e[1]-n[1],o[2]=e[2]-n[2],i[0]=o[0]*Math.cos(r)-o[1]*Math.sin(r),i[1]=o[0]*Math.sin(r)+o[1]*Math.cos(r),i[2]=o[2],t[0]=i[0]+n[0],t[1]=i[1]+n[1],t[2]=i[2]+n[2],t}function he(t,e){var n=t[0],r=t[1],o=t[2],i=e[0],a=e[1],u=e[2],c=Math.sqrt(n*n+r*r+o*o)*Math.sqrt(i*i+a*a+u*u),s=c&&ee(t,e)/c;return Math.acos(Math.min(Math.max(s,-1),1))}function ve(t){return t[0]=0,t[1]=0,t[2]=0,t}function pe(t){return"vec3("+t[0]+", "+t[1]+", "+t[2]+")"}function ye(t,e){return t[0]===e[0]&&t[1]===e[1]&&t[2]===e[2]}function ge(t,e){var n=t[0],r=t[1],o=t[2],i=e[0],a=e[1],c=e[2];return Math.abs(n-i)<=u*Math.max(1,Math.abs(n),Math.abs(i))&&Math.abs(r-a)<=u*Math.max(1,Math.abs(r),Math.abs(a))&&Math.abs(o-c)<=u*Math.max(1,Math.abs(o),Math.abs(c))}var xe=Nt,me=Ft,_e=Bt,be=Yt,we=$t,Oe=jt,Re=Zt,Me=function(){var t=Tt();return function(e,n,r,o,i,a){var u,c;for(n||(n=3),r||(r=0),c=o?Math.min(o*n+r,e.length):e.length,u=r;u0;e--){var n=Math.floor(Math.random()*(e+1)),r=[t[n],t[e]];t[e]=r[0],t[n]=r[1]}return t},toPointList:function(t){var e=t.reduce((function(t,e){var n="[".concat(e.join(","),"]");return t.push(n),t}),[]);return"[".concat(e.join(",\r\n"),"]")},threshold:function(t,e,n){return t.reduce((function(r,o){return n.apply(t,[o])>=e&&r.push(o),r}),[])},maxIndex:function(t){for(var e=0,n=0;nt[e]&&(e=n);return e},max:function(t){for(var e=0,n=0;ne&&(e=t[n]);return e},sum:function(t){for(var e=t.length,n=0;e--;)n+=t[e];return n}}},function(t,e,n){"use strict";n.d(e,"h",(function(){return u})),n.d(e,"i",(function(){return s})),n.d(e,"b",(function(){return f})),n.d(e,"j",(function(){return l})),n.d(e,"e",(function(){return d})),n.d(e,"c",(function(){return h})),n.d(e,"f",(function(){return v})),n.d(e,"g",(function(){return p})),n.d(e,"a",(function(){return g})),n.d(e,"d",(function(){return m}));var r=n(5),o=n(9);r.a.setMatrixArrayType(Array);var i=function(t,e){var n=[],o={rad:0,vec:r.c.clone([0,0])},i={};function a(t){i[t.id]=t,n.push(t)}function u(){var t,e=0;for(t=0;te},getPoints:function(){return n},getCenter:function(){return o}}},a=function(t,e,n){return{rad:t[n],point:t,id:e}};function u(t,e){return{x:t,y:e,toVec2:function(){return r.c.clone([this.x,this.y])},toVec3:function(){return r.d.clone([this.x,this.y,1])},round:function(){return this.x=this.x>0?Math.floor(this.x+.5):Math.floor(this.x-.5),this.y=this.y>0?Math.floor(this.y+.5):Math.floor(this.y-.5),this}}}function c(t,e){e||(e=8);for(var n=t.data,r=n.length,o=8-e,i=new Int32Array(1<>o]++;return i}function s(t,e){var n=function(t){var e,n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:8,r=8-n;function i(t,n){for(var r=0,o=t;o<=n;o++)r+=e[o];return r}function a(t,n){for(var r=0,o=t;o<=n;o++)r+=o*e[o];return r}function u(){var r,u,s,f,l=[0],d=(1<c)for((i=s[u]).score=o,i.item=t[r],c=Number.MAX_VALUE,a=0;a1&&void 0!==arguments[1]?arguments[1]:[0,0,0],n=t[0],r=t[1],o=t[2],i=o*r,a=i*(1-Math.abs(n/60%2-1)),u=o-i,c=0,s=0,f=0;return n<60?(c=i,s=a):n<120?(c=a,s=i):n<180?(s=i,f=a):n<240?(s=a,f=i):n<300?(c=a,f=i):n<360&&(c=i,f=a),e[0]=255*(c+u)|0,e[1]=255*(s+u)|0,e[2]=255*(f+u)|0,e}function y(t){for(var e=[],n=[],r=1;re[r]?r++:n++;return o}(r,o),u=[8,10,15,20,32,60,80],c={"x-small":5,small:4,medium:3,large:2,"x-large":1},s=c[t]||c.medium,f=u[s],l=Math.floor(i/f);function d(t){for(var e=0,n=t[Math.floor(t.length/2)];e0&&(n=Math.abs(t[e]-l)>Math.abs(t[e-1]-l)?t[e-1]:t[e]),l/nu[s-1]/u[s]?{x:n,y:n}:null}return(n=d(a))||(n=d(y(i)))||(n=d(y(l*f))),n}var x={top:function(t,e){return"%"===t.unit?Math.floor(e.height*(t.value/100)):null},right:function(t,e){return"%"===t.unit?Math.floor(e.width-e.width*(t.value/100)):null},bottom:function(t,e){return"%"===t.unit?Math.floor(e.height-e.height*(t.value/100)):null},left:function(t,e){return"%"===t.unit?Math.floor(e.width*(t.value/100)):null}};function m(t,e,n){var r={width:t,height:e},o=Object.keys(n).reduce((function(t,e){var o=function(t){return{value:parseFloat(t),unit:(t.indexOf("%"),t.length,"%")}}(n[e]),i=x[e](o,r);return t[e]=i,t}),{});return{sx:o.left,sy:o.top,sw:o.right-o.left,sh:o.bottom-o.top}}},function(t,e,n){"use strict";var r=n(62),o=n.n(r),i=n(3),a=n.n(i),u=n(4),c=n.n(u),s=n(0),f=n.n(s),l=n(5),d=n(9),h=n(10);function v(t){if(t<0)throw new Error("expected positive number, received ".concat(t))}l.a.setMatrixArrayType(Array);var p=function(){function t(e,n){var r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:Uint8Array,o=arguments.length>3?arguments[3]:void 0;a()(this,t),f()(this,"data",void 0),f()(this,"size",void 0),f()(this,"indexMapping",void 0),n?this.data=n:(this.data=new r(e.x*e.y),o&&d.a.init(this.data,0)),this.size=e}return c()(t,[{key:"inImageWithBorder",value:function(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0;return v(e),t.x>=0&&t.y>=0&&t.x0&&((a=p[r-1]).m00+=1,a.m01+=n,a.m10+=e,a.m11+=e*n,a.m02+=o,a.m20+=e*e);for(i=0;i=0?x:-x)+g,a.theta=(180*f/g+90)%180-90,a.theta<0&&(a.theta+=180),a.rad=f>g?f-g:f,a.vec=l.c.clone([Math.cos(f),Math.sin(f)]),y.push(a));return y}},{key:"getAsRGBA",value:function(){for(var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:1,e=new Uint8ClampedArray(4*this.size.x*this.size.y),n=0;n1&&void 0!==arguments[1]?arguments[1]:1;console.warn("* imagewrapper show getcontext 2d");var n=t.getContext("2d");if(!n)throw new Error("Unable to get canvas context");var r=n.getImageData(0,0,t.width,t.height),o=this.getAsRGBA(e);t.width=this.size.x,t.height=this.size.y;var i=new ImageData(o,r.width,r.height);n.putImageData(i,0,0)}},{key:"overlay",value:function(t,e,n){var r=e<0||e>360?360:e,i=[0,1,1],a=[0,0,0],u=[255,255,255],c=[0,0,0];console.warn("* imagewrapper overlay getcontext 2d");var s=t.getContext("2d");if(!s)throw new Error("Unable to get canvas context");for(var f=s.getImageData(n.x,n.y,this.size.x,this.size.y),l=f.data,d=this.data.length;d--;){i[0]=this.data[d]*r;var v=4*d,p=i[0]<=0?u:i[0]>=360?c:Object(h.g)(i,a),y=o()(p,3);l[v]=y[0],l[v+1]=y[1],l[v+2]=y[2],l[v+3]=255}s.putImageData(f,n.x,n.y)}}]),t}();e.a=p},function(t,e){function n(t,e,n,r,o,i,a){try{var u=t[i](a),c=u.value}catch(t){return void n(t)}u.done?e(c):Promise.resolve(c).then(r,o)}t.exports=function(t){return function(){var e=this,r=arguments;return new Promise((function(o,i){var a=t.apply(e,r);function u(t){n(a,o,i,u,c,"next",t)}function c(t){n(a,o,i,u,c,"throw",t)}u(void 0)}))}},t.exports.__esModule=!0,t.exports.default=t.exports},function(t,e){function n(e){return t.exports=n="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},t.exports.__esModule=!0,t.exports.default=t.exports,n(e)}t.exports=n,t.exports.__esModule=!0,t.exports.default=t.exports},function(t,e,n){var r=n(142);function o(){return"undefined"!=typeof Reflect&&Reflect.get?(t.exports=o=Reflect.get.bind(),t.exports.__esModule=!0,t.exports.default=t.exports):(t.exports=o=function(t,e,n){var o=r(t,e);if(o){var i=Object.getOwnPropertyDescriptor(o,e);return i.get?i.get.call(arguments.length<3?t:n):i.value}},t.exports.__esModule=!0,t.exports.default=t.exports),o.apply(this,arguments)}t.exports=o,t.exports.__esModule=!0,t.exports.default=t.exports},function(t,e){t.exports=function(t){var e=typeof t;return null!=t&&("object"==e||"function"==e)}},function(t,e){var n=Array.isArray;t.exports=n},function(t,e,n){"use strict";e.a={drawRect:function(t,e,n,r){n.strokeStyle=r.color,n.fillStyle=r.color,n.lineWidth=r.lineWidth||1,n.beginPath(),n.strokeRect(t.x,t.y,e.x,e.y)},drawPath:function(t,e,n,r){n.strokeStyle=r.color,n.fillStyle=r.color,n.lineWidth=r.lineWidth,n.beginPath(),n.moveTo(t[0][e.x],t[0][e.y]);for(var o=1;od&&(d=i.box[o][0]),i.box[o][1]v&&(v=i.box[o][1]);for(u=[[s,f],[d,f],[d,v],[s,v]],c=r.halfSample?2:1,a=h.b.invert(a,a),o=0;o<4;o++)h.c.transformMat2(u[o],u[o],a);for(o=0;o<4;o++)h.c.scale(u[o],u[o],c);return u}function M(t,e){l.subImageAsCopy(a,Object(p.h)(t,e)),b.skeletonize()}function C(t,e,n,r){var o,i,u,c,s=[],f=[],l=Math.ceil(d.x/3);if(t.length>=2){for(o=0;ol&&s.push(t[o]);if(s.length>=2){for(u=function(t){var e=Object(p.b)(t,.9),n=Object(p.j)(e,1,(function(t){return t.getPoints().length})),r=[],o=[];if(1===n.length){r=n[0].item.getPoints();for(var i=0;i1&&u.length>=s.length/4*3&&u.length>t.length/4&&(i/=u.length,c={index:e[1]*O.x+e[0],pos:{x:n,y:r},box:[h.c.clone([n,r]),h.c.clone([n+a.size.x,r]),h.c.clone([n+a.size.x,r+a.size.y]),h.c.clone([n,r+a.size.y])],moments:u,rad:i,vec:h.c.clone([Math.cos(i),Math.sin(i)])},f.push(c))}}return f}e.a={init:function(e,n){r=n,_=e,function(){o=r.halfSample?new v.a({x:_.size.x/2|0,y:_.size.y/2|0}):_,d=Object(p.a)(r.patchSize,o.size),O.x=o.size.x/d.x|0,O.y=o.size.y/d.y|0,l=new v.a(o.size,void 0,Uint8Array,!1),u=new v.a(d,void 0,Array,!0);var e=new ArrayBuffer(65536);a=new v.a(d,new Uint8Array(e,0,d.x*d.y)),i=new v.a(d,new Uint8Array(e,d.x*d.y*3,d.x*d.y),void 0,!0),b=Object(m.a)("undefined"!=typeof window?window:"undefined"!=typeof self?self:t,{size:d.x},e),f=new v.a({x:o.size.x/a.size.x|0,y:o.size.y/a.size.y|0},void 0,Array,!0),c=new v.a(f.size,void 0,void 0,!0),s=new v.a(f.size,void 0,Int32Array,!0)}(),function(){if(!r.useWorker&&"undefined"!=typeof document){w.dom.binary=document.createElement("canvas"),w.dom.binary.className="binaryBuffer";var t=!!r.willReadFrequently;console.warn("* initCanvas willReadFrequently",t,r),w.ctx.binary=w.dom.binary.getContext("2d",{willReadFrequently:t}),w.dom.binary.width=l.size.x,w.dom.binary.height=l.size.y}}()},locate:function(){r.halfSample&&Object(p.f)(_,o),Object(p.i)(o,l),l.zeroBorder();var t=function(){var t,e,n,r,o,c,s=[];for(t=0;t.95&&a(i):s.data[i]=Number.MAX_VALUE}for(y.a.init(c.data,0),y.a.init(s.data,0),y.a.init(f.data,null),e=0;e0&&r[s.data[n]-1]++;return(r=r.map((function(t,e){return{val:t,label:e+1}}))).sort((function(t,e){return e.val-t.val})),r.filter((function(t){return t.val>=5}))}(e);return 0===n.length?null:function(t,e){var n,r,o,i,a=[],u=[];for(n=0;n-1&&t%1==0&&t-1&&t%1==0&&t<=9007199254740991}},function(t,e){function n(e,r){return t.exports=n=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(t,e){return t.__proto__=e,t},t.exports.__esModule=!0,t.exports.default=t.exports,n(e,r)}t.exports=n,t.exports.__esModule=!0,t.exports.default=t.exports},function(t,e,n){var r=n(22),o=n(20);t.exports=function(t){return"symbol"==typeof t||o(t)&&"[object Symbol]"==r(t)}},function(t,e,n){var r=n(41);t.exports=function(t){if("string"==typeof t||r(t))return t;var e=t+"";return"0"==e&&1/t==-1/0?"-0":e}},function(t,e,n){var r=n(34)(n(19),"Map");t.exports=r},function(t,e,n){(function(e){var n="object"==typeof e&&e&&e.Object===Object&&e;t.exports=n}).call(this,n(45))},function(t,e){var n;n=function(){return this}();try{n=n||new Function("return this")()}catch(t){"object"==typeof window&&(n=window)}t.exports=n},function(t,e,n){var r=n(93),o=n(100),i=n(102),a=n(103),u=n(104);function c(t){var e=-1,n=null==t?0:t.length;for(this.clear();++et.length)&&(e=t.length);for(var n=0,r=new Array(e);n0){a=a-1|0;r[n+a|0]=(r[t+a|0]|0)-(r[e+a|0]|0)|0}}function c(t,e,n){t|=0;e|=0;n|=0;var a=0;a=i(o,o)|0;while((a|0)>0){a=a-1|0;r[n+a|0]=r[t+a|0]|0|(r[e+a|0]|0)|0}}function s(t){t|=0;var e=0;var n=0;n=i(o,o)|0;while((n|0)>0){n=n-1|0;e=(e|0)+(r[t+n|0]|0)|0}return e|0}function f(t,e){t|=0;e|=0;var n=0;n=i(o,o)|0;while((n|0)>0){n=n-1|0;r[t+n|0]=e}}function l(t,e){t|=0;e|=0;var n=0;var i=0;var a=0;var u=0;var c=0;var s=0;var f=0;var l=0;for(n=1;(n|0)<(o-1|0);n=n+1|0){l=l+o|0;for(i=1;(i|0)<(o-1|0);i=i+1|0){u=l-o|0;c=l+o|0;s=i-1|0;f=i+1|0;a=(r[t+u+s|0]|0)+(r[t+u+f|0]|0)+(r[t+l+i|0]|0)+(r[t+c+s|0]|0)+(r[t+c+f|0]|0)|0;if((a|0)>(0|0)){r[e+l+i|0]=1}else{r[e+l+i|0]=0}}}}function d(t,e){t|=0;e|=0;var n=0;n=i(o,o)|0;while((n|0)>0){n=n-1|0;r[e+n|0]=r[t+n|0]|0}}function h(t){t|=0;var e=0;var n=0;for(e=0;(e|0)<(o-1|0);e=e+1|0){r[t+e|0]=0;r[t+n|0]=0;n=n+o-1|0;r[t+n|0]=0;n=n+1|0}for(e=0;(e|0)<(o|0);e=e+1|0){r[t+n|0]=0;n=n+1|0}}function v(){var t=0;var e=0;var n=0;var r=0;var v=0;var p=0;e=i(o,o)|0;n=e+e|0;r=n+e|0;f(r,0);h(t);do{a(t,e);l(e,n);u(t,n,n);c(r,n,r);d(e,t);v=s(t)|0;p=(v|0)==0|0}while(!p)}return{skeletonize:v}}},,,,,,,function(t,e,n){t.exports=n(168)},function(t,e,n){var r=n(75),o=n(47),i=n(105),a=n(107),u=n(15),c=n(55),s=n(53);t.exports=function t(e,n,f,l,d){e!==n&&i(n,(function(i,c){if(d||(d=new r),u(i))a(e,n,c,f,t,l,d);else{var h=l?l(s(e,c),i,c+"",e,n,d):void 0;void 0===h&&(h=i),o(e,c,h)}}),c)}},function(t,e,n){var r=n(24),o=n(81),i=n(82),a=n(83),u=n(84),c=n(85);function s(t){var e=this.__data__=new r(t);this.size=e.size}s.prototype.clear=o,s.prototype.delete=i,s.prototype.get=a,s.prototype.has=u,s.prototype.set=c,t.exports=s},function(t,e){t.exports=function(){this.__data__=[],this.size=0}},function(t,e,n){var r=n(25),o=Array.prototype.splice;t.exports=function(t){var e=this.__data__,n=r(e,t);return!(n<0)&&(n==e.length-1?e.pop():o.call(e,n,1),--this.size,!0)}},function(t,e,n){var r=n(25);t.exports=function(t){var e=this.__data__,n=r(e,t);return n<0?void 0:e[n][1]}},function(t,e,n){var r=n(25);t.exports=function(t){return r(this.__data__,t)>-1}},function(t,e,n){var r=n(25);t.exports=function(t,e){var n=this.__data__,o=r(n,t);return o<0?(++this.size,n.push([t,e])):n[o][1]=e,this}},function(t,e,n){var r=n(24);t.exports=function(){this.__data__=new r,this.size=0}},function(t,e){t.exports=function(t){var e=this.__data__,n=e.delete(t);return this.size=e.size,n}},function(t,e){t.exports=function(t){return this.__data__.get(t)}},function(t,e){t.exports=function(t){return this.__data__.has(t)}},function(t,e,n){var r=n(24),o=n(43),i=n(46);t.exports=function(t,e){var n=this.__data__;if(n instanceof r){var a=n.__data__;if(!o||a.length<199)return a.push([t,e]),this.size=++n.size,this;n=this.__data__=new i(a)}return n.set(t,e),this.size=n.size,this}},function(t,e,n){var r=n(35),o=n(89),i=n(15),a=n(91),u=/^\[object .+?Constructor\]$/,c=Function.prototype,s=Object.prototype,f=c.toString,l=s.hasOwnProperty,d=RegExp("^"+f.call(l).replace(/[\\^$.*+?()[\]{}|]/g,"\\$&").replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g,"$1.*?")+"$");t.exports=function(t){return!(!i(t)||o(t))&&(r(t)?d:u).test(a(t))}},function(t,e,n){var r=n(27),o=Object.prototype,i=o.hasOwnProperty,a=o.toString,u=r?r.toStringTag:void 0;t.exports=function(t){var e=i.call(t,u),n=t[u];try{t[u]=void 0;var r=!0}catch(t){}var o=a.call(t);return r&&(e?t[u]=n:delete t[u]),o}},function(t,e){var n=Object.prototype.toString;t.exports=function(t){return n.call(t)}},function(t,e,n){var r,o=n(90),i=(r=/[^.]+$/.exec(o&&o.keys&&o.keys.IE_PROTO||""))?"Symbol(src)_1."+r:"";t.exports=function(t){return!!i&&i in t}},function(t,e,n){var r=n(19)["__core-js_shared__"];t.exports=r},function(t,e){var n=Function.prototype.toString;t.exports=function(t){if(null!=t){try{return n.call(t)}catch(t){}try{return t+""}catch(t){}}return""}},function(t,e){t.exports=function(t,e){return null==t?void 0:t[e]}},function(t,e,n){var r=n(94),o=n(24),i=n(43);t.exports=function(){this.size=0,this.__data__={hash:new r,map:new(i||o),string:new r}}},function(t,e,n){var r=n(95),o=n(96),i=n(97),a=n(98),u=n(99);function c(t){var e=-1,n=null==t?0:t.length;for(this.clear();++e1?n[i-1]:void 0,u=i>2?n[2]:void 0;for(a=t.length>3&&"function"==typeof a?(i--,a):void 0,u&&o(n[0],n[1],u)&&(a=i<3?void 0:a,i=1),e=Object(e);++r0){if(++e>=800)return arguments[0]}else e=0;return t.apply(void 0,arguments)}}},function(t,e,n){var r=n(26),o=n(38),i=n(31),a=n(15);t.exports=function(t,e,n){if(!a(n))return!1;var u=typeof e;return!!("number"==u?o(n)&&i(e,n.length):"string"==u&&e in n)&&r(n[e],t)}},function(t,e){"undefined"!=typeof window&&(window.requestAnimationFrame||(window.requestAnimationFrame=window.webkitRequestAnimationFrame||window.mozRequestAnimationFrame||window.oRequestAnimationFrame||window.msRequestAnimationFrame||function(t){window.setTimeout(t,1e3/60)})),"function"!=typeof Math.imul&&(Math.imul=function(t,e){var n=65535&t,r=65535&e;return n*r+((t>>>16&65535)*r+n*(e>>>16&65535)<<16>>>0)|0}),"function"!=typeof Object.assign&&(Object.assign=function(t){"use strict";if(null===t)throw new TypeError("Cannot convert undefined or null to object");for(var e=Object(t),n=1;n=0;--o){var i=this.tryEntries[o],u=i.completion;if("root"===i.tryLoc)return r("end");if(i.tryLoc<=this.prev){var c=a.call(i,"catchLoc"),s=a.call(i,"finallyLoc");if(c&&s){if(this.prev=0;--n){var r=this.tryEntries[n];if(r.tryLoc<=this.prev&&a.call(r,"finallyLoc")&&this.prev=0;--e){var n=this.tryEntries[e];if(n.finallyLoc===t)return this.complete(n.completion,n.afterLoc),P(n),x}},catch:function(t){for(var e=this.tryEntries.length-1;e>=0;--e){var n=this.tryEntries[e];if(n.tryLoc===t){var r=n.completion;if("throw"===r.type){var o=r.arg;P(n)}return o}}throw new Error("illegal catch attempt")},delegateYield:function(t,n,r){return this.delegate={iterator:D(t),resultName:n,nextLoc:r},"next"===this.method&&(this.arg=e),x}},n}t.exports=o,t.exports.__esModule=!0,t.exports.default=t.exports},function(t,e,n){var r=n(2);t.exports=function(t,e){for(;!Object.prototype.hasOwnProperty.call(t,e)&&null!==(t=r(t)););return t},t.exports.__esModule=!0,t.exports.default=t.exports},function(t,e,n){var r=n(60);t.exports=function(t){if(Array.isArray(t))return r(t)},t.exports.__esModule=!0,t.exports.default=t.exports},function(t,e){t.exports=function(t){if("undefined"!=typeof Symbol&&null!=t[Symbol.iterator]||null!=t["@@iterator"])return Array.from(t)},t.exports.__esModule=!0,t.exports.default=t.exports},function(t,e){t.exports=function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")},t.exports.__esModule=!0,t.exports.default=t.exports},function(t,e,n){var r=n(147),o=n(157);t.exports=function(t,e){return r(t,e,(function(e,n){return o(t,n)}))}},function(t,e,n){var r=n(148),o=n(156),i=n(32);t.exports=function(t,e,n){for(var a=-1,u=e.length,c={};++a0&&i(f)?n>1?t(f,n-1,i,a,u):r(u,f):a||(u[u.length]=f)}return u}},function(t,e){t.exports=function(t,e){for(var n=-1,r=e.length,o=t.length;++n1&&void 0!==arguments[1]?arguments[1]:0,n=e;n2&&void 0!==arguments[2]?arguments[2]:this.SINGLE_CODE_ERROR||1,r=0,o=0,i=0,a=0,u=0,c=0,s=0,f=0;fn)return Number.MAX_VALUE;r+=o}return r/a}},{key:"_nextSet",value:function(t){for(var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0,n=e;n1&&(t[n[r]]=o)}},{key:"decodePattern",value:function(t){this._row=t;var e=this.decode();return null===e?(this._row.reverse(),(e=this.decode())&&(e.direction=S.Reverse,e.start=this._row.length-e.start,e.end=this._row.length-e.end)):e.direction=S.Forward,e&&(e.format=this.FORMAT),e}},{key:"_matchRange",value:function(t,e,n){var r;for(r=t=t<0?0:t;r0&&void 0!==arguments[0]?arguments[0]:this._nextUnset(this._row),e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:this._row.length,n=!(arguments.length>2&&void 0!==arguments[2])||arguments[2],r=[],o=0;r[o]=0;for(var i=t;i2&&void 0!==arguments[2]&&arguments[2],r=arguments.length>3&&void 0!==arguments[3]&&arguments[3],o=[],i=0,a={error:Number.MAX_VALUE,code:-1,start:0,end:0},u=0,c=0,s=this.AVG_CODE_ERROR;e||(e=this._nextSet(this._row));for(var f=0;f=0&&this._matchRange(r,t.start,0))return t;e=t.end,t=null}return t}},{key:"_verifyTrailingWhitespace",value:function(t){var e=t.end+(t.end-t.start)/2;return er&&(r=o),othis._counters.length)return-1;for(var n=this._computeAlternatingThreshold(t,e),r=this._computeAlternatingThreshold(t+1,e),o=64,i=0,a=0,u=0;u<7;u++)i=0==(1&u)?n:r,this._counters[t+u]>i&&(a|=o),o>>=1;return a}},{key:"_isStartEnd",value:function(t){for(var e=0;e=this._calculatePatternLength(t)/2)&&(e+8>=this._counters.length||this._counters[e+7]>=this._calculatePatternLength(e)/2)}},{key:"_charToPattern",value:function(t){for(var e=t.charCodeAt(0),n=0;n=0;a--){var u=2==(1&a)?r.bar:r.space,c=1==(1&n)?u.wide:u.narrow;c.size+=this._counters[o+a],c.counts++,n>>=1}o+=8}return["space","bar"].forEach((function(t){var e=r[t];e.wide.min=Math.floor((e.narrow.size/e.narrow.counts+e.wide.size/e.wide.counts)/2),e.narrow.max=Math.ceil(e.wide.min),e.wide.max=Math.ceil((2*e.wide.size+1.5)/e.wide.counts)})),r}},{key:"_validateResult",value:function(t,e){for(var n,r=this._thresholdResultPattern(t,e),o=e,i=0;i=0;a--){var u=0==(1&a)?r.bar:r.space,c=1==(1&n)?u.wide:u.narrow,s=this._counters[o+a];if(sc.max)return!1;n>>=1}o+=8}return!0}},{key:"decode",value:function(t,e){if(this._counters=this._fillCounters(),!(e=this._findStart()))return null;var n,r=e.startCounter,o=[];do{if((n=this._toPattern(r))<0)return null;var i=this._patternToChar(n);if(null===i)return null;if(o.push(i),r+=8,o.length>1&&this._isStartEnd(n))break}while(rthis._counters.length?this._counters.length:r;var a=e.start+this._sumCounters(e.startCounter,r-8);return{code:o.join(""),start:e.start,end:a,startInfo:e,decodedCodes:o,format:this.FORMAT}}}]),n}(k);function W(t){var e=function(){if("undefined"==typeof Reflect||!Reflect.construct)return!1;if(Reflect.construct.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],(function(){}))),!0}catch(t){return!1}}();return function(){var n,r=M()(t);if(e){var o=M()(this).constructor;n=Reflect.construct(r,arguments,o)}else n=r.apply(this,arguments);return O()(this,n)}}var q=function(t){b()(n,t);var e=W(n);function n(){var t;p()(this,n);for(var r=arguments.length,o=new Array(r),i=0;ithis.AVG_CODE_ERROR?null:(this.CODE_PATTERN[n.code]&&(n.correction.bar=this.calculateCorrection(this.CODE_PATTERN[n.code],r,this.MODULE_INDICES.bar),n.correction.space=this.calculateCorrection(this.CODE_PATTERN[n.code],r,this.MODULE_INDICES.space)),n)}r[++a]=1,i=!i}return null}},{key:"_correct",value:function(t,e){this._correctBars(t,e.bar,this.MODULE_INDICES.bar),this._correctBars(t,e.space,this.MODULE_INDICES.space)}},{key:"_findStart",value:function(){for(var t=[0,0,0,0,0,0],e=this._nextSet(this._row),n={error:Number.MAX_VALUE,code:-1,start:0,end:0,correction:{bar:1,space:1}},r=!1,o=0,i=e;i3;){n=this._findNextWidth(t,n),r=0;for(var i=0,a=0;an&&(i|=1<0;u++)if(t[u]>n&&(r--,2*t[u]>=o))return-1;return i}}return-1}},{key:"_findNextWidth",value:function(t,e){for(var n=Number.MAX_VALUE,r=0;re&&(n=t[r]);return n}},{key:"_patternToChar",value:function(t){for(var e=0;e<$.length;e++)if($[e]===t)return String.fromCharCode(Y[e]);return null}},{key:"_verifyTrailingWhitespace",value:function(t,e,n){var r=A.a.sum(n);return 3*(e-t-r)>=r}},{key:"decode",value:function(){var t=new Uint16Array([0,0,0,0,0,0,0,0,0]),e=[],n=this._findStart();if(!n)return null;var r,o,i=this._nextSet(this._row,n.end);do{t=this._toCounters(i,t);var a=this._toPattern(t);if(a<0)return null;if(null===(r=this._patternToChar(a)))return null;e.push(r),o=i,i+=A.a.sum(t),i=this._nextSet(this._row,i)}while("*"!==r);return e.pop(),e.length&&this._verifyTrailingWhitespace(o,i,t)?{code:e.join(""),start:n.start,end:i,startInfo:n,decodedCodes:e,format:this.FORMAT}:null}}]),n}(k);function K(t){var e=function(){if("undefined"==typeof Reflect||!Reflect.construct)return!1;if(Reflect.construct.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],(function(){}))),!0}catch(t){return!1}}();return function(){var n,r=M()(t);if(e){var o=M()(this).constructor;n=Reflect.construct(r,arguments,o)}else n=r.apply(this,arguments);return O()(this,n)}}var J=/[AEIO]/g,tt=function(t){b()(n,t);var e=K(n);function n(){var t;p()(this,n);for(var r=arguments.length,o=new Array(r),i=0;i4)return-1;if(0==(1&o))for(var a=0;a="a"&&o<="d"){if(r>e-2)return null;var i=t[++r],a=i.charCodeAt(0),u=void 0;switch(o){case"a":if(!(i>="A"&&i<="Z"))return null;u=String.fromCharCode(a-64);break;case"b":if(i>="A"&&i<="E")u=String.fromCharCode(a-38);else if(i>="F"&&i<="J")u=String.fromCharCode(a-11);else if(i>="K"&&i<="O")u=String.fromCharCode(a+16);else if(i>="P"&&i<="S")u=String.fromCharCode(a+43);else{if(!(i>="T"&&i<="Z"))return null;u=String.fromCharCode(127)}break;case"c":if(i>="A"&&i<="O")u=String.fromCharCode(a-32);else{if("Z"!==i)return null;u=":"}break;case"d":if(!(i>="A"&&i<="Z"))return null;u=String.fromCharCode(a+32);break;default:return console.warn("* code_93_reader _decodeExtended hit default case, this may be an error",u),null}n.push(u)}else n.push(o)}return n}},{key:"_matchCheckChar",value:function(t,e,n){var r=t.slice(0,e),o=r.length,i=r.reduce((function(t,e,r){return t+((-1*r+(o-1))%n+1)*at.indexOf(e.charCodeAt(0))}),0);return at[i%47]===t[e].charCodeAt(0)}},{key:"_verifyChecksums",value:function(t){return this._matchCheckChar(t,t.length-2,20)&&this._matchCheckChar(t,t.length-1,15)}},{key:"decode",value:function(t,e){if(!(e=this._findStart()))return null;var n,r,o=new Uint16Array([0,0,0,0,0,0]),i=[],a=this._nextSet(this._row,e.end);do{o=this._toCounters(a,o);var u=this._toPattern(o);if(u<0)return null;if(null===(r=this._patternToChar(u)))return null;i.push(r),n=a,a+=A.a.sum(o),a=this._nextSet(this._row,a)}while("*"!==r);return i.pop(),i.length&&this._verifyEnd(n,a)&&this._verifyChecksums(i)?(i=i.slice(0,i.length-2),null===(i=this._decodeExtended(i))?null:{code:i.join(""),start:e.start,end:a,startInfo:e,decodedCodes:i,format:this.FORMAT}):null}}]),n}(k);function st(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter((function(e){return Object.getOwnPropertyDescriptor(t,e).enumerable}))),n.push.apply(n,r)}return n}function ft(t){for(var e=1;e.48?null:o}n[++a]=1,i=!i}return null}},{key:"_findStart",value:function(){for(var t=this._nextSet(this._row),e=null;!e;){if(!(e=this._findPattern(dt,t,!1,!0)))return null;var n=e.start-(e.end-e.start);if(n>=0&&this._matchRange(n,e.start,0))return e;t=e.end,e=null}return null}},{key:"_calculateFirstDigit",value:function(t){for(var e=0;e=10?(r.code-=10,o|=1<<5-i):o|=0<<5-i,e.push(r.code),n.push(r)}var a=this._calculateFirstDigit(o);if(null===a)return null;e.unshift(a);var u=this._findPattern(ht,r.end,!0,!1);if(null===u||!u.end)return null;n.push(u);for(var c=0;c<6;c++){if(!(u=this._decodeCode(u.end,10)))return null;n.push(u),e.push(u.code)}return u}},{key:"_verifyTrailingWhitespace",value:function(t){var e=t.end+(t.end-t.start);return e=0;n-=2)e+=t[n];e*=3;for(var r=t.length-1;r>=0;r-=2)e+=t[r];return e%10==0}},{key:"_decodeExtensions",value:function(t){var e=this._nextSet(this._row,t),n=this._findPattern(vt,e,!1,!1);if(null===n)return null;for(var r=0;r0){var u=this._decodeExtensions(a.end);if(!u)return null;if(!u.decodedCodes)return null;var c=u.decodedCodes[u.decodedCodes.length-1],s={start:c.start+((c.end-c.start)/2|0),end:c.end};if(!this._verifyTrailingWhitespace(s))return null;o={supplement:u,code:n.join("")+u.code}}return ft(ft({code:n.join(""),start:i.start,end:a.end,startInfo:i,decodedCodes:r},o),{},{format:this.FORMAT})}}]),n}(k);function xt(t){var e=function(){if("undefined"==typeof Reflect||!Reflect.construct)return!1;if(Reflect.construct.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],(function(){}))),!0}catch(t){return!1}}();return function(){var n,r=M()(t);if(e){var o=M()(this).constructor;n=Reflect.construct(r,arguments,o)}else n=r.apply(this,arguments);return O()(this,n)}}var mt=function(t){b()(n,t);var e=xt(n);function n(){var t;p()(this,n);for(var r=arguments.length,o=new Array(r),i=0;i=10&&(n|=1<<1-c),1!==c&&(r=this._nextSet(this._row,u.end),r=this._nextUnset(this._row,r))}if(2!==i.length||parseInt(i.join(""))%4!==n)return null;var s=this._findStart();return{code:i.join(""),decodedCodes:a,end:u.end,format:this.FORMAT,startInfo:s,start:s.start}}}]),n}(gt);function _t(t){var e=function(){if("undefined"==typeof Reflect||!Reflect.construct)return!1;if(Reflect.construct.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],(function(){}))),!0}catch(t){return!1}}();return function(){var n,r=M()(t);if(e){var o=M()(this).constructor;n=Reflect.construct(r,arguments,o)}else n=r.apply(this,arguments);return O()(this,n)}}var bt=[24,20,18,17,12,6,3,10,9,5];var wt=function(t){b()(n,t);var e=_t(n);function n(){var t;p()(this,n);for(var r=arguments.length,o=new Array(r),i=0;i=10&&(n|=1<<4-c),4!==c&&(r=this._nextSet(this._row,i.end),r=this._nextUnset(this._row,r))}if(5!==a.length)return null;if(function(t){for(var e=t.length,n=0,r=e-2;r>=0;r-=2)n+=t[r];n*=3;for(var o=e-1;o>=0;o-=2)n+=t[o];return(n*=3)%10}(a)!==function(t){for(var e=0;e<10;e++)if(t===bt[e])return e;return null}(n))return null;var s=this._findStart();return{code:a.join(""),decodedCodes:u,end:i.end,format:this.FORMAT,startInfo:s,start:s.start}}}]),n}(gt);function Ot(t){var e=function(){if("undefined"==typeof Reflect||!Reflect.construct)return!1;if(Reflect.construct.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],(function(){}))),!0}catch(t){return!1}}();return function(){var n,r=M()(t);if(e){var o=M()(this).constructor;n=Reflect.construct(r,arguments,o)}else n=r.apply(this,arguments);return O()(this,n)}}var Rt=function(t){b()(n,t);var e=Ot(n);function n(){var t;p()(this,n);for(var r=arguments.length,o=new Array(r),i=0;i2&&void 0!==arguments[2]&&arguments[2],r=arguments.length>3&&void 0!==arguments[3]&&arguments[3],o=new Array(t.length).fill(0),i=0,a={error:Number.MAX_VALUE,start:0,end:0},u=this.AVG_CODE_ERROR;n=n||!1,r=r||!1,e||(e=this._nextSet(this._row));for(var c=e;c=0&&this._matchRange(t,n.start,0))return n;e=n.end,n=null}return null}},{key:"_verifyTrailingWhitespace",value:function(t){var e=t.end+(t.end-t.start)/2;return e=10&&(r.code=r.code-10,o|=1<<5-i),e.push(r.code),n.push(r)}return this._determineParity(o,e)?r:null}},{key:"_determineParity",value:function(t,e){for(var n=0;nMath.abs(f-c),h=[],v=t.data,p=t.size.x,y=255,g=0;function x(t,e){u=v[e*p+t],y=ug?u:g,h.push(u)}d&&(i=c,c=s,s=i,i=f,f=l,l=i),c>f&&(i=c,c=f,f=i,i=s,s=l,l=i);var m=f-c,_=Math.abs(l-s);r=m/2|0,o=s;var b=sf?Dt.UP:Dt.DOWN,l.push({pos:0,val:s[0]}),i=0;id&&s[i+1]>.5*f?Dt.UP:r)&&(l.push({pos:i,val:s[i]}),r=o);for(l.push({pos:s.length,val:s[s.length-1]}),a=l[0].pos;af?0:1;for(i=1;il[i].val?l[i].val+(l[i+1].val-l[i].val)/3*2|0:l[i+1].val+(l[i].val-l[i+1].val)/3|0,a=l[i].pos;ad?0:1;return{line:s,threshold:d}},Tt.debug={printFrequency:function(t,e){var n,r=e.getContext("2d");for(e.width=t.length,e.height=256,r.beginPath(),r.strokeStyle="blue",n=0;n=t.length?{done:!0}:{done:!1,value:t[r++]}},e:function(t){throw t},f:o}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var i,a=!0,u=!1;return{s:function(){n=n.call(t)},n:function(){var t=n.next();return a=t.done,t},e:function(t){u=!0,i=t},f:function(){try{a||null==n.return||n.return()}finally{if(u)throw i}}}}function zt(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,r=new Array(e);n1&&(!e.inImageWithBorder(t[0])||!e.inImageWithBorder(t[1]));)o(-(r-=Math.ceil(r/2)));return t}(r,u,Math.floor(.1*i)))?null:(null===(o=a(r))&&(o=function(t,e,n){var r,o,i,u=Math.sqrt(Math.pow(t[1][0]-t[0][0],2)+Math.pow(t[1][1]-t[0][1],2)),c=null,s=Math.sin(n),f=Math.cos(n);for(r=1;r<16&&null===c;r++)i={y:(o=u/16*r*(r%2==0?-1:1))*s,x:o*f},e[0].y+=i.x,e[0].x-=i.y,e[1].y+=i.x,e[1].x-=i.y,c=a(e);return c}(t,r,u)),null===o?null:{codeResult:o.codeResult,line:r,angle:u,pattern:o.barcodeLine.line,threshold:o.barcodeLine.threshold})}return o(),{decodeFromBoundingBox:function(t){return s(t)},decodeFromBoundingBoxes:function(e){var n,r,o=[],i=t.multiple;for(n=0;n2&&void 0!==arguments[2]&&arguments[2];r(t,{callback:e,async:n,once:!0})},unsubscribe:function(n,r){if(n){var o=e(n);o.subscribers=o&&r?o.subscribers.filter((function(t){return t.callback!==r})):[]}else t={}}}}(),Ft=n(63),Bt=n.n(Ft),Wt=n(64);function qt(t){var e=function(){if("undefined"==typeof Reflect||!Reflect.construct)return!1;if(Reflect.construct.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],(function(){}))),!0}catch(t){return!1}}();return function(){var n,r=M()(t);if(e){var o=M()(this).constructor;n=Reflect.construct(r,arguments,o)}else n=r.apply(this,arguments);return O()(this,n)}}var Vt,Gt=function(t){b()(n,t);var e=qt(n);function n(t,r){var o;return p()(this,n),o=e.call(this,t),E()(m()(o),"code",void 0),o.code=r,Object.setPrototypeOf(m()(o),n.prototype),o}return g()(n)}(n.n(Wt)()(Error)),Ht="This may mean that the user has declined camera access, or the browser does not support media APIs. If you are running in iOS, you must use Safari.";function Xt(){try{return navigator.mediaDevices.enumerateDevices()}catch(e){var t=new Gt("enumerateDevices is not defined. ".concat(Ht),-1);return Promise.reject(t)}}function Qt(t){try{return navigator.mediaDevices.getUserMedia(t)}catch(t){var e=new Gt("getUserMedia is not defined. ".concat(Ht),-1);return Promise.reject(e)}}function Yt(t){return new Promise((function(e,n){var r=10;!function o(){r>0?t.videoWidth>10&&t.videoHeight>10?e():window.setTimeout(o,500):n(new Gt("Unable to play video stream. Is webcam working?",-1)),r--}()}))}function $t(t,e){return Zt.apply(this,arguments)}function Zt(){return(Zt=f()(d.a.mark((function t(e,n){var r;return d.a.wrap((function(t){for(;;)switch(t.prev=t.next){case 0:return t.next=2,Qt(n);case 2:if(r=t.sent,Vt=r,!e){t.next=11;break}return e.setAttribute("autoplay","true"),e.setAttribute("muted","true"),e.setAttribute("playsinline","true"),e.srcObject=r,e.addEventListener("loadedmetadata",(function(){e.play().catch((function(t){console.warn("* Error while trying to play video stream:",t)}))})),t.abrupt("return",Yt(e));case 11:return t.abrupt("return",Promise.resolve());case 12:case"end":return t.stop()}}),t)})))).apply(this,arguments)}function Kt(t){var e=Bt()(t,["width","height","facingMode","aspectRatio","deviceId"]);return void 0!==t.minAspectRatio&&t.minAspectRatio>0&&(e.aspectRatio=t.minAspectRatio,console.log("WARNING: Constraint 'minAspectRatio' is deprecated; Use 'aspectRatio' instead")),void 0!==t.facing&&(e.facingMode=t.facing,console.log("WARNING: Constraint 'facing' is deprecated. Use 'facingMode' instead'")),e}function Jt(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},e=Kt(t);return e&&e.deviceId&&e.facingMode&&delete e.facingMode,Promise.resolve({audio:!1,video:e})}function te(){return(te=f()(d.a.mark((function t(){var e;return d.a.wrap((function(t){for(;;)switch(t.prev=t.next){case 0:return t.next=2,Xt();case 2:return e=t.sent,t.abrupt("return",e.filter((function(t){return"videoinput"===t.kind})));case 4:case"end":return t.stop()}}),t)})))).apply(this,arguments)}function ee(){if(!Vt)return null;var t=Vt.getVideoTracks();return t&&null!=t&&t.length?t[0]:null}var ne={requestedVideoElement:null,request:function(t,e){return f()(d.a.mark((function n(){var r;return d.a.wrap((function(n){for(;;)switch(n.prev=n.next){case 0:return ne.requestedVideoElement=t,n.next=3,Jt(e);case 3:return r=n.sent,n.abrupt("return",$t(t,r));case 5:case"end":return n.stop()}}),n)})))()},release:function(){var t=Vt&&Vt.getVideoTracks();return null!==ne.requestedVideoElement&&ne.requestedVideoElement.pause(),new Promise((function(e){setTimeout((function(){t&&t.length&&t.forEach((function(t){return t.stop()})),Vt=null,ne.requestedVideoElement=null,e()}),0)}))},enumerateVideoDevices:function(){return te.apply(this,arguments)},getActiveStreamLabel:function(){var t=ee();return t?t.label:""},getActiveTrack:ee,disableTorch:function(){return f()(d.a.mark((function t(){var e;return d.a.wrap((function(t){for(;;)switch(t.prev=t.next){case 0:if(!(e=ee())){t.next=11;break}return t.prev=2,t.next=5,e.applyConstraints({advanced:[{torch:!1}]});case 5:t.next=11;break;case 7:throw t.prev=7,t.t0=t.catch(2),t.t0 instanceof OverconstrainedError&&console.warn("quagga2/CameraAccess: Torch not supported on this device"),t.t0;case 11:case"end":return t.stop()}}),t,null,[[2,7]])})))()},enableTorch:function(){return f()(d.a.mark((function t(){var e;return d.a.wrap((function(t){for(;;)switch(t.prev=t.next){case 0:if(!(e=ee())){t.next=11;break}return t.prev=2,t.next=5,e.applyConstraints({advanced:[{torch:!0}]});case 5:t.next=11;break;case 7:throw t.prev=7,t.t0=t.catch(2),t.t0 instanceof OverconstrainedError&&console.warn("quagga2/CameraAccess: Torch not supported on this device"),t.t0;case 11:case"end":return t.stop()}}),t,null,[[2,7]])})))()}},re=ne;var oe={create:function(t){var e,n=document.createElement("canvas"),r=n.getContext("2d",{willReadFrequently:!!t.willReadFrequently}),o=[],i=null!==(e=t.capacity)&&void 0!==e?e:20,a=!0===t.capture;function u(e){return!!i&&e&&!function(t,e){return e&&e.some((function(e){return Object.keys(e).every((function(n){return e[n]===t[n]}))}))}(e,t.blacklist)&&function(t,e){return"function"!=typeof e||e(t)}(e,t.filter)}return{addResult:function(t,e,c){var s={};u(c)&&(i--,s.codeResult=c,a&&(n.width=e.x,n.height=e.y,h.a.drawImage(t,e,r),s.frame=n.toDataURL()),o.push(s))},getResults:function(){return o}}}},ie={inputStream:{name:"Live",type:"LiveStream",constraints:{width:640,height:480,facingMode:"environment"},area:{top:"0%",right:"0%",left:"0%",bottom:"0%"},singleChannel:!1},locate:!0,numOfWorkers:4,decoder:{readers:["code_128_reader"]},locator:{halfSample:!0,patchSize:"medium"}},ae=n(5),ue=n(10),ce=Math.PI/180;var se={create:function(t,e){var n,r={},o=t.getConfig(),i=(Object(ue.h)(t.getRealWidth(),t.getRealHeight()),t.getCanvasSize()),a=Object(ue.h)(t.getWidth(),t.getHeight()),u=t.getTopRight(),c=u.x,s=u.y,f=null,l=null,d=o.willReadFrequently;return(n=e||document.createElement("canvas")).width=i.x,n.height=i.y,console.warn("*** frame_grabber_browser: willReadFrequently=",d,"canvas=",n),f=n.getContext("2d",{willReadFrequently:!!d}),l=new Uint8Array(a.x*a.y),r.attachData=function(t){l=t},r.getData=function(){return l},r.grab=function(){var e,r=o.halfSample,u=t.getFrame(),d=u,h=0;if(d){if(function(t,e){t.width!==e.x&&(t.width=e.x),t.height!==e.y&&(t.height=e.y)}(n,i),"ImageStream"===o.type&&(d=u.img,u.tags&&u.tags.orientation))switch(u.tags.orientation){case 6:h=90*ce;break;case 8:h=-90*ce}return 0!==h?(f.translate(i.x/2,i.y/2),f.rotate(h),f.drawImage(d,-i.y/2,-i.x/2,i.y,i.x),f.rotate(-h),f.translate(-i.x/2,-i.y/2)):f.drawImage(d,0,0,i.x,i.y),e=f.getImageData(c,s,a.x,a.y).data,r?Object(ue.e)(e,a,l):Object(ue.c)(e,l,o),!0}return!1},r.getSize=function(){return a},r}},fe=se,le={274:"orientation"},de=Object.keys(le).map((function(t){return le[t]}));function he(t){return new Promise((function(e){var n=new FileReader;n.onload=function(t){return e(t.target.result)},n.readAsArrayBuffer(t)}))}function ve(t){return new Promise((function(e,n){var r=new XMLHttpRequest;r.open("GET",t,!0),r.responseType="blob",r.onreadystatechange=function(){r.readyState!==XMLHttpRequest.DONE||200!==r.status&&0!==r.status||e(this.response)},r.onerror=n,r.send()}))}function pe(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:de,n=new DataView(t),r=t.byteLength,o=e.reduce((function(t,e){var n=Object.keys(le).filter((function(t){return le[t]===e}))[0];return n&&(t[n]=e),t}),{}),i=2;if(255!==n.getUint8(0)||216!==n.getUint8(1))return!1;for(;i1&&void 0!==arguments[1]?arguments[1]:de;return/^blob:/i.test(t)?ve(t).then(he).then((function(t){return pe(t,e)})):Promise.resolve(null)}(t,["orientation"]).then((function(t){s[0].tags=t,e(s)})).catch((function(t){console.log(t),e(s)})):e(s))},i=0;i0&&n.forEach((function(n){t.removeEventListener(e,n)}))}))},trigger:function(o,a){var s,f,l,d,h,v=i[o];if("canrecord"===o&&(d=t.videoWidth,h=t.videoHeight,e=null!==(f=r)&&void 0!==f&&f.size?d/h>1?r.size:Math.floor(d/h*r.size):d,n=null!==(l=r)&&void 0!==l&&l.size?d/h>1?Math.floor(h/d*r.size):r.size:h,u.x=e,u.y=n),v&&v.length>0)for(s=0;s0)for(n=0;n1?n.size:Math.floor(r/o*n.size):r,e=null!==(f=n)&&void 0!==f&&f.size?r/o>1?Math.floor(o/r*n.size):n.size:o,p.x=t,p.y=e,u=!0,i=0,setTimeout((function(){y("canrecord",[])}),0)}),1,s,null===(l=n)||void 0===l?void 0:l.sequence)},ended:function(){return l},setAttribute:function(){},getConfig:function(){return n},pause:function(){a=!0},play:function(){a=!1},setCurrentTime:function(t){i=t},addEventListener:function(t,e){-1!==d.indexOf(t)&&(h[t]||(h[t]=[]),h[t].push(e))},clearEventHandlers:function(){Object.keys(h).forEach((function(t){return delete h[t]}))},setTopRight:function(t){v.x=t.x,v.y=t.y},getTopRight:function(){return v},setCanvasSize:function(t){p.x=t.x,p.y=t.y},getCanvasSize:function(){return p},getFrame:function(){var t,e;if(!u)return null;a||(t=null===(e=c)||void 0===e?void 0:e[i],i=t&&r&&r()};if(e)for(var a=0;a0&&void 0!==arguments[0]?arguments[0]:"LiveStream",e=arguments.length>1?arguments[1]:void 0,n=arguments.length>2?arguments[2]:void 0;switch(t){case"VideoStream":var r=document.createElement("video");return{video:r,inputStream:n.createVideoStream(r)};case"ImageStream":return{inputStream:n.createImageStream()};case"LiveStream":var o=null;return e&&((o=e.querySelector("video"))||(o=document.createElement("video"),e.appendChild(o))),{video:o,inputStream:n.createLiveStream(o)};default:return console.error("* setupInputStream invalid type ".concat(t)),{video:null,inputStream:null}}}(n,this.getViewPort(),we),i=o.video,a=o.inputStream;"LiveStream"===n&&i&&re.request(i,r).then((function(){return a.trigger("canrecord")})).catch((function(e){return t(e)})),a&&(a.setAttribute("preload","auto"),a.setInputStream(this.context.config.inputStream),a.addEventListener("canrecord",this.canRecord.bind(void 0,t))),this.context.inputStream=a}}},{key:"getBoundingBoxes",value:function(){var t;return null!==(t=this.context.config)&&void 0!==t&&t.locate?Oe.a.locate():[[ae.c.clone(this.context.boxSize[0]),ae.c.clone(this.context.boxSize[1]),ae.c.clone(this.context.boxSize[2]),ae.c.clone(this.context.boxSize[3])]]}},{key:"transformResult",value:function(t){var e=this,n=this.context.inputStream.getTopRight(),r=n.x,o=n.y;if((0!==r||0!==o)&&(t.barcodes&&t.barcodes.forEach((function(t){return e.transformResult(t)})),t.line&&2===t.line.length&&function(t,e,n){t[0].x+=e,t[0].y+=n,t[1].x+=e,t[1].y+=n}(t.line,r,o),t.box&&Ue(t.box,r,o),t.boxes&&t.boxes.length>0))for(var i=0;i0&&void 0!==arguments[0]?arguments[0]:null,n=arguments.length>1?arguments[1]:void 0,r=e;e&&this.context.onUIThread&&(this.transformResult(e),this.addResult(e,n),r=(null==e||null===(t=e.barcodes)||void 0===t?void 0:t.length)>0?e.barcodes:e);Nt.publish("processed",r),this.hasCodeResult(e)&&Nt.publish("detected",r)}},{key:"locateAndDecode",value:(n=f()(d.a.mark((function t(){var e,n,r,o,i;return d.a.wrap((function(t){for(;;)switch(t.prev=t.next){case 0:if(!(e=this.getBoundingBoxes())){t.next=12;break}return t.next=4,this.context.decoder.decodeFromBoundingBoxes(e);case 4:if(t.t0=t.sent,t.t0){t.next=7;break}t.t0={};case 7:(r=t.t0).boxes=e,this.publishResult(r,null===(n=this.context.inputImageWrapper)||void 0===n?void 0:n.data),t.next=16;break;case 12:return t.next=14,this.context.decoder.decodeFromImage(this.context.inputImageWrapper);case 14:(o=t.sent)?this.publishResult(o,null===(i=this.context.inputImageWrapper)||void 0===i?void 0:i.data):this.publishResult();case 16:case"end":return t.stop()}}),t,this)}))),function(){return n.apply(this,arguments)})},{key:"startContinuousUpdate",value:function(){var t,e=this,n=null,r=1e3/((null===(t=this.context.config)||void 0===t?void 0:t.frequency)||60);this.context.stopped=!1;var o=this.context;!function t(i){n=n||i,o.stopped||(i>=n&&(n+=r,e.update()),window.requestAnimationFrame(t))}(performance.now())}},{key:"start",value:function(){var t,e;this.context.onUIThread&&"LiveStream"===(null===(t=this.context.config)||void 0===t||null===(e=t.inputStream)||void 0===e?void 0:e.type)?this.startContinuousUpdate():this.update()}},{key:"stop",value:(e=f()(d.a.mark((function t(){var e;return d.a.wrap((function(t){for(;;)switch(t.prev=t.next){case 0:if(this.context.stopped=!0,ze(0),null===(e=this.context.config)||void 0===e||!e.inputStream||"LiveStream"!==this.context.config.inputStream.type){t.next=6;break}return t.next=5,re.release();case 5:this.context.inputStream.clearEventHandlers();case 6:case"end":return t.stop()}}),t,this)}))),function(){return e.apply(this,arguments)})},{key:"setReaders",value:function(t){this.context.decoder&&this.context.decoder.setReaders(t),function(t){Te.forEach((function(e){return e.worker.postMessage({cmd:"setReaders",readers:t})}))}(t)}},{key:"registerReader",value:function(t,e){Lt.registerReader(t,e),this.context.decoder&&this.context.decoder.registerReader(t,e),function(t,e){Te.forEach((function(n){return n.worker.postMessage({cmd:"registerReader",name:t,reader:e})}))}(t,e)}}]),t}(),Ne=new Le,Fe=Ne.context,Be={init:function(t,e,n){var r,o=arguments.length>3&&void 0!==arguments[3]?arguments[3]:Ne;return e||(r=new Promise((function(t,n){e=function(e){e?n(e):t()}}))),o.context.config=u()({},ie,t),o.context.config.numOfWorkers>0&&(o.context.config.numOfWorkers=0),n?(o.context.onUIThread=!1,o.initializeData(n),e&&e()):o.initInputStream(e),r},start:function(){return Ne.start()},stop:function(){return Ne.stop()},pause:function(){Fe.stopped=!0},onDetected:function(t){t&&("function"==typeof t||"object"===i()(t)&&t.callback)?Nt.subscribe("detected",t):console.trace("* warning: Quagga.onDetected called with invalid callback, ignoring")},offDetected:function(t){Nt.unsubscribe("detected",t)},onProcessed:function(t){t&&("function"==typeof t||"object"===i()(t)&&t.callback)?Nt.subscribe("processed",t):console.trace("* warning: Quagga.onProcessed called with invalid callback, ignoring")},offProcessed:function(t){Nt.unsubscribe("processed",t)},setReaders:function(t){t?Ne.setReaders(t):console.trace("* warning: Quagga.setReaders called with no readers, ignoring")},registerReader:function(t,e){t?e?Ne.registerReader(t,e):console.trace("* warning: Quagga.registerReader called with no reader, ignoring"):console.trace("* warning: Quagga.registerReader called with no name, ignoring")},registerResultCollector:function(t){t&&"function"==typeof t.addResult&&(Fe.resultCollector=t)},get canvas(){return Fe.canvasContainer},decodeSingle:function(t,e){var n=this,r=new Le;return(t=u()({inputStream:{type:"ImageStream",sequence:!1,size:800,src:t.src},numOfWorkers:1,locator:{halfSample:!1}},t)).numOfWorkers>0&&(t.numOfWorkers=0),t.numOfWorkers>0&&("undefined"==typeof Blob||"undefined"==typeof Worker)&&(console.warn("* no Worker and/or Blob support - forcing numOfWorkers to 0"),t.numOfWorkers=0),new Promise((function(o,i){try{n.init(t,(function(){Nt.once("processed",(function(t){r.stop(),e&&e.call(null,t),o(t)}),!0),r.start()}),null,r)}catch(t){i(t)}}))},get default(){return Be},Readers:r,CameraAccess:re,ImageDebug:h.a,ImageWrapper:c.a,ResultCollector:oe};e.default=Be}]).default})); \ No newline at end of file diff --git a/backup.sh b/backup.sh index 5ffe046..aeaaf5a 100755 --- a/backup.sh +++ b/backup.sh @@ -1,13 +1,19 @@ #!/bin/bash # Daily backup of EverShelf database (local only) -# The database is NOT pushed to remote repositories. -# Runs via cron: creates a local timestamped backup copy -# -# Example crontab entry: -# 0 3 * * * /var/www/html/evershelf/backup.sh +# Retention follows BACKUP_RETENTION_DAYS from .env (default 3) -INSTALL_DIR="$(cd "$(dirname "$0")" && pwd)" +set -euo pipefail +INSTALL_DIR="$(cd "$(dirname "$0")/.." && pwd)" BACKUP_DIR="${INSTALL_DIR}/data/backups" +ENV_FILE="${INSTALL_DIR}/.env" + +RETENTION=3 +if [ -f "$ENV_FILE" ]; then + val=$(grep -E '^BACKUP_RETENTION_DAYS=' "$ENV_FILE" | tail -1 | cut -d= -f2) + if [[ "$val" =~ ^[0-9]+$ ]] && [ "$val" -ge 1 ]; then + RETENTION="$val" + fi +fi mkdir -p "$BACKUP_DIR" @@ -19,5 +25,5 @@ fi DATE=$(date '+%Y-%m-%d_%H%M') cp "$DB_FILE" "${BACKUP_DIR}/evershelf_${DATE}.db" -# Keep only the last 7 backups -ls -t "${BACKUP_DIR}"/evershelf_*.db 2>/dev/null | tail -n +8 | xargs -r rm -- +# Keep only the newest N backups +ls -t "${BACKUP_DIR}"/evershelf_*.db 2>/dev/null | tail -n +$((RETENTION + 1)) | xargs -r rm -- diff --git a/data/.htaccess b/data/.htaccess new file mode 100644 index 0000000..3b5a8f7 --- /dev/null +++ b/data/.htaccess @@ -0,0 +1,2 @@ +# Deny all direct HTTP access to runtime data (DB, tokens, caches, logs) +Require all denied diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..01ce923 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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`) diff --git a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/KioskActivity.kt b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/KioskActivity.kt index 08dbbf8..2e4e85b 100644 --- a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/KioskActivity.kt +++ b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/KioskActivity.kt @@ -101,6 +101,20 @@ class KioskActivity : AppCompatActivity() { // Pending WebView permission request private var pendingWebPermission: PermissionRequest? = null + private fun safeEvalJs(script: String) { + if (!::webView.isInitialized) return + if (isFinishing || isDestroyed) return + if (webView.visibility != View.VISIBLE) return + runCatching { webView.evaluateJavascript(script, null) } + .onFailure { + ErrorReporter.reportMessage( + type = "webview-js-bridge-error", + message = "Failed to deliver JS callback to WebView", + extra = mapOf("error" to (it.message ?: "unknown")) + ) + } + } + companion object { private const val FILE_CHOOSER_REQUEST = 1002 private const val PERMISSION_REQUEST_CODE = 1003 @@ -150,18 +164,18 @@ class KioskActivity : AppCompatActivity() { override fun onStart(utteranceId: String?) {} override fun onDone(utteranceId: String?) { runOnUiThread { - webView.evaluateJavascript("if(window._kioskTtsDone)window._kioskTtsDone('$utteranceId')", null) + safeEvalJs("if(window._kioskTtsDone)window._kioskTtsDone('$utteranceId')") } } @Deprecated("Deprecated in API 21") override fun onError(utteranceId: String?) { runOnUiThread { - webView.evaluateJavascript("if(window._kioskTtsError)window._kioskTtsError('$utteranceId','error')", null) + safeEvalJs("if(window._kioskTtsError)window._kioskTtsError('$utteranceId','error')") } } override fun onError(utteranceId: String?, errorCode: Int) { runOnUiThread { - webView.evaluateJavascript("if(window._kioskTtsError)window._kioskTtsError('$utteranceId',$errorCode)", null) + safeEvalJs("if(window._kioskTtsError)window._kioskTtsError('$utteranceId',$errorCode)") } } }) diff --git a/index.html b/index.html index 489e6b9..be4e117 100644 --- a/index.html +++ b/index.html @@ -11,9 +11,13 @@ EverShelf - - - + + + + + + + + diff --git a/logs/.htaccess b/logs/.htaccess new file mode 100644 index 0000000..b66e808 --- /dev/null +++ b/logs/.htaccess @@ -0,0 +1 @@ +Require all denied diff --git a/package.json b/package.json new file mode 100644 index 0000000..f366ef5 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/scripts/encrypt-gh-token.php b/scripts/encrypt-gh-token.php new file mode 100755 index 0000000..c259e57 --- /dev/null +++ b/scripts/encrypt-gh-token.php @@ -0,0 +1,14 @@ +#!/usr/bin/env php + \n"); + exit(1); +} +require_once __DIR__ . '/../api/lib/github.php'; +echo evershelfEncryptGhToken($argv[1], $argv[2]) . "\n"; diff --git a/scripts/fix-permissions.sh b/scripts/fix-permissions.sh new file mode 100755 index 0000000..e17a480 --- /dev/null +++ b/scripts/fix-permissions.sh @@ -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}" diff --git a/scripts/migrate-env-security.php b/scripts/migrate-env-security.php new file mode 100755 index 0000000..4dde000 --- /dev/null +++ b/scripts/migrate-env-security.php @@ -0,0 +1,57 @@ +#!/usr/bin/env php +