diff --git a/public/app.js b/public/app.js
index 2604dee..0d65d53 100644
--- a/public/app.js
+++ b/public/app.js
@@ -52,8 +52,8 @@ const adminClose = document.getElementById('admin-close');
const adminStatsContainer = document.getElementById('admin-stats-container');
const btnAdminClearCache = document.getElementById('btn-admin-clear-cache');
const btnAdminClearLogins = document.getElementById('btn-admin-clear-logins');
-const adminPasswordForm = document.getElementById('admin-password-form');
-const adminNewPassword = document.getElementById('admin-new-password');
+const adminSessionsContainer = document.getElementById('admin-sessions-container');
+const adminIpLogContainer = document.getElementById('admin-ip-log-container');
// ── Authentication ─────────────────────────────────────────
@@ -129,6 +129,8 @@ adminClose.addEventListener('click', () => {
async function fetchAdminStats() {
adminStatsContainer.innerHTML = '
Loading…';
+ adminSessionsContainer.innerHTML = ' Loading…';
+ adminIpLogContainer.innerHTML = ' Loading…';
try {
const res = await fetch('/api/admin/stats', {
headers: { 'x-auth-token': authToken }
@@ -141,28 +143,78 @@ async function fetchAdminStats() {
const m = Math.floor((stats.uptime % 3600) / 60);
const uptimeStr = `${h}h ${m}m`;
- let html = `
+ // Server stats
+ adminStatsContainer.innerHTML = `
Uptime: ${uptimeStr}
Memory Used: ${stats.memoryUsedMB} MB
- Active Uploads/Sessions: ${stats.activeSessions}
- Tracked IPs (Brute Force): ${stats.loginAttemptsTracked}
+ Active Sessions: ${stats.activeSessions}
+ Tracked IPs: ${stats.loginAttemptsTracked}
`;
- if (stats.blockedIps.length > 0) {
- html += `Currently Locked Out IPs:
`;
- stats.blockedIps.forEach(b => {
- html += `
- ${b.ip} (${b.attempts} fails): ${b.lockoutRemaining}s left
-
`;
+ // Active sessions display
+ if (stats.sessionDetails && stats.sessionDetails.length > 0) {
+ let sessHtml = '';
+ stats.sessionDetails.forEach(s => {
+ const elapsed = s.startTime ? formatDuration(Date.now() - s.startTime) : 'N/A';
+ sessHtml += `
+
+
+ 📄
+ ${escapeHtml(s.originalName)}
+
+
+
IP${escapeHtml(s.ip)}
+
Browser${escapeHtml(s.browser)}
+
OS${escapeHtml(s.os)}
+
Duration${elapsed}
+
Clients${s.connectedClients}
+
+
+ `;
});
+ adminSessionsContainer.innerHTML = sessHtml;
+ } else {
+ adminSessionsContainer.innerHTML = 'No active sessions.';
+ }
+
+ // Tracked IPs log
+ if (stats.trackedIps && stats.trackedIps.length > 0) {
+ let ipHtml = '';
+ ipHtml += '';
+ stats.trackedIps.forEach(t => {
+ const statusClass = t.locked ? 'ip-locked' : 'ip-tracking';
+ const statusText = t.locked ? `Locked (${t.lockoutRemaining}s)` : 'Tracking';
+ ipHtml += `
+
+ ${escapeHtml(t.ip)}
+ ${t.attempts}
+ ${statusText}
+
+ `;
+ });
+ ipHtml += '
';
+ adminIpLogContainer.innerHTML = ipHtml;
+ } else {
+ adminIpLogContainer.innerHTML = 'No tracked IPs. All clear.';
}
- adminStatsContainer.innerHTML = html;
} catch (err) {
adminStatsContainer.innerHTML = `Error loading stats`;
+ adminSessionsContainer.innerHTML = '';
+ adminIpLogContainer.innerHTML = '';
}
}
+function formatDuration(ms) {
+ const totalSeconds = Math.floor(ms / 1000);
+ const hours = Math.floor(totalSeconds / 3600);
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
+ const seconds = totalSeconds % 60;
+ if (hours > 0) return `${hours}h ${minutes}m`;
+ if (minutes > 0) return `${minutes}m ${seconds}s`;
+ return `${seconds}s`;
+}
+
btnAdminClearCache.addEventListener('click', async () => {
if (!confirm('This will delete all uploaded PDFs and immediately disconnect all users. Continue?')) return;
try {
@@ -173,6 +225,7 @@ btnAdminClearCache.addEventListener('click', async () => {
const data = await res.json();
if (res.ok) {
showToast(`Cache cleared. Deleted ${data.deletedCount} files.`, 'success');
+ resetClientState();
fetchAdminStats();
} else throw new Error(data.error);
} catch (err) {
@@ -196,23 +249,7 @@ btnAdminClearLogins.addEventListener('click', async () => {
}
});
-adminPasswordForm.addEventListener('submit', async (e) => {
- e.preventDefault();
- const newPassword = adminNewPassword.value;
- try {
- const res = await fetch('/api/admin/change-password', {
- method: 'POST',
- headers: { 'x-auth-token': authToken, 'Content-Type': 'application/json' },
- body: JSON.stringify({ newPassword })
- });
- if (res.ok) {
- showToast('User password updated successfully!', 'success');
- adminNewPassword.value = '';
- } else throw new Error('Failed to update password');
- } catch (err) {
- showToast(err.message, 'error');
- }
-});
+// (Password change form removed)
// ── WebSocket ──────────────────────────────────────────────
@@ -228,12 +265,48 @@ function connectWebSocket() {
}
};
+ ws.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data);
+ if (data.type === 'cache-cleared') {
+ resetClientState();
+ showToast('Server cache cleared. Your session has been reset.', 'info');
+ }
+ } catch (e) { /* ignore */ }
+ };
+
ws.onclose = () => {
// Reconnect after 3 seconds
setTimeout(connectWebSocket, 3000);
};
}
+// Reset all client-side PDF state (used when cache is cleared)
+function resetClientState() {
+ sessionId = null;
+ pdfDoc = null;
+ totalPages = 0;
+ allChapters = [];
+ chapters = [];
+ selectedPages.clear();
+
+ // Reset UI
+ fileInfo.classList.add('hidden');
+ fileName.textContent = '';
+ filePages.textContent = '';
+ chapterList.innerHTML = '';
+ chapterNone.classList.add('hidden');
+ chapterLoad.classList.add('hidden');
+ chapterSearchWrap.classList.add('hidden');
+ depthControl.classList.add('hidden');
+ pageInput.value = '';
+ previewGrid.innerHTML = '';
+ previewGrid.classList.add('hidden');
+ previewEmpty.classList.remove('hidden');
+ thumbElements = [];
+ updateState();
+}
+
// ── Sidebar Resize ─────────────────────────────────────────
const SIDEBAR_MIN = 250;
diff --git a/public/index.html b/public/index.html
index ac4df08..1387ac2 100644
--- a/public/index.html
+++ b/public/index.html
@@ -167,6 +167,20 @@
+
+
Active Sessions
+
+ No active sessions.
+
+
+
+
+
Tracked IPs (Brute Force Log)
+
+ No tracked IPs.
+
+
+
Actions
@@ -174,15 +188,6 @@
-
-
-
Change User Password
-
Updates the normal APP_PASSWORD instantly in memory. (Will revert on container restart unless you change it in Unraid).
-
-
diff --git a/public/style.css b/public/style.css
index d0fc7a2..f773e70 100644
--- a/public/style.css
+++ b/public/style.css
@@ -89,16 +89,22 @@ body {
flex-direction: column;
gap: 8px;
padding: 12px;
- overflow-y: auto;
- overflow-x: hidden;
- scrollbar-width: thin;
- scrollbar-color: var(--border-light) transparent;
+ overflow: hidden;
}
#sidebar::-webkit-scrollbar { width: 5px; }
#sidebar::-webkit-scrollbar-track { background: transparent; }
#sidebar::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 10px; }
+/* Chapter section fills remaining vertical space */
+#chapter-section {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
/* ── Resize Handle ───────────────────────────────────────── */
.resize-handle {
@@ -286,7 +292,8 @@ body.resizing * {
display: flex;
flex-direction: column;
gap: 2px;
- max-height: 45vh;
+ flex: 1;
+ min-height: 0;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border-light) transparent;
@@ -902,15 +909,140 @@ body.resizing * {
display: flex;
gap: 12px;
}
-.admin-password-form {
+
+/* ── Active Sessions Cards ──────────────────────────────────── */
+
+.admin-sessions {
display: flex;
- gap: 12px;
+ flex-direction: column;
+ gap: 8px;
+ max-height: 240px;
+ overflow-y: auto;
+ scrollbar-width: thin;
+ scrollbar-color: var(--border-light) transparent;
}
-.admin-password-form input {
+
+.admin-session-card {
+ background: var(--bg-input);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 10px 12px;
+ transition: border-color var(--transition);
+}
+.admin-session-card:hover {
+ border-color: var(--border-light);
+}
+
+.session-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 8px;
+ padding-bottom: 6px;
+ border-bottom: 1px solid var(--border);
+}
+.session-icon {
+ font-size: 14px;
+ flex-shrink: 0;
+}
+.session-file {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-primary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
flex: 1;
}
-.admin-password-form button {
- width: auto;
+
+.session-details {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
+ gap: 4px 12px;
+}
+.session-detail {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 6px;
+}
+.detail-label {
+ font-size: 10.5px;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ font-weight: 600;
+}
+.detail-value {
+ font-size: 12px;
+ color: var(--text-secondary);
+ font-weight: 500;
+ font-variant-numeric: tabular-nums;
+}
+
+/* ── IP Tracking Log ────────────────────────────────────────── */
+
+.admin-ip-log {
+ max-height: 200px;
+ overflow-y: auto;
+ scrollbar-width: thin;
+ scrollbar-color: var(--border-light) transparent;
+}
+
+.ip-log-table {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+.ip-log-header {
+ display: grid;
+ grid-template-columns: 1fr 80px 120px;
+ gap: 8px;
+ padding: 6px 10px;
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.8px;
+ color: var(--text-muted);
+ border-bottom: 1px solid var(--border);
+}
+.ip-log-row {
+ display: grid;
+ grid-template-columns: 1fr 80px 120px;
+ gap: 8px;
+ padding: 6px 10px;
+ font-size: 12px;
+ border-radius: var(--radius-sm);
+ transition: background var(--transition);
+}
+.ip-log-row:hover {
+ background: var(--bg-card-hover);
+}
+.ip-log-row.ip-locked {
+ background: rgba(239, 68, 68, 0.08);
+}
+.ip-log-row.ip-locked:hover {
+ background: rgba(239, 68, 68, 0.14);
+}
+.ip-address {
+ color: var(--text-primary);
+ font-family: monospace;
+ font-size: 11.5px;
+}
+.ip-attempts {
+ color: var(--text-secondary);
+ text-align: center;
+ font-variant-numeric: tabular-nums;
+}
+.ip-status {
+ text-align: right;
+ font-weight: 500;
+}
+.ip-tracking .ip-status {
+ color: var(--text-muted);
+}
+.ip-locked .ip-status {
+ color: var(--danger);
}
/* ── Utilities ───────────────────────────────────────────── */
diff --git a/server.js b/server.js
index 57b8bc1..d317203 100644
--- a/server.js
+++ b/server.js
@@ -7,6 +7,30 @@ 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;
@@ -104,7 +128,7 @@ const upload = multer({
});
// ── Session Tracking ───────────────────────────────────────
-// Map sessionId -> { filePath, originalName, connectedClients, lastAccess }
+// Map sessionId -> { filePath, originalName, connectedClients, lastAccess, ip, userAgent, browser, os, startTime }
const sessions = new Map();
@@ -145,8 +169,10 @@ setInterval(() => {
const wss = new WebSocketServer({ server });
-wss.on('connection', (ws) => {
+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 {
@@ -163,6 +189,16 @@ wss.on('connection', (ws) => {
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)`);
}
@@ -232,11 +268,30 @@ app.post('/api/login', checkBruteForce, (req, res) => {
app.get('/api/admin/stats', requireAuth, requireAdmin, (req, res) => {
const mem = process.memoryUsage();
- const blockedIps = [];
+ // All tracked IPs (including non-blocked)
+ const trackedIps = [];
for (const [ip, record] of loginAttempts) {
- if (record.lockoutUntil > Date.now()) {
- blockedIps.push({ ip, attempts: record.attempts, lockoutRemaining: Math.ceil((record.lockoutUntil - Date.now()) / 1000) });
- }
+ 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({
@@ -244,7 +299,8 @@ app.get('/api/admin/stats', requireAuth, requireAdmin, (req, res) => {
memoryUsedMB: Math.round(mem.rss / 1024 / 1024),
activeSessions: sessions.size,
loginAttemptsTracked: loginAttempts.size,
- blockedIps
+ trackedIps,
+ sessionDetails
});
});
@@ -259,6 +315,16 @@ app.post('/api/admin/clear-cache', requireAuth, requireAdmin, (req, res) => {
} 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 });
});
@@ -267,11 +333,7 @@ app.post('/api/admin/clear-logins', requireAuth, requireAdmin, (req, res) => {
res.json({ success: true });
});
-app.post('/api/admin/change-password', requireAuth, requireAdmin, (req, res) => {
- const { newPassword } = req.body;
- currentAppPassword = newPassword || null;
- res.json({ success: true });
-});
+// (Change-password endpoint removed)
// ── API: Upload PDF ────────────────────────────────────────
@@ -280,11 +342,19 @@ app.post('/api/upload', requireAuth, upload.single('pdf'), (req, res) => {
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()
+ 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}`);