import express from 'express'; import multer from 'multer'; import path from 'path'; import fs from 'fs'; import { fileURLToPath } from 'url'; import { validateApiKey, loadUsers } from './users.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // Configuration from environment variables const PORT = process.env.PICUPPER_PORT; const UPLOAD_DIR = process.env.PICUPPER_UPLOAD_DIR || path.join(__dirname, 'uploads'); const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB - typical webcam snapshot size if (!PORT) { console.error('ERROR: PICUPPER_PORT environment variable is required'); process.exit(1); } // Check for users const users = loadUsers(); if (Object.keys(users).length === 0) { console.warn('WARNING: No users configured. Use "node manage-users.js add " to create users.'); } // Ensure upload directory exists if (!fs.existsSync(UPLOAD_DIR)) { fs.mkdirSync(UPLOAD_DIR, { recursive: true }); } const app = express(); // API Key authentication middleware function authenticate(req, res, next) { const providedKey = req.headers['x-api-key'] || req.query.apiKey; if (!providedKey) { return res.status(401).json({ error: 'Unauthorized: Missing API key' }); } const username = validateApiKey(providedKey); if (!username) { return res.status(401).json({ error: 'Unauthorized: Invalid API key' }); } // Attach username to request for logging/tracking req.authenticatedUser = username; next(); } // Configure multer storage const storage = multer.diskStorage({ destination: (req, file, cb) => { const cameraId = req.body.cameraId || req.query.cameraId || 'default'; const now = new Date(); // Create directory structure: uploads/{cameraId}/{YYYY}/{MM}/{DD}/ const year = now.getFullYear().toString(); const month = String(now.getMonth() + 1).padStart(2, '0'); const day = String(now.getDate()).padStart(2, '0'); const destPath = path.join(UPLOAD_DIR, cameraId, year, month, day); fs.mkdirSync(destPath, { recursive: true }); cb(null, destPath); }, filename: (req, file, cb) => { const cameraId = req.body.cameraId || req.query.cameraId || 'default'; const now = new Date(); // Filename format: {cameraId}_{timestamp}.{ext} const timestamp = now.toISOString().replace(/[:.]/g, '-'); const ext = path.extname(file.originalname) || '.jpg'; cb(null, `${cameraId}_${timestamp}${ext}`); } }); // File filter - only accept images function fileFilter(req, file, cb) { const allowedMimes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; if (allowedMimes.includes(file.mimetype)) { cb(null, true); } else { cb(new Error(`Invalid file type: ${file.mimetype}. Allowed: ${allowedMimes.join(', ')}`), false); } } const upload = multer({ storage, fileFilter, limits: { fileSize: MAX_FILE_SIZE } }); // Health check endpoint (no auth required) app.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString(), uptime: process.uptime() }); }); // Upload endpoint app.post('/upload', authenticate, upload.single('image'), (req, res) => { if (!req.file) { return res.status(400).json({ error: 'No image file provided. Use form field "image".' }); } const cameraId = req.body.cameraId || req.query.cameraId || 'default'; res.json({ success: true, cameraId, filename: req.file.filename, path: req.file.path, size: req.file.size, timestamp: new Date().toISOString() }); }); // List cameras (directories in upload folder) app.get('/cameras', authenticate, (req, res) => { try { if (!fs.existsSync(UPLOAD_DIR)) { return res.json({ cameras: [] }); } const cameras = fs.readdirSync(UPLOAD_DIR, { withFileTypes: true }) .filter(dirent => dirent.isDirectory()) .map(dirent => dirent.name); res.json({ cameras }); } catch (error) { res.status(500).json({ error: 'Failed to list cameras', details: error.message }); } }); // Get statistics for a camera app.get('/stats/:cameraId', authenticate, (req, res) => { const { cameraId } = req.params; const cameraPath = path.join(UPLOAD_DIR, cameraId); if (!fs.existsSync(cameraPath)) { return res.status(404).json({ error: `Camera '${cameraId}' not found` }); } try { let totalImages = 0; let totalSize = 0; let oldestImage = null; let newestImage = null; // Recursively count files function countFiles(dir) { const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { countFiles(fullPath); } else if (entry.isFile()) { totalImages++; const stats = fs.statSync(fullPath); totalSize += stats.size; if (!oldestImage || stats.mtime < oldestImage) { oldestImage = stats.mtime; } if (!newestImage || stats.mtime > newestImage) { newestImage = stats.mtime; } } } } countFiles(cameraPath); res.json({ cameraId, totalImages, totalSizeBytes: totalSize, totalSizeMB: (totalSize / (1024 * 1024)).toFixed(2), oldestImage: oldestImage ? oldestImage.toISOString() : null, newestImage: newestImage ? newestImage.toISOString() : null }); } catch (error) { res.status(500).json({ error: 'Failed to get camera stats', details: error.message }); } }); // Camera settings storage const SETTINGS_FILE = process.env.PICUPPER_SETTINGS_FILE || path.join(__dirname, 'camera-settings.json'); function loadCameraSettings() { try { if (fs.existsSync(SETTINGS_FILE)) { return JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8')); } } catch (error) { console.error('Error loading camera settings:', error.message); } return {}; } function saveCameraSettings(settings) { fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2)); } // Get camera settings app.get('/settings/:cameraId', authenticate, (req, res) => { const { cameraId } = req.params; const allSettings = loadCameraSettings(); const settings = allSettings[cameraId] || {}; res.json({ cameraId, settings: { focus_automatic_continuous: settings.focus_automatic_continuous ?? 0, focus_absolute: settings.focus_absolute ?? 30, exposure_auto: settings.exposure_auto ?? 1, exposure_absolute: settings.exposure_absolute ?? 200, brightness: settings.brightness ?? 0, contrast: settings.contrast ?? 32, ...settings } }); }); // Update camera settings app.put('/settings/:cameraId', authenticate, express.json(), (req, res) => { const { cameraId } = req.params; const newSettings = req.body; if (!newSettings || typeof newSettings !== 'object') { return res.status(400).json({ error: 'Settings object required in request body' }); } const allSettings = loadCameraSettings(); allSettings[cameraId] = { ...(allSettings[cameraId] || {}), ...newSettings, updatedAt: new Date().toISOString() }; try { saveCameraSettings(allSettings); res.json({ success: true, cameraId, settings: allSettings[cameraId] }); } catch (error) { res.status(500).json({ error: 'Failed to save settings', details: error.message }); } }); // Error handling middleware app.use((error, req, res, next) => { if (error instanceof multer.MulterError) { if (error.code === 'LIMIT_FILE_SIZE') { return res.status(413).json({ error: `File too large. Maximum size: ${MAX_FILE_SIZE / (1024 * 1024)}MB` }); } return res.status(400).json({ error: error.message }); } if (error) { return res.status(400).json({ error: error.message }); } next(); }); // Start server app.listen(PORT, () => { console.log(`PicUpper service running on port ${PORT}`); console.log(`Upload directory: ${UPLOAD_DIR}`); console.log(`Max file size: ${MAX_FILE_SIZE / (1024 * 1024)}MB`); });