532 lines
18 KiB
JavaScript
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('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''');
|
|
}
|
|
|
|
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}`);
|
|
});
|