feat: Add FaceAI integration with handoff and return functionality

- Introduced a new workspace for FaceAI in package.json.
- Implemented FaceAI handoff logic in faceai_handoff.php, including identity verification and token signing.
- Created faceai_return.php to handle return requests from FaceAI, validating tokens and forwarding results.
- Developed faceai_simulator.php and faceai_simulator_view.php for simulating the FaceAI interface with demo photos.
- Enhanced rus-ecom-240621.js to support new FaceAI features, including dynamic URL building and button integration.
- Added faceai_config.php for configuration management, including environment variable handling and utility functions.
- Updated HTML structure and styles in simulator view for better user experience.
This commit is contained in:
MaddoScientisto 2026-04-07 19:53:40 +02:00
commit da362c201f
31 changed files with 4511 additions and 60 deletions

View file

@ -0,0 +1,15 @@
{
"name": "@regalami/faceai-backend",
"private": true,
"type": "module",
"scripts": {
"dev": "node --watch src/server.js",
"build": "node -e \"console.log('backend build not required')\"",
"start": "node src/server.js"
},
"dependencies": {
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"express": "^4.21.2"
}
}

View file

@ -0,0 +1,40 @@
import crypto from 'node:crypto';
function base64UrlEncode(input) {
return Buffer.from(input).toString('base64url');
}
function base64UrlDecode(input) {
return Buffer.from(input, 'base64url').toString('utf8');
}
export function signPayload(payload, secret) {
const body = base64UrlEncode(JSON.stringify(payload));
const signature = crypto.createHmac('sha256', secret).update(body).digest('base64url');
return `${body}.${signature}`;
}
export function verifySignedPayload(token, secret) {
if (!token || !token.includes('.')) {
throw new Error('Invalid token format');
}
const [body, signature] = token.split('.');
const expected = crypto.createHmac('sha256', secret).update(body).digest('base64url');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
throw new Error('Invalid token signature');
}
const payload = JSON.parse(base64UrlDecode(body));
if (payload.expiresAt && Date.now() > payload.expiresAt) {
throw new Error('Token expired');
}
return payload;
}
export function randomId(prefix) {
return `${prefix}_${crypto.randomBytes(8).toString('hex')}`;
}

View file

@ -0,0 +1,18 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const defaultLocalLegacyRoot = path.resolve(__dirname, '../../../../www');
export const config = {
port: Number(process.env.PORT || 3001),
frontendUrl: process.env.FACEAI_FRONTEND_URL || 'http://localhost:5173',
publicBaseUrl: process.env.FACEAI_PUBLIC_BASE_URL || 'http://localhost:3001',
legacyReturnUrl: process.env.FACEAI_LEGACY_RETURN_URL || 'http://localhost:3001/dev/legacy/return',
enableLocalLegacyStatic: process.env.FACEAI_ENABLE_LOCAL_LEGACY_STATIC
? process.env.FACEAI_ENABLE_LOCAL_LEGACY_STATIC === '1'
: process.env.NODE_ENV !== 'production',
localLegacyStaticRoot: process.env.FACEAI_LOCAL_LEGACY_STATIC_ROOT || defaultLocalLegacyRoot,
sharedSecret: process.env.FACEAI_SHARED_SECRET || 'change-me',
sessionCookieName: process.env.FACEAI_SESSION_COOKIE || 'rus_faceai_session'
};

View file

@ -0,0 +1,356 @@
import express from 'express';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { config } from './config.js';
import { signPayload, verifySignedPayload } from './auth.js';
import { createSession, createSearch, completeSearch, getResult, getSearch, getSession, mockCatalog } from './store.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const frontendDist = path.resolve(__dirname, '../../frontend/dist');
const app = express();
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();
}
function issueHandoffToken({ raceId, raceSlug, lang, returnUrl }) {
const race = mockCatalog[raceId] || { id: raceId, slug: raceSlug || `race-${raceId}`, name: raceSlug || `Race ${raceId}` };
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
},
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 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, lang, returnUrl });
res.redirect(`${config.frontendUrl}/auth/callback?token=${encodeURIComponent(token)}`);
});
app.get('/dev/legacy/return', (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 = getResult(String(req.query.resultId || payload.resultId));
if (!result || result.userId !== payload.userId) {
throw new Error('Result not found');
}
res.type('html').send(renderLegacyRacePage({ raceId: result.raceId, lang: result.lang || 'it', result }));
} catch (error) {
res.status(400).type('html').send(`<h1>Return handoff failed</h1><p>${escapeHtml(error.message)}</p>`);
}
});
app.post('/api/auth/exchange', (req, res) => {
try {
const { token } = req.body;
const payload = verifySignedPayload(token, config.sharedSecret);
if (payload.type !== 'handoff') {
throw new Error('Wrong token type');
}
const sessionId = createSession({
user: payload.user,
race: payload.race,
lang: payload.lang,
returnUrl: payload.returnUrl,
access: {
faceAiAllowed: payload.user.membershipStatus === 'active'
}
});
res.cookie(config.sessionCookieName, sessionId, {
httpOnly: true,
sameSite: 'lax',
secure: false,
path: '/'
});
res.json({
user: payload.user,
race: payload.race,
lang: payload.lang,
returnUrl: payload.returnUrl,
access: {
faceAiAllowed: true
}
});
} 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, (req, res) => {
const raceId = String(req.body.raceId || req.faceaiSession.race.id);
const selfieName = String(req.body.selfieName || 'selfie.jpg');
const search = createSearch({
raceId,
selfieName,
user: req.faceaiSession.user,
returnUrl: req.faceaiSession.returnUrl,
lang: req.faceaiSession.lang
});
setTimeout(() => {
completeSearch(search.id);
}, 3500);
res.status(201).json({
id: search.id,
status: search.status,
raceId: search.raceId,
selfieName: search.selfieName
});
});
app.get('/api/searches/:id', requireSession, (req, res) => {
const search = getSearch(req.params.id);
if (!search || search.user.id !== 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.matches.length
});
});
app.get('/api/searches/:id/redirect', requireSession, (req, res) => {
const search = getSearch(req.params.id);
if (!search || search.user.id !== 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 = getResult(search.resultId);
const token = issueReturnToken(result);
res.json({
url: `${config.legacyReturnUrl}?resultId=${encodeURIComponent(result.id)}&token=${encodeURIComponent(token)}`
});
});
app.get('/bridge/results/:id', (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 = getResult(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 });
}
});
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}`);
});

View file

@ -0,0 +1,110 @@
import { randomId } from './auth.js';
export const mockCatalog = {
'101': {
id: '101',
slug: 'mezza-di-firenze',
name: 'Mezza di Firenze',
photos: [
{ id: 'f101-001', label: 'Arrivo 001', bib: '245', checkpoint: 'Arrivo', thumb: 'thumb-arrivo-001.jpg' },
{ id: 'f101-002', label: 'Arrivo 002', bib: '245', checkpoint: 'Arrivo', thumb: 'thumb-arrivo-002.jpg' },
{ id: 'f101-003', label: 'Ponte 003', bib: '245', checkpoint: 'Ponte', thumb: 'thumb-ponte-003.jpg' },
{ id: 'f101-004', label: 'Centro 004', bib: '245', checkpoint: 'Centro', thumb: 'thumb-centro-004.jpg' },
{ id: 'f101-005', label: 'Centro 005', bib: '812', checkpoint: 'Centro', thumb: 'thumb-centro-005.jpg' },
{ id: 'f101-006', label: 'Arrivo 006', bib: '812', checkpoint: 'Arrivo', thumb: 'thumb-arrivo-006.jpg' },
{ id: 'f101-007', label: 'Ponte 007', bib: '391', checkpoint: 'Ponte', thumb: 'thumb-ponte-007.jpg' },
{ id: 'f101-008', label: 'Centro 008', bib: '391', checkpoint: 'Centro', thumb: 'thumb-centro-008.jpg' },
{ id: 'f101-009', label: 'Arrivo 009', bib: '128', checkpoint: 'Arrivo', thumb: 'thumb-arrivo-009.jpg' },
{ id: 'f101-010', label: 'Lungarno 010', bib: '128', checkpoint: 'Lungarno', thumb: 'thumb-lungarno-010.jpg' },
{ id: 'f101-011', label: 'Piazza 011', bib: '560', checkpoint: 'Piazza', thumb: 'thumb-piazza-011.jpg' },
{ id: 'f101-012', label: 'Arrivo 012', bib: '560', checkpoint: 'Arrivo', thumb: 'thumb-arrivo-012.jpg' }
]
},
'202': {
id: '202',
slug: 'trail-del-chianti',
name: 'Trail del Chianti',
photos: [
{ id: 'f202-001', label: 'Bosco 001', bib: '77', checkpoint: 'Bosco', thumb: 'thumb-bosco-001.jpg' },
{ id: 'f202-002', label: 'Salita 002', bib: '77', checkpoint: 'Salita', thumb: 'thumb-salita-002.jpg' },
{ id: 'f202-003', label: 'Arrivo 003', bib: '77', checkpoint: 'Arrivo', thumb: 'thumb-arrivo-003.jpg' },
{ id: 'f202-004', label: 'Bosco 004', bib: '19', checkpoint: 'Bosco', thumb: 'thumb-bosco-004.jpg' }
]
}
};
const sessions = new Map();
const searches = new Map();
const results = new Map();
export function createSession(session) {
const sessionId = randomId('sess');
sessions.set(sessionId, {
...session,
createdAt: Date.now()
});
return sessionId;
}
export function getSession(sessionId) {
return sessions.get(sessionId) || null;
}
export function createSearch({ raceId, user, selfieName, returnUrl, lang }) {
const searchId = randomId('search');
searches.set(searchId, {
id: searchId,
raceId,
user,
selfieName,
returnUrl,
lang,
status: 'processing',
createdAt: Date.now(),
completedAt: null,
resultId: null,
matches: []
});
return searches.get(searchId);
}
export function getSearch(searchId) {
return searches.get(searchId) || null;
}
export function completeSearch(searchId) {
const search = searches.get(searchId);
if (!search) {
return null;
}
const race = mockCatalog[search.raceId];
const matches = (race?.photos || []).slice(0, Math.min(4, race?.photos?.length || 0));
const resultId = randomId('result');
results.set(resultId, {
id: resultId,
raceId: search.raceId,
raceName: race?.name || search.raceId,
userId: search.user.id,
returnUrl: search.returnUrl,
lang: search.lang,
matches,
createdAt: Date.now()
});
const completed = {
...search,
status: 'completed',
completedAt: Date.now(),
resultId,
matches
};
searches.set(searchId, completed);
return completed;
}
export function getResult(resultId) {
return results.get(resultId) || null;
}

View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FaceAI</title>
<script type="module" src="/src/main.js"></script>
</head>
<body>
<div id="app"></div>
</body>
</html>

View file

@ -0,0 +1,18 @@
{
"name": "@regalami/faceai-frontend",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^6.1.0"
}
}

View file

@ -0,0 +1,3 @@
<template>
<router-view />
</template>

View file

@ -0,0 +1,57 @@
<script setup>
import { legacyAsset } from '../legacyAssets.js';
const logoUrl = legacyAsset('/images/layout/regalami-un-sorriso-ets-640.png');
const facebookUrl = legacyAsset('/images/FB-f-Logo__blue_29.png');
const donateUrl = legacyAsset('/images/btn_donateCC_LG.gif');
</script>
<template>
<div>
<a id="top"></a>
<nav class="navbar fixed-top navbar-expand-lg navbar-light bg-white fixed-top">
<div class="container">
<a class="navbar-brand" href="http://localhost:8080/faceai_simulator.php?raceId=101&lang=it">
<img :src="logoUrl" alt="Regalami Un Sorriso ETS" width="100" />
</a>
<button class="navbar-toggler navbar-toggler-right" type="button">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse show" id="navbarResponsive">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="http://localhost:8080/index.jsp">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="http://localhost:8080/associazione.jsp">Associazione</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="http://localhost:8080/faceai_simulator.php?raceId=101&lang=it">Foto</a>
</li>
<li class="nav-item">
<a class="nav-link btn btn-sm btn-warning" href="http://localhost:8080/gallery2.php">Archivio</a>
</li>
<li class="nav-item">
<a href="http://localhost:8080/dettaglio_clienti-it.html">
<img :src="donateUrl" border="0" alt="PayPal" />
</a>
</li>
</ul>
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link active" href="#">
<i class="fa fa-user" aria-hidden="true"></i> Il mio account
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="https://it-it.facebook.com/pg/Regalami-un-sorriso-ETS-189377806523/community/">
<img :src="facebookUrl" class="img-fluid" alt="Facebook" />
</a>
</li>
</ul>
</div>
</div>
</nav>
</div>
</template>

View file

@ -0,0 +1,26 @@
const legacyAssetBaseUrl = (import.meta.env.VITE_LEGACY_ASSET_BASE_URL || '/legacy-static').replace(/\/$/, '');
export function legacyAsset(path) {
return `${legacyAssetBaseUrl}${path.startsWith('/') ? path : `/${path}`}`;
}
export function injectLegacyStylesheets() {
const stylesheets = [
legacyAsset('/vendor/bootstrap/css/bootstrap.min.css'),
legacyAsset('/css/font-awesome.min.css'),
'https://fonts.googleapis.com/css?family=Roboto:300,300i,400,400i,700,700i',
legacyAsset('/css/custom-style.css')
];
stylesheets.forEach((href) => {
if (document.head.querySelector(`link[data-legacy-href="${href}"]`)) {
return;
}
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
link.dataset.legacyHref = href;
document.head.appendChild(link);
});
}

View file

@ -0,0 +1,8 @@
import { createApp } from 'vue';
import App from './App.vue';
import router from './router.js';
import './styles.css';
import { injectLegacyStylesheets } from './legacyAssets.js';
injectLegacyStylesheets();
createApp(App).use(router).mount('#app');

View file

@ -0,0 +1,17 @@
import { createRouter, createWebHistory } from 'vue-router';
import HomeView from './views/HomeView.vue';
import HandoffCallbackView from './views/HandoffCallbackView.vue';
export default createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
component: HomeView
},
{
path: '/auth/callback',
component: HandoffCallbackView
}
]
});

View file

@ -0,0 +1,65 @@
body {
background: #fff;
}
.faceai-page {
min-height: calc(100vh - 120px);
}
.faceai-form-shell {
padding: 0 0 12px;
}
.faceai-action-row {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.faceai-feedback {
background: #f8f9fa;
border-left: 5px solid #fe3d00;
padding: 16px 18px;
}
.faceai-feedback .lead {
font-size: 1rem;
}
.faceai-spinner-block {
display: inline-flex;
align-items: center;
gap: 12px;
font-weight: 500;
color: #5b4938;
}
.faceai-spinner-block .spinner-border {
flex: 0 0 auto;
}
.callback-shell {
min-height: calc(100vh - 120px);
}
.callback-card {
width: 100%;
max-width: 620px;
background: #fff;
border: 1px solid #ddd;
padding: 24px;
margin: 40px auto;
}
@media (max-width: 767px) {
.faceai-action-row {
display: block;
}
.faceai-action-row .btn {
display: block;
width: 100%;
margin-bottom: 8px;
}
}

View file

@ -0,0 +1,51 @@
<script setup>
import LegacyHeader from '../components/LegacyHeader.vue';
import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const route = useRoute();
const router = useRouter();
const errorMessage = ref('');
onMounted(async () => {
const token = route.query.token;
if (!token) {
errorMessage.value = 'Missing handoff token.';
return;
}
const response = await fetch('/api/auth/exchange', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({ token })
});
if (!response.ok) {
const payload = await response.json().catch(() => ({ error: 'Token exchange failed' }));
errorMessage.value = payload.error || 'Token exchange failed';
return;
}
router.replace('/');
});
</script>
<template>
<main>
<LegacyHeader />
<div class="container my-3 callback-shell">
<div class="callback-card">
<h1 class="my-4">Face ID</h1>
<div v-if="!errorMessage" class="faceai-spinner-block">
<span class="spinner-border text-warning" role="status" aria-hidden="true"></span>
<span>Validazione handoff legacy e creazione della sessione FaceAI in corso...</span>
</div>
<p v-else class="text-danger">{{ errorMessage }}</p>
</div>
</div>
</main>
</template>

View file

@ -0,0 +1,235 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import LegacyHeader from '../components/LegacyHeader.vue';
import { legacyAsset } from '../legacyAssets.js';
const coverImageUrl = legacyAsset('/images/layout/Logo_RUS_ETS_tricolore_3-1.jpg');
const session = ref(null);
const loading = ref(true);
const errorMessage = ref('');
const selectedFile = ref(null);
const activeSearch = ref(null);
const redirectUrl = ref('');
const isSubmitting = ref(false);
const isRedirecting = ref(false);
let pollTimer = null;
const isWorking = computed(() => loading.value || isSubmitting.value || isRedirecting.value || activeSearch.value?.status === 'processing');
const busyLabel = computed(() => {
if (loading.value) {
return 'Caricamento sessione FaceAI...';
}
if (isSubmitting.value) {
return 'Invio del selfie e preparazione della ricerca...';
}
if (isRedirecting.value) {
return 'Reindirizzamento alla pagina legacy filtrata in corso...';
}
if (activeSearch.value?.status === 'processing') {
return 'Ricerca biometrica in corso su tutte le foto della gara...';
}
return '';
});
const statusLabel = computed(() => {
if (!activeSearch.value) {
return 'Carica un selfie per avviare una ricerca limitata alla gara corrente.';
}
if (activeSearch.value.status === 'completed') {
return `Ricerca completata. Trovate ${activeSearch.value.matchCount} foto corrispondenti.`;
}
return 'Ricerca in corso. Il sistema aggiorna automaticamente lo stato finche il risultato non e pronto.';
});
async function loadSession() {
loading.value = true;
const response = await fetch('/api/session', { credentials: 'include' });
if (!response.ok) {
loading.value = false;
return;
}
session.value = await response.json();
loading.value = false;
}
function onFileChange(event) {
selectedFile.value = event.target.files?.[0] || null;
}
async function pollSearch(searchId) {
const response = await fetch(`/api/searches/${searchId}`, { credentials: 'include' });
if (!response.ok) {
errorMessage.value = 'Unable to read search status.';
isSubmitting.value = false;
return;
}
activeSearch.value = await response.json();
if (activeSearch.value.status === 'completed') {
isSubmitting.value = false;
const redirectResponse = await fetch(`/api/searches/${searchId}/redirect`, { credentials: 'include' });
const payload = await redirectResponse.json();
if (!redirectResponse.ok) {
errorMessage.value = payload.error || 'Unable to build return URL.';
return;
}
redirectUrl.value = payload.url;
isRedirecting.value = true;
window.setTimeout(() => {
window.location.href = payload.url;
}, 1200);
return;
}
pollTimer = window.setTimeout(() => {
pollSearch(searchId);
}, 1500);
}
async function submitSearch() {
errorMessage.value = '';
redirectUrl.value = '';
isRedirecting.value = false;
if (!selectedFile.value) {
errorMessage.value = 'Choose a selfie before starting the search.';
return;
}
isSubmitting.value = true;
const response = await fetch('/api/searches', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({
raceId: session.value.race.id,
selfieName: selectedFile.value.name
})
});
const payload = await response.json();
if (!response.ok) {
errorMessage.value = payload.error || 'Unable to create the search.';
isSubmitting.value = false;
return;
}
activeSearch.value = payload;
pollSearch(payload.id);
}
onMounted(loadSession);
onBeforeUnmount(() => {
if (pollTimer) {
window.clearTimeout(pollTimer);
}
});
</script>
<template>
<main>
<LegacyHeader />
<div class="container my-3 faceai-page">
<div class="row mb-5">
<div class="col-lg-12">
<h1 class="my-4">Face ID</h1>
</div>
<div class="col-md-2">
<img :src="coverImageUrl" class="img-fluid border border-warning" alt="FaceAI" />
</div>
<div class="col-md-10">
<div class="row riepilogo">
<div class="col-md-3">
<p><i class="fa fa-user fa-lg text-warning" aria-hidden="true"></i> {{ session ? session.user.displayName : 'Sessione FaceAI' }}</p>
</div>
<div class="col-md-3">
<p><i class="fa fa-camera-retro fa-lg text-warning" aria-hidden="true"></i> {{ session ? session.race.name : 'Upload selfie' }}</p>
</div>
<div class="col">
<p><i class="fa fa-refresh fa-lg text-warning" aria-hidden="true"></i> {{ activeSearch ? activeSearch.status : 'ready' }}</p>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="bg-light faceai-form-shell">
<div class="row">
<div class="form-group mx-3 pt-4 pb-1 mb-0 px-2 arrow_box">
<h2>Cerca le tue foto</h2>
</div>
<div v-if="loading" class="col-12 p-4">
<div class="faceai-spinner-block">
<span class="spinner-border text-warning" role="status" aria-hidden="true"></span>
<span>{{ busyLabel }}</span>
</div>
</div>
<div v-else-if="!session" class="col-12 p-4">
<p>Apri prima il simulatore legacy per generare il token firmato di handoff.</p>
<a class="btn btn-warning" href="http://localhost:8080/faceai_simulator.php?raceId=101&lang=it">Apri il simulatore legacy</a>
</div>
<template v-else>
<div class="form-group col-12 col-md-4 mt-3 ml-3">
<div class="input-group">
<label for="raceName" class="sr-only">Gara</label>
<input id="raceName" class="form-control form-control-sm mb-2 mb-sm-0" :value="session.race.name" readonly />
</div>
</div>
<div class="form-group col-12 col-md-3 mt-3 ml-3">
<div class="input-group">
<label for="langView" class="sr-only">Lingua</label>
<input id="langView" class="form-control form-control-sm mb-2 mb-sm-0" :value="session.lang" readonly />
</div>
</div>
<div class="form-group col-12 col-md-4 mt-3 ml-3">
<div class="input-group">
<label for="selfieUpload" class="sr-only">Selfie</label>
<input id="selfieUpload" class="form-control form-control-sm mb-2 mb-sm-0" type="file" accept="image/*" @change="onFileChange" />
</div>
</div>
<div class="form-group col-12 mt-2 ml-3 mr-3 faceai-action-row">
<button class="btn btn-warning" type="button" :disabled="isWorking" @click="submitSearch">Avvia ricerca Face ID</button>
<a class="btn btn-light" :href="session.returnUrl">Torna alla pagina gara</a>
</div>
</template>
</div>
</div>
</div>
</div>
<div class="faceai-feedback mt-4">
<p class="lead mb-2">{{ statusLabel }}</p>
<div v-if="isWorking && busyLabel" class="faceai-spinner-block mb-3">
<span class="spinner-border spinner-border-sm text-warning" role="status" aria-hidden="true"></span>
<span>{{ busyLabel }}</span>
</div>
<p v-if="activeSearch" class="mb-2">Match trovati: {{ activeSearch.matchCount }}</p>
<p v-if="redirectUrl" class="mb-2">Reindirizzamento alla pagina legacy filtrata in corso...</p>
<p v-if="errorMessage" class="text-danger mb-2">{{ errorMessage }}</p>
</div>
</div>
</div>
</div>
</main>
</template>

View file

@ -0,0 +1,13 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:3001',
'/dev': 'http://localhost:3001'
}
}
});