- Split monolithic server.js (1111 lines) into route modules - Add .env config (port, session secret, quality, limits) - Add SSRF protection for parser/proxy/redirect endpoints - Add optional password auth middleware - Add structured logger (replaces raw fs.appendFileSync) - Add graceful shutdown with timeout - Add extended /health endpoint (uptime, memory, pid) - Add ecosystem.config.js for PM2 (memory limit, restart policy) - Make compress quality configurable (was hardcoded 60) - Expand SVG AI icons library (7 -> 20 icons) - Add dotenv dependency
101 lines
3.2 KiB
JavaScript
101 lines
3.2 KiB
JavaScript
const express = require('express');
|
|
const rateLimit = require('express-rate-limit');
|
|
const path = require('path');
|
|
const { validateUrl } = require('../lib/ssrf');
|
|
|
|
const router = express.Router();
|
|
|
|
// History (per session)
|
|
router.get('/api/history', (req, res) => {
|
|
res.json(req.session.httpHistory || []);
|
|
});
|
|
|
|
router.post('/api/history/add', express.json(), (req, res) => {
|
|
if (!req.session.httpHistory) req.session.httpHistory = [];
|
|
req.session.httpHistory.unshift(req.body);
|
|
if (req.session.httpHistory.length > 30) req.session.httpHistory.length = 30;
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
router.delete('/api/history', (req, res) => {
|
|
req.session.httpHistory = [];
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// Proxy
|
|
const proxyLimiter = rateLimit({
|
|
windowMs: 60 * 1000,
|
|
max: parseInt(process.env.PROXY_RATE_LIMIT_MAX) || 60,
|
|
message: { error: 'Слишком много запросов. Попробуйте через минуту.' },
|
|
});
|
|
|
|
router.post('/api/proxy', proxyLimiter, express.json({ limit: '10mb' }), async (req, res) => {
|
|
const { url, method = 'GET', headers = {}, body, timeout = 30000 } = req.body || {};
|
|
|
|
const check = await validateUrl(url);
|
|
if (!check.safe) return res.status(400).json({ error: check.error });
|
|
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), Math.min(Number(timeout) || 30000, 60000));
|
|
|
|
const fetchOptions = {
|
|
method: method.toUpperCase(),
|
|
headers: Object.fromEntries(
|
|
Object.entries(headers).filter(([k]) => !['host', 'connection', 'transfer-encoding'].includes(k.toLowerCase()))
|
|
),
|
|
signal: controller.signal,
|
|
redirect: 'follow',
|
|
};
|
|
|
|
if (body != null && !['GET', 'HEAD'].includes(method.toUpperCase())) {
|
|
fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body);
|
|
}
|
|
|
|
const response = await fetch(check.url, fetchOptions);
|
|
clearTimeout(timer);
|
|
|
|
const elapsed = Date.now() - startTime;
|
|
const responseHeaders = {};
|
|
response.headers.forEach((value, key) => { responseHeaders[key] = value; });
|
|
|
|
const MAX_SIZE = 10 * 1024 * 1024;
|
|
const reader = response.body?.getReader();
|
|
const chunks = [];
|
|
let totalSize = 0;
|
|
let truncated = false;
|
|
|
|
if (reader) {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
totalSize += value.length;
|
|
if (totalSize > MAX_SIZE) { truncated = true; break; }
|
|
chunks.push(value);
|
|
}
|
|
}
|
|
|
|
const buffer = Buffer.concat(chunks.map(c => Buffer.from(c)));
|
|
const responseBody = buffer.toString('utf-8');
|
|
|
|
res.json({
|
|
status: response.status, statusText: response.statusText,
|
|
headers: responseHeaders, body: responseBody,
|
|
time: elapsed, size: totalSize, truncated, url: response.url,
|
|
});
|
|
} catch (err) {
|
|
const elapsed = Date.now() - startTime;
|
|
if (err.name === 'AbortError') return res.status(408).json({ error: 'Таймаут запроса', time: elapsed });
|
|
res.status(500).json({ error: err.message, time: elapsed });
|
|
}
|
|
});
|
|
|
|
// Page
|
|
router.get('/httpclient', (req, res) => {
|
|
res.sendFile(path.join(__dirname, '..', 'public', 'httpclient.html'));
|
|
});
|
|
|
|
module.exports = router;
|