Files
boc/quixzoom-capture-pipeline/api/upload.js
T
Bernt bae705aa97 ARCHITECTURE: NFC roadmap, edge AI, audit logging
- 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
2026-06-29 16:24:48 +00:00

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;