/** * QUIXZOOM Capture Pipeline — Upload API * * Endpoints: * POST /api/upload — Ladda upp bild med metadata * GET /api/upload/status/:id — Kolla uppladdningsstatus * * Teknik: Node.js, Express, Multer, Cloudflare R2 (S3-kompatibel) */ const express = require('express'); const multer = require('multer'); const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3'); const { v4: uuidv4 } = require('uuid'); const exifParser = require('exif-parser'); const crypto = require('crypto'); const router = express.Router(); // Cloudflare R2 konfiguration const r2Client = new S3Client({ region: 'auto', endpoint: process.env.R2_ENDPOINT, credentials: { accessKeyId: process.env.R2_ACCESS_KEY_ID, secretAccessKey: process.env.R2_SECRET_ACCESS_KEY, }, }); const BUCKET_NAME = process.env.R2_BUCKET_NAME || 'quixzoom-capture'; // Multer konfiguration — spara temporärt i minnet const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024, // 50MB max files: 10, // Max 10 filer per request }, fileFilter: (req, file, cb) => { // Acceptera endast bilder if (file.mimetype.startsWith('image/')) { cb(null, true); } else { cb(new Error('Endast bildfiler accepteras'), false); } }, }); /** * Extrahera metadata från bild */ async function extractMetadata(buffer, body) { const metadata = { // Systemgenererat captureId: uuidv4(), uploadedAt: new Date().toISOString(), processedAt: null, // EXIF-data från bilden exif: {}, // GPS från EXIF eller request body gps: { latitude: null, longitude: null, altitude: null, accuracy: null, }, // Sensor-data från request body (iPhone sensors) sensors: { compassHeading: body.compassHeading ? parseFloat(body.compassHeading) : null, gyroscope: body.gyroscope ? JSON.parse(body.gyroscope) : null, accelerometer: body.accelerometer ? JSON.parse(body.accelerometer) : null, deviceOrientation: body.deviceOrientation || null, }, // Enhetsinfo device: { model: body.deviceModel || 'unknown', os: body.deviceOS || 'unknown', appVersion: body.appVersion || 'unknown', camera: body.cameraModel || 'unknown', }, // Miljödata (kan beräknas senare) environment: { weather: null, temperature: null, sunPosition: null, timeOfDay: null, }, // Filinfo file: { originalName: null, mimeType: null, size: buffer.length, checksum: crypto.createHash('sha256').update(buffer).digest('hex'), dimensions: { width: null, height: null, }, }, // AI-analys (fylls i senare steg) aiAnalysis: { status: 'pending', blurScore: null, qualityScore: null, objectsDetected: [], ocrText: null, sceneClassification: null, duplicates: [], }, // Taxonomi (fylls i senare steg) taxonomy: { physical: {}, operational: {}, economic: {}, institutional: {}, social: {}, temporal: {}, }, }; // Extrahera EXIF try { const parser = exifParser.create(buffer); const result = parser.parse(); metadata.exif = result.tags; // GPS från EXIF if (result.tags.GPSLatitude && result.tags.GPSLongitude) { metadata.gps.latitude = result.tags.GPSLatitude; metadata.gps.longitude = result.tags.GPSLongitude; metadata.gps.altitude = result.tags.GPSAltitude || null; } // Bilddimensioner metadata.file.dimensions.width = result.tags.ImageWidth || result.tags.ExifImageWidth; metadata.file.dimensions.height = result.tags.ImageHeight || result.tags.ExifImageHeight; // Tid från EXIF if (result.tags.DateTimeOriginal) { metadata.environment.timeOfDay = getTimeOfDay(new Date(result.tags.DateTimeOriginal * 1000)); } } catch (err) { console.log('EXIF extraction failed:', err.message); } // GPS från request body (prioritet över EXIF) if (body.latitude && body.longitude) { metadata.gps.latitude = parseFloat(body.latitude); metadata.gps.longitude = parseFloat(body.longitude); metadata.gps.accuracy = body.accuracy ? parseFloat(body.accuracy) : null; metadata.gps.altitude = body.altitude ? parseFloat(body.altitude) : null; } // Beräkna tid på dygnet om inte från EXIF if (!metadata.environment.timeOfDay) { metadata.environment.timeOfDay = getTimeOfDay(new Date()); } return metadata; } /** * Bestäm tid på dygnet */ function getTimeOfDay(date) { const hour = date.getHours(); if (hour >= 5 && hour < 9) return 'early_morning'; if (hour >= 9 && hour < 12) return 'morning'; if (hour >= 12 && hour < 14) return 'midday'; if (hour >= 14 && hour < 17) return 'afternoon'; if (hour >= 17 && hour < 21) return 'evening'; return 'night'; } /** * POST /api/upload * * Body (multipart/form-data): * - image: Bildfil * - latitude: GPS latitud * - longitude: GPS longitud * - accuracy: GPS noggrannhet (meter) * - altitude: Höjd över havet * - compassHeading: Kompassriktning (grader) * - gyroscope: Gyroskop-data (JSON) * - accelerometer: Accelerometer-data (JSON) * - deviceOrientation: Enhetsorientering * - deviceModel: Telefonmodell * - deviceOS: OS-version * - appVersion: App-version * - missionId: Uppdrags-ID (om tillämpligt) * - contributorId: Zoomer-ID */ router.post('/upload', upload.single('image'), async (req, res) => { try { if (!req.file) { return res.status(400).json({ error: 'Ingen bildfil tillhandahållen' }); } const { buffer, originalname, mimetype } = req.file; const { body } = req; // Extrahera metadata const metadata = await extractMetadata(buffer, body); metadata.file.originalName = originalname; metadata.file.mimeType = mimetype; // Generera filnamn: YYYY/MM/DD/captureId.ext const now = new Date(); const datePath = `${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, '0')}/${String(now.getDate()).padStart(2, '0')}`; const extension = originalname.split('.').pop(); const fileName = `${datePath}/${metadata.captureId}.${extension}`; const metadataFileName = `${datePath}/${metadata.captureId}.json`; // Ladda upp bild till R2 await r2Client.send(new PutObjectCommand({ Bucket: BUCKET_NAME, Key: `originals/${fileName}`, Body: buffer, ContentType: mimetype, Metadata: { 'capture-id': metadata.captureId, 'contributor-id': body.contributorId || 'anonymous', 'mission-id': body.missionId || 'none', }, })); // Ladda upp metadata till R2 await r2Client.send(new PutObjectCommand({ Bucket: BUCKET_NAME, Key: `metadata/${metadataFileName}`, Body: JSON.stringify(metadata, null, 2), ContentType: 'application/json', })); // Lägg till i processing-kö (Redis/RabbitMQ/SQS) await addToProcessingQueue(metadata.captureId, fileName); // Svar till klient res.status(201).json({ success: true, captureId: metadata.captureId, status: 'uploaded', message: 'Bild och metadata mottagna. Bearbetning pågår.', urls: { original: `https://${BUCKET_NAME}.r2.cloudflarestorage.com/originals/${fileName}`, metadata: `https://${BUCKET_NAME}.r2.cloudflarestorage.com/metadata/${metadataFileName}`, }, }); } catch (error) { console.error('Upload error:', error); res.status(500).json({ error: 'Uppladdning misslyckades', message: error.message, }); } }); /** * Lägg till i processing-kö */ async function addToProcessingQueue(captureId, fileName) { // Implementera med Redis/RabbitMQ/SQS // För nu: logga till konsol console.log(`[QUEUE] Added ${captureId} to processing queue`); // TODO: Implementera faktisk kö // await redis.lpush('quixzoom:processing:queue', JSON.stringify({ captureId, fileName })); } /** * GET /api/upload/status/:captureId */ router.get('/upload/status/:captureId', async (req, res) => { try { const { captureId } = req.params; // Hämta metadata från R2 // TODO: Implementera faktisk hämtning res.json({ captureId, status: 'processing', stages: { upload: 'completed', metadataExtraction: 'completed', aiPreprocessing: 'pending', qualityCheck: 'pending', taxonomyTagging: 'pending', }, }); } catch (error) { res.status(500).json({ error: error.message }); } }); /** * GET /api/upload/batch * Batch-uppladdningsstatus för flera capture IDs */ router.post('/upload/batch/status', async (req, res) => { try { const { captureIds } = req.body; // Hämta status för alla const statuses = captureIds.map(id => ({ captureId: id, status: 'processing', })); res.json({ statuses }); } catch (error) { res.status(500).json({ error: error.message }); } }); module.exports = router;