import express from 'express'; import multer from 'multer'; import path from 'path'; import fs from 'fs'; import { fileURLToPath } from 'url'; import sharp from 'sharp'; 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'), async (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'; // Generate thumbnail try { const thumbnailFilename = req.file.filename.replace(path.extname(req.file.filename), '_thumb.avif'); const thumbnailPath = path.join(path.dirname(req.file.path), thumbnailFilename); await sharp(req.file.path) .resize(320) // Resize to 320px width, auto height .toFormat('avif') .toFile(thumbnailPath); res.json({ success: true, cameraId, filename: req.file.filename, path: req.file.path, thumbnail: thumbnailFilename, thumbnailPath: thumbnailPath, size: req.file.size, timestamp: new Date().toISOString() }); } catch (error) { console.error('Thumbnail generation failed:', error); // Still return success for the upload even if thumbnail fails res.json({ success: true, cameraId, filename: req.file.filename, path: req.file.path, thumbnailError: error.message, 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 // Get camera settings app.get('/settings/:cameraId', authenticate, (req, res) => { const { cameraId } = req.params; const user = req.authenticatedUser; const storageKey = `${user}:${cameraId}`; const allSettings = loadCameraSettings(); const settings = allSettings[storageKey] || {}; 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 user = req.authenticatedUser; const storageKey = `${user}:${cameraId}`; 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[storageKey] = { ...(allSettings[storageKey] || {}), ...newSettings, updatedAt: new Date().toISOString(), updatedBy: user }; try { saveCameraSettings(allSettings); res.json({ success: true, cameraId, settings: allSettings[storageKey] }); } catch (error) { res.status(500).json({ error: 'Failed to save settings', details: error.message }); } }); // === Web Interface Endpoints === // Serve static files (frontend) app.use(express.static(path.join(__dirname, 'public'))); // Serve uploads (images) app.use('/uploads', express.static(UPLOAD_DIR)); // Get available dates for a camera app.get('/cameras/:cameraId/dates', async (req, res) => { const { cameraId } = req.params; const cameraPath = path.join(UPLOAD_DIR, cameraId); if (!fs.existsSync(cameraPath)) { return res.json({ dates: [] }); } try { const dates = []; const years = fs.readdirSync(cameraPath).filter(name => /^\d{4}$/.test(name)); for (const year of years) { const yearPath = path.join(cameraPath, year); const months = fs.readdirSync(yearPath).filter(name => /^\d{2}$/.test(name)); for (const month of months) { const monthPath = path.join(yearPath, month); const days = fs.readdirSync(monthPath).filter(name => /^\d{2}$/.test(name)); for (const day of days) { dates.push(`${year}-${month}-${day}`); } } } // Sort newest first dates.sort((a, b) => b.localeCompare(a)); res.json({ dates }); } catch (error) { res.status(500).json({ error: 'Failed to list dates', details: error.message }); } }); // Get images for a specific date app.get('/cameras/:cameraId/:year/:month/:day', async (req, res) => { const { cameraId, year, month, day } = req.params; const datePath = path.join(UPLOAD_DIR, cameraId, year, month, day); if (!fs.existsSync(datePath)) { return res.status(404).json({ error: 'Date not found' }); } try { const files = fs.readdirSync(datePath); const images = []; // Group files by timestamp (original and thumbnail) // Expected format: {cameraId}_{timestamp}.jpg and {cameraId}_{timestamp}_thumb.avif for (const file of files) { // Skip existing thumbnails from the list loop to avoid duplicates // We'll attach them to the main image entry if (file.includes('_thumb.avif')) continue; // Only process image files if (!/\.(jpg|jpeg|png|webp|gif)$/i.test(file)) continue; const thumbName = file.replace(path.extname(file), '_thumb.avif'); const hasThumb = files.includes(thumbName); images.push({ filename: file, url: `uploads/${cameraId}/${year}/${month}/${day}/${file}`, thumbnailUrl: hasThumb ? `uploads/${cameraId}/${year}/${month}/${day}/${thumbName}` : `uploads/${cameraId}/${year}/${month}/${day}/${file}`, // Fallback to original timestamp: file.split('_')[1]?.replace(path.extname(file), '') || '' }); } // Sort by filename (timestamp) descending images.sort((a, b) => b.filename.localeCompare(a.filename)); res.json({ images }); } catch (error) { res.status(500).json({ error: 'Failed to list images', 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`); });