688 lines
30 KiB
JavaScript
688 lines
30 KiB
JavaScript
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, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
function emailWrapper(body) {
|
|
const siteName = 'Calorie AI';
|
|
const font = '-apple-system,BlinkMacSystemFont,\'Segoe UI\',Helvetica,Arial,sans-serif';
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
<title>${escHtml(siteName)}</title>
|
|
<style>
|
|
body,table,td,p,a{font-family:${font};}
|
|
@media only screen and (max-width:600px){.outer{padding:24px 16px !important;}}
|
|
</style>
|
|
</head>
|
|
<body style="margin:0;padding:0;background:#ffffff;-webkit-text-size-adjust:100%;">
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
|
<tr><td class="outer" align="center" style="padding:48px 24px;">
|
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="max-width:520px;width:100%;">
|
|
<tr><td style="padding-bottom:32px;border-bottom:1px solid #e5e7eb;">
|
|
<p style="margin:0;font-size:14px;font-weight:600;color:#111827;letter-spacing:-0.1px;">${escHtml(siteName)}</p>
|
|
</td></tr>
|
|
<tr><td style="padding:32px 0;">${body}</td></tr>
|
|
<tr><td style="padding-top:24px;border-top:1px solid #e5e7eb;">
|
|
<p style="margin:0;font-size:12px;color:#9ca3af;line-height:1.7;">
|
|
${escHtml(siteName)} — meal tracking and weight-loss planning<br>
|
|
If you didn’t request this, you can safely ignore it.
|
|
</p>
|
|
</td></tr>
|
|
</table>
|
|
</td></tr>
|
|
</table>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
function buttonHtml(href, label) {
|
|
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:24px 0;">
|
|
<tr><td style="border-radius:5px;background:#111827;">
|
|
<a href="${escHtml(href)}" target="_blank" style="display:inline-block;color:#ffffff;padding:11px 22px;border-radius:5px;text-decoration:none;font-weight:500;font-size:14px;line-height:1;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;">${escHtml(label)}</a>
|
|
</td></tr>
|
|
</table>`;
|
|
}
|
|
|
|
function linkFallback(url) {
|
|
return `<p style="margin:16px 0 4px;font-size:13px;color:#6b7280;line-height:1.5;">If the button doesn’t work, copy this link:</p>
|
|
<p style="margin:0;padding:10px 12px;background:#f9fafb;border:1px solid #e5e7eb;border-radius:4px;font-size:12px;font-family:'SFMono-Regular',Consolas,'Liberation Mono',Menlo,monospace;word-break:break-all;color:#374151;line-height:1.6;">${escHtml(url)}</p>`;
|
|
}
|
|
|
|
function actionEmailHtml(title, body, link, label) {
|
|
return emailWrapper(`<p style="margin:0 0 8px;font-size:20px;font-weight:600;color:#111827;">${escHtml(title)}</p>
|
|
<p style="color:#4b5563;margin:12px 0 20px;line-height:1.6;font-size:14px;">${escHtml(body).replace(/\n/g, '<br>')}</p>
|
|
${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 }));
|
|
}
|
|
}
|
|
|
|
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.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);
|
|
});
|