feat: full reverse-proxy to student.sechenov.ru with chat overlay injection
This commit is contained in:
168
server.js
168
server.js
@@ -11,8 +11,12 @@ if (proxyUrl) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json({ limit: '10mb' }));
|
const PUBLIC_DIR = path.join(__dirname, 'public');
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
const UPSTREAM = 'https://student.sechenov.ru';
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// CLAUDE CHAT (/api/chat)
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const MODELS = {
|
const MODELS = {
|
||||||
sonnet: 'claude-sonnet-4-6',
|
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;
|
const { text, model = 'sonnet' } = req.body;
|
||||||
if (!text?.trim()) return res.status(400).json({ error: 'text required' });
|
if (!text?.trim()) return res.status(400).json({ error: 'text required' });
|
||||||
|
|
||||||
@@ -86,28 +90,22 @@ app.post('/api/chat', async (req, res) => {
|
|||||||
res.flushHeaders();
|
res.flushHeaders();
|
||||||
|
|
||||||
const heartbeat = setInterval(() => res.write(': ping\n\n'), 15000);
|
const heartbeat = setInterval(() => res.write(': ping\n\n'), 15000);
|
||||||
|
|
||||||
const primaryId = MODELS[model] || MODELS.sonnet;
|
const primaryId = MODELS[model] || MODELS.sonnet;
|
||||||
const fallbackId = MODELS.haiku;
|
const fallbackId = MODELS.haiku;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let apiResp = await callClaude(primaryId, text, accessToken);
|
let apiResp = await callClaude(primaryId, text, accessToken);
|
||||||
|
|
||||||
// Fallback to Haiku on rate limit
|
|
||||||
if (apiResp.status === 429 && primaryId !== fallbackId) {
|
if (apiResp.status === 429 && primaryId !== fallbackId) {
|
||||||
apiResp = await callClaude(fallbackId, text, accessToken);
|
apiResp = await callClaude(fallbackId, text, accessToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!apiResp.ok) {
|
if (!apiResp.ok) {
|
||||||
const errText = await apiResp.text();
|
const errText = await apiResp.text();
|
||||||
res.write(`data: ${JSON.stringify({ error: `${apiResp.status}: ${errText.substring(0, 500)}` })}\n\n`);
|
res.write(`data: ${JSON.stringify({ error: `${apiResp.status}: ${errText.substring(0, 500)}` })}\n\n`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reader = apiResp.body.getReader();
|
const reader = apiResp.body.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buf = '';
|
let buf = '';
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) break;
|
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) => {
|
app.get('/proxy/*', async (req, res) => {
|
||||||
const upstreamPath = req.url.replace(/^\/proxy/, '') || '/';
|
const upstreamPath = req.url.replace(/^\/proxy/, '') || '/';
|
||||||
const upstreamUrl = 'https://student.sechenov.ru' + upstreamPath;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const r = await fetch(upstreamUrl, {
|
const r = await fetch(UPSTREAM + upstreamPath, {
|
||||||
headers: {
|
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',
|
'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': req.headers.accept || '*/*',
|
||||||
'Accept-Language': req.headers['accept-language'] || 'ru,en;q=0.9',
|
'Accept-Language': req.headers['accept-language'] || 'ru,en;q=0.9',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const ct = r.headers.get('content-type') || 'application/octet-stream';
|
const ct = r.headers.get('content-type') || 'application/octet-stream';
|
||||||
res.status(r.status);
|
res.status(r.status);
|
||||||
res.setHeader('Content-Type', ct);
|
res.setHeader('Content-Type', ct);
|
||||||
// strip framing restrictions
|
|
||||||
res.removeHeader('X-Frame-Options');
|
res.removeHeader('X-Frame-Options');
|
||||||
res.removeHeader('Content-Security-Policy');
|
res.removeHeader('Content-Security-Policy');
|
||||||
|
|
||||||
if (ct.includes('text/html')) {
|
if (ct.includes('text/html')) {
|
||||||
let html = await r.text();
|
let html = await r.text();
|
||||||
html = html
|
html = html
|
||||||
// navigation → stay inside iframe via our proxy
|
|
||||||
.replace(/\b(href|action)="\/(?!\/)/g, '$1="/proxy/')
|
.replace(/\b(href|action)="\/(?!\/)/g, '$1="/proxy/')
|
||||||
// assets load directly from upstream (faster, no rewrite needed)
|
.replace(/\bsrc="\/(?!\/)/g, 'src="' + UPSTREAM + '/')
|
||||||
.replace(/\bsrc="\/(?!\/)/g, 'src="https://student.sechenov.ru/')
|
.replace(/url\(["']?\/(?!\/)/g, 'url(' + UPSTREAM + '/')
|
||||||
.replace(/url\(["']?\/(?!\/)/g, 'url(https://student.sechenov.ru/')
|
|
||||||
// strip framing-break targets
|
|
||||||
.replace(/\btarget=["'](_top|_parent|_blank)["']/gi, '')
|
.replace(/\btarget=["'](_top|_parent|_blank)["']/gi, '')
|
||||||
// neutralize meta CSP/X-Frame
|
|
||||||
.replace(/<meta[^>]+http-equiv=["']?(Content-Security-Policy|X-Frame-Options)["']?[^>]*>/gi, '')
|
.replace(/<meta[^>]+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(/\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/");
|
.replace(/\blocation\.replace\(\s*(['"])\/(?!\/)/g, "location.replace($1/proxy/");
|
||||||
// inject a <base> so unrewritten relative URLs default to proxy root
|
|
||||||
html = html.replace(/<head[^>]*>/i, m => m + '\n<base href="/proxy/">');
|
html = html.replace(/<head[^>]*>/i, m => m + '\n<base href="/proxy/">');
|
||||||
res.send(html);
|
res.send(html);
|
||||||
} else if (ct.includes('text/css')) {
|
} else if (ct.includes('text/css')) {
|
||||||
let css = await r.text();
|
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);
|
res.send(css);
|
||||||
} else {
|
} else {
|
||||||
// stream binary/text as-is
|
res.send(Buffer.from(await r.arrayBuffer()));
|
||||||
const buf = Buffer.from(await r.arrayBuffer());
|
|
||||||
res.send(buf);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(502).send('proxy error: ' + e.message);
|
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 = `
|
||||||
|
<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,'&').replace(/</g,'<').replace(/>/g,'>'); 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)'));
|
||||||
|
|||||||
Reference in New Issue
Block a user