All checks were successful
Automated Container Build / build-and-push (push) Successful in 17s
422 lines
14 KiB
JavaScript
422 lines
14 KiB
JavaScript
const express = require('express');
|
|
const multer = require('multer');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const { v4: uuidv4 } = require('uuid');
|
|
const { PDFDocument } = require('pdf-lib');
|
|
const { WebSocketServer } = require('ws');
|
|
const http = require('http');
|
|
|
|
// ── UA Parsing Helper ──────────────────────────────────────
|
|
|
|
function parseUserAgent(ua) {
|
|
if (!ua) return { browser: 'Unknown', os: 'Unknown' };
|
|
|
|
let browser = 'Unknown';
|
|
if (ua.includes('Edg/')) browser = 'Edge';
|
|
else if (ua.includes('OPR/') || ua.includes('Opera')) browser = 'Opera';
|
|
else if (ua.includes('Chrome/')) browser = 'Chrome';
|
|
else if (ua.includes('Safari/') && !ua.includes('Chrome')) browser = 'Safari';
|
|
else if (ua.includes('Firefox/')) browser = 'Firefox';
|
|
else if (ua.includes('MSIE') || ua.includes('Trident/')) browser = 'IE';
|
|
|
|
let os = 'Unknown';
|
|
if (ua.includes('Windows')) os = 'Windows';
|
|
else if (ua.includes('Mac OS')) os = 'macOS';
|
|
else if (ua.includes('Android')) os = 'Android';
|
|
else if (ua.includes('iPhone') || ua.includes('iPad')) os = 'iOS';
|
|
else if (ua.includes('Linux')) os = 'Linux';
|
|
else if (ua.includes('CrOS')) os = 'ChromeOS';
|
|
|
|
return { browser, os };
|
|
}
|
|
|
|
const app = express();
|
|
const server = http.createServer(app);
|
|
const PORT = process.env.PORT || 3000;
|
|
|
|
// Trust the reverse proxy (Nginx) so req.ip returns the real user IP
|
|
app.set('trust proxy', 1);
|
|
|
|
// ── Authentication ─────────────────────────────────────────
|
|
|
|
let currentAppPassword = process.env.APP_PASSWORD || null;
|
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || null;
|
|
|
|
// Generate unique session tokens every time the server starts
|
|
const USER_TOKEN = uuidv4();
|
|
const ADMIN_TOKEN = uuidv4();
|
|
|
|
function requireAuth(req, res, next) {
|
|
if (!currentAppPassword && !ADMIN_PASSWORD) {
|
|
req.userRole = 'admin';
|
|
return next();
|
|
}
|
|
|
|
const token = req.headers['x-auth-token'];
|
|
|
|
if (ADMIN_PASSWORD && token === ADMIN_TOKEN) {
|
|
req.userRole = 'admin';
|
|
return next();
|
|
}
|
|
|
|
if (currentAppPassword && token === USER_TOKEN) {
|
|
req.userRole = 'user';
|
|
return next();
|
|
}
|
|
|
|
res.status(401).json({ error: 'Unauthorized' });
|
|
}
|
|
|
|
function requireAdmin(req, res, next) {
|
|
if (req.userRole === 'admin') return next();
|
|
res.status(403).json({ error: 'Forbidden: Admins only' });
|
|
}
|
|
|
|
// ── Brute Force Protection ─────────────────────────────────
|
|
|
|
const loginAttempts = new Map();
|
|
|
|
function checkBruteForce(req, res, next) {
|
|
const ip = req.ip;
|
|
const record = loginAttempts.get(ip);
|
|
if (record && record.lockoutUntil && Date.now() < record.lockoutUntil) {
|
|
const remaining = Math.ceil((record.lockoutUntil - Date.now()) / 1000);
|
|
return res.status(429).json({ error: `Too many attempts. Try again in ${remaining} seconds.` });
|
|
}
|
|
next();
|
|
}
|
|
|
|
function handleFailedLogin(ip) {
|
|
let record = loginAttempts.get(ip) || { attempts: 0, lockoutUntil: 0 };
|
|
record.attempts += 1;
|
|
|
|
if (record.attempts >= 5) {
|
|
// 5th attempt = 1 min, 6th = 2 mins, 7th = 4 mins, etc.
|
|
const lockMinutes = Math.pow(2, record.attempts - 5);
|
|
record.lockoutUntil = Date.now() + (lockMinutes * 60 * 1000);
|
|
}
|
|
loginAttempts.set(ip, record);
|
|
return record;
|
|
}
|
|
|
|
function handleSuccessfulLogin(ip) {
|
|
loginAttempts.delete(ip);
|
|
}
|
|
|
|
// ── Upload Storage ─────────────────────────────────────────
|
|
|
|
const UPLOAD_DIR = path.join(__dirname, 'uploads');
|
|
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
|
|
|
const storage = multer.diskStorage({
|
|
destination: (req, file, cb) => cb(null, UPLOAD_DIR),
|
|
filename: (req, file, cb) => {
|
|
const sessionId = uuidv4();
|
|
const ext = path.extname(file.originalname);
|
|
cb(null, `${sessionId}${ext}`);
|
|
}
|
|
});
|
|
|
|
const upload = multer({
|
|
storage,
|
|
limits: { fileSize: 500 * 1024 * 1024 }, // 500 MB
|
|
fileFilter: (req, file, cb) => {
|
|
if (file.mimetype === 'application/pdf') cb(null, true);
|
|
else cb(new Error('Only PDF files are allowed'));
|
|
}
|
|
});
|
|
|
|
// ── Session Tracking ───────────────────────────────────────
|
|
// Map sessionId -> { filePath, originalName, connectedClients, lastAccess, ip, userAgent, browser, os, startTime }
|
|
|
|
const sessions = new Map();
|
|
|
|
function touchSession(sessionId) {
|
|
const s = sessions.get(sessionId);
|
|
if (s) s.lastAccess = Date.now();
|
|
}
|
|
|
|
function cleanupSession(sessionId) {
|
|
const session = sessions.get(sessionId);
|
|
if (!session) return;
|
|
|
|
// Only delete if no clients are connected
|
|
if (session.connectedClients > 0) return;
|
|
|
|
try {
|
|
if (fs.existsSync(session.filePath)) {
|
|
fs.unlinkSync(session.filePath);
|
|
console.log(`[cleanup] Deleted: ${session.originalName} (${sessionId})`);
|
|
}
|
|
} catch (e) {
|
|
console.warn(`[cleanup] Failed to delete ${sessionId}:`, e.message);
|
|
}
|
|
sessions.delete(sessionId);
|
|
}
|
|
|
|
// Periodic cleanup: delete sessions idle for > 10 minutes
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [id, session] of sessions) {
|
|
if (session.connectedClients <= 0 && now - session.lastAccess > 10 * 60 * 1000) {
|
|
cleanupSession(id);
|
|
}
|
|
}
|
|
}, 60 * 1000); // check every minute
|
|
|
|
// ── WebSocket for Connection Tracking ──────────────────────
|
|
|
|
const wss = new WebSocketServer({ server });
|
|
|
|
wss.on('connection', (ws, req) => {
|
|
let wsSessionId = null;
|
|
const wsIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket.remoteAddress;
|
|
const wsUserAgent = req.headers['user-agent'] || '';
|
|
|
|
ws.on('message', (msg) => {
|
|
try {
|
|
const data = JSON.parse(msg);
|
|
if (data.type === 'register' && data.sessionId) {
|
|
// Enforce auth on websocket
|
|
if (currentAppPassword || ADMIN_PASSWORD) {
|
|
const isAdmin = ADMIN_PASSWORD && data.token === ADMIN_TOKEN;
|
|
const isUser = currentAppPassword && data.token === USER_TOKEN;
|
|
if (!isAdmin && !isUser) return;
|
|
}
|
|
|
|
wsSessionId = data.sessionId;
|
|
const session = sessions.get(wsSessionId);
|
|
if (session) {
|
|
session.connectedClients++;
|
|
// Update connection metadata from WebSocket
|
|
if (!session.ip || session.ip === 'unknown') {
|
|
session.ip = wsIp;
|
|
}
|
|
if (!session.userAgent) {
|
|
session.userAgent = wsUserAgent;
|
|
const parsed = parseUserAgent(wsUserAgent);
|
|
session.browser = parsed.browser;
|
|
session.os = parsed.os;
|
|
}
|
|
touchSession(wsSessionId);
|
|
console.log(`[ws] Client connected to session ${wsSessionId} (${session.connectedClients} clients)`);
|
|
}
|
|
}
|
|
} catch (e) { /* ignore */ }
|
|
});
|
|
|
|
ws.on('close', () => {
|
|
if (wsSessionId) {
|
|
const session = sessions.get(wsSessionId);
|
|
if (session) {
|
|
session.connectedClients--;
|
|
console.log(`[ws] Client disconnected from session ${wsSessionId} (${session.connectedClients} remaining)`);
|
|
// Schedule cleanup after a short delay (in case of page refresh)
|
|
setTimeout(() => {
|
|
const s = sessions.get(wsSessionId);
|
|
if (s && s.connectedClients <= 0) {
|
|
cleanupSession(wsSessionId);
|
|
}
|
|
}, 30 * 1000); // 30 second grace period for refreshes
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── Static Files ───────────────────────────────────────────
|
|
|
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
app.use(express.json());
|
|
|
|
// ── API: Auth ──────────────────────────────────────────────
|
|
|
|
app.get('/api/check-auth', requireAuth, (req, res) => {
|
|
res.json({ ok: true, role: req.userRole });
|
|
});
|
|
|
|
app.post('/api/login', checkBruteForce, (req, res) => {
|
|
if (!currentAppPassword && !ADMIN_PASSWORD) {
|
|
return res.json({ token: 'no-auth', role: 'admin' });
|
|
}
|
|
|
|
const { password } = req.body;
|
|
const ip = req.ip;
|
|
|
|
if (ADMIN_PASSWORD && password === ADMIN_PASSWORD) {
|
|
handleSuccessfulLogin(ip);
|
|
return res.json({ token: ADMIN_TOKEN, role: 'admin' });
|
|
}
|
|
|
|
if (currentAppPassword && password === currentAppPassword) {
|
|
handleSuccessfulLogin(ip);
|
|
return res.json({ token: USER_TOKEN, role: 'user' });
|
|
}
|
|
|
|
const record = handleFailedLogin(ip);
|
|
|
|
if (record.lockoutUntil > Date.now()) {
|
|
const remaining = Math.ceil((record.lockoutUntil - Date.now()) / 1000);
|
|
return res.status(429).json({ error: `Too many attempts. Locked out for ${remaining} seconds.` });
|
|
}
|
|
|
|
res.status(401).json({ error: 'Invalid password' });
|
|
});
|
|
|
|
// ── API: Admin ─────────────────────────────────────────────
|
|
|
|
app.get('/api/admin/stats', requireAuth, requireAdmin, (req, res) => {
|
|
const mem = process.memoryUsage();
|
|
|
|
// All tracked IPs (including non-blocked)
|
|
const trackedIps = [];
|
|
for (const [ip, record] of loginAttempts) {
|
|
const isLocked = record.lockoutUntil && record.lockoutUntil > Date.now();
|
|
trackedIps.push({
|
|
ip,
|
|
attempts: record.attempts,
|
|
locked: isLocked,
|
|
lockoutRemaining: isLocked ? Math.ceil((record.lockoutUntil - Date.now()) / 1000) : 0
|
|
});
|
|
}
|
|
|
|
// Detailed session info
|
|
const sessionDetails = [];
|
|
for (const [id, session] of sessions) {
|
|
sessionDetails.push({
|
|
id: id.substring(0, 8),
|
|
originalName: session.originalName,
|
|
ip: session.ip || 'Unknown',
|
|
browser: session.browser || 'Unknown',
|
|
os: session.os || 'Unknown',
|
|
startTime: session.startTime || null,
|
|
connectedClients: session.connectedClients
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
uptime: process.uptime(),
|
|
memoryUsedMB: Math.round(mem.rss / 1024 / 1024),
|
|
activeSessions: sessions.size,
|
|
loginAttemptsTracked: loginAttempts.size,
|
|
trackedIps,
|
|
sessionDetails
|
|
});
|
|
});
|
|
|
|
app.post('/api/admin/clear-cache', requireAuth, requireAdmin, (req, res) => {
|
|
let deletedCount = 0;
|
|
for (const [id, session] of sessions) {
|
|
try {
|
|
if (fs.existsSync(session.filePath)) {
|
|
fs.unlinkSync(session.filePath);
|
|
deletedCount++;
|
|
}
|
|
} catch (e) { /* ignore */ }
|
|
}
|
|
sessions.clear();
|
|
|
|
// Broadcast cache-cleared event to all connected WebSocket clients
|
|
for (const client of wss.clients) {
|
|
if (client.readyState === 1) { // WebSocket.OPEN
|
|
try {
|
|
client.send(JSON.stringify({ type: 'cache-cleared' }));
|
|
} catch (e) { /* ignore */ }
|
|
}
|
|
}
|
|
|
|
res.json({ success: true, deletedCount });
|
|
});
|
|
|
|
app.post('/api/admin/clear-logins', requireAuth, requireAdmin, (req, res) => {
|
|
loginAttempts.clear();
|
|
res.json({ success: true });
|
|
});
|
|
|
|
// (Change-password endpoint removed)
|
|
|
|
// ── API: Upload PDF ────────────────────────────────────────
|
|
|
|
app.post('/api/upload', requireAuth, upload.single('pdf'), (req, res) => {
|
|
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
|
|
|
const sessionId = path.basename(req.file.filename, path.extname(req.file.filename));
|
|
|
|
const ua = req.headers['user-agent'] || '';
|
|
const parsed = parseUserAgent(ua);
|
|
|
|
sessions.set(sessionId, {
|
|
filePath: req.file.path,
|
|
originalName: req.file.originalname,
|
|
connectedClients: 0,
|
|
lastAccess: Date.now(),
|
|
ip: req.ip || 'unknown',
|
|
userAgent: ua,
|
|
browser: parsed.browser,
|
|
os: parsed.os,
|
|
startTime: Date.now()
|
|
});
|
|
|
|
console.log(`[upload] ${req.file.originalname} -> session ${sessionId}`);
|
|
|
|
res.json({
|
|
sessionId,
|
|
originalName: req.file.originalname,
|
|
fileSize: req.file.size
|
|
});
|
|
});
|
|
|
|
// ── API: Serve PDF for client-side rendering ───────────────
|
|
|
|
app.get('/api/pdf/:sessionId', requireAuth, (req, res) => {
|
|
const session = sessions.get(req.params.sessionId);
|
|
if (!session || !fs.existsSync(session.filePath)) {
|
|
return res.status(404).json({ error: 'Session not found' });
|
|
}
|
|
touchSession(req.params.sessionId);
|
|
res.setHeader('Content-Type', 'application/pdf');
|
|
res.sendFile(session.filePath);
|
|
});
|
|
|
|
// ── API: Split PDF ─────────────────────────────────────────
|
|
|
|
app.post('/api/split', requireAuth, async (req, res) => {
|
|
const { sessionId, pageNumbers } = req.body;
|
|
|
|
const session = sessions.get(sessionId);
|
|
if (!session || !fs.existsSync(session.filePath)) {
|
|
return res.status(404).json({ error: 'Session not found' });
|
|
}
|
|
touchSession(sessionId);
|
|
|
|
try {
|
|
const inputBytes = fs.readFileSync(session.filePath);
|
|
const srcDoc = await PDFDocument.load(inputBytes, {
|
|
ignoreEncryption: true,
|
|
updateMetadata: false
|
|
});
|
|
const outDoc = await PDFDocument.create();
|
|
|
|
const sorted = [...pageNumbers].sort((a, b) => a - b);
|
|
const indices = sorted.map(p => p - 1).filter(i => i >= 0 && i < srcDoc.getPageCount());
|
|
|
|
const copiedPages = await outDoc.copyPages(srcDoc, indices);
|
|
copiedPages.forEach(page => outDoc.addPage(page));
|
|
|
|
const outBytes = await outDoc.save();
|
|
|
|
const outputName = session.originalName.replace(/\.pdf$/i, '') + '_split.pdf';
|
|
res.setHeader('Content-Type', 'application/pdf');
|
|
res.setHeader('Content-Disposition', `attachment; filename="${outputName}"`);
|
|
res.send(Buffer.from(outBytes));
|
|
} catch (err) {
|
|
console.error('[split] Error:', err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ── Start Server ───────────────────────────────────────────
|
|
|
|
server.listen(PORT, '0.0.0.0', () => {
|
|
console.log(`PDF Splitter running at http://0.0.0.0:${PORT}`);
|
|
});
|