diff --git a/.forgejo/workflows/web.yml b/.forgejo/workflows/web.yml index d8557cb..e36170e 100644 --- a/.forgejo/workflows/web.yml +++ b/.forgejo/workflows/web.yml @@ -24,18 +24,11 @@ jobs: npm install npx playwright install --with-deps chromium - - name: Start web app - run: | - cd web - PORT=8095 node server.js > /tmp/calorie-ai-web.log 2>&1 & - for i in $(seq 1 30); do - curl -fsS http://127.0.0.1:8095 >/dev/null && exit 0 - sleep 1 - done - cat /tmp/calorie-ai-web.log - exit 1 - - name: Run Playwright tests run: | cd web + CI=true \ + CALORIE_AI_WEB_USER=admin \ + CALORIE_AI_WEB_PASSWORD=test-password \ + CALORIE_AI_SESSION_SECRET=test-session-secret \ npm test diff --git a/.gitignore b/.gitignore index 907ad8d..0c0632c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ local.properties web/node_modules/ web/test-results/ web/playwright-report/ +web/data/ +web/.env diff --git a/README.md b/README.md index 67049cd..cb9dfa9 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Tagged versions also build an installable APK named `calorie-ai-vX.Y.Z.apk` thro ## Web Frontend -The web frontend lives in `web/`. It serves a browser UI and a tiny Node proxy at `/api/chat` so OpenAI-compatible endpoints are called server-side instead of directly from the browser. +The web frontend lives in `web/`. It serves an authenticated browser UI and a tiny Node proxy at `/api/chat` so OpenAI-compatible endpoints are called server-side instead of directly from the browser. Run it with Docker: @@ -41,6 +41,8 @@ Then open: http://127.0.0.1:8095 ``` +The Docker web server creates credentials on first boot in `web/data/auth.json` if `CALORIE_AI_WEB_PASSWORD` and `CALORIE_AI_SESSION_SECRET` are not supplied. To pin credentials, copy `web/.env.example` to `web/.env` and set strong values before starting Docker Compose. + ## AI Endpoint The app expects an OpenAI-compatible endpoint at: @@ -56,4 +58,4 @@ Example base URLs: Default admin PIN is `admin`. Change it in the Admin AI Settings panel after first launch. -The web frontend stores AI settings in browser local storage and does not require a PIN by default. +The web frontend stores AI settings in browser local storage after web login. The web login is separate from the Android admin PIN. diff --git a/web/.dockerignore b/web/.dockerignore index b0e3907..def1654 100644 --- a/web/.dockerignore +++ b/web/.dockerignore @@ -1,3 +1,5 @@ node_modules npm-debug.log .DS_Store +data +.env diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..eac2d21 --- /dev/null +++ b/web/.env.example @@ -0,0 +1,3 @@ +CALORIE_AI_WEB_USER=admin +CALORIE_AI_WEB_PASSWORD=change-this-password +CALORIE_AI_SESSION_SECRET=change-this-session-secret diff --git a/web/docker-compose.yml b/web/docker-compose.yml index d8dc165..a04d880 100644 --- a/web/docker-compose.yml +++ b/web/docker-compose.yml @@ -5,3 +5,5 @@ services: restart: unless-stopped ports: - "127.0.0.1:8095:8080" + volumes: + - ./data:/app/data diff --git a/web/playwright.config.js b/web/playwright.config.js index 8698696..2891810 100644 --- a/web/playwright.config.js +++ b/web/playwright.config.js @@ -3,7 +3,18 @@ const { defineConfig } = require('@playwright/test'); module.exports = defineConfig({ testDir: './tests', timeout: 30000, + webServer: { + command: 'node server.js', + url: 'http://127.0.0.1:8096', + reuseExistingServer: !process.env.CI, + env: { + PORT: '8096', + CALORIE_AI_WEB_USER: 'admin', + CALORIE_AI_WEB_PASSWORD: 'test-password', + CALORIE_AI_SESSION_SECRET: 'test-session-secret', + }, + }, use: { - baseURL: 'http://127.0.0.1:8095', + baseURL: 'http://127.0.0.1:8096', }, }); diff --git a/web/public/app.js b/web/public/app.js index 67f216e..2c39d7e 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -5,6 +5,11 @@ const entriesKey = 'calorie-ai-web-entries'; let selectedImageDataUrl = ''; let selectedImageName = ''; +function setText(id, value) { + const element = $(id); + if (element) element.textContent = value; +} + function todayKey() { return dateKey(new Date()); } @@ -147,14 +152,14 @@ function drawMacroChart(total) { ctx.stroke(); start += arc; }); - ctx.fillStyle = '#26372d'; + ctx.fillStyle = '#17251d'; ctx.font = '700 20px sans-serif'; ctx.fillText('Today', 240, 88); ctx.font = '15px sans-serif'; labels.forEach((label, index) => { ctx.fillStyle = colors[index]; ctx.fillRect(240, 112 + index * 34, 14, 14); - ctx.fillStyle = '#26372d'; + ctx.fillStyle = '#17251d'; ctx.fillText(label, 264, 125 + index * 34); }); } @@ -207,6 +212,7 @@ function renderEntries() { ${e.calories} kcal | P ${e.proteinGrams}g | C ${e.carbsGrams}g | F ${e.fatGrams}g Fruit ${Number(e.fruitServings || 0).toFixed(1)} | Vegetables ${Number(e.vegetableServings || 0).toFixed(1)}${e.imageIncluded ? ' | image analyzed' : ''} ${escapeHtml(e.description || '')} + ${e.foodGroups ? `Groups: ${escapeHtml(e.foodGroups)}` : ''} ${e.notes ? `${escapeHtml(e.notes)}` : ''} `).join(''); } @@ -217,16 +223,33 @@ function escapeHtml(value) { function render() { const today = totalsFor(todayKey()); - $('todayCalories').textContent = `${today.calories} kcal`; - $('todayMacroText').textContent = `P ${today.protein}g | C ${today.carbs}g | F ${today.fat}g`; - $('todayProduceText').textContent = `Fruit ${today.fruit.toFixed(1)} | Veg ${today.vegetables.toFixed(1)}`; const week = last7Days(); + const weekAverage = Math.round(week.reduce((sum, day) => sum + day.calories, 0) / week.length); + const produce = today.fruit + today.vegetables; + + setText('todayCalories', `${today.calories} kcal`); + setText('todayMacroText', `P ${today.protein}g | C ${today.carbs}g | F ${today.fat}g`); + setText('todayProduceText', `Fruit ${today.fruit.toFixed(1)} | Veg ${today.vegetables.toFixed(1)}`); + setText('mealCountText', String(today.meals)); + setText('weekAverageText', `${weekAverage} kcal`); + setText('produceGoalText', `${produce.toFixed(1)} / 5`); + drawMacroChart(today); drawBars('calorieChart', week, 'calories'); drawBars('produceChart', week, 'produce'); renderEntries(); } +function showSection(sectionName) { + document.querySelectorAll('.section-page').forEach(section => { + section.hidden = section.dataset.section !== sectionName; + }); + document.querySelectorAll('[data-nav]').forEach(button => { + button.classList.toggle('active', button.dataset.nav === sectionName); + }); + document.body.classList.remove('menu-open'); +} + function loadSettings() { const cfg = settings(); $('baseUrl').value = cfg.baseUrl; @@ -244,6 +267,19 @@ $('saveSettings').addEventListener('click', () => { }); }); +document.querySelectorAll('[data-nav]').forEach(button => { + button.addEventListener('click', () => showSection(button.dataset.nav)); +}); + +$('menuToggle').addEventListener('click', () => { + document.body.classList.toggle('menu-open'); +}); + +$('logout').addEventListener('click', async () => { + await fetch('/api/logout', { method: 'POST' }); + window.location.href = '/login.html'; +}); + $('image').addEventListener('change', async (event) => { const file = event.target.files[0]; selectedImageDataUrl = file ? await fileToDataUrl(file) : ''; diff --git a/web/public/index.html b/web/public/index.html index ece7d0c..09f9a95 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -8,75 +8,155 @@ -
-
-
-

AI food diary

-

Calorie AI

-

Upload a meal image, add a portion note, and track calories, macros, fruit, and vegetables per day.

+
+
+ -
-
-

Add meal

- - - - - -

-
+
+
+ +
+

AI nutrition cockpit

+

Private food tracking with image-backed nutrition estimates

+
+ +
-
-

Admin AI settings

- - - - - -

The vision model estimates food and calories from images. The tasking model normalizes the final nutrition JSON. They can be the same model.

-
-
+
+
+
+
+

Today

+ 0 kcal + P 0g | C 0g | F 0g + Fruit 0.0 | Veg 0.0 +
+
+ Meals logged + 0 + Entries saved locally +
+
+ 7-day average + 0 kcal + Calories per day +
+
+ Produce today + 0.0 / 5 + Fruit + vegetable servings +
+
-
-
-

Macros today

- -
-
-

Calories, last 7 days

- -
-
-

Fruit and vegetables

- -
-
+
+
+

Quick log

+

Capture the meal while it is still in front of you.

+

Upload a plate photo, add a portion note, and let the vision model hand context to the tasking model for clean nutrition JSON.

+ +
+ +
+
-
-
-

Food diary

- -
-
-
-
+ + + + + + + +
+ + diff --git a/web/public/login.html b/web/public/login.html new file mode 100644 index 0000000..e422b7f --- /dev/null +++ b/web/public/login.html @@ -0,0 +1,29 @@ + + + + + + Sign in - Calorie AI + + + + +
+
+

Private nutrition console

+

Calorie AI

+

Protected meal logging for calories, macros, fruit, vegetables, and AI image estimates.

+
+
+ CA +

Sign in to Calorie AI

+

Use the local web credentials configured on this host.

+ + + +

+
+
+ + + diff --git a/web/public/login.js b/web/public/login.js new file mode 100644 index 0000000..70f2950 --- /dev/null +++ b/web/public/login.js @@ -0,0 +1,31 @@ +const loginForm = document.getElementById('loginForm'); +const loginStatus = document.getElementById('loginStatus'); + +loginForm.addEventListener('submit', async (event) => { + event.preventDefault(); + const button = loginForm.querySelector('button'); + button.disabled = true; + loginStatus.textContent = 'Checking credentials...'; + loginStatus.className = 'status ok'; + + try { + const response = await fetch('/api/login', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + username: document.getElementById('username').value.trim(), + password: document.getElementById('password').value, + }), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Sign in failed' })); + throw new Error(error.error || 'Sign in failed'); + } + window.location.href = '/'; + } catch (error) { + loginStatus.textContent = error.message; + loginStatus.className = 'status error'; + } finally { + button.disabled = false; + } +}); diff --git a/web/public/styles.css b/web/public/styles.css index 9583ea7..0966abf 100644 --- a/web/public/styles.css +++ b/web/public/styles.css @@ -1,80 +1,246 @@ :root { color-scheme: light; - --bg: #f8f4ec; - --ink: #26372d; - --muted: #5b635c; - --card: #ffffff; - --line: #e5ddcf; - --green: #2f7d59; - --orange: #da9648; - --blue: #6177c4; - --soft: #eef3ee; + --bg: #f3efe3; + --panel: rgba(255, 253, 247, .94); + --panel-strong: #fffaf0; + --ink: #17251d; + --muted: #667268; + --line: #e3d8c4; + --green: #236b4b; + --green-2: #39a36d; + --orange: #d5873a; + --blue: #5168bc; + --nav: #112018; + --nav-soft: #1c3427; + --danger: #a43434; + --shadow: 0 28px 80px rgba(41, 31, 17, .12); } * { box-sizing: border-box; } + body { margin: 0; + min-height: 100vh; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - background: radial-gradient(circle at top left, #fff8df, transparent 30rem), var(--bg); + background: + radial-gradient(circle at 15% 10%, rgba(255, 226, 154, .65), transparent 28rem), + radial-gradient(circle at 80% 0%, rgba(79, 138, 104, .22), transparent 30rem), + var(--bg); color: var(--ink); } -.shell { width: min(1180px, calc(100% - 32px)); margin: 0 auto; padding: 32px 0; } -.hero { display: grid; grid-template-columns: 1fr minmax(220px, 320px); gap: 20px; align-items: stretch; margin-bottom: 20px; } -.eyebrow { color: var(--green); font-weight: 800; letter-spacing: .12em; margin: 0 0 8px; text-transform: uppercase; } -h1 { font-size: clamp(2.7rem, 8vw, 6rem); line-height: .9; margin: 0; } -h2 { margin: 0 0 16px; font-size: 1.15rem; } -p { color: var(--muted); line-height: 1.5; } -.today-card, .panel { - border: 1px solid var(--line); - border-radius: 24px; - background: rgba(255,255,255,.88); - box-shadow: 0 24px 60px rgba(48, 38, 20, .08); -} -.today-card { padding: 24px; display: grid; align-content: center; gap: 8px; } -.today-card strong { font-size: 2.3rem; color: var(--green); } -.today-card small { color: var(--muted); } -.grid { display: grid; gap: 20px; margin-bottom: 20px; } -.two { grid-template-columns: 1.2fr .8fr; } -.three { grid-template-columns: minmax(260px, .8fr) minmax(320px, 1.1fr); } -.wide { grid-column: auto; } -.panel { padding: 22px; } -label { display: grid; gap: 7px; margin-bottom: 14px; color: var(--muted); font-weight: 700; } -input, textarea { - width: 100%; - border: 1px solid #dbd3c5; - border-radius: 14px; - background: #fcfaf6; - color: var(--ink); - font: inherit; - padding: 12px 13px; -} -textarea { resize: vertical; } +button, input, textarea { font: inherit; } + button { border: 0; - border-radius: 14px; - background: var(--green); + border-radius: 16px; + background: linear-gradient(135deg, var(--green), var(--green-2)); color: white; cursor: pointer; - font: inherit; - font-weight: 800; - padding: 12px 15px; + font-weight: 850; + padding: 13px 17px; + transition: transform .18s ease, box-shadow .18s ease, opacity .18s ease; } -button.secondary { background: #e9e3d7; color: var(--ink); } -button:disabled { opacity: .55; cursor: wait; } -.preview { width: 100%; max-height: 280px; object-fit: cover; border-radius: 18px; margin: 4px 0 14px; border: 1px solid var(--line); } -.status { min-height: 1.5em; margin-bottom: 0; } -.status.error { color: #a43434; } -.status.ok { color: var(--green); } -.muted { color: var(--muted); font-size: .9rem; } -.chart-panel canvas { width: 100%; height: auto; } -.entries { display: grid; gap: 12px; } -.entry { border: 1px solid var(--line); border-radius: 18px; padding: 14px; background: #fffdf8; } -.entry strong { display: block; margin-bottom: 5px; } -.entry small { color: var(--muted); display: block; margin-top: 4px; } -.row-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; } -@media (max-width: 860px) { - .hero, .two, .three { grid-template-columns: 1fr; } - .shell { width: min(100% - 20px, 1180px); padding: 20px 0; } +button:hover { transform: translateY(-1px); box-shadow: 0 12px 28px rgba(35, 107, 75, .22); } +button:disabled { cursor: wait; opacity: .55; transform: none; box-shadow: none; } +button.secondary { background: #ebe1d0; color: var(--ink); } +button.ghost { width: 100%; background: rgba(255,255,255,.09); color: #f4ead6; box-shadow: none; } + +input, textarea { + width: 100%; + border: 1px solid #d9ceb8; + border-radius: 16px; + background: #fffaf2; + color: var(--ink); + outline: none; + padding: 13px 14px; +} + +input:focus, textarea:focus { border-color: var(--green-2); box-shadow: 0 0 0 4px rgba(57, 163, 109, .14); } +textarea { resize: vertical; } +label { display: grid; gap: 8px; color: var(--muted); font-weight: 800; } +h1, h2, p { margin-top: 0; } +h1 { max-width: 820px; margin-bottom: 0; font-size: clamp(2rem, 4vw, 4.2rem); line-height: .94; letter-spacing: -.06em; } +h2 { margin-bottom: 12px; font-size: clamp(1.35rem, 2vw, 2rem); letter-spacing: -.035em; } +p { color: var(--muted); line-height: 1.6; } +canvas { max-width: 100%; } + +.app-frame { display: grid; grid-template-columns: 280px 1fr; min-height: 100vh; } +.sidebar { + position: sticky; + top: 0; + height: 100vh; + padding: 22px; + display: grid; + grid-template-rows: auto 1fr auto; + gap: 28px; + background: linear-gradient(180deg, var(--nav), #0b140f); + color: #f8ecd7; +} + +.brand-block { display: flex; align-items: center; gap: 12px; } +.brand-block strong { display: block; font-size: 1.05rem; } +.brand-block small, .sidebar-footer small { color: rgba(248, 236, 215, .62); } +.brand-mark { + width: 48px; + height: 48px; + display: inline-grid; + place-items: center; + border-radius: 17px; + background: linear-gradient(135deg, #e7a24e, #3fb473); + color: #102017; + font-weight: 950; + letter-spacing: -.04em; +} + +.main-nav { display: grid; align-content: start; gap: 9px; } +.nav-item { + width: 100%; + padding: 13px 14px; + border-radius: 15px; + background: transparent; + color: rgba(248, 236, 215, .72); + text-align: left; + box-shadow: none; +} +.nav-item:hover, .nav-item.active { background: var(--nav-soft); color: #fff4df; box-shadow: none; } +.sidebar-footer { display: grid; gap: 12px; } + +.content-shell { min-width: 0; } +.topbar { + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 26px clamp(18px, 4vw, 48px); + border-bottom: 1px solid rgba(227, 216, 196, .7); + background: rgba(243, 239, 227, .82); + backdrop-filter: blur(20px); +} + +.menu-toggle { display: none; background: #e9dfcc; color: var(--ink); box-shadow: none; } +.primary-action { white-space: nowrap; } +.content { width: min(1180px, calc(100% - 36px)); margin: 0 auto; padding: 28px 0 44px; } +.section-page { display: grid; gap: 22px; } + +.eyebrow { + margin: 0 0 8px; + color: var(--green); + font-size: .76rem; + font-weight: 950; + letter-spacing: .14em; + text-transform: uppercase; +} + +.hero-grid { display: grid; grid-template-columns: minmax(300px, 1.4fr) repeat(3, minmax(150px, .62fr)); gap: 18px; } +.hero-card, .metric-card, .panel, .login-card, .login-brand { + border: 1px solid rgba(227, 216, 196, .9); + border-radius: 28px; + background: var(--panel); + box-shadow: var(--shadow); +} +.hero-card { + min-height: 220px; + padding: 28px; + display: grid; + align-content: end; + background: + linear-gradient(135deg, rgba(22, 42, 30, .9), rgba(35, 107, 75, .84)), + radial-gradient(circle at top right, rgba(235, 173, 79, .6), transparent 20rem); + color: white; +} +.hero-card .eyebrow, .hero-card span { color: rgba(255,255,255,.78); } +.hero-card strong { display: block; margin-bottom: 8px; font-size: clamp(3rem, 8vw, 5.6rem); line-height: .9; letter-spacing: -.07em; } +.hero-card span { display: block; margin-top: 6px; } +.metric-card { padding: 22px; display: grid; align-content: end; gap: 8px; background: linear-gradient(180deg, #fffdf8, #f7efde); } +.metric-card small { color: var(--muted); font-weight: 850; text-transform: uppercase; letter-spacing: .08em; } +.metric-card strong { font-size: 2.2rem; line-height: 1; letter-spacing: -.05em; } +.metric-card span { color: var(--muted); font-size: .92rem; } +.metric-card.warm strong { color: var(--orange); } +.metric-card.cool strong { color: var(--blue); } + +.panel { padding: clamp(20px, 3vw, 32px); } +.split-panel { display: grid; grid-template-columns: minmax(280px, .85fr) minmax(320px, 1fr); gap: 24px; align-items: center; } +.panel-heading { max-width: 760px; margin-bottom: 20px; } +.panel-heading.compact { margin-bottom: 8px; } +.grid { display: grid; gap: 20px; } +.grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } +.span-2 { grid-column: span 2; } +.form-actions { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-top: 18px; } +.meal-panel { background: linear-gradient(135deg, rgba(255,253,247,.96), rgba(244, 233, 210, .94)); } +.preview { width: 100%; max-height: 360px; object-fit: cover; border: 1px solid var(--line); border-radius: 24px; margin-top: 18px; } +.status { min-height: 1.5em; margin: 0; font-weight: 800; } +.status.error { color: var(--danger); } +.status.ok { color: var(--green); } +.muted { color: var(--muted); font-size: .93rem; } +.chart-panel canvas, .split-panel canvas { width: 100%; height: auto; } +.row-title { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 18px; } +.row-title h2 { margin-bottom: 0; } +.entries { display: grid; gap: 12px; } +.entry { + display: grid; + gap: 7px; + border: 1px solid var(--line); + border-radius: 20px; + padding: 16px; + background: var(--panel-strong); +} +.entry strong { font-size: 1.02rem; } +.entry small { color: var(--muted); } + +.login-body { display: grid; place-items: center; padding: 24px; } +.login-shell { width: min(980px, 100%); display: grid; grid-template-columns: 1.1fr .9fr; gap: 22px; align-items: stretch; } +.login-brand { + min-height: 560px; + padding: clamp(28px, 5vw, 54px); + display: grid; + align-content: end; + background: + linear-gradient(135deg, rgba(15, 29, 21, .94), rgba(29, 88, 61, .86)), + radial-gradient(circle at top right, rgba(218, 150, 72, .82), transparent 22rem); + color: #fff4df; +} +.login-brand h1 { color: white; } +.login-brand p, .login-brand .eyebrow { color: rgba(255,244,223,.78); } +.login-card { padding: clamp(24px, 4vw, 40px); align-self: center; display: grid; gap: 16px; } +.login-card h2 { margin-bottom: 0; } + +@media (max-width: 1050px) { + .app-frame { grid-template-columns: 1fr; } + .sidebar { + position: fixed; + left: 14px; + top: 14px; + bottom: 14px; + z-index: 30; + width: min(320px, calc(100vw - 28px)); + height: auto; + border-radius: 26px; + transform: translateX(calc(-100% - 18px)); + transition: transform .2s ease; + box-shadow: 0 30px 90px rgba(0,0,0,.34); + } + body.menu-open .sidebar { transform: translateX(0); } + .menu-toggle { display: inline-flex; } + .hero-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .hero-card { grid-column: span 2; } + .split-panel, .grid.two, .login-shell { grid-template-columns: 1fr; } + .login-brand { min-height: 360px; } +} + +@media (max-width: 700px) { + .topbar { align-items: flex-start; padding: 18px; } + .primary-action { display: none; } + .content { width: min(100% - 22px, 1180px); padding-top: 18px; } + .hero-grid, .form-grid { grid-template-columns: 1fr; } + .hero-card, .span-2 { grid-column: auto; } + .row-title, .form-actions { align-items: stretch; flex-direction: column; } + .row-title button, .form-actions button { width: 100%; } + .login-body { padding: 12px; } + .login-brand { min-height: 260px; } } diff --git a/web/server.js b/web/server.js index 4807ba2..160b79c 100644 --- a/web/server.js +++ b/web/server.js @@ -1,10 +1,16 @@ const http = require('http'); +const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const port = Number(process.env.PORT || 8080); const publicDir = path.join(__dirname, 'public'); const publicRoot = publicDir + path.sep; +const dataDir = process.env.CALORIE_AI_DATA_DIR || path.join(__dirname, 'data'); +const authFile = path.join(dataDir, 'auth.json'); +const sessionCookieName = 'calorie_ai_session'; +const sessionSeconds = 60 * 60 * 24 * 7; +const auth = loadAuthConfig(); const types = { '.html': 'text/html; charset=utf-8', @@ -29,14 +35,98 @@ function readBody(req) { }); } -function send(res, status, body, contentType = 'application/json; charset=utf-8', headOnly = false) { +function loadAuthConfig() { + let stored = {}; + try { + stored = JSON.parse(fs.readFileSync(authFile, 'utf8')); + } catch { + stored = {}; + } + + const user = process.env.CALORIE_AI_WEB_USER || stored.user || 'admin'; + const password = process.env.CALORIE_AI_WEB_PASSWORD || stored.password || crypto.randomBytes(18).toString('base64url'); + const sessionSecret = process.env.CALORIE_AI_SESSION_SECRET || stored.sessionSecret || crypto.randomBytes(32).toString('hex'); + + if (!process.env.CALORIE_AI_WEB_PASSWORD || !process.env.CALORIE_AI_SESSION_SECRET) { + fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync(authFile, JSON.stringify({ user, password, sessionSecret, createdAt: new Date().toISOString() }, null, 2)); + fs.chmodSync(authFile, 0o600); + console.log(`Calorie AI auth is stored at ${authFile}`); + } + + return { user, password, sessionSecret }; +} + +function send(res, status, body, contentType = 'application/json; charset=utf-8', headOnly = false, headers = {}) { res.writeHead(status, { 'content-type': contentType, 'cache-control': 'no-store', + 'x-content-type-options': 'nosniff', + 'referrer-policy': 'same-origin', + 'x-frame-options': 'DENY', + 'content-security-policy': "default-src 'self'; img-src 'self' data: blob:; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'self'; frame-ancestors 'none'", + ...headers, }); res.end(headOnly ? '' : body); } +function redirect(res, location) { + res.writeHead(302, { + location, + 'cache-control': 'no-store', + 'referrer-policy': 'same-origin', + 'x-frame-options': 'DENY', + }); + res.end(); +} + +function parseCookies(req) { + return Object.fromEntries(String(req.headers.cookie || '').split(';').map(part => { + const index = part.indexOf('='); + if (index < 0) return ['', '']; + return [part.slice(0, index).trim(), decodeURIComponent(part.slice(index + 1).trim())]; + }).filter(([key]) => key)); +} + +function safeEqual(left, right) { + const leftBuffer = Buffer.from(String(left)); + const rightBuffer = Buffer.from(String(right)); + return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer); +} + +function sign(value) { + return crypto.createHmac('sha256', auth.sessionSecret).update(value).digest('base64url'); +} + +function createSession() { + const payload = Buffer.from(JSON.stringify({ user: auth.user, exp: Date.now() + sessionSeconds * 1000 })).toString('base64url'); + return `${payload}.${sign(payload)}`; +} + +function isAuthenticated(req) { + const token = parseCookies(req)[sessionCookieName]; + if (!token) return false; + const [payload, signature] = token.split('.'); + if (!payload || !signature || !safeEqual(sign(payload), signature)) return false; + try { + const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); + return decoded.user === auth.user && decoded.exp > Date.now(); + } catch { + return false; + } +} + +function cookieOptions(req, maxAge = sessionSeconds) { + const secure = req.headers['x-forwarded-proto'] === 'https' || process.env.CALORIE_AI_COOKIE_SECURE === 'true'; + return `HttpOnly; SameSite=Lax; Path=/; Max-Age=${maxAge}${secure ? '; Secure' : ''}`; +} + +function publicRequest(req) { + const url = req.url.split('?')[0]; + return (req.method === 'POST' && url === '/api/login') + || ((req.method === 'GET' || req.method === 'HEAD') && ['/login.html', '/login.js', '/styles.css', '/favicon.svg'].includes(url)); +} + function serveStatic(req, res) { const cleanUrl = decodeURIComponent(req.url.split('?')[0]); const file = cleanUrl === '/' ? '/index.html' : cleanUrl; @@ -71,7 +161,40 @@ async function proxyChat(req, res) { } } +async function login(req, res) { + try { + const payload = JSON.parse(await readBody(req)); + if (!safeEqual(payload.username || '', auth.user) || !safeEqual(payload.password || '', auth.password)) { + return send(res, 401, JSON.stringify({ error: 'Invalid username or password' })); + } + + send(res, 200, JSON.stringify({ ok: true, user: auth.user }), undefined, false, { + 'set-cookie': `${sessionCookieName}=${encodeURIComponent(createSession())}; ${cookieOptions(req)}`, + }); + } catch (error) { + send(res, 400, JSON.stringify({ error: error.message })); + } +} + +function logout(req, res) { + send(res, 200, JSON.stringify({ ok: true }), undefined, false, { + 'set-cookie': `${sessionCookieName}=; ${cookieOptions(req, 0)}`, + }); +} + http.createServer((req, res) => { + const url = req.url.split('?')[0]; + const authenticated = isAuthenticated(req); + + if (!authenticated && !publicRequest(req)) { + if (url.startsWith('/api/')) return send(res, 401, JSON.stringify({ error: 'Authentication required' })); + return redirect(res, '/login.html'); + } + + if (authenticated && (req.method === 'GET' || req.method === 'HEAD') && url === '/login.html') return redirect(res, '/'); + if (req.method === 'POST' && url === '/api/login') return login(req, res); + if (req.method === 'POST' && url === '/api/logout') return logout(req, res); + if (req.method === 'GET' && url === '/api/session') return send(res, 200, JSON.stringify({ user: auth.user })); if (req.method === 'POST' && req.url === '/api/chat') return proxyChat(req, res); if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res); send(res, 405, 'Method not allowed', 'text/plain; charset=utf-8'); diff --git a/web/tests/app.spec.js b/web/tests/app.spec.js index ea886e3..98b2a1c 100644 --- a/web/tests/app.spec.js +++ b/web/tests/app.spec.js @@ -5,55 +5,82 @@ const png = Buffer.from( 'base64', ); -test.beforeEach(async ({ page }) => { +async function login(page) { await page.goto('/'); - await page.evaluate(() => localStorage.clear()); - await page.reload(); + await expect(page).toHaveURL(/login\.html/); + await expect(page.getByRole('heading', { name: 'Sign in to Calorie AI' })).toBeVisible(); + await page.locator('#password').fill('test-password'); + await page.getByRole('button', { name: 'Sign in' }).click(); + await expect(page.getByRole('heading', { name: /Private food tracking/ })).toBeVisible(); +} + +test('requires authentication before loading the app', async ({ page, request }) => { + await page.goto('/'); + await expect(page).toHaveURL(/login\.html/); + await expect(page.getByRole('heading', { name: 'Sign in to Calorie AI' })).toBeVisible(); + + const response = await request.post('/api/chat', { data: {} }); + expect(response.status()).toBe(401); }); -test('loads the dashboard and validates missing meal input', async ({ page }) => { - await expect(page.getByRole('heading', { name: 'Calorie AI' })).toBeVisible(); - await page.getByRole('button', { name: 'Analyze and save' }).click(); - await expect(page.locator('#status')).toContainText('Describe the meal or upload an image.'); -}); - -test('saves settings, uploads an image, logs nutrition, updates charts, and clears diary', async ({ page }) => { - let calls = 0; - await page.route('**/api/chat', async (route) => { - calls += 1; - const content = calls === 1 - ? '{"foods":["salmon","rice","broccoli","berries"],"calories":620,"proteinGrams":42,"carbsGrams":58,"fatGrams":22,"fruitServings":0.5,"vegetableServings":1.5}' - : '{"mealName":"Salmon rice bowl","calories":640,"proteinGrams":44,"carbsGrams":60,"fatGrams":23,"fruitServings":0.5,"vegetableServings":1.5,"foodGroups":"fish, grains, fruit, vegetables","notes":"Estimated from uploaded image and portion note."}'; - - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ choices: [{ message: { content } }] }), - }); +test.describe('authenticated app', () => { + test.beforeEach(async ({ page }) => { + await login(page); + await page.evaluate(() => localStorage.clear()); + await page.reload(); }); - await page.locator('#baseUrl').fill('https://api.example.test/v1'); - await page.locator('#visionModel').fill('vision-test-model'); - await page.locator('#taskModel').fill('task-test-model'); - await page.getByRole('button', { name: 'Save settings' }).click(); + test('loads the dashboard, menu, and validates missing meal input', async ({ page }) => { + await expect(page.getByRole('navigation', { name: 'Main menu' })).toBeVisible(); + await expect(page.locator('#todayCalories')).toHaveText('0 kcal'); + await page.getByRole('button', { name: 'Log meal' }).click(); + await page.getByRole('button', { name: 'Analyze and save' }).click(); + await expect(page.locator('#status')).toContainText('Describe the meal or upload an image.'); + }); - await page.locator('#description').fill('salmon rice bowl with broccoli and berries'); - await page.locator('#measure').fill('one dinner bowl'); - await page.locator('#image').setInputFiles({ name: 'meal.png', mimeType: 'image/png', buffer: png }); - await expect(page.locator('#preview')).toBeVisible(); + test('saves settings, uploads an image, logs nutrition, updates charts, and clears diary', async ({ page }) => { + let calls = 0; + await page.route('**/api/chat', async (route) => { + calls += 1; + const content = calls === 1 + ? '{"foods":["salmon","rice","broccoli","berries"],"calories":620,"proteinGrams":42,"carbsGrams":58,"fatGrams":22,"fruitServings":0.5,"vegetableServings":1.5}' + : '{"mealName":"Salmon rice bowl","calories":640,"proteinGrams":44,"carbsGrams":60,"fatGrams":23,"fruitServings":0.5,"vegetableServings":1.5,"foodGroups":"fish, grains, fruit, vegetables","notes":"Estimated from uploaded image and portion note."}'; - await page.getByRole('button', { name: 'Analyze and save' }).click(); - await expect(page.locator('#status')).toContainText('Saved.'); - await expect(page.locator('#todayCalories')).toHaveText('640 kcal'); - await expect(page.locator('#todayMacroText')).toHaveText('P 44g | C 60g | F 23g'); - await expect(page.locator('#todayProduceText')).toHaveText('Fruit 0.5 | Veg 1.5'); - await expect(page.locator('.entry')).toContainText('Salmon rice bowl'); - await expect(page.locator('#macroChart')).toBeVisible(); - await expect(page.locator('#calorieChart')).toBeVisible(); - await expect(page.locator('#produceChart')).toBeVisible(); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ choices: [{ message: { content } }] }), + }); + }); - page.once('dialog', dialog => dialog.accept()); - await page.getByRole('button', { name: 'Clear local diary' }).click(); - await expect(page.locator('#todayCalories')).toHaveText('0 kcal'); - await expect(page.locator('#entries')).toContainText('No meals logged yet.'); + await page.getByRole('button', { name: 'AI settings' }).click(); + await page.locator('#baseUrl').fill('https://api.example.test/v1'); + await page.locator('#visionModel').fill('vision-test-model'); + await page.locator('#taskModel').fill('task-test-model'); + await page.getByRole('button', { name: 'Save settings' }).click(); + + await page.getByRole('button', { name: 'Log meal' }).click(); + await page.locator('#description').fill('salmon rice bowl with broccoli and berries'); + await page.locator('#measure').fill('one dinner bowl'); + await page.locator('#image').setInputFiles({ name: 'meal.png', mimeType: 'image/png', buffer: png }); + await expect(page.locator('#preview')).toBeVisible(); + + await page.getByRole('button', { name: 'Analyze and save' }).click(); + await expect(page.locator('#status')).toContainText('Saved.'); + await expect(page.locator('#todayCalories')).toHaveText('640 kcal'); + await expect(page.locator('#todayMacroText')).toHaveText('P 44g | C 60g | F 23g'); + await expect(page.locator('#todayProduceText')).toHaveText('Fruit 0.5 | Veg 1.5'); + + await page.getByRole('button', { name: 'Analytics' }).click(); + await expect(page.locator('#calorieChart')).toBeVisible(); + await expect(page.locator('#produceChart')).toBeVisible(); + + await page.getByRole('button', { name: 'Diary', exact: true }).click(); + await expect(page.locator('.entry')).toContainText('Salmon rice bowl'); + + page.once('dialog', dialog => dialog.accept()); + await page.getByRole('button', { name: 'Clear local diary' }).click(); + await expect(page.locator('#todayCalories')).toHaveText('0 kcal'); + await expect(page.locator('#entries')).toContainText('No meals logged yet.'); + }); });