2026-04-07 19:53:40 +02:00
import express from 'express' ;
import cors from 'cors' ;
import cookieParser from 'cookie-parser' ;
2026-04-11 17:53:22 +02:00
import multer from 'multer' ;
2026-04-07 19:53:40 +02:00
import fs from 'node:fs' ;
2026-04-11 17:53:22 +02:00
import fsp from 'node:fs/promises' ;
2026-04-07 19:53:40 +02:00
import path from 'node:path' ;
import { fileURLToPath } from 'node:url' ;
import { config } from './config.js' ;
import { signPayload , verifySignedPayload } from './auth.js' ;
2026-04-11 17:53:22 +02:00
import { createSession , getSession , mockCatalog } from './store.js' ;
2026-04-12 17:26:17 +02:00
import { buildRaceStorage , resolveRacePklAvailability } from './race-storage.js' ;
2026-04-11 17:53:22 +02:00
import {
acquireActiveSearchLock ,
createRedisConnection ,
createSearchRecord ,
getActiveSearchId ,
getResultRecord ,
getSearchRecord ,
incrementRateLimit ,
saveSearchRecord
} from './redis-store.js' ;
import { getSearchQueue } from './queue.js' ;
import { normalizeMatches } from './matcher-results.js' ;
2026-04-07 19:53:40 +02:00
const _ _dirname = path . dirname ( fileURLToPath ( import . meta . url ) ) ;
const frontendDist = path . resolve ( _ _dirname , '../../frontend/dist' ) ;
const app = express ( ) ;
2026-04-11 17:53:22 +02:00
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 } ` ) ;
}
} )
} ) ;
2026-04-07 19:53:40 +02:00
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 ( ) ;
}
2026-04-11 17:53:22 +02:00
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 ( ) ;
}
2026-04-12 17:26:17 +02:00
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 || { } )
} ;
2026-04-07 19:53:40 +02:00
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 ,
2026-04-12 17:26:17 +02:00
name : race . name ,
storage : buildRaceStorage ( raceStorage || race . storage || { } )
2026-04-07 19:53:40 +02:00
} ,
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 ) } < / d i v >
< div class = "legacy-meta" >
< strong > $ { escapeHtml ( photo . label ) } < / s t r o n g >
< span > ID foto : $ { escapeHtml ( photo . id ) } < / s p a n >
< span > Punto foto : $ { escapeHtml ( photo . checkpoint || '-' ) } < / s p a n >
< / d i v >
< / l i >
` ).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 < / t i t l e >
< style >
body { font - family : Georgia , serif ; margin : 0 ; background : # f7f1e8 ; color : # 2 c241b ; }
. topbar { background : # fff ; border - bottom : 1 px solid # d9c7aa ; padding : 16 px 24 px ; display : flex ; justify - content : space - between ; align - items : center ; }
. brand { font - size : 22 px ; font - weight : 700 ; }
. page { max - width : 1120 px ; margin : 0 auto ; padding : 24 px ; }
. toolbar { background : # fff ; border : 1 px solid # dccab2 ; padding : 16 px ; display : flex ; gap : 12 px ; align - items : center ; flex - wrap : wrap ; }
. toolbar label { font - size : 14 px ; color : # 6 a5845 ; }
. toolbar select { padding : 10 px 12 px ; min - width : 220 px ; }
. toolbar button { background : # 8 d1f1f ; color : # fff ; border : 0 ; padding : 11 px 18 px ; font - weight : 700 ; cursor : pointer ; }
. legacy - banner { margin : 20 px 0 ; background : # f6d9b6 ; border : 1 px solid # c69257 ; padding : 14 px 16 px ; }
. legacy - banner - neutral { background : # e9efe8 ; border - color : # 8 fa18b ; }
. summary { margin : 12 px 0 24 px ; }
. gallery { list - style : none ; padding : 0 ; margin : 0 ; display : grid ; grid - template - columns : repeat ( auto - fit , minmax ( 240 px , 1 fr ) ) ; gap : 16 px ; }
. legacy - card { background : # fff ; border : 1 px solid # dccab2 ; padding : 16 px ; display : grid ; gap : 12 px ; }
. legacy - thumb { background : # efe2d1 ; border : 1 px dashed # b79a77 ; min - height : 120 px ; display : grid ; place - items : center ; color : # 6 f5b47 ; font - size : 13 px ; }
. legacy - meta { display : grid ; gap : 4 px ; font - size : 14 px ; }
. legacy - empty { background : # fff ; border : 1 px solid # dccab2 ; padding : 24 px ; }
< / s t y l e >
< / h e a d >
< body >
< header class = "topbar" >
< div class = "brand" > Regalami un Sorriso ETS < / d i v >
< div > Utente simulato : Mario Rossi < / d i v >
< / h e a d e r >
< main class = "page" >
< h1 > $ { escapeHtml ( race . name ) } < / h 1 >
< div class = "toolbar" >
< label > Punti Foto < / l a b e l >
< select >
< option > -- Punti Foto -- < / o p t i o n >
< option > Arrivo < / o p t i o n >
< option > Centro < / o p t i o n >
< option > Ponte < / o p t i o n >
< / s e l e c t >
< button id = "faceai-launch" > Face ID < / b u t t o n >
< / d i v >
$ { 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 } < / u l >
< / m a i n >
< 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 ( ) ;
} ) ;
< / s c r i p t >
< / b o d y >
< / h t m l > ` ;
}
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 } ` ) ;
2026-04-12 17:26:17 +02:00
const raceName = String ( req . query . raceName || mockCatalog [ raceId ] ? . name || raceSlug ) ;
2026-04-07 19:53:40 +02:00
const lang = String ( req . query . lang || 'it' ) ;
const returnUrl = String ( req . query . returnUrl || ` ${ config . publicBaseUrl } /dev/legacy/race?raceId= ${ encodeURIComponent ( raceId ) } &lang= ${ encodeURIComponent ( lang ) } ` ) ;
2026-04-12 17:26:17 +02:00
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
} ) ;
2026-04-07 19:53:40 +02:00
res . redirect ( ` ${ config . frontendUrl } /auth/callback?token= ${ encodeURIComponent ( token ) } ` ) ;
} ) ;
2026-04-11 17:53:22 +02:00
app . get ( '/dev/legacy/return' , async ( req , res ) => {
2026-04-07 19:53:40 +02:00
try {
const token = String ( req . query . token || '' ) ;
const payload = verifySignedPayload ( token , config . sharedSecret ) ;
if ( payload . type !== 'return' ) {
throw new Error ( 'Wrong token type' ) ;
}
2026-04-11 17:53:22 +02:00
const result = await getResultRecord ( redis , String ( req . query . resultId || payload . resultId ) ) ;
2026-04-07 19:53:40 +02:00
if ( ! result || result . userId !== payload . userId ) {
throw new Error ( 'Result not found' ) ;
}
2026-04-11 17:53:22 +02:00
const normalizedResult = {
... result ,
matches : normalizeMatches ( result )
} ;
res . type ( 'html' ) . send ( renderLegacyRacePage ( { raceId : result . raceId , lang : result . lang || 'it' , result : normalizedResult } ) ) ;
2026-04-07 19:53:40 +02:00
} catch ( error ) {
res . status ( 400 ) . type ( 'html' ) . send ( ` <h1>Return handoff failed</h1><p> ${ escapeHtml ( error . message ) } </p> ` ) ;
}
} ) ;
2026-04-12 17:26:17 +02:00
app . post ( '/api/auth/exchange' , async ( req , res ) => {
2026-04-07 19:53:40 +02:00
try {
const { token } = req . body ;
const payload = verifySignedPayload ( token , config . sharedSecret ) ;
if ( payload . type !== 'handoff' ) {
throw new Error ( 'Wrong token type' ) ;
}
2026-04-12 17:26:17 +02:00
const race = normalizeRaceForSession ( payload . race ) ;
const availability = await buildRaceAvailability ( race ) ;
const faceAiAllowed = payload . user . membershipStatus === 'active' && availability . available ;
2026-04-07 19:53:40 +02:00
const sessionId = createSession ( {
user : payload . user ,
2026-04-12 17:26:17 +02:00
race ,
2026-04-07 19:53:40 +02:00
lang : payload . lang ,
returnUrl : payload . returnUrl ,
2026-04-12 17:26:17 +02:00
availability ,
2026-04-07 19:53:40 +02:00
access : {
2026-04-12 17:26:17 +02:00
faceAiAllowed
2026-04-07 19:53:40 +02:00
}
} ) ;
res . cookie ( config . sessionCookieName , sessionId , {
httpOnly : true ,
sameSite : 'lax' ,
secure : false ,
path : '/'
} ) ;
res . json ( {
user : payload . user ,
2026-04-12 17:26:17 +02:00
race ,
2026-04-07 19:53:40 +02:00
lang : payload . lang ,
returnUrl : payload . returnUrl ,
2026-04-12 17:26:17 +02:00
availability ,
2026-04-07 19:53:40 +02:00
access : {
2026-04-12 17:26:17 +02:00
faceAiAllowed
2026-04-07 19:53:40 +02:00
}
} ) ;
} catch ( error ) {
res . status ( 400 ) . json ( { error : error . message } ) ;
}
} ) ;
app . get ( '/api/session' , requireSession , ( req , res ) => {
res . json ( req . faceaiSession ) ;
} ) ;
2026-04-11 17:53:22 +02:00
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 ) ;
2026-04-12 17:26:17 +02:00
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 ;
}
2026-04-11 17:53:22 +02:00
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 ;
}
2026-04-07 19:53:40 +02:00
2026-04-11 17:53:22 +02:00
if ( ! req . file ) {
res . status ( 400 ) . json ( {
error : 'Choose a selfie before starting the search.' ,
code : 'MISSING_SELFIE'
} ) ;
return ;
}
2026-04-07 19:53:40 +02:00
2026-04-11 17:53:22 +02:00
const search = await createSearchRecord ( redis , {
raceId ,
raceName : race ? . name || raceId ,
2026-04-12 17:26:17 +02:00
raceStorage : race ? . storage || availability . storage ,
2026-04-11 17:53:22 +02:00
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 ;
}
2026-04-07 19:53:40 +02:00
2026-04-11 17:53:22 +02:00
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 ,
2026-04-12 17:26:17 +02:00
raceStorage : updatedSearch . raceStorage ,
2026-04-11 17:53:22 +02:00
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.' } ) ;
}
2026-04-07 19:53:40 +02:00
} ) ;
2026-04-11 17:53:22 +02:00
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 ) {
2026-04-07 19:53:40 +02:00
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 ,
2026-04-11 17:53:22 +02:00
matchCount : search . matchCount || 0 ,
errorCode : search . errorCode ,
errorMessage : search . errorMessage
2026-04-07 19:53:40 +02:00
} ) ;
} ) ;
2026-04-11 17:53:22 +02:00
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 ) {
2026-04-07 19:53:40 +02:00
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 ;
}
2026-04-11 17:53:22 +02:00
const result = await getResultRecord ( redis , search . resultId ) ;
if ( ! result ) {
res . status ( 404 ) . json ( { error : 'Result not found' } ) ;
return ;
}
2026-04-07 19:53:40 +02:00
const token = issueReturnToken ( result ) ;
res . json ( {
url : ` ${ config . legacyReturnUrl } ?resultId= ${ encodeURIComponent ( result . id ) } &token= ${ encodeURIComponent ( token ) } `
} ) ;
} ) ;
2026-04-11 17:53:22 +02:00
app . get ( '/bridge/results/:id' , async ( req , res ) => {
2026-04-07 19:53:40 +02:00
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' ) ;
}
2026-04-11 17:53:22 +02:00
const result = await getResultRecord ( redis , req . params . id ) ;
2026-04-07 19:53:40 +02:00
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 } ) ;
}
} ) ;
2026-04-11 17:53:22 +02:00
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 } ) ;
}
} ) ;
2026-04-07 19:53:40 +02:00
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 } ` ) ;
} ) ;