Regalamiunsorriso/faceai/apps/backend/src/server.js

532 lines
18 KiB
JavaScript

import express from 'express';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import multer from 'multer';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { config } from './config.js';
import { signPayload, verifySignedPayload } from './auth.js';
import { createSession, getSession, mockCatalog } from './store.js';
import { buildRaceStorage, resolveRacePklAvailability } from './race-storage.js';
import {
acquireActiveSearchLock,
createRedisConnection,
createSearchRecord,
getActiveSearchId,
getResultRecord,
getSearchRecord,
incrementRateLimit,
saveSearchRecord
} from './redis-store.js';
import { getSearchQueue } from './queue.js';
import { normalizeMatches } from './matcher-results.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const frontendDist = path.resolve(__dirname, '../../frontend/dist');
const app = express();
const redis = createRedisConnection(config.redisUrl);
const searchQueue = getSearchQueue({ queueName: config.queueName, connection: redis });
await fsp.mkdir(config.uploadRoot, { recursive: true });
const upload = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => {
const pendingRoot = path.join(config.uploadRoot, 'pending');
fsp.mkdir(pendingRoot, { recursive: true })
.then(() => cb(null, pendingRoot))
.catch((error) => cb(error));
},
filename: (req, file, cb) => {
const safeName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_');
cb(null, `${Date.now()}_${safeName}`);
}
})
});
app.use(cookieParser());
app.use(express.json());
if (config.enableLocalLegacyStatic && fs.existsSync(config.localLegacyStaticRoot)) {
app.use('/legacy-static', express.static(config.localLegacyStaticRoot));
} else {
app.use('/legacy-static', (req, res) => {
res.status(404).type('text/plain').send('Legacy static assets are not configured in this environment.');
});
}
app.use(cors({
origin: config.frontendUrl,
credentials: true
}));
function getFaceAiSession(req) {
const sessionId = req.cookies[config.sessionCookieName];
return sessionId ? getSession(sessionId) : null;
}
function requireSession(req, res, next) {
const session = getFaceAiSession(req);
if (!session) {
res.status(401).json({ error: 'Not authenticated with FaceAI' });
return;
}
req.faceaiSession = session;
next();
}
async function enforceSearchRateLimit(req, res, next) {
const userId = req.faceaiSession?.user?.id;
if (!userId) {
res.status(401).json({ error: 'Not authenticated with FaceAI' });
return;
}
const count = await incrementRateLimit(redis, userId, config.rateLimitWindowSeconds);
if (count > config.rateLimitMaxRequests) {
res.status(429).json({
error: 'Too many search attempts. Please try again later.',
code: 'RATE_LIMITED'
});
return;
}
next();
}
function normalizeRaceForSession(raceInput) {
return {
...raceInput,
storage: buildRaceStorage(raceInput?.storage || {})
};
}
async function buildRaceAvailability(race) {
return resolveRacePklAvailability({ pklRoot: config.pklRoot, race });
}
function issueHandoffToken({ raceId, raceSlug, raceName, raceStorage, lang, returnUrl }) {
const race = mockCatalog[raceId] || {
id: raceId,
slug: raceSlug || `race-${raceId}`,
name: raceName || raceSlug || `Race ${raceId}`,
storage: buildRaceStorage(raceStorage || {})
};
return signPayload({
type: 'handoff',
user: {
id: 'legacy-user-1',
displayName: 'Mario Rossi',
email: 'mario.rossi@example.test',
membershipStatus: 'active'
},
race: {
id: race.id,
slug: race.slug,
name: race.name,
storage: buildRaceStorage(raceStorage || race.storage || {})
},
lang: lang || 'it',
returnUrl,
expiresAt: Date.now() + 5 * 60 * 1000
}, config.sharedSecret);
}
function issueReturnToken(result) {
return signPayload({
type: 'return',
resultId: result.id,
raceId: result.raceId,
userId: result.userId,
expiresAt: Date.now() + 5 * 60 * 1000
}, config.sharedSecret);
}
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function renderLegacyRacePage({ raceId, lang = 'it', result = null }) {
const race = mockCatalog[raceId] || { id: raceId, name: `Race ${raceId}`, slug: `race-${raceId}`, photos: [] };
const returnUrl = `${config.publicBaseUrl}/dev/legacy/race?raceId=${encodeURIComponent(race.id)}&lang=${encodeURIComponent(lang)}`;
const photos = result ? result.matches : race.photos;
const banner = result
? `<div class="legacy-banner">Vista filtrata da FaceAI. Trovate ${photos.length} foto per l'utente corrente.</div>`
: '<div class="legacy-banner legacy-banner-neutral">Pagina gara simulata per il test locale del handoff FaceAI.</div>';
const photoList = photos.length
? photos.map((photo) => `
<li class="legacy-card">
<div class="legacy-thumb">${escapeHtml(photo.thumb || photo.id)}</div>
<div class="legacy-meta">
<strong>${escapeHtml(photo.label)}</strong>
<span>ID foto: ${escapeHtml(photo.id)}</span>
<span>Punto foto: ${escapeHtml(photo.checkpoint || '-')}</span>
</div>
</li>
`).join('')
: '<li class="legacy-empty">Nessuna foto disponibile.</li>';
return `<!doctype html>
<html lang="${escapeHtml(lang)}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Legacy Race Simulator</title>
<style>
body { font-family: Georgia, serif; margin: 0; background: #f7f1e8; color: #2c241b; }
.topbar { background: #fff; border-bottom: 1px solid #d9c7aa; padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; }
.brand { font-size: 22px; font-weight: 700; }
.page { max-width: 1120px; margin: 0 auto; padding: 24px; }
.toolbar { background: #fff; border: 1px solid #dccab2; padding: 16px; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.toolbar label { font-size: 14px; color: #6a5845; }
.toolbar select { padding: 10px 12px; min-width: 220px; }
.toolbar button { background: #8d1f1f; color: #fff; border: 0; padding: 11px 18px; font-weight: 700; cursor: pointer; }
.legacy-banner { margin: 20px 0; background: #f6d9b6; border: 1px solid #c69257; padding: 14px 16px; }
.legacy-banner-neutral { background: #e9efe8; border-color: #8fa18b; }
.summary { margin: 12px 0 24px; }
.gallery { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; }
.legacy-card { background: #fff; border: 1px solid #dccab2; padding: 16px; display: grid; gap: 12px; }
.legacy-thumb { background: #efe2d1; border: 1px dashed #b79a77; min-height: 120px; display: grid; place-items: center; color: #6f5b47; font-size: 13px; }
.legacy-meta { display: grid; gap: 4px; font-size: 14px; }
.legacy-empty { background: #fff; border: 1px solid #dccab2; padding: 24px; }
</style>
</head>
<body>
<header class="topbar">
<div class="brand">Regalami un Sorriso ETS</div>
<div>Utente simulato: Mario Rossi</div>
</header>
<main class="page">
<h1>${escapeHtml(race.name)}</h1>
<div class="toolbar">
<label>Punti Foto</label>
<select>
<option>-- Punti Foto --</option>
<option>Arrivo</option>
<option>Centro</option>
<option>Ponte</option>
</select>
<button id="faceai-launch">Face ID</button>
</div>
${banner}
<p class="summary">${result ? 'La pagina mostra solo le foto restituite da FaceAI.' : 'In questa simulazione il vecchio select tipoPuntoFoto è sostituito dal pulsante Face ID.'}</p>
<ul class="gallery">${photoList}</ul>
</main>
<script>
const launchButton = document.getElementById('faceai-launch');
launchButton.addEventListener('click', () => {
const returnUrl = ${JSON.stringify(returnUrl)};
const launchUrl = new URL('/dev/legacy/launch', ${JSON.stringify(config.publicBaseUrl)});
launchUrl.searchParams.set('raceId', ${JSON.stringify(race.id)});
launchUrl.searchParams.set('raceSlug', ${JSON.stringify(race.slug)});
launchUrl.searchParams.set('lang', ${JSON.stringify(lang)});
launchUrl.searchParams.set('returnUrl', returnUrl);
window.location.href = launchUrl.toString();
});
</script>
</body>
</html>`;
}
app.get('/health', (req, res) => {
res.json({ ok: true });
});
app.get('/dev/legacy/race', (req, res) => {
const raceId = String(req.query.raceId || '101');
const lang = String(req.query.lang || 'it');
res.type('html').send(renderLegacyRacePage({ raceId, lang }));
});
app.get('/dev/legacy/launch', (req, res) => {
const raceId = String(req.query.raceId || '101');
const raceSlug = String(req.query.raceSlug || mockCatalog[raceId]?.slug || `race-${raceId}`);
const raceName = String(req.query.raceName || mockCatalog[raceId]?.name || raceSlug);
const lang = String(req.query.lang || 'it');
const returnUrl = String(req.query.returnUrl || `${config.publicBaseUrl}/dev/legacy/race?raceId=${encodeURIComponent(raceId)}&lang=${encodeURIComponent(lang)}`);
const token = issueHandoffToken({
raceId,
raceSlug,
raceName,
raceStorage: {
year: String(req.query.raceYear || mockCatalog[raceId]?.storage?.year || ''),
monthFolder: String(req.query.raceMonthFolder || mockCatalog[raceId]?.storage?.monthFolder || ''),
raceFolder: String(req.query.raceFolder || mockCatalog[raceId]?.storage?.raceFolder || '')
},
lang,
returnUrl
});
res.redirect(`${config.frontendUrl}/auth/callback?token=${encodeURIComponent(token)}`);
});
app.get('/dev/legacy/return', async (req, res) => {
try {
const token = String(req.query.token || '');
const payload = verifySignedPayload(token, config.sharedSecret);
if (payload.type !== 'return') {
throw new Error('Wrong token type');
}
const result = await getResultRecord(redis, String(req.query.resultId || payload.resultId));
if (!result || result.userId !== payload.userId) {
throw new Error('Result not found');
}
const normalizedResult = {
...result,
matches: normalizeMatches(result)
};
res.type('html').send(renderLegacyRacePage({ raceId: result.raceId, lang: result.lang || 'it', result: normalizedResult }));
} catch (error) {
res.status(400).type('html').send(`<h1>Return handoff failed</h1><p>${escapeHtml(error.message)}</p>`);
}
});
app.post('/api/auth/exchange', async (req, res) => {
try {
const { token } = req.body;
const payload = verifySignedPayload(token, config.sharedSecret);
if (payload.type !== 'handoff') {
throw new Error('Wrong token type');
}
const race = normalizeRaceForSession(payload.race);
const availability = await buildRaceAvailability(race);
const faceAiAllowed = payload.user.membershipStatus === 'active' && availability.available;
const sessionId = createSession({
user: payload.user,
race,
lang: payload.lang,
returnUrl: payload.returnUrl,
availability,
access: {
faceAiAllowed
}
});
res.cookie(config.sessionCookieName, sessionId, {
httpOnly: true,
sameSite: 'lax',
secure: false,
path: '/'
});
res.json({
user: payload.user,
race,
lang: payload.lang,
returnUrl: payload.returnUrl,
availability,
access: {
faceAiAllowed
}
});
} catch (error) {
res.status(400).json({ error: error.message });
}
});
app.get('/api/session', requireSession, (req, res) => {
res.json(req.faceaiSession);
});
app.post('/api/searches', requireSession, enforceSearchRateLimit, upload.single('selfie'), async (req, res) => {
try {
const raceId = String(req.body.raceId || req.faceaiSession.race.id);
const userId = String(req.faceaiSession.user.id);
const race = normalizeRaceForSession(raceId === req.faceaiSession.race.id
? req.faceaiSession.race
: (mockCatalog[raceId] || req.faceaiSession.race));
const availability = await buildRaceAvailability(race);
if (!availability.available) {
res.status(409).json({
error: availability.message,
code: availability.reasonCode || 'RACE_PKL_UNAVAILABLE'
});
return;
}
const activeSearchId = await getActiveSearchId(redis, userId);
if (activeSearchId) {
res.status(409).json({
error: 'There is already an operation being processed.',
code: 'ACTIVE_SEARCH_EXISTS',
activeSearchId
});
return;
}
if (!req.file) {
res.status(400).json({
error: 'Choose a selfie before starting the search.',
code: 'MISSING_SELFIE'
});
return;
}
const search = await createSearchRecord(redis, {
raceId,
raceName: race?.name || raceId,
raceStorage: race?.storage || availability.storage,
userId,
returnUrl: req.faceaiSession.returnUrl,
lang: req.faceaiSession.lang,
selfieName: req.file.originalname,
selfiePath: req.file.path,
uploadPath: req.file.path
}, config.searchTtlSeconds);
const lockAcquired = await acquireActiveSearchLock(redis, userId, search.id, config.searchTtlSeconds);
if (!lockAcquired) {
await fsp.unlink(req.file.path).catch(() => {});
res.status(409).json({
error: 'There is already an operation being processed.',
code: 'ACTIVE_SEARCH_EXISTS'
});
return;
}
const finalUploadDir = path.join(config.uploadRoot, search.id);
await fsp.mkdir(finalUploadDir, { recursive: true });
const finalUploadPath = path.join(finalUploadDir, path.basename(req.file.path));
await fsp.rename(req.file.path, finalUploadPath);
const updatedSearch = await saveSearchRecord(redis, {
...search,
selfiePath: finalUploadPath,
uploadPath: finalUploadPath
}, config.searchTtlSeconds);
await searchQueue.add('run-search', {
searchId: search.id
}, {
removeOnComplete: 100,
removeOnFail: 100
});
res.status(201).json({
id: updatedSearch.id,
status: updatedSearch.status,
raceId: updatedSearch.raceId,
raceStorage: updatedSearch.raceStorage,
selfieName: updatedSearch.selfieName,
matchCount: updatedSearch.matchCount,
errorCode: updatedSearch.errorCode,
errorMessage: updatedSearch.errorMessage
});
} catch (error) {
res.status(500).json({ error: error.message || 'Unable to create the search.' });
}
});
app.get('/api/searches/:id', requireSession, async (req, res) => {
const search = await getSearchRecord(redis, req.params.id);
if (!search || search.userId !== req.faceaiSession.user.id) {
res.status(404).json({ error: 'Search not found' });
return;
}
res.json({
id: search.id,
status: search.status,
raceId: search.raceId,
resultId: search.resultId,
createdAt: search.createdAt,
completedAt: search.completedAt,
matchCount: search.matchCount || 0,
errorCode: search.errorCode,
errorMessage: search.errorMessage
});
});
app.get('/api/searches/:id/redirect', requireSession, async (req, res) => {
const search = await getSearchRecord(redis, req.params.id);
if (!search || search.userId !== req.faceaiSession.user.id) {
res.status(404).json({ error: 'Search not found' });
return;
}
if (search.status !== 'completed' || !search.resultId) {
res.status(409).json({ error: 'Search not completed yet' });
return;
}
const result = await getResultRecord(redis, search.resultId);
if (!result) {
res.status(404).json({ error: 'Result not found' });
return;
}
const token = issueReturnToken(result);
res.json({
url: `${config.legacyReturnUrl}?resultId=${encodeURIComponent(result.id)}&token=${encodeURIComponent(token)}`
});
});
app.get('/bridge/results/:id', async (req, res) => {
try {
const token = String(req.query.token || '');
const payload = verifySignedPayload(token, config.sharedSecret);
if (payload.type !== 'return') {
throw new Error('Wrong token type');
}
if (String(payload.resultId || '') !== String(req.params.id)) {
throw new Error('Result id mismatch');
}
const result = await getResultRecord(redis, req.params.id);
if (!result || result.userId !== payload.userId) {
throw new Error('Result not found');
}
res.json({
id: result.id,
raceId: result.raceId,
raceName: result.raceName,
userId: result.userId,
returnUrl: result.returnUrl,
lang: result.lang,
matches: result.matches
});
} catch (error) {
res.status(400).json({ error: error.message });
}
});
app.get('/api/health/queue', async (req, res) => {
try {
await redis.ping();
res.json({ ok: true });
} catch (error) {
res.status(500).json({ ok: false, error: error.message });
}
});
if (fs.existsSync(frontendDist)) {
app.use(express.static(frontendDist));
app.get('*', (req, res, next) => {
if (req.path.startsWith('/api/') || req.path.startsWith('/dev/')) {
next();
return;
}
res.sendFile(path.join(frontendDist, 'index.html'));
});
}
app.listen(config.port, () => {
console.log(`FaceAI backend listening on http://localhost:${config.port}`);
});