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
+272
View File
@@ -2009,6 +2009,59 @@ body.server-offline .bottom-nav {
.scan-status-msg.state-confirmed { color: #4ade80; background: rgba(74,222,128,0.22); }
.scan-status-msg.state-retry { color: #fb923c; }
/* — AI processing overlay (full-viewport, shown during Gemini Vision call) — */
.scan-ai-overlay {
position: absolute;
inset: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.72);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
border-radius: var(--radius);
}
.scan-ai-overlay-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 24px 28px;
background: rgba(255,255,255,0.07);
border: 1.5px solid rgba(255,255,255,0.18);
border-radius: 16px;
}
.scan-ai-overlay-label {
font-size: 0.65rem;
color: rgba(255,255,255,0.5);
text-transform: uppercase;
letter-spacing: 0.1em;
font-family: monospace;
}
.scan-ai-overlay-msg {
font-size: 0.88rem;
color: #fff;
text-align: center;
max-width: 220px;
}
/* — AI retry button (shown below scanner after visual ID fails) — */
.scan-ai-retry-btn {
width: 100%;
margin-top: 10px;
font-size: 0.95rem;
padding: 12px;
border-radius: var(--radius);
border: 2px solid var(--accent);
background: rgba(124,58,237,0.1);
color: var(--accent);
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.scan-ai-retry-btn:active { background: rgba(124,58,237,0.22); }
/* — Viewport overlay controls (torch / zoom / flip) — */
.scan-viewport-controls {
position: absolute;
@@ -2059,6 +2112,118 @@ body.server-offline .bottom-nav {
box-shadow: var(--shadow);
}
.scan-ai-match-box {
display: flex;
flex-direction: column;
gap: 12px;
}
.scan-ai-match-head {
display: flex;
flex-direction: column;
gap: 4px;
}
.scan-ai-match-title {
font-size: 1rem;
font-weight: 700;
color: var(--text);
}
.scan-ai-match-subtitle {
font-size: 0.82rem;
color: var(--text-muted);
}
.scan-ai-match-list-wrap {
display: flex;
flex-direction: column;
gap: 8px;
}
.scan-ai-match-list-title {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
font-weight: 700;
}
.scan-ai-match-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.scan-ai-candidate-item {
border: 1px solid var(--border);
background: var(--bg-main);
border-radius: 12px;
padding: 10px;
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
text-align: left;
}
.scan-ai-candidate-item:active { transform: scale(0.99); }
.scan-ai-candidate-icon {
font-size: 1.3rem;
flex-shrink: 0;
}
.scan-ai-candidate-info {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.scan-ai-candidate-name {
font-size: 0.9rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.scan-ai-candidate-meta {
font-size: 0.76rem;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.scan-ai-candidate-cta {
font-size: 0.74rem;
color: var(--accent);
border: 1px solid var(--accent);
border-radius: 999px;
padding: 3px 8px;
flex-shrink: 0;
}
.scan-ai-match-empty {
font-size: 0.86rem;
color: var(--text-muted);
background: var(--bg-main);
border: 1px dashed var(--border);
border-radius: 10px;
padding: 10px 12px;
}
.scan-ai-add-btn {
width: 100%;
}
.scan-ai-detected-label {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted);
}
.scan-ai-detected-pill {
font-size: 0.8rem;
color: var(--text-muted);
background: var(--bg-main);
border-radius: 999px;
border: 1px solid var(--border);
padding: 6px 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* — Recent scans — */
.scan-recents {
display: flex;
@@ -4295,6 +4460,93 @@ body.server-offline .bottom-nav {
line-height: 1.5;
}
/* ===== RECIPE NUTRITION BLOCK ===== */
.recipe-nutrition-block {
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: var(--radius-sm);
padding: 12px 14px 8px;
margin-top: 16px;
}
.recipe-section-heading {
font-size: 0.85rem;
font-weight: 700;
color: #15803d;
margin: 0 0 10px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.recipe-nutrition-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
text-align: center;
}
.recipe-nutrition-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.recipe-nutrition-icon { font-size: 1.2rem; }
.recipe-nutrition-value {
font-size: 0.95rem;
font-weight: 700;
color: #15803d;
}
.recipe-nutrition-label {
font-size: 0.65rem;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.recipe-nutrition-note {
font-size: 0.7rem;
color: #94a3b8;
text-align: center;
margin: 6px 0 0;
}
.recipe-nutrition-footnote {
color: var(--text-muted);
font-size: 0.85rem;
margin-top: 12px;
}
/* ===== RECIPE STORAGE CARD ===== */
.recipe-storage-card {
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: var(--radius-sm);
padding: 12px 14px 8px;
margin-top: 12px;
}
.recipe-storage-card .recipe-section-heading { color: #b45309; }
.recipe-storage-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 6px;
}
.recipe-storage-badge {
background: #fef3c7;
border: 1px solid #fcd34d;
border-radius: 20px;
padding: 2px 12px;
font-size: 0.8rem;
font-weight: 600;
color: #92400e;
white-space: nowrap;
text-transform: capitalize;
}
.recipe-storage-days { background: #dbeafe; border-color: #93c5fd; color: #1d4ed8; }
.recipe-storage-now { background: #fee2e2; border-color: #fca5a5; color: #b91c1c; }
.recipe-storage-tips {
font-size: 0.82rem;
color: #78350f;
margin: 2px 0 0;
line-height: 1.4;
}
.recipe-tools-banner {
display: flex;
flex-wrap: wrap;
@@ -5939,6 +6191,12 @@ body.cooking-mode-active .app-header {
}
.banner-anomaly .alert-banner-title { color: #9a3412; }
.banner-anomaly .alert-banner-counter .banner-dot.active { background: #ea580c; }
.alert-banner.banner-dup-loss {
background: linear-gradient(135deg, #fef2f2 0%, #fecaca 100%);
border-color: #dc2626;
}
.banner-dup-loss .alert-banner-title { color: #991b1b; }
.banner-dup-loss .alert-banner-counter .banner-dot.active { background: #dc2626; }
.alert-banner.banner-no-expiry {
background: linear-gradient(135deg, #f0fdf4 0%, #bbf7d0 100%);
border-color: #16a34a;
@@ -7838,6 +8096,8 @@ body.cooking-mode-active .app-header {
[data-theme="dark"] .banner-prediction .alert-banner-counter { color: #a78bfa; }
[data-theme="dark"] .alert-banner.banner-anomaly { background: #1a1200; border-color: #c2410c; }
[data-theme="dark"] .banner-anomaly .alert-banner-title { color: #fdba74; }
[data-theme="dark"] .alert-banner.banner-dup-loss { background: #2a0808; border-color: #dc2626; }
[data-theme="dark"] .banner-dup-loss .alert-banner-title { color: #fca5a5; }
[data-theme="dark"] .alert-banner.banner-no-expiry { background: #0f2a1a; border-color: #166534; }
[data-theme="dark"] .banner-no-expiry .alert-banner-title { color: #86efac; }
@@ -7908,6 +8168,18 @@ body.cooking-mode-active .app-header {
/* ── Recipe components ── */
[data-theme="dark"] .recipe-expiry-note { background: #2a1e00; color: #fde68a; }
[data-theme="dark"] .recipe-nutrition-block { background: #052e16; border-color: #166534; }
[data-theme="dark"] .recipe-section-heading { color: #4ade80; }
[data-theme="dark"] .recipe-storage-card .recipe-section-heading { color: #fbbf24; }
[data-theme="dark"] .recipe-nutrition-value { color: #4ade80; }
[data-theme="dark"] .recipe-nutrition-label { color: #94a3b8; }
[data-theme="dark"] .recipe-nutrition-note { color: #64748b; }
[data-theme="dark"] .recipe-nutrition-footnote { color: var(--text-muted); }
[data-theme="dark"] .recipe-storage-card { background: #1c1400; border-color: #78350f; }
[data-theme="dark"] .recipe-storage-badge { background: #2a1e00; border-color: #92400e; color: #fde68a; }
[data-theme="dark"] .recipe-storage-days { background: #0c1a2e; border-color: #1d4ed8; color: #93c5fd; }
[data-theme="dark"] .recipe-storage-now { background: #2a0a0a; border-color: #b91c1c; color: #fca5a5; }
[data-theme="dark"] .recipe-storage-tips { color: #fde68a; }
[data-theme="dark"] .recipe-tools-banner { background: #1a1040; border-color: #3730a3; color: #c4b5fd; }
[data-theme="dark"] .recipe-tool-chip { background: #2e1a4a; color: #c4b5fd; }
[data-theme="dark"] .recipe-step-appliance { background: #052e16; border-color: #166534; color: #4ade80; }
+123 -40
View File
@@ -317,12 +317,17 @@ function scaleInit() {
_scaleConnect(s.scale_gateway_url);
}
function _scaleAuthQuery() {
const tok = typeof getApiToken === 'function' ? getApiToken() : '';
return tok ? '&api_token=' + encodeURIComponent(tok) : '';
}
function _scaleConnect(url) {
if (_scaleEs) { try { _scaleEs.close(); } catch(e) {} _scaleEs = null; }
if (_scaleReconnectTimer) { clearTimeout(_scaleReconnectTimer); _scaleReconnectTimer = null; }
try {
// Connect via the PHP SSE relay so the HTTPS page is not blocked by mixed-content
_scaleEs = new EventSource('api/scale_relay.php?url=' + encodeURIComponent(url));
// EventSource cannot send custom headers — pass api_token in query string
_scaleEs = new EventSource('api/scale_relay.php?url=' + encodeURIComponent(url) + _scaleAuthQuery());
_scaleEs.onopen = () => _scaleUpdateStatus('searching');
_scaleEs.onmessage = (evt) => {
try { _scaleOnMessage(JSON.parse(evt.data)); } catch(e) {}
@@ -1041,7 +1046,7 @@ function testScaleConnection() {
statusEl.textContent = '❌ ' + t('scale.timeout');
statusEl.className = 'settings-status error';
}, 8000);
fetch('api/scale_ping.php?url=' + encodeURIComponent(url), { signal: ac.signal })
fetch('api/scale_ping.php?url=' + encodeURIComponent(url) + _scaleAuthQuery(), { signal: ac.signal })
.then(r => r.json())
.then(data => {
clearTimeout(timeout);
@@ -1073,7 +1078,7 @@ async function discoverScaleGateway() {
status.textContent = '🔍 Scanning local network for scale gateway…';
try {
const res = await fetch('api/scale_discover.php', { signal: AbortSignal.timeout(8000) });
const res = await fetch('api/scale_discover.php', { signal: AbortSignal.timeout(8000), headers: { ...(typeof apiAuthHeaders === 'function' ? apiAuthHeaders() : {}) } });
const data = await res.json();
if (data.error) {
@@ -1271,8 +1276,8 @@ function _setThemeMode(mode) {
// Persist dark_mode to server .env immediately (no need to send the full
// settings payload — save_settings only updates keys present in the body
// and keeps all other .env values intact).
const token = document.getElementById('setting-settings-token')?.value.trim() || '';
const headers = token ? { 'X-Settings-Token': token } : {};
const token = document.getElementById('setting-settings-token')?.value.trim() || (typeof getApiToken === 'function' ? getApiToken() : '');
const headers = token ? { 'X-API-Token': token } : {};
api('save_settings', {}, 'POST', { dark_mode: mode }, headers).catch(() => {});
}
@@ -1284,7 +1289,9 @@ setInterval(() => {
// ===== EXPORT INVENTORY =====
function exportInventory(format) {
const url = `api/index.php?action=export_inventory&format=${encodeURIComponent(format)}&_t=${Date.now()}`;
const tok = typeof getApiToken === 'function' ? getApiToken() : '';
const tokParam = tok ? `&api_token=${encodeURIComponent(tok)}` : '';
const url = `api/index.php?action=export_inventory&format=${encodeURIComponent(format)}&_t=${Date.now()}${tokParam}`;
if (format === 'csv') {
// Direct download via <a> trick
const a = document.createElement('a');
@@ -3219,9 +3226,13 @@ async function loadSettingsUI() {
'ha_enabled','ha_url','ha_tts_entity','ha_webhook_id','ha_webhook_events',
'ha_notify_service','ha_expiry_days'];
// Note: gemini_key is never sent from server; settings_token_set is metadata only
const settingsTokenRequired = !!serverSettings.settings_token_set;
const settingsTokenRequired = !!(serverSettings.api_token_required || serverSettings.settings_token_set);
const tokenHintEl = document.getElementById('settings-token-status-hint');
if (tokenHintEl) tokenHintEl.style.display = settingsTokenRequired ? 'block' : 'none';
if (settingsTokenRequired && typeof setApiToken === 'function') {
const fieldTok = document.getElementById('setting-settings-token')?.value.trim();
if (fieldTok) setApiToken(fieldTok);
}
let changed = false;
for (const key of serverKeys) {
if (serverSettings[key] !== undefined && serverSettings[key] !== null && serverSettings[key] !== '') {
@@ -3797,8 +3808,9 @@ async function saveSettings() {
// Save ALL settings to server .env
try {
const settingsToken = document.getElementById('setting-settings-token')?.value.trim() || '';
const tokenHeader = settingsToken ? { 'X-Settings-Token': settingsToken } : {};
const settingsToken = document.getElementById('setting-settings-token')?.value.trim() || (typeof getApiToken === 'function' ? getApiToken() : '');
if (settingsToken && typeof setApiToken === 'function') setApiToken(settingsToken);
const tokenHeader = settingsToken ? { 'X-API-Token': settingsToken } : (typeof apiAuthHeaders === 'function' ? apiAuthHeaders() : {});
const result = await api('save_settings', {}, 'POST', {
...(s.gemini_key ? { gemini_key: s.gemini_key } : {}),
bring_email: s.bring_email,
@@ -3944,11 +3956,12 @@ async function api(action, params = {}, method = 'GET', body = null, extraHeader
});
}
const opts = { method, cache: 'no-store' };
const authHdrs = typeof apiAuthHeaders === 'function' ? apiAuthHeaders() : {};
if (body) {
opts.headers = { 'Content-Type': 'application/json', 'X-EverShelf-Request': '1', ...extraHeaders };
opts.headers = { 'Content-Type': 'application/json', 'X-EverShelf-Request': '1', ...authHdrs, ...extraHeaders };
opts.body = JSON.stringify(body);
} else if (Object.keys(extraHeaders).length > 0) {
opts.headers = { ...extraHeaders };
} else {
opts.headers = { ...authHdrs, ...extraHeaders };
}
let res;
try {
@@ -3966,6 +3979,10 @@ async function api(action, params = {}, method = 'GET', body = null, extraHeader
}
if (!res.ok) {
remoteLog('API_ERROR', `${action} HTTP ${res.status}`);
if (res.status === 401) {
window._apiTokenRequired = true;
if (typeof _promptApiTokenIfNeeded === 'function') _promptApiTokenIfNeeded();
}
// Report HTTP 5xx as server errors (not 4xx which are usually user errors)
if (res.status >= 500) {
reportError({
@@ -3981,6 +3998,10 @@ async function api(action, params = {}, method = 'GET', body = null, extraHeader
_offlineCacheSet(data.inventory);
}
if (action === 'get_settings' && data && data.success !== false) {
window._apiTokenRequired = !!data.api_token_required;
if (data.api_token_required && typeof _promptApiTokenIfNeeded === 'function') {
_promptApiTokenIfNeeded();
}
_offlineCacheSetSettings(data);
}
if (data && data.error) {
@@ -12821,13 +12842,6 @@ async function analyzeExpiryImage(dataUrl) {
}
}
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function stripHtml(str) {
if (!str) return '';
return str.replace(/<[^>]*>/g, '');
@@ -13952,7 +13966,7 @@ function renderRecipe(r) {
const isFav = !!(_cachedRecipe && _cachedRecipe.is_favorite);
let html = `<h2>${r.title}</h2>`;
let html = `<h2>${escapeHtml(r.title)}</h2>`;
// Meta tags + star (#124) + persons rescaler (#123)
html += '<div class="recipe-meta">';
@@ -13964,7 +13978,7 @@ function renderRecipe(r) {
</span>`;
if (r.prep_time) html += `<span class="recipe-tag">🔪 ${r.prep_time}</span>`;
if (r.cook_time) html += `<span class="recipe-tag">🔥 ${r.cook_time}</span>`;
if (r.tags) r.tags.forEach(t => { html += `<span class="recipe-tag">${t}</span>`; });
if (r.tags) r.tags.forEach(tag => { html += `<span class="recipe-tag">${escapeHtml(tag)}</span>`; });
// Favorite star button (#124) — visible only for archived recipes (have an id)
if (_cachedRecipe && _cachedRecipe.id) {
html += `<button class="btn-recipe-fav${isFav ? ' active' : ''}" onclick="toggleRecipeFavorite(this)" title="${isFav ? t('recipes.unfavorite') : t('recipes.favorite')}">${isFav ? '★' : '☆'}</button>`;
@@ -13973,7 +13987,7 @@ function renderRecipe(r) {
// Expiry note
if (r.expiry_note) {
html += `<div class="recipe-expiry-note">⚠️ ${r.expiry_note}</div>`;
html += `<div class="recipe-expiry-note">⚠️ ${escapeHtml(r.expiry_note)}</div>`;
}
// Tools/appliances banner (shown only when specific equipment is needed)
@@ -13981,7 +13995,7 @@ function renderRecipe(r) {
? r.tools_needed.filter(t => t && t.trim())
: _extractToolsFromSteps(r.steps);
if (tools.length > 0) {
html += `<div class="recipe-tools-banner">🔧 <strong>${t('recipes.tools_title')}:</strong> ${tools.map(t => `<span class="recipe-tool-chip">${t}</span>`).join('')}</div>`;
html += `<div class="recipe-tools-banner">🔧 <strong>${escapeHtml(t('recipes.tools_title'))}:</strong> ${tools.map(tool => `<span class="recipe-tool-chip">${escapeHtml(tool)}</span>`).join('')}</div>`;
}
// Ingredients
@@ -13991,8 +14005,8 @@ function renderRecipe(r) {
const qtyNum = Math.round((ing.qty_number || 0) * 10) / 10;
const loc = (ing.location || 'dispensa').replace(/'/g, "\\'");
const alreadyUsed = ing.used === true;
html += `<li class="recipe-ingredient${alreadyUsed ? ' recipe-ing-used' : ''}" id="recipe-ing-${idx}" data-base-qty="${ing.qty_number || 0}" data-base-qty-str="${(ing.qty || '').replace(/"/g, '&quot;')}">`;
html += `<span class="recipe-ing-text"><strong class="recipe-ing-name" onclick="openIngredientDetail(${ing.product_id}, '${loc}')" title="${t('action.edit') || 'Modifica'}">${ing.name}</strong>${ing.brand ? ' <em>(' + ing.brand + ')</em>' : ''}: <span class="recipe-ing-qty">${ing.qty}</span> ✅`;
html += `<li class="recipe-ingredient${alreadyUsed ? ' recipe-ing-used' : ''}" id="recipe-ing-${idx}" data-base-qty="${ing.qty_number || 0}" data-base-qty-str="${escapeHtml(ing.qty || '')}">`;
html += `<span class="recipe-ing-text"><strong class="recipe-ing-name" onclick="openIngredientDetail(${ing.product_id}, '${loc}')" title="${escapeHtml(t('action.edit') || 'Modifica')}">${escapeHtml(ing.name)}</strong>${ing.brand ? ' <em>(' + escapeHtml(ing.brand) + ')</em>' : ''}: <span class="recipe-ing-qty">${escapeHtml(ing.qty)}</span> ✅`;
// Detail line: location + expiry
let details = [];
const ingredientLocLabels = Object.fromEntries(Object.entries(LOCATIONS).map(([k,v]) => [k, `${v.icon} ${v.label}`]));
@@ -14016,7 +14030,7 @@ function renderRecipe(r) {
html += `</li>`;
} else {
const pantryIcon = ing.from_pantry ? ' ✅' : ' 🛒';
html += `<li class="recipe-ingredient" data-base-qty="${ing.qty_number || 0}" data-base-qty-str="${(ing.qty || '').replace(/"/g, '&quot;')}"><span class="recipe-ing-text"><strong>${ing.name}</strong>: <span class="recipe-ing-qty">${ing.qty}</span>${pantryIcon}</span></li>`;
html += `<li class="recipe-ingredient" data-base-qty="${ing.qty_number || 0}" data-base-qty-str="${escapeHtml(ing.qty || '')}"><span class="recipe-ing-text"><strong>${escapeHtml(ing.name)}</strong>: <span class="recipe-ing-qty">${escapeHtml(ing.qty)}</span>${pantryIcon}</span></li>`;
}
});
html += '</ul>';
@@ -14028,13 +14042,60 @@ function renderRecipe(r) {
html += `<h3>${t('recipes.steps_title')}</h3><ol>`;
(r.steps || []).forEach(step => {
const appliance = _stepAppliance(step);
html += `<li>${_stepStr(step)}${appliance ? ` <span class="recipe-step-appliance">${appliance}</span>` : ''}</li>`;
html += `<li>${escapeHtml(_stepStr(step))}${appliance ? ` <span class="recipe-step-appliance">${escapeHtml(appliance)}</span>` : ''}</li>`;
});
html += '</ol>';
// Nutrition note
// Nutritional values grid
if (r.nutrition && (r.nutrition.kcal || r.nutrition.protein_g || r.nutrition.carbs_g || r.nutrition.fat_g)) {
const n = r.nutrition;
html += `<div class="recipe-nutrition-block">
<h4 class="recipe-section-heading">📊 ${t('recipes.nutrition_title')}</h4>
<div class="recipe-nutrition-grid">
<div class="recipe-nutrition-item">
<span class="recipe-nutrition-icon">🔥</span>
<span class="recipe-nutrition-value">${n.kcal ?? '—'}</span>
<span class="recipe-nutrition-label">${t('recipes.nutrition_kcal')}</span>
</div>
<div class="recipe-nutrition-item">
<span class="recipe-nutrition-icon">🥩</span>
<span class="recipe-nutrition-value">${n.protein_g ?? '—'} g</span>
<span class="recipe-nutrition-label">${t('recipes.nutrition_protein')}</span>
</div>
<div class="recipe-nutrition-item">
<span class="recipe-nutrition-icon">🍞</span>
<span class="recipe-nutrition-value">${n.carbs_g ?? '—'} g</span>
<span class="recipe-nutrition-label">${t('recipes.nutrition_carbs')}</span>
</div>
<div class="recipe-nutrition-item">
<span class="recipe-nutrition-icon">🫒</span>
<span class="recipe-nutrition-value">${n.fat_g ?? '—'} g</span>
<span class="recipe-nutrition-label">${t('recipes.nutrition_fat')}</span>
</div>
</div>
<p class="recipe-nutrition-note">${t('recipes.nutrition_per_serving')}</p>
</div>`;
}
// Storage info
if (r.storage && (r.storage.where || r.storage.tips)) {
const s = r.storage;
const daysLabel = s.days > 0
? t('recipes.storage_days').replace('{n}', s.days)
: t('recipes.storage_immediately');
html += `<div class="recipe-storage-card">
<h4 class="recipe-section-heading">📦 ${t('recipes.storage_title')}</h4>
<div class="recipe-storage-row">
${s.where ? `<span class="recipe-storage-badge">${escapeHtml(s.where)}</span>` : ''}
${s.days > 0 ? `<span class="recipe-storage-badge recipe-storage-days">${escapeHtml(daysLabel)}</span>` : `<span class="recipe-storage-badge recipe-storage-now">${escapeHtml(daysLabel)}</span>`}
</div>
${s.tips ? `<p class="recipe-storage-tips">${escapeHtml(s.tips)}</p>` : ''}
</div>`;
}
// Nutrition note (legacy / AI extra note)
if (r.nutrition_note) {
html += `<p style="color:var(--text-muted);font-size:0.85rem;margin-top:12px">💡 ${r.nutrition_note}</p>`;
html += `<p class="recipe-nutrition-footnote">💡 ${escapeHtml(r.nutrition_note)}</p>`;
}
document.getElementById('recipe-content').innerHTML = html;
@@ -14453,10 +14514,7 @@ function _buildTtsRequest(text, s) {
function _buildHaTtsRequest(text, s) {
const haUrl = (s.ha_url || '').replace(/\/$/, '');
const url = haUrl + '/api/services/tts/speak';
const headers = {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + (s.ha_token || ''),
};
const headers = { 'Content-Type': 'application/json' };
const body = JSON.stringify({
entity_id: s.ha_tts_entity || '',
message: text,
@@ -14739,8 +14797,9 @@ async function saveHaSettings() {
const statusEl = document.getElementById('ha-save-status');
try {
const settingsToken = document.getElementById('setting-settings-token')?.value.trim() || '';
const tokenHeader = settingsToken ? { 'X-Settings-Token': settingsToken } : {};
const settingsToken = document.getElementById('setting-settings-token')?.value.trim() || (typeof getApiToken === 'function' ? getApiToken() : '');
if (settingsToken && typeof setApiToken === 'function') setApiToken(settingsToken);
const tokenHeader = settingsToken ? { 'X-API-Token': settingsToken } : (typeof apiAuthHeaders === 'function' ? apiAuthHeaders() : {});
const result = await api('save_settings', {}, 'POST', {
ha_enabled: haEnabled,
ha_url: haUrl,
@@ -17522,7 +17581,11 @@ async function _runStartupCheck() {
if (!wrapEl) return true; // preloader already removed
const tl = (key, fallback) => { try { return t('startup.' + key); } catch(e) { return fallback; } };
const tl = (key, fallback) => {
const full = 'startup.' + key;
const v = typeof t === 'function' ? t(full) : full;
return (v === full) ? fallback : v;
};
// Switch from spinner to progress bar
if (spinnerEl) spinnerEl.style.display = 'none';
@@ -17553,6 +17616,12 @@ async function _runStartupCheck() {
el.textContent = cleanLabel;
};
// Auto-provision API token for same-origin browser sessions
if (typeof ensureApiToken === 'function') {
setProgress(5, tl('token_autoconfig', 'Configurazione accesso...'), 'ok');
await ensureApiToken();
}
// Phase 1: animate 0→15% while fetching (so it never looks stuck)
setProgress(0, tl('connecting', 'Connessione al server...'));
let _fetchDone = false;
@@ -17568,9 +17637,22 @@ async function _runStartupCheck() {
try {
const ctrl = new AbortController();
const tid = setTimeout(() => ctrl.abort(), 12000);
const resp = await fetch('api/index.php?action=health_check', { signal: ctrl.signal });
const resp = await fetch('api/index.php?action=health_check', {
signal: ctrl.signal,
headers: { ...(typeof apiAuthHeaders === 'function' ? apiAuthHeaders() : {}) },
});
clearTimeout(tid);
result = await resp.json();
if (result.public && result.api_token_required && typeof getApiToken === 'function' && !getApiToken()) {
window._apiTokenRequired = true;
if (typeof _promptApiTokenIfNeeded === 'function') _promptApiTokenIfNeeded();
setProgress(100, tl('token_required', 'Token API richiesto'), 'warn');
return false;
}
if (result.public && result.api_token_required && typeof getApiToken === 'function' && getApiToken()) {
const resp2 = await fetch('api/index.php?action=health_check', { headers: apiAuthHeaders() });
result = await resp2.json();
}
} catch(e) {
clearInterval(slowAnim);
_showStartupErrorPopup(
@@ -17697,9 +17779,10 @@ async function _runStartupCheck() {
// The bar already shows 100%; we just update the label for a moment.
try {
setProgress(100, tl('syncing_local', 'Sincronizzazione dati locali...'), 'ok');
const authH = typeof apiAuthHeaders === 'function' ? apiAuthHeaders() : {};
const [invData, settingsData] = await Promise.all([
fetch('api/index.php?action=inventory_list').then(r => r.json()).catch(() => null),
fetch('api/index.php?action=get_settings').then(r => r.json()).catch(() => null),
fetch('api/index.php?action=inventory_list', { headers: authH }).then(r => r.json()).catch(() => null),
fetch('api/index.php?action=get_settings', { headers: authH }).then(r => r.json()).catch(() => null),
]);
if (invData && Array.isArray(invData.inventory)) _offlineCacheSet(invData.inventory);
if (settingsData && settingsData.success !== false) _offlineCacheSetSettings(settingsData);
+77
View File
@@ -0,0 +1,77 @@
/**
* EverShelf core — API token storage and auth headers.
*/
const EVERSHELF_TOKEN_KEY = 'evershelf_api_token';
function getApiToken() {
return localStorage.getItem(EVERSHELF_TOKEN_KEY) || '';
}
function setApiToken(token) {
const t = (token || '').trim();
if (t) {
localStorage.setItem(EVERSHELF_TOKEN_KEY, t);
} else {
localStorage.removeItem(EVERSHELF_TOKEN_KEY);
}
}
function apiAuthHeaders() {
const fromStorage = getApiToken();
const fromSettingsField = document.getElementById('setting-settings-token')?.value.trim() || '';
const token = fromSettingsField || fromStorage;
if (!token) return {};
return { 'X-API-Token': token };
}
/** Fetch API token from server when loading the UI from the same origin. */
async function ensureApiToken() {
if (getApiToken()) return true;
try {
const res = await fetch('api/index.php?action=app_bootstrap', { cache: 'no-store' });
if (!res.ok) return false;
const data = await res.json();
window._apiTokenRequired = !!data.api_token_required;
if (data.api_token) {
setApiToken(data.api_token);
return true;
}
} catch (_) { /* offline / network */ }
return !!getApiToken();
}
function _promptApiTokenIfNeeded() {
if (!window._apiTokenRequired) return;
if (getApiToken()) return;
const existing = document.getElementById('api-token-overlay');
if (existing) return;
const title = typeof t === 'function' ? t('startup.token_prompt_title') : '🔒 API Token';
const hint = typeof t === 'function' ? t('startup.token_prompt_hint') : 'Enter API_TOKEN from .env';
const btn = typeof t === 'function' ? t('startup.token_prompt_btn') : 'Continue';
const overlay = document.createElement('div');
overlay.id = 'api-token-overlay';
overlay.className = 'modal-overlay';
overlay.style.display = 'flex';
overlay.innerHTML = `
<div class="modal-content" style="max-width:420px;padding:20px">
<h3>${title}</h3>
<p class="settings-hint">${hint}</p>
<input type="password" id="api-token-input" class="form-input" placeholder="API token">
<button class="btn btn-primary full-width mt-2" id="api-token-save">${btn}</button>
</div>`;
document.body.appendChild(overlay);
document.getElementById('api-token-save').onclick = () => {
const v = document.getElementById('api-token-input').value.trim();
if (v) {
setApiToken(v);
overlay.remove();
location.reload();
}
};
}
window.getApiToken = getApiToken;
window.setApiToken = setApiToken;
window.apiAuthHeaders = apiAuthHeaders;
window.ensureApiToken = ensureApiToken;
window._promptApiTokenIfNeeded = _promptApiTokenIfNeeded;
+11
View File
@@ -0,0 +1,11 @@
/**
* EverShelf core — safe HTML escaping (loaded before app.js).
*/
function escapeHtml(str) {
if (str == null) return '';
const div = document.createElement('div');
div.textContent = String(str);
return div.innerHTML;
}
window.escapeHtml = escapeHtml;
File diff suppressed because one or more lines are too long