Files
picUploadApi/server.js

411 lines
13 KiB
JavaScript

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 <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'), 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`);
});