const express = require('express'); const cors = require('cors'); require('dotenv').config(); 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); // 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 // These requests are prefixed by /api from the client but might be handled differently in middleware // Checking both common forms for safety if (req.path === '/api/check' || req.path === '/check' || req.path.includes('/auth/check')) { return next(); } try { const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'session_timeout'"); const timeoutMinutes = rows.length > 0 ? parseInt(rows[0].setting_value) : 60; 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 app.use((req, res, next) => { const now = new Date(); // UTC 시간에 9시간을 더한 뒤 ISO 문자열로 변환하고 끝의 'Z'를 제거하여 한국 시간 형식 생성 const kstDate = new Date(now.getTime() + (9 * 60 * 60 * 1000)).toISOString().replace('Z', ''); console.log(`[${kstDate}] ${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"); } // 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', 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); // Update existing table if needed try { await db.query("ALTER TABLE users MODIFY COLUMN role ENUM('supervisor', 'admin', 'user') DEFAULT 'user'"); } catch (e) { // Ignore if it fails (e.g. column doesn't exist yet handled by SQL above) } console.log('✅ Users Table Initialized with Supervisor role'); // 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'); } else { // Ensure existing admin has supervisor role for this transition await db.query('UPDATE users SET role = "supervisor" WHERE id = ?', [adminId]); } } 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 camera_settings table const cameraTable = ` CREATE TABLE IF NOT EXISTS camera_settings ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, ip_address VARCHAR(100) NOT NULL, port INT DEFAULT 554, username VARCHAR(100), password VARCHAR(100), stream_path VARCHAR(200) DEFAULT '/stream1', 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); // Check for 'transport_mode' and 'rtsp_encoding' columns and add if missing const [camColumns] = await db.query("SHOW COLUMNS FROM camera_settings LIKE 'transport_mode'"); if (camColumns.length === 0) { await db.query("ALTER TABLE camera_settings ADD COLUMN transport_mode ENUM('tcp', 'udp', 'auto') DEFAULT 'tcp' AFTER stream_path"); await db.query("ALTER TABLE camera_settings ADD COLUMN rtsp_encoding BOOLEAN DEFAULT FALSE AFTER transport_mode"); await db.query("ALTER TABLE camera_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 camera_settings"); } else { // Check if quality exists (for subsequent updates) const [qualCol] = await db.query("SHOW COLUMNS FROM camera_settings LIKE 'quality'"); if (qualCol.length === 0) { await db.query("ALTER TABLE camera_settings ADD COLUMN quality ENUM('low', 'medium', 'original') DEFAULT 'low' AFTER transport_mode"); console.log("✅ Added 'quality' column to camera_settings"); } } // Check for 'display_order' column const [orderCol] = await db.query("SHOW COLUMNS FROM camera_settings LIKE 'display_order'"); if (orderCol.length === 0) { await db.query("ALTER TABLE camera_settings ADD COLUMN display_order INT DEFAULT 0 AFTER quality"); console.log("✅ Added 'display_order' column to camera_settings"); } // 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', '자산 관리', true, 'dev']); await db.query(insert, ['production', '생산 관리', false, null]); await db.query(insert, ['monitoring', 'CCTV', false, null]); } console.log('✅ Tables Initialized'); } catch (err) { console.error('❌ Table Initialization Failed:', err); } }; initTables(); const packageJson = require('./package.json'); app.get('/api/health', (req, res) => { res.json({ status: 'ok', version: packageJson.version, node_version: process.version, platform: process.platform, arch: process.arch, timestamp: new Date().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);