calorie-ai-android/web/server.js
Daniel 2e2ba12cf3
All checks were successful
Android CI / build (push) Successful in 1m6s
Web CI / test (push) Successful in 50s
Update web model management and meal diary
2026-05-19 19:47:30 +02:00

386 lines
15 KiB
JavaScript

const http = require('http');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const argon2 = require('argon2');
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 sessionCookieName = 'calorie_ai_session';
const sessionSeconds = 60 * 60 * 24 * 7;
let auth;
let modelConfig;
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) {
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);
});
}
function readJsonFile(file, fallback) {
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
}
function writePrivateJson(file, value) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify(value, null, 2));
fs.chmodSync(file, 0o600);
}
async function hashPassword(password) {
return argon2.hash(password, { type: argon2.argon2id });
}
async function loadAuthConfig() {
let stored = {};
stored = readJsonFile(authFile, {});
const user = process.env.CALORIE_AI_WEB_USER || stored.user || 'admin';
if (process.env.CALORIE_AI_WEB_PASSWORD && process.env.CALORIE_AI_SESSION_SECRET) {
return {
user,
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) {
writePrivateJson(authFile, {
user,
passwordHash,
sessionSecret,
createdAt: stored.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
...(plainPassword && !stored.passwordHash ? { initialPassword: plainPassword } : {}),
});
console.log(`Calorie AI auth is stored at ${authFile}`);
}
return { user, passwordHash, sessionSecret };
}
function loadModelConfig() {
const stored = readJsonFile(modelsFile, {});
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 (!fs.existsSync(modelsFile)) saveModelConfig(config);
return config;
}
function saveModelConfig(config = modelConfig) {
writePrivateJson(modelsFile, { ...config, updatedAt: new Date().toISOString() });
}
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'; 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') && (url === '/login' || 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;
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;
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 < 12) return send(res, 400, JSON.stringify({ error: 'Use at least 12 characters for the new password' }));
auth.passwordHash = await hashPassword(payload.newPassword);
writePrivateJson(authFile, { user: auth.user, passwordHash: auth.passwordHash, sessionSecret: auth.sessionSecret, updatedAt: new Date().toISOString() });
send(res, 200, JSON.stringify({ ok: true }));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
}
}
async function resetPassword(req, res) {
try {
const generatedPassword = crypto.randomBytes(18).toString('base64url');
auth.passwordHash = await hashPassword(generatedPassword);
writePrivateJson(authFile, { user: auth.user, passwordHash: auth.passwordHash, sessionSecret: auth.sessionSecret, updatedAt: new Date().toISOString() });
send(res, 200, JSON.stringify({ ok: true, password: generatedPassword }));
} 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 handleRequest(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');
}
if (authenticated && (req.method === 'GET' || req.method === 'HEAD') && url === '/login') 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 === 'POST' && url === '/api/password/change') return changePassword(req, res);
if (req.method === 'POST' && url === '/api/password/reset') return resetPassword(req, res);
if (req.method === 'GET' && url === '/api/session') return send(res, 200, JSON.stringify({ user: auth.user }));
if (req.method === 'POST' && url === '/api/models') return proxyModels(req, res);
if (req.method === 'POST' && url === '/api/models/search') return searchModels(req, res);
if (req.method === 'POST' && url === '/api/models/add') return addModel(req, res);
if (req.method === 'POST' && url === '/api/models/remove') return removeModel(req, res);
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');
}
async function start() {
auth = await loadAuthConfig();
modelConfig = loadModelConfig();
http.createServer((req, res) => handleRequest(req, res).catch(error => send(res, 500, JSON.stringify({ error: error.message })))).listen(port, () => {
console.log(`Calorie AI web listening on ${port}`);
});
}
start().catch(error => {
console.error(error);
process.exit(1);
});