From 56371822dc408e6f7622ebfdaca5cc2788691017 Mon Sep 17 00:00:00 2001 From: aedes Date: Tue, 21 Apr 2026 17:17:00 +0300 Subject: [PATCH] feat: full reverse-proxy to student.sechenov.ru with chat overlay injection --- server.js | 168 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 138 insertions(+), 30 deletions(-) diff --git a/server.js b/server.js index bddd008..f7b4a56 100644 --- a/server.js +++ b/server.js @@ -11,8 +11,12 @@ if (proxyUrl) { } const app = express(); -app.use(express.json({ limit: '10mb' })); -app.use(express.static(path.join(__dirname, 'public'))); +const PUBLIC_DIR = path.join(__dirname, 'public'); +const UPSTREAM = 'https://student.sechenov.ru'; + +// ══════════════════════════════════════════════════════════════ +// CLAUDE CHAT (/api/chat) +// ══════════════════════════════════════════════════════════════ const MODELS = { sonnet: 'claude-sonnet-4-6', @@ -71,7 +75,7 @@ async function callClaude(modelId, text, accessToken) { }); } -app.post('/api/chat', async (req, res) => { +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' }); @@ -86,28 +90,22 @@ app.post('/api/chat', async (req, res) => { 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); - - // Fallback to Haiku on rate limit 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; @@ -137,58 +135,168 @@ app.post('/api/chat', async (req, res) => { } }); -// ── iframe proxy: fetch student.sechenov.ru, strip X-Frame-Options, rewrite URLs ── +// ══════════════════════════════════════════════════════════════ +// 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/, '') || '/'; - const upstreamUrl = 'https://student.sechenov.ru' + upstreamPath; - try { - const r = await fetch(upstreamUrl, { + 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); - // strip framing restrictions res.removeHeader('X-Frame-Options'); res.removeHeader('Content-Security-Policy'); - if (ct.includes('text/html')) { let html = await r.text(); html = html - // navigation → stay inside iframe via our proxy .replace(/\b(href|action)="\/(?!\/)/g, '$1="/proxy/') - // assets load directly from upstream (faster, no rewrite needed) - .replace(/\bsrc="\/(?!\/)/g, 'src="https://student.sechenov.ru/') - .replace(/url\(["']?\/(?!\/)/g, 'url(https://student.sechenov.ru/') - // strip framing-break targets + .replace(/\bsrc="\/(?!\/)/g, 'src="' + UPSTREAM + '/') + .replace(/url\(["']?\/(?!\/)/g, 'url(' + UPSTREAM + '/') .replace(/\btarget=["'](_top|_parent|_blank)["']/gi, '') - // neutralize meta CSP/X-Frame .replace(/]+http-equiv=["']?(Content-Security-Policy|X-Frame-Options)["']?[^>]*>/gi, '') - // intercept inline JS redirects .replace(/\b(document|window|top|self)\.location(\.href)?\s*=\s*(['"])\/(?!\/)/g, "document.location.href=$3/proxy/") - .replace(/\blocation\.href\s*=\s*(['"])\/(?!\/)/g, "location.href=$1/proxy/") .replace(/\blocation\.replace\(\s*(['"])\/(?!\/)/g, "location.replace($1/proxy/"); - // inject a so unrewritten relative URLs default to proxy root 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($1https://student.sechenov.ru/'); + css = css.replace(/url\(\s*(["']?)\/(?!\/)/g, 'url($1' + UPSTREAM + '/'); res.send(css); } else { - // stream binary/text as-is - const buf = Buffer.from(await r.arrayBuffer()); - res.send(buf); + res.send(Buffer.from(await r.arrayBuffer())); } } catch (e) { res.status(502).send('proxy error: ' + e.message); } }); -app.listen(8108, () => console.log('sechenov :8108')); +// ══════════════════════════════════════════════════════════════ +// 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 = ` + +
+
+ +`; + +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(/]+http-equiv=["']?(Content-Security-Policy|X-Frame-Options)["']?[^>]*>/gi, ''); + // inject chat overlay + if (html.match(/<\/body>/i)) { + html = html.replace(/<\/body>/i, CHAT_OVERLAY + ''); + } 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)'));