/** * DumbAssets - Asset Tracking Application * Server implementation for handling API requests and file operations */ // --- SECURITY & CONFIG IMPORTS --- require('dotenv').config(); // console.log('process.env:', process.env); const express = require('express'); const session = require('express-session'); const helmet = require('helmet'); const crypto = require('crypto'); const path = require('path'); const cookieParser = require('cookie-parser'); const cors = require('cors'); const fs = require('fs'); const multer = require('multer'); const { v4: uuidv4 } = require('uuid'); const XLSX = require('xlsx'); const { sendNotification } = require('./src/services/notifications/appriseNotifier'); const { startWarrantyCron } = require('./src/services/notifications/warrantyCron'); const { generatePWAManifest } = require("./scripts/pwa-manifest-generator"); const { originValidationMiddleware, getCorsOptions } = require('./middleware/cors'); const { demoModeMiddleware } = require('./middleware/demo'); const { sanitizeFileName } = require('./src/services/fileUpload/utils'); const packageJson = require('./package.json'); const app = express(); const PORT = process.env.PORT || 3000; const DEBUG = process.env.DEBUG === 'TRUE'; const NODE_ENV = process.env.NODE_ENV || 'production'; const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`; const DEMO_MODE = process.env.DEMO_MODE === 'true'; const SITE_TITLE = DEMO_MODE ? `${process.env.SITE_TITLE || 'DumbAssets'} (DEMO)` : (process.env.SITE_TITLE || 'DumbAssets'); const PUBLIC_DIR = path.join(__dirname, 'public'); const PUBLIC_ASSETS_DIR = path.join(PUBLIC_DIR, 'assets'); const DATA_DIR = path.join(__dirname, 'data'); const VERSION = packageJson.version; const DEFAULT_SETTINGS = { notificationSettings: { notifyAdd: true, notifyDelete: false, notifyEdit: true, notify1Month: true, notify2Week: false, notify7Day: true, notify3Day: false, notifyMaintenance: false }, interfaceSettings: { dashboardOrder: ["analytics", "totals", "warranties", "events"], dashboardVisibility: { analytics: true, totals: true, warranties: true, events: true }, cardVisibility: { assets: true, components: true, value: true, warranties: true, within60: true, within30: true, expired: true, active: true } }, }; // Currency configuration from environment variables const CURRENCY_CODE = process.env.CURRENCY_CODE || 'USD'; const CURRENCY_LOCALE = process.env.CURRENCY_LOCALE || 'en-US'; generatePWAManifest(SITE_TITLE); // Set timezone from environment variable or default to America/Chicago process.env.TZ = process.env.TZ || 'America/Chicago'; function debugLog(...args) { if (DEBUG) { console.log('[DEBUG]', ...args); } } // --- BASE PATH & PIN CONFIG --- const BASE_PATH = (() => { if (!BASE_URL) { debugLog('No BASE_URL set, using empty base path'); return ''; } try { const url = new URL(BASE_URL); const path = url.pathname.replace(/\/$/, ''); debugLog('Base URL Configuration:', { originalUrl: BASE_URL, extractedPath: path, protocol: url.protocol, hostname: url.hostname }); return path; } catch { const path = BASE_URL.replace(/\/$/, ''); debugLog('Using direct path as BASE_URL:', path); return path; } })(); const projectName = packageJson.name.toUpperCase().replace(/-/g, '_'); const PIN = process.env.DUMBASSETS_PIN; console.log('PIN:', PIN); if (!PIN || PIN.trim() === '') { debugLog('PIN protection is disabled'); } else { debugLog('PIN protection is enabled, PIN length:', PIN.length); } // --- BRUTE FORCE PROTECTION --- const loginAttempts = new Map(); const MAX_ATTEMPTS = 5; const LOCKOUT_TIME = 15 * 60 * 1000; function resetAttempts(ip) { loginAttempts.delete(ip); } function isLockedOut(ip) { const attempts = loginAttempts.get(ip); if (!attempts) return false; if (attempts.count >= MAX_ATTEMPTS) { const timeElapsed = Date.now() - attempts.lastAttempt; if (timeElapsed < LOCKOUT_TIME) return true; resetAttempts(ip); } return false; } function recordAttempt(ip) { const attempts = loginAttempts.get(ip) || { count: 0, lastAttempt: 0 }; attempts.count += 1; attempts.lastAttempt = Date.now(); loginAttempts.set(ip, attempts); } // --- SECURITY MIDDLEWARE --- app.use(helmet({ noSniff: true, // Prevent MIME type sniffing frameguard: { action: 'deny' }, // Prevent clickjacking hsts: { maxAge: 31536000, includeSubDomains: true }, // Enforce HTTPS for one year crossOriginEmbedderPolicy: true, crossOriginOpenerPolicy: { policy: 'same-origin-allow-popups' }, crossOriginResourcePolicy: { policy: 'same-origin' }, referrerPolicy: { policy: 'no-referrer-when-downgrade' }, // Set referrer policy ieNoOpen: true, // Prevent IE from executing downloads // Disabled Helmet middlewares: contentSecurityPolicy: false, // Disable CSP for now dnsPrefetchControl: true, // Disable DNS prefetching permittedCrossDomainPolicies: false, originAgentCluster: false, xssFilter: false, })); app.use(express.json()); app.set('trust proxy', 1); app.use(cors(getCorsOptions(BASE_URL))); app.use(cookieParser()); app.use(session({ secret: process.env.SESSION_SECRET || 'your-secret-key', resave: false, saveUninitialized: false, cookie: { httpOnly: true, secure: (BASE_URL.startsWith('https') && NODE_ENV === 'production'), sameSite: 'strict', maxAge: 24 * 60 * 60 * 1000 // 24 hours } })); // Add helper function to get base URL for notifications function getBaseUrl(req) { // Try to get from environment variable first if (process.env.BASE_URL) { return process.env.BASE_URL; } // Try to construct from request headers if (req) { const protocol = req.secure || req.get('X-Forwarded-Proto') === 'https' ? 'https' : 'http'; const host = req.get('Host') || req.get('X-Forwarded-Host'); if (host) { return `${protocol}://${host}`; } } // Fallback to localhost with default port return 'http://localhost:3000'; } // --- AUTHENTICATION MIDDLEWARE FOR ALL PROTECTED ROUTES --- app.use(BASE_PATH, (req, res, next) => { // List of paths that should be publicly accessible const publicPaths = [ '/login', '/pin-length', '/verify-pin', '/config.js', '/assets/', '/styles.css', '/manifest.json', '/asset-manifest.json', ]; // Check if the current path matches any of the public paths if (publicPaths.some(path => req.path.startsWith(path))) { return next(); } // For all other paths, apply both origin validation and auth middleware originValidationMiddleware(req, res, () => { authMiddleware(req, res, () => { demoModeMiddleware(req, res, next); }); }); }); // --- PIN VERIFICATION --- function verifyPin(storedPin, providedPin) { if (!storedPin || !providedPin) return false; if (storedPin.length !== providedPin.length) return false; try { return crypto.timingSafeEqual(Buffer.from(storedPin), Buffer.from(providedPin)); } catch { return false; } } // --- AUTH MIDDLEWARE --- function authMiddleware(req, res, next) { debugLog('Auth check for path:', req.path, 'Method:', req.method); if (!PIN || PIN.trim() === '') return next(); const pinCookie = req.cookies[`${projectName}_PIN`]; if (req.session.authenticated || verifyPin(PIN, pinCookie)) { debugLog('Auth successful - Valid cookie found'); req.session.authenticated = true; return next(); } if (req.path.startsWith('/api/') || req.xhr) { req.session.authenticated = false; // Return JSON error for API requests return res.status(401).json({ error: 'Authentication required', redirectTo: BASE_PATH + '/login' }); } else { req.session.authenticated = false; // Preserve the original URL with query parameters for post-login redirect const originalUrl = req.originalUrl; const loginUrl = `${BASE_PATH}/login${originalUrl ? `?returnTo=${encodeURIComponent(originalUrl)}` : ''}`; debugLog('Redirecting to login with return URL:', loginUrl); return res.redirect(loginUrl); } }; // --- STATIC FILES & CONFIG --- app.get(BASE_PATH + '/config.js', async (req, res) => { debugLog('Serving config.js with basePath:', BASE_PATH); // Set proper MIME type res.setHeader('Content-Type', 'application/javascript'); const currency = JSON.stringify({ code: CURRENCY_CODE, locale: CURRENCY_LOCALE }); // First send the dynamic config res.write(` window.appConfig = { basePath: '${BASE_PATH}', debug: ${DEBUG}, siteTitle: '${SITE_TITLE}', version: '${VERSION}', defaultSettings: ${JSON.stringify(DEFAULT_SETTINGS)}, demoMode: ${DEMO_MODE}, currency: ${currency}, }; `); // Then append the static config.js content try { const staticConfig = await fs.promises.readFile(path.join(PUBLIC_DIR, 'config.js'), 'utf8'); res.write('\n\n' + staticConfig); } catch (error) { console.error('Error reading static config.js:', error); } res.end(); }); // Dynamic service worker with correct version app.get(BASE_PATH + '/service-worker.js', async (req, res) => { debugLog('Serving service-worker.js with version:', VERSION); // Set proper MIME type and cache headers to prevent caching res.setHeader('Content-Type', 'application/javascript'); res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); try { let swContent = await fs.promises.readFile(path.join(PUBLIC_DIR, 'service-worker.js'), 'utf8'); // Replace the version initialization with the actual version from package.json swContent = swContent.replace( /let APP_VERSION = ".*?";/, `let APP_VERSION = "${VERSION}";` ); res.write(swContent); res.end(); } catch (error) { console.error('Error reading service-worker.js:', error); res.status(500).send('Error loading service worker'); } }); // Serve static files for public assets app.use(BASE_PATH + '/', express.static(path.join(PUBLIC_DIR))); app.get(BASE_PATH + "/manifest.json", (req, res) => { res.sendFile(path.join(PUBLIC_ASSETS_DIR, "manifest.json")); }); app.get(BASE_PATH + "/asset-manifest.json", (req, res) => { res.sendFile(path.join(PUBLIC_ASSETS_DIR, "asset-manifest.json")); }); // Unprotected routes and files (accessible without login) app.get(BASE_PATH + '/login', (req, res) => { // If no PIN is set, redirect to return URL or main page (preserving any asset parameters) if (!PIN || PIN.trim() === '') { const returnTo = req.query.returnTo || (BASE_PATH + '/'); debugLog('No PIN set, redirecting to:', returnTo); return res.redirect(returnTo); } // If already authenticated, redirect to return URL or main page if (req.session.authenticated) { const returnTo = req.query.returnTo || (BASE_PATH + '/'); debugLog('Already authenticated, redirecting to:', returnTo); return res.redirect(returnTo); } // Store the return URL in the session if provided if (req.query.returnTo) { req.session.returnTo = req.query.returnTo; debugLog('Stored return URL in session:', req.query.returnTo); } res.sendFile(path.join(PUBLIC_DIR, 'login.html')); }); app.get(BASE_PATH + '/pin-length', (req, res) => { if (!PIN || PIN.trim() === '') return res.json({ length: 0 }); res.json({ length: PIN.length }); }); app.post(BASE_PATH + '/verify-pin', (req, res) => { debugLog('PIN verification attempt from IP:', req.ip); // If no PIN is set, authentication is successful if (!PIN || PIN.trim() === '') { debugLog('PIN verification bypassed - No PIN configured'); req.session.authenticated = true; // Get the return URL from session, or default to main page const returnTo = req.session.returnTo || (BASE_PATH + '/'); // Clear the return URL from session delete req.session.returnTo; debugLog('No PIN set, redirecting to:', returnTo); // Redirect to the intended destination return res.redirect(returnTo); } // Check if IP is locked out const ip = req.ip; if (isLockedOut(ip)) { const attempts = loginAttempts.get(ip); const timeLeft = Math.ceil((LOCKOUT_TIME - (Date.now() - attempts.lastAttempt)) / 1000 / 60); debugLog('PIN verification blocked - IP is locked out:', ip); return res.status(429).json({ error: `Too many attempts. Please try again in ${timeLeft} minutes.` }); } const { pin } = req.body; if (!pin || typeof pin !== 'string') { debugLog('PIN verification failed - Invalid PIN format'); return res.status(400).json({ error: 'Invalid PIN format' }); } // Verify PIN first const isPinValid = verifyPin(PIN, pin); if (isPinValid) { debugLog('PIN verification successful'); // Reset attempts on successful login resetAttempts(ip); // Set authentication in session immediately req.session.authenticated = true; // Set secure cookie res.cookie(`${projectName}_PIN`, pin, { httpOnly: true, secure: req.secure || (BASE_URL.startsWith('https') && NODE_ENV === 'production'), sameSite: 'strict', maxAge: 24 * 60 * 60 * 1000 }); // Get the return URL from session, or default to main page const returnTo = req.session.returnTo || (BASE_PATH + '/'); // Clear the return URL from session delete req.session.returnTo; debugLog('Redirecting after successful login to:', returnTo); // Redirect to the intended destination res.redirect(returnTo); } else { debugLog('PIN verification failed - Invalid PIN'); // Record failed attempt recordAttempt(ip); const attempts = loginAttempts.get(ip); const attemptsLeft = MAX_ATTEMPTS - attempts.count; res.status(401).json({ error: 'Invalid PIN', attemptsLeft: Math.max(0, attemptsLeft) }); } }); // Login page static assets (need to be accessible without authentication) app.use(BASE_PATH + '/styles.css', express.static('public/styles.css')); app.use(BASE_PATH + '/script.js', express.static('public/script.js')); // Module files (need to be accessible for imports) app.use(BASE_PATH + '/src/services/fileUpload', express.static('src/services/fileUpload')); app.use(BASE_PATH + '/src/services/render', express.static('src/services/render')); // Serve Chart.js from node_modules app.use(BASE_PATH + '/js/chart.js', express.static('node_modules/chart.js/dist/chart.umd.js')); // Serve uploaded files app.use(BASE_PATH + '/Images', express.static('data/Images')); app.use(BASE_PATH + '/Receipts', express.static('data/Receipts')); app.use(BASE_PATH + '/Manuals', express.static('data/Manuals')); // Protected API routes app.use('/api', (req, res, next) => { console.log(`API Request: ${req.method} ${req.path}`); next(); }); // --- ASSET MANAGEMENT (existing code preserved) --- // File paths const assetsFilePath = path.join(DATA_DIR, 'Assets.json'); const subAssetsFilePath = path.join(DATA_DIR, 'SubAssets.json'); // Helper Functions function ensureDirectoryExists(directory) { if (!fs.existsSync(directory)) { fs.mkdirSync(directory, { recursive: true }); } } function readJsonFile(filePath) { try { if (!fs.existsSync(filePath)) { return []; } const data = fs.readFileSync(filePath, 'utf8'); return JSON.parse(data); } catch (error) { console.error(`Error reading ${filePath}:`, error); return []; } } function writeJsonFile(filePath, data) { try { const dirPath = path.dirname(filePath); ensureDirectoryExists(dirPath); fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8'); return true; } catch (error) { console.error(`Error writing to ${filePath}:`, error); return false; } } function generateId() { // Generate a 10-digit ID return Math.floor(1000000000 + Math.random() * 9000000000).toString(); } function deleteAssetFileAsync(filePath) { return new Promise((resolve, reject) => { if (!filePath) { console.log('[DEBUG] Skipping empty filePath'); return resolve(); } // File paths are stored as '/Images/filename.jpg', so we need to join with DATA_DIR // and remove the leading slash to avoid double slashes const cleanPath = filePath.startsWith('/') ? filePath.substring(1) : filePath; const fullPath = path.join(DATA_DIR, cleanPath); console.log(`[DEBUG] Attempting to delete file: ${fullPath}`); fs.unlink(fullPath, (err) => { if (err && err.code !== 'ENOENT') { console.error(`[DEBUG] Error deleting file ${fullPath}:`, err); return reject(err); } if (!err) { console.log(`[DEBUG] Successfully deleted file: ${fullPath}`); } else { console.log(`[DEBUG] File not found (already deleted?): ${fullPath}`); } resolve(); }); }); } /** * Recursively finds all sub-assets (including nested ones) for a given parent. * @param {string} parentId - The parent asset ID * @param {string} parentSubId - The parent sub-asset ID (for nested sub-assets) * @param {Array} allSubAssets - Array of all sub-assets to search through * @returns {Array} Array of all child sub-assets (direct and nested) */ function findAllChildSubAssets(parentId, parentSubId, allSubAssets) { const directChildren = allSubAssets.filter(sa => { if (parentSubId) { // Looking for sub-assets of a sub-asset return sa.parentSubId === parentSubId; } else { // Looking for sub-assets of an asset return sa.parentId === parentId && !sa.parentSubId; } }); let allChildren = [...directChildren]; // Recursively find children of each direct child for (const child of directChildren) { const nestedChildren = findAllChildSubAssets(parentId, child.id, allSubAssets); allChildren.push(...nestedChildren); } return allChildren; } /** * Deletes files associated with assets or sub-assets. * @param {string|string[]|Object|Object[]} input - File paths, asset objects, or arrays of either. */ async function deleteAssetFiles(input) { if (!input) return; // Normalize input to an array const items = Array.isArray(input) ? input : [input]; const pathsToDelete = []; // Extract file paths from assets/sub-assets or use direct paths for (const item of items) { if (typeof item === 'string') { // Direct file path console.log('[DEBUG] Will delete file path:', item); pathsToDelete.push(item); } else if (typeof item === 'object' && item !== null) { // Asset or sub-asset object - extract all file paths const asset = item; console.log('[DEBUG] Processing asset/sub-asset for deletion:', asset.id || asset.name || asset); // Photos if (asset.photoPaths && Array.isArray(asset.photoPaths)) { asset.photoPaths.forEach(p => console.log('[DEBUG] Will delete photo:', p)); pathsToDelete.push(...asset.photoPaths); } else if (asset.photoPath) { console.log('[DEBUG] Will delete photo:', asset.photoPath); pathsToDelete.push(asset.photoPath); } // Receipts if (asset.receiptPaths && Array.isArray(asset.receiptPaths)) { asset.receiptPaths.forEach(p => console.log('[DEBUG] Will delete receipt:', p)); pathsToDelete.push(...asset.receiptPaths); } else if (asset.receiptPath) { console.log('[DEBUG] Will delete receipt:', asset.receiptPath); pathsToDelete.push(asset.receiptPath); } // Manuals if (asset.manualPaths && Array.isArray(asset.manualPaths)) { asset.manualPaths.forEach(p => console.log('[DEBUG] Will delete manual:', p)); pathsToDelete.push(...asset.manualPaths); } else if (asset.manualPath) { console.log('[DEBUG] Will delete manual:', asset.manualPath); pathsToDelete.push(asset.manualPath); } } } // Delete all collected file paths for (const filePath of pathsToDelete) { if (filePath) { try { await deleteAssetFileAsync(filePath); } catch (error) { // Log error but continue trying to delete other files console.error(`[DEBUG] Failed to delete ${filePath}, continuing...`); } } } } // Initialize data directories ensureDirectoryExists(path.join(DATA_DIR, 'Images')); ensureDirectoryExists(path.join(DATA_DIR, 'Receipts')); ensureDirectoryExists(path.join(DATA_DIR, 'Manuals')); // Initialize empty files if they don't exist if (!fs.existsSync(assetsFilePath)) { writeJsonFile(assetsFilePath, []); } if (!fs.existsSync(subAssetsFilePath)) { writeJsonFile(subAssetsFilePath, []); } // API Routes // Get all assets app.get('/api/assets', (req, res) => { const assets = readJsonFile(assetsFilePath); // Ensure backwards compatibility for quantity field const assetsWithQuantity = assets.map(asset => ({ ...asset, quantity: asset.quantity || 1 })); res.json(assetsWithQuantity); }); // Get all sub-assets app.get('/api/subassets', (req, res) => { const subAssets = readJsonFile(subAssetsFilePath); // Ensure backwards compatibility for quantity field const subAssetsWithQuantity = subAssets.map(subAsset => ({ ...subAsset, quantity: subAsset.quantity || 1 })); res.json(subAssetsWithQuantity); }); // Create a new asset app.post('/api/asset', async (req, res) => { const assets = readJsonFile(assetsFilePath); const newAsset = req.body; // Ensure maintenanceEvents is always present (even if empty) newAsset.maintenanceEvents = newAsset.maintenanceEvents || []; // Ensure quantity is present for backwards compatibility if (typeof newAsset.quantity === 'undefined' || newAsset.quantity === null) { newAsset.quantity = 1; } // Ensure required fields if (!newAsset.name) { return res.status(400).json({ error: 'Asset name is required' }); } // Generate ID if not provided if (!newAsset.id) { newAsset.id = generateId(); } // Set timestamps newAsset.createdAt = new Date().toISOString(); newAsset.updatedAt = new Date().toISOString(); assets.push(newAsset); let success = writeJsonFile(assetsFilePath, assets); if (success) { if (DEBUG) { console.log('[DEBUG] Asset added:', { name: newAsset.name, modelNumber: newAsset.modelNumber, description: newAsset.description }); } // Notification logic try { const configPath = path.join(DATA_DIR, 'config.json'); let config = {}; if (fs.existsSync(configPath)) { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } const notificationSettings = config.notificationSettings || {}; const appriseUrl = process.env.APPRISE_URL || (config.appriseUrl || null); if (DEBUG) { console.log('[DEBUG] Notification settings (add):', notificationSettings, 'Apprise URL:', appriseUrl); } if (notificationSettings.notifyAdd && appriseUrl) { await sendNotification('asset_added', { id: newAsset.id, name: newAsset.name, modelNumber: newAsset.modelNumber, description: newAsset.description }, { appriseUrl, baseUrl: getBaseUrl(req) }); if (DEBUG) { console.log('[DEBUG] Asset added notification sent.'); } } } catch (err) { console.error('Failed to send asset added notification:', err.message); } res.status(201).json(newAsset); } else { res.status(500).json({ error: 'Failed to create asset' }); } }); // Update an existing asset app.put('/api/assets/:id', async (req, res) => { try { const assetId = req.params.id; const updatedAssetData = req.body; const assets = readJsonFile(path.join(DATA_DIR, 'Assets.json')); const assetIndex = assets.findIndex(a => a.id === assetId); if (assetIndex === -1) { return res.status(404).json({ message: 'Asset not found' }); } // Validate required fields if (!updatedAssetData.name) { return res.status(400).json({ error: 'Asset name is required' }); } const existingAsset = assets[assetIndex]; // Ensure quantity is present for backwards compatibility if (typeof updatedAssetData.quantity === 'undefined' || updatedAssetData.quantity === null) { updatedAssetData.quantity = existingAsset.quantity || 1; } if (updatedAssetData.filesToDelete && updatedAssetData.filesToDelete.length > 0) { await deleteAssetFiles(updatedAssetData.filesToDelete); } const finalAsset = { ...existingAsset, ...updatedAssetData, updatedAt: new Date().toISOString() }; delete finalAsset.filesToDelete; assets[assetIndex] = finalAsset; writeJsonFile(path.join(DATA_DIR, 'Assets.json'), assets); if (DEBUG) { console.log('[DEBUG] Asset updated:', { id: finalAsset.id, name: finalAsset.name, modelNumber: finalAsset.modelNumber }); } // Notification logic for asset edit try { const configPath = path.join(DATA_DIR, 'config.json'); let config = {}; if (fs.existsSync(configPath)) { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } const notificationSettings = config.notificationSettings || {}; const appriseUrl = process.env.APPRISE_URL || (config.appriseUrl || null); if (DEBUG) { console.log('[DEBUG] Notification settings (edit):', notificationSettings, 'Apprise URL:', appriseUrl); } if (notificationSettings.notifyEdit && appriseUrl) { await sendNotification('asset_edited', { id: finalAsset.id, name: finalAsset.name, modelNumber: finalAsset.modelNumber, description: finalAsset.description }, { appriseUrl, baseUrl: getBaseUrl(req) }); if (DEBUG) { console.log('[DEBUG] Asset edited notification sent.'); } } } catch (err) { console.error('Failed to send asset edited notification:', err.message); } res.json(finalAsset); } catch (error) { console.error(`Error updating asset ${req.params.id}:`, error); res.status(500).json({ message: 'Error updating asset' }); } }); // Delete an asset app.delete('/api/asset/:id', async (req, res) => { const assetId = req.params.id; const assets = readJsonFile(assetsFilePath); const subAssets = readJsonFile(subAssetsFilePath); // Find the asset to delete const assetIndex = assets.findIndex(a => a.id === assetId); if (assetIndex === -1) { return res.status(404).json({ error: 'Asset not found' }); } // Get the asset to delete const deletedAsset = assets.splice(assetIndex, 1)[0]; console.log(`[DEBUG] Deleting asset: ${deletedAsset.id} (${deletedAsset.name})`); // Find all sub-assets (including nested ones) that belong to this asset const allChildSubAssets = findAllChildSubAssets(assetId, null, subAssets); console.log(`[DEBUG] Found ${allChildSubAssets.length} sub-assets to delete for asset ${assetId}`); // Remove all related sub-assets from the array const updatedSubAssets = subAssets.filter(sa => { return sa.parentId !== assetId && !allChildSubAssets.some(child => child.id === sa.id); }); // Delete all associated files try { await deleteAssetFiles(deletedAsset); if (allChildSubAssets.length > 0) { await deleteAssetFiles(allChildSubAssets); } console.log(`[DEBUG] Deleted asset ${deletedAsset.id} and ${allChildSubAssets.length} sub-assets with their files`); } catch (error) { console.error('[DEBUG] Error deleting asset files:', error); } // Write updated assets if (writeJsonFile(assetsFilePath, assets) && writeJsonFile(subAssetsFilePath, updatedSubAssets)) { // Notification logic for asset delete try { const configPath = path.join(DATA_DIR, 'config.json'); let config = {}; if (fs.existsSync(configPath)) { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } const notificationSettings = config.notificationSettings || {}; const appriseUrl = process.env.APPRISE_URL || (config.appriseUrl || null); if (DEBUG) { console.log('[DEBUG] Notification settings (delete):', notificationSettings, 'Apprise URL:', appriseUrl); } if (notificationSettings.notifyDelete && appriseUrl) { await sendNotification('asset_deleted', { id: deletedAsset.id, name: deletedAsset.name, modelNumber: deletedAsset.modelNumber, description: deletedAsset.description }, { appriseUrl, baseUrl: getBaseUrl(req) }); if (DEBUG) { console.log('[DEBUG] Asset deleted notification sent.'); } } } catch (err) { console.error('Failed to send asset deleted notification:', err.message); } res.json({ message: 'Asset deleted successfully' }); } else { res.status(500).json({ error: 'Failed to delete asset' }); } }); // Create a new sub-asset app.post('/api/subasset', async (req, res) => { const subAssets = readJsonFile(subAssetsFilePath); const newSubAsset = req.body; // Remove legacy maintenanceReminder if present if (newSubAsset.maintenanceReminder) delete newSubAsset.maintenanceReminder; // Ensure maintenanceEvents is always present (even if empty) newSubAsset.maintenanceEvents = newSubAsset.maintenanceEvents || []; // Ensure quantity is present for backwards compatibility if (typeof newSubAsset.quantity === 'undefined' || newSubAsset.quantity === null) { newSubAsset.quantity = 1; } // Ensure required fields if (!newSubAsset.name || !newSubAsset.parentId) { return res.status(400).json({ error: 'Sub-asset name and parent ID are required' }); } // Generate ID if not provided if (!newSubAsset.id) { newSubAsset.id = generateId(); } // Set timestamps newSubAsset.createdAt = new Date().toISOString(); newSubAsset.updatedAt = new Date().toISOString(); subAssets.push(newSubAsset); if (writeJsonFile(subAssetsFilePath, subAssets)) { if (DEBUG) { console.log('[DEBUG] Sub-asset added:', { id: newSubAsset.id, name: newSubAsset.name, parentId: newSubAsset.parentId }); } // Notification logic for sub-asset creation try { const configPath = path.join(DATA_DIR, 'config.json'); let config = {}; if (fs.existsSync(configPath)) { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } const notificationSettings = config.notificationSettings || {}; const appriseUrl = process.env.APPRISE_URL || (config.appriseUrl || null); if (DEBUG) { console.log('[DEBUG] Sub-asset notification settings (add):', notificationSettings, 'Apprise URL:', appriseUrl); } if (notificationSettings.notifyAdd && appriseUrl) { await sendNotification('asset_added', { id: newSubAsset.id, parentId: newSubAsset.parentId, name: `${newSubAsset.name} (Component)`, modelNumber: newSubAsset.modelNumber, description: newSubAsset.description || newSubAsset.notes }, { appriseUrl, baseUrl: getBaseUrl(req) }); if (DEBUG) { console.log('[DEBUG] Sub-asset added notification sent.'); } } } catch (err) { console.error('Failed to send sub-asset added notification:', err.message); } res.status(201).json(newSubAsset); } else { res.status(500).json({ error: 'Failed to create sub-asset' }); } }); // Update an existing sub-asset app.put('/api/subassets/:id', async (req, res) => { try { const subAssetId = req.params.id; const updatedSubAssetData = req.body; const subAssets = readJsonFile(path.join(DATA_DIR, 'SubAssets.json')); const subAssetIndex = subAssets.findIndex(sa => sa.id === subAssetId); if (subAssetIndex === -1) { return res.status(404).json({ message: 'Sub-asset not found' }); } // Validate required fields if (!updatedSubAssetData.name) { return res.status(400).json({ error: 'Sub-asset name is required' }); } const existingSubAsset = subAssets[subAssetIndex]; // Ensure quantity is present for backwards compatibility if (typeof updatedSubAssetData.quantity === 'undefined' || updatedSubAssetData.quantity === null) { updatedSubAssetData.quantity = existingSubAsset.quantity || 1; } if (updatedSubAssetData.filesToDelete && updatedSubAssetData.filesToDelete.length > 0) { await deleteAssetFiles(updatedSubAssetData.filesToDelete); } const finalSubAsset = { ...existingSubAsset, ...updatedSubAssetData, updatedAt: new Date().toISOString() }; delete finalSubAsset.filesToDelete; subAssets[subAssetIndex] = finalSubAsset; writeJsonFile(path.join(DATA_DIR, 'SubAssets.json'), subAssets); if (DEBUG) { console.log('[DEBUG] Sub-asset updated:', { id: finalSubAsset.id, name: finalSubAsset.name, parentId: finalSubAsset.parentId }); } // Notification logic for sub-asset edit try { const configPath = path.join(DATA_DIR, 'config.json'); let config = {}; if (fs.existsSync(configPath)) { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } const notificationSettings = config.notificationSettings || {}; const appriseUrl = process.env.APPRISE_URL || (config.appriseUrl || null); if (DEBUG) { console.log('[DEBUG] Sub-asset notification settings (edit):', notificationSettings, 'Apprise URL:', appriseUrl); } if (notificationSettings.notifyEdit && appriseUrl) { await sendNotification('asset_edited', { id: finalSubAsset.id, parentId: finalSubAsset.parentId, name: `${finalSubAsset.name} (Component)`, modelNumber: finalSubAsset.modelNumber, description: finalSubAsset.description || finalSubAsset.notes }, { appriseUrl, baseUrl: getBaseUrl(req) }); if (DEBUG) { console.log('[DEBUG] Sub-asset edited notification sent.'); } } } catch (err) { console.error('Failed to send sub-asset edited notification:', err.message); } res.json(finalSubAsset); } catch (error) { console.error(`Error updating sub-asset ${req.params.id}:`, error); res.status(500).json({ message: 'Error updating sub-asset' }); } }); // Delete a sub-asset app.delete('/api/subasset/:id', async (req, res) => { const subAssetId = req.params.id; const subAssets = readJsonFile(subAssetsFilePath); // Find the sub-asset to delete const subAssetIndex = subAssets.findIndex(sa => sa.id === subAssetId); if (subAssetIndex === -1) { return res.status(404).json({ error: 'Sub-asset not found' }); } // Get the sub-asset to delete const deletedSubAsset = subAssets.splice(subAssetIndex, 1)[0]; console.log(`[DEBUG] Deleting sub-asset: ${deletedSubAsset.id} (${deletedSubAsset.name})`); // Find all child sub-assets (nested ones) that belong to this sub-asset const allChildSubAssets = findAllChildSubAssets(deletedSubAsset.parentId, subAssetId, subAssets); console.log(`[DEBUG] Found ${allChildSubAssets.length} nested sub-assets to delete for sub-asset ${subAssetId}`); // Remove all related sub-assets from the array const updatedSubAssets = subAssets.filter(sa => { return sa.id !== subAssetId && !allChildSubAssets.some(child => child.id === sa.id); }); // Delete all associated files try { await deleteAssetFiles(deletedSubAsset); if (allChildSubAssets.length > 0) { await deleteAssetFiles(allChildSubAssets); } console.log(`[DEBUG] Deleted sub-asset ${deletedSubAsset.id} and ${allChildSubAssets.length} nested sub-assets with their files`); } catch (error) { console.error('[DEBUG] Error deleting files:', error); } // Write updated sub-assets if (writeJsonFile(subAssetsFilePath, updatedSubAssets)) { // Notification logic for sub-asset delete try { const configPath = path.join(DATA_DIR, 'config.json'); let config = {}; if (fs.existsSync(configPath)) { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } const notificationSettings = config.notificationSettings || {}; const appriseUrl = process.env.APPRISE_URL || (config.appriseUrl || null); if (DEBUG) { console.log('[DEBUG] Sub-asset notification settings (delete):', notificationSettings, 'Apprise URL:', appriseUrl); } if (notificationSettings.notifyDelete && appriseUrl) { await sendNotification('asset_deleted', { id: deletedSubAsset.id, parentId: deletedSubAsset.parentId, name: `${deletedSubAsset.name} (Component)`, modelNumber: deletedSubAsset.modelNumber, description: deletedSubAsset.description || deletedSubAsset.notes }, { appriseUrl, baseUrl: getBaseUrl(req) }); if (DEBUG) { console.log('[DEBUG] Sub-asset deleted notification sent.'); } } } catch (err) { console.error('Failed to send sub-asset deleted notification:', err.message); } res.json({ message: 'Sub-asset deleted successfully' }); } else { res.status(500).json({ error: 'Failed to delete sub-asset' }); } }); // File upload endpoints const imageStorage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, path.join(DATA_DIR, 'Images')); }, filename: (req, file, cb) => { const safeName = sanitizeFileName(file.originalname); cb(null, `${uuidv4()}${path.extname(safeName)}`); } }); const receiptStorage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, path.join(DATA_DIR, 'Receipts')); }, filename: (req, file, cb) => { const safeName = sanitizeFileName(file.originalname); cb(null, `${uuidv4()}${path.extname(safeName)}`); } }); const manualStorage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, path.join(DATA_DIR, 'Manuals')); }, filename: (req, file, cb) => { const safeName = sanitizeFileName(file.originalname); cb(null, `${uuidv4()}${path.extname(safeName)}`); } }); const uploadImage = multer({ storage: imageStorage, fileFilter: (req, file, cb) => { if (file.mimetype.startsWith('image/')) { cb(null, true); } else { cb(new Error('Only image files are allowed')); } } }); const uploadReceipt = multer({ storage: receiptStorage, fileFilter: (req, file, cb) => { if (file.mimetype.startsWith('image/') || file.mimetype === 'application/pdf') { cb(null, true); } else { cb(new Error('Only image and PDF files are allowed')); } } }); const uploadManual = multer({ storage: manualStorage, fileFilter: (req, file, cb) => { const allowedTypes = [ 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'text/markdown', 'text/plain', ]; if (allowedTypes.includes(file.mimetype)) { cb(null, true); } else { cb(new Error('Only PDF, DOC, and DOCX files are allowed')); } } }); app.post('/api/upload/image', uploadImage.array('photo', 10), (req, res) => { if (!req.files || req.files.length === 0) return res.status(400).json({ error: 'No files uploaded' }); const uploadedFiles = req.files.map(file => { const stats = fs.statSync(file.path); return { path: `/Images/${sanitizeFileName(file.filename)}`, fileInfo: { originalName: sanitizeFileName(file.originalname), size: stats.size, fileName: sanitizeFileName(file.filename) } }; }); res.json({ files: uploadedFiles }); }); app.post('/api/upload/receipt', uploadReceipt.array('receipt', 10), (req, res) => { if (!req.files || req.files.length === 0) return res.status(400).json({ error: 'No files uploaded' }); const uploadedFiles = req.files.map(file => { const stats = fs.statSync(file.path); return { path: `/Receipts/${sanitizeFileName(file.filename)}`, fileInfo: { originalName: sanitizeFileName(file.originalname), size: stats.size, fileName: sanitizeFileName(file.filename) } }; }); res.json({ files: uploadedFiles }); }); app.post('/api/upload/manual', uploadManual.array('manual', 10), (req, res) => { if (!req.files || req.files.length === 0) return res.status(400).json({ error: 'No files uploaded' }); const uploadedFiles = req.files.map(file => { const stats = fs.statSync(file.path); return { path: `/Manuals/${sanitizeFileName(file.filename)}`, fileInfo: { originalName: sanitizeFileName(file.originalname), size: stats.size, fileName: sanitizeFileName(file.filename) } }; }); res.json({ files: uploadedFiles }); }); // Delete a file (image, receipt, or manual) app.post('/api/delete-file', (req, res) => { const { path: filePath } = req.body; if (!filePath) return res.status(400).json({ error: 'No file path provided' }); const absPath = path.join(__dirname, filePath.startsWith('/') ? filePath.substring(1) : filePath); fs.unlink(absPath, (err) => { if (err) { // If file doesn't exist, treat as success if (err.code === 'ENOENT') return res.json({ message: 'File already deleted' }); return res.status(500).json({ error: 'Failed to delete file' }); } res.json({ message: 'File deleted' }); }); }); // Configure multer for file uploads const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024 // 5MB limit } }); // Helper function to parse Excel dates function parseExcelDate(value) { if (!value) return ''; // If it's already a date string in ISO format, return as is if (typeof value === 'string' && value.match(/^\d{4}-\d{2}-\d{2}/)) { return value; } // If it's a number (Excel date), convert it if (typeof value === 'number') { // Excel's epoch starts from Dec 30, 1899 const date = new Date((value - 25569) * 86400 * 1000); return date.toISOString().split('T')[0]; } // Try to parse MM/DD/YYYY format if (typeof value === 'string' && value.match(/^\d{1,2}\/\d{1,2}\/\d{4}$/)) { const [month, day, year] = value.split('/').map(Number); const date = new Date(year, month - 1, day); if (!isNaN(date.getTime())) { return date.toISOString().split('T')[0]; } } // Try to parse as regular date string try { const date = new Date(value); if (!isNaN(date.getTime())) { return date.toISOString().split('T')[0]; } } catch (e) { console.log('Failed to parse date:', value); } return ''; } function getAppSettings() { const configPath = path.join(DATA_DIR, 'config.json'); // Return default settings if config does not exist if (!fs.existsSync(configPath)) { return { ...DEFAULT_SETTINGS }; } const config = { ...DEFAULT_SETTINGS, ...JSON.parse(fs.readFileSync(configPath, 'utf8'))}; return config; } // Import assets route app.post('/api/import-assets', upload.single('file'), (req, res) => { try { const file = req.file; if (!file) { return res.status(400).json({ error: 'No file uploaded' }); } // If only headers are requested (first step), return headers if (!req.body.mappings) { let workbook = XLSX.read(file.buffer, { type: 'buffer' }); let sheet = workbook.Sheets[workbook.SheetNames[0]]; let json = XLSX.utils.sheet_to_json(sheet, { header: 1 }); const headers = json[0] || []; return res.json({ headers }); } // Parse mappings const mappings = JSON.parse(req.body.mappings); let workbook = XLSX.read(file.buffer, { type: 'buffer' }); let sheet = workbook.Sheets[workbook.SheetNames[0]]; let json = XLSX.utils.sheet_to_json(sheet, { header: 1 }); const headers = json[0] || []; const rows = json.slice(1); let importedCount = 0; let assets = readJsonFile(assetsFilePath); for (const row of rows) { if (!row.length) continue; const get = idx => (mappings[idx] !== undefined && mappings[idx] !== "" && row[mappings[idx]] !== undefined) ? row[mappings[idx]] : ""; const name = get('name'); if (!name) continue; // Parse lifetime warranty value const lifetimeValue = get('lifetime'); const isLifetime = lifetimeValue ? (lifetimeValue.toString().toLowerCase() === 'true' || lifetimeValue.toString().toLowerCase() === '1' || lifetimeValue.toString().toLowerCase() === 'yes') : false; const asset = { id: generateId(), name: name, manufacturer: get('manufacturer'), modelNumber: get('model'), serialNumber: get('serial'), purchaseDate: parseExcelDate(get('purchaseDate')), price: get('purchasePrice'), quantity: parseInt(get('quantity')) || 1, description: get('notes'), link: get('url'), warranty: { scope: get('warranty'), expirationDate: isLifetime ? null : parseExcelDate(get('warrantyExpiration')), isLifetime: isLifetime }, secondaryWarranty: { scope: get('secondaryWarranty'), expirationDate: parseExcelDate(get('secondaryWarrantyExpiration')) }, tags: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; // Parse tags if mapped if (mappings.tags !== undefined && mappings.tags !== "" && row[mappings.tags] !== undefined) { const tagsRaw = row[mappings.tags]; if (typeof tagsRaw === 'string') { asset.tags = tagsRaw.split(/[,;]+/).map(t => t.trim()).filter(Boolean); } else if (Array.isArray(tagsRaw)) { asset.tags = tagsRaw.map(t => String(t).trim()).filter(Boolean); } } assets.push(asset); importedCount++; } writeJsonFile(assetsFilePath, assets); res.json({ importedCount }); } catch (err) { console.error('Import error:', err); res.status(500).json({ error: 'Failed to import assets' }); } }); // Get all settings app.get('/api/settings', (req, res) => { try { const appSettings = getAppSettings(); res.json(appSettings); } catch (err) { res.status(500).json({ error: 'Failed to load settings' }); } }); // Save all settings app.post('/api/settings', (req, res) => { try { const config = getAppSettings(); // Update settings with the new values const updatedConfig = { ...config, ...req.body }; const configPath = path.join(DATA_DIR, 'config.json'); fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2)); res.json({ success: true }); } catch (err) { res.status(500).json({ error: 'Failed to save settings' }); } }); // Test notification endpoint app.post('/api/notification-test', async (req, res) => { if (DEBUG) { console.log('[DEBUG] /api/notification-test called'); } try { const configPath = path.join(DATA_DIR, 'config.json'); let config = {}; if (fs.existsSync(configPath)) { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } // Use APPRISE_URL from env or config const appriseUrl = process.env.APPRISE_URL || (config.appriseUrl || null); // Get notification settings from request body const notificationSettings = req.body || {}; // Ensure notification settings are present if (!notificationSettings) { return res.status(400).json({ error: 'No notification settings provided.' }); } // Ensure notification settings are valid if (!notificationSettings.enabledTypes || !Array.isArray(notificationSettings.enabledTypes)) { return res.status(400).json({ error: 'Invalid notification settings provided.' }); } // Ensure APPRISE_URL is present if (!appriseUrl) { return res.status(400).json({ error: 'No Apprise URL configured.' }); } // Log the notification settings and Apprise URL for debugging if (DEBUG) { console.log('[DEBUG] Notification settings (test):', notificationSettings, 'Apprise URL:', appriseUrl); } if (!appriseUrl) return res.status(400).json({ error: 'No Apprise URL configured.' }); // Get enabled notification types from request body const { enabledTypes } = notificationSettings;; if (!enabledTypes || !Array.isArray(enabledTypes)) { return res.status(400).json({ error: 'No enabled notification types provided.' }); } // Send test notifications for each enabled type console.log('Enabled notification types:', enabledTypes); for (const type of enabledTypes) { let notificationData = {}; let message = ''; switch (type) { case 'notifyAdd': notificationData = { name: 'Quantum Router (notifyAdd Test)', modelNumber: 'Q-Bit-9000', serialNumber: '0xDEADBEEF', description: "✅ It's in a superposition of working and not working." }; message = `Test: Asset Added Notification\n\nTest Asset: ${notificationData.name} (Model: ${notificationData.modelNumber}, Serial: ${notificationData.serialNumber}) has been added to your inventory. ${notificationData.description}`; break; case 'notifyDelete': notificationData = { name: 'Stack Overflow Generator (notifyDelete Test)', modelNumber: 'SO-404', serialNumber: 'NULL-PTR', description: '❌ It finally found the answer it was looking for.' }; message = `Test: Asset Deleted Notification\n\nTest Asset: ${notificationData.name} (Model: ${notificationData.modelNumber}, Serial: ${notificationData.serialNumber}) has been removed from your inventory. ${notificationData.description}`; break; case 'notifyEdit': notificationData = { name: 'Recursive Coffee Maker (notifyEdit Test)', modelNumber: 'Java-8', serialNumber: 'Stack-Overflow', description: '✏️ It now makes coffee while making coffee.' }; message = `Test: Asset Edited Notification\n\nTest Asset: ${notificationData.name} (Model: ${notificationData.modelNumber}, Serial: ${notificationData.serialNumber}) has been updated. ${notificationData.description}`; break; case 'notify1Month': notificationData = { name: 'Infinite Loop Detector (notify1Month Test)', modelNumber: 'Break-1', serialNumber: 'While-True', description: '⏳ Without it, you might be stuck in an endless cycle of debugging.' }; message = `Test: Warranty Expiring in 1 Month\n\nTest Asset: ${notificationData.name} (Model: ${notificationData.modelNumber}, Serial: ${notificationData.serialNumber}) warranty expires in 1 month. ${notificationData.description}`; break; case 'notify2Week': notificationData = { name: 'Memory Leak Plug (notify2Week Test)', modelNumber: 'GC-2023', serialNumber: 'OutOfMemory', description: '⏳ Without warranty, it might forget to forget things.' }; message = `Test: Warranty Expiring in 2 Weeks\n\nTest Asset: ${notificationData.name} (Model: ${notificationData.modelNumber}, Serial: ${notificationData.serialNumber}) warranty expires in 2 weeks. ${notificationData.description}`; break; case 'notify7Day': notificationData = { name: 'Binary Clock (notify7Day Test)', modelNumber: '0x10', serialNumber: '0b1010', description: '⏳ Without warranty, it might start counting in hexadecimal.' }; message = `Test: Warranty Expiring in 7 Days\n\nTest Asset: ${notificationData.name} (Model: ${notificationData.modelNumber}, Serial: ${notificationData.serialNumber}) warranty expires in 7 days. ${notificationData.description}`; break; case 'notify3Day': notificationData = { name: 'Cache Warmer (notify3Day Test)', modelNumber: 'L1-L2-L3', serialNumber: 'Miss-Rate', description: '⏳ Without warranty, your data might get cold feet.' }; message = `Test: Warranty Expiring in 3 Days\n\nTest Asset: ${notificationData.name} (Model: ${notificationData.modelNumber}, Serial: ${notificationData.serialNumber}) warranty expires in 3 days. ${notificationData.description}`; break; case 'notifyMaintenance': notificationData = { name: 'Entropy Reducer (notifyMaintenance Test)', modelNumber: 'MTN-42', serialNumber: 'SCH-2025', description: '🛠️ Scheduled maintenance is due soon. Keep your assets running smoothly!' }; message = `Test: Maintenance Schedule Notification\n\nTest Asset: ${notificationData.name} (Model: ${notificationData.modelNumber}, Serial: ${notificationData.serialNumber}) is due for scheduled maintenance. ${notificationData.description}`; break; } // Send the notification await sendNotification('test', notificationData, { appriseUrl, appriseMessage: message }); // Note: No manual delay needed - the notification queue handles delays automatically } if (DEBUG) { console.log('[DEBUG] Test notifications sent.'); } res.json({ success: true }); } catch (err) { res.status(500).json({ error: 'Failed to send test notifications.' }); } }); // --- CLEANUP LOCKOUTS --- setInterval(() => { const now = Date.now(); for (const [ip, attempts] of loginAttempts.entries()) { if (now - attempts.lastAttempt >= LOCKOUT_TIME) { loginAttempts.delete(ip); } } }, 60000); // Warranty expiration notification cron startWarrantyCron(); // --- START SERVER --- app.listen(PORT, () => { debugLog('Server Configuration:', { port: PORT, basePath: BASE_PATH, pinProtection: !!PIN, nodeEnv: NODE_ENV, debug: DEBUG, version: VERSION, demoMode: DEMO_MODE, }); console.log(`Server running on: ${BASE_URL}`); }); // --- END ---