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:
@@ -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
@@ -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, '"')}">`;
|
||||
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, '"')}"><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);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* EverShelf core — API token storage and auth headers.
|
||||
*/
|
||||
const EVERSHELF_TOKEN_KEY = 'evershelf_api_token';
|
||||
|
||||
function getApiToken() {
|
||||
return localStorage.getItem(EVERSHELF_TOKEN_KEY) || '';
|
||||
}
|
||||
|
||||
function setApiToken(token) {
|
||||
const t = (token || '').trim();
|
||||
if (t) {
|
||||
localStorage.setItem(EVERSHELF_TOKEN_KEY, t);
|
||||
} else {
|
||||
localStorage.removeItem(EVERSHELF_TOKEN_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
function apiAuthHeaders() {
|
||||
const fromStorage = getApiToken();
|
||||
const fromSettingsField = document.getElementById('setting-settings-token')?.value.trim() || '';
|
||||
const token = fromSettingsField || fromStorage;
|
||||
if (!token) return {};
|
||||
return { 'X-API-Token': token };
|
||||
}
|
||||
|
||||
/** Fetch API token from server when loading the UI from the same origin. */
|
||||
async function ensureApiToken() {
|
||||
if (getApiToken()) return true;
|
||||
try {
|
||||
const res = await fetch('api/index.php?action=app_bootstrap', { cache: 'no-store' });
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json();
|
||||
window._apiTokenRequired = !!data.api_token_required;
|
||||
if (data.api_token) {
|
||||
setApiToken(data.api_token);
|
||||
return true;
|
||||
}
|
||||
} catch (_) { /* offline / network */ }
|
||||
return !!getApiToken();
|
||||
}
|
||||
|
||||
function _promptApiTokenIfNeeded() {
|
||||
if (!window._apiTokenRequired) return;
|
||||
if (getApiToken()) return;
|
||||
const existing = document.getElementById('api-token-overlay');
|
||||
if (existing) return;
|
||||
const title = typeof t === 'function' ? t('startup.token_prompt_title') : '🔒 API Token';
|
||||
const hint = typeof t === 'function' ? t('startup.token_prompt_hint') : 'Enter API_TOKEN from .env';
|
||||
const btn = typeof t === 'function' ? t('startup.token_prompt_btn') : 'Continue';
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'api-token-overlay';
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.style.display = 'flex';
|
||||
overlay.innerHTML = `
|
||||
<div class="modal-content" style="max-width:420px;padding:20px">
|
||||
<h3>${title}</h3>
|
||||
<p class="settings-hint">${hint}</p>
|
||||
<input type="password" id="api-token-input" class="form-input" placeholder="API token">
|
||||
<button class="btn btn-primary full-width mt-2" id="api-token-save">${btn}</button>
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
document.getElementById('api-token-save').onclick = () => {
|
||||
const v = document.getElementById('api-token-input').value.trim();
|
||||
if (v) {
|
||||
setApiToken(v);
|
||||
overlay.remove();
|
||||
location.reload();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
window.getApiToken = getApiToken;
|
||||
window.setApiToken = setApiToken;
|
||||
window.apiAuthHeaders = apiAuthHeaders;
|
||||
window.ensureApiToken = ensureApiToken;
|
||||
window._promptApiTokenIfNeeded = _promptApiTokenIfNeeded;
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* EverShelf core — safe HTML escaping (loaded before app.js).
|
||||
*/
|
||||
function escapeHtml(str) {
|
||||
if (str == null) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = String(str);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
window.escapeHtml = escapeHtml;
|
||||
Vendored
+4
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user