const express = require('express'); const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const argon2 = require('argon2'); const nodemailer = require('nodemailer'); const { createStore, defaultSettings } = require('./server/storage'); const port = Number(process.env.PORT || 8080); const publicDir = fs.existsSync(path.join(__dirname, 'dist')) ? path.join(__dirname, 'dist') : 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 modelsFile = path.join(dataDir, 'models.json'); const plansFile = path.join(dataDir, 'plans.json'); const settingsFile = path.join(dataDir, 'settings.json'); const entriesFile = path.join(dataDir, 'entries.json'); const sessionCookieName = 'calorie_ai_session'; const sessionSeconds = 60 * 60 * 24 * 7; let auth; let modelConfig; let store; const aiConfig = { baseUrl: (process.env.CALORIE_AI_API_BASE_URL || process.env.LITELLM_API_BASE || 'https://llm.danvics.com/v1').replace(/\/+$/, ''), apiKey: process.env.CALORIE_AI_API_KEY || process.env.LITELLM_API_KEY || process.env.OPENAI_API_KEY || '', defaultModel: process.env.CALORIE_AI_DEFAULT_MODEL || process.env.LITELLM_MODEL || 'gpt-4o-mini', }; const types = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'application/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.woff2': 'font/woff2', }; function readBody(req) { if (typeof req.body === 'string') return Promise.resolve(req.body); if (req.body && typeof req.body === 'object') return Promise.resolve(JSON.stringify(req.body)); return new Promise((resolve, reject) => { let body = ''; req.on('data', chunk => { body += chunk; if (body.length > 12 * 1024 * 1024) { req.destroy(); reject(new Error('Request body is too large')); } }); req.on('end', () => resolve(body)); req.on('error', reject); }); } async function hashPassword(password) { return argon2.hash(password, { type: argon2.argon2id }); } async function loadAuthConfig() { const stored = await store.getAuthConfig(); const user = process.env.CALORIE_AI_WEB_USER || stored.user || 'admin'; const email = process.env.CALORIE_AI_ADMIN_EMAIL || stored.email || process.env.MAIL_FROM || process.env.SMTP_FROM || ''; if (process.env.CALORIE_AI_WEB_PASSWORD && process.env.CALORIE_AI_SESSION_SECRET) { return { user, email, emailVerified: stored.emailVerified === true, passwordHash: await hashPassword(process.env.CALORIE_AI_WEB_PASSWORD), sessionSecret: process.env.CALORIE_AI_SESSION_SECRET, }; } const generatedPassword = crypto.randomBytes(18).toString('base64url'); const plainPassword = stored.password || (!stored.passwordHash ? generatedPassword : ''); const passwordHash = stored.passwordHash || await hashPassword(plainPassword); const sessionSecret = process.env.CALORIE_AI_SESSION_SECRET || stored.sessionSecret || crypto.randomBytes(32).toString('hex'); if (!stored.passwordHash || !stored.sessionSecret || stored.password) { await store.saveAuthConfig({ user, email, emailVerified: stored.emailVerified === true, passwordHash, sessionSecret, createdAt: stored.createdAt || new Date().toISOString(), updatedAt: new Date().toISOString(), ...(plainPassword && !stored.passwordHash ? { initialPassword: plainPassword } : {}), }); console.log(`Calorie AI auth is stored in ${store.databaseLabel}`); } return { ...stored, user, email, emailVerified: stored.emailVerified === true, passwordHash, sessionSecret }; } async function saveAuthConfig(extra = {}) { auth = { ...auth, ...extra, updatedAt: new Date().toISOString() }; await store.saveAuthConfig({ user: auth.user, email: auth.email || '', emailVerified: auth.emailVerified === true, passwordHash: auth.passwordHash, sessionSecret: auth.sessionSecret, resetTokenHash: auth.resetTokenHash, resetExpiresAt: auth.resetExpiresAt, verificationTokenHash: auth.verificationTokenHash, verificationExpiresAt: auth.verificationExpiresAt, createdAt: auth.createdAt || new Date().toISOString(), updatedAt: auth.updatedAt, }); } function mailConfig() { const host = process.env.CALORIE_AI_SMTP_HOST || process.env.MAIL_SERVER || process.env.SMTP_HOST || ''; const port = Number(process.env.CALORIE_AI_SMTP_PORT || process.env.MAIL_PORT || process.env.SMTP_PORT || 587); const user = process.env.CALORIE_AI_SMTP_USER || process.env.MAIL_USERNAME || process.env.SMTP_USER || ''; const pass = process.env.CALORIE_AI_SMTP_PASSWORD || process.env.MAIL_PASSWORD || process.env.SMTP_PASSWORD || ''; const from = process.env.CALORIE_AI_MAIL_FROM || process.env.MAIL_FROM || process.env.SMTP_FROM || ''; const secure = String(process.env.CALORIE_AI_SMTP_SECURE || process.env.MAIL_SSL_TLS || 'false') === 'true'; return { host, port, user, pass, from, secure, configured: Boolean(host && port && from) }; } function appOrigin(req) { const proto = req.headers['x-forwarded-proto'] || (req.socket.encrypted ? 'https' : 'http'); const host = req.headers['x-forwarded-host'] || req.headers.host; return `${proto}://${host}`; } function tokenHash(token) { return crypto.createHash('sha256').update(token).digest('hex'); } function escHtml(value) { return String(value || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function emailWrapper(body) { const siteName = 'Calorie AI'; const font = '-apple-system,BlinkMacSystemFont,\'Segoe UI\',Helvetica,Arial,sans-serif'; return ` ${escHtml(siteName)}

${escHtml(siteName)}

${body}

${escHtml(siteName)} — meal tracking and weight-loss planning
If you didn’t request this, you can safely ignore it.

`; } function buttonHtml(href, label) { return `
${escHtml(label)}
`; } function linkFallback(url) { return `

If the button doesn’t work, copy this link:

${escHtml(url)}

`; } function actionEmailHtml(title, body, link, label) { return emailWrapper(`

${escHtml(title)}

${escHtml(body).replace(/\n/g, '
')}

${buttonHtml(link, label)} ${linkFallback(link)}`); } async function sendMail(to, subject, text, html = '') { const config = mailConfig(); if (!config.configured) throw new Error('SMTP is not configured'); const transporter = nodemailer.createTransport({ host: config.host, port: config.port, secure: config.secure, auth: config.user || config.pass ? { user: config.user, pass: config.pass } : undefined, }); await transporter.sendMail({ from: config.from, to, subject, text, html: html || undefined }); } async function loadModelConfig() { const stored = await store.getModelConfig(); const defaultModel = stored.defaultModel || aiConfig.defaultModel; const models = Array.isArray(stored.models) && stored.models.length ? stored.models : [{ id: defaultModel, name: defaultModel, tag: 'DEFAULT' }]; const config = { defaultModel, models: dedupeModels(models) }; if (!stored.models?.length) await saveModelConfig(config); return config; } async function saveModelConfig(config = modelConfig) { await store.saveModelConfig({ ...config, updatedAt: new Date().toISOString() }); } async function getPlans(req, res) { send(res, 200, JSON.stringify(await store.getPlans())); } async function createPlan(req, res) { const payload = JSON.parse(await readBody(req) || '{}'); if (!payload.content) return send(res, 400, JSON.stringify({ error: 'Plan content required' })); const config = await store.getPlans(); const plan = { id: payload.id || crypto.randomUUID(), title: String(payload.title || `Weight-loss plan ${config.plans.length + 1}`), version: Number(payload.version || Math.max(0, ...config.plans.map(item => Number(item.version || 0))) + 1), content: String(payload.content), previousPlanId: payload.previousPlanId || undefined, createdAt: payload.createdAt || new Date().toISOString(), }; send(res, 200, JSON.stringify(await store.savePlan(plan))); } async function selectPlanRoute(req, res) { const payload = JSON.parse(await readBody(req) || '{}'); if (payload.id && !await store.planExists(payload.id)) return send(res, 404, JSON.stringify({ error: 'Plan not found' })); send(res, 200, JSON.stringify(await store.selectPlan(payload.id || ''))); } async function deletePlanRoute(req, res) { const payload = JSON.parse(await readBody(req) || '{}'); send(res, 200, JSON.stringify(await store.deletePlan(payload.id))); } async function getSettings(req, res) { send(res, 200, JSON.stringify(await store.getSettings())); } async function updateSettings(req, res) { try { const payload = JSON.parse(await readBody(req) || '{}'); const current = await store.getSettings(); const allowed = Object.keys(defaultSettings); const updated = { ...current }; for (const key of allowed) if (payload[key] !== undefined) updated[key] = payload[key]; await store.saveSettings(updated); send(res, 200, JSON.stringify(updated)); } catch (error) { send(res, 400, JSON.stringify({ error: error.message })); } } async function getEntriesRoute(req, res) { send(res, 200, JSON.stringify(await store.getEntries())); } async function upsertEntryRoute(req, res) { try { const payload = JSON.parse(await readBody(req) || '{}'); if (!payload.id) return send(res, 400, JSON.stringify({ error: 'Entry ID required' })); send(res, 200, JSON.stringify(await store.upsertMealEntry(payload))); } catch (error) { send(res, 400, JSON.stringify({ error: error.message })); } } async function deleteEntriesRoute(req, res) { try { const payload = JSON.parse(await readBody(req) || '{}'); const ids = new Set(Array.isArray(payload.ids) ? payload.ids : [payload.id].filter(Boolean)); send(res, 200, JSON.stringify(await store.moveEntriesToTrash([...ids]))); } catch (error) { send(res, 400, JSON.stringify({ error: error.message })); } } async function restoreEntryRoute(req, res) { try { const payload = JSON.parse(await readBody(req) || '{}'); const data = await store.restoreEntry(payload.id); if (!data) return send(res, 404, JSON.stringify({ error: 'Entry not found in trash' })); send(res, 200, JSON.stringify(data)); } catch (error) { send(res, 400, JSON.stringify({ error: error.message })); } } async function clearTrashRoute(req, res) { try { send(res, 200, JSON.stringify(await store.clearTrash())); } catch (error) { send(res, 400, JSON.stringify({ error: error.message })); } } async function getActivitiesRoute(req, res) { send(res, 200, JSON.stringify(await store.getActivities())); } async function syncActivitiesRoute(req, res) { try { const payload = JSON.parse(await readBody(req) || '{}'); const activities = Array.isArray(payload.activities) ? payload.activities : []; send(res, 200, JSON.stringify(await store.upsertActivities(activities, payload.source || ''))); } catch (error) { send(res, 400, JSON.stringify({ error: error.message })); } } function dedupeModels(models) { return Array.from(new Map((models || []).filter(model => model && model.id).map(model => [model.id, { id: String(model.id), name: String(model.name || model.id), tag: model.tag || 'ADDED', }])).values()).sort((a, b) => a.name.localeCompare(b.name)); } 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' 'unsafe-inline'; 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 === 'POST' && (url === '/api/password/request-reset' || url === '/api/password/confirm-reset' || url === '/api/account/verify')) || ((req.method === 'GET' || req.method === 'HEAD') && (url === '/login' || url === '/reset' || url === '/verify' || url === '/favicon.svg' || url.startsWith('/assets/'))); } function sendFile(req, res, target) { fs.readFile(target, (err, data) => { if (err) return send(res, 404, 'Not found', 'text/plain; charset=utf-8'); send(res, 200, data, types[path.extname(target)] || 'application/octet-stream', req.method === 'HEAD'); }); } function serveStatic(req, res) { const cleanUrl = decodeURIComponent(req.url.split('?')[0]); const file = cleanUrl === '/' ? '/index.html' : cleanUrl; const target = path.normalize(path.join(publicDir, file)); if (!target.startsWith(publicRoot)) return send(res, 403, 'Forbidden', 'text/plain; charset=utf-8'); fs.readFile(target, (err, data) => { if (!err) return send(res, 200, data, types[path.extname(target)] || 'application/octet-stream', req.method === 'HEAD'); if (req.method === 'GET' || req.method === 'HEAD') return sendFile(req, res, path.join(publicDir, 'index.html')); send(res, 404, 'Not found', 'text/plain; charset=utf-8'); }); } async function proxyChat(req, res) { try { const payload = JSON.parse(await readBody(req)); if (!/^https?:\/\//.test(aiConfig.baseUrl)) throw new Error('AI endpoint is not configured'); if (!payload.body || typeof payload.body !== 'object') throw new Error('Missing request body'); const headers = { 'content-type': 'application/json' }; if (aiConfig.apiKey) headers.authorization = `Bearer ${aiConfig.apiKey}`; const upstream = await fetch(`${aiConfig.baseUrl}/chat/completions`, { method: 'POST', headers, body: JSON.stringify(payload.body), }); const text = await upstream.text(); send(res, upstream.status, text); } catch (error) { send(res, 400, JSON.stringify({ error: error.message })); } } async function fetchProviderModels(search = '') { if (!/^https?:\/\//.test(aiConfig.baseUrl)) throw new Error('AI endpoint is not configured'); const headers = {}; if (aiConfig.apiKey) headers.authorization = `Bearer ${aiConfig.apiKey}`; const infoBase = aiConfig.baseUrl.replace(/\/v1$/, ''); let models = []; try { const info = await fetch(`${infoBase}/model/info`, { headers }); if (info.ok) { const data = await info.json(); models = (data.data || []) .filter(item => item.model_name && (!item.model_info || !item.model_info.mode || item.model_info.mode === 'chat')) .map(item => ({ id: item.model_name, name: item.model_name })); } } catch {} if (!models.length) { const upstream = await fetch(`${aiConfig.baseUrl}/models`, { headers }); const data = await upstream.json(); models = (data.data || []).map(item => ({ id: item.id, name: item.id })); } const query = search.trim().toLowerCase(); return dedupeModels(models) .filter(model => !query || `${model.name} ${model.id}`.toLowerCase().includes(query)) .slice(0, 100); } async function proxyModels(req, res) { try { send(res, 200, JSON.stringify({ models: modelConfig.models, defaultModel: modelConfig.defaultModel })); } catch (error) { send(res, 400, JSON.stringify({ error: error.message })); } } async function searchModels(req, res) { try { const payload = req.method === 'POST' ? JSON.parse(await readBody(req) || '{}') : {}; if (!payload.query || String(payload.query).trim().length < 2) { return send(res, 200, JSON.stringify({ models: [], count: 0 })); } const models = await fetchProviderModels(payload.query || ''); send(res, 200, JSON.stringify({ models, count: models.length })); } catch (error) { send(res, 400, JSON.stringify({ error: error.message })); } } async function addModel(req, res) { try { const payload = JSON.parse(await readBody(req)); if (!payload.id) return send(res, 400, JSON.stringify({ error: 'Model ID required' })); modelConfig.models = dedupeModels([...modelConfig.models, { id: payload.id, name: payload.name || payload.id, tag: 'ADDED' }]); if (!modelConfig.defaultModel) modelConfig.defaultModel = payload.id; await saveModelConfig(); send(res, 200, JSON.stringify({ ok: true, models: modelConfig.models, defaultModel: modelConfig.defaultModel })); } catch (error) { send(res, 400, JSON.stringify({ error: error.message })); } } async function removeModel(req, res) { try { const payload = JSON.parse(await readBody(req)); modelConfig.models = modelConfig.models.filter(model => model.id !== payload.id); if (!modelConfig.models.length) modelConfig.models = [{ id: aiConfig.defaultModel, name: aiConfig.defaultModel, tag: 'DEFAULT' }]; if (!modelConfig.models.some(model => model.id === modelConfig.defaultModel)) modelConfig.defaultModel = modelConfig.models[0].id; await saveModelConfig(); send(res, 200, JSON.stringify({ ok: true, models: modelConfig.models, defaultModel: modelConfig.defaultModel })); } catch (error) { send(res, 400, JSON.stringify({ error: error.message })); } } async function login(req, res) { try { const payload = JSON.parse(await readBody(req)); const validUser = safeEqual(payload.username || '', auth.user); const validPassword = validUser && await argon2.verify(auth.passwordHash, payload.password || ''); if (!validUser || !validPassword) { 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 })); } } async function changePassword(req, res) { try { const payload = JSON.parse(await readBody(req)); if (!await argon2.verify(auth.passwordHash, payload.currentPassword || '')) return send(res, 401, JSON.stringify({ error: 'Current password is incorrect' })); if (!payload.newPassword || payload.newPassword.length < 8) return send(res, 400, JSON.stringify({ error: 'Use at least 8 characters for the new password' })); auth.passwordHash = await hashPassword(payload.newPassword); await saveAuthConfig({ passwordHash: auth.passwordHash, resetTokenHash: undefined, resetExpiresAt: undefined }); send(res, 200, JSON.stringify({ ok: true })); } catch (error) { send(res, 400, JSON.stringify({ error: error.message })); } } async function resetPassword(req, res) { try { if (!auth.email) return send(res, 400, JSON.stringify({ error: 'Set an account email first' })); const token = crypto.randomBytes(32).toString('base64url'); await saveAuthConfig({ resetTokenHash: tokenHash(token), resetExpiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() }); const link = `${appOrigin(req)}/reset?token=${encodeURIComponent(token)}`; const text = `Use this link to reset your Calorie AI password. It expires in 1 hour.\n\n${link}`; const html = actionEmailHtml('Password reset request', 'Someone requested a password reset for your Calorie AI account. If that was you, click the button below to choose a new password. This link expires in 1 hour. If you did not request this, no action is needed.', link, 'Reset My Password'); await sendMail(auth.email, 'Reset your Calorie AI password', text, html); send(res, 200, JSON.stringify({ ok: true, message: 'Reset email sent.' })); } catch (error) { send(res, 400, JSON.stringify({ error: error.message })); } } async function requestPasswordReset(req, res) { try { const payload = JSON.parse(await readBody(req) || '{}'); const identity = String(payload.identity || '').trim().toLowerCase(); if (!identity || (identity !== auth.user.toLowerCase() && identity !== String(auth.email || '').toLowerCase())) { return send(res, 200, JSON.stringify({ ok: true, message: 'If the account exists, a reset email was sent.' })); } if (!auth.email) return send(res, 200, JSON.stringify({ ok: true, message: 'If the account exists, a reset email was sent.' })); const token = crypto.randomBytes(32).toString('base64url'); await saveAuthConfig({ resetTokenHash: tokenHash(token), resetExpiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() }); const link = `${appOrigin(req)}/reset?token=${encodeURIComponent(token)}`; const text = `Use this link to reset your Calorie AI password. It expires in 1 hour.\n\n${link}`; const html = actionEmailHtml('Password reset request', 'Someone requested a password reset for your Calorie AI account. If that was you, click the button below to choose a new password. This link expires in 1 hour. If you did not request this, no action is needed.', link, 'Reset My Password'); await sendMail(auth.email, 'Reset your Calorie AI password', text, html); send(res, 200, JSON.stringify({ ok: true, message: 'If the account exists, a reset email was sent.' })); } catch (error) { send(res, 400, JSON.stringify({ error: error.message })); } } async function confirmPasswordReset(req, res) { try { const payload = JSON.parse(await readBody(req) || '{}'); if (!payload.newPassword || payload.newPassword.length < 8) return send(res, 400, JSON.stringify({ error: 'Use at least 8 characters for the new password' })); const valid = auth.resetTokenHash && auth.resetExpiresAt && new Date(auth.resetExpiresAt).getTime() > Date.now() && safeEqual(auth.resetTokenHash, tokenHash(payload.token || '')); if (!valid) return send(res, 400, JSON.stringify({ error: 'Reset link is invalid or expired' })); const passwordHash = await hashPassword(payload.newPassword); await saveAuthConfig({ passwordHash, resetTokenHash: undefined, resetExpiresAt: undefined }); send(res, 200, JSON.stringify({ ok: true })); } catch (error) { send(res, 400, JSON.stringify({ error: error.message })); } } async function updateAccount(req, res) { try { const payload = JSON.parse(await readBody(req) || '{}'); const email = String(payload.email || '').trim(); if (!/^\S+@\S+\.\S+$/.test(email)) return send(res, 400, JSON.stringify({ error: 'Enter a valid email address' })); await saveAuthConfig({ email, emailVerified: email === auth.email ? auth.emailVerified : false }); send(res, 200, JSON.stringify({ ok: true, email: auth.email, emailVerified: auth.emailVerified })); } catch (error) { send(res, 400, JSON.stringify({ error: error.message })); } } async function sendVerification(req, res) { try { if (!auth.email) return send(res, 400, JSON.stringify({ error: 'Set an account email first' })); const token = crypto.randomBytes(32).toString('base64url'); await saveAuthConfig({ verificationTokenHash: tokenHash(token), verificationExpiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() }); const link = `${appOrigin(req)}/verify?token=${encodeURIComponent(token)}`; const text = `Use this link to verify your Calorie AI account email. It expires in 24 hours.\n\n${link}`; const html = actionEmailHtml('Verify your email', 'Please verify your Calorie AI account email by clicking the button below. This link expires in 24 hours.', link, 'Verify My Email'); await sendMail(auth.email, 'Verify your Calorie AI account', text, html); send(res, 200, JSON.stringify({ ok: true, message: 'Verification email sent.' })); } catch (error) { send(res, 400, JSON.stringify({ error: error.message })); } } async function verifyAccount(req, res) { try { const payload = JSON.parse(await readBody(req) || '{}'); const valid = auth.verificationTokenHash && auth.verificationExpiresAt && new Date(auth.verificationExpiresAt).getTime() > Date.now() && safeEqual(auth.verificationTokenHash, tokenHash(payload.token || '')); if (!valid) return send(res, 400, JSON.stringify({ error: 'Verification link is invalid or expired' })); await saveAuthConfig({ emailVerified: true, verificationTokenHash: undefined, verificationExpiresAt: undefined }); send(res, 200, JSON.stringify({ ok: true })); } 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)}`, }); } async function start() { store = await createStore({ dataDir, legacyFiles: { authFile, modelsFile, plansFile, settingsFile, entriesFile }, }); auth = await loadAuthConfig(); modelConfig = await loadModelConfig(); const app = express(); app.use(express.text({ type: '*/*', limit: '12mb' })); app.use((req, res, next) => { 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'); } if (authenticated && (req.method === 'GET' || req.method === 'HEAD') && url === '/login') return redirect(res, '/'); next(); }); const wrap = handler => (req, res) => handler(req, res).catch(error => send(res, 500, JSON.stringify({ error: error.message }))); app.post('/api/login', wrap(login)); app.post('/api/logout', logout); app.post('/api/account', wrap(updateAccount)); app.post('/api/account/send-verification', wrap(sendVerification)); app.post('/api/account/verify', wrap(verifyAccount)); app.post('/api/password/change', wrap(changePassword)); app.post('/api/password/reset', wrap(resetPassword)); app.post('/api/password/request-reset', wrap(requestPasswordReset)); app.post('/api/password/confirm-reset', wrap(confirmPasswordReset)); app.get('/api/session', (req, res) => send(res, 200, JSON.stringify({ user: auth.user, email: auth.email || '', emailVerified: auth.emailVerified === true }))); app.post('/api/models', wrap(proxyModels)); app.post('/api/models/search', wrap(searchModels)); app.post('/api/models/add', wrap(addModel)); app.post('/api/models/remove', wrap(removeModel)); app.get('/api/plans', wrap(getPlans)); app.post('/api/plans', wrap(createPlan)); app.post('/api/plans/select', wrap(selectPlanRoute)); app.post('/api/plans/delete', wrap(deletePlanRoute)); app.get('/api/settings', wrap(getSettings)); app.post('/api/settings', wrap(updateSettings)); app.get('/api/entries', wrap(getEntriesRoute)); app.post('/api/entries', wrap(upsertEntryRoute)); app.post('/api/entries/delete', wrap(deleteEntriesRoute)); app.post('/api/entries/restore', wrap(restoreEntryRoute)); app.post('/api/entries/clear-trash', wrap(clearTrashRoute)); app.get('/api/activities', wrap(getActivitiesRoute)); app.post('/api/activities/sync', wrap(syncActivitiesRoute)); app.post('/api/chat', wrap(proxyChat)); app.use((req, res) => { if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res); send(res, 405, 'Method not allowed', 'text/plain; charset=utf-8'); }); app.listen(port, () => { console.log(`Calorie AI web listening on ${port}`); }); } start().catch(error => { console.error(error); process.exit(1); });