Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
webapp/node_modules
webapp/dist
data
face-processing/models
*.zip
*.log
__pycache__
Expand Down
34 changes: 34 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
POSTGRES_PASSWORD=replace-with-a-long-random-password
JWT_SECRET=replace-with-at-least-32-random-bytes
JWT_ISSUER=fdx
JWT_AUDIENCE=fdx-web
ACCESS_TOKEN_MINUTES=15
REFRESH_TOKEN_DAYS=7
INVITATION_TOKEN_HOURS=72
PASSWORD_RESET_MINUTES=30
ENROLLMENT_TOKEN_DAYS=7
GALLERY_TOKEN_DAYS=7
FDX_SUPER_ADMIN_EMAIL=superadmin@fdx.io
FDX_SUPER_ADMIN_PASSWORD=replace-with-a-strong-bootstrap-password
FDX_WEB_PORT=8080
Expand All @@ -15,8 +23,34 @@ FDX_ENVIRONMENT=development
EMAIL_PROVIDER=outbox
EMAIL_FROM=FDX <noreply@example.com>
RESEND_API_KEY=
EMAIL_WEBHOOK_SECRET=

# Object storage: local or s3
STORAGE_BACKEND=local
S3_BUCKET=
AWS_REGION=ap-south-1

# ML matching policy and model traceability
FDX_DETECTOR_MODEL_VERSION=retinaface-r50-v1
FDX_EMBEDDER_MODEL_VERSION=adaface-ir101-ms1mv2-v1
MATCH_AUTO_THRESHOLD=0.85
MATCH_REVIEW_THRESHOLD=0.65
MATCH_RUNNER_UP_MARGIN=0.08
MINIMUM_FACE_SIZE=40
LOW_RESOLUTION_FACE_SIZE=80
MINIMUM_DETECTOR_CONFIDENCE=0.60
LOW_RESOLUTION_THRESHOLD_BOOST=0.05
THRESHOLD_PROFILE_VERSION=default-v1
FDX_DEVICE=cpu

# Workflow policy
CONSENT_POLICY_VERSION=2026-08-13
UPLOAD_RESERVATION_MINUTES=60
MAX_UPLOAD_BYTES=107374182400
MAX_MEDIA_FILE_BYTES=104857600
MAX_ENROLLMENT_BYTES=15728640
MAX_IMAGE_PIXELS=100000000
MULTIPART_THRESHOLD_BYTES=20971520
MULTIPART_PART_BYTES=8388608
RETENTION_SCHEDULER_ENABLED=true
RETENTION_POLL_SECONDS=60
84 changes: 84 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
name: FDX CI

on:
pull_request:
push:
branches: [main, feature/scale]

permissions:
contents: read

jobs:
frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: webapp
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: webapp/package-lock.json
- run: npm ci
- run: npm run format:check
- run: npm run lint
- run: npm run build
- run: npm audit --audit-level=high

backend:
runs-on: ubuntu-latest
services:
postgres:
image: pgvector/pgvector:0.8.6-pg17
env:
POSTGRES_DB: fdx
POSTGRES_USER: fdx
POSTGRES_PASSWORD: fdx-ci
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U fdx -d fdx"
--health-interval 5s
--health-timeout 5s
--health-retries 20
env:
DATABASE_URL: postgresql+psycopg://fdx:fdx-ci@127.0.0.1:5432/fdx
FDX_ENVIRONMENT: test
PYTHONPATH: backend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: backend/requirements.txt
- run: pip install -r backend/requirements.txt ruff pytest pip-audit
- run: ruff check backend
- run: ruff format --check backend
- run: python -m compileall -q backend/app backend/alembic
- run: alembic -c backend/alembic.ini upgrade head
- run: pytest -q backend/tests
- run: pip-audit -r backend/requirements.txt

infrastructure:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install cfn-lint
- run: docker compose config --quiet
- run: cfn-lint deploy/aws/platform.yml
- run: sudo apt-get update && sudo apt-get install -y shellcheck
- run: find . -path './.venv' -prune -o -name '*.sh' -type f -print0 | xargs -0 shellcheck
- run: bash -n run-platform.sh stop-platform.sh
- run: sh -n deploy/aws/publish.sh tools/verify_models.sh backend/entrypoint.sh

images:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker build -f backend/Dockerfile -t fdx-api:ci .
- run: docker build -f webapp/Dockerfile -t fdx-web:ci .
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
models/
/face-processing/models/detection/*.onnx
/face-processing/models/recognition/*.onnx
.venv/
.env
data/
Expand Down
32 changes: 26 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,25 +1,29 @@
# FDX

FDX is a multi-tenant event-photo delivery platform implementing the workflow in `docs/workflow.txt`. A single JWT login routes Super Admins, Organization Admins, and restricted Staff users to role-scoped React dashboards.
FDX is a multi-tenant event-photo delivery platform implementing the product workflow in [`docs/workflow.md`](docs/workflow.md) and the V2 technical contract in [`docs/specs.md`](docs/specs.md). A single JWT login routes Super Admins, Organization Admins, and restricted Staff users to role-scoped React dashboards. The implementation map is maintained in [`docs/spec-implementation.md`](docs/spec-implementation.md).

## Architecture

- `webapp/` — React and Vite dashboards plus participant enrollment and private gallery pages.
- `backend/` — FastAPI API, PostgreSQL models/Alembic migrations, authentication, storage, email, retention, and Kafka worker.
- `face-processing/ml/` — Gunicorn-hosted RetinaFace R50 and AdaFace IR101 service.
- `face-processing/service/` — Gunicorn-hosted face-processing inference service.
- `face-processing/models/detection/` — RetinaFace face-detection weights.
- `face-processing/models/recognition/` — AdaFace face-recognition weights.
- `deploy/nginx/` — frontend hosting, reverse proxy, upload limits, and API rate limiting.
- `deploy/aws/` — production CloudFormation and publishing workflow.
- `tools/` — model integrity and end-to-end platform verification.

PostgreSQL is the source of truth, Redis provides login rate limiting and health caching, Kafka distributes processing jobs, and the worker retains a PostgreSQL fallback queue. Development media uses a Docker volume; production media uses private S3 storage with generated thumbnails.

The stable dashboard remains compatible with the original `/api` contract while security-sensitive and high-scale workflows use the additive `/api/v2` contract: rotating refresh sessions, import preview/confirmation, presigned upload batches, processing/review, delivery, and asynchronous gallery exports.

## Required models

Place these files under `models/onnx/`:
Place each ONNX model under the directory matching its role:

```text
models/onnx/retinaface-r50.onnx
models/onnx/adaface-ir101-ms1mv2.onnx
face-processing/models/detection/retinaface-r50.onnx
face-processing/models/recognition/adaface-ir101-ms1mv2.onnx
```

Verify them with:
Expand Down Expand Up @@ -67,12 +71,20 @@ node tools/verify_platform.mjs
To include the real ML enrollment/matching/gallery path:

```sh
FDX_VERIFY_FACE_IMAGE=face-processing/ml/assets/warmup/einstein.jpeg \
FDX_VERIFY_FACE_IMAGE=face-processing/service/assets/warmup/einstein.jpeg \
node tools/verify_platform.mjs
```

Set `FDX_VERIFY_XLS=/path/to/participants.xls` to include legacy Excel verification.

Run the V2 acceptance flow (refresh replay prevention, invitations, tenant isolation, import idempotency, direct upload, real ML, private gallery, async ZIP export, and logout revocation):

```sh
FDX_VERIFY_FACE_IMAGE=/path/to/clear-face.jpg node tools/verify_v2.mjs
```

API service metrics are available at `GET /metrics`; dependency probes are exposed at `/health/live`, `/health/ready`, and `/health/dependencies`.

Frontend checks:

```sh
Expand All @@ -86,4 +98,12 @@ npm run build

`deploy/aws/platform.yml` provisions the production baseline: VPC, HTTPS ALB, EC2 Auto Scaling, RDS PostgreSQL, ElastiCache Redis, MSK Kafka, ECR, S3/Glacier, SES/IAM, Secrets Manager, SSM, and scheduled Lambda retention.

For an NVIDIA worker host with NVIDIA Container Toolkit installed, build the dedicated CUDA image and set `ML_IMAGE` and `FDX_DEVICE=cuda` in the production environment:

```sh
docker build -f face-processing/service/Dockerfile.gpu -t fdx-ml:gpu .
```

The regular ML Dockerfile remains the CPU image; both variants serve inference through Gunicorn.

See [`deploy/aws/README.md`](deploy/aws/README.md) for deployment and image publishing commands.
4 changes: 2 additions & 2 deletions backend/alembic.ini
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[alembic]
script_location = /app/alembic
prepend_sys_path = /app
script_location = %(here)s/alembic
prepend_sys_path = %(here)s
sqlalchemy.url = postgresql+psycopg://fdx:fdx@postgres:5432/fdx

[loggers]
Expand Down
13 changes: 11 additions & 2 deletions backend/alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,22 @@


def run_migrations_offline():
context.configure(url=settings.database_url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"})
context.configure(
url=settings.database_url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()


def run_migrations_online():
connectable = engine_from_config(config.get_section(config.config_ini_section), prefix="sqlalchemy.", poolclass=pool.NullPool)
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
Expand Down
35 changes: 29 additions & 6 deletions backend/alembic/versions/20260812_01_complete_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,43 @@ def upgrade() -> None:
op.execute("ALTER TYPE userrole ADD VALUE IF NOT EXISTS 'STAFF'")
photo_columns = {column["name"] for column in inspector.get_columns("photos")}
if "thumbnail_storage_key" not in photo_columns:
op.add_column("photos", sa.Column("thumbnail_storage_key", sa.String(length=500), nullable=True))
op.add_column(
"photos",
sa.Column("thumbnail_storage_key", sa.String(length=500), nullable=True),
)
if "thumbnail_size_bytes" not in photo_columns:
op.add_column("photos", sa.Column("thumbnail_size_bytes", sa.BigInteger(), nullable=False, server_default="0"))
op.add_column(
"photos",
sa.Column(
"thumbnail_size_bytes",
sa.BigInteger(),
nullable=False,
server_default="0",
),
)
enrollment_columns = {column["name"] for column in inspector.get_columns("face_enrollments")}
if "size_bytes" not in enrollment_columns:
op.add_column("face_enrollments", sa.Column("size_bytes", sa.BigInteger(), nullable=False, server_default="0"))
op.add_column(
"face_enrollments",
sa.Column("size_bytes", sa.BigInteger(), nullable=False, server_default="0"),
)
email_columns = {column["name"] for column in inspector.get_columns("email_outbox")}
if "attempts" not in email_columns:
op.add_column("email_outbox", sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"))
op.add_column(
"email_outbox",
sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"),
)
if "next_attempt_at" not in email_columns:
op.add_column("email_outbox", sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=True))
op.add_column(
"email_outbox",
sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_email_outbox_next_attempt_at", "email_outbox", ["next_attempt_at"])
if "last_attempt_at" not in email_columns:
op.add_column("email_outbox", sa.Column("last_attempt_at", sa.DateTime(timezone=True), nullable=True))
op.add_column(
"email_outbox",
sa.Column("last_attempt_at", sa.DateTime(timezone=True), nullable=True),
)


def downgrade() -> None:
Expand Down
14 changes: 12 additions & 2 deletions backend/alembic/versions/20260812_02_email_delivery_link.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,18 @@ def upgrade() -> None:
inspector = inspect(op.get_bind())
columns = {column["name"] for column in inspector.get_columns("email_outbox")}
if "delivery_id" not in columns:
op.add_column("email_outbox", sa.Column("delivery_id", sa.String(length=36), nullable=True))
op.create_foreign_key("fk_email_outbox_delivery_id", "email_outbox", "deliveries", ["delivery_id"], ["id"], ondelete="SET NULL")
op.add_column(
"email_outbox",
sa.Column("delivery_id", sa.String(length=36), nullable=True),
)
op.create_foreign_key(
"fk_email_outbox_delivery_id",
"email_outbox",
"deliveries",
["delivery_id"],
["id"],
ondelete="SET NULL",
)
op.create_index("ix_email_outbox_delivery_id", "email_outbox", ["delivery_id"])


Expand Down
Loading
Loading