nextcloud-install/lib/node-server.js
2026-06-24 09:23:58 +03:00

190 lines
5.7 KiB
JavaScript

/**
* Минимальный HTTP-сервер без Express: node:http / node:http2
*/
import fs from 'node:fs';
import path from 'node:path';
import http from 'node:http';
import http2 from 'node:http2';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
const MIME = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.ico': 'image/x-icon',
'.svg': 'image/svg+xml',
'.png': 'image/png',
};
export function loadEnv(envPath) {
if (!fs.existsSync(envPath)) return;
for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
const i = t.indexOf('=');
if (i < 0) continue;
const key = t.slice(0, i).trim();
let val = t.slice(i + 1).trim();
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
}
if (!(key in process.env)) process.env[key] = val;
}
}
export function sendJson(res, status, data) {
const body = JSON.stringify(data);
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
});
res.end(body);
}
export function readJsonBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
if (!chunks.length) return resolve({});
try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
} catch {
reject(new Error('Invalid JSON'));
}
});
req.on('error', reject);
});
}
export function parseCookies(req) {
const raw = req.headers.cookie || '';
const out = {};
for (const part of raw.split(';')) {
const [k, ...v] = part.trim().split('=');
if (k) out[k] = decodeURIComponent(v.join('='));
}
return out;
}
export function setCookie(res, name, value, opts = {}) {
const parts = [`${name}=${encodeURIComponent(value)}`, 'Path=/', 'HttpOnly', 'SameSite=Lax'];
if (opts.maxAge) parts.push(`Max-Age=${opts.maxAge}`);
if (opts.clear) parts.push('Max-Age=0');
const prev = res.getHeader('Set-Cookie');
const next = Array.isArray(prev) ? [...prev, parts.join('; ')] : prev ? [prev, parts.join('; ')] : [parts.join('; ')];
res.setHeader('Set-Cookie', next);
}
export function signSession(data, secret) {
const payload = Buffer.from(JSON.stringify(data)).toString('base64url');
const sig = crypto.createHmac('sha256', secret).update(payload).digest('base64url');
return `${payload}.${sig}`;
}
export function verifySession(token, secret) {
if (!token) return null;
const dot = token.lastIndexOf('.');
if (dot < 0) return null;
const payload = token.slice(0, dot);
const sig = token.slice(dot + 1);
const expected = crypto.createHmac('sha256', secret).update(payload).digest('base64url');
if (sig !== expected) return null;
try {
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
} catch {
return null;
}
}
export function clientIp(req) {
return (req.socket?.remoteAddress || '').replace('::ffff:', '');
}
export function matchRoute(method, pathname, routes) {
for (const r of routes) {
if (r.method !== method) continue;
const pattern = r.path.replace(/:([^/]+)/g, '([^/]+)');
const re = new RegExp(`^${pattern}$`);
const m = pathname.match(re);
if (!m) continue;
const keys = [...r.path.matchAll(/:([^/]+)/g)].map((x) => x[1]);
const params = {};
keys.forEach((k, i) => { params[k] = decodeURIComponent(m[i + 1]); });
return { handler: r.handler, params };
}
return null;
}
export async function serveStatic(publicDir, pathname) {
const base = path.resolve(publicDir);
const rel = pathname === '/' ? 'index.html' : pathname.replace(/^\//, '');
const filePath = path.resolve(base, rel);
if (!filePath.startsWith(base + path.sep) && filePath !== base) return null;
try {
const stat = fs.statSync(filePath);
if (!stat.isFile()) return null;
const ext = path.extname(filePath);
return {
body: fs.readFileSync(filePath),
type: MIME[ext] || 'application/octet-stream',
};
} catch {
return null;
}
}
/** Rate limit: max запросов за windowMs на IP */
export function createRateLimiter({ windowMs, max }) {
const hits = new Map();
return (ip) => {
const now = Date.now();
let entry = hits.get(ip);
if (!entry || now - entry.start > windowMs) {
entry = { start: now, count: 0 };
hits.set(ip, entry);
}
entry.count += 1;
return entry.count <= max;
};
}
/**
* @param {function} onRequest async (req, res, ctx) => void
* @param {{ http2?: boolean, tls?: { key, cert }, host, port }} opts
*/
export function listen(onRequest, opts) {
const handler = async (req, res) => {
try {
await onRequest(req, res);
} catch (e) {
if (!res.headersSent) sendJson(res, 500, { error: e.message || 'Internal error' });
}
};
let server;
if (opts.tls?.key && opts.tls?.cert) {
server = http2.createSecureServer(
{ key: fs.readFileSync(opts.tls.key), cert: fs.readFileSync(opts.tls.cert) },
handler
);
} else if (opts.http2) {
server = http2.createServer(handler);
} else {
server = http.createServer(handler);
}
server.listen(opts.port, opts.host, () => {
const proto = opts.tls ? 'https' : 'http';
const h2 = opts.http2 || opts.tls ? ' HTTP/2' : '';
console.log(`Listening → ${proto}://${opts.host}:${opts.port}${h2}`);
});
return server;
}
export function dirFromMeta(metaUrl) {
return path.dirname(fileURLToPath(metaUrl));
}