Files
boc/quixzoom-mission-patched.mjs
T

474 lines
17 KiB
JavaScript
Raw Normal View History

import express from 'express';
import Database from 'better-sqlite3';
import multer from 'multer';
import { randomUUID } from 'crypto';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = 7060;
const DB_PATH = '/opt/amos/data/quixzoom-missions.db';
const UPLOAD_DIR = '/opt/amos/data/quixzoom-uploads';
const ATLAS_URL = 'http://127.0.0.1:7002';
const INGEST_URL = 'http://127.0.0.1:7001';
const PAYROLL_URL = 'http://127.0.0.1:6001';
const SELF_URL = 'http://127.0.0.1:7060';
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
const app = express();
app.use(express.json());
const upload = multer({
dest: UPLOAD_DIR,
limits: { fileSize: 50 * 1024 * 1024 },
});
// Serve uploaded images so 3D ingest can download them
app.use('/uploads', express.static(UPLOAD_DIR));
// ---------------------------------------------------------------------------
// Database
// ---------------------------------------------------------------------------
// ═══════════════════════════════════════════════════════════════════════
// SÄKERHET — Manipulation Prevention Layer
// ═══════════════════════════════════════════════════════════════════════
// 1. MIME-validering via magic bytes — inga falska filändelser
const ALLOWED_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif']);
function validateUploadedFile(file) {
const ext = path.extname(file.originalname || '').toLowerCase();
const buf = Buffer.alloc(12);
const fd = fs.openSync(file.path, 'r');
fs.readSync(fd, buf, 0, 12, 0);
fs.closeSync(fd);
const jpeg = buf[0] === 0xFF && buf[1] === 0xD8;
const png = buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47;
const webp = buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50;
const heic = buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70;
if (!jpeg && !png && !webp && !heic)
return { valid: false, reason: `Ogiltig filtyp: '${file.originalname}' är inte en giltig bild.` };
if (!ALLOWED_EXT.has(ext))
return { valid: false, reason: `Otillåten filändelse: ${ext}` };
return { valid: true };
}
// 2. Submit-metadata — GPS + timestamp + device_id krävs
function validateSubmitMetadata(body) {
const errors = [];
const lat = parseFloat(body.lat), lon = parseFloat(body.lon);
if (isNaN(lat) || isNaN(lon)) errors.push('GPS-koordinater (lat, lon) krävs.');
else if (lat < -90 || lat > 90 || lon < -180 || lon > 180) errors.push('GPS utanför giltigt intervall.');
const clientTs = parseInt(body.captured_at_ms);
if (!clientTs) {
errors.push('captured_at_ms (unix ms) krävs.');
} else {
const drift = Math.abs(Date.now() - clientTs);
if (drift > 10 * 60 * 1000)
errors.push(`Timestamp-avvikelse ${Math.round(drift/1000)}s. Max ±10 min.`);
}
if (!body.device_id || body.device_id.length < 8)
errors.push('device_id krävs (min 8 tecken).');
return errors;
}
// 3. Approve-spärr — AVO-beslut krävs innan betalning
function requireAVOApproval(mission) {
if (!mission.avo_decision)
return 'AVO-analys saknas. Kör /submit först.';
if (mission.avo_decision === 'rejected')
return 'AVO nekade uppdraget. Ny inlämning krävs.';
if (mission.avo_decision === 'insufficient_evidence')
return 'Otillräcklig evidens. Kräver manuell granskning.';
return null;
}
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS missions (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
lat REAL NOT NULL,
lon REAL NOT NULL,
address TEXT,
status TEXT NOT NULL DEFAULT 'open',
zoomer_id TEXT,
reward_sek INTEGER NOT NULL DEFAULT 0,
mission_type TEXT NOT NULL DEFAULT 'inspection',
brief_url TEXT,
ingest_job_id TEXT,
avo_decision TEXT,
avo_trust REAL,
avo_claim_id TEXT,
avo_raw TEXT,
payment_ref TEXT,
created_at TEXT NOT NULL,
accepted_at TEXT,
submitted_at TEXT,
approved_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_missions_status ON missions(status);
CREATE INDEX IF NOT EXISTS idx_missions_coords ON missions(lat, lon);
`);
// ---------------------------------------------------------------------------
// Seed demo data
// ---------------------------------------------------------------------------
function seedDemo() {
const { c } = db.prepare('SELECT COUNT(*) as c FROM missions').get();
if (c > 0) return;
const demos = [
{
title: 'Fasadinspektion Kungsgatan 12',
description: 'Dokumentera brandskadat fasadparti. 3D-scanning av hela fronten.',
lat: 59.3349, lon: 18.0632,
address: 'Kungsgatan 12, Stockholm',
reward_sek: 1800,
mission_type: 'facade_scan',
},
{
title: 'Takskanning Södermalm',
description: 'Stormskada på tak efter aprilstormen. Fotografera hela takytan.',
lat: 59.3163, lon: 18.0738,
address: 'Hornsgatan 55, Stockholm',
reward_sek: 2400,
mission_type: 'roof_scan',
},
{
title: 'Vägbana Strandvägen potthålskarta',
description: 'Kartlägg potthål och sprickor längs Strandvägen 200 m.',
lat: 59.3318, lon: 18.0892,
address: 'Strandvägen, Östermalm, Stockholm',
reward_sek: 900,
mission_type: 'road_inspection',
},
{
title: 'Fuktskadat källarplan Odengatan 44',
description: 'Dokumentera vattenskada och mögeltillväxt i källare och bottenvåning.',
lat: 59.3421, lon: 18.0559,
address: 'Odengatan 44, Stockholm',
reward_sek: 3200,
mission_type: 'interior_scan',
},
{
title: 'Stormfällda träd Djurgårdsparken',
description: 'Inventera skadade träd och blockerade gångvägar efter stormen.',
lat: 59.3258, lon: 18.1043,
address: 'Djurgårdsparken, Stockholm',
reward_sek: 1200,
mission_type: 'vegetation_survey',
},
];
const insert = db.prepare(`
INSERT INTO missions (id, title, description, lat, lon, address, status, reward_sek, mission_type, brief_url, created_at)
VALUES (?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, ?)
`);
const insertMany = db.transaction(() => {
for (const d of demos) {
const id = `mission-${randomUUID().slice(0, 8)}`;
const briefUrl = `${ATLAS_URL}/coverage/${d.lat}/${d.lon}/500`;
insert.run(id, d.title, d.description, d.lat, d.lon, d.address, d.reward_sek, d.mission_type, briefUrl, new Date().toISOString());
}
});
insertMany();
console.log('[quiXzoom-mission] Seeded 5 demo missions in Stockholm');
}
seedDemo();
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function registerAtlasObject(mission) {
try {
const res = await fetch(`${ATLAS_URL}/objects/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: mission.title,
object_type: mission.mission_type,
location_lat: mission.lat,
location_lon: mission.lon,
s3_model_key: `missions/${mission.id}/brief.glb`,
}),
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return null;
const obj = await res.json();
return `${ATLAS_URL}/objects/${obj.id}/model`;
} catch {
return `${ATLAS_URL}/coverage/${mission.lat}/${mission.lon}/500`;
}
}
async function triggerIngest(missionId, lat, lon, imageUrls) {
const res = await fetch(`${INGEST_URL}/jobs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
area_id: `${lat.toFixed(4)},${lon.toFixed(4)}`,
image_urls: imageUrls,
}),
signal: AbortSignal.timeout(10000),
});
if (!res.ok) throw new Error(`Ingest returned ${res.status}`);
return res.json();
}
async function triggerPayroll(mission) {
try {
const res = await fetch(`${PAYROLL_URL}/mission-payment`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mission_id: mission.id,
zoomer_id: mission.zoomer_id,
amount_sek: mission.reward_sek,
description: `quiXzoom uppdrag: ${mission.title}`,
approved_at: new Date().toISOString(),
}),
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return { status: 'queued', ref: `PAY-${randomUUID().slice(0, 8)}`, warning: `payroll HTTP ${res.status}` };
const body = await res.json();
return { status: 'triggered', ref: body.id || body.ref || `PAY-${randomUUID().slice(0, 8)}`, ...body };
} catch (err) {
// Payroll service may not be on :6001 yet — store ref and continue
return { status: 'queued', ref: `PAY-${randomUUID().slice(0, 8)}`, warning: err.message };
}
}
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
// POST /missions
app.post('/missions', async (req, res) => {
const { title, description, lat, lon, address, reward_sek = 0, mission_type = 'inspection' } = req.body;
if (!title || lat == null || lon == null) {
return res.status(400).json({ error: 'title, lat and lon are required' });
}
const id = `mission-${randomUUID().slice(0, 8)}`;
const now = new Date().toISOString();
const mission = { id, title, description, lat: parseFloat(lat), lon: parseFloat(lon), address, reward_sek: parseInt(reward_sek) || 0, mission_type };
const briefUrl = await registerAtlasObject(mission);
mission.brief_url = briefUrl;
db.prepare(`
INSERT INTO missions (id, title, description, lat, lon, address, status, reward_sek, mission_type, brief_url, created_at)
VALUES (?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, ?)
`).run(id, title, description || null, mission.lat, mission.lon, address || null, mission.reward_sek, mission_type, briefUrl, now);
res.status(201).json(db.prepare('SELECT * FROM missions WHERE id = ?').get(id));
});
// GET /missions?bbox=lat1,lon1,lat2,lon2
app.get('/missions', (req, res) => {
const { bbox, status } = req.query;
let rows = db.prepare("SELECT * FROM missions WHERE status IN ('open', 'assigned')").all();
if (status) {
rows = db.prepare('SELECT * FROM missions WHERE status = ?').all(status);
}
if (bbox) {
const parts = bbox.split(',').map(parseFloat);
if (parts.length !== 4 || parts.some(isNaN)) {
return res.status(400).json({ error: 'bbox must be lat1,lon1,lat2,lon2' });
}
const [lat1, lon1, lat2, lon2] = parts;
const minLat = Math.min(lat1, lat2);
const maxLat = Math.max(lat1, lat2);
const minLon = Math.min(lon1, lon2);
const maxLon = Math.max(lon1, lon2);
rows = rows.filter(m => m.lat >= minLat && m.lat <= maxLat && m.lon >= minLon && m.lon <= maxLon);
}
res.json({ missions: rows, count: rows.length });
});
// GET /missions/:id
app.get('/missions/:id', (req, res) => {
const mission = db.prepare('SELECT * FROM missions WHERE id = ?').get(req.params.id);
if (!mission) return res.status(404).json({ error: 'Mission not found' });
res.json({
...mission,
viewer_url: mission.brief_url,
ingest_status_url: mission.ingest_job_id ? `${INGEST_URL}/jobs/${mission.ingest_job_id}` : null,
});
});
// POST /missions/:id/accept
app.post('/missions/:id/accept', (req, res) => {
const { zoomer_id } = req.body;
if (!zoomer_id) return res.status(400).json({ error: 'zoomer_id is required' });
const mission = db.prepare('SELECT * FROM missions WHERE id = ?').get(req.params.id);
if (!mission) return res.status(404).json({ error: 'Mission not found' });
if (mission.status !== 'open') return res.status(409).json({ error: `Mission is already ${mission.status}` });
const now = new Date().toISOString();
db.prepare("UPDATE missions SET status='assigned', zoomer_id=?, accepted_at=? WHERE id=?")
.run(zoomer_id, now, req.params.id);
res.json(db.prepare('SELECT * FROM missions WHERE id = ?').get(req.params.id));
});
// POST /missions/:id/submit (multipart images)
app.post('/missions/:id/submit', upload.array('images', 50), async (req, res) => {
const mission = db.prepare('SELECT * FROM missions WHERE id = ?').get(req.params.id);
if (!mission) return res.status(404).json({ error: 'Mission not found' });
if (!['assigned', 'open'].includes(mission.status)) {
return res.status(409).json({ error: `Cannot submit from status: ${mission.status}` });
}
const files = req.files || [];
if (files.length === 0) return res.status(400).json({ error: 'Minst en bild krävs.' });
// Metadata-validering
const metaErrors = validateSubmitMetadata(req.body);
if (metaErrors.length > 0) {
files.forEach(f => { try { fs.unlinkSync(f.path); } catch {} });
return res.status(400).json({ error: 'Ogiltig metadata', details: metaErrors });
}
// MIME magic bytes-validering
for (const file of files) {
const check = validateUploadedFile(file);
if (!check.valid) {
files.forEach(f => { try { fs.unlinkSync(f.path); } catch {} });
return res.status(400).json({ error: check.reason });
}
}
// Rename uploads to have proper extensions for the ingest service
const imageUrls = files.map(f => {
const ext = path.extname(f.originalname) || '.jpg';
const newName = `${f.filename}${ext}`;
fs.renameSync(f.path, path.join(UPLOAD_DIR, newName));
return `${SELF_URL}/uploads/${newName}`;
});
let ingestJob = null;
try {
ingestJob = await triggerIngest(req.params.id, mission.lat, mission.lon, imageUrls);
} catch (err) {
console.error('[quiXzoom-mission] Ingest trigger failed:', err.message);
}
// AVO fas B
let avoResult = null;
try {
const avoResp = await fetch('http://127.0.0.1:7070/avo/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mission_id: req.params.id,
control_type: mission.control_type || 'building_facade',
image_urls: imageUrls,
claim: `${mission.control_type || 'object'}_verified`,
metadata: { lat: parseFloat(req.body.lat), lon: parseFloat(req.body.lon),
device_id: req.body.device_id }
})
});
if (avoResp.ok) avoResult = await avoResp.json();
} catch (err) {
console.error('[quiXzoom-mission] AVO anrop misslyckades:', err.message);
}
const avoDecision = avoResult?.decision || null;
const avoTrust = avoResult?.verified_claim?.trust_accumulated ?? null;
const avoClaimId = avoResult?.verified_claim?.claim_id || null;
const newStatus = avoDecision === 'approved' ? 'approved' : 'submitted';
const now = new Date().toISOString();
db.prepare(`UPDATE missions SET status=?, submitted_at=?, ingest_job_id=?,
avo_decision=?, avo_trust=?, avo_claim_id=?, avo_raw=? WHERE id=?`)
.run(newStatus, now, ingestJob?.job_id || null,
avoDecision, avoTrust, avoClaimId,
avoResult ? JSON.stringify(avoResult) : null, req.params.id);
res.json({
mission: db.prepare('SELECT * FROM missions WHERE id = ?').get(req.params.id),
images_uploaded: imageUrls.length,
image_urls: imageUrls,
ingest_job: ingestJob,
avo: avoResult ? {
decision: avoDecision,
trust_accumulated: avoTrust,
trust_threshold: avoResult.verified_claim?.trust_threshold,
sufficient: avoResult.verified_claim?.sufficient,
claim_id: avoClaimId,
summary: avoResult.summary,
liveness_score: avoResult.liveness_score,
needs_human_review: avoResult.needs_human_review
} : { decision: null, note: 'AVO ej tillgänglig — manuell granskning krävs' }
});
});
// POST /missions/:id/approve
app.post('/missions/:id/approve', async (req, res) => {
const mission = db.prepare('SELECT * FROM missions WHERE id = ?').get(req.params.id);
if (!mission) return res.status(404).json({ error: 'Mission not found' });
if (mission.status !== 'submitted') {
return res.status(409).json({ error: `Mission must be submitted before approval (current: ${mission.status})` });
}
// AVO-spärr: godkännande blockeras utan verifierat AVO-beslut
const avoBlockReason = requireAVOApproval(mission);
if (avoBlockReason) {
return res.status(403).json({
error: 'AVO-verifiering saknas',
detail: avoBlockReason,
avo_decision: mission.avo_decision || null,
avo_claim_id: mission.avo_claim_id || null
});
}
const payment = await triggerPayroll(mission);
const now = new Date().toISOString();
db.prepare("UPDATE missions SET status='approved', approved_at=?, payment_ref=? WHERE id=?")
.run(now, payment.ref, req.params.id);
res.json({
mission: db.prepare('SELECT * FROM missions WHERE id = ?').get(req.params.id),
payment,
});
});
// GET /health
app.get('/health', (_req, res) => {
const { c } = db.prepare('SELECT COUNT(*) as c FROM missions').get();
res.json({
status: 'ok',
service: 'quixzoom-mission-engine',
port: PORT,
missions_total: c,
uptime_seconds: Math.floor(process.uptime()),
timestamp: new Date().toISOString(),
});
});
app.listen(PORT, () => {
console.log(`[quiXzoom-mission] Mission Engine running on port ${PORT}`);
});