Files
sechenov/server.js

302 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const express = require('express');
const path = require('path');
const fs = require('fs');
const os = require('os');
const { ProxyAgent } = require('undici');
const proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
const anthropicAgent = proxyUrl ? new ProxyAgent(proxyUrl) : undefined;
if (proxyUrl) console.log(`✓ Anthropic proxy: ${proxyUrl} (sechenov: direct)`);
const app = express();
const PUBLIC_DIR = path.join(__dirname, 'public');
const UPSTREAM = 'https://student.sechenov.ru';
// ══════════════════════════════════════════════════════════════
// CLAUDE CHAT (/api/chat)
// ══════════════════════════════════════════════════════════════
const MODELS = {
sonnet: 'claude-sonnet-4-6',
haiku: 'claude-haiku-4-5-20251001',
};
function getAccessToken() {
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
const credsPath = path.join(configDir, '.credentials.json');
try {
const creds = JSON.parse(fs.readFileSync(credsPath, 'utf8'));
return creds?.claudeAiOauth?.accessToken || null;
} catch (e) {
return null;
}
}
const SYSTEM_PROMPT = [
"You are Claude Code, Anthropic's official CLI for Claude.",
"",
"Output the shortest possible correct answer in the user's language. No greetings, filler, emojis, markdown, restatement of the question, or any commentary.",
"",
"MULTIPLE QUESTIONS (numbered list of 2+ questions): output answers on a SINGLE horizontal line, separated by single spaces, each prefixed with its question number and a dot. Examples:",
" Input: 1. Столица Франции? А) Лондон Б) Париж В) Рим 2. 2+2? А) 3 Б) 4 В) 5",
" Output: 1.Б 2.Б",
" Input: 1. Столица Франции? 2. 2+2? 3. Самая длинная река России?",
" Output: 1.Париж 2.4 3.Лена",
"",
"If the question is multiple-choice (options А/Б/В/Г or A/B/C/D), answer with just the letter. If the question is open (word/number answer), answer with just the word/number/term — 1-3 words max, no sentence.",
"",
"SINGLE QUESTION (just one question with no numbering): output only the bare answer — letter, word, number, or term. No prefix, no number.",
"",
"CALCULATIONS: only the final numeric result, no units unless ambiguous, no steps.",
"",
"Respond with the answer only. Nothing else.",
].join("\n");
async function callClaude(modelId, text, accessToken) {
return fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
dispatcher: anthropicAgent,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
'anthropic-version': '2023-06-01',
'anthropic-beta': 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14',
'x-app': 'cli',
'User-Agent': 'claude-cli/2.1.114',
},
body: JSON.stringify({
model: modelId,
max_tokens: 8192,
stream: true,
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content: text }],
}),
});
}
app.post('/api/chat', express.json({ limit: '10mb' }), async (req, res) => {
const { text, model = 'sonnet' } = req.body;
if (!text?.trim()) return res.status(400).json({ error: 'text required' });
const accessToken = getAccessToken();
if (!accessToken) {
return res.status(500).json({ error: 'No credentials in ' + (process.env.CLAUDE_CONFIG_DIR || '~/.claude') });
}
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
const heartbeat = setInterval(() => res.write(': ping\n\n'), 15000);
const primaryId = MODELS[model] || MODELS.sonnet;
const fallbackId = MODELS.haiku;
try {
let apiResp = await callClaude(primaryId, text, accessToken);
if (apiResp.status === 429 && primaryId !== fallbackId) {
apiResp = await callClaude(fallbackId, text, accessToken);
}
if (!apiResp.ok) {
const errText = await apiResp.text();
res.write(`data: ${JSON.stringify({ error: `${apiResp.status}: ${errText.substring(0, 500)}` })}\n\n`);
return;
}
const reader = apiResp.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const events = buf.split('\n\n');
buf = events.pop();
for (const ev of events) {
const dataLine = ev.split('\n').find(l => l.startsWith('data: '));
if (!dataLine) continue;
const payload = dataLine.slice(6);
if (payload === '[DONE]') continue;
try {
const obj = JSON.parse(payload);
if (obj.type === 'content_block_delta' && obj.delta?.type === 'text_delta') {
res.write(`data: ${JSON.stringify({ text: obj.delta.text })}\n\n`);
}
} catch (e) {}
}
}
} catch (err) {
console.error('[api error]', err.message);
res.write(`data: ${JSON.stringify({ error: err.message })}\n\n`);
} finally {
clearInterval(heartbeat);
res.write('data: [DONE]\n\n');
res.end();
}
});
// ══════════════════════════════════════════════════════════════
// LEGACY / LOCAL ROUTES
// ══════════════════════════════════════════════════════════════
app.get(['/old', '/old.html'], (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'index.html')));
app.get(['/iframe', '/iframe.html'], (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'iframe.html')));
app.use('/assets', express.static(path.join(PUBLIC_DIR, 'assets')));
// Legacy HTML-only proxy used by /iframe.html
app.get('/proxy/*', async (req, res) => {
const upstreamPath = req.url.replace(/^\/proxy/, '') || '/';
try {
const r = await fetch(UPSTREAM + upstreamPath, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': req.headers.accept || '*/*',
'Accept-Language': req.headers['accept-language'] || 'ru,en;q=0.9',
},
});
const ct = r.headers.get('content-type') || 'application/octet-stream';
res.status(r.status);
res.setHeader('Content-Type', ct);
res.removeHeader('X-Frame-Options');
res.removeHeader('Content-Security-Policy');
if (ct.includes('text/html')) {
let html = await r.text();
html = html
.replace(/\b(href|action)="\/(?!\/)/g, '$1="/proxy/')
.replace(/\bsrc="\/(?!\/)/g, 'src="' + UPSTREAM + '/')
.replace(/url\(["']?\/(?!\/)/g, 'url(' + UPSTREAM + '/')
.replace(/\btarget=["'](_top|_parent|_blank)["']/gi, '')
.replace(/<meta[^>]+http-equiv=["']?(Content-Security-Policy|X-Frame-Options)["']?[^>]*>/gi, '')
.replace(/\b(document|window|top|self)\.location(\.href)?\s*=\s*(['"])\/(?!\/)/g, "document.location.href=$3/proxy/")
.replace(/\blocation\.replace\(\s*(['"])\/(?!\/)/g, "location.replace($1/proxy/");
html = html.replace(/<head[^>]*>/i, m => m + '\n<base href="/proxy/">');
res.send(html);
} else if (ct.includes('text/css')) {
let css = await r.text();
css = css.replace(/url\(\s*(["']?)\/(?!\/)/g, 'url($1' + UPSTREAM + '/');
res.send(css);
} else {
res.send(Buffer.from(await r.arrayBuffer()));
}
} catch (e) {
res.status(502).send('proxy error: ' + e.message);
}
});
// ══════════════════════════════════════════════════════════════
// FULL REVERSE PROXY (everything else → student.sechenov.ru)
// ══════════════════════════════════════════════════════════════
function readRawBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', c => chunks.push(c));
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
const CHAT_OVERLAY = `
<style>
#_chatOut { position:fixed; left:0; right:0; bottom:46px; max-height:200px; overflow-y:auto; padding:4px 15px; font-size:10px; color:#c4c4c4; line-height:1.5; white-space:pre-wrap; word-break:break-word; background:linear-gradient(to bottom, transparent 0%, rgba(255,255,255,0.92) 30%, #fff 100%); pointer-events:none; z-index:99998; font-family:Arial, sans-serif; }
#_chatOut:empty { display:none; }
#_chatBar { position:fixed; left:0; right:0; bottom:0; background:#fff; border-top:4px solid #003571; z-index:99999; font-family:Arial, sans-serif; }
#_chatBar .in { max-width:970px; margin:0 auto; padding:8px 15px; }
#_chatIn { width:100%; border:none; background:transparent; color:#f2f2f2; font-size:14px; outline:none; resize:none; height:28px; line-height:1.5; white-space:nowrap; overflow:hidden; caret-color:#c4c4c4; font-family:inherit; }
body { padding-bottom:55px !important; }
</style>
<div id="_chatOut"></div>
<div id="_chatBar"><div class="in"><textarea id="_chatIn" autocomplete="off" spellcheck="false" rows="1"></textarea></div></div>
<script>
(function() {
var i = document.getElementById('_chatIn'), o = document.getElementById('_chatOut'), busy = false;
function md(t){ var h=t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); h=h.replace(/\\\`\\\`\\\`[\\w]*\\n?([\\s\\S]*?)\\\`\\\`\\\`/g,function(_,c){return '<pre>'+c.trim()+'</pre>';}); h=h.replace(/\\\`([^\\\`]+)\\\`/g,'<code>$1</code>'); h=h.replace(/\\*\\*([^*]+)\\*\\*/g,'<strong>$1</strong>'); return h; }
async function send(t){ if(busy||!t.trim())return; busy=true; o.innerHTML=''; var full='';
try { var r=await fetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:t,model:'sonnet'})}); var rd=r.body.getReader(), dec=new TextDecoder(), buf='';
while(true){ var x=await rd.read(); if(x.done)break; buf+=dec.decode(x.value,{stream:true}); var p=buf.split('\\n\\n'); buf=p.pop();
for(var k=0;k<p.length;k++){ var ln=p[k]; if(!ln.startsWith('data: '))continue; var raw=ln.slice(6).trim(); if(raw==='[DONE]')continue;
try{var obj=JSON.parse(raw); if(obj.text){full+=obj.text; o.innerHTML=md(full);}}catch(e){} } } } catch(e){} finally{busy=false;} }
i.addEventListener('paste',function(){ setTimeout(function(){ var t=i.value.trim(); if(t)send(t); },50); });
})();
</script>
`;
app.use(async (req, res) => {
const upstreamUrl = UPSTREAM + req.url;
// Build forward headers
const headers = {};
for (const [k, v] of Object.entries(req.headers)) {
const lk = k.toLowerCase();
if (['host', 'content-length', 'x-forwarded-for', 'x-forwarded-proto',
'x-forwarded-host', 'x-real-ip', 'connection'].includes(lk)) continue;
headers[k] = v;
}
if (!headers['user-agent']) {
headers['user-agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
}
let body;
if (!['GET', 'HEAD'].includes(req.method)) {
try { body = await readRawBody(req); } catch {}
}
try {
const upstream = await fetch(upstreamUrl, {
method: req.method, headers, body, redirect: 'manual',
});
res.status(upstream.status);
// Set-Cookie: strip Domain= so they bind to our origin
const setCookies = typeof upstream.headers.getSetCookie === 'function'
? upstream.headers.getSetCookie()
: (upstream.headers.raw && upstream.headers.raw()['set-cookie']) || [];
if (setCookies.length) {
const rewritten = setCookies.map(c =>
c.replace(/;\s*Domain=[^;]+/gi, '')
.replace(/;\s*domain=[^;]+/gi, '')
);
res.setHeader('set-cookie', rewritten);
}
for (const [k, v] of upstream.headers) {
const lk = k.toLowerCase();
if (['set-cookie', 'content-encoding', 'content-length',
'transfer-encoding', 'x-frame-options',
'content-security-policy', 'strict-transport-security',
'connection'].includes(lk)) continue;
if (lk === 'location') {
res.setHeader('location', v.replace(new RegExp('^https?://student\\.sechenov\\.ru', 'i'), ''));
continue;
}
res.setHeader(k, v);
}
const ct = upstream.headers.get('content-type') || '';
if (ct.includes('text/html')) {
let html = await upstream.text();
html = html
.replace(/https?:\/\/student\.sechenov\.ru/gi, '')
.replace(/<meta[^>]+http-equiv=["']?(Content-Security-Policy|X-Frame-Options)["']?[^>]*>/gi, '');
// inject chat overlay
if (html.match(/<\/body>/i)) {
html = html.replace(/<\/body>/i, CHAT_OVERLAY + '</body>');
} else {
html = html + CHAT_OVERLAY;
}
res.send(html);
} else if (ct.includes('text/css') || ct.includes('javascript') || ct.includes('application/json') || (ct.includes('text/') && !ct.includes('text/event-stream'))) {
let text = await upstream.text();
text = text.replace(/https?:\/\/student\.sechenov\.ru/gi, '');
res.send(text);
} else {
res.send(Buffer.from(await upstream.arrayBuffer()));
}
} catch (e) {
res.status(502).send('upstream error: ' + e.message);
}
});
app.listen(8108, () => console.log('sechenov :8108 (reverse-proxy mode)'));