From bbb9c193ceffff540c7a357fa7d9199fb60124ab Mon Sep 17 00:00:00 2001 From: MaddoScientisto Date: Sat, 11 Apr 2026 17:53:22 +0200 Subject: [PATCH 1/2] feat: add processor service with Redis-backed job queue - Introduced a new `processor` service in the Docker Compose setup to handle face matching jobs. - Configured Redis as a job queue and state management system for processing searches. - Updated the backend to enqueue jobs and manage user locks using Redis. - Added environment variables for Redis configuration and runtime paths. - Created technical design documentation for the processor service outlining architecture, queue model, and search lifecycle. - Updated package.json and package-lock.json to include dependencies for BullMQ and ioredis in the processor workspace. - Added sample PKL files for local testing in the `test_pkl` directory. --- faceai/README.md | 127 ++++++ faceai/apps/backend/package.json | 5 +- faceai/apps/backend/src/config.js | 10 +- faceai/apps/backend/src/matcher-results.js | 9 + faceai/apps/backend/src/queue.js | 11 + faceai/apps/backend/src/redis-store.js | 138 +++++++ faceai/apps/backend/src/server.js | 192 +++++++-- faceai/apps/backend/src/store.js | 61 --- faceai/apps/frontend/src/views/HomeView.vue | 22 +- faceai/apps/processor/package.json | 14 + faceai/apps/processor/src/config.js | 12 + faceai/apps/processor/src/worker-utils.js | 99 +++++ faceai/apps/processor/src/worker.js | 82 ++++ faceai/docker-compose.yml | 35 ++ faceai/docs/processor-technical-design.md | 166 ++++++++ faceai/package-lock.json | 431 +++++++++++++++++++- faceai/package.json | 7 +- test_pkl/face_encodings_20260330_170155.pkl | Bin 0 -> 7674 bytes test_pkl/face_encodings_20260330_170210.pkl | Bin 0 -> 52607 bytes test_pkl/face_encodings_20260330_170340.pkl | Bin 0 -> 55843 bytes 20 files changed, 1313 insertions(+), 108 deletions(-) create mode 100644 faceai/apps/backend/src/matcher-results.js create mode 100644 faceai/apps/backend/src/queue.js create mode 100644 faceai/apps/backend/src/redis-store.js create mode 100644 faceai/apps/processor/package.json create mode 100644 faceai/apps/processor/src/config.js create mode 100644 faceai/apps/processor/src/worker-utils.js create mode 100644 faceai/apps/processor/src/worker.js create mode 100644 faceai/docs/processor-technical-design.md create mode 100644 test_pkl/face_encodings_20260330_170155.pkl create mode 100644 test_pkl/face_encodings_20260330_170210.pkl create mode 100644 test_pkl/face_encodings_20260330_170340.pkl diff --git a/faceai/README.md b/faceai/README.md index d9a2762a..2dfa1cb7 100644 --- a/faceai/README.md +++ b/faceai/README.md @@ -81,6 +81,133 @@ If you change frontend code and want Docker to serve the updated UI, rebuild fir npm run build ``` +## Production Deployment From Registry + +The published container is the user-facing FaceAI site only. It already contains: + +- the Node/Express backend +- the built Vue frontend assets served by that backend + +It does not include: + +- the legacy PHP simulator +- the existing `www` site +- the future queue/processor worker + +In production, deploy a single FaceAI container behind HTTPS on its own host name, for example `faceai.regalamiunsorriso.it`, and keep the legacy site on its existing stack. + +### What The Production Container Exposes + +- HTTP service on port `3001` inside the container +- health endpoint at `/health` +- frontend and API from the same process + +The image should be run with a reverse proxy or ingress that terminates TLS and forwards traffic to the container. + +### Required Runtime Configuration + +Set these environment variables for production: + +| Variable | Required | Example | Purpose | +| --- | --- | --- | --- | +| `NODE_ENV` | yes | `production` | disables development defaults | +| `PORT` | optional | `3001` | internal listen port | +| `FACEAI_FRONTEND_URL` | yes | `https://faceai.regalamiunsorriso.it` | URL used when the legacy bridge redirects into the app | +| `FACEAI_PUBLIC_BASE_URL` | yes | `https://faceai.regalamiunsorriso.it` | public base URL used for local links and return flow generation | +| `FACEAI_LEGACY_RETURN_URL` | yes | `https://www.regalamiunsorriso.it/faceai_return.php` | legacy endpoint that receives the signed FaceAI result handoff | +| `FACEAI_SHARED_SECRET` | yes | long random secret | shared signing secret between FaceAI and the legacy handoff/return bridge | +| `FACEAI_SESSION_COOKIE` | optional | `rus_faceai_session` | cookie name for the FaceAI session | +| `FACEAI_ENABLE_LOCAL_LEGACY_STATIC` | recommended | `0` | disables development-only static serving of local legacy assets | + +Do not enable `FACEAI_ENABLE_LOCAL_LEGACY_STATIC` in production. That mode exists only for local simulator flows. + +### Legacy-Side Configuration That Must Match + +The container will not work correctly in production unless the legacy bridge is configured consistently. + +The legacy site must: + +- redirect users into `FACEAI_FRONTEND_URL` with a valid signed handoff token +- use the same `FACEAI_SHARED_SECRET` as the FaceAI container +- expose the configured `FACEAI_LEGACY_RETURN_URL` +- validate the signed return token and fetch the result payload from FaceAI + +The shared secret is the trust boundary between the legacy site and FaceAI. Treat it like any other production secret and inject it through the platform secret store, not through source control. + +### Example Docker Compose For Production + +Replace the registry path and secret values with the real ones from Forgejo. + +```yaml +services: + faceai: + image: registry.example.com/my-namespace/faceai:latest + container_name: regalami-faceai + restart: unless-stopped + environment: + NODE_ENV: production + PORT: 3001 + FACEAI_FRONTEND_URL: https://faceai.regalamiunsorriso.it + FACEAI_PUBLIC_BASE_URL: https://faceai.regalamiunsorriso.it + FACEAI_LEGACY_RETURN_URL: https://www.regalamiunsorriso.it/faceai_return.php + FACEAI_SHARED_SECRET: change-this-to-a-long-random-secret + FACEAI_SESSION_COOKIE: rus_faceai_session + FACEAI_ENABLE_LOCAL_LEGACY_STATIC: 0 + ports: + - "127.0.0.1:3001:3001" +``` + +This pattern assumes a reverse proxy on the host publishes `https://faceai.regalamiunsorriso.it` and forwards to `127.0.0.1:3001`. + +### Example Docker Run + +```bash +docker run -d \ + --name regalami-faceai \ + --restart unless-stopped \ + -p 127.0.0.1:3001:3001 \ + -e NODE_ENV=production \ + -e PORT=3001 \ + -e FACEAI_FRONTEND_URL=https://faceai.regalamiunsorriso.it \ + -e FACEAI_PUBLIC_BASE_URL=https://faceai.regalamiunsorriso.it \ + -e FACEAI_LEGACY_RETURN_URL=https://www.regalamiunsorriso.it/faceai_return.php \ + -e FACEAI_SHARED_SECRET=change-this-to-a-long-random-secret \ + -e FACEAI_SESSION_COOKIE=rus_faceai_session \ + -e FACEAI_ENABLE_LOCAL_LEGACY_STATIC=0 \ + registry.example.com/my-namespace/faceai:latest +``` + +### Reverse Proxy Expectations + +The app should sit behind HTTPS. In practice that means: + +- publish only the public FaceAI host name externally +- forward the original host and scheme headers from the proxy +- keep the container bound to localhost or a private network if possible +- allow normal browser redirects between the legacy site and the FaceAI host + +### Post-Deploy Validation + +After the container is up, validate at least the following: + +1. `GET /health` returns `{"ok":true}` through the public FaceAI host. +2. The legacy handoff endpoint redirects to `https://faceai.../auth/callback?token=...`. +3. FaceAI can exchange the token and establish a session. +4. Completing a search produces a redirect URL that points to `FACEAI_LEGACY_RETURN_URL`. +5. The legacy return endpoint can resolve the signed result and render the filtered race page. + +### Current Production Limitations + +This image can be published and deployed, but the current scaffold still has important limitations: + +- sessions and search results are stored only in memory, so container restarts lose state +- there is no real queue or processor yet +- there is no persistent storage layer yet +- the backend currently sets the FaceAI session cookie with `secure: false`, which should be hardened before final public rollout +- the local simulator endpoints under `/dev/*` are still present in the app and should be treated as non-production scaffolding + +So the registry deployment is appropriate for early hosted integration and controlled production-like rollout, but not yet for the final hardened architecture described in the integration plan + ## Environment Defaults are already set for local development, but these can be overridden: diff --git a/faceai/apps/backend/package.json b/faceai/apps/backend/package.json index a516e935..1ee57caa 100644 --- a/faceai/apps/backend/package.json +++ b/faceai/apps/backend/package.json @@ -8,8 +8,11 @@ "start": "node src/server.js" }, "dependencies": { + "bullmq": "^5.48.1", "cookie-parser": "^1.4.7", "cors": "^2.8.5", - "express": "^4.21.2" + "express": "^4.21.2", + "ioredis": "^5.4.1", + "multer": "^2.0.0" } } diff --git a/faceai/apps/backend/src/config.js b/faceai/apps/backend/src/config.js index 8dcb1d23..7cc03656 100644 --- a/faceai/apps/backend/src/config.js +++ b/faceai/apps/backend/src/config.js @@ -14,5 +14,13 @@ export const config = { : 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' + sessionCookieName: process.env.FACEAI_SESSION_COOKIE || 'rus_faceai_session', + redisUrl: process.env.FACEAI_REDIS_URL || 'redis://redis:6379', + queueName: process.env.FACEAI_QUEUE_NAME || 'faceai-searches', + runtimeRoot: process.env.FACEAI_RUNTIME_ROOT || '/data/runtime', + uploadRoot: process.env.FACEAI_UPLOAD_ROOT || path.join(process.env.FACEAI_RUNTIME_ROOT || '/data/runtime', 'uploads'), + searchTtlSeconds: Number(process.env.FACEAI_SEARCH_TTL_SECONDS || 24 * 60 * 60), + resultTtlSeconds: Number(process.env.FACEAI_RESULT_TTL_SECONDS || 24 * 60 * 60), + rateLimitWindowSeconds: Number(process.env.FACEAI_RATE_LIMIT_WINDOW_SECONDS || 10 * 60), + rateLimitMaxRequests: Number(process.env.FACEAI_RATE_LIMIT_MAX_REQUESTS || 5) }; diff --git a/faceai/apps/backend/src/matcher-results.js b/faceai/apps/backend/src/matcher-results.js new file mode 100644 index 00000000..ff74a2be --- /dev/null +++ b/faceai/apps/backend/src/matcher-results.js @@ -0,0 +1,9 @@ +export function normalizeMatches(result) { + return (result.matches || []).map((match) => ({ + id: match.photoId, + label: match.label || match.photoId, + checkpoint: match.checkpoint || '-', + thumb: match.thumb || match.photoId, + score: match.score ?? null + })); +} \ No newline at end of file diff --git a/faceai/apps/backend/src/queue.js b/faceai/apps/backend/src/queue.js new file mode 100644 index 00000000..ea314fd2 --- /dev/null +++ b/faceai/apps/backend/src/queue.js @@ -0,0 +1,11 @@ +import { Queue } from 'bullmq'; + +let queue = null; + +export function getSearchQueue({ queueName, connection }) { + if (!queue) { + queue = new Queue(queueName, { connection }); + } + + return queue; +} \ No newline at end of file diff --git a/faceai/apps/backend/src/redis-store.js b/faceai/apps/backend/src/redis-store.js new file mode 100644 index 00000000..343de8bc --- /dev/null +++ b/faceai/apps/backend/src/redis-store.js @@ -0,0 +1,138 @@ +import Redis from 'ioredis'; +import { randomId } from './auth.js'; + +export function createRedisConnection(redisUrl) { + return new Redis(redisUrl, { + maxRetriesPerRequest: null, + enableReadyCheck: true + }); +} + +function searchKey(searchId) { + return `faceai:search:${searchId}`; +} + +function resultKey(resultId) { + return `faceai:result:${resultId}`; +} + +function activeSearchKey(userId) { + return `faceai:active-search:user:${userId}`; +} + +function rateLimitKey(userId) { + return `faceai:rate-limit:${userId}`; +} + +export async function incrementRateLimit(redis, userId, windowSeconds) { + const key = rateLimitKey(userId); + const count = await redis.incr(key); + if (count === 1) { + await redis.expire(key, windowSeconds); + } + return count; +} + +export async function acquireActiveSearchLock(redis, userId, searchId, ttlSeconds) { + const result = await redis.set(activeSearchKey(userId), searchId, 'EX', ttlSeconds, 'NX'); + return result === 'OK'; +} + +export async function releaseActiveSearchLock(redis, userId, searchId) { + const key = activeSearchKey(userId); + const current = await redis.get(key); + if (current === String(searchId)) { + await redis.del(key); + } +} + +export async function getActiveSearchId(redis, userId) { + return redis.get(activeSearchKey(userId)); +} + +export async function createSearchRecord(redis, payload, ttlSeconds) { + const searchId = randomId('search'); + const record = { + id: searchId, + status: 'queued', + resultId: null, + matchCount: 0, + errorCode: null, + errorMessage: null, + createdAt: Date.now(), + startedAt: null, + completedAt: null, + ...payload + }; + + await redis.set(searchKey(searchId), JSON.stringify(record), 'EX', ttlSeconds); + return record; +} + +export async function saveSearchRecord(redis, record, ttlSeconds) { + await redis.set(searchKey(record.id), JSON.stringify(record), 'EX', ttlSeconds); + return record; +} + +export async function getSearchRecord(redis, searchId) { + const raw = await redis.get(searchKey(searchId)); + return raw ? JSON.parse(raw) : null; +} + +async function updateSearchRecord(redis, searchId, updater, ttlSeconds) { + const current = await getSearchRecord(redis, searchId); + if (!current) { + return null; + } + + const next = updater(current); + await redis.set(searchKey(searchId), JSON.stringify(next), 'EX', ttlSeconds); + return next; +} + +export async function markSearchProcessing(redis, searchId, ttlSeconds = 24 * 60 * 60) { + return updateSearchRecord(redis, searchId, (current) => ({ + ...current, + status: 'processing', + startedAt: Date.now(), + errorCode: null, + errorMessage: null + }), ttlSeconds); +} + +export async function markSearchCompleted(redis, searchId, resultId, matchCount, ttlSeconds) { + return updateSearchRecord(redis, searchId, (current) => ({ + ...current, + status: 'completed', + resultId, + matchCount, + completedAt: Date.now() + }), ttlSeconds); +} + +export async function markSearchFailed(redis, searchId, errorCode, errorMessage, ttlSeconds) { + return updateSearchRecord(redis, searchId, (current) => ({ + ...current, + status: 'failed', + errorCode, + errorMessage, + completedAt: Date.now() + }), ttlSeconds); +} + +export async function storeResultRecord(redis, payload, ttlSeconds) { + const resultId = randomId('result'); + const record = { + id: resultId, + createdAt: Date.now(), + ...payload + }; + + await redis.set(resultKey(resultId), JSON.stringify(record), 'EX', ttlSeconds); + return record; +} + +export async function getResultRecord(redis, resultId) { + const raw = await redis.get(resultKey(resultId)); + return raw ? JSON.parse(raw) : null; +} \ No newline at end of file diff --git a/faceai/apps/backend/src/server.js b/faceai/apps/backend/src/server.js index 6db1f221..1f202237 100644 --- a/faceai/apps/backend/src/server.js +++ b/faceai/apps/backend/src/server.js @@ -1,16 +1,49 @@ import express from 'express'; import cors from 'cors'; import cookieParser from 'cookie-parser'; +import multer from 'multer'; import fs from 'node:fs'; +import fsp from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { config } from './config.js'; import { signPayload, verifySignedPayload } from './auth.js'; -import { createSession, createSearch, completeSearch, getResult, getSearch, getSession, mockCatalog } from './store.js'; +import { createSession, getSession, mockCatalog } from './store.js'; +import { + acquireActiveSearchLock, + createRedisConnection, + createSearchRecord, + getActiveSearchId, + getResultRecord, + getSearchRecord, + incrementRateLimit, + saveSearchRecord +} from './redis-store.js'; +import { getSearchQueue } from './queue.js'; +import { normalizeMatches } from './matcher-results.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const frontendDist = path.resolve(__dirname, '../../frontend/dist'); const app = express(); +const redis = createRedisConnection(config.redisUrl); +const searchQueue = getSearchQueue({ queueName: config.queueName, connection: redis }); + +await fsp.mkdir(config.uploadRoot, { recursive: true }); + +const upload = multer({ + storage: multer.diskStorage({ + destination: (req, file, cb) => { + const pendingRoot = path.join(config.uploadRoot, 'pending'); + fsp.mkdir(pendingRoot, { recursive: true }) + .then(() => cb(null, pendingRoot)) + .catch((error) => cb(error)); + }, + filename: (req, file, cb) => { + const safeName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_'); + cb(null, `${Date.now()}_${safeName}`); + } + }) +}); app.use(cookieParser()); app.use(express.json()); @@ -42,6 +75,25 @@ function requireSession(req, res, next) { next(); } +async function enforceSearchRateLimit(req, res, next) { + const userId = req.faceaiSession?.user?.id; + if (!userId) { + res.status(401).json({ error: 'Not authenticated with FaceAI' }); + return; + } + + const count = await incrementRateLimit(redis, userId, config.rateLimitWindowSeconds); + if (count > config.rateLimitMaxRequests) { + res.status(429).json({ + error: 'Too many search attempts. Please try again later.', + code: 'RATE_LIMITED' + }); + return; + } + + next(); +} + function issueHandoffToken({ raceId, raceSlug, lang, returnUrl }) { const race = mockCatalog[raceId] || { id: raceId, slug: raceSlug || `race-${raceId}`, name: raceSlug || `Race ${raceId}` }; @@ -185,7 +237,7 @@ app.get('/dev/legacy/launch', (req, res) => { res.redirect(`${config.frontendUrl}/auth/callback?token=${encodeURIComponent(token)}`); }); -app.get('/dev/legacy/return', (req, res) => { +app.get('/dev/legacy/return', async (req, res) => { try { const token = String(req.query.token || ''); const payload = verifySignedPayload(token, config.sharedSecret); @@ -193,12 +245,17 @@ app.get('/dev/legacy/return', (req, res) => { throw new Error('Wrong token type'); } - const result = getResult(String(req.query.resultId || payload.resultId)); + const result = await getResultRecord(redis, 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 })); + const normalizedResult = { + ...result, + matches: normalizeMatches(result) + }; + + res.type('html').send(renderLegacyRacePage({ raceId: result.raceId, lang: result.lang || 'it', result: normalizedResult })); } catch (error) { res.status(400).type('html').send(`

Return handoff failed

${escapeHtml(error.message)}

`); } @@ -247,33 +304,86 @@ 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'); +app.post('/api/searches', requireSession, enforceSearchRateLimit, upload.single('selfie'), async (req, res) => { + try { + const raceId = String(req.body.raceId || req.faceaiSession.race.id); + const userId = String(req.faceaiSession.user.id); + const activeSearchId = await getActiveSearchId(redis, userId); - const search = createSearch({ - raceId, - selfieName, - user: req.faceaiSession.user, - returnUrl: req.faceaiSession.returnUrl, - lang: req.faceaiSession.lang - }); + if (activeSearchId) { + res.status(409).json({ + error: 'There is already an operation being processed.', + code: 'ACTIVE_SEARCH_EXISTS', + activeSearchId + }); + return; + } - setTimeout(() => { - completeSearch(search.id); - }, 3500); + if (!req.file) { + res.status(400).json({ + error: 'Choose a selfie before starting the search.', + code: 'MISSING_SELFIE' + }); + return; + } - res.status(201).json({ - id: search.id, - status: search.status, - raceId: search.raceId, - selfieName: search.selfieName - }); + const race = mockCatalog[raceId] || req.faceaiSession.race; + const search = await createSearchRecord(redis, { + raceId, + raceName: race?.name || raceId, + userId, + returnUrl: req.faceaiSession.returnUrl, + lang: req.faceaiSession.lang, + selfieName: req.file.originalname, + selfiePath: req.file.path, + uploadPath: req.file.path + }, config.searchTtlSeconds); + + const lockAcquired = await acquireActiveSearchLock(redis, userId, search.id, config.searchTtlSeconds); + if (!lockAcquired) { + await fsp.unlink(req.file.path).catch(() => {}); + res.status(409).json({ + error: 'There is already an operation being processed.', + code: 'ACTIVE_SEARCH_EXISTS' + }); + return; + } + + const finalUploadDir = path.join(config.uploadRoot, search.id); + await fsp.mkdir(finalUploadDir, { recursive: true }); + const finalUploadPath = path.join(finalUploadDir, path.basename(req.file.path)); + await fsp.rename(req.file.path, finalUploadPath); + + const updatedSearch = await saveSearchRecord(redis, { + ...search, + selfiePath: finalUploadPath, + uploadPath: finalUploadPath + }, config.searchTtlSeconds); + + await searchQueue.add('run-search', { + searchId: search.id + }, { + removeOnComplete: 100, + removeOnFail: 100 + }); + + res.status(201).json({ + id: updatedSearch.id, + status: updatedSearch.status, + raceId: updatedSearch.raceId, + selfieName: updatedSearch.selfieName, + matchCount: updatedSearch.matchCount, + errorCode: updatedSearch.errorCode, + errorMessage: updatedSearch.errorMessage + }); + } catch (error) { + res.status(500).json({ error: error.message || 'Unable to create the search.' }); + } }); -app.get('/api/searches/:id', requireSession, (req, res) => { - const search = getSearch(req.params.id); - if (!search || search.user.id !== req.faceaiSession.user.id) { +app.get('/api/searches/:id', requireSession, async (req, res) => { + const search = await getSearchRecord(redis, req.params.id); + if (!search || search.userId !== req.faceaiSession.user.id) { res.status(404).json({ error: 'Search not found' }); return; } @@ -285,13 +395,15 @@ app.get('/api/searches/:id', requireSession, (req, res) => { resultId: search.resultId, createdAt: search.createdAt, completedAt: search.completedAt, - matchCount: search.matches.length + matchCount: search.matchCount || 0, + errorCode: search.errorCode, + errorMessage: search.errorMessage }); }); -app.get('/api/searches/:id/redirect', requireSession, (req, res) => { - const search = getSearch(req.params.id); - if (!search || search.user.id !== req.faceaiSession.user.id) { +app.get('/api/searches/:id/redirect', requireSession, async (req, res) => { + const search = await getSearchRecord(redis, req.params.id); + if (!search || search.userId !== req.faceaiSession.user.id) { res.status(404).json({ error: 'Search not found' }); return; } @@ -301,7 +413,12 @@ app.get('/api/searches/:id/redirect', requireSession, (req, res) => { return; } - const result = getResult(search.resultId); + const result = await getResultRecord(redis, search.resultId); + if (!result) { + res.status(404).json({ error: 'Result not found' }); + return; + } + const token = issueReturnToken(result); res.json({ @@ -309,7 +426,7 @@ app.get('/api/searches/:id/redirect', requireSession, (req, res) => { }); }); -app.get('/bridge/results/:id', (req, res) => { +app.get('/bridge/results/:id', async (req, res) => { try { const token = String(req.query.token || ''); const payload = verifySignedPayload(token, config.sharedSecret); @@ -321,7 +438,7 @@ app.get('/bridge/results/:id', (req, res) => { throw new Error('Result id mismatch'); } - const result = getResult(req.params.id); + const result = await getResultRecord(redis, req.params.id); if (!result || result.userId !== payload.userId) { throw new Error('Result not found'); } @@ -340,6 +457,15 @@ app.get('/bridge/results/:id', (req, res) => { } }); +app.get('/api/health/queue', async (req, res) => { + try { + await redis.ping(); + res.json({ ok: true }); + } catch (error) { + res.status(500).json({ ok: false, error: error.message }); + } +}); + if (fs.existsSync(frontendDist)) { app.use(express.static(frontendDist)); app.get('*', (req, res, next) => { diff --git a/faceai/apps/backend/src/store.js b/faceai/apps/backend/src/store.js index dd25fc4a..03350a1c 100644 --- a/faceai/apps/backend/src/store.js +++ b/faceai/apps/backend/src/store.js @@ -34,8 +34,6 @@ export const mockCatalog = { }; const sessions = new Map(); -const searches = new Map(); -const results = new Map(); export function createSession(session) { const sessionId = randomId('sess'); @@ -49,62 +47,3 @@ export function createSession(session) { 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; -} diff --git a/faceai/apps/frontend/src/views/HomeView.vue b/faceai/apps/frontend/src/views/HomeView.vue index b9df54a5..f20d064d 100644 --- a/faceai/apps/frontend/src/views/HomeView.vue +++ b/faceai/apps/frontend/src/views/HomeView.vue @@ -46,6 +46,10 @@ const statusLabel = computed(() => { return `Ricerca completata. Trovate ${activeSearch.value.matchCount} foto corrispondenti.`; } + if (activeSearch.value.status === 'failed') { + return 'La ricerca non e stata completata. Verifica il messaggio di errore e riprova.'; + } + return 'Ricerca in corso. Il sistema aggiorna automaticamente lo stato finche il risultato non e pronto.'; }); @@ -75,6 +79,12 @@ async function pollSearch(searchId) { } activeSearch.value = await response.json(); + if (activeSearch.value.status === 'failed') { + isSubmitting.value = false; + errorMessage.value = activeSearch.value.errorMessage || 'The search failed.'; + return; + } + if (activeSearch.value.status === 'completed') { isSubmitting.value = false; const redirectResponse = await fetch(`/api/searches/${searchId}/redirect`, { credentials: 'include' }); @@ -108,16 +118,14 @@ async function submitSearch() { isSubmitting.value = true; + const formData = new FormData(); + formData.set('raceId', session.value.race.id); + formData.set('selfie', selectedFile.value); + 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 - }) + body: formData }); const payload = await response.json(); diff --git a/faceai/apps/processor/package.json b/faceai/apps/processor/package.json new file mode 100644 index 00000000..24fae449 --- /dev/null +++ b/faceai/apps/processor/package.json @@ -0,0 +1,14 @@ +{ + "name": "@regalami/faceai-processor", + "private": true, + "type": "module", + "scripts": { + "dev": "node --watch src/worker.js", + "build": "node -e \"console.log('processor build not required')\"", + "start": "node src/worker.js" + }, + "dependencies": { + "bullmq": "^5.48.1", + "ioredis": "^5.4.1" + } +} \ No newline at end of file diff --git a/faceai/apps/processor/src/config.js b/faceai/apps/processor/src/config.js new file mode 100644 index 00000000..0be8c4b9 --- /dev/null +++ b/faceai/apps/processor/src/config.js @@ -0,0 +1,12 @@ +export const config = { + redisUrl: process.env.FACEAI_REDIS_URL || 'redis://redis:6379', + queueName: process.env.FACEAI_QUEUE_NAME || 'faceai-searches', + workerConcurrency: Number(process.env.FACEAI_WORKER_CONCURRENCY || 2), + workerTimeoutMs: Number(process.env.FACEAI_WORKER_TIMEOUT_MS || 5 * 60 * 1000), + runtimeRoot: process.env.FACEAI_RUNTIME_ROOT || '/data/runtime', + pklRoot: process.env.FACEAI_PKL_ROOT || '/data/pkl', + fallbackPklRoot: process.env.FACEAI_TEST_PKL_ROOT || '/data/pkl/test', + matcherBinary: process.env.FACEAI_MATCHER_BINARY || '/opt/face-recognition/face_matcher', + searchTtlSeconds: Number(process.env.FACEAI_SEARCH_TTL_SECONDS || 24 * 60 * 60), + resultTtlSeconds: Number(process.env.FACEAI_RESULT_TTL_SECONDS || 24 * 60 * 60) +}; \ No newline at end of file diff --git a/faceai/apps/processor/src/worker-utils.js b/faceai/apps/processor/src/worker-utils.js new file mode 100644 index 00000000..41abbbca --- /dev/null +++ b/faceai/apps/processor/src/worker-utils.js @@ -0,0 +1,99 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; + +async function fileExists(filePath) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +export async function resolvePklPath({ raceId, pklRoot, fallbackPklRoot }) { + const preferred = path.join(pklRoot, String(raceId), 'face_encodings.pkl'); + if (await fileExists(preferred)) { + return preferred; + } + + const flatFile = path.join(pklRoot, `${raceId}.pkl`); + if (await fileExists(flatFile)) { + return flatFile; + } + + const fallbackEntries = await fs.readdir(fallbackPklRoot).catch(() => []); + const fallbackFile = fallbackEntries.find((entry) => entry.toLowerCase().endsWith('.pkl')); + if (fallbackFile) { + return path.join(fallbackPklRoot, fallbackFile); + } + + throw new Error(`No PKL file available for race ${raceId}`); +} + +export async function runFaceMatcher({ matcherBinary, selfiePath, pklPath, csvPath, logPath, timeoutMs }) { + await fs.mkdir(path.dirname(csvPath), { recursive: true }); + await fs.mkdir(path.dirname(logPath), { recursive: true }); + + return new Promise((resolve, reject) => { + const child = spawn(matcherBinary, [ + '--image', selfiePath, + '--encodings', pklPath, + '--out', csvPath, + '--log', logPath + ], { + stdio: 'ignore' + }); + + const timer = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error('face_matcher timed out')); + }, timeoutMs); + + child.on('error', (error) => { + clearTimeout(timer); + reject(error); + }); + + child.on('exit', (code) => { + clearTimeout(timer); + if (code === 0) { + resolve(); + return; + } + + reject(new Error(`face_matcher exited with code ${code}`)); + }); + }); +} + +export async function parseMatcherCsv(csvPath) { + const content = await fs.readFile(csvPath, 'utf8'); + const lines = content + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + if (!lines.length) { + return []; + } + + const rows = lines.map((line) => line.split(',').map((part) => part.trim().replace(/^"|"$/g, ''))); + const firstRow = rows[0]; + const hasHeader = firstRow.some((cell) => /file|image|score|distance|confidence/i.test(cell)); + const dataRows = hasHeader ? rows.slice(1) : rows; + + return dataRows + .filter((cells) => cells[0]) + .map((cells) => { + const photoId = path.basename(cells[0]); + const numericCell = cells.find((cell, index) => index > 0 && !Number.isNaN(Number(cell))); + const score = numericCell ? Number(numericCell) : null; + + return { + photoId, + score, + label: photoId + }; + }); +} \ No newline at end of file diff --git a/faceai/apps/processor/src/worker.js b/faceai/apps/processor/src/worker.js new file mode 100644 index 00000000..28a91613 --- /dev/null +++ b/faceai/apps/processor/src/worker.js @@ -0,0 +1,82 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { Worker } from 'bullmq'; +import { config } from './config.js'; +import { + createRedisConnection, + getSearchRecord, + markSearchCompleted, + markSearchFailed, + markSearchProcessing, + releaseActiveSearchLock, + storeResultRecord +} from '../../backend/src/redis-store.js'; +import { parseMatcherCsv, resolvePklPath, runFaceMatcher } from './worker-utils.js'; + +const connection = createRedisConnection(config.redisUrl); + +async function processJob(job) { + const searchId = String(job.data.searchId || ''); + const search = await getSearchRecord(connection, searchId); + if (!search) { + throw new Error(`Search ${searchId} not found`); + } + + await markSearchProcessing(connection, searchId, config.searchTtlSeconds); + + const searchDir = path.join(config.runtimeRoot, 'searches', searchId); + await fs.mkdir(searchDir, { recursive: true }); + + try { + const pklPath = await resolvePklPath({ + raceId: search.raceId, + pklRoot: config.pklRoot, + fallbackPklRoot: config.fallbackPklRoot + }); + + const csvPath = path.join(searchDir, 'result.csv'); + const logPath = path.join(searchDir, 'matcher.log'); + + await runFaceMatcher({ + matcherBinary: config.matcherBinary, + selfiePath: search.selfiePath, + pklPath, + csvPath, + logPath, + timeoutMs: config.workerTimeoutMs + }); + + const matches = await parseMatcherCsv(csvPath); + const result = await storeResultRecord(connection, { + raceId: search.raceId, + raceName: search.raceName, + userId: search.userId, + returnUrl: search.returnUrl, + lang: search.lang, + matches + }, config.resultTtlSeconds); + + await markSearchCompleted(connection, searchId, result.id, matches.length, config.searchTtlSeconds); + await releaseActiveSearchLock(connection, search.userId, searchId); + } catch (error) { + await markSearchFailed(connection, searchId, 'PROCESSOR_ERROR', error.message, config.searchTtlSeconds); + await releaseActiveSearchLock(connection, search.userId, searchId); + throw error; + } +} + +const worker = new Worker(config.queueName, processJob, { + connection, + concurrency: config.workerConcurrency +}); + +worker.on('completed', (job) => { + console.log(`Completed FaceAI search ${job.data.searchId}`); +}); + +worker.on('failed', (job, error) => { + const searchId = job?.data?.searchId || 'unknown'; + console.error(`Failed FaceAI search ${searchId}: ${error.message}`); +}); + +console.log(`FaceAI processor listening on queue ${config.queueName} with concurrency ${config.workerConcurrency}`); \ No newline at end of file diff --git a/faceai/docker-compose.yml b/faceai/docker-compose.yml index 114e4f2d..7693b083 100644 --- a/faceai/docker-compose.yml +++ b/faceai/docker-compose.yml @@ -13,11 +13,43 @@ services: FACEAI_LOCAL_LEGACY_STATIC_ROOT: /legacy-www FACEAI_SHARED_SECRET: change-me FACEAI_SESSION_COOKIE: rus_faceai_session + FACEAI_REDIS_URL: redis://redis:6379 + FACEAI_RUNTIME_ROOT: /data/runtime + FACEAI_UPLOAD_ROOT: /data/runtime/uploads volumes: - .:/app - ../www:/legacy-www:ro + - faceai-runtime:/data/runtime ports: - "3001:3001" + depends_on: + - redis + + processor: + image: node:20-bookworm-slim + container_name: regalami-faceai-processor + working_dir: /app + command: sh -c "npm run start --workspace @regalami/faceai-processor" + environment: + FACEAI_REDIS_URL: redis://redis:6379 + FACEAI_QUEUE_NAME: faceai-searches + FACEAI_RUNTIME_ROOT: /data/runtime + FACEAI_PKL_ROOT: /data/pkl + FACEAI_TEST_PKL_ROOT: /data/pkl/test + FACEAI_WORKER_CONCURRENCY: 2 + FACEAI_MATCHER_BINARY: /opt/face-recognition/face_matcher + volumes: + - .:/app + - ../bin/Face_Recognition_Unix:/opt/face-recognition:ro + - ../test_pkl:/data/pkl/test:ro + - faceai-runtime:/data/runtime + depends_on: + - redis + + redis: + image: redis:7-alpine + container_name: regalami-faceai-redis + command: redis-server --appendonly no legacy-php: image: php:8.3-apache @@ -32,3 +64,6 @@ services: - ../www:/var/www/html ports: - "8080:80" + +volumes: + faceai-runtime: diff --git a/faceai/docs/processor-technical-design.md b/faceai/docs/processor-technical-design.md new file mode 100644 index 00000000..cc523665 --- /dev/null +++ b/faceai/docs/processor-technical-design.md @@ -0,0 +1,166 @@ +# FaceAI Processor Technical Design + +## Goal + +Add an internal processor service that executes `face_matcher` jobs for the public FaceAI site, while preventing duplicate searches per user and keeping all state short-lived and restart-safe. + +## Scope Of This Slice + +- add Redis-backed queue and job state +- add a dedicated `processor` workspace and container scaffold +- replace in-memory search orchestration in the public backend +- preserve the existing frontend polling and legacy return flow +- support local PKL testing from `test_pkl/` + +This slice does not yet implement production NAS mounting, persistent databases, or a final parser tailored to the real matcher CSV format. + +## Runtime Architecture + +### Public backend + +- owns the authenticated API used by the Vue frontend +- stores uploaded selfies in a shared runtime volume +- enqueues jobs into BullMQ +- keeps per-search state, results, rate limits, and active-user locks in Redis +- never executes `face_matcher` directly + +### Processor + +- consumes queue jobs from Redis using BullMQ worker concurrency +- resolves the race-scoped PKL path for each job +- executes the Linux `face_matcher` binary +- parses the CSV result into legacy-compatible `photoId` matches +- writes final state and result payload back to Redis + +### Redis + +- queue broker for BullMQ +- source of truth for active-user locks +- source of truth for search status and short-lived results +- source of truth for rate-limit counters + +## Queue And Locking Model + +- queue name: `faceai-searches` +- active lock key: `faceai:active-search:user:{legacyUserId}` +- search record key: `faceai:search:{searchId}` +- result record key: `faceai:result:{resultId}` +- rate limit key prefix: `faceai:rate-limit:{legacyUserId}` + +`POST /api/searches` must acquire the active-user lock before enqueueing. If the lock already exists, the backend returns `409` with error code `ACTIVE_SEARCH_EXISTS`. + +The lock is released only when the processor marks the search as terminal: `completed`, `failed`, or `timed_out`. + +## Race And PKL Resolution + +The canonical race key is the legacy `id_gara`, already exposed as `raceId` in the existing handoff flow. + +The processor resolves the PKL path using a race-based directory layout: + +```text +/data/pkl/ + 101/ + face_encodings.pkl + 202/ + face_encodings.pkl +``` + +The lookup rule is: + +1. try `/data/pkl/{raceId}/face_encodings.pkl` +2. optionally fall back to `/data/pkl/{raceId}.pkl` +3. fail the job if neither exists + +For local development, `test_pkl/` is mounted into `/data/pkl/test` and the backend can fall back to the first `.pkl` file in that folder when no race-specific file exists yet. + +## Shared Runtime Storage + +Both the public backend and the processor mount the same writable runtime directory: + +```text +/data/runtime/ + uploads/ + searches/ +``` + +- uploaded selfies are written under `uploads/{searchId}/` +- worker output and logs are written under `searches/{searchId}/` +- cleanup can safely remove old per-search directories after retention expires + +## Search Lifecycle + +1. frontend uploads a selfie and calls `POST /api/searches` +2. backend validates session, rate limit, and active-user lock +3. backend stores the upload and creates a Redis search record with status `queued` +4. backend enqueues a BullMQ job +5. processor picks up the job and sets status `processing` +6. processor runs `face_matcher` +7. processor parses CSV output into matches +8. processor stores a result record and marks the search `completed` +9. frontend polling reads Redis-backed state through `GET /api/searches/:id` +10. existing redirect flow sends the user back to the legacy filtered page + +## Search Record Shape + +```json +{ + "id": "search_...", + "status": "queued", + "raceId": "101", + "userId": "legacy-user-1", + "returnUrl": "https://...", + "lang": "it", + "selfieName": "selfie.jpg", + "selfiePath": "/data/runtime/uploads/search_.../selfie.jpg", + "resultId": null, + "matchCount": 0, + "errorCode": null, + "errorMessage": null, + "createdAt": 0, + "startedAt": null, + "completedAt": null +} +``` + +## Result Shape + +```json +{ + "id": "result_...", + "raceId": "101", + "raceName": "Mezza di Firenze", + "userId": "legacy-user-1", + "returnUrl": "https://...", + "lang": "it", + "matches": [ + { + "photoId": "legacy-photo-id", + "score": 0.98, + "label": "legacy-photo-id" + } + ], + "createdAt": 0 +} +``` + +## Compose Topology + +- `faceai`: public backend plus built frontend +- `processor`: queue consumer and matcher executor +- `redis`: queue and short-lived state +- `legacy-php`: local bridge simulator for end-to-end testing + +## Operational Defaults + +- worker concurrency: `2` +- active search retention: `24h` +- result retention: `24h` +- rate limit window: `5 requests / 10 minutes / user` +- worker timeout: `5 minutes` + +## Known Follow-Up Work + +- confirm the real CSV columns emitted by `face_matcher` +- verify the Linux binary shared library requirements inside the processor image +- replace the PKL fallback with a strict NAS-backed race mapping once the final folder layout is agreed +- add cleanup jobs for expired runtime files \ No newline at end of file diff --git a/faceai/package-lock.json b/faceai/package-lock.json index 3f0e2e71..cfe382c1 100644 --- a/faceai/package-lock.json +++ b/faceai/package-lock.json @@ -7,7 +7,8 @@ "name": "faceai", "workspaces": [ "apps/frontend", - "apps/backend" + "apps/backend", + "apps/processor" ], "devDependencies": { "concurrently": "^9.1.2" @@ -16,9 +17,12 @@ "apps/backend": { "name": "@regalami/faceai-backend", "dependencies": { + "bullmq": "^5.48.1", "cookie-parser": "^1.4.7", "cors": "^2.8.5", - "express": "^4.21.2" + "express": "^4.21.2", + "ioredis": "^5.4.1", + "multer": "^2.0.0" } }, "apps/frontend": { @@ -32,6 +36,13 @@ "vite": "^6.1.0" } }, + "apps/processor": { + "name": "@regalami/faceai-processor", + "dependencies": { + "bullmq": "^5.48.1", + "ioredis": "^5.4.1" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -520,12 +531,96 @@ "node": ">=18" } }, + "node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT" + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@regalami/faceai-backend": { "resolved": "apps/backend", "link": true @@ -534,6 +629,10 @@ "resolved": "apps/frontend", "link": true }, + "node_modules/@regalami/faceai-processor": { + "resolved": "apps/processor", + "link": true + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", @@ -1050,6 +1149,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -1080,6 +1185,38 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/bullmq": { + "version": "5.73.4", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.73.4.tgz", + "integrity": "sha512-Q+NeFLtdKSD3GDPYSX4pH+Mc9E4OZVKimXwrnZ5WmndNy31COMy4vQV9zfhgfHGSUFrlpsBicfKYbSjx9FbO+A==", + "license": "MIT", + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.10.1", + "msgpackr": "1.11.5", + "node-abort-controller": "3.1.1", + "semver": "7.7.4", + "tslib": "2.8.1", + "uuid": "11.1.0" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1163,6 +1300,15 @@ "node": ">=12" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1183,6 +1329,21 @@ "dev": true, "license": "MIT" }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/concurrently": { "version": "9.2.1", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", @@ -1274,6 +1435,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1289,6 +1462,15 @@ "ms": "2.0.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1308,6 +1490,16 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1714,6 +1906,53 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ioredis": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ioredis/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ioredis/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1733,6 +1972,27 @@ "node": ">=8" } }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1817,6 +2077,56 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/msgpackr": { + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz", + "integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, + "node_modules/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1844,6 +2154,27 @@ "node": ">= 0.6" } }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -1991,6 +2322,41 @@ "node": ">= 0.8" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -2082,6 +2448,18 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", @@ -2227,6 +2605,12 @@ "node": ">=0.10.0" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2236,6 +2620,23 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -2320,7 +2721,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/type-is": { @@ -2336,6 +2736,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -2345,6 +2751,12 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -2354,6 +2766,19 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/faceai/package.json b/faceai/package.json index 7a2b1c19..fd05e0d8 100644 --- a/faceai/package.json +++ b/faceai/package.json @@ -3,14 +3,17 @@ "private": true, "workspaces": [ "apps/frontend", - "apps/backend" + "apps/backend", + "apps/processor" ], "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", + "dev:processor": "npm run dev --workspace @regalami/faceai-processor", "build": "npm run build --workspace @regalami/faceai-frontend && npm run build --workspace @regalami/faceai-backend", - "start": "npm run start --workspace @regalami/faceai-backend" + "start": "npm run start --workspace @regalami/faceai-backend", + "start:processor": "npm run start --workspace @regalami/faceai-processor" }, "devDependencies": { "concurrently": "^9.1.2" diff --git a/test_pkl/face_encodings_20260330_170155.pkl b/test_pkl/face_encodings_20260330_170155.pkl new file mode 100644 index 0000000000000000000000000000000000000000..000c8fbed7e8d5f9caaf98089649490a3e97d366 GIT binary patch literal 7674 zcmb7JX?Rc9x89{u5v{St;%HD)4KYv6CL}7NQLQ1QHc3bbk_ZioO&U{zh+ksnh^UAm zA%h_C+cL@4X*xpXd7EwT8X++H1dS?URu2Mn7u7@Mp25 zeT1#gY_B=qezT{~wRl#Ejb_iA6|kVoWUo0vK3!(b^AGl$5)?FLfhD5$m z2hH;ewrsRS*uc{g@!o82(`BQ@m4#WH-kEAyY5CI9-t`?<8D{aV5ngplvUF4=dkKSXg`j?6*J>p23hesguQQ?b7 zFUCw#z`E5B5E>;S8rWi0EFpXBchZsks?|t_{9%J!lJ(}UaU}EiQ_c{o>=xTe&niFJ zNXU0=uo6m(Ybx30xMNAc=5A+!4|B2znV!6rP_tjM(vi?{)@G9V%yx++^GDZmNR~_g zE+tgieU8AM-s>cA6FcfyrDyS*kCMz}t1X0buJ5gPy(`gN% z@v1%YVgr9XMs{&xa4G4vlfA-8*6dFsPnGgE3I0d26`>rOQUbqAK?Q_rOI|o3J3oGw zwF-=&FVhJ5gHG9`=aYZIxy2_&8p+!J&$56+HXet4V(2MCHmx&c)}`lhE3Ny~ejn*Y zWZW4-X}b;Q5s7tmlJze?N&&w4DZxtq1FxB!z8ngSv#T6>47@xZvD2wUNjQ%m7EHmh`aTcl3qM+no4N=8i8}`zn=n6 z1e+2;dgULTL#S=}2z+I|rXycfui!gtm7aCHA47K0&mFwshXnR9h6Y;6u5v!xNGQWy zz*AxG8B4Np!6up3v8FX}KPuF{2>Kj5>?c;#zOdyYEsdO;`1UrVSR0sVE`43gQp#_(rZUh$A0tb|?vXd?2`L-O;; zUxc?kLC7uc&>NTU!;c>sihGfX7Zx~LswRQ2jc-jFD89A+=QyeV&wvoW)k#Odj#oeg zRdYTrjz_+m0e!EQ2MLXWe&)efg@8En$$B&n|6ns3K#shCgUk4VAUylC|M!kn>$J0_ zD4_mT!4D*}7tSc0miqt&3?#7*y(mZ;&_hxaUNE#-NJRR>E8NKrGgcpC&!;^c4HDWdm_xY}q&a zNv}$x4#WPHHyT@w&OpIg_3Ox+op|;o*+sqCCDtmGW1a-Vf0!5QsJ(vxjlpN$M}71r ze@-DkUU@H){N&7+IG=P&I0iYitc>hh%%>YkW(^=u^3l4tK|R>BC_nO3_iB|>d^Xqy z=hQA`n*0AUz?36BF}U?rx%)5(vERp2r2L zKgR>5_#F%+Sb4}@%dhopZQ5Ox0dSB7tcfBVLm+-g6C6xJg1 z%Hn35DYra?naYxe;ixQ7_)#iyQFW9;cXL4L*&6Q@(yN<)fTG5TMWrNb6@4)FA}et`Xr*aY(paae>A zrTyM0iu}Z+>!5;~7QBwu^K5BeZA`&@#F^5%82ZKIFyiF+!9e~X9&b16R04{Nwt+F^ z&+;=Tl0Vx%Gz%D#V~)hyAEHir{9mBFoSK(U{_0giHZbH*v>bo`YfwdXSaA~T=O0F1 z`q+m!r@=ZQu4ddoy+ngSprjn^2ED4^a4oG9$s5qhqREd|if@cL1!jB>r%Z|DPF!v>>MKk*Kp z47sRx%qcIE&~SQKNOpPEvxsDQAA7S92KWN18>C6v#ZF? zZ=MeZ`kLSL`rc-^7vtNdX1~0#+edcewi(9=II!toMu`1C-xzvlKU}Q(pyUK4z~ZVM zB-B>sfq;exK7_!N0&y{{RRdh8OlX3O(~gD~kiR+=hJtGKV@yIXy0jYcA{(KBjPing z@cXn7#MF%R9FmPa3vm$H^Cuv`^jDs>O3$7@G2dYC>EDr`HtHG%h2|3iVrX+pBN1vv6>Y^AtYuKfs9jT#e*C!NeXW|vwzSWL9Gr*7Q6pcKk<7dA+Dhw;nosfO8}&ABBw0+nif;@REmKI=y}Zwmznb|1 zM3;`{o5ONO7m(iATC#`y_@F4{Bj;^HU1U+iqh!~YsxCaq%(yWK?(DCFaZn>0GT=78^2ApO$ z8~f`|uBSoYAOnrhaz~>9jk@00N59h=_rp?mfMR)HHxU}OCRnh3)4Cw6YteBh z>G{=_sf29LggnyoJ2_`bRs$Dcpfk%r=+)G(A@e5{;J?gn2!4u%9dKXL`TikV&jK%+ z{7^S=9%CIpMDb+sxGiKi`oB#hnOU;;5~?rD68~jD{C~bN&I9lk8g0hl4HFI#C?!8Ue?P^MpQnOSazmSg zn%>(E+?$7&Btp!iyzeJSwN$&-XC>>Io_ zYW0JyB#RyzMz^-G9a=yy^~O2)OHZ_p=BuH_dz`jGn=E!u)b=ID+|2Zg!AZctL3a5zPA`LMyb2j1WhyoD<7!=JJKiyIg@+RR<% zJ%qfER^UeW2(ojBg{ZSiTbKkndWu=M<-eG%fA5@mKTmGr9C~bP8 zn}=_)R-v)|i)_etEzv^k^qN@6;UyMR|6(`!X~P2x2-(E};Gs+%u@82)hSPveTA;qV z!!guFZu~2r^s1sqDxu!2;~6Vq{~87G|HSza$vk4EPAK;kPAAmbJYGt6W7IK>Ozr&` zon)< z_KHz$L3}OM7VCIT+f=fPbOYZ8Dt*pAtZPub4Em9&W&!*1@{<)9<^`z0a|{%)Ic z^3xl&%OW%)lktYC2M^6W9)}`twR#YUVhkFCH(YeyjKV43KG;tVaRTu~z(*jQHc}p^ zc-*rO8bI4`o>%o)5eNOnqC7&C)X~g0zB1X71;VAgpYgl1F-BTt>Zl z`C2p}^ZyI+Sj%RpuguRnY_0NBFWmMM>M`jUQ0&dUTw2fWWq?@f;&1t6H)44<>A6i! z)JeE?2qRfJ-^~G@TMvJ2#02vpXxFk3>spuNzQpxW736312rvik**6%pa;kj{>Dl0? zXiP3&BR{sxSVelVvjXc`kX-=9lgEET{nX2vXc%qWoYmyVhkT6uwS*MJk!8mv;{Iy? z6QQ*2B?wjVa~=9cek-A$ehqw=!#~6zR9R!cvsU>D_o?Q$a?{~v{vNmCC;WFJZ=?P3 zc=A&dYQQdUwgrEUt%azcdVB_X@~(d1IsayeIj{~kF?n=zJb1}Qy#%ji9>>6E+SO9T zjd_53R4#vF0I|pn3^=}{K>_k^?Tq_YKRq$;ciyvEv`+uc4)x>L%W_HPOZg9k+Ep72 zAXPL8e3Q2infVNFy43oA|DyB12ZVh16}}AQuYX`->z6iS^2@{*d$7@sqMd}|>O|ij|N_zu!HRCVi*c4MueFY z+`|_ar5D`-;f&Sq#gU)U{lp<)UcF#iuQu5uKVz)@9@6tkfoK$VrBgZCkY#V~n_f2ItK_#QBWnldvzhD@k*tc&9rcFO|Cr#F5q} z#iTcSIBg)*dxVxDUfpWgPmf%SIDGu&EZC>_!T?Y$KR5Gz@CNy)$(b0;YUV@`LG}FX z0>xoPEAa)w2Fy1J=Taw;;;1GfgOE*K2w8h{&}_)>hxuTi)n6$1oeTj%WMuIQYn8iZ zKNbO*7G%cCPm zFKs`Dzo>T`4QpJjU5t3iJwbG~>kaC`>QB2waYfQ(3>x;dD+UOA=7+rH>(VS*$MRiu z;DK*;l0Wmw!9Zb0cg!H!Xn8)GP z6tGRr&4hZ610EoeRX-g1@D`cYD!pO+hJDp8S3CrA{<__yms3_CFCMu(7INH}(X@_l zbx$EwYT;SZi|JQUN3nE566xhD0|Qu8Y)yy1e=GDEcWR13rY){-^4;+a&MBYWxJYsI zm5$(x-tZ{~g8s{u7+NoGJK>!A@=)+r_qQ|8-QX$sBzxK7T-@G1pW^Td^{@|*y>1T1 z0f%Rko@Lau5Xw2zCB7hhB5bGm`TNYCGRw#OPuki}t|KQqI5_m{GQ!=(;yWzDRP^Z9 W%~Ux5N8!-Br>Su9b@7?k<-Y*P0Lm8t literal 0 HcmV?d00001 diff --git a/test_pkl/face_encodings_20260330_170210.pkl b/test_pkl/face_encodings_20260330_170210.pkl new file mode 100644 index 0000000000000000000000000000000000000000..f73173a127c69fdd80a93a7c4335514eeede5276 GIT binary patch literal 52607 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 literal 0 HcmV?d00001 diff --git a/test_pkl/face_encodings_20260330_170340.pkl b/test_pkl/face_encodings_20260330_170340.pkl new file mode 100644 index 0000000000000000000000000000000000000000..c55e8c9d92bd932b0efb2756279974b164c7845d GIT binary patch literal 55843 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 Date: Sun, 12 Apr 2026 15:21:33 +0200 Subject: [PATCH 2/2] Updated configurations for deployment --- faceai/README.md | 288 ++++++++++++++++++++++------------ faceai/docker/Dockerfile | 1 + sync/www-deploy-manifest.md | 116 +++----------- www/_js/rus-ecom-240621.js | 76 ++++++++- www/faceai_config.php | 86 +++++++++- www/faceai_handoff.php | 24 +-- www/faceai_simulator_view.php | 1 + www/fotoCR-en.jsp | 12 ++ www/fotoCR.jsp | 12 ++ 9 files changed, 399 insertions(+), 217 deletions(-) diff --git a/faceai/README.md b/faceai/README.md index 2dfa1cb7..5c36e47c 100644 --- a/faceai/README.md +++ b/faceai/README.md @@ -5,7 +5,8 @@ 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 Node/Express backend for session exchange, queueing, and return handoff +- a dedicated processor runner that consumes matcher jobs from Redis and executes `face_matcher` - 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 @@ -16,64 +17,91 @@ faceai/ apps/ backend/ frontend/ + processor/ docker/ Dockerfile ``` -## What The Local Test Covers +## Runtime Topology + +The scaffold currently expects four runtime roles: + +- `faceai`: public HTTP service on port `3001`, serving the built Vue app and the authenticated API +- `processor`: background matcher runner consuming BullMQ jobs from Redis and executing the Linux `face_matcher` binary +- `redis`: short-lived queue and search-state store +- `legacy-php`: local-only PHP Apache simulator for exercising the real bridge files under `www/` + +For hosted deployment, the long-lived application topology is `faceai` + `processor` + `redis`. The PHP simulator stays local-only and the real legacy site remains on its existing stack. + +## What The End-To-End 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 +1. a legacy-like race page loads the original `www/_js/rus-ecom-240621.js` script and shows a `Face ID` button instead of `tipoPuntoFoto` +2. clicking it hits the real PHP handoff bridge at `www/faceai_handoff.php` 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 +5. the user uploads a selfie and starts a Redis-backed 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 +8. the browser is redirected back to the real PHP return bridge at `www/faceai_return.php` +9. the PHP bridge fetches the signed result from FaceAI and renders a filtered legacy-like race page -## Local Run +## Local Testing With The Legacy PHP Simulator + +This is the recommended local test path because it exercises the public site, the processor, Redis, and the real PHP bridge files together. + +### Prerequisites + +- Docker Desktop or another Docker Engine with Compose support +- local npm dependencies installed in this `faceai/` workspace + +### Start The Stack 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. +The checked-in `docker-compose.yml` starts: -This starts: +- FaceAI public site on `http://localhost:3001` +- processor runner on the internal Compose network +- Redis on the internal Compose network +- PHP Apache serving `../www` on `http://localhost:8080` -- FaceAI app on `http://localhost:3001` -- PHP Apache serving `www` on `http://localhost:8080` +The local stack also mounts: -For the end-to-end test through the PHP bridge, open: +- `../bin/Face_Recognition_Unix` into the processor container as the matcher binary source +- `../test_pkl` into the processor container as fallback PKL test data +- `../www` into the PHP container so the real bridge files are used + +### Run The Browser Test + +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`. +That page simulates the legacy race 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`. + +### Expected Local Flow + +Use the page above and verify this sequence: + +1. the simulator page renders on port `8080` +2. the visible checkpoint selector is replaced with the `Face ID` launch button +3. clicking `Face ID` redirects through `faceai_handoff.php` into `http://localhost:3001/auth/callback?token=...` +4. the FaceAI app establishes its session and loads the upload flow +5. uploading a selfie creates a queued search that the processor picks up +6. when polling completes, FaceAI redirects back to `http://localhost:8080/faceai_return.php?...` +7. the PHP return page renders the filtered photo list from the FaceAI result payload + +### Rebuild Notes If you change frontend code and want Docker to serve the updated UI, rebuild first with: @@ -81,62 +109,38 @@ If you change frontend code and want Docker to serve the updated UI, rebuild fir npm run build ``` -## Production Deployment From Registry +If you want to stop and remove the local containers afterward, run: -The published container is the user-facing FaceAI site only. It already contains: +```bash +docker compose down +``` -- the Node/Express backend -- the built Vue frontend assets served by that backend +## Optional Backend And Frontend Dev Loop -It does not include: +If you only want to iterate on the app without the PHP simulator, you can still run the public site and the processor separately. The queue-backed flow now requires Redis and the processor, so `npm run dev` alone is no longer the full stack. -- the legacy PHP simulator -- the existing `www` site -- the future queue/processor worker +One workable loop is: -In production, deploy a single FaceAI container behind HTTPS on its own host name, for example `faceai.regalamiunsorriso.it`, and keep the legacy site on its existing stack. +```bash +npm install +docker compose up redis -d +npm run dev +``` -### What The Production Container Exposes +Then start the processor in a second shell, either with its own local environment or by keeping the Compose-managed processor service running. -- HTTP service on port `3001` inside the container -- health endpoint at `/health` -- frontend and API from the same process +## Docker Compose Deployment For The Public Site And Matcher Runner -The image should be run with a reverse proxy or ingress that terminates TLS and forwards traffic to the container. +The checked-in `docker-compose.yml` is for local integration testing because it also includes the PHP simulator and local bind mounts. For hosted deployment, keep the same three-service application topology but remove `legacy-php` and replace the local mounts with your production matcher and PKL paths. -### Required Runtime Configuration +The public FaceAI site and the matcher runner can both use the same application image. The difference is only the process command: -Set these environment variables for production: +- `npm run start` for the public site +- `npm run start:processor` for the matcher runner -| Variable | Required | Example | Purpose | -| --- | --- | --- | --- | -| `NODE_ENV` | yes | `production` | disables development defaults | -| `PORT` | optional | `3001` | internal listen port | -| `FACEAI_FRONTEND_URL` | yes | `https://faceai.regalamiunsorriso.it` | URL used when the legacy bridge redirects into the app | -| `FACEAI_PUBLIC_BASE_URL` | yes | `https://faceai.regalamiunsorriso.it` | public base URL used for local links and return flow generation | -| `FACEAI_LEGACY_RETURN_URL` | yes | `https://www.regalamiunsorriso.it/faceai_return.php` | legacy endpoint that receives the signed FaceAI result handoff | -| `FACEAI_SHARED_SECRET` | yes | long random secret | shared signing secret between FaceAI and the legacy handoff/return bridge | -| `FACEAI_SESSION_COOKIE` | optional | `rus_faceai_session` | cookie name for the FaceAI session | -| `FACEAI_ENABLE_LOCAL_LEGACY_STATIC` | recommended | `0` | disables development-only static serving of local legacy assets | +### Production Compose Example -Do not enable `FACEAI_ENABLE_LOCAL_LEGACY_STATIC` in production. That mode exists only for local simulator flows. - -### Legacy-Side Configuration That Must Match - -The container will not work correctly in production unless the legacy bridge is configured consistently. - -The legacy site must: - -- redirect users into `FACEAI_FRONTEND_URL` with a valid signed handoff token -- use the same `FACEAI_SHARED_SECRET` as the FaceAI container -- expose the configured `FACEAI_LEGACY_RETURN_URL` -- validate the signed return token and fetch the result payload from FaceAI - -The shared secret is the trust boundary between the legacy site and FaceAI. Treat it like any other production secret and inject it through the platform secret store, not through source control. - -### Example Docker Compose For Production - -Replace the registry path and secret values with the real ones from Forgejo. +Replace the registry path, secrets, and host paths with the real deployment values. ```yaml services: @@ -147,35 +151,104 @@ services: environment: NODE_ENV: production PORT: 3001 - FACEAI_FRONTEND_URL: https://faceai.regalamiunsorriso.it - FACEAI_PUBLIC_BASE_URL: https://faceai.regalamiunsorriso.it + FACEAI_FRONTEND_URL: https://ai.regalamiunsorriso.it + FACEAI_PUBLIC_BASE_URL: https://ai.regalamiunsorriso.it FACEAI_LEGACY_RETURN_URL: https://www.regalamiunsorriso.it/faceai_return.php FACEAI_SHARED_SECRET: change-this-to-a-long-random-secret FACEAI_SESSION_COOKIE: rus_faceai_session + FACEAI_REDIS_URL: redis://redis:6379 + FACEAI_QUEUE_NAME: faceai-searches + FACEAI_RUNTIME_ROOT: /data/runtime + FACEAI_UPLOAD_ROOT: /data/runtime/uploads FACEAI_ENABLE_LOCAL_LEGACY_STATIC: 0 + volumes: + - faceai-runtime:/data/runtime ports: - "127.0.0.1:3001:3001" + depends_on: + - redis + + processor: + image: registry.example.com/my-namespace/faceai:latest + container_name: regalami-faceai-processor + restart: unless-stopped + command: npm run start:processor + environment: + NODE_ENV: production + FACEAI_REDIS_URL: redis://redis:6379 + FACEAI_QUEUE_NAME: faceai-searches + FACEAI_RUNTIME_ROOT: /data/runtime + FACEAI_PKL_ROOT: /data/pkl + FACEAI_MATCHER_BINARY: /opt/face-recognition/face_matcher + FACEAI_WORKER_CONCURRENCY: 2 + FACEAI_WORKER_TIMEOUT_MS: 300000 + volumes: + - faceai-runtime:/data/runtime + - /srv/faceai/pkl:/data/pkl:ro + - /srv/faceai/bin/Face_Recognition_Unix:/opt/face-recognition:ro + depends_on: + - redis + + redis: + image: redis:7-alpine + container_name: regalami-faceai-redis + restart: unless-stopped + command: redis-server --appendonly no + +volumes: + faceai-runtime: ``` -This pattern assumes a reverse proxy on the host publishes `https://faceai.regalamiunsorriso.it` and forwards to `127.0.0.1:3001`. +This pattern assumes a reverse proxy on the host publishes `https://ai.regalamiunsorriso.it` and forwards to `127.0.0.1:3001`. The processor is internal-only and does not expose any public port. -### Example Docker Run +### Required Runtime Configuration -```bash -docker run -d \ - --name regalami-faceai \ - --restart unless-stopped \ - -p 127.0.0.1:3001:3001 \ - -e NODE_ENV=production \ - -e PORT=3001 \ - -e FACEAI_FRONTEND_URL=https://faceai.regalamiunsorriso.it \ - -e FACEAI_PUBLIC_BASE_URL=https://faceai.regalamiunsorriso.it \ - -e FACEAI_LEGACY_RETURN_URL=https://www.regalamiunsorriso.it/faceai_return.php \ - -e FACEAI_SHARED_SECRET=change-this-to-a-long-random-secret \ - -e FACEAI_SESSION_COOKIE=rus_faceai_session \ - -e FACEAI_ENABLE_LOCAL_LEGACY_STATIC=0 \ - registry.example.com/my-namespace/faceai:latest -``` +Shared application settings: + +| Variable | Required | Example | Purpose | +| --- | --- | --- | --- | +| `NODE_ENV` | yes | `production` | disables development defaults | +| `FACEAI_REDIS_URL` | yes | `redis://redis:6379` | queue and search-state backend | +| `FACEAI_QUEUE_NAME` | optional | `faceai-searches` | BullMQ queue name | +| `FACEAI_RUNTIME_ROOT` | yes | `/data/runtime` | shared writable runtime root between site and processor | +| `FACEAI_SHARED_SECRET` | yes | long random secret | trust boundary between FaceAI and the legacy bridge | + +Public site settings: + +| Variable | Required | Example | Purpose | +| --- | --- | --- | --- | +| `PORT` | optional | `3001` | internal listen port | +| `FACEAI_FRONTEND_URL` | yes | `https://ai.regalamiunsorriso.it` | URL used when the legacy bridge redirects into the app | +| `FACEAI_PUBLIC_BASE_URL` | yes | `https://ai.regalamiunsorriso.it` | public base URL used for local links and return flow generation | +| `FACEAI_LEGACY_RETURN_URL` | yes | `https://www.regalamiunsorriso.it/faceai_return.php` | legacy endpoint that receives the signed FaceAI result handoff | +| `FACEAI_SESSION_COOKIE` | optional | `rus_faceai_session` | cookie name for the FaceAI session | +| `FACEAI_UPLOAD_ROOT` | optional | `/data/runtime/uploads` | upload directory inside the shared runtime volume | +| `FACEAI_ENABLE_LOCAL_LEGACY_STATIC` | recommended | `0` | disables development-only static serving of local legacy assets | + +Processor settings: + +| Variable | Required | Example | Purpose | +| --- | --- | --- | --- | +| `FACEAI_PKL_ROOT` | yes | `/data/pkl` | mounted race-to-PKL dataset root | +| `FACEAI_TEST_PKL_ROOT` | optional | `/data/pkl/test` | local-only fallback PKL location | +| `FACEAI_MATCHER_BINARY` | yes | `/opt/face-recognition/face_matcher` | matcher executable inside the processor container | +| `FACEAI_WORKER_CONCURRENCY` | optional | `2` | BullMQ worker concurrency | +| `FACEAI_WORKER_TIMEOUT_MS` | optional | `300000` | matcher timeout in milliseconds | + +Do not enable `FACEAI_ENABLE_LOCAL_LEGACY_STATIC` in production. That mode exists only for local simulator flows. + +### Legacy-Side Configuration That Must Match + +The deployment will not work correctly unless the legacy bridge is configured consistently. + +The legacy site must: + +- redirect users into `FACEAI_FRONTEND_URL` with a valid signed handoff token +- use the same `FACEAI_SHARED_SECRET` as the FaceAI deployment +- expose the configured `FACEAI_LEGACY_RETURN_URL` +- validate the signed return token and fetch the result payload from FaceAI + +The shared secret is the trust boundary between the legacy site and FaceAI. Treat it like any other production secret and inject it through the platform secret store, not through source control. ### Reverse Proxy Expectations @@ -188,25 +261,27 @@ The app should sit behind HTTPS. In practice that means: ### Post-Deploy Validation -After the container is up, validate at least the following: +After the Compose stack is up, validate at least the following: 1. `GET /health` returns `{"ok":true}` through the public FaceAI host. 2. The legacy handoff endpoint redirects to `https://faceai.../auth/callback?token=...`. 3. FaceAI can exchange the token and establish a session. -4. Completing a search produces a redirect URL that points to `FACEAI_LEGACY_RETURN_URL`. -5. The legacy return endpoint can resolve the signed result and render the filtered race page. +4. A search is enqueued in Redis and picked up by the processor. +5. Completing a search produces a redirect URL that points to `FACEAI_LEGACY_RETURN_URL`. +6. The legacy return endpoint can resolve the signed result and render the filtered race page. ### Current Production Limitations -This image can be published and deployed, but the current scaffold still has important limitations: +This scaffold can now be deployed with the public site, processor, and Redis, but it still has important limitations: -- sessions and search results are stored only in memory, so container restarts lose state -- there is no real queue or processor yet -- there is no persistent storage layer yet +- search state is short-lived in Redis and is not backed by a durable database +- runtime uploads and matcher output still need an agreed production retention and cleanup policy +- the final production PKL/NAS layout is not yet locked down - the backend currently sets the FaceAI session cookie with `secure: false`, which should be hardened before final public rollout - the local simulator endpoints under `/dev/*` are still present in the app and should be treated as non-production scaffolding +- the processor CSV parser is still based on the current scaffolded matcher output assumptions -So the registry deployment is appropriate for early hosted integration and controlled production-like rollout, but not yet for the final hardened architecture described in the integration plan +So the Compose deployment is appropriate for hosted integration and controlled production-like rollout, but not yet for the final hardened architecture described in the integration plan. ## Environment @@ -219,6 +294,13 @@ 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 +FACEAI_REDIS_URL=redis://redis:6379 +FACEAI_QUEUE_NAME=faceai-searches +FACEAI_RUNTIME_ROOT=/data/runtime +FACEAI_UPLOAD_ROOT=/data/runtime/uploads +FACEAI_PKL_ROOT=/data/pkl +FACEAI_TEST_PKL_ROOT=/data/pkl/test +FACEAI_MATCHER_BINARY=/opt/face-recognition/face_matcher ``` 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. @@ -231,8 +313,8 @@ 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. +- Search orchestration now uses Redis and a dedicated processor worker. +- The checked-in Compose file is meant for local integration testing, not as-is production use. - 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. diff --git a/faceai/docker/Dockerfile b/faceai/docker/Dockerfile index 57879377..12248770 100644 --- a/faceai/docker/Dockerfile +++ b/faceai/docker/Dockerfile @@ -5,6 +5,7 @@ WORKDIR /app COPY package.json ./ COPY apps/frontend/package.json apps/frontend/package.json COPY apps/backend/package.json apps/backend/package.json +COPY apps/processor/package.json apps/processor/package.json RUN npm install diff --git a/sync/www-deploy-manifest.md b/sync/www-deploy-manifest.md index b5e9090d..696c5505 100644 --- a/sync/www-deploy-manifest.md +++ b/sync/www-deploy-manifest.md @@ -1,117 +1,47 @@ # WWW Deployment Manifest -This document lists the files under `www/` that changed after the initial `www` import baseline and should be copied to the remote staging path: +This document lists the files under `www/` in the current FaceAI feature-flag rollout that should be copied to the remote staging path: `/home/marco/regalamiunsorriso/incoming/www` -## Baseline Used +## Deployment Set -- Excluded the initial `www` import history by using commit `cc69770608bd0f1c32eeac01e16042f4e8a47012` (`First commit`) as the baseline. -- Included committed changes after that baseline up to `HEAD`. -- Included the current uncommitted workspace change in `www/controlCode.jsp`. +All files in this rollout are deployed from the current working tree. ## New Files -- `www/faceai_config.php` -- `www/faceai_handoff.php` -- `www/faceai_return.php` -- `www/faceai_simulator.php` -- `www/faceai_simulator_view.php` +- None in this rollout. ## Updated Files -- `www/_inc_footer.jsp` - `www/_js/rus-ecom-240621.js` -- `www/associazione-en.jsp` -- `www/associazione.jsp` -- `www/atleticaImmagine_chiSiamo-en.jsp` -- `www/atleticaImmagine_chiSiamo.jsp` -- `www/controlCode-en.jsp` -- `www/controlCode.jsp` -- `www/includes/inc-header.php` -- `www/lostPwd.jsp` -- `www/mailMessage/noMorePic.html` -- `www/mailMessage/noMorePic.txt` -- `www/mailMessage/noMorePicCc.html` -- `www/mailMessage/noMorePicScad.html` -- `www/mailMessage/noMorePicScad.txt` -- `www/mailMessage/perScadereMsg.html` -- `www/mailMessage/userMsg_it.html` -- `www/mailMessage/userMsg_itCC.html` -- `www/newsCR-en.jsp` -- `www/newsCR.jsp` -- `www/pg/controlCode.jsp` -- `www/pg/logon.jsp` -- `www/pg/registra.jsp` -- `www/users-en.jsp` -- `www/users.jsp` - -## Local Workspace-Only Change Included - -- `www/controlCode.jsp` currently has an uncommitted local modification and should be deployed from the working tree version, not just from `HEAD`. +- `www/faceai_config.php` +- `www/faceai_handoff.php` +- `www/faceai_simulator_view.php` +- `www/fotoCR-en.jsp` +- `www/fotoCR.jsp` ## Remote Copy Target - Source root: `K:\various\regalamiunsorriso` - Remote host: `marco@83.149.164.4:410` -- Remote path: `/home/marco/regalamiunsorriso/incoming/www` -- Total files in this manifest: `30` +- Remote staging path: `/home/marco/regalamiunsorriso/incoming/www` +- Remote live path: `/home/sites/regalamiunsorriso/www` +- Total files in this manifest: `6` -## Transfer Notes +## Transfer Method -- Transfer completed by streaming a tar archive over SSH and extracting it into `/home/marco/regalamiunsorriso/incoming` so the `www/...` directory structure was preserved. -- Representative remote verification succeeded for new files and updated files, including `www/faceai_config.php`, `www/faceai_handoff.php`, `www/faceai_return.php`, `www/faceai_simulator.php`, `www/faceai_simulator_view.php`, `www/controlCode.jsp`, `www/_js/rus-ecom-240621.js`, `www/includes/inc-header.php`, and `www/pg/logon.jsp`. -- `www/controlCode.jsp` was uploaded from the local working tree, which includes an uncommitted change. +- Stage by streaming a tar archive over SSH and extracting it into `/home/marco/regalamiunsorriso/incoming` so the `www/...` directory structure is preserved. +- Promote with `/home/marco/promote-file.sh` through `sudo tcsh` so the live destination keeps its required owner, group, and mode. -## Issues Encountered +## Verification Expectations -- The remote login shell behaves as `tcsh`, so POSIX shell loops like `for ...; do ...; done` fail unless they are explicitly run through `sh -c`. -- The server `sh` does not accept the `-l` option, so verification commands must use `sh -c`, not `sh -lc`. -- The direct SSH and tar-based copy path works; the MCP SSH tools were not used for this transfer because they were previously failing authentication or transport checks. +- Verify staged files with `ls -l` and `cksum`. +- Verify live files with `ls -l`, `stat -f`, and `cksum`. +- Existing destination files should retain their original metadata after promotion. -## Single-File Live Promotion Test +## Known Shell Quirks -- Tested file: `www/associazione-en.jsp` -- Staged source: `/home/marco/regalamiunsorriso/incoming/www/associazione-en.jsp` -- Live destination: `/home/sites/regalamiunsorriso/www/associazione-en.jsp` -- Original live metadata before copy: owner `jenkins`, group `www`, mode `100644`, size `6289` -- Live metadata after copy: owner `jenkins`, group `www`, mode `100644`, size `6139` -- Content verification after copy succeeded: `cksum` matched for staged and live files. - -## Promotion Script - -- Local template: `sync/promote-file.sh` -- Remote installed script: `/home/marco/promote-file.sh` -- Purpose: copy one source file to one destination file, then restore the destination file owner, group, and mode from the original live file. -- Supports an optional third argument: a metadata source file to use when the destination file does not exist yet and the target directory has mixed permission patterns. - -### Command That Worked - -```powershell -ssh -tt -i C:\Users\Maddo\.ssh\id_rsa -p 410 marco@83.149.164.4 "sudo tcsh -c '/home/marco/promote-file.sh /home/marco/regalamiunsorriso/incoming/www/associazione-en.jsp /home/sites/regalamiunsorriso/www/associazione-en.jsp'" -``` - -## Additional Problems Found During Live Promotion - -- Uploading a multi-line script inline from PowerShell was unreliable in the local terminal because the prompt layer interfered with the here-string before SSH execution. Using a normal local file plus `scp` worked cleanly. -- The live-site verification command was interrupted once when run in parallel with the promotion command. Re-running verification separately avoided that issue. -- Promotion to `/home/sites/regalamiunsorriso/www` must run through `sudo tcsh`; copying as `marco` alone is not sufficient for the live path and would not preserve the required live ownership. -- Root-level PHP files on the live site do not have a single uniform mode. For example, `_inc_footer.php` and `gallery1.php` are `775`, while `test.php` is `644`. The promotion helper was extended to accept an explicit metadata source so new files can follow a chosen live pattern instead of relying on the first sibling match. - -## Recommended Replication Procedure - -1. Stage the file under `/home/marco/regalamiunsorriso/incoming/www/...`. -2. Inspect the live destination metadata before changing anything. -3. Run `/home/marco/promote-file.sh [metadata-source]` through `sudo tcsh` in an SSH session opened with `-tt`. -4. Verify the live file with `ls -l`, `stat -f`, and `cksum` against the staged source. - -## Full Live Promotion Result - -- After the single-file test with `www/associazione-en.jsp`, the remaining `29` files in the manifest were promoted successfully to `/home/sites/regalamiunsorriso/www`. -- Existing destination files kept their original live owner, group, and mode. -- The new `faceai_*.php` files were created as `jenkins:www` with mode `775`, using `/home/sites/regalamiunsorriso/www/_inc_footer.php` as the explicit metadata source. -- Representative content verification succeeded with matching `cksum` values for: - - `www/faceai_config.php` - - `www/controlCode.jsp` - - `www/pg/logon.jsp` -- Representative metadata verification succeeded for updated files in root, `pg`, `includes`, and `mailMessage` directories. \ No newline at end of file +- The remote login shell behaves as `tcsh`, so POSIX shell loops fail unless run through `sh -c`. +- The server `sh` does not support `-l`, so use `sh -c`, not `sh -lc`. +- Direct SSH plus tar works reliably on this host; MCP SSH was previously unreliable and is avoided. \ No newline at end of file diff --git a/www/_js/rus-ecom-240621.js b/www/_js/rus-ecom-240621.js index 5e82edf8..b217394d 100644 --- a/www/_js/rus-ecom-240621.js +++ b/www/_js/rus-ecom-240621.js @@ -114,6 +114,79 @@ function getCurrentLangValue() { return $("html").attr("lang") || "it"; } +function faceAiFeatureEnabled() { + var config = window.faceAiConfig || {}; + var simulatorConfig = window.faceAiSimulator || {}; + var value = typeof config.enabled !== "undefined" ? config.enabled : simulatorConfig.enabled; + + if (typeof value === "string") { + value = value.toLowerCase(); + return value === "1" || value === "true" || value === "yes" || value === "on"; + } + + return value === true; +} + +function faceAiEscapeHtml(value) { + return String(value || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function getFaceAiErrorState() { + if (typeof URLSearchParams === "undefined") { + return null; + } + + var params = new URLSearchParams(window.location.search || ""); + if (params.get("faceaiError") !== "1") { + return null; + } + + return { + title: params.get("faceaiErrorTitle") || "Face ID non disponibile", + message: params.get("faceaiErrorMessage") || "Il servizio Face ID non e al momento disponibile. Riprova piu tardi." + }; +} + +function clearFaceAiErrorState() { + if (!window.history || !window.history.replaceState || typeof URL === "undefined") { + return; + } + + var cleanUrl = new URL(window.location.href); + cleanUrl.searchParams.delete("faceaiError"); + cleanUrl.searchParams.delete("faceaiErrorTitle"); + cleanUrl.searchParams.delete("faceaiErrorMessage"); + window.history.replaceState({}, document.title, cleanUrl.pathname + cleanUrl.search + cleanUrl.hash); +} + +function showFaceAiErrorModal(title, message) { + var modal = $("#faceAiErrorModal"); + + if (!modal.length) { + $("body").append(''); + modal = $("#faceAiErrorModal"); + } + + $("#faceAiErrorModalLabel").html(faceAiEscapeHtml(title)); + $("#faceAiErrorModalMessage").html(faceAiEscapeHtml(message)); + modal.modal("show"); +} + +function initFaceAiErrorModal() { + var errorState = getFaceAiErrorState(); + if (!errorState) { + return; + } + + showFaceAiErrorModal(errorState.title, errorState.message); + clearFaceAiErrorState(); +} + function buildFaceAiLaunchUrl() { var raceId = $("#id_gara").val() || ""; var raceSlug = $("#garaDesc").val() || ""; @@ -153,7 +226,7 @@ function launchFaceAi() { function initFaceAiRaceSearchButton() { var select = $("#tipoPuntoFoto"); - if (!select.length || $("#faceaiLaunchButton").length) { + if (!select.length || $("#faceaiLaunchButton").length || !faceAiFeatureEnabled()) { return; } @@ -377,6 +450,7 @@ function goPage() $(function() { initFaceAiRaceSearchButton(); + initFaceAiErrorModal(); }); diff --git a/www/faceai_config.php b/www/faceai_config.php index c5ad0f98..5c01cea3 100644 --- a/www/faceai_config.php +++ b/www/faceai_config.php @@ -6,6 +6,69 @@ function faceai_env($key, $default = null) return $value === false ? $default : $value; } +function faceai_env_flag($key, $default = false) +{ + $value = strtolower(trim((string) faceai_env($key, $default ? '1' : '0'))); + return in_array($value, array('1', 'true', 'yes', 'on'), true); +} + +function faceai_request_host() +{ + if (empty($_SERVER['HTTP_HOST'])) { + return ''; + } + + return strtolower(trim((string) $_SERVER['HTTP_HOST'])); +} + +function faceai_is_local_host($host) +{ + $normalized = strtolower(trim((string) $host)); + if ($normalized === '') { + return false; + } + + $withoutPort = preg_replace('/:\d+$/', '', $normalized); + return in_array($withoutPort, array('localhost', '127.0.0.1', '::1'), true); +} + +function faceai_request_targets_local_frontend() +{ + if (faceai_is_local_host(faceai_request_host())) { + return true; + } + + $returnUrl = faceai_request_value('returnUrl'); + if ($returnUrl === '') { + return false; + } + + $host = parse_url($returnUrl, PHP_URL_HOST); + if (!is_string($host) || $host === '') { + return false; + } + + return faceai_is_local_host($host); +} + +function faceai_default_frontend_url() +{ + if (faceai_request_targets_local_frontend()) { + return 'http://localhost:3001'; + } + + return 'https://ai.regalamiunsorriso.it'; +} + +function faceai_default_backend_internal_url() +{ + if (faceai_is_local_host(faceai_request_host())) { + return 'http://localhost:3001'; + } + + return 'https://ai.regalamiunsorriso.it'; +} + function faceai_config() { static $config = null; @@ -15,10 +78,11 @@ function faceai_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'), '/'), + 'feature_enabled' => faceai_env_flag('FACEAI_FEATURE_ENABLED', false), + 'frontend_url' => rtrim(faceai_env('FACEAI_FRONTEND_URL', faceai_default_frontend_url()), '/'), + 'backend_internal_url' => rtrim(faceai_env('FACEAI_BACKEND_INTERNAL_URL', faceai_default_backend_internal_url()), '/'), 'shared_secret' => (string) faceai_env('FACEAI_SHARED_SECRET', 'change-me'), - 'allow_dev_handoff' => faceai_env('FACEAI_ALLOW_DEV_HANDOFF', '1') === '1', + 'allow_dev_handoff' => faceai_env_flag('FACEAI_ALLOW_DEV_HANDOFF', true), 'identity_cookie' => (string) faceai_env('FACEAI_IDENTITY_COOKIE', 'rus_faceai_identity'), 'return_forward_url' => rtrim((string) faceai_env('FACEAI_RETURN_FORWARD_URL', ''), '/') ); @@ -80,6 +144,22 @@ function faceai_build_url($baseUrl, array $params) return $baseUrl . (strpos($baseUrl, '?') === false ? '?' : '&') . http_build_query($params); } +function faceai_redirect_with_error($returnUrl, $message, $title = 'Face ID non disponibile') +{ + if (is_string($returnUrl) && trim($returnUrl) !== '') { + header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); + header('Pragma: no-cache'); + header('Location: ' . faceai_build_url($returnUrl, array( + 'faceaiError' => '1', + 'faceaiErrorTitle' => $title, + 'faceaiErrorMessage' => $message + )), true, 302); + exit; + } + + faceai_render_message_page($title, $message, array(), 503); +} + function faceai_request_value($key, $default = '') { if (!isset($_GET[$key])) { diff --git a/www/faceai_handoff.php b/www/faceai_handoff.php index 73cbe10e..8d6231e8 100644 --- a/www/faceai_handoff.php +++ b/www/faceai_handoff.php @@ -11,6 +11,10 @@ try { $lang = faceai_request_value('lang', 'it'); $returnUrl = faceai_request_value('returnUrl'); + if (empty($config['feature_enabled'])) { + faceai_redirect_with_error($returnUrl, 'La ricerca Face ID non e ancora disponibile.'); + } + if ($raceId === '' || $returnUrl === '') { faceai_render_message_page( 'FaceAI handoff non disponibile', @@ -25,25 +29,11 @@ try { $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 - ); + faceai_redirect_with_error($returnUrl, 'Il servizio Face ID non e al momento disponibile. Riprova piu tardi.'); } 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 - ); + faceai_redirect_with_error($returnUrl, 'Il tuo account non e abilitato all uso di Face ID.'); } $payload = array( @@ -72,5 +62,5 @@ try { header('Location: ' . $targetUrl, true, 302); exit; } catch (Throwable $error) { - faceai_render_message_page('Errore handoff FaceAI', $error->getMessage(), array(), 500); + faceai_redirect_with_error(isset($returnUrl) ? $returnUrl : '', 'Il servizio Face ID non e al momento disponibile. Riprova piu tardi.'); } diff --git a/www/faceai_simulator_view.php b/www/faceai_simulator_view.php index 8ebccf3d..473de53d 100644 --- a/www/faceai_simulator_view.php +++ b/www/faceai_simulator_view.php @@ -166,6 +166,7 @@ function faceai_sim_render_page(array $options) + +