const express = require('express'); const cors = require('cors'); const path = require('path'); // Prioritize machine-specific local config (.env.local) require('dotenv').config({ path: path.join(__dirname, '.env.local') }); require('dotenv').config(); // Fallback to standard .env const db = require('./db'); const authRoutes = require('./routes/auth'); const cameraRoutes = require('./modules/cctv/routes'); const StreamRelay = require('./modules/cctv/streamRelay'); const { csrfProtection } = require('./middleware/csrfMiddleware'); const { isAuthenticated } = require('./middleware/authMiddleware'); const app = express(); const PORT = process.env.PORT || 3005; // Changed to 3005 to avoid conflict with Synology services (3001 issue) const path = require('path'); const fs = require('fs'); // Ensure uploads directory exists const uploadDir = path.join(__dirname, 'uploads'); if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir); } // Multer Configuration const multer = require('multer'); const storage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, uploadDir); }, filename: (req, file, cb) => { // Use timestamp + random number + extension to avoid encoding issues with Korean filenames const ext = path.extname(file.originalname); const filename = `${Date.now()}-${Math.round(Math.random() * 1000)}${ext}`; cb(null, filename); } }); const upload = multer({ storage: storage }); // Session Store Configuration const session = require('express-session'); const MySQLStore = require('express-mysql-session')(session); const sessionStoreOptions = { host: process.env.DB_HOST, port: process.env.DB_PORT || 3306, user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, clearExpired: true, checkExpirationInterval: 900000, // 15 min expiration: 86400000 // 1 day }; const sessionStore = new MySQLStore(sessionStoreOptions); sessionStore.on('error', (err) => { console.error('Session Store Error:', err); }); // Middleware app.use(cors({ origin: true, credentials: true, allowedHeaders: ['Content-Type', 'X-CSRF-Token', 'Authorization'] })); app.use(express.json()); app.use('/uploads', express.static(uploadDir)); // Session Middleware app.use(session({ key: 'smartims_sid', secret: process.env.SESSION_SECRET || 'smartims_session_secret_key', store: sessionStore, resave: false, saveUninitialized: false, rolling: false, // Do not automatic rolling (we control it in middleware) cookie: { httpOnly: true, secure: false, // HTTPS 사용 시 true로 변경 필요 maxAge: 3600000, // 기본 1시간 (미들웨어에서 동적 조정) sameSite: 'lax' } })); // Dynamic Session Timeout Middleware app.use(async (req, res, next) => { if (req.session && req.session.user) { // Skip session extension for background check requests if (req.path === '/api/check' || req.path === '/check' || req.path.includes('/auth/check')) { return next(); } try { // Priority: User's individual timeout > System default let timeoutMinutes = 60; // Default fallback if (req.session.user.session_timeout) { timeoutMinutes = parseInt(req.session.user.session_timeout); } else { const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'session_timeout'"); timeoutMinutes = rows.length > 0 ? parseInt(rows[0].setting_value) : 10; // New default fallback 10 as requested } req.session.cookie.maxAge = timeoutMinutes * 60 * 1000; // Explicitly save session before moving to next middleware req.session.save((err) => { if (err) console.error('Session save error:', err); next(); }); return; } catch (err) { console.error('Session timeout fetch error:', err); } } next(); }); // Apply CSRF Protection app.use(csrfProtection); // Request Logger with Session Remaining Time app.use((req, res, next) => { const now = new Date(); const kstDate = new Date(now.getTime() + (9 * 60 * 60 * 1000)).toISOString().replace('Z', '').replace('T', ' '); let sessionInfo = ''; if (req.session && req.session.cookie && req.session.cookie.expires) { const remainingMs = req.session.cookie.expires - now; if (remainingMs > 0) { const remMin = Math.floor(remainingMs / 60000); const remSec = Math.floor((remainingMs % 60000) / 1000); sessionInfo = ` [Session: ${remMin}m ${remSec}s left]`; } else { sessionInfo = ` [Session: Expired]`; } } console.log(`[${kstDate}]${sessionInfo} ${req.method} ${req.url}`); next(); }); // Test DB Connection db.query('SELECT 1') .then(() => console.log('✅ Connected to Database')) .catch(err => { console.error('❌ Database Connection Failed:', err.message); console.error('Hint: Check your .env DB_HOST and ensure Synology MariaDB allows remote connections.'); }); // Ensure Tables Exist const initTables = async () => { try { const assetTable = ` CREATE TABLE IF NOT EXISTS assets ( id VARCHAR(20) PRIMARY KEY, name VARCHAR(100) NOT NULL, category VARCHAR(50) NOT NULL, image_url VARCHAR(255), model_name VARCHAR(100), serial_number VARCHAR(100), manufacturer VARCHAR(100), location VARCHAR(100), purchase_date DATE, purchase_price BIGINT, manager VARCHAR(50), status ENUM('active', 'maintain', 'broken', 'disposed') DEFAULT 'active', calibration_cycle VARCHAR(50), specs TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `; const maintenanceTable = ` CREATE TABLE IF NOT EXISTS maintenance_history ( id INT AUTO_INCREMENT PRIMARY KEY, asset_id VARCHAR(20) NOT NULL, maintenance_date DATE NOT NULL, type VARCHAR(50) NOT NULL, content TEXT, image_url VARCHAR(255), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `; await db.query(assetTable); await db.query(assetTable); await db.query(maintenanceTable); // Check/Add 'quantity' column to assets const [assetCols] = await db.query("SHOW COLUMNS FROM assets LIKE 'quantity'"); if (assetCols.length === 0) { await db.query("ALTER TABLE assets ADD COLUMN quantity INT DEFAULT 1"); console.log("✅ Added 'quantity' column to assets"); } // Check/Add 'parent_id' column to assets for sub-equipment management const [parentIdCols] = await db.query("SHOW COLUMNS FROM assets LIKE 'parent_id'"); if (parentIdCols.length === 0) { await db.query("ALTER TABLE assets ADD COLUMN parent_id VARCHAR(20) AFTER id"); await db.query("ALTER TABLE assets ADD CONSTRAINT fk_assets_parent FOREIGN KEY (parent_id) REFERENCES assets(id) ON DELETE SET NULL"); console.log("✅ Added 'parent_id' column to assets"); } // Create maintenance_parts table const maintenancePartsTable = ` CREATE TABLE IF NOT EXISTS maintenance_parts ( id INT AUTO_INCREMENT PRIMARY KEY, maintenance_id INT NOT NULL, part_id VARCHAR(20) NOT NULL, quantity INT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (maintenance_id) REFERENCES maintenance_history(id) ON DELETE CASCADE, FOREIGN KEY (part_id) REFERENCES assets(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `; await db.query(maintenancePartsTable); const [usersTableExists] = await db.query("SHOW TABLES LIKE 'users'"); if (usersTableExists.length === 0) { const usersTableSQL = ` CREATE TABLE IF NOT EXISTS users ( id VARCHAR(50) PRIMARY KEY, password VARCHAR(255) NOT NULL, name VARCHAR(100) NOT NULL, department VARCHAR(100), position VARCHAR(100), phone VARCHAR(255), role ENUM('supervisor', 'admin', 'user') DEFAULT 'user', session_timeout INT DEFAULT 10, last_login TIMESTAMP NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `; await db.query(usersTableSQL); console.log('✅ Users Table Created'); // Default Admin const adminId = 'admin'; const [adminExists] = await db.query('SELECT 1 FROM users WHERE id = ?', [adminId]); if (adminExists.length === 0) { const crypto = require('crypto'); const hashedPass = crypto.createHash('sha256').update('admin123').digest('hex'); await db.query( 'INSERT INTO users (id, password, name, role, department, position) VALUES (?, ?, ?, ?, ?, ?)', [adminId, hashedPass, '관리자', 'supervisor', 'IT팀', '관리자'] ); console.log('✅ Default Admin Created as Supervisor'); } } // 2. Ensure schema updates for existing table try { await db.query("ALTER TABLE users MODIFY COLUMN role ENUM('supervisor', 'admin', 'user') DEFAULT 'user'"); } catch (e) { // Ignore } const [userTimeoutCols] = await db.query("SHOW COLUMNS FROM users LIKE 'session_timeout'"); if (userTimeoutCols.length === 0) { await db.query("ALTER TABLE users ADD COLUMN session_timeout INT DEFAULT 10 AFTER role"); console.log("✅ Added 'session_timeout' column to users"); } console.log('✅ Tables Initialized'); // Create asset_manuals table const manualTable = ` CREATE TABLE IF NOT EXISTS asset_manuals ( id INT AUTO_INCREMENT PRIMARY KEY, asset_id VARCHAR(20) NOT NULL, file_name VARCHAR(255) NOT NULL, file_url VARCHAR(255) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `; await db.query(manualTable); // Create asset_accessories table const accessoryTable = ` CREATE TABLE IF NOT EXISTS asset_accessories ( id INT AUTO_INCREMENT PRIMARY KEY, asset_id VARCHAR(20) NOT NULL, name VARCHAR(100) NOT NULL, spec VARCHAR(100), quantity INT DEFAULT 1, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `; await db.query(accessoryTable); // 1. Rename camera_settings to cctv_settings if old table exists const [oldCamTable] = await db.query("SHOW TABLES LIKE 'camera_settings'"); if (oldCamTable.length > 0) { await db.query("RENAME TABLE camera_settings TO cctv_settings"); console.log("✅ Renamed 'camera_settings' to 'cctv_settings'"); } // Create cctv_settings table const cameraTable = ` CREATE TABLE IF NOT EXISTS cctv_settings ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, zone VARCHAR(50) DEFAULT '기본 구역', ip_address VARCHAR(100) NOT NULL, port INT DEFAULT 554, username VARCHAR(100), password VARCHAR(255), stream_path VARCHAR(200) DEFAULT '/stream1', is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `; await db.query(cameraTable); // Ensure password field is long enough for encryption await db.query("ALTER TABLE cctv_settings MODIFY COLUMN password VARCHAR(255)"); // Check for 'transport_mode' and 'rtsp_encoding' columns and add if missing const [camColumns] = await db.query("SHOW COLUMNS FROM cctv_settings LIKE 'transport_mode'"); if (camColumns.length === 0) { await db.query("ALTER TABLE cctv_settings ADD COLUMN transport_mode ENUM('tcp', 'udp', 'auto') DEFAULT 'tcp' AFTER stream_path"); await db.query("ALTER TABLE cctv_settings ADD COLUMN rtsp_encoding BOOLEAN DEFAULT FALSE AFTER transport_mode"); await db.query("ALTER TABLE cctv_settings ADD COLUMN quality ENUM('low', 'medium', 'original') DEFAULT 'low' AFTER transport_mode"); // Default to low for stability console.log("✅ Added 'transport_mode', 'quality', and 'rtsp_encoding' columns to cctv_settings"); } else { // Check if quality exists (for subsequent updates) const [qualCol] = await db.query("SHOW COLUMNS FROM cctv_settings LIKE 'quality'"); if (qualCol.length === 0) { await db.query("ALTER TABLE cctv_settings ADD COLUMN quality ENUM('low', 'medium', 'original') DEFAULT 'low' AFTER transport_mode"); console.log("✅ Added 'quality' column to cctv_settings"); } } // Check for 'zone' column const [zoneCol] = await db.query("SHOW COLUMNS FROM cctv_settings LIKE 'zone'"); if (zoneCol.length === 0) { await db.query("ALTER TABLE cctv_settings ADD COLUMN zone VARCHAR(50) DEFAULT '기본 구역' AFTER name"); console.log("✅ Added 'zone' column to cctv_settings"); } // Check for 'display_order' column const [orderCol] = await db.query("SHOW COLUMNS FROM cctv_settings LIKE 'display_order'"); if (orderCol.length === 0) { await db.query("ALTER TABLE cctv_settings ADD COLUMN display_order INT DEFAULT 0 AFTER quality"); console.log("✅ Added 'display_order' column to cctv_settings"); } // Create cctv_zones table const zoneTable = ` CREATE TABLE IF NOT EXISTS cctv_zones ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL UNIQUE, layout ENUM('1', '1*2', '2*2') DEFAULT '2*2', display_order INT DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `; await db.query(zoneTable); // Check for 'layout' column (for migration) const [layoutCol] = await db.query("SHOW COLUMNS FROM cctv_zones LIKE 'layout'"); if (layoutCol.length === 0) { await db.query("ALTER TABLE cctv_zones ADD COLUMN layout VARCHAR(10) DEFAULT '2*2' AFTER name"); console.log("✅ Added 'layout' column to cctv_zones"); } else { // Migration: Convert ENUM to VARCHAR await db.query("ALTER TABLE cctv_zones MODIFY COLUMN layout VARCHAR(10) DEFAULT '2*2'"); } // Initialize default zone if empty const [existingZones] = await db.query('SELECT 1 FROM cctv_zones LIMIT 1'); if (existingZones.length === 0) { await db.query("INSERT INTO cctv_zones (name, display_order) VALUES ('기본 구역', 0)"); console.log("✅ Initialized default CCTV zone"); } // Create system_settings table (Key-Value store) const systemSettingsTable = ` CREATE TABLE IF NOT EXISTS system_settings ( setting_key VARCHAR(50) PRIMARY KEY, setting_value TEXT, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `; await db.query(systemSettingsTable); // Create system_modules table const systemModulesTable = ` CREATE TABLE IF NOT EXISTS system_modules ( code VARCHAR(50) PRIMARY KEY, name VARCHAR(100) NOT NULL, is_active BOOLEAN DEFAULT FALSE, license_key TEXT, license_type VARCHAR(20), expiry_date DATETIME, subscriber_id VARCHAR(50), updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `; await db.query(systemModulesTable); // Create license_history table const licenseHistoryTable = ` CREATE TABLE IF NOT EXISTS license_history ( id INT AUTO_INCREMENT PRIMARY KEY, module_code VARCHAR(50) NOT NULL, license_key TEXT, license_type VARCHAR(20), subscriber_id VARCHAR(50), activated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, replaced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `; await db.query(licenseHistoryTable); // Create issued_licenses table (for tracking generated keys) const issuedLicensesTable = ` CREATE TABLE IF NOT EXISTS issued_licenses ( id INT AUTO_INCREMENT PRIMARY KEY, module_code VARCHAR(50) NOT NULL, license_key TEXT, license_type VARCHAR(20), subscriber_id VARCHAR(50), status VARCHAR(20) DEFAULT 'ISSUED', -- ISSUED, ACTIVE, EXPIRED, REVOKED created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, activation_deadline DATETIME ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `; await db.query(issuedLicensesTable); // Check for 'subscriber_id' column in system_modules (for existing installations) const [modColumns] = await db.query("SHOW COLUMNS FROM system_modules LIKE 'subscriber_id'"); if (modColumns.length === 0) { await db.query("ALTER TABLE system_modules ADD COLUMN subscriber_id VARCHAR(50) AFTER expiry_date"); console.log("✅ Added 'subscriber_id' column to system_modules"); } // Initialize default modules if empty (Disabled by default as per request behavior) const [existingModules] = await db.query('SELECT 1 FROM system_modules LIMIT 1'); if (existingModules.length === 0) { const insert = `INSERT INTO system_modules (code, name, is_active, license_type) VALUES (?, ?, ?, ?)`; await db.query(insert, ['asset', '자산 관리', false, null]); await db.query(insert, ['production', '생산 관리', false, null]); await db.query(insert, ['cctv', 'CCTV', false, null]); } else { // One-time update: Rename 'monitoring' code to 'cctv' (migration) // Use subquery or check if cctv exists to avoid ER_DUP_ENTRY const [cctvExists] = await db.query("SELECT 1 FROM system_modules WHERE code = 'cctv'"); if (cctvExists.length > 0) { // If cctv already exists, just remove monitoring if it's there await db.query("DELETE FROM system_modules WHERE code = 'monitoring'"); } else { // If cctv doesn't exist, try renaming monitoring await db.query("UPDATE system_modules SET code = 'cctv' WHERE code = 'monitoring'"); } } console.log('✅ Tables Initialized'); } catch (err) { console.error('❌ Table Initialization Failed:', err); } }; initTables(); const packageJson = require('./package.json'); app.get('/api/health', (req, res) => { // Dynamic version check (Light-weight) const kstOffset = 9 * 60 * 60 * 1000; const kstDate = new Date(Date.now() + kstOffset); let version = packageJson.version; try { const { execSync } = require('child_process'); // Check git tag in parent directory (Project root) version = execSync('git describe --tags --abbrev=0', { cwd: path.join(__dirname, '..'), stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim().replace(/^v/, ''); } catch (e) { // Safe fallback to package.json } res.json({ status: 'ok', version: version, node_version: process.version, platform: process.platform, arch: process.arch, timestamp: kstDate.toISOString().replace('T', ' ').split('.')[0] }); }); // Routes app.use('/api', authRoutes); app.use('/api/cameras', cameraRoutes); app.use('/api/system', require('./routes/system')); const { requireModule } = require('./middleware/licenseMiddleware'); // Protect following routes app.use(['/api/assets', '/api/maintenance', '/api/manuals', '/api/upload'], isAuthenticated, requireModule('asset')); // 0. File Upload Endpoint app.post('/api/upload', upload.single('image'), (req, res) => { if (!req.file) { return res.status(400).json({ error: 'No file uploaded' }); } // Return the full URL or relative path // Assuming server is accessed via same host, we just return the path const fileUrl = `/uploads/${req.file.filename}`; res.json({ url: fileUrl }); }); // 1. Get All Assets (Moved to modules/asset/routes.js) // 2. Get Asset by ID (Moved to modules/asset/routes.js) // 3. Create Asset (Moved to modules/asset/routes.js) // 4. Update Asset (Moved to modules/asset/routes.js) // 5. Get All Maintenance Records for an Asset (Moved to modules/asset/routes.js) // 6. Get Single Maintenance Record by ID (Moved to modules/asset/routes.js) // 7. Create Maintenance Record (Moved to modules/asset/routes.js) // 8. Update Maintenance Record (Moved to modules/asset/routes.js) // 9. Delete Maintenance Record (Moved to modules/asset/routes.js) // 10. Get Manuals for an Asset (Moved to modules/asset/routes.js) // 11. Add Manual to Asset (Moved to modules/asset/routes.js) // 12. Delete Manual (Moved to modules/asset/routes.js) // Mount Asset Module app.use('/api', require('./modules/asset/routes')); // Serve Frontend Static Files (Production/Deployment) // Strict Sibling Structure: Expects 'dist' to be a sibling of 'server' // This matches the local development environment structure. const distPath = path.join(__dirname, '../dist'); console.log(`Serving static files from: ${distPath}`); app.use(express.static(distPath)); // The "catchall" handler: for any request that doesn't // match one above, send back React's index.html file. app.get(/(.*)/, (req, res) => { // Prevent caching for index.html to ensure updates are detected immediately res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); res.sendFile(path.join(distPath, 'index.html')); }); const server = app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); }); // Initialize Stream Relay const streamRelay = new StreamRelay(server); app.set('streamRelay', streamRelay);