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 ? `
Vista filtrata da FaceAI. Trovate ${photos.length} foto per l'utente corrente.
` : '
Pagina gara simulata per il test locale del handoff FaceAI.
'; const photoList = photos.length ? photos.map((photo) => `
  • ${escapeHtml(photo.thumb || photo.id)}
    ${escapeHtml(photo.label)} ID foto: ${escapeHtml(photo.id)} Punto foto: ${escapeHtml(photo.checkpoint || '-')}
  • `).join('') : '
  • Nessuna foto disponibile.
  • '; return ` Legacy Race Simulator
    Regalami un Sorriso ETS
    Utente simulato: Mario Rossi

    ${escapeHtml(race.name)}

    ${banner}

    ${result ? 'La pagina mostra solo le foto restituite da FaceAI.' : 'In questa simulazione il vecchio select tipoPuntoFoto รจ sostituito dal pulsante Face ID.'}

    `; } 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(`

    Return handoff failed

    ${escapeHtml(error.message)}

    `); } }); 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}`); });