287 lines
8.6 KiB
JavaScript
287 lines
8.6 KiB
JavaScript
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 <username>" 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`);
|
|
});
|