diff --git a/server/diagnostics.js b/server/diagnostics.js index c02ce6600..100e4f932 100644 --- a/server/diagnostics.js +++ b/server/diagnostics.js @@ -98,8 +98,8 @@ class DiagnosticTool { console.error('Error generating recommendations:', e); } - // Sanitize sensitive data before returning - return this.sanitizeReport(report); + // Return unsanitized report - sanitization will be done client-side for copy/download + return report; } sanitizeReport(report) { @@ -232,7 +232,7 @@ class DiagnosticTool { const permCheck = { id: id, name: clientObj.config?.name || id, - host: this.sanitizeUrl(clientObj.config?.host), + host: clientObj.config?.host, canConnect: false, canListNodes: false, canListVMs: false, @@ -357,7 +357,7 @@ class DiagnosticTool { const permCheck = { id: id, name: clientObj.config?.name || id, - host: this.sanitizeUrl(clientObj.config?.host), + host: clientObj.config?.host, node_name: clientObj.config?.nodeName || clientObj.config?.node_name || 'NOT SET', canConnect: false, canListDatastores: false, @@ -445,7 +445,7 @@ class DiagnosticTool { if (!id.startsWith('pbs_') && clientObj && clientObj.config) { config.proxmox.push({ id: id, - host: this.sanitizeUrl(clientObj.config.host), + host: clientObj.config.host, name: clientObj.config.name || id, port: clientObj.config.port || '8006', tokenConfigured: !!clientObj.config.tokenId, @@ -464,7 +464,7 @@ class DiagnosticTool { const nodeName = clientObj.config.nodeName || clientObj.config.node_name; config.pbs.push({ id: id, - host: this.sanitizeUrl(clientObj.config.host), + host: clientObj.config.host, name: clientObj.config.name || id, port: clientObj.config.port || '8007', node_name: nodeName || 'NOT SET', diff --git a/server/index.js b/server/index.js index 7276114e1..baa493e72 100644 --- a/server/index.js +++ b/server/index.js @@ -43,6 +43,7 @@ const cors = require('cors'); const compression = require('compression'); const { Server } = require('socket.io'); const { URL } = require('url'); // <--- ADD: Import URL constructor +const axios = require('axios'); const axiosRetry = require('axios-retry').default; // Import axios-retry // Development specific dependencies @@ -351,17 +352,78 @@ app.delete('/api/alerts/rules/:id', (req, res) => { } }); -// Example API Route (Add your actual API routes here) -app.get('/api/version', (req, res) => { +// Version check functionality +let latestVersionCache = null; +let lastVersionCheck = 0; +const VERSION_CHECK_INTERVAL = 6 * 60 * 60 * 1000; // 6 hours + +async function checkLatestVersion() { + const now = Date.now(); + + // Return cached version if still fresh + if (latestVersionCache && (now - lastVersionCheck) < VERSION_CHECK_INTERVAL) { + return latestVersionCache; + } + + try { + const response = await axios.get('https://api.github.com/repos/rcourtman/Pulse/releases/latest', { + timeout: 5000, + headers: { + 'Accept': 'application/vnd.github.v3+json' + } + }); + + if (response.data && response.data.tag_name) { + // Remove 'v' prefix if present + const version = response.data.tag_name.replace(/^v/, ''); + latestVersionCache = version; + lastVersionCheck = now; + return version; + } + } catch (error) { + console.error('Error checking latest version:', error.message); + } + + return null; +} + +// Version API endpoint +app.get('/api/version', async (req, res) => { try { const packageJson = require('../package.json'); - res.json({ version: packageJson.version || 'N/A' }); + const currentVersion = packageJson.version || 'N/A'; + + // Check for latest version + const latestVersion = await checkLatestVersion(); + + res.json({ + version: currentVersion, + latestVersion: latestVersion, + updateAvailable: latestVersion && latestVersion !== currentVersion && + compareVersions(latestVersion, currentVersion) > 0 + }); } catch (error) { - console.error("Error reading package.json for version:", error); + console.error("Error in version endpoint:", error); res.status(500).json({ error: "Could not retrieve version" }); } }); +// Simple version comparison function +function compareVersions(v1, v2) { + const parts1 = v1.split('.').map(Number); + const parts2 = v2.split('.').map(Number); + + for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) { + const part1 = parts1[i] || 0; + const part2 = parts2[i] || 0; + + if (part1 > part2) return 1; + if (part1 < part2) return -1; + } + + return 0; +} + app.get('/api/storage', async (req, res) => { try { // Get current nodes from state manager diff --git a/src/public/diagnostics.html b/src/public/diagnostics.html index 1b9b55ed8..b45774b3c 100644 --- a/src/public/diagnostics.html +++ b/src/public/diagnostics.html @@ -254,20 +254,18 @@ let html = ''; - // Add sanitization notice if data is sanitized - if (data._sanitized) { - html += ` -
-

🛡️ Privacy Protected

-

- ${data._sanitized.notice} -

-

- ✅ This report is safe to share in GitHub issues or support requests -

-
- `; - } + // Show notice about sensitive data + html += ` +
+

⚠️ Sensitive Data Visible

+

+ This page displays real hostnames, IPs, and other potentially sensitive information for diagnostic purposes. +

+

+ 📋 When you copy or download this report, all sensitive data will be automatically sanitized for safe sharing. +

+
+ `; // Errors section (if any) if (data.errors && data.errors.length > 0) { @@ -557,10 +555,179 @@ return html; } + function sanitizeUrl(url) { + if (!url) return url; + + // Remove protocol if present + let sanitized = url.replace(/^https?:\/\//, ''); + + // Replace IP addresses + sanitized = sanitized.replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, '[IP-ADDRESS]'); + + // Replace hostnames (anything before port or path) + sanitized = sanitized.replace(/^[^:/]+/, '[HOSTNAME]'); + + // Replace ports + sanitized = sanitized.replace(/:\d+/, ':[PORT]'); + + return sanitized; + } + + function sanitizeErrorMessage(errorMsg) { + if (!errorMsg) return errorMsg; + + // Remove potential IP addresses, hostnames, and ports + let sanitized = errorMsg + .replace(/\b(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?\b/g, '[IP-ADDRESS]') + .replace(/https?:\/\/[^\/\s:]+(?::\d+)?/g, '[HOSTNAME]') + .replace(/([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}/g, '[HOSTNAME]') + .replace(/:\d{4,5}\b/g, ':[PORT]'); + + return sanitized; + } + + function sanitizeRecommendationMessage(message) { + if (!message) return message; + + // Replace specific hostnames and IPs in common recommendation patterns + let sanitized = message + .replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, '[IP-ADDRESS]') + .replace(/https?:\/\/[^\/\s:]+/g, '[HOSTNAME]') + .replace(/host\s*'[^']+'/g, "host '[HOSTNAME]'") + .replace(/host\s*"[^"]+"/g, 'host "[HOSTNAME]"') + .replace(/node\s+'[^']+'/g, "node '[NODE-NAME]'") + .replace(/node\s+"[^"]+"/g, 'node "[NODE-NAME]"') + .replace(/:\d{4,5}\b/g, ':[PORT]'); + + return sanitized; + } + + function sanitizeReport(report) { + // Deep clone the report to avoid modifying the original + const sanitized = JSON.parse(JSON.stringify(report)); + + // Sanitize configuration section + if (sanitized.configuration) { + if (sanitized.configuration.proxmox) { + sanitized.configuration.proxmox = sanitized.configuration.proxmox.map(pve => ({ + ...pve, + host: sanitizeUrl(pve.host), + // Remove potentially sensitive fields, keep only structure info + tokenConfigured: pve.tokenConfigured, + selfSignedCerts: pve.selfSignedCerts + })); + } + + if (sanitized.configuration.pbs) { + sanitized.configuration.pbs = sanitized.configuration.pbs.map(pbs => ({ + ...pbs, + host: sanitizeUrl(pbs.host), + // Remove potentially sensitive fields, keep only structure info + tokenConfigured: pbs.tokenConfigured, + selfSignedCerts: pbs.selfSignedCerts, + node_name: pbs.node_name + })); + } + } + + // Sanitize permissions section + if (sanitized.permissions) { + if (sanitized.permissions.proxmox) { + sanitized.permissions.proxmox = sanitized.permissions.proxmox.map(perm => ({ + ...perm, + host: sanitizeUrl(perm.host), + name: sanitizeUrl(perm.name), + // Keep diagnostic info but sanitize error messages + errors: perm.errors ? perm.errors.map(err => sanitizeErrorMessage(err)) : [] + })); + } + + if (sanitized.permissions.pbs) { + sanitized.permissions.pbs = sanitized.permissions.pbs.map(perm => ({ + ...perm, + host: sanitizeUrl(perm.host), + name: sanitizeUrl(perm.name), + // Keep diagnostic info but sanitize error messages + errors: perm.errors ? perm.errors.map(err => sanitizeErrorMessage(err)) : [] + })); + } + } + + // Sanitize state section + if (sanitized.state) { + // Remove potentially sensitive node names, keep only counts and structure + if (sanitized.state.nodes && sanitized.state.nodes.names) { + sanitized.state.nodes.names = sanitized.state.nodes.names.map((name, index) => `node-${index + 1}`); + } + + // Remove specific backup IDs, keep only counts + if (sanitized.state.pbs && sanitized.state.pbs.sampleBackupIds) { + sanitized.state.pbs.sampleBackupIds = sanitized.state.pbs.sampleBackupIds.map((id, index) => `backup-${index + 1}`); + } + } + + // Sanitize recommendations + if (sanitized.recommendations) { + sanitized.recommendations = sanitized.recommendations.map(rec => ({ + ...rec, + message: sanitizeRecommendationMessage(rec.message) + })); + } + + // Sanitize connectivity section + if (sanitized.connectivity) { + if (sanitized.connectivity.proxmox) { + sanitized.connectivity.proxmox = sanitized.connectivity.proxmox.map(conn => ({ + ...conn, + host: sanitizeUrl(conn.host), + error: conn.error ? sanitizeErrorMessage(conn.error) : conn.error + })); + } + + if (sanitized.connectivity.pbs) { + sanitized.connectivity.pbs = sanitized.connectivity.pbs.map(conn => ({ + ...conn, + host: sanitizeUrl(conn.host), + error: conn.error ? sanitizeErrorMessage(conn.error) : conn.error + })); + } + } + + // Sanitize dataFlow section + if (sanitized.dataFlow && sanitized.dataFlow.pbs && sanitized.dataFlow.pbs.backup_matching) { + sanitized.dataFlow.pbs.backup_matching = sanitized.dataFlow.pbs.backup_matching.map(instance => ({ + ...instance, + sample_backups: instance.sample_backups ? instance.sample_backups.map((backup, idx) => ({ + ...backup, + backup_id: `backup-${idx + 1}` + })) : [] + })); + } + + // Sanitize errors section + if (sanitized.errors) { + sanitized.errors = sanitized.errors.map(err => ({ + ...err, + error: sanitizeErrorMessage(err.error) + })); + } + + // Add notice about sanitization + sanitized._sanitized = { + notice: "This diagnostic report has been sanitized for safe sharing. Hostnames, IPs, node names, and backup IDs have been anonymized while preserving structural information needed for troubleshooting.", + timestamp: new Date().toISOString() + }; + + return sanitized; + } + function copyReport() { if (!diagnosticData) return; - const text = JSON.stringify(diagnosticData, null, 2); + // Sanitize the data before copying + const sanitizedData = sanitizeReport(diagnosticData); + const text = JSON.stringify(sanitizedData, null, 2); + navigator.clipboard.writeText(text).then(() => { const button = document.getElementById('copyReport'); const originalText = button.textContent; @@ -576,9 +743,11 @@ function downloadReport() { if (!diagnosticData) return; + // Sanitize the data before downloading + const sanitizedData = sanitizeReport(diagnosticData); const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5); const filename = `pulse_diagnostics_${timestamp}.json`; - const text = JSON.stringify(diagnosticData, null, 2); + const text = JSON.stringify(sanitizedData, null, 2); const blob = new Blob([text], { type: 'application/json' }); const url = URL.createObjectURL(blob); diff --git a/src/public/js/main.js b/src/public/js/main.js index 6d1cc1887..e02d4b486 100644 --- a/src/public/js/main.js +++ b/src/public/js/main.js @@ -112,6 +112,40 @@ document.addEventListener('DOMContentLoaded', function() { console.log('Version data received:', data); if (data.version) { versionSpan.textContent = data.version; + + // Check if update is available + if (data.updateAvailable && data.latestVersion) { + // Check if update indicator already exists + const existingIndicator = document.getElementById('update-indicator'); + if (!existingIndicator) { + // Create update indicator + const updateIndicator = document.createElement('span'); + updateIndicator.id = 'update-indicator'; + updateIndicator.className = 'ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'; + updateIndicator.innerHTML = ` + + + + v${data.latestVersion} available + `; + updateIndicator.title = 'Click to view the latest release'; + updateIndicator.style.cursor = 'pointer'; + updateIndicator.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + window.open('https://github.com/rcourtman/Pulse/releases/latest', '_blank'); + }); + + // Insert after version link + versionSpan.parentNode.insertBefore(updateIndicator, versionSpan.nextSibling); + } + } else { + // Remove update indicator if no update available + const existingIndicator = document.getElementById('update-indicator'); + if (existingIndicator) { + existingIndicator.remove(); + } + } } else { versionSpan.textContent = 'unknown'; } @@ -141,4 +175,10 @@ document.addEventListener('DOMContentLoaded', function() { fetchVersion(); } }, 2000); + + // Periodically check for updates (every 6 hours) + setInterval(() => { + console.log('[Main] Checking for version updates...'); + fetchVersion(); + }, 6 * 60 * 60 * 1000); }); diff --git a/src/public/js/ui/storage.js b/src/public/js/ui/storage.js index 7f596f28b..7670a11a7 100644 --- a/src/public/js/ui/storage.js +++ b/src/public/js/ui/storage.js @@ -163,7 +163,7 @@ PulseApp.ui.storage = (() => { const thead = document.createElement('thead'); thead.innerHTML = ` - Storage + Storage Content Type Shared @@ -231,7 +231,7 @@ PulseApp.ui.storage = (() => { const contentBadges = getContentBadgesHTML(store.content); row.innerHTML = ` - ${store.storage || 'N/A'} + ${store.storage || 'N/A'} ${contentBadges} ${store.type || 'N/A'} ${sharedIcon}