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', '.css': 'text/css; charset=utf-8', '.js': 'application/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', }; 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 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; 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, 404, 'Not found', 'text/plain; charset=utf-8'); send(res, 200, data, types[path.extname(target)] || 'application/octet-stream', req.method === 'HEAD'); }); } async function proxyChat(req, res) { try { const payload = JSON.parse(await readBody(req)); const baseUrl = String(payload.baseUrl || '').replace(/\/+$/, ''); if (!/^https?:\/\//.test(baseUrl)) throw new Error('A valid API base URL is required'); if (!payload.body || typeof payload.body !== 'object') throw new Error('Missing chat completion body'); const headers = { 'content-type': 'application/json' }; if (payload.apiKey) headers.authorization = `Bearer ${payload.apiKey}`; const upstream = await fetch(`${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 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'); }).listen(port, () => { console.log(`Calorie AI web listening on ${port}`); });