bae705aa97
- Add NFC ePassport roadmap (ICAO 9303, eIDAS) - Add TensorFlow.js edge face detection (BlazeFace) - Add structured audit logger (GDPR-compliant) - Risk scoring support Part of KYC Apple Native UX v1.1.0
326 lines
9.0 KiB
JavaScript
326 lines
9.0 KiB
JavaScript
/**
|
|
* 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;
|