const express = require('express');
const path = require('path');
const fs = require('fs');
const os = require('os');
const { ProxyAgent, Agent, setGlobalDispatcher } = require('undici');
const proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
const directAgent = new Agent();
const anthropicAgent = proxyUrl ? new ProxyAgent(proxyUrl) : directAgent;
// Force global default = DIRECT. Anthropic calls override with their own dispatcher.
setGlobalDispatcher(directAgent);
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
// ══════════════════════════════════════════════════════════════
// Main page: local clone (reverse-proxy disabled — Sechenov banned our IPs)
app.use(express.static(PUBLIC_DIR));
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')));
// 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(/]+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(/
]*>/i, m => m + '\n');
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 = `
`;
// ══════════════════════════════════════════════════════════════
// MIRROR — one-time asset mirror + in-memory HTML cache
// Goal: cache everything locally, refresh HTML rarely, never
// hammer sechenov. Steady-state: ~1 upstream request / 30 min.
// ══════════════════════════════════════════════════════════════
const MIRROR_DIR = path.join(PUBLIC_DIR, 'assets', 'mirror');
const MIRROR_TTL_MS = 30 * 60 * 1000;
const MIRROR_MIN_GAP = 60 * 1000;
const UPSTREAM_DELAY = 500;
const BROWSER_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
let cachedMirrorHtml = null;
let mirrorInProgress = false;
let lastMirrorAt = 0;
let mirrorSessionCookies = '';
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function safeFetch(url, extraHeaders = {}) {
const h = {
'User-Agent': BROWSER_UA,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Accept-Language': 'ru,en;q=0.9',
'Referer': UPSTREAM + '/',
...extraHeaders,
};
if (mirrorSessionCookies) h['Cookie'] = mirrorSessionCookies;
return fetch(url, { dispatcher: directAgent, headers: h });
}
function extractAssetUrls(html) {
const urls = new Set();
const patterns = [
/(?:href|src)="(\/[^"\s]+)"/g,
/url\(["']?(\/[^"'\s\)]+)/g,
];
for (const p of patterns) {
let m;
while ((m = p.exec(html)) !== null) {
const u = m[1];
if (u.startsWith('//')) continue;
if (u.startsWith('/api/') || u.startsWith('/assets/') || u.startsWith('/proxy/')) continue;
urls.add(u);
}
}
return [...urls];
}
async function mirrorAsset(urlPath) {
const pure = urlPath.split(/[?#]/)[0];
const local = path.join(MIRROR_DIR, pure);
try { await fs.promises.stat(local); return false; } catch {}
const r = await safeFetch(UPSTREAM + urlPath);
if (!r.ok) { console.warn(`[mirror] ${r.status} ${urlPath}`); return false; }
const ct = r.headers.get('content-type') || '';
let buf;
if (ct.includes('text/css')) {
let css = await r.text();
css = css.replace(/url\(\s*(["']?)\/(?!\/)/g, 'url($1/assets/mirror/');
buf = Buffer.from(css, 'utf8');
} else {
buf = Buffer.from(await r.arrayBuffer());
}
await fs.promises.mkdir(path.dirname(local), { recursive: true });
await fs.promises.writeFile(local, buf);
console.log(`[mirror] + ${pure} (${buf.length}b)`);
await sleep(UPSTREAM_DELAY);
return true;
}
async function refreshMirror() {
if (mirrorInProgress) return;
if (Date.now() - lastMirrorAt < MIRROR_MIN_GAP && cachedMirrorHtml) return;
mirrorInProgress = true;
lastMirrorAt = Date.now();
try {
console.log('[mirror] fetching HTML…');
const r = await safeFetch(UPSTREAM + '/auth.php');
if (!r.ok) throw new Error('upstream ' + r.status);
const setCookies = typeof r.headers.getSetCookie === 'function' ? r.headers.getSetCookie() : [];
if (setCookies.length) {
mirrorSessionCookies = setCookies.map(c => c.split(';')[0]).join('; ');
}
let html = await r.text();
const assetUrls = extractAssetUrls(html);
console.log(`[mirror] HTML ${html.length}b, ${assetUrls.length} assets referenced`);
let downloaded = 0;
for (const u of assetUrls) {
try { if (await mirrorAsset(u)) downloaded++; } catch (e) { console.warn('[mirror]', u, e.message); }
}
console.log(`[mirror] downloaded ${downloaded} new assets (cached: ${assetUrls.length - downloaded})`);
html = html
.replace(/(href|src)="\/(?!\/)/g, '$1="/assets/mirror/')
.replace(/url\(["']?\/(?!\/)/g, 'url(/assets/mirror/')
.replace(/https?:\/\/student\.sechenov\.ru/gi, '')
.replace(/]+http-equiv=["']?(Content-Security-Policy|X-Frame-Options)["']?[^>]*>/gi, '')
.replace(/\btarget=["'](_top|_parent|_blank)["']/gi, '');
html = html.replace(/<\/body>/i, CHAT_OVERLAY + '');
cachedMirrorHtml = html;
console.log('[mirror] ready');
} catch (e) {
console.error('[mirror fail]', e.message);
} finally {
mirrorInProgress = false;
}
}
setTimeout(() => refreshMirror(), 3000);
setInterval(() => refreshMirror(), MIRROR_TTL_MS);
const serveMirror = (req, res) => {
if (!cachedMirrorHtml) return res.status(503).send('mirror initializing — try again in 60s');
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(cachedMirrorHtml);
};
// Entry points that render the cached mirror HTML (any HTTP method)
app.all(['/m', '/m.html', '/mirror'], serveMirror);
app.all(['/auth.php', '/index.php'], serveMirror);
app.all(['/assets/mirror', '/assets/mirror/', '/assets/mirror/auth.php', '/assets/mirror/index.php'], serveMirror);
app.use('/assets/mirror', express.static(MIRROR_DIR));
// Reverse-proxy disabled: Sechenov banned us by IP. Left here for reference.
app.use('/_disabled_reverse_proxy', 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(/]+http-equiv=["']?(Content-Security-Policy|X-Frame-Options)["']?[^>]*>/gi, '');
// inject chat overlay
if (html.match(/<\/body>/i)) {
html = html.replace(/<\/body>/i, CHAT_OVERLAY + '