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:
parent
cc69770608
commit
97e53353c4
31 changed files with 4511 additions and 60 deletions
|
|
@ -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
5
faceai/.dockerignore
Normal 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
8
faceai/.env.example
Normal 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
3
faceai/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
apps/frontend/dist/
|
||||
.env
|
||||
111
faceai/README.md
Normal file
111
faceai/README.md
Normal 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.
|
||||
15
faceai/apps/backend/package.json
Normal file
15
faceai/apps/backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
40
faceai/apps/backend/src/auth.js
Normal file
40
faceai/apps/backend/src/auth.js
Normal 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')}`;
|
||||
}
|
||||
18
faceai/apps/backend/src/config.js
Normal file
18
faceai/apps/backend/src/config.js
Normal 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'
|
||||
};
|
||||
356
faceai/apps/backend/src/server.js
Normal file
356
faceai/apps/backend/src/server.js
Normal 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('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function renderLegacyRacePage({ raceId, lang = 'it', result = null }) {
|
||||
const race = mockCatalog[raceId] || { id: raceId, name: `Race ${raceId}`, slug: `race-${raceId}`, photos: [] };
|
||||
const returnUrl = `${config.publicBaseUrl}/dev/legacy/race?raceId=${encodeURIComponent(race.id)}&lang=${encodeURIComponent(lang)}`;
|
||||
const photos = result ? result.matches : race.photos;
|
||||
const banner = result
|
||||
? `<div class="legacy-banner">Vista filtrata da FaceAI. Trovate ${photos.length} foto per l'utente corrente.</div>`
|
||||
: '<div class="legacy-banner legacy-banner-neutral">Pagina gara simulata per il test locale del handoff FaceAI.</div>';
|
||||
|
||||
const photoList = photos.length
|
||||
? photos.map((photo) => `
|
||||
<li class="legacy-card">
|
||||
<div class="legacy-thumb">${escapeHtml(photo.thumb || photo.id)}</div>
|
||||
<div class="legacy-meta">
|
||||
<strong>${escapeHtml(photo.label)}</strong>
|
||||
<span>ID foto: ${escapeHtml(photo.id)}</span>
|
||||
<span>Punto foto: ${escapeHtml(photo.checkpoint || '-')}</span>
|
||||
</div>
|
||||
</li>
|
||||
`).join('')
|
||||
: '<li class="legacy-empty">Nessuna foto disponibile.</li>';
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="${escapeHtml(lang)}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Legacy Race Simulator</title>
|
||||
<style>
|
||||
body { font-family: Georgia, serif; margin: 0; background: #f7f1e8; color: #2c241b; }
|
||||
.topbar { background: #fff; border-bottom: 1px solid #d9c7aa; padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.brand { font-size: 22px; font-weight: 700; }
|
||||
.page { max-width: 1120px; margin: 0 auto; padding: 24px; }
|
||||
.toolbar { background: #fff; border: 1px solid #dccab2; padding: 16px; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||
.toolbar label { font-size: 14px; color: #6a5845; }
|
||||
.toolbar select { padding: 10px 12px; min-width: 220px; }
|
||||
.toolbar button { background: #8d1f1f; color: #fff; border: 0; padding: 11px 18px; font-weight: 700; cursor: pointer; }
|
||||
.legacy-banner { margin: 20px 0; background: #f6d9b6; border: 1px solid #c69257; padding: 14px 16px; }
|
||||
.legacy-banner-neutral { background: #e9efe8; border-color: #8fa18b; }
|
||||
.summary { margin: 12px 0 24px; }
|
||||
.gallery { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; }
|
||||
.legacy-card { background: #fff; border: 1px solid #dccab2; padding: 16px; display: grid; gap: 12px; }
|
||||
.legacy-thumb { background: #efe2d1; border: 1px dashed #b79a77; min-height: 120px; display: grid; place-items: center; color: #6f5b47; font-size: 13px; }
|
||||
.legacy-meta { display: grid; gap: 4px; font-size: 14px; }
|
||||
.legacy-empty { background: #fff; border: 1px solid #dccab2; padding: 24px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">Regalami un Sorriso ETS</div>
|
||||
<div>Utente simulato: Mario Rossi</div>
|
||||
</header>
|
||||
<main class="page">
|
||||
<h1>${escapeHtml(race.name)}</h1>
|
||||
<div class="toolbar">
|
||||
<label>Punti Foto</label>
|
||||
<select>
|
||||
<option>-- Punti Foto --</option>
|
||||
<option>Arrivo</option>
|
||||
<option>Centro</option>
|
||||
<option>Ponte</option>
|
||||
</select>
|
||||
<button id="faceai-launch">Face ID</button>
|
||||
</div>
|
||||
${banner}
|
||||
<p class="summary">${result ? 'La pagina mostra solo le foto restituite da FaceAI.' : 'In questa simulazione il vecchio select tipoPuntoFoto è sostituito dal pulsante Face ID.'}</p>
|
||||
<ul class="gallery">${photoList}</ul>
|
||||
</main>
|
||||
<script>
|
||||
const launchButton = document.getElementById('faceai-launch');
|
||||
launchButton.addEventListener('click', () => {
|
||||
const returnUrl = ${JSON.stringify(returnUrl)};
|
||||
const launchUrl = new URL('/dev/legacy/launch', ${JSON.stringify(config.publicBaseUrl)});
|
||||
launchUrl.searchParams.set('raceId', ${JSON.stringify(race.id)});
|
||||
launchUrl.searchParams.set('raceSlug', ${JSON.stringify(race.slug)});
|
||||
launchUrl.searchParams.set('lang', ${JSON.stringify(lang)});
|
||||
launchUrl.searchParams.set('returnUrl', returnUrl);
|
||||
window.location.href = launchUrl.toString();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/dev/legacy/race', (req, res) => {
|
||||
const raceId = String(req.query.raceId || '101');
|
||||
const lang = String(req.query.lang || 'it');
|
||||
res.type('html').send(renderLegacyRacePage({ raceId, lang }));
|
||||
});
|
||||
|
||||
app.get('/dev/legacy/launch', (req, res) => {
|
||||
const raceId = String(req.query.raceId || '101');
|
||||
const raceSlug = String(req.query.raceSlug || mockCatalog[raceId]?.slug || `race-${raceId}`);
|
||||
const 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}`);
|
||||
});
|
||||
110
faceai/apps/backend/src/store.js
Normal file
110
faceai/apps/backend/src/store.js
Normal 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;
|
||||
}
|
||||
12
faceai/apps/frontend/index.html
Normal file
12
faceai/apps/frontend/index.html
Normal 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>
|
||||
18
faceai/apps/frontend/package.json
Normal file
18
faceai/apps/frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
3
faceai/apps/frontend/src/App.vue
Normal file
3
faceai/apps/frontend/src/App.vue
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
57
faceai/apps/frontend/src/components/LegacyHeader.vue
Normal file
57
faceai/apps/frontend/src/components/LegacyHeader.vue
Normal 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>
|
||||
26
faceai/apps/frontend/src/legacyAssets.js
Normal file
26
faceai/apps/frontend/src/legacyAssets.js
Normal 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);
|
||||
});
|
||||
}
|
||||
8
faceai/apps/frontend/src/main.js
Normal file
8
faceai/apps/frontend/src/main.js
Normal 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');
|
||||
17
faceai/apps/frontend/src/router.js
Normal file
17
faceai/apps/frontend/src/router.js
Normal 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
|
||||
}
|
||||
]
|
||||
});
|
||||
65
faceai/apps/frontend/src/styles.css
Normal file
65
faceai/apps/frontend/src/styles.css
Normal 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;
|
||||
}
|
||||
}
|
||||
51
faceai/apps/frontend/src/views/HandoffCallbackView.vue
Normal file
51
faceai/apps/frontend/src/views/HandoffCallbackView.vue
Normal 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>
|
||||
235
faceai/apps/frontend/src/views/HomeView.vue
Normal file
235
faceai/apps/frontend/src/views/HomeView.vue
Normal 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>
|
||||
13
faceai/apps/frontend/vite.config.js
Normal file
13
faceai/apps/frontend/vite.config.js
Normal 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
34
faceai/docker-compose.yml
Normal 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
24
faceai/docker/Dockerfile
Normal 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
2535
faceai/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
18
faceai/package.json
Normal file
18
faceai/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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, '"') + '">');
|
||||
}
|
||||
|
||||
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
185
www/faceai_config.php
Normal 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
76
www/faceai_handoff.php
Normal 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
56
www/faceai_return.php
Normal 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
35
www/faceai_simulator.php
Normal 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
|
||||
));
|
||||
184
www/faceai_simulator_view.php
Normal file
184
www/faceai_simulator_view.php
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue