Harden security, modularize API bootstrap, and fix scale SSE auth.

Block web access to sensitive paths, require API_TOKEN for mutations, encrypt GitHub issue credentials in .env, auto-provision tokens for same-origin clients, and pass api_token in scale relay URLs since EventSource cannot send headers.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dadaloop82
2026-06-03 18:04:19 +00:00
parent 7104483dac
commit d33b0ca2fe
34 changed files with 1619 additions and 277 deletions
+11
View File
@@ -0,0 +1,11 @@
<?php
/**
* EverShelf API bootstrap — shared by HTTP router and cron.
*/
require_once __DIR__ . '/lib/env.php';
require_once __DIR__ . '/lib/constants.php';
require_once __DIR__ . '/lib/github.php';
require_once __DIR__ . '/lib/security.php';
require_once __DIR__ . '/lib/cron_log.php';
require_once __DIR__ . '/logger.php';
require_once __DIR__ . '/database.php';
+4 -2
View File
@@ -11,14 +11,16 @@ if (PHP_SAPI !== 'cli') {
exit('Forbidden');
}
// Define CRON_MODE before loading index.php so the router is skipped
// Define CRON_MODE before loading bootstrap so the HTTP router is skipped
define('CRON_MODE', true);
// Load all API functions without running the HTTP router
require_once __DIR__ . '/bootstrap.php';
require_once __DIR__ . '/index.php';
const CACHE_FILE = __DIR__ . '/../data/smart_shopping_cache.json';
evershelfRotateCronLog();
try {
$db = getDB();
+282 -129
View File
@@ -8,49 +8,8 @@
* @license MIT
*/
// ── GitHub error-reporting credentials ───────────────────────────────────────
// The token is XOR-obfuscated so the literal secret string never appears in
// source or git history (prevents GitHub secret scanning from revoking it).
// Scoped only to Issues (R+W) on this single repository.
// Defined at the very top so the global exception handler can use it.
define('_GH_TK_ENC', '23580718460c2c444031290243627e7971622b29030a3e4d50001e45261659420b6e110a423f30447133205b425a577971561f32762b0b034e0b3e56106d5945020406254a3a4647592a1a611c66687a0b672043700f34757900014004');
define('_GH_TK_KEY', 'D1sp3ns4!Ev3r#26');
define('GH_REPO', 'dadaloop82/EverShelf');
define('PRICE_CACHE_PATH', __DIR__ . '/../data/shopping_price_cache.json');
define('CATEGORY_CACHE_PATH', __DIR__ . '/../data/category_ai_cache.json');
define('SHELF_CACHE_PATH', __DIR__ . '/../data/opened_shelf_cache.json');
define('FOODFACTS_CACHE_PATH', __DIR__ . '/../data/food_facts_cache.json');
define('SHOPPING_NAME_CACHE_PATH', __DIR__ . '/../data/shopping_name_cache.json');
define('BRING_TOKEN_PATH', __DIR__ . '/../data/bring_token.json');
define('AI_USAGE_PATH', __DIR__ . '/../data/ai_usage.json');
define('BACKUP_DIR', __DIR__ . '/../data/backups');
define('BACKUP_LAST_TS_PATH', __DIR__ . '/../data/backup_last_ts.json');
// Gemini pricing (USD per 1M tokens) — configurable in .env (GEMINI_COST_25F_IN etc.)
// Defaults: gemini-2.5-flash $0.15/M in · $0.60/M out — gemini-2.0-flash $0.10/M in · $0.40/M out
define('GEMINI_COST_25F_IN', (float)(getenv('GEMINI_COST_25F_IN') ?: 0.15));
define('GEMINI_COST_25F_OUT', (float)(getenv('GEMINI_COST_25F_OUT') ?: 0.60));
define('GEMINI_COST_20F_IN', (float)(getenv('GEMINI_COST_20F_IN') ?: 0.10));
define('GEMINI_COST_20F_OUT', (float)(getenv('GEMINI_COST_20F_OUT') ?: 0.40));
/** Decode the XOR-obfuscated GitHub token at runtime. */
function _ghToken(): string {
static $token = null;
if ($token !== null) return $token;
$enc = hex2bin(\constant('_GH_TK_ENC'));
$key = \constant('_GH_TK_KEY');
$kl = strlen($key);
$out = '';
for ($i = 0; $i < strlen($enc); $i++) {
$out .= chr(ord($enc[$i]) ^ ord($key[$i % $kl]));
}
$token = $out;
return $token;
}
// logger.php must be loaded BEFORE database.php so LoggingPDO class exists when getDB() runs
require_once __DIR__ . '/logger.php';
// database.php must always be loaded (used both by HTTP router and cron)
require_once __DIR__ . '/database.php';
// ── Core bootstrap (env, security, database, logger) ─────────────────────────
require_once __DIR__ . '/bootstrap.php';
// ── Global PHP error/exception reporters ─────────────────────────────────────
// These are registered immediately so any crash anywhere in this file is caught.
@@ -74,41 +33,11 @@ if (!defined('CRON_MODE')) {
});
}
/**
* Load environment variables from .env file.
* Returns associative array of key => value pairs.
*/
function loadEnv(): array {
static $cache = null;
if ($cache !== null) return $cache;
$envFile = __DIR__ . '/../.env';
$cache = [];
if (file_exists($envFile)) {
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (strpos($line, '#') === 0 || strpos($line, '=') === false) continue;
list($key, $val) = explode('=', $line, 2);
$cache[trim($key)] = trim($val);
}
}
return $cache;
}
/**
* Get a single environment variable, with optional default.
*/
function env(string $key, string $default = ''): string {
$vars = loadEnv();
return $vars[$key] ?? $default;
}
// When included by the cron script, skip HTTP headers and routing entirely
if (!defined('CRON_MODE')) {
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
evershelfSendCorsHeaders();
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
@@ -121,6 +50,17 @@ if (($_GET['action'] ?? '') === 'ping') {
exit;
}
// ── App bootstrap — same-origin browsers receive API token automatically ───────
if (($_GET['action'] ?? '') === 'app_bootstrap') {
$required = evershelfApiTokenRequired();
$out = ['api_token_required' => $required];
if ($required && evershelfIsSameOriginBrowser()) {
$out['api_token'] = evershelfEffectiveApiToken();
}
echo json_encode($out);
exit;
}
// ── Google Drive OAuth callback — returns HTML, not JSON ──────────────────────
if (($_GET['action'] ?? '') === 'gdrive_oauth_callback') {
_gdriveHandleOAuthCallback();
@@ -130,9 +70,9 @@ if (($_GET['action'] ?? '') === 'gdrive_oauth_callback') {
// ── Log viewer — returns last N log lines (requires SETTINGS_TOKEN if set) ────
if (($_GET['action'] ?? '') === 'get_logs') {
require_once __DIR__ . '/logger.php';
$token = loadEnv()['SETTINGS_TOKEN'] ?? '';
$reqTok = $_GET['token'] ?? $_SERVER['HTTP_X_SETTINGS_TOKEN'] ?? '';
if (!empty($token) && $reqTok !== $token) {
$token = evershelfEffectiveApiToken();
$reqTok = evershelfGetProvidedApiTokenFromHeaders() ?: (string)($_GET['token'] ?? '');
if ($token !== '' && ($reqTok === '' || !hash_equals($token, $reqTok))) {
EverLog::warn('get_logs: unauthorized (403)');
http_response_code(403);
echo json_encode(['error' => 'Unauthorized']);
@@ -323,8 +263,17 @@ if (($_GET['action'] ?? '') === 'gemini_usage') {
}
}
// ── Health check — startup diagnostic (no rate-limit, no auth required) ──────
// ── Health check — minimal public probe; full diagnostics require API token ──
if (($_GET['action'] ?? '') === 'health_check') {
if (evershelfApiTokenRequired() && !evershelfApiTokenValid()) {
header('Content-Type: application/json');
echo json_encode([
'ok' => true,
'public' => true,
'api_token_required' => true,
], JSON_UNESCAPED_UNICODE);
exit;
}
$checks = [];
// ── Helper: read .env values without triggering app init ─────────────────
@@ -363,7 +312,7 @@ if (($_GET['action'] ?? '') === 'health_check') {
$dataDir = __DIR__ . '/../data';
if (!is_dir($dataDir)) @mkdir($dataDir, 0775, true);
$dataDirOk = is_dir($dataDir) && is_writable($dataDir);
$checks['data_dir'] = ['ok' => $dataDirOk, 'path' => realpath($dataDir) ?: $dataDir];
$checks['data_dir'] = ['ok' => $dataDirOk];
// data/rate_limits/
$rlDir = $dataDir . '/rate_limits';
@@ -714,24 +663,19 @@ $method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
EverLog::request($action, $method);
// API token auth (when API_TOKEN or SETTINGS_TOKEN is configured)
evershelfRequireApiAuth($action, $method);
} // end !CRON_MODE block for router bootstrap
if (!defined('CRON_MODE')):
try {
// DEMO_MODE guard
if (env('DEMO_MODE') === 'true') {
$demoBlocked = [
'save_settings', 'product_save', 'product_delete', 'product_merge',
'inventory_add', 'inventory_use', 'inventory_update', 'inventory_remove',
'dismiss_anomaly', 'bring_add', 'bring_remove', 'bring_sync',
'backup_delete', 'backup_restore',
];
if (in_array($action, $demoBlocked, true)) {
EverLog::warn('demo_mode blocked (403)');
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'demo_mode']);
exit;
}
// DEMO_MODE — block all writes and AI generation
if (evershelfDemoBlocksAction($action, $method)) {
EverLog::warn('demo_mode blocked (403)', ['action' => $action]);
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'demo_mode']);
exit;
}
switch ($action) {
@@ -760,6 +704,9 @@ try {
case 'products_search':
searchProducts($db);
break;
case 'inventory_search':
searchInventoryProducts($db);
break;
// ===== INVENTORY =====
case 'inventory_list':
@@ -813,6 +760,10 @@ try {
getInventoryAnomalies($db);
break;
case 'inventory_duplicate_loss_checks':
getDuplicateLossChecks($db);
break;
case 'dismiss_anomaly':
dismissInventoryAnomaly();
break;
@@ -1252,9 +1203,33 @@ function ttsProxy() {
$method = isset($body['method']) ? strtoupper(trim($body['method'])) : 'POST';
$headers = isset($body['headers']) && is_array($body['headers']) ? $body['headers'] : [];
$payload = isset($body['payload']) ? $body['payload'] : '';
$contentType = '';
foreach ($headers as $k => $v) {
if (strtolower($k) === 'content-type') { $contentType = $v; break; }
// Never trust client-supplied auth headers — inject from server .env
$headers = array_filter($headers, static function ($k) {
$lk = strtolower((string)$k);
return !in_array($lk, ['authorization', 'x-api-key', 'x-auth-token'], true);
}, ARRAY_FILTER_USE_KEY);
$haBase = rtrim(env('HA_URL', ''), '/');
if ($haBase !== '' && str_starts_with($url, $haBase)) {
$haTok = env('HA_TOKEN');
if ($haTok !== '') {
$headers['Authorization'] = 'Bearer ' . $haTok;
}
} elseif ($url !== '' && $url === env('TTS_URL', '')) {
$authType = env('TTS_AUTH_TYPE', 'bearer');
if ($authType === 'bearer') {
$tok = env('TTS_TOKEN');
if ($tok !== '') {
$headers['Authorization'] = 'Bearer ' . $tok;
}
} elseif ($authType === 'header') {
$hn = env('TTS_AUTH_HEADER_NAME');
$hv = env('TTS_AUTH_HEADER_VALUE');
if ($hn !== '') {
$headers[$hn] = $hv;
}
}
}
if (!$url || !preg_match('/^https?:\/\/.+/', $url)) {
@@ -1424,8 +1399,6 @@ function _haProductSelect(): string {
*/
function haInventorySensor(PDO $db): void {
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
$sensor = strtolower(trim($_GET['sensor'] ?? 'overview'));
$expiryDays = max(1, min(90, (int)($_GET['expiry_days'] ?? env('HA_EXPIRY_DAYS', 3))));
@@ -1448,7 +1421,6 @@ function haInventorySensor(PDO $db): void {
$stmt->execute($params);
$items = array_map('_haFormatProduct', $stmt->fetchAll(PDO::FETCH_ASSOC));
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
echo json_encode([
'state' => count($items),
'items' => $items,
@@ -1693,7 +1665,6 @@ function haInventorySensor(PDO $db): void {
*/
function haCalendar(PDO $db): void {
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
try {
$rows = $db->query(
"SELECT p.name, i.quantity, p.unit, i.location, i.expiry_date
@@ -1728,8 +1699,6 @@ function haCalendar(PDO $db): void {
*/
function haSuggestRecipe(PDO $db): void {
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
$apiKey = env('GEMINI_API_KEY', '');
if (!$apiKey) {
http_response_code(503);
@@ -1814,8 +1783,6 @@ function haSuggestRecipe(PDO $db): void {
*/
function haRefreshPrices(PDO $db): void {
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
try {
$country = env('PRICE_COUNTRY', 'Italia');
$currency = env('PRICE_CURRENCY', 'EUR');
@@ -1889,8 +1856,6 @@ function haRefreshPrices(PDO $db): void {
*/
function haClearExpired(PDO $db): void {
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
try {
$stmt = $db->prepare(
"DELETE FROM inventory WHERE expiry_date < date('now') AND quantity <= 0"
@@ -1966,7 +1931,6 @@ function haTestConnection(): void {
*/
function haGetInfo(PDO $db): void {
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
// Stable unique_id derived from server identity (survives restarts)
$uniqueId = 'evershelf_' . substr(md5(__DIR__ . php_uname('n')), 0, 12);
$itemsCount = (int)$db->query("SELECT COUNT(*) FROM inventory WHERE quantity > 0")->fetchColumn();
@@ -1988,7 +1952,6 @@ function haGetInfo(PDO $db): void {
*/
function haGetShoppingItems(PDO $db): void {
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
try {
if (isShoppingBringMode()) {
$auth = bringAuth();
@@ -2603,6 +2566,57 @@ function searchProducts(PDO $db): void {
echo json_encode(['products' => $stmt->fetchAll()]);
}
function searchInventoryProducts(PDO $db): void {
EverLog::debug('searchInventoryProducts');
$q = trim((string)($_GET['q'] ?? ''));
$limit = (int)($_GET['limit'] ?? 3);
if ($limit < 1) $limit = 1;
if ($limit > 10) $limit = 10;
if ($q === '' || mb_strlen($q) < 2) {
echo json_encode(['items' => []]);
return;
}
$like = "%{$q}%";
$prefix = mb_strtolower($q) . '%';
$exact = mb_strtolower($q);
$sql = "
SELECT
p.id,
p.name,
p.brand,
p.category,
p.barcode,
p.image_url,
p.unit,
p.default_quantity,
p.package_unit,
p.notes,
SUM(i.quantity) AS total_qty,
GROUP_CONCAT(DISTINCT i.location) AS locations
FROM inventory i
JOIN products p ON p.id = i.product_id
WHERE i.quantity > 0
AND (p.name LIKE ? OR p.brand LIKE ?)
GROUP BY p.id
ORDER BY
CASE
WHEN lower(p.name) = ? THEN 0
WHEN lower(p.name) LIKE ? THEN 1
ELSE 2
END,
total_qty DESC,
p.name ASC
LIMIT {$limit}
";
$stmt = $db->prepare($sql);
$stmt->execute([$like, $like, $exact, $prefix]);
echo json_encode(['items' => $stmt->fetchAll()]);
}
// ===== INVENTORY FUNCTIONS =====
function listInventory(PDO $db): void {
@@ -2828,22 +2842,38 @@ function useFromInventory(PDO $db): void {
}
// ── Server-side deduplication ─────────────────────────────────────────
// Reject if the same product already has an 'out' transaction in the last
// 12 seconds. This guards against scale double-triggers (the scale can fire
// a second stable reading ~10 s after the first auto-confirm, while the
// product is still on the plate), regardless of the client-side guard.
// Guard against accidental double-consume triggers (scale jitter, double tap,
// delayed/offline replay burst). We only apply this stricter gate to manual
// uses with empty notes, so recipe uses (notes="Ricetta: ...") remain unaffected.
if (!$useAll) {
$dedupWindow = ($notes === '') ? 120 : 12;
$dedup = $db->prepare(
"SELECT id FROM transactions
WHERE product_id = ? AND type IN ('out','waste') AND undone = 0
AND created_at >= datetime('now', '-12 seconds')
"SELECT id, quantity, created_at FROM transactions
WHERE product_id = ?
AND location = ?
AND type IN ('out','waste')
AND undone = 0
AND COALESCE(notes, '') = ?
AND created_at >= datetime('now', '-' || ? || ' seconds')
ORDER BY id DESC
LIMIT 1"
);
$dedup->execute([$productId]);
if ($dedup->fetch()) {
$dedup->execute([$productId, $location, $notes, $dedupWindow]);
$recent = $dedup->fetch();
if ($recent) {
EverLog::warn('useFromInventory duplicate blocked', [
'product_id' => $productId,
'location' => $location,
'window_s' => $dedupWindow,
'recent_tx_id' => $recent['id'] ?? null,
'recent_qty' => $recent['quantity'] ?? null,
'recent_created_at' => $recent['created_at'] ?? null,
'requested_qty' => $quantity,
'notes' => $notes,
]);
echo json_encode([
'success' => false,
'error' => 'Operazione già registrata di recente — attendi qualche secondo.',
'error' => 'Operazione già registrata di recente — verifica prima la quantità rimasta.',
'duplicate' => true,
]);
return;
@@ -3574,6 +3604,122 @@ function getInventoryAnomalies(PDO $db): void {
echo json_encode(['success' => true, 'anomalies' => $anomalies], JSON_UNESCAPED_UNICODE);
}
/**
* Detect likely "double consume" losses:
* latest pair of out transactions for same product+location within 120s,
* empty notes, current inventory at 0, and last tx at that location is out.
*/
function getDuplicateLossChecks(PDO $db): void {
EverLog::info('getDuplicateLossChecks');
$sql = "
WITH out_tx AS (
SELECT
id,
product_id,
IFNULL(location, '') AS location,
quantity,
created_at,
COALESCE(notes, '') AS notes
FROM transactions
WHERE type = 'out' AND undone = 0
),
pairs AS (
SELECT
t1.product_id,
t1.location,
t1.id AS tx1,
t2.id AS tx2,
t1.quantity AS q1,
t2.quantity AS q2,
t2.created_at AS c2,
ROUND((julianday(t2.created_at) - julianday(t1.created_at)) * 86400.0, 1) AS dt_sec
FROM out_tx t1
JOIN out_tx t2
ON t2.product_id = t1.product_id
AND t2.location = t1.location
AND t2.id > t1.id
AND (julianday(t2.created_at) - julianday(t1.created_at)) * 86400.0 BETWEEN 0 AND 120
WHERE TRIM(t1.notes) = '' AND TRIM(t2.notes) = ''
),
latest_pair AS (
SELECT
p.*,
ROW_NUMBER() OVER (PARTITION BY p.product_id, p.location ORDER BY p.c2 DESC) AS rn
FROM pairs p
),
inv AS (
SELECT
product_id,
IFNULL(location, '') AS location,
MIN(id) AS inventory_id,
SUM(quantity) AS quantity
FROM inventory
GROUP BY product_id, IFNULL(location, '')
),
last_tx AS (
SELECT
product_id,
IFNULL(location, '') AS location,
type,
created_at,
ROW_NUMBER() OVER (PARTITION BY product_id, IFNULL(location, '') ORDER BY id DESC) AS rn
FROM transactions
WHERE undone = 0
)
SELECT
p.id AS product_id,
p.name,
p.brand,
p.unit,
p.default_quantity,
p.package_unit,
lp.location,
lp.tx1,
lp.q1,
lp.tx2,
lp.q2,
lp.dt_sec,
lp.c2 AS latest_pair_at,
IFNULL(inv.inventory_id, 0) AS inventory_id,
IFNULL(inv.quantity, 0) AS inv_qty_now
FROM latest_pair lp
JOIN products p ON p.id = lp.product_id
LEFT JOIN inv ON inv.product_id = lp.product_id AND inv.location = lp.location
LEFT JOIN last_tx lt ON lt.product_id = lp.product_id AND lt.location = lp.location AND lt.rn = 1
WHERE lp.rn = 1
AND IFNULL(inv.quantity, 0) = 0
AND lt.type = 'out'
ORDER BY lp.c2 DESC
LIMIT 30
";
$rows = $db->query($sql)->fetchAll(PDO::FETCH_ASSOC) ?: [];
$checks = array_map(function(array $r): array {
return [
'product_id' => (int)$r['product_id'],
'name' => (string)$r['name'],
'brand' => (string)($r['brand'] ?? ''),
'unit' => (string)($r['unit'] ?? 'pz'),
'default_quantity' => isset($r['default_quantity']) ? (float)$r['default_quantity'] : 0.0,
'package_unit' => (string)($r['package_unit'] ?? ''),
'location' => (string)($r['location'] ?? ''),
'tx1' => (int)$r['tx1'],
'q1' => (float)$r['q1'],
'tx2' => (int)$r['tx2'],
'q2' => (float)$r['q2'],
'dt_sec' => (float)$r['dt_sec'],
'latest_pair_at' => (string)$r['latest_pair_at'],
'inventory_id' => (int)$r['inventory_id'],
'inv_qty_now' => (float)$r['inv_qty_now'],
'dismiss_key' => 'dup_' . ((int)$r['product_id']) . '_' . md5((string)($r['location'] ?? '')),
];
}, $rows);
echo json_encode(['success' => true, 'checks' => $checks], JSON_UNESCAPED_UNICODE);
}
/**
* Dismiss a specific anomaly so it no longer appears in the banner.
*/
@@ -4301,12 +4447,13 @@ function getServerSettings(): void {
echo json_encode([
'gemini_key_set' => !empty($geminiKey),
'api_token_required' => evershelfApiTokenRequired(),
'bring_email' => $bringEmail,
'settings_token_set' => !empty(env('SETTINGS_TOKEN')),
'settings_token_set' => evershelfApiTokenRequired(),
'demo_mode' => env('DEMO_MODE') === 'true',
'bring_password_set' => !empty(env('BRING_PASSWORD')),
'tts_url' => env('TTS_URL'),
'tts_token' => env('TTS_TOKEN'),
'tts_token_set' => !empty(env('TTS_TOKEN')),
'tts_method' => env('TTS_METHOD', 'POST'),
'tts_auth_type' => env('TTS_AUTH_TYPE', 'bearer'),
'tts_content_type' => env('TTS_CONTENT_TYPE', 'application/json'),
@@ -4316,7 +4463,7 @@ function getServerSettings(): void {
'tts_rate' => (float)env('TTS_RATE', '1'),
'tts_pitch' => (float)env('TTS_PITCH', '1'),
'tts_auth_header_name' => env('TTS_AUTH_HEADER_NAME', ''),
'tts_auth_header_value' => env('TTS_AUTH_HEADER_VALUE', ''),
'tts_auth_header_value_set' => !empty(env('TTS_AUTH_HEADER_VALUE', '')),
'tts_extra_fields' => env('TTS_EXTRA_FIELDS', ''),
// User preferences (now server-side)
'default_persons' => intval(env('DEFAULT_PERSONS', '1')),
@@ -4361,7 +4508,7 @@ function getServerSettings(): void {
// Home Assistant Integration
'ha_enabled' => env('HA_ENABLED', 'false') === 'true',
'ha_url' => env('HA_URL', ''),
'ha_token' => env('HA_TOKEN', ''),
'ha_token_set' => !empty(env('HA_TOKEN', '')),
'ha_tts_entity' => env('HA_TTS_ENTITY', ''),
'ha_webhook_id' => env('HA_WEBHOOK_ID', ''),
'ha_webhook_events' => env('HA_WEBHOOK_EVENTS', 'expiry,shopping_add,stock_update,barcode_scan'),
@@ -4392,11 +4539,11 @@ function dbCleanup(?PDO $db = null): void {
}
function saveSettings(): void {
// Require SETTINGS_TOKEN if configured
$requiredToken = env('SETTINGS_TOKEN');
if (!empty($requiredToken)) {
// Require API token if configured
$requiredToken = evershelfEffectiveApiToken();
if ($requiredToken !== '') {
EverLog::debug('saveSettings');
$provided = $_SERVER['HTTP_X_SETTINGS_TOKEN'] ?? '';
$provided = evershelfGetProvidedApiToken();
if (!hash_equals($requiredToken, $provided)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'unauthorized']);
@@ -5660,13 +5807,15 @@ REGOLE:
8. `tools_needed`: array of kitchen tools/appliances actually required by this recipe (e.g. ["Forno","Frullateur"]). Use the same language as all other text fields. Empty array [] if only stovetop/knife/pan needed.
9. `steps`: array of PLAIN TEXT STRINGS only no objects, no JSON, no sub-fields. Each step is a single readable string. If appliances are used, include the appliance/mode information directly in the step text (e.g. "Nel Cookeo, modalità Rosolare: aggiungere la cipolla…"). NEVER output steps as objects like {"instruction":, "appliance_function":}.
10. NON confondere forme diverse dello stesso ingrediente di base: 'Pomodori'/'Pomodoro Piccadilly' (freschi, pz/g) 'Passata di pomodoro'/'Polpa di pomodoro'/'Sugo al pomodoro' (elaborato, conf/g); 'Latte fresco' 'Latte UHT' 'Panna'; 'Farina 00' 'Farina integrale'. Se la ricetta richiede un tipo di ingrediente che NON è disponibile nella forma giusta in lista, NON sostituirlo con una forma diversa: scegli una ricetta che usa gli ingredienti esattamente nella forma disponibile.
11. `nutrition`: object with estimated macro values PER SERVING for the finished dish: {"kcal":450,"protein_g":25,"carbs_g":40,"fat_g":15}. All values are integers. Estimate realistically based on the ingredients and quantities used.
12. `storage`: object describing how to store leftovers: {"where":"frigo","days":3,"tips":""}. `where` = one of: frigo / freezer / dispensa / temperatura ambiente (in target language). `days` = integer max days safe to keep. `tips` = one concise sentence in target language. If the dish is best eaten immediately, set days=0 and tips accordingly.
DISPENSA:
$ingredientsText
Rispondi SOLO JSON valido (no markdown):
{$promptLanguageRule}
{"title":"","meal":"$mealType","persons":$persons,"prep_time":"","cook_time":"","tags":[""],"expiry_note":"","tools_needed":[""],"ingredients":[{"name":"","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":""}
{"title":"","meal":"$mealType","persons":$persons,"prep_time":"","cook_time":"","tags":[""],"expiry_note":"","tools_needed":[""],"ingredients":[{"name":"","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":"","nutrition":{"kcal":450,"protein_g":25,"carbs_g":40,"fat_g":15},"storage":{"where":"frigo","days":3,"tips":""}}
PROMPT;
$payload = [
@@ -6091,12 +6240,14 @@ REGOLE:
5. "name": usa ESATTAMENTE il nome dalla lista dispensa (il sistema lo usa per scalare l'inventario).
6. "from_pantry": true se l'ingrediente è nella lista DISPENSA, false per acqua/sale/pepe/olio.
7. Language: {$langName} for all text fields. Keep "meal" as English meal key (colazione/pranzo/cena/snack/dolce/libero).
8. `nutrition`: object with estimated macro values PER SERVING for the finished dish: {"kcal":450,"protein_g":25,"carbs_g":40,"fat_g":15}. All values are integers.
9. `storage`: object describing how to store leftovers: {"where":"frigo","days":3,"tips":""}. `where` in target language (frigo / freezer / dispensa / temperatura ambiente). `days` = integer. `tips` = one concise sentence.
DISPENSA:
{$ingredientsText}
JSON schema:
{"title":"","meal":"libero","persons":{$persons},"prep_time":"","cook_time":"","tags":[""],"ingredients":[{"name":"","qty":"80 g","qty_number":80,"from_pantry":true}],"steps":[""],"nutrition_note":""}
{"title":"","meal":"libero","persons":{$persons},"prep_time":"","cook_time":"","tags":[""],"ingredients":[{"name":"","qty":"80 g","qty_number":80,"from_pantry":true}],"steps":[""],"nutrition_note":"","nutrition":{"kcal":450,"protein_g":25,"carbs_g":40,"fat_g":15},"storage":{"where":"frigo","days":3,"tips":""}}
PROMPT;
$payload = [
@@ -6608,13 +6759,15 @@ REGOLE:
9. `zero_waste_tips`: array of zero-waste tips for steps that generate reusable scraps (peels, leftover cooking water, egg whites, cheese rinds, bread crusts, vegetable tops, etc.). Each entry: {"step": 0-based_step_index, "scrap": "scrap name", "tip": "short practical reuse tip (max 20 words)"}. Use the same language as other text fields. Empty array [] if no reusable scraps are generated.
10. `steps`: array of PLAIN TEXT STRINGS only no objects, no JSON, no sub-fields. Each step is a single readable string. If appliances are used, include the appliance/mode information directly in the step text (e.g. "Nel Cookeo, modalità Rosolare: aggiungere la cipolla…"). NEVER output steps as objects like {"instruction":, "appliance_function":}.
11. NON confondere forme diverse dello stesso ingrediente di base: 'Pomodori'/'Pomodoro Piccadilly' (freschi, pz/g) 'Passata di pomodoro'/'Polpa di pomodoro'/'Sugo al pomodoro' (elaborato, conf/g); 'Latte fresco' 'Latte UHT' 'Panna'; 'Farina 00' 'Farina integrale'. Se la ricetta richiede un tipo di ingrediente che NON è disponibile nella forma giusta in lista, NON sostituirlo con una forma diversa: scegli una ricetta che usa gli ingredienti esattamente nella forma disponibile.
12. `nutrition`: object with estimated macro values PER SERVING for the finished dish: {"kcal":450,"protein_g":25,"carbs_g":40,"fat_g":15}. All values are integers. Estimate realistically based on the ingredients and quantities used.
13. `storage`: object describing how to store leftovers: {"where":"frigo","days":3,"tips":""}. `where` = one of: frigo / freezer / dispensa / temperatura ambiente (in target language). `days` = integer max days safe to keep. `tips` = one concise sentence in target language. If the dish is best eaten immediately, set days=0 and tips accordingly.
DISPENSA:
$ingredientsText
Rispondi SOLO JSON valido (no markdown):
{$promptLanguageRule}
{"title":"","meal":"$mealType","persons":$persons,"prep_time":"","cook_time":"","tags":[""],"expiry_note":"","tools_needed":[""],"ingredients":[{"name":"","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":"","zero_waste_tips":[{"step":0,"scrap":"","tip":""}]}
{"title":"","meal":"$mealType","persons":$persons,"prep_time":"","cook_time":"","tags":[""],"expiry_note":"","tools_needed":[""],"ingredients":[{"name":"","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":"","zero_waste_tips":[{"step":0,"scrap":"","tip":""}],"nutrition":{"kcal":450,"protein_g":25,"carbs_g":40,"fat_g":15},"storage":{"where":"frigo","days":3,"tips":""}}
PROMPT;
$genConfig = [
+22
View File
@@ -0,0 +1,22 @@
<?php
/**
* EverShelf — shared path constants.
*/
define('EVERSHELF_ROOT', dirname(__DIR__, 2));
define('GH_REPO', 'dadaloop82/EverShelf');
define('PRICE_CACHE_PATH', EVERSHELF_ROOT . '/data/shopping_price_cache.json');
define('CATEGORY_CACHE_PATH', EVERSHELF_ROOT . '/data/category_ai_cache.json');
define('SHELF_CACHE_PATH', EVERSHELF_ROOT . '/data/opened_shelf_cache.json');
define('FOODFACTS_CACHE_PATH', EVERSHELF_ROOT . '/data/food_facts_cache.json');
define('SHOPPING_NAME_CACHE_PATH', EVERSHELF_ROOT . '/data/shopping_name_cache.json');
define('BRING_TOKEN_PATH', EVERSHELF_ROOT . '/data/bring_token.json');
define('AI_USAGE_PATH', EVERSHELF_ROOT . '/data/ai_usage.json');
define('BACKUP_DIR', EVERSHELF_ROOT . '/data/backups');
define('BACKUP_LAST_TS_PATH', EVERSHELF_ROOT . '/data/backup_last_ts.json');
define('CRON_LOG_PATH', EVERSHELF_ROOT . '/data/cron.log');
define('GEMINI_COST_25F_IN', (float)(getenv('GEMINI_COST_25F_IN') ?: 0.15));
define('GEMINI_COST_25F_OUT', (float)(getenv('GEMINI_COST_25F_OUT') ?: 0.60));
define('GEMINI_COST_20F_IN', (float)(getenv('GEMINI_COST_20F_IN') ?: 0.10));
define('GEMINI_COST_20F_OUT', (float)(getenv('GEMINI_COST_20F_OUT') ?: 0.40));
+28
View File
@@ -0,0 +1,28 @@
<?php
/**
* Rotate data/cron.log — keep last N MB / lines.
*/
require_once __DIR__ . '/constants.php';
function evershelfRotateCronLog(?int $maxBytes = null, int $keepRotated = 3): void {
$path = CRON_LOG_PATH;
if (!file_exists($path)) {
return;
}
$maxBytes = $maxBytes ?? max(65536, (int)env('CRON_LOG_MAX_BYTES', '524288'));
$size = filesize($path);
if ($size === false || $size <= $maxBytes) {
return;
}
for ($i = $keepRotated; $i >= 1; $i--) {
$from = ($i === 1) ? $path : $path . '.' . ($i - 1);
$to = $path . '.' . $i;
if ($i === $keepRotated && file_exists($to)) {
@unlink($to);
}
if (file_exists($from)) {
@rename($from, $to);
}
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
/**
* EverShelf — environment variable loader (.env).
*/
function loadEnv(): array {
static $cache = null;
if ($cache !== null) {
return $cache;
}
$envFile = dirname(__DIR__, 2) . '/.env';
$cache = [];
if (file_exists($envFile)) {
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (strpos($line, '#') === 0 || strpos($line, '=') === false) {
continue;
}
[$key, $val] = explode('=', $line, 2);
$cache[trim($key)] = trim($val);
}
}
return $cache;
}
function env(string $key, string $default = ''): string {
$vars = loadEnv();
return $vars[$key] ?? $default;
}
/** Push a single key into the in-memory env cache (after .env write). */
function envCacheSet(string $key, string $value): void {
loadEnv();
// Force reload on next call — callers should use loadEnv() return for batch updates
}
+69
View File
@@ -0,0 +1,69 @@
<?php
/**
* EverShelf — GitHub issue reporting token (encrypted at rest in .env).
*
* Configure ONE of:
* GH_ISSUE_TOKEN=ghp_... (plain, .env is gitignored)
* GH_ISSUE_TOKEN_ENC=... + GH_ISSUE_TOKEN_KEY=... (AES-256-GCM, preferred)
*
* Generate encrypted value: php scripts/encrypt-gh-token.php 'ghp_xxx' 'your-secret-key'
*/
require_once __DIR__ . '/env.php';
function evershelfDecryptGhToken(string $encB64, string $key): string {
$raw = base64_decode($encB64, true);
if ($raw === false || strlen($raw) < 28) {
return '';
}
$iv = substr($raw, 0, 12);
$tag = substr($raw, 12, 16);
$cipher = substr($raw, 28);
$plain = openssl_decrypt(
$cipher,
'aes-256-gcm',
hash('sha256', $key, true),
OPENSSL_RAW_DATA,
$iv,
$tag
);
return ($plain !== false) ? $plain : '';
}
function evershelfEncryptGhToken(string $plain, string $key): string {
$iv = random_bytes(12);
$tag = '';
$cipher = openssl_encrypt(
$plain,
'aes-256-gcm',
hash('sha256', $key, true),
OPENSSL_RAW_DATA,
$iv,
$tag
);
return base64_encode($iv . $tag . $cipher);
}
/** Decode GitHub Issues token at runtime — never stored in source code. */
function _ghToken(): string {
static $token = null;
if ($token !== null) {
return $token;
}
$plain = env('GH_ISSUE_TOKEN');
if ($plain !== '') {
$token = $plain;
return $token;
}
$enc = env('GH_ISSUE_TOKEN_ENC');
$key = env('GH_ISSUE_TOKEN_KEY');
if ($enc !== '' && $key !== '') {
$token = evershelfDecryptGhToken($enc, $key);
return $token;
}
$token = '';
return $token;
}
+286
View File
@@ -0,0 +1,286 @@
<?php
/**
* EverShelf — authentication, CORS, demo mode, scale gateway allowlist.
*/
require_once __DIR__ . '/env.php';
/** Effective API token: API_TOKEN takes precedence over legacy SETTINGS_TOKEN. */
function evershelfEffectiveApiToken(): string {
$api = env('API_TOKEN');
if ($api !== '') {
return $api;
}
return env('SETTINGS_TOKEN', '');
}
function evershelfApiTokenRequired(): bool {
return evershelfEffectiveApiToken() !== '';
}
function evershelfGetProvidedApiToken(): string {
if (!empty($_SERVER['HTTP_X_API_TOKEN'])) {
return (string)$_SERVER['HTTP_X_API_TOKEN'];
}
if (!empty($_SERVER['HTTP_X_SETTINGS_TOKEN'])) {
return (string)$_SERVER['HTTP_X_SETTINGS_TOKEN'];
}
if (isset($_GET['api_token'])) {
return (string)$_GET['api_token'];
}
return evershelfGetProvidedApiTokenFromHeaders();
}
function evershelfApiTokenValid(): bool {
$required = evershelfEffectiveApiToken();
if ($required === '') {
return true;
}
$provided = evershelfGetProvidedApiToken();
return $provided !== '' && hash_equals($required, $provided);
}
function evershelfGetProvidedApiTokenFromHeaders(): string {
return (string)($_SERVER['HTTP_X_API_TOKEN'] ?? $_SERVER['HTTP_X_SETTINGS_TOKEN'] ?? '');
}
/** Actions reachable without API token (telemetry + public probes). */
function evershelfPublicActions(): array {
return [
'ping',
'app_bootstrap',
'check_update',
'report_error',
'report_bug',
'client_log',
'gdrive_oauth_callback',
];
}
/** GET actions that mutate state — require auth when token is configured. */
function evershelfMutatingGetActions(): array {
return ['db_cleanup', 'export_inventory'];
}
function evershelfDestructiveActions(): array {
return [
'save_settings', 'db_cleanup',
'backup_now', 'backup_delete', 'backup_restore',
'gdrive_push', 'gdrive_oauth_exchange',
'migrate_units',
];
}
function evershelfActionNeedsAuth(string $action, string $method): bool {
if (!evershelfApiTokenRequired()) {
return false;
}
if (in_array($action, evershelfPublicActions(), true)) {
return false;
}
if ($method === 'POST') {
return true;
}
if ($method === 'GET' && in_array($action, evershelfMutatingGetActions(), true)) {
return true;
}
if (in_array($action, ['get_logs', 'gemini_usage', 'get_client_log'], true)) {
return true;
}
if (in_array($action, evershelfDestructiveActions(), true)) {
return true;
}
// Protect all data reads when API token is set
return true;
}
function evershelfRequireApiAuth(string $action, string $method): void {
if (!evershelfActionNeedsAuth($action, $method)) {
return;
}
if (evershelfApiTokenValid()) {
return;
}
http_response_code(401);
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'success' => false,
'error' => 'unauthorized',
'api_token_required' => true,
]);
exit;
}
function evershelfRequireAuthForSensitive(string $action): void {
if (!evershelfApiTokenRequired()) {
return;
}
if (evershelfApiTokenValid()) {
return;
}
http_response_code(401);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['success' => false, 'error' => 'unauthorized', 'api_token_required' => true]);
exit;
}
function evershelfSendCorsHeaders(): void {
$configured = env('CORS_ORIGIN', '');
if ($configured === '') {
// Same-origin SPA — do not emit wildcard CORS
return;
}
if ($configured === '*') {
header('Access-Control-Allow-Origin: *');
} else {
$reqOrigin = $_SERVER['HTTP_ORIGIN'] ?? '';
$allowed = array_filter(array_map('trim', explode(',', $configured)));
if ($reqOrigin !== '' && in_array($reqOrigin, $allowed, true)) {
header('Access-Control-Allow-Origin: ' . $reqOrigin);
header('Vary: Origin');
}
}
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, X-EverShelf-Request, X-API-Token, X-Settings-Token');
}
/** Read-only actions allowed in DEMO_MODE. */
function evershelfDemoReadOnlyActions(): array {
return [
'ping', 'check_update', 'health_check', 'get_settings', 'gemini_usage',
'search_barcode', 'lookup_barcode', 'stock_for_name',
'product_get', 'products_list', 'products_search', 'inventory_search',
'inventory_list', 'inventory_summary', 'inventory_finished_items',
'transactions_list', 'stats', 'monthly_stats', 'macro_stats',
'consumption_predictions', 'inventory_anomalies', 'inventory_duplicate_loss_checks',
'recent_popular_products', 'expiry_history', 'food_facts', 'opened_shelf_life',
'bring_list', 'bring_suggest', 'shopping_list', 'shopping_suggest', 'smart_shopping',
'recipes_list', 'chat_list', 'app_settings_get',
'ha_sensor', 'ha_info', 'ha_shopping_items', 'ha_test', 'ha_calendar',
'guess_category', 'get_shopping_price', 'get_all_shopping_prices',
'backup_list', 'export_inventory',
];
}
function evershelfDemoBlocksAction(string $action, string $method): bool {
if (env('DEMO_MODE') !== 'true') {
return false;
}
if (in_array($action, evershelfDemoReadOnlyActions(), true)) {
return false;
}
// Block all AI generation in demo (cost + writes)
if (str_starts_with($action, 'gemini_') || in_array($action, [
'generate_recipe', 'generate_recipe_stream', 'chat_to_recipe', 'recipe_from_ingredient',
], true)) {
return true;
}
if ($method === 'POST') {
return true;
}
if (in_array($action, evershelfMutatingGetActions(), true)) {
return true;
}
return !in_array($action, evershelfDemoReadOnlyActions(), true);
}
/** Hosts allowed for scale WebSocket relay (SSRF guard). */
function evershelfAllowedScaleHosts(): array {
$hosts = ['127.0.0.1', 'localhost', '::1'];
$gw = env('SCALE_GATEWAY_URL', '');
if ($gw !== '') {
$p = parse_url($gw);
if (!empty($p['host'])) {
$hosts[] = strtolower($p['host']);
}
}
// Server's own LAN IP — gateway may bind here on kiosk LAN
if (function_exists('gethostname')) {
$lan = gethostbyname(gethostname());
if ($lan && filter_var($lan, FILTER_VALIDATE_IP)) {
$hosts[] = $lan;
}
}
return array_values(array_unique($hosts));
}
function evershelfScaleHostAllowed(string $host): bool {
$host = strtolower(trim($host));
if ($host === '') {
return false;
}
foreach (evershelfAllowedScaleHosts() as $allowed) {
if ($host === strtolower($allowed)) {
return true;
}
}
// Allow private /24 only when host matches server's subnet (kiosk on same LAN)
$serverIp = evershelfLocalLanIp();
if ($serverIp !== '') {
$subnet = implode('.', array_slice(explode('.', $serverIp), 0, 3));
if (str_starts_with($host, $subnet . '.')) {
return true;
}
}
return false;
}
function evershelfLocalLanIp(): string {
$sock = @socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
if ($sock) {
@socket_connect($sock, '8.8.8.8', 53);
@socket_getsockname($sock, $ip);
socket_close($sock);
if (isset($ip) && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return $ip;
}
}
return '';
}
/**
* True when the request comes from the EverShelf web UI on the same host.
* Used to auto-provision API_TOKEN to the browser without manual .env copy.
*/
function evershelfIsSameOriginBrowser(): bool {
$host = strtolower(explode(':', $_SERVER['HTTP_HOST'] ?? '')[0]);
if ($host === '') {
return false;
}
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if ($origin !== '') {
$oh = parse_url($origin, PHP_URL_HOST);
return $oh && strtolower($oh) === $host;
}
$referer = $_SERVER['HTTP_REFERER'] ?? '';
if ($referer !== '') {
$rh = parse_url($referer, PHP_URL_HOST);
return $rh && strtolower($rh) === $host;
}
$fetchSite = $_SERVER['HTTP_SEC_FETCH_SITE'] ?? '';
if (in_array($fetchSite, ['same-origin', 'same-site'], true)) {
return true;
}
return false;
}
/** Auth for scale endpoints — EventSource cannot send headers; allow query token or same-origin UI. */
function evershelfRequireScaleAccess(): void {
if (!evershelfApiTokenRequired()) {
return;
}
if (evershelfApiTokenValid()) {
return;
}
if (evershelfIsSameOriginBrowser()) {
return;
}
http_response_code(401);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'unauthorized', 'api_token_required' => true]);
exit;
}
+56 -51
View File
@@ -1,57 +1,53 @@
<?php
/**
* EverShelf Scale Gateway — Auto-discovery
*
* Scans the server's local /24 subnet for any host responding on the gateway
* port (default 8765) and confirms it with a WebSocket handshake.
*
* Returns: {"found": ["ws://192.168.1.100:8765", ...]}
* EverShelf Scale Gateway — Auto-discovery (auth + rate limit + LAN only).
*/
require_once __DIR__ . '/lib/env.php';
require_once __DIR__ . '/lib/security.php';
header('Content-Type: application/json');
header('Cache-Control: no-cache');
evershelfSendCorsHeaders();
$port = (int)($_GET['port'] ?? 8765);
if ($port < 1 || $port > 65535) $port = 8765;
// ── Determine server LAN IP ────────────────────────────────────────────────
// SERVER_ADDR may be 127.0.0.1 when accessed via internal vhost — fall back
// to a UDP trick (no actual packet sent) to find the default-route interface IP.
function localLanIp(): string {
$sock = @socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
if ($sock) {
@socket_connect($sock, '8.8.8.8', 53);
@socket_getsockname($sock, $ip);
socket_close($sock);
if (isset($ip) && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) return $ip;
}
// Fallback: parse /proc/net/route for default gateway interface then ip neigh
$ifaces = @net_get_interfaces();
if ($ifaces) {
foreach ($ifaces as $name => $info) {
if ($name === 'lo') continue;
foreach ($info['unicast'] ?? [] as $u) {
$ip = $u['address'] ?? '';
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE)) continue;
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) return $ip;
}
}
}
return '';
if (evershelfApiTokenRequired() && !evershelfApiTokenValid() && !evershelfIsSameOriginBrowser()) {
http_response_code(401);
echo json_encode(['error' => 'unauthorized', 'api_token_required' => true]);
exit;
}
$serverIp = localLanIp();
// Simple rate limit: max 6 scans per minute per IP
$rlDir = dirname(__DIR__) . '/data/rate_limits';
if (!is_dir($rlDir)) {
@mkdir($rlDir, 0755, true);
}
$rlFile = $rlDir . '/scale_discover_' . md5($_SERVER['REMOTE_ADDR'] ?? 'cli') . '.json';
$now = time();
$hits = [];
if (file_exists($rlFile)) {
$hits = array_filter(json_decode(file_get_contents($rlFile), true) ?: [], fn($t) => $t > $now - 60);
}
if (count($hits) >= 6) {
http_response_code(429);
echo json_encode(['error' => 'Too many discovery scans']);
exit;
}
$hits[] = $now;
@file_put_contents($rlFile, json_encode($hits), LOCK_EX);
$port = (int)($_GET['port'] ?? 8765);
if ($port < 1 || $port > 65535) {
$port = 8765;
}
$serverIp = evershelfLocalLanIp();
$parts = explode('.', $serverIp);
if (count($parts) !== 4) {
echo json_encode(['error' => 'Cannot determine local subnet', 'server_ip' => $serverIp]);
echo json_encode(['error' => 'Cannot determine local subnet', 'found' => []]);
exit;
}
$subnet = $parts[0] . '.' . $parts[1] . '.' . $parts[2] . '.';
// ── Phase 1: Async TCP connect to all 254 hosts ────────────────────────────
// Non-blocking stream_socket_client + stream_select to detect open ports quickly.
// Total scan budget: 1.5 seconds.
$candidates = [];
for ($i = 1; $i <= 254; $i++) {
$ip = $subnet . $i;
@@ -74,25 +70,28 @@ while (!empty($candidates) && microtime(true) < $deadline) {
$read = null;
$usec = (int)(max(0, $deadline - microtime(true)) * 1_000_000);
$n = @stream_select($read, $write, $except, 0, $usec);
if ($n === false || $n === 0) break;
if ($n === false || $n === 0) {
break;
}
// Sockets in $except = connection refused/error
$failed = [];
foreach ($except as $s) {
$ip = array_search($s, $candidates, true);
if ($ip !== false) $failed[$ip] = true;
if ($ip !== false) {
$failed[$ip] = true;
}
}
// Sockets in $write = connection complete (may overlap with $except on error)
foreach ($write as $s) {
$ip = array_search($s, $candidates, true);
if ($ip === false) continue;
if ($ip === false) {
continue;
}
if (!isset($failed[$ip])) {
$found_tcp[] = $ip;
}
@fclose($s);
unset($candidates[$ip]);
}
// Close failed sockets too
foreach ($failed as $ip => $_) {
if (isset($candidates[$ip])) {
@fclose($candidates[$ip]);
@@ -100,13 +99,16 @@ while (!empty($candidates) && microtime(true) < $deadline) {
}
}
}
foreach ($candidates as $s) @fclose($s); // close remaining (timeout)
foreach ($candidates as $s) {
@fclose($s);
}
// ── Phase 2: WebSocket handshake to confirm each TCP responder ─────────────
$gateways = [];
foreach ($found_tcp as $ip) {
$sock = @stream_socket_client("tcp://{$ip}:{$port}", $errno, $errstr, 2);
if (!$sock) continue;
if (!$sock) {
continue;
}
stream_set_timeout($sock, 2);
$key = base64_encode(random_bytes(16));
@@ -124,9 +126,13 @@ foreach ($found_tcp as $ip) {
$dl = microtime(true) + 2;
while (microtime(true) < $dl && !feof($sock)) {
$line = fgets($sock, 256);
if ($line === false) break;
if ($line === false) {
break;
}
$resp .= $line;
if ($line === "\r\n") break;
if ($line === "\r\n") {
break;
}
}
fclose($sock);
@@ -138,5 +144,4 @@ foreach ($found_tcp as $ip) {
echo json_encode([
'found' => $gateways,
'subnet' => rtrim($subnet, '.') . '.0/24',
'server_ip' => $serverIp,
]);
+16 -7
View File
@@ -1,16 +1,20 @@
<?php
/**
* EverShelf Scale Gateway — Connection ping / test
*
* Performs a WebSocket handshake with the gateway and returns
* {"ok":true} on success, {"ok":false,"error":"..."} on failure.
*
* Usage: GET /api/scale_ping.php?url=ws%3A%2F%2F192.168.1.100%3A8765
* EverShelf Scale Gateway — Connection ping / test (SSRF-hardened)
*/
require_once __DIR__ . '/lib/env.php';
require_once __DIR__ . '/lib/security.php';
header('Content-Type: application/json');
header('Cache-Control: no-cache');
if (evershelfApiTokenRequired() && !evershelfApiTokenValid() && !evershelfIsSameOriginBrowser()) {
http_response_code(401);
echo json_encode(['ok' => false, 'error' => 'unauthorized', 'api_token_required' => true]);
exit;
}
$rawUrl = $_GET['url'] ?? '';
if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
@@ -19,7 +23,7 @@ if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
}
$parsed = parse_url($rawUrl);
$host = $parsed['host'] ?? '';
$host = strtolower($parsed['host'] ?? '');
$port = (int)($parsed['port'] ?? 8765);
$path = ($parsed['path'] ?? '') ?: '/';
@@ -28,6 +32,11 @@ if (!$host || $port < 1 || $port > 65535) {
exit;
}
if (!evershelfScaleHostAllowed($host)) {
echo json_encode(['ok' => false, 'error' => 'Gateway host not allowed']);
exit;
}
// Try to open a TCP connection with a 5-second timeout
$sock = @stream_socket_client("tcp://{$host}:{$port}", $errno, $errstr, 5);
if (!$sock) {
+17 -1
View File
@@ -8,6 +8,16 @@
* Usage: GET /api/scale_relay.php?url=ws%3A%2F%2F192.168.1.100%3A8765
*/
require_once __DIR__ . '/lib/env.php';
require_once __DIR__ . '/lib/security.php';
if (evershelfApiTokenRequired() && !evershelfApiTokenValid() && !evershelfIsSameOriginBrowser()) {
header('Content-Type: application/json; charset=utf-8');
http_response_code(401);
echo json_encode(['error' => 'unauthorized', 'api_token_required' => true]);
exit;
}
// ── Input validation ──────────────────────────────────────────────────────────
$rawUrl = $_GET['url'] ?? '';
@@ -19,7 +29,7 @@ if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
}
$parsed = parse_url($rawUrl);
$wsHost = $parsed['host'] ?? '';
$wsHost = strtolower($parsed['host'] ?? '');
$wsPort = (int)($parsed['port'] ?? 8765);
$wsPath = ($parsed['path'] ?? '') ?: '/';
@@ -29,6 +39,12 @@ if (!$wsHost || $wsPort < 1 || $wsPort > 65535) {
exit;
}
if (!evershelfScaleHostAllowed($wsHost)) {
header('Content-Type: text/event-stream');
echo 'data: ' . json_encode(['type' => 'error', 'message' => 'Gateway host not allowed']) . "\n\n";
exit;
}
// ── SSE headers ───────────────────────────────────────────────────────────────
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache, no-store, must-revalidate');