250 lines
9.8 KiB
JavaScript
250 lines
9.8 KiB
JavaScript
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 = 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 sessionCookieName = 'calorie_ai_session';
|
|
const sessionSeconds = 60 * 60 * 24 * 7;
|
|
const auth = loadAuthConfig();
|
|
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 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') && (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 proxyModels(req, res) {
|
|
try {
|
|
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 deduped = Array.from(new Map(models.filter(m => m.id).map(m => [m.id, m])).values())
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
if (!deduped.some(model => model.id === aiConfig.defaultModel)) deduped.unshift({ id: aiConfig.defaultModel, name: aiConfig.defaultModel });
|
|
send(res, 200, JSON.stringify({ models: deduped, defaultModel: aiConfig.defaultModel }));
|
|
} 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');
|
|
}
|
|
|
|
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 === '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' && 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}`);
|
|
});
|