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

@ -9,9 +9,10 @@ The new app will:
- start from the race photo view page
- search only within the current race
- accept a selfie upload
- show matched race photos with previews
- let the user open and download photos through the existing legacy download flow
- fall back to email delivery when the queue is long or processing is slow
- process the request inside FaceAI
- return the user to the original race page with the results filtered to only the matched images
- keep photo opening and downloading entirely on the legacy site
- use polling only in v1 if processing takes time
## What Exists Today
@ -31,11 +32,15 @@ The legacy site identity is held in the Java web session, not in PHP. That means
Because of that, the recommended plan needs one of these two options:
1. Preferred: a very small server-side bridge on the legacy Java side, or at the reverse proxy level with app support, to mint a trusted handoff token for FaceAI.
1. Preferred: a very small server-side bridge on the legacy Java or JSP side, or at the reverse proxy level with app support, to mint a trusted handoff token for FaceAI.
2. Fallback: a separate login flow for FaceAI.
If the requirement is strict single sign-on based on the current site session, option 1 is the only realistic path.
There is also an operational constraint from the implementation side:
The bridge should be designed so it can be deployed without setting up a full local Java development environment and without recompiling the existing application locally. That makes a tiny JSP-based handoff endpoint or a minimal existing-controller extension preferable to adding new compiled Java modules.
## Recommended Architecture
Use three deployable parts:
@ -49,6 +54,7 @@ Use three deployable parts:
- Keep authentication, membership checks, and download-credit subtraction as the source of truth.
- Launch the FaceAI app from the race page.
- Issue a short-lived signed handoff token that identifies the user and race context.
- Accept the final FaceAI match result and turn it into a legacy race-page filter.
- Continue to serve original photo downloads so existing counters and permissions remain unchanged.
### 2. FaceAI app responsibilities
@ -58,7 +64,8 @@ Use three deployable parts:
- Let the user upload a selfie.
- Create a race-scoped search request.
- Poll job status or show queued state.
- Render matched photos and route download/open actions back to legacy endpoints.
- Return a stable list of matched legacy photo identifiers.
- Redirect the user back to the legacy race page with a filter payload that reproduces the matched set.
- Preserve the page the user came from and offer a one-click return.
### 3. Processing service responsibilities
@ -67,7 +74,7 @@ Use three deployable parts:
- Queue requests and process them one by one.
- Run the external face-recognition program.
- Return match results with confidence and photo ids or file identifiers.
- Mark long-running jobs for async completion and email fallback.
- Return a completed result set usable by the legacy filter handoff.
## Authentication And Cookie Strategy
@ -96,6 +103,16 @@ Instead:
This gives the new app shared-domain cookies while avoiding direct dependency on the Java session internals.
### Preferred bridge implementation shape
Given the local environment constraint, the bridge should preferably be one of these:
- a tiny JSP endpoint that reads the existing session beans and performs a redirect
- a minimal addition to an already-existing legacy action/controller endpoint
- a reverse-proxy-assisted signed redirect if the platform already supports auth subrequests
Avoid a plan that requires introducing new compiled Java packages as the first step.
## Access Check
The handoff token should already include whether the feature is allowed. That check should be done on the legacy side where the real account state already exists.
@ -104,8 +121,6 @@ Minimal validation inputs:
- logged-in user exists
- account is active enough to use the feature
- race is eligible for FaceAI
- optional plan or quota flag for face search access
To avoid unnecessary database reads, compute this from already-loaded session/account state when possible. Only hit the database if the existing session object does not contain enough information.
@ -115,14 +130,15 @@ The smallest practical change set on the legacy site is:
### Frontend change
Do not replace the dropdown in JSP markup first.
Remove the old `tipoPuntoFoto` select from the user flow and replace it with the FaceAI launch button.
Instead, update `www/_js/rus-ecom-240621.js` so that on the race page it:
The lowest-risk way to do that is to update `www/_js/rus-ecom-240621.js` so that on the race page it:
- detects `#tipoPuntoFoto`
- hides or disables that select for eligible races
- removes that select from the rendered UI
- inserts a `Face ID` button in the same area
- builds the launch URL using the current race context and current page URL
- carries `raceId`, race description or slug, language, and exact `returnUrl`
This avoids fragile JSP layout edits and keeps the change deployable as a single JS asset update.
@ -137,12 +153,23 @@ Add one minimal auth bridge endpoint on the legacy stack. It can be:
That endpoint should:
- read the current legacy session
- verify the user and access
- verify the user and active-membership access
- generate the signed handoff token
- redirect to FaceAI
If this endpoint truly cannot be added, then single sign-on should be considered blocked and the plan should switch to a separate login flow.
### Legacy return filter change
The old site needs one small return-integration path so FaceAI can send the user back to the race page showing only the matched images.
That should be implemented as one of these:
- a new legacy endpoint that accepts a signed FaceAI result token and loads the matched photo set into the request or session before rendering the race page
- an extension of the existing photo search flow so it can accept a FaceAI result id and fetch the matched photo ids server-side
This is preferable to putting the matched ids directly in the browser URL, because the result set may be long and should remain tamper-resistant.
## FaceAI App Structure
The requested target folder is `faceai/`. It does not currently exist in this workspace, so this plan assumes it will be created as a new app.
@ -165,31 +192,47 @@ faceai/
## FaceAI User Flow
1. User opens a race photo page on `www`.
2. The old `tipoPuntoFoto` dropdown is replaced in the browser by a `Face ID` button.
2. The old `tipoPuntoFoto` dropdown is removed from the visible UI and replaced by a `Face ID` button.
3. User clicks the button.
4. Legacy bridge validates session and redirects to FaceAI with signed context.
5. FaceAI shows a page styled like the old site, including a matching header and a clear `Back to race page` action.
6. User uploads a selfie.
7. FaceAI creates a search job with `userId`, `raceId`, `requestId`, and selfie file reference.
8. If the queue is short, FaceAI waits and then shows results.
9. If processing is long, FaceAI tells the user the request will complete by email and stores the result for later retrieval.
10. User opens a matched photo detail or download action.
11. That action goes back through the legacy photo view/download endpoints so the current account checks and photo-count subtraction still apply.
8. FaceAI polls until the processing job completes.
9. Once the result is ready, FaceAI redirects the browser back to the original race page on `www`.
10. The legacy site resolves the matched photo ids and renders the race page filtered to those photos only, similar in spirit to the existing pettorale-based flow.
11. User opens and downloads photos exactly as they do today, through the legacy site.
## Result And Download Strategy
Do not duplicate the download-credit logic in FaceAI.
Do not duplicate either the final photo listing view or the download-credit logic in FaceAI.
Instead:
- FaceAI should store and display legacy photo identifiers, not its own download copies.
- When the user clicks a matched photo, FaceAI should open either:
- the existing legacy photo detail modal/page endpoint, or
- a dedicated legacy deep link for that photo
- When the user downloads, the request must end on the legacy side where `user.puoScaricareFoto()` and the existing decrement rules already live.
- FaceAI should store legacy photo identifiers, not its own download copies.
- FaceAI v1 should not become a second gallery UI for final results.
- When matching is complete, FaceAI should hand the result back to the legacy site.
- The legacy site should render the final matched-photo page using its existing race photo UI and existing photo modal/download endpoints.
This keeps the business rule in one place and avoids mismatched counters.
### Recommended handoff model for match results
Use a signed result reference instead of passing the whole match list in the browser.
Recommended flow:
1. FaceAI stores the match result under a `resultId`.
2. FaceAI redirects to something like `https://www.regalamiunsorriso.it/faceai-return?...` with:
- `resultId`
- `raceId`
- signed token
3. Legacy endpoint validates the token.
4. Legacy endpoint fetches the matched legacy photo ids from FaceAI or from a shared temporary store.
5. Legacy endpoint renders the normal race page constrained to that id set.
This avoids URL-length issues and reduces tampering risk.
## Matching Result Model
The processing service should return at least:
@ -204,11 +247,12 @@ The processing service should return at least:
Each match should contain:
- `photoId` compatible with legacy photo endpoints
- `previewUrl` or enough file info to derive the thumbnail
- `score` or confidence
- `capturedAt` if known
- `puntoFoto` or checkpoint info if available
For v1, `photoId` is the most important field. If the legacy page is the final renderer, thumbnails can remain a legacy concern after redirect.
Race scope is mandatory. The service must never search globally by default.
## Async Processing Design
@ -222,6 +266,7 @@ Use an API plus worker model.
- `POST /api/searches`
- `GET /api/searches/:id`
- `GET /api/searches/:id/results`
- `GET /api/searches/:id/redirect`
- `POST /api/searches/:id/cancel` optional
### Internal worker API or queue contract
@ -241,7 +286,7 @@ Output job:
- match list
- logs
- processing duration
- email-required flag
- legacy-renderable result reference
### Queue choice
@ -253,23 +298,23 @@ Any of these are reasonable:
For this use case, Redis plus BullMQ is the most pragmatic default.
## Email Fallback
## Polling And Timeout Strategy For V1
If the job stays queued too long or exceeds a synchronous timeout:
V1 should use polling only and should not send email.
1. FaceAI stores the request in `queued` or `processing` state.
2. Worker completes later.
3. System emails the user with:
- race name
- request id
- summary of result count
- list of photo names or identifiers for the race, as requested
- optional direct link back to the FaceAI results page
Recommended behavior:
1. FaceAI submits the job.
2. Browser polls `GET /api/searches/:id`.
3. While waiting, FaceAI shows a queue or processing state.
4. When complete, FaceAI redirects to the legacy filtered-result page.
Recommended timeout split:
- up to 15 to 30 seconds: keep user on the page with polling
- beyond that: switch to email fallback and let the user leave
- up to 30 seconds: keep the user on the page with polling
- beyond 30 seconds: keep polling but show a clear long-running state and a manual retry or refresh path
Email can be revisited in a later phase after the core handoff flow is stable.
## Database Usage
@ -283,7 +328,7 @@ Recommended storage responsibilities:
- job status
- uploaded selfie metadata
- result references to legacy photo ids
- audit fields and email status
- audit fields
Avoid copying full user profiles or photo business state into the FaceAI database.
@ -298,6 +343,8 @@ Recommended approach:
- Show account actions based on FaceAI session state.
- Add a prominent `Back to race results` link using the captured `returnUrl`.
For v1, the header should be a very close copy, not just a lightweight brand reference. The goal is that the user should feel they are still inside the same site family during the upload and waiting flow.
This is safer than trying to embed the old JSP header directly into a Node app.
## Security Requirements
@ -307,22 +354,24 @@ This is safer than trying to embed the old JSP header directly into a Node app.
- Uploaded selfies should have a short retention period.
- Face search results must be visible only to the requesting user.
- Queue jobs must be race-scoped and tied to the authenticated user.
- Email contents should avoid exposing direct raw file paths.
- Result handoff back to legacy must be signed and must not trust raw photo ids coming from the browser.
## Rollout Plan
### Phase 1: spike and contracts
- Confirm whether a minimal legacy auth bridge endpoint is possible.
- Confirm that a minimal JSP or existing-controller auth bridge endpoint is possible.
- Define the signed token payload.
- Define the worker input and output contract.
- Confirm which legacy photo id is stable enough to use in FaceAI results.
- Define how legacy will accept a FaceAI result reference and render a filtered race page.
### Phase 2: legacy launch integration
- Update `www/_js/rus-ecom-240621.js` to replace the dropdown with a FaceAI button in the browser.
- Update `www/_js/rus-ecom-240621.js` to remove the dropdown from the UI and insert the FaceAI button.
- Add the legacy auth bridge endpoint.
- Pass `raceId`, `lang`, and `returnUrl` into the FaceAI launch.
- Add the legacy return endpoint or result-aware race filter path.
### Phase 3: FaceAI app shell
@ -330,42 +379,43 @@ This is safer than trying to embed the old JSP header directly into a Node app.
- Implement auth callback and FaceAI session cookie.
- Build the legacy-style header and return navigation.
- Add selfie upload UI and request status page.
- Implement polling-only job completion flow.
### Phase 4: processing service
- Add queue and worker.
- Integrate the external face-recognition program.
- Return matched legacy photo ids and previews.
- Return matched legacy photo ids and a stored result reference suitable for legacy rendering.
### Phase 5: download integration
### Phase 5: legacy filtered-results integration
- Deep-link results back to legacy photo view/download endpoints.
- Redirect results back to the legacy race page.
- Verify that the legacy page can render only the matched id set.
- Verify that photo-credit subtraction still happens only on successful legacy downloads.
### Phase 6: async completion and email
### Phase 6: optional future enhancements
- Add timeout-based fallback.
- Send race-scoped result emails with photo names and a link back to FaceAI.
- Add email or offline completion flow if polling-only v1 proves insufficient.
- Add richer FaceAI-side previews only if needed after the legacy handoff works reliably.
## Open Questions To Resolve Early
1. Can the legacy site accept one minimal Java or JSP bridge endpoint for SSO handoff?
2. Which exact account rule should control FaceAI access: active membership only, extra entitlement, race flag, or download quota?
3. Which legacy endpoint is the best deep link for opening one photo from FaceAI results?
4. Is the existing session cookie already scoped to `.regalamiunsorriso.it`, or is it host-only today?
5. Should FaceAI results include only downloadable photos, or also visible-but-not-downloadable photos?
6. What is the acceptable selfie retention period for privacy compliance?
7. Should the email contain only photo names, or also signed result links?
1. Which existing legacy endpoint is the best place to implement the FaceAI return filter flow?
2. Should the return flow fetch matched photo ids directly from FaceAI, or from a shared short-lived store?
3. What is the acceptable selfie retention period for privacy compliance?
4. Should long-running polling survive page refresh via persisted request id in the FaceAI session?
5. Does the legacy race page need an explicit visual label that the current listing comes from FaceAI results?
## Recommended First Implementation
For the first version, keep the scope strict:
- launch from one race page only
- synchronous search if the queue is short
- email fallback if it exceeds the timeout
- result cards with preview plus `Open on Regalami un Sorriso`
- remove `tipoPuntoFoto` from the user-facing race search UI
- polling only, no email
- final results rendered on the legacy race page, not inside FaceAI
- all downloads still served by the legacy site
- one lightweight auth bridge only
- one lightweight return-filter bridge only
This version gives the new experience without moving the fragile parts of the old platform.

5
faceai/.dockerignore Normal file
View file

@ -0,0 +1,5 @@
node_modules/
apps/frontend/node_modules/
apps/backend/node_modules/
apps/frontend/dist/
.env

8
faceai/.env.example Normal file
View file

@ -0,0 +1,8 @@
PORT=3001
FACEAI_FRONTEND_URL=http://localhost:5173
FACEAI_PUBLIC_BASE_URL=http://localhost:3001
FACEAI_LEGACY_RETURN_URL=http://localhost:3001/dev/legacy/return
FACEAI_ENABLE_LOCAL_LEGACY_STATIC=1
FACEAI_LOCAL_LEGACY_STATIC_ROOT=k:\various\regalamiunsorriso\www
FACEAI_SHARED_SECRET=change-me
FACEAI_SESSION_COOKIE=rus_faceai_session

3
faceai/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules/
apps/frontend/dist/
.env

111
faceai/README.md Normal file
View file

@ -0,0 +1,111 @@
# FaceAI Scaffold
This folder scaffolds the new FaceAI app described in the integration plan.
It includes:
- a Vue frontend for the FaceAI upload and polling flow
- a Node/Express backend for session exchange, mocked searches, and return handoff
- a local legacy simulator so the launch and return flow can be tested without the old Java site
- a Dockerized PHP Apache stack for exercising the real `www/faceai_handoff.php` and `www/faceai_return.php` bridge files
## Structure
```text
faceai/
apps/
backend/
frontend/
docker/
Dockerfile
```
## What The Local Test Covers
The local simulator exercises the exact flow the plan is aiming for:
1. a legacy-like race page shows a `Face ID` button instead of `tipoPuntoFoto`
2. clicking it hits a mock legacy handoff endpoint
3. the backend signs a short-lived handoff token and redirects to the Vue app
4. the Vue app exchanges the token for its own FaceAI session cookie
5. the user uploads a selfie and starts a mocked race-scoped search
6. the frontend polls until the job completes
7. FaceAI requests a signed return URL
8. the browser is redirected back to a legacy-like filtered race page showing only the matched photos
## Local Run
From this folder:
```bash
npm install
npm run dev
```
Then open:
```text
http://localhost:3001/dev/legacy/race?raceId=101&lang=it
```
That page simulates the old site and launches the FaceAI app at `http://localhost:5173`.
## Docker Run With PHP Simulator
If you do not have PHP locally, use Docker instead:
```bash
npm install
npm run build
docker compose up --build
```
The Docker stack reuses the local FaceAI workspace and only containerizes the runtime services. That means PHP is fully containerized, while the Node service runs inside Docker against the already-installed local workspace dependencies and the already-built frontend assets.
This starts:
- FaceAI app on `http://localhost:3001`
- PHP Apache serving `www` on `http://localhost:8080`
For the end-to-end test through the PHP bridge, open:
```text
http://localhost:8080/faceai_simulator.php?raceId=101&lang=it
```
That page loads the original race-page JavaScript from `www/_js/rus-ecom-240621.js`, lets the script replace the visible `tipoPuntoFoto` selector with the new `Face ID` button, and launches the real PHP handoff bridge at `www/faceai_handoff.php`.
If you change frontend code and want Docker to serve the updated UI, rebuild first with:
```bash
npm run build
```
## Environment
Defaults are already set for local development, but these can be overridden:
```text
PORT=3001
FACEAI_FRONTEND_URL=http://localhost:5173
FACEAI_PUBLIC_BASE_URL=http://localhost:3001
FACEAI_LEGACY_RETURN_URL=http://localhost:3001/dev/legacy/return
FACEAI_SHARED_SECRET=change-me
FACEAI_SESSION_COOKIE=rus_faceai_session
```
If you want FaceAI to return through the new PHP bridge prepared under `www`, point `FACEAI_LEGACY_RETURN_URL` to that endpoint instead, for example `http://localhost/faceai_return.php` or the equivalent URL in your local PHP setup.
In the provided Docker Compose stack, that wiring is already done with:
```text
FACEAI_LEGACY_RETURN_URL=http://localhost:8080/faceai_return.php
```
## Notes
- The backend currently uses in-memory stores and mocked search results.
- No database or real queue is wired yet.
- The local legacy simulator is intentionally backend-driven so the handoff can be tested without compiling the existing Java application.
- `www/faceai_simulator.php` exists only for local testing. It does not replace the actual JSP race page.
- The final legacy integration still needs a real signed identity source and a real return-filter implementation on the old site.

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

34
faceai/docker-compose.yml Normal file
View file

@ -0,0 +1,34 @@
services:
faceai:
image: node:20-alpine
container_name: regalami-faceai
working_dir: /app
command: sh -c "npm run start --workspace @regalami/faceai-backend"
environment:
PORT: 3001
FACEAI_FRONTEND_URL: http://localhost:3001
FACEAI_PUBLIC_BASE_URL: http://localhost:3001
FACEAI_LEGACY_RETURN_URL: http://localhost:8080/faceai_return.php
FACEAI_ENABLE_LOCAL_LEGACY_STATIC: 1
FACEAI_LOCAL_LEGACY_STATIC_ROOT: /legacy-www
FACEAI_SHARED_SECRET: change-me
FACEAI_SESSION_COOKIE: rus_faceai_session
volumes:
- .:/app
- ../www:/legacy-www:ro
ports:
- "3001:3001"
legacy-php:
image: php:8.3-apache
container_name: regalami-legacy-php
environment:
FACEAI_BACKEND_INTERNAL_URL: http://faceai:3001
FACEAI_FRONTEND_URL: http://localhost:3001
FACEAI_SHARED_SECRET: change-me
FACEAI_ALLOW_DEV_HANDOFF: 1
FACEAI_IDENTITY_COOKIE: rus_faceai_identity
volumes:
- ../www:/var/www/html
ports:
- "8080:80"

24
faceai/docker/Dockerfile Normal file
View file

@ -0,0 +1,24 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json ./
COPY apps/frontend/package.json apps/frontend/package.json
COPY apps/backend/package.json apps/backend/package.json
RUN npm install
COPY . .
RUN npm run build
FROM node:20-alpine AS runtime
WORKDIR /app
COPY --from=build /app /app
ENV NODE_ENV=production
EXPOSE 3001
CMD ["npm", "run", "start"]

2535
faceai/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

18
faceai/package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "faceai",
"private": true,
"workspaces": [
"apps/frontend",
"apps/backend"
],
"scripts": {
"dev": "concurrently \"npm:dev:backend\" \"npm:dev:frontend\"",
"dev:backend": "npm run dev --workspace @regalami/faceai-backend",
"dev:frontend": "npm run dev --workspace @regalami/faceai-frontend",
"build": "npm run build --workspace @regalami/faceai-frontend && npm run build --workspace @regalami/faceai-backend",
"start": "npm run start --workspace @regalami/faceai-backend"
},
"devDependencies": {
"concurrently": "^9.1.2"
}
}

View file

@ -96,10 +96,89 @@ function searchGara() {
/* PAGINA RICERCA FOTOCR */
/***************************************************/
/***************************************************/
function getTipoPuntoFotoValue() {
var field = $("#tipoPuntoFoto");
if (!field.length) {
return "";
}
return field.val() || "";
}
function getCurrentLangValue() {
var field = $("#lang");
if (field.length && field.val()) {
return field.val();
}
return $("html").attr("lang") || "it";
}
function buildFaceAiLaunchUrl() {
var raceId = $("#id_gara").val() || "";
var raceSlug = $("#garaDesc").val() || "";
var raceName = $("h1.my-4").last().text().replace(/\s+/g, " ").trim();
var lang = getCurrentLangValue();
var handoffUrl = (window.faceAiSimulator && window.faceAiSimulator.handoffUrl) || "faceai_handoff.php";
var returnUrl = (window.faceAiSimulator && window.faceAiSimulator.returnUrl) || window.location.href;
var query = [
"raceId=" + encodeURIComponent(raceId),
"raceSlug=" + encodeURIComponent(raceSlug),
"raceName=" + encodeURIComponent(raceName),
"lang=" + encodeURIComponent(lang),
"returnUrl=" + encodeURIComponent(returnUrl)
];
if (window.faceAiSimulator && window.faceAiSimulator.devUserId) {
query.push("devUserId=" + encodeURIComponent(window.faceAiSimulator.devUserId));
}
if (window.faceAiSimulator && window.faceAiSimulator.devDisplayName) {
query.push("devDisplayName=" + encodeURIComponent(window.faceAiSimulator.devDisplayName));
}
if (window.faceAiSimulator && window.faceAiSimulator.devEmail) {
query.push("devEmail=" + encodeURIComponent(window.faceAiSimulator.devEmail));
}
if (window.faceAiSimulator && window.faceAiSimulator.devMembershipStatus) {
query.push("devMembershipStatus=" + encodeURIComponent(window.faceAiSimulator.devMembershipStatus));
}
return handoffUrl + "?" + query.join("&");
}
function launchFaceAi() {
$("body").addClass("loading");
window.location.href = buildFaceAiLaunchUrl();
return false;
}
function initFaceAiRaceSearchButton() {
var select = $("#tipoPuntoFoto");
if (!select.length || $("#faceaiLaunchButton").length) {
return;
}
var inputGroup = select.closest(".input-group");
var renderTarget = inputGroup.length ? inputGroup : select.parent();
var currentValue = select.val() || "";
if (!renderTarget.length) {
return;
}
select.off("change");
select.remove();
if (!$("#tipoPuntoFoto").length) {
renderTarget.append('<input type="hidden" name="tipoPuntoFoto" id="tipoPuntoFoto" value="' + currentValue.replace(/"/g, '&quot;') + '">');
}
renderTarget.append('<button type="button" id="faceaiLaunchButton" class="btn btn-warning btn-block text-uppercase" onclick="return launchFaceAi();"><i class="fa fa-camera-retro" aria-hidden="true"></i> Face ID</button>');
}
function searching() {
//gara%201_gara-1---2.html
$("body").addClass("loading");
theSvlt = $("#garaDesc").val() + "_gara-" + $("#id_gara").val() + "-" + $("#id_puntoFoto").val() + "-" + $("#tipoPuntoFoto").val() + "-" + $("#pageRow").val() + "-1-"+$("#pettorale").val()+"-"+$("#lang").val()+".html";
theSvlt = $("#garaDesc").val() + "_gara-" + $("#id_gara").val() + "-" + $("#id_puntoFoto").val() + "-" + getTipoPuntoFotoValue() + "-" + $("#pageRow").val() + "-1-"+$("#pettorale").val()+"-"+getCurrentLangValue()+".html";
//alert(theSvlt);
location.href = theSvlt;
@ -107,7 +186,7 @@ function searching() {
function searchingTPF() {
//gara%201_gara-1---2.html
theSvlt = $("#garaDesc").val() + "_gara-" + $("#id_gara").val() + "--" + $("#tipoPuntoFoto").val() + "-" + $("#pageRow").val() + "-1.html";
theSvlt = $("#garaDesc").val() + "_gara-" + $("#id_gara").val() + "--" + getTipoPuntoFotoValue() + "-" + $("#pageRow").val() + "-1.html";
//alert(theSvlt);
location.href = theSvlt;
@ -288,7 +367,7 @@ function goPage()
if(parseFloat(pnGo)<= parseFloat(pn))
{
theSvlt = $("#garaDesc").val() + "_gara-" + $("#id_gara").val() + "-" + $("#id_puntoFoto").val() + "-" + $("#tipoPuntoFoto").val() + "-" + $("#pageRow").val() + "-"+pnGo+".html";
theSvlt = $("#garaDesc").val() + "_gara-" + $("#id_gara").val() + "-" + $("#id_puntoFoto").val() + "-" + getTipoPuntoFotoValue() + "-" + $("#pageRow").val() + "-"+pnGo+".html";
//alert(theSvlt);
location.href = theSvlt;
}
@ -296,6 +375,10 @@ function goPage()
alert('Errore!!');
}
$(function() {
initFaceAiRaceSearchButton();
});

185
www/faceai_config.php Normal file
View file

@ -0,0 +1,185 @@
<?php
function faceai_env($key, $default = null)
{
$value = getenv($key);
return $value === false ? $default : $value;
}
function faceai_config()
{
static $config = null;
if ($config !== null) {
return $config;
}
$config = array(
'frontend_url' => rtrim(faceai_env('FACEAI_FRONTEND_URL', 'http://localhost:5173'), '/'),
'backend_internal_url' => rtrim(faceai_env('FACEAI_BACKEND_INTERNAL_URL', 'http://localhost:3001'), '/'),
'shared_secret' => (string) faceai_env('FACEAI_SHARED_SECRET', 'change-me'),
'allow_dev_handoff' => faceai_env('FACEAI_ALLOW_DEV_HANDOFF', '1') === '1',
'identity_cookie' => (string) faceai_env('FACEAI_IDENTITY_COOKIE', 'rus_faceai_identity'),
'return_forward_url' => rtrim((string) faceai_env('FACEAI_RETURN_FORWARD_URL', ''), '/')
);
return $config;
}
function faceai_base64url_encode($value)
{
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
}
function faceai_base64url_decode($value)
{
$padding = strlen($value) % 4;
if ($padding > 0) {
$value .= str_repeat('=', 4 - $padding);
}
return base64_decode(strtr($value, '-_', '+/'));
}
function faceai_sign_payload(array $payload, $secret)
{
$body = faceai_base64url_encode(json_encode($payload));
$signature = hash_hmac('sha256', $body, $secret, true);
return $body . '.' . faceai_base64url_encode($signature);
}
function faceai_verify_payload($token, $secret)
{
if (!is_string($token) || strpos($token, '.') === false) {
throw new RuntimeException('Invalid token format.');
}
list($body, $signature) = explode('.', $token, 2);
$expected = faceai_base64url_encode(hash_hmac('sha256', $body, $secret, true));
if (!hash_equals($expected, $signature)) {
throw new RuntimeException('Invalid token signature.');
}
$decoded = faceai_base64url_decode($body);
$payload = json_decode($decoded, true);
if (!is_array($payload)) {
throw new RuntimeException('Invalid token payload.');
}
if (isset($payload['expiresAt']) && (int) $payload['expiresAt'] < (int) round(microtime(true) * 1000)) {
throw new RuntimeException('Token expired.');
}
return $payload;
}
function faceai_build_url($baseUrl, array $params)
{
return $baseUrl . (strpos($baseUrl, '?') === false ? '?' : '&') . http_build_query($params);
}
function faceai_request_value($key, $default = '')
{
if (!isset($_GET[$key])) {
return $default;
}
if (is_array($_GET[$key])) {
return $default;
}
return trim((string) $_GET[$key]);
}
function faceai_html($value)
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
function faceai_resolve_identity(array $config)
{
if (!empty($_COOKIE[$config['identity_cookie']])) {
$payload = faceai_verify_payload($_COOKIE[$config['identity_cookie']], $config['shared_secret']);
if (($payload['type'] ?? '') !== 'legacy-identity') {
throw new RuntimeException('Unexpected identity cookie payload.');
}
return array(
'id' => (string) ($payload['userId'] ?? ''),
'displayName' => (string) ($payload['displayName'] ?? ''),
'email' => (string) ($payload['email'] ?? ''),
'membershipStatus' => (string) ($payload['membershipStatus'] ?? 'inactive')
);
}
if ($config['allow_dev_handoff']) {
$userId = faceai_request_value('devUserId');
if ($userId !== '') {
return array(
'id' => $userId,
'displayName' => faceai_request_value('devDisplayName', 'Local Test User'),
'email' => faceai_request_value('devEmail', 'local.test@example.invalid'),
'membershipStatus' => faceai_request_value('devMembershipStatus', 'active')
);
}
}
return null;
}
function faceai_render_message_page($title, $message, array $details = array(), $statusCode = 400)
{
http_response_code($statusCode);
header('Content-Type: text/html; charset=UTF-8');
echo '<!doctype html><html lang="it"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">';
echo '<title>' . faceai_html($title) . '</title>';
echo '<style>body{font-family:Georgia,serif;background:#f7f1e8;color:#2a231b;margin:0;padding:32px}main{max-width:900px;margin:0 auto;background:#fff;border:1px solid #ddcbb5;padding:24px}h1{margin-top:0}code{background:#f2ece2;padding:2px 5px}ul{padding-left:20px}li{margin:8px 0}</style>';
echo '</head><body><main>';
echo '<h1>' . faceai_html($title) . '</h1>';
echo '<p>' . faceai_html($message) . '</p>';
if (!empty($details)) {
echo '<ul>';
foreach ($details as $detail) {
echo '<li>' . faceai_html($detail) . '</li>';
}
echo '</ul>';
}
echo '</main></body></html>';
exit;
}
function faceai_fetch_json($url)
{
$context = stream_context_create(array(
'http' => array(
'ignore_errors' => true,
'timeout' => 10
)
));
$response = @file_get_contents($url, false, $context);
if ($response === false) {
throw new RuntimeException('Unable to fetch remote FaceAI data.');
}
$statusCode = 0;
if (!empty($http_response_header[0]) && preg_match('/\s(\d{3})\s/', $http_response_header[0], $matches)) {
$statusCode = (int) $matches[1];
}
$payload = json_decode($response, true);
if (!is_array($payload)) {
throw new RuntimeException('FaceAI returned invalid JSON.');
}
if ($statusCode >= 400) {
throw new RuntimeException($payload['error'] ?? ('FaceAI bridge request failed with status ' . $statusCode . '.'));
}
return $payload;
}

76
www/faceai_handoff.php Normal file
View file

@ -0,0 +1,76 @@
<?php
require_once __DIR__ . '/faceai_config.php';
$config = faceai_config();
try {
$raceId = faceai_request_value('raceId');
$raceSlug = faceai_request_value('raceSlug');
$raceName = faceai_request_value('raceName', $raceSlug !== '' ? $raceSlug : $raceId);
$lang = faceai_request_value('lang', 'it');
$returnUrl = faceai_request_value('returnUrl');
if ($raceId === '' || $returnUrl === '') {
faceai_render_message_page(
'FaceAI handoff non disponibile',
'Mancano i parametri minimi richiesti per lanciare FaceAI.',
array(
'Parametri richiesti: raceId, returnUrl.',
'Il pulsante Face ID deve passare anche raceSlug e lang quando disponibili.'
),
400
);
}
$identity = faceai_resolve_identity($config);
if ($identity === null) {
faceai_render_message_page(
'FaceAI handoff in attesa del bridge legacy',
'Questo endpoint PHP non puo leggere la sessione Java esistente. Per funzionare in produzione deve ricevere una identita firmata dal layer legacy o dal reverse proxy.',
array(
'Opzione consigliata: cookie firmato ' . $config['identity_cookie'] . ' con payload type=legacy-identity.',
'Per test locale e possibile passare devUserId, devDisplayName, devEmail e devMembershipStatus se FACEAI_ALLOW_DEV_HANDOFF=1.',
'Esempio locale: faceai_handoff.php?raceId=101&raceSlug=mezza-di-firenze&lang=it&returnUrl=http%3A%2F%2Flocalhost%2Fold&devUserId=1&devDisplayName=Mario%20Rossi&devEmail=mario%40example.test&devMembershipStatus=active'
),
501
);
}
if (($identity['membershipStatus'] ?? 'inactive') !== 'active') {
faceai_render_message_page(
'FaceAI non disponibile',
'L utente corrente non risulta abilitato all uso di FaceAI in base allo stato di membership.',
array('Stato attuale: ' . ($identity['membershipStatus'] ?? 'unknown')),
403
);
}
$payload = array(
'type' => 'handoff',
'user' => array(
'id' => $identity['id'],
'displayName' => $identity['displayName'],
'email' => $identity['email'],
'membershipStatus' => $identity['membershipStatus']
),
'race' => array(
'id' => $raceId,
'slug' => $raceSlug !== '' ? $raceSlug : $raceId,
'name' => $raceName !== '' ? $raceName : $raceId
),
'lang' => $lang,
'returnUrl' => $returnUrl,
'expiresAt' => ((int) round(microtime(true) * 1000)) + (5 * 60 * 1000)
);
$token = faceai_sign_payload($payload, $config['shared_secret']);
$targetUrl = faceai_build_url($config['frontend_url'] . '/auth/callback', array('token' => $token));
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
header('Location: ' . $targetUrl, true, 302);
exit;
} catch (Throwable $error) {
faceai_render_message_page('Errore handoff FaceAI', $error->getMessage(), array(), 500);
}

56
www/faceai_return.php Normal file
View file

@ -0,0 +1,56 @@
<?php
require_once __DIR__ . '/faceai_config.php';
require_once __DIR__ . '/faceai_simulator_view.php';
$config = faceai_config();
try {
$resultId = faceai_request_value('resultId');
$token = faceai_request_value('token');
if ($resultId === '' || $token === '') {
faceai_render_message_page(
'FaceAI return non disponibile',
'Mancano resultId o token nella chiamata di ritorno da FaceAI.',
array(),
400
);
}
$payload = faceai_verify_payload($token, $config['shared_secret']);
if (($payload['type'] ?? '') !== 'return') {
throw new RuntimeException('Wrong return token type.');
}
if ((string) ($payload['resultId'] ?? '') !== $resultId) {
throw new RuntimeException('Result id mismatch.');
}
if ($config['return_forward_url'] !== '') {
header('Location: ' . faceai_build_url($config['return_forward_url'], array(
'resultId' => $resultId,
'token' => $token
)), true, 302);
exit;
}
$bridgeUrl = faceai_build_url($config['backend_internal_url'] . '/bridge/results/' . rawurlencode($resultId), array(
'token' => $token
));
$result = faceai_fetch_json($bridgeUrl);
faceai_sim_render_page(array(
'raceId' => (string) ($result['raceId'] ?? ($payload['raceId'] ?? '')),
'lang' => (string) ($result['lang'] ?? 'it'),
'raceSlug' => (string) ($result['raceId'] ?? ($payload['raceId'] ?? '')),
'raceName' => (string) ($result['raceName'] ?? ('Race ' . ($payload['raceId'] ?? ''))),
'returnUrl' => (string) ($result['returnUrl'] ?? 'faceai_simulator.php'),
'banner' => 'Vista filtrata da FaceAI. Sono state trovate <strong>' . count($result['matches'] ?? array()) . '</strong> foto corrispondenti per l utente corrente.',
'totalLabel' => count($result['matches'] ?? array()) . ' foto da FaceAI',
'photos' => is_array($result['matches'] ?? null) ? $result['matches'] : array(),
'showSimulatorBootstrap' => false
));
} catch (Throwable $error) {
faceai_render_message_page('Errore return FaceAI', $error->getMessage(), array(), 500);
}

35
www/faceai_simulator.php Normal file
View file

@ -0,0 +1,35 @@
<?php
require_once __DIR__ . '/faceai_simulator_view.php';
$raceId = isset($_GET['raceId']) ? trim((string) $_GET['raceId']) : '101';
$lang = isset($_GET['lang']) ? trim((string) $_GET['lang']) : 'it';
$raceSlug = isset($_GET['raceSlug']) ? trim((string) $_GET['raceSlug']) : 'mezza-di-firenze';
$raceName = isset($_GET['raceName']) ? trim((string) $_GET['raceName']) : 'Mezza di Firenze';
$returnUrl = 'http://localhost:8080/faceai_simulator.php?raceId=' . rawurlencode($raceId) . '&lang=' . rawurlencode($lang) . '&raceSlug=' . rawurlencode($raceSlug) . '&raceName=' . rawurlencode($raceName);
$photos = array(
array('id' => 'f101-001', 'thumb' => 'thumb-arrivo-001.jpg', 'label' => 'Arrivo 001', 'checkpoint' => 'Arrivo'),
array('id' => 'f101-002', 'thumb' => 'thumb-arrivo-002.jpg', 'label' => 'Arrivo 002', 'checkpoint' => 'Arrivo'),
array('id' => 'f101-003', 'thumb' => 'thumb-ponte-003.jpg', 'label' => 'Ponte 003', 'checkpoint' => 'Ponte'),
array('id' => 'f101-004', 'thumb' => 'thumb-centro-004.jpg', 'label' => 'Centro 004', 'checkpoint' => 'Centro'),
array('id' => 'f101-005', 'thumb' => 'thumb-centro-005.jpg', 'label' => 'Centro 005', 'checkpoint' => 'Centro'),
array('id' => 'f101-006', 'thumb' => 'thumb-arrivo-006.jpg', 'label' => 'Arrivo 006', 'checkpoint' => 'Arrivo'),
array('id' => 'f101-007', 'thumb' => 'thumb-ponte-007.jpg', 'label' => 'Ponte 007', 'checkpoint' => 'Ponte'),
array('id' => 'f101-008', 'thumb' => 'thumb-centro-008.jpg', 'label' => 'Centro 008', 'checkpoint' => 'Centro'),
array('id' => 'f101-009', 'thumb' => 'thumb-arrivo-009.jpg', 'label' => 'Arrivo 009', 'checkpoint' => 'Arrivo'),
array('id' => 'f101-010', 'thumb' => 'thumb-lungarno-010.jpg', 'label' => 'Lungarno 010', 'checkpoint' => 'Lungarno'),
array('id' => 'f101-011', 'thumb' => 'thumb-piazza-011.jpg', 'label' => 'Piazza 011', 'checkpoint' => 'Piazza'),
array('id' => 'f101-012', 'thumb' => 'thumb-arrivo-012.jpg', 'label' => 'Arrivo 012', 'checkpoint' => 'Arrivo')
);
faceai_sim_render_page(array(
'raceId' => $raceId,
'lang' => $lang,
'raceSlug' => $raceSlug,
'raceName' => $raceName,
'returnUrl' => $returnUrl,
'banner' => 'Questa pagina PHP simula il punto di ingresso del sito legacy. Il vecchio select con ID <strong>tipoPuntoFoto</strong> viene rimosso dal JavaScript originale e sostituito dal pulsante Face ID.',
'totalLabel' => count($photos) . ' foto demo',
'photos' => $photos,
'showSimulatorBootstrap' => true
));

View file

@ -0,0 +1,184 @@
<?php
function faceai_sim_html($value)
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
function faceai_sim_render_page(array $options)
{
$raceId = $options['raceId'];
$lang = $options['lang'];
$raceSlug = $options['raceSlug'];
$raceName = $options['raceName'];
$returnUrl = $options['returnUrl'];
$banner = $options['banner'];
$totalLabel = $options['totalLabel'];
$photos = $options['photos'];
$showSimulatorBootstrap = !empty($options['showSimulatorBootstrap']);
?><!doctype html>
<html lang="<?php echo faceai_sim_html($lang); ?>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>FaceAI Legacy Simulator</title>
<link href="vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="css/font-awesome.min.css">
<link href="https://fonts.googleapis.com/css?family=Roboto:300,300i,400,400i,700,700i" rel="stylesheet">
<link href="css/custom-style.css" rel="stylesheet">
<style>
.page-shell {
max-width: 1120px;
margin: 32px auto;
padding: 0 16px;
}
.sim-banner {
background: #efe4d2;
border: 1px solid #c9ab83;
padding: 14px 16px;
margin-bottom: 24px;
}
.gallery-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.gallery-card {
background: #fff;
border: 1px solid #decbb5;
padding: 16px;
}
.gallery-thumb {
min-height: 120px;
background: #efe4d2;
display: grid;
place-items: center;
margin-bottom: 12px;
color: #6d5a46;
overflow: hidden;
}
.gallery-thumb img {
max-width: 100%;
max-height: 120px;
}
</style>
</head>
<body>
<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="faceai_simulator.php?raceId=<?php echo faceai_sim_html($raceId); ?>&lang=<?php echo faceai_sim_html($lang); ?>"><img src="images/layout/regalami-un-sorriso-ets-640.png" 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="index.jsp">Home</a></li>
<li class="nav-item"><a class="nav-link" href="associazione.jsp">Associazione</a></li>
<li class="nav-item"><a class="nav-link active" href="faceai_simulator.php?raceId=<?php echo faceai_sim_html($raceId); ?>&lang=<?php echo faceai_sim_html($lang); ?>">Foto</a></li>
<li class="nav-item dropdown show"><a class="nav-link btn btn-sm btn-warning dropdown-toggle" href="#">Archivio</a></li>
<li class="nav-item"><a href="#"><img src="images/btn_donateCC_LG.gif" border="0" alt="PayPal"></a></li>
</ul>
<ul class="navbar-nav ml-auto">
<li class="nav-item dropdown"><a class="nav-link dropdown-toggle 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="images/FB-f-Logo__blue_29.png" class="img-fluid" alt="Facebook"></a></li>
</ul>
</div>
</div>
</nav>
<div class="container my-3 page-shell">
<div class="row mb-5">
<div class="col-lg-12">
<h1 class="my-4"><?php echo faceai_sim_html($raceName); ?></h1>
</div>
<div class="col-md-2">
<img src="images/layout/Logo_RUS_ETS_tricolore_3-1.jpg" class="img-fluid border border-warning" alt="Gara">
</div>
<div class="col-md-10">
<div class="row riepilogo">
<div class="col-md-3"><p><i class="fa fa-map-marker fa-lg text-warning" aria-hidden="true"></i> Firenze</p></div>
<div class="col-md-3"><p><i class="fa fa-calendar fa-lg text-warning" aria-hidden="true"></i> 07/04/2026</p></div>
<div class="col"><p><i class="fa fa-camera-retro fa-lg text-warning"></i> <?php echo faceai_sim_html($totalLabel); ?></p></div>
</div>
<div class="sim-banner"><?php echo $banner; ?></div>
<form class="bg-light p-3 border" onsubmit="return searching()">
<input name="id_gara" id="id_gara" type="hidden" value="<?php echo faceai_sim_html($raceId); ?>">
<input name="id_foto" id="id_foto" type="hidden">
<input name="garaDesc" id="garaDesc" type="hidden" value="<?php echo faceai_sim_html($raceSlug); ?>">
<input name="lang" id="lang" type="hidden" value="<?php echo faceai_sim_html($lang); ?>">
<input name="pageNumber" id="pageNumber" type="hidden" value="1">
<input name="actionPage" id="actionPage" type="hidden" value="Foto.abl">
<input name="totPageNumber" id="totPageNumber" type="hidden" value="5">
<div class="row align-items-end">
<div class="form-group col-12 col-md-4">
<label for="id_puntoFoto">Punti Foto</label>
<select name="id_puntoFoto" id="id_puntoFoto" onchange="searchingPF()" class="custom-select form-control form-control-sm mb-2 mb-sm-0">
<option value="">-- Punti Foto --</option>
<option value="arrivo">Arrivo</option>
<option value="centro">Centro</option>
<option value="ponte">Ponte</option>
</select>
</div>
<div class="form-group col-12 col-md-4">
<label for="tipoPuntoFoto">Descrizione/Orario</label>
<select name="tipoPuntoFoto" id="tipoPuntoFoto" onchange="searchingTPF()" class="custom-select form-control form-control-sm mb-2 mb-sm-0">
<option value="">-- Descrizione/Orario --</option>
<option value="arrivo-09-15">Arrivo 09:15</option>
<option value="centro-08-45">Centro 08:45</option>
<option value="ponte-08-10">Ponte 08:10</option>
</select>
</div>
<div class="form-group col-12 col-md-2">
<label for="pageRow">Foto per pagina</label>
<select name="pageRow" id="pageRow" class="custom-select form-control form-control-sm mb-2 mb-sm-0">
<option value="24">24</option>
<option value="36">36</option>
<option value="48">48</option>
</select>
</div>
<div class="form-group col-12 col-md-2">
<label for="pettorale">Pettorale</label>
<input name="pettorale" id="pettorale" value="245" class="form-control form-control-sm mb-2 mb-sm-0">
</div>
</div>
</form>
</div>
</div>
<div class="gallery-grid">
<?php foreach ($photos as $photo): ?>
<div class="gallery-card">
<div class="gallery-thumb">
<?php if (!empty($photo['previewUrl'])): ?>
<img src="<?php echo faceai_sim_html($photo['previewUrl']); ?>" alt="<?php echo faceai_sim_html($photo['label']); ?>">
<?php else: ?>
<?php echo faceai_sim_html($photo['thumb'] ?? $photo['id']); ?>
<?php endif; ?>
</div>
<strong><?php echo faceai_sim_html($photo['label'] ?? $photo['id']); ?></strong><br>
<small>ID foto: <?php echo faceai_sim_html($photo['id'] ?? ''); ?></small><br>
<small>Punto foto: <?php echo faceai_sim_html($photo['checkpoint'] ?? '-'); ?></small>
</div>
<?php endforeach; ?>
</div>
</div>
<?php if ($showSimulatorBootstrap): ?>
<script>
window.faceAiSimulator = {
handoffUrl: 'faceai_handoff.php',
returnUrl: <?php echo json_encode($returnUrl); ?>,
devUserId: '1',
devDisplayName: 'Mario Rossi',
devEmail: 'mario.rossi@example.test',
devMembershipStatus: 'active'
};
</script>
<?php endif; ?>
<script src="vendor/jquery/jquery.min.js"></script>
<script src="vendor/bootstrap/js/bootstrap.min.js"></script>
<script src="_js/rus-ecom-240621.js"></script>
</body>
</html>
<?php
}