From 4b2fefcf921fb918847944f360d16a5cdd5ab644 Mon Sep 17 00:00:00 2001 From: Roberto Musso Date: Sat, 11 Apr 2026 10:35:53 +0200 Subject: [PATCH] Initial commit: waitlist microservice --- .env.example | 11 ++ .gitea/workflows/deploy.yaml | 97 ++++++++++++++++ .gitignore | 31 +++++ Dockerfile | 19 +++ README.md | 69 +++++++++++ alembic.ini | 36 ++++++ alembic/env.py | 46 ++++++++ alembic/script.py.mako | 25 ++++ .../versions/001_create_waitlist_entries.py | 36 ++++++ app/__init__.py | 0 app/config.py | 21 ++++ app/db.py | 17 +++ app/main.py | 59 ++++++++++ app/models.py | 28 +++++ app/rate_limit.py | 76 ++++++++++++ app/routes.py | 48 ++++++++ app/schemas.py | 12 ++ app/security.py | 59 ++++++++++ docker-compose.yml | 32 +++++ requirements.txt | 10 ++ tests/__init__.py | 0 tests/test_waitlist.py | 109 ++++++++++++++++++ 22 files changed, 841 insertions(+) create mode 100644 .env.example create mode 100644 .gitea/workflows/deploy.yaml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 alembic.ini create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/001_create_waitlist_entries.py create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/db.py create mode 100644 app/main.py create mode 100644 app/models.py create mode 100644 app/rate_limit.py create mode 100644 app/routes.py create mode 100644 app/schemas.py create mode 100644 app/security.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 tests/__init__.py create mode 100644 tests/test_waitlist.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6f72742 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Database +DATABASE_URL=postgresql+asyncpg://waitlist:changeme@localhost:5432/waitlist_db + +# CORS — comma-separated allowed origins +ALLOWED_ORIGINS=https://adiuvai.com,https://www.adiuvai.com + +# Rate limiting +RATE_LIMIT_PER_MINUTE=5 + +# Set to "production" in prod to enforce strict origin checks +ENVIRONMENT=development diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml new file mode 100644 index 0000000..c2762bf --- /dev/null +++ b/.gitea/workflows/deploy.yaml @@ -0,0 +1,97 @@ +name: Test & Deploy Waitlist +run-name: ${{ gitea.ref_name }} → Docker LXC + +on: + push: + tags: + - 'v*' + +jobs: + # ── 1. Run tests in an isolated Python container ────────────────── + test: + runs-on: ubuntu-latest + container: + image: python:3.12-slim + + steps: + - name: Install git + run: apt-get update && apt-get install -y --no-install-recommends git + + - name: Checkout Code + run: | + git clone --depth 1 --branch "${GITHUB_REF_NAME}" \ + "http://10.0.0.119:3000/${GITHUB_REPOSITORY}.git" . || \ + git clone --depth 1 "http://10.0.0.119:3000/${GITHUB_REPOSITORY}.git" . && \ + git checkout "${GITHUB_SHA}" + + - name: Install Dependencies + run: pip install --no-cache-dir -r requirements.txt + + - name: Install Test Dependencies + run: pip install --no-cache-dir pytest pytest-asyncio httpx aiosqlite + + - name: Run Linter + run: ruff check app/ tests/ + + - name: Run Tests + run: pytest tests/ -v --tb=short + + # ── 2. Deploy to Docker LXC via SSH ───────────────────────────────── + deploy: + needs: test + runs-on: ubuntu-latest + if: gitea.event_name == 'push' + + steps: + - name: Deploy via SSH + uses: appleboy/ssh-action@v1.0.0 + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USER }} + key: ${{ secrets.SSH_KEY }} + script: | + set -e + DEPLOY_DIR="/opt/adiuvai-waitlist" + REPO_URL="http://10.0.0.119:3000/${{ gitea.repository }}.git" + TAG="${{ gitea.ref_name }}" + + # ── Pull latest code ── + cd /tmp && rm -rf adiuvai-waitlist-deploy + git clone --depth 1 --branch "${TAG}" "${REPO_URL}" adiuvai-waitlist-deploy + + # ── Sync source (preserve .env) ── + cp -rf /tmp/adiuvai-waitlist-deploy/app/ \ + /tmp/adiuvai-waitlist-deploy/alembic/ \ + /tmp/adiuvai-waitlist-deploy/alembic.ini \ + /tmp/adiuvai-waitlist-deploy/Dockerfile \ + /tmp/adiuvai-waitlist-deploy/docker-compose.yml \ + /tmp/adiuvai-waitlist-deploy/requirements.txt \ + "$DEPLOY_DIR/" + rm -rf /tmp/adiuvai-waitlist-deploy + + # ── Verify .env ── + if [ ! -f "$DEPLOY_DIR/.env" ]; then + echo "❌ $DEPLOY_DIR/.env not found. Create it before deploying." + exit 1 + fi + + # ── Build & restart (app only — keep DB running) ── + cd "$DEPLOY_DIR" + docker compose up -d --build --no-deps db # ensure DB is running + docker compose build app # rebuild app image + docker compose up -d --no-deps app # restart only app container + + # ── Migrations ── + docker compose exec -T app alembic upgrade head + + # ── Health check ── + echo "Waiting for app..." + sleep 5 + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8001/health) + if [ "$HTTP_CODE" -eq 200 ]; then + echo "✅ Waitlist service is healthy (HTTP ${HTTP_CODE})" + else + echo "❌ Health check failed (HTTP ${HTTP_CODE})" + docker compose logs app --tail=50 + exit 1 + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f67a9dc --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ + +# Virtual environment +.venv/ +venv/ +env/ + +# Environment variables +.env + +# IDE +.vscode/ +.idea/ + +# Testing / coverage +.pytest_cache/ +htmlcov/ +.coverage +test_waitlist.db + +# Docker +*.log + +# OS +.DS_Store +Thumbs.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3d7302b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Install only psycopg2 build deps (needed for alembic sync driver) +RUN apt-get update && \ + apt-get install -y --no-install-recommends libpq-dev gcc && \ + rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt psycopg2-binary + +COPY alembic.ini . +COPY alembic/ alembic/ +COPY app/ app/ + +EXPOSE 8001 + +CMD ["sh", "-c", "alembic upgrade head && gunicorn app.main:app -k uvicorn.workers.UvicornWorker -w 2 -b 0.0.0.0:8001 --timeout 30"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..2bb8305 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# adiuvAI Waitlist Service + +Minimal FastAPI microservice that stores waitlist email signups in PostgreSQL. + +## Security + +Designed to sit behind **Cloudflare** (WAF + DDoS protection). Additional hardening: + +| Layer | What | +|-------|------| +| **Cloudflare** | WAF, bot management, DDoS mitigation (external) | +| **Rate limiter** | 5 req/min per IP, Cloudflare-aware (`CF-Connecting-IP`) | +| **Origin validation** | Rejects POST without valid `Origin`/`Referer` in production | +| **CORS** | Locked to `adiuvai.com` origins only | +| **Honeypot field** | Hidden `website` field — bots that fill it get a silent 200 | +| **Request size limit** | 4 KB max body (email payload is ~100 bytes) | +| **Input validation** | Pydantic `EmailStr` with normalization | +| **SQL injection** | SQLAlchemy parameterized queries (no raw SQL) | +| **No PII leakage** | Errors return generic messages, no email reflection | +| **Docs disabled in prod** | `/docs` and `/openapi.json` only in development | +| **Idempotent** | Duplicate emails return success (no enumeration) | + +## Quick Start + +```bash +# 1. Start Postgres + app +docker compose up --build + +# 2. Test +curl -X POST https://waitlist.adiuvai.com/api/v1/waitlist \ + -H "Content-Type: application/json" \ + -d '{"email": "user@example.com"}' +``` + +## Local Development + +```bash +cd waitlist +python -m venv .venv +.venv\Scripts\Activate.ps1 # Windows +pip install -r requirements.txt + +# Copy and edit .env +cp .env.example .env + +# Run migrations +alembic upgrade head + +# Start dev server +uvicorn app.main:app --reload --port 8001 + +# Run tests +pip install pytest pytest-asyncio httpx aiosqlite +pytest tests/ -v +``` + +## Deployment (Cloudflare) + +1. Point `waitlist.adiuvai.com` to your server via Cloudflare DNS (orange cloud ON) +2. Set environment variables (see `.env.example`) +3. `docker compose up -d` +4. Cloudflare handles TLS termination, bot filtering, and rate limiting at the edge + +### Recommended Cloudflare Settings + +- **WAF**: Enable managed rulesets (OWASP Core) +- **Bot Fight Mode**: ON +- **Rate Limiting Rule**: 10 req/10s to `/api/v1/waitlist` (defense in depth) +- **SSL mode**: Full (Strict) diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..c2474ae --- /dev/null +++ b/alembic.ini @@ -0,0 +1,36 @@ +[alembic] +script_location = alembic +sqlalchemy.url = postgresql+psycopg2://waitlist:changeme@localhost:5432/waitlist_db + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..a0b09d8 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,46 @@ +import os +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.models import Base + +config = context.config + +# Override URL from env if available (so .env takes precedence over alembic.ini) +db_url = os.environ.get("DATABASE_URL", "") +if db_url: + # Alembic needs the sync driver + sync_url = db_url.replace("+asyncpg", "+psycopg2") + config.set_main_option("sqlalchemy.url", sync_url) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=target_metadata, literal_binds=True) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + 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(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..043de1c --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/001_create_waitlist_entries.py b/alembic/versions/001_create_waitlist_entries.py new file mode 100644 index 0000000..245f3f2 --- /dev/null +++ b/alembic/versions/001_create_waitlist_entries.py @@ -0,0 +1,36 @@ +"""create waitlist_entries table + +Revision ID: 001 +Revises: +Create Date: 2026-04-11 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "001" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "waitlist_entries", + sa.Column("id", sa.BigInteger(), autoincrement=True, primary_key=True), + sa.Column("email", sa.String(320), nullable=False, unique=True, index=True), + sa.Column("ip_address", sa.String(45), nullable=True), + sa.Column("source", sa.String(64), nullable=True), + sa.Column("confirmed", sa.Boolean(), server_default="false", nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + ) + + +def downgrade() -> None: + op.drop_table("waitlist_entries") diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..8e97382 --- /dev/null +++ b/app/config.py @@ -0,0 +1,21 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + DATABASE_URL: str = "postgresql+asyncpg://waitlist:changeme@localhost:5432/waitlist_db" + ALLOWED_ORIGINS: str = "https://adiuvai.com,https://www.adiuvai.com" + RATE_LIMIT_PER_MINUTE: int = 5 + ENVIRONMENT: str = "development" + + model_config = {"env_file": ".env", "env_file_encoding": "utf-8"} + + @property + def origins_list(self) -> list[str]: + return [o.strip() for o in self.ALLOWED_ORIGINS.split(",") if o.strip()] + + @property + def sync_database_url(self) -> str: + return self.DATABASE_URL.replace("+asyncpg", "+psycopg2") + + +settings = Settings() diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..503944f --- /dev/null +++ b/app/db.py @@ -0,0 +1,17 @@ +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.config import settings + +engine = create_async_engine( + settings.DATABASE_URL, + pool_size=5, + max_overflow=10, + pool_pre_ping=True, +) + +async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +async def get_db() -> AsyncSession: + async with async_session() as session: + yield session diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..f8e0305 --- /dev/null +++ b/app/main.py @@ -0,0 +1,59 @@ +import logging + +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.config import settings +from app.rate_limit import RateLimiter +from app.routes import router +from app.security import OriginValidator, RequestSizeLimiter + +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info("Waitlist service starting (env=%s)", settings.ENVIRONMENT) + yield + logger.info("Waitlist service shutting down") + + +app = FastAPI( + title="adiuvAI Waitlist", + version="1.0.0", + docs_url="/docs" if settings.ENVIRONMENT != "production" else None, + redoc_url=None, + openapi_url="/openapi.json" if settings.ENVIRONMENT != "production" else None, + lifespan=lifespan, +) + +# ── Middleware stack (outermost runs first) ────────────────────────── + +# 1. CORS — locked to allowed origins +app.add_middleware( + CORSMiddleware, + allow_origins=settings.origins_list, + allow_methods=["POST", "OPTIONS"], + allow_headers=["Content-Type"], + allow_credentials=False, + max_age=86400, +) + +# 2. Rate limiter (per-IP, Cloudflare-aware) +app.add_middleware(RateLimiter) + +# 3. Origin / Referer validation (production only) +app.add_middleware(OriginValidator) + +# 4. Request body size limit (4 KB) +app.add_middleware(RequestSizeLimiter) + +# ── Routes ─────────────────────────────────────────────────────────── +app.include_router(router, prefix="/api/v1") + + +@app.get("/health") +async def health(): + return {"status": "ok"} diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..3307f84 --- /dev/null +++ b/app/models.py @@ -0,0 +1,28 @@ +import datetime + +import sqlalchemy as sa +from sqlalchemy import BigInteger, Boolean, DateTime, String +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + pass + + +class WaitlistEntry(Base): + __tablename__ = "waitlist_entries" + + id: Mapped[int] = mapped_column( + BigInteger().with_variant(sa.Integer, "sqlite"), + primary_key=True, + autoincrement=True, + ) + email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False, index=True) + ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True) + source: Mapped[str | None] = mapped_column(String(64), nullable=True) + confirmed: Mapped[bool] = mapped_column(Boolean, default=False, server_default=sa.text("0")) + created_at: Mapped[datetime.datetime] = mapped_column( + DateTime(timezone=True), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + ) diff --git a/app/rate_limit.py b/app/rate_limit.py new file mode 100644 index 0000000..f9929cb --- /dev/null +++ b/app/rate_limit.py @@ -0,0 +1,76 @@ +""" +IP-based sliding-window rate limiter. + +Cloudflare-aware: uses CF-Connecting-IP → X-Forwarded-For → client.host +to identify the real client IP. +""" + +import time +from collections import defaultdict +from threading import Lock + +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse + +from app.config import settings + + +def _get_client_ip(request: Request) -> str: + """Extract real client IP behind Cloudflare / reverse proxy.""" + # Cloudflare always sets this when proxying + cf_ip = request.headers.get("cf-connecting-ip") + if cf_ip: + return cf_ip.strip() + + # Fallback: first entry in X-Forwarded-For (set by most reverse proxies) + xff = request.headers.get("x-forwarded-for") + if xff: + return xff.split(",")[0].strip() + + # Last resort: direct connection IP + return request.client.host if request.client else "unknown" + + +# Module-level hits store so tests can clear it +_hits_store: dict[str, list[float]] = defaultdict(list) + + +class RateLimiter(BaseHTTPMiddleware): + """ + Sliding-window rate limiter keyed on client IP. + + Only applies to POST /api/v1/waitlist. + Returns 429 with Retry-After header when exceeded. + """ + + def __init__(self, app, per_minute: int = settings.RATE_LIMIT_PER_MINUTE): + super().__init__(app) + self.per_minute = per_minute + self.window = 60 # seconds + self._hits = _hits_store + self._lock = Lock() + + def _prune(self, ip: str, now: float) -> None: + cutoff = now - self.window + self._hits[ip] = [t for t in self._hits[ip] if t > cutoff] + + async def dispatch(self, request: Request, call_next) -> Response: + # Only rate-limit the waitlist POST endpoint + if request.method != "POST" or request.url.path != "/api/v1/waitlist": + return await call_next(request) + + ip = _get_client_ip(request) + now = time.monotonic() + + with self._lock: + self._prune(ip, now) + if len(self._hits[ip]) >= self.per_minute: + return JSONResponse( + status_code=429, + content={"detail": "Too many requests. Please try again later."}, + headers={"Retry-After": str(self.window)}, + ) + self._hits[ip].append(now) + + return await call_next(request) diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 0000000..279f15e --- /dev/null +++ b/app/routes.py @@ -0,0 +1,48 @@ +import logging + +from fastapi import APIRouter, Depends, Request +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import get_db +from app.rate_limit import _get_client_ip +from app.schemas import WaitlistRequest, WaitlistResponse +from app.models import WaitlistEntry + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.post("/waitlist", response_model=WaitlistResponse) +async def join_waitlist( + body: WaitlistRequest, + request: Request, + db: AsyncSession = Depends(get_db), +) -> WaitlistResponse: + """ + Add an email to the waitlist. + + - Honeypot: if `website` field is non-empty, silently succeed (bot trap). + - Duplicate emails: idempotent — returns success without error. + - Stores the Cloudflare-resolved client IP for analytics (not exposed). + """ + # Honeypot — bots fill hidden fields; silently "succeed" + if body.website: + return WaitlistResponse() + + email = body.email.lower().strip() + ip = _get_client_ip(request) + + # Check for existing entry — idempotent + existing = await db.execute( + select(WaitlistEntry.id).where(WaitlistEntry.email == email) + ) + if existing.scalar_one_or_none() is not None: + return WaitlistResponse() + + entry = WaitlistEntry(email=email, ip_address=ip, source="website") + db.add(entry) + await db.commit() + + logger.info("New waitlist signup: %s", email[:3] + "***") + return WaitlistResponse() diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..5092af3 --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel, EmailStr, Field + + +class WaitlistRequest(BaseModel): + email: EmailStr + # Honeypot field — must be empty. Bots tend to fill hidden fields. + website: str = Field(default="", max_length=0) + + +class WaitlistResponse(BaseModel): + ok: bool = True + message: str = "You're on the list!" diff --git a/app/security.py b/app/security.py new file mode 100644 index 0000000..dbb7459 --- /dev/null +++ b/app/security.py @@ -0,0 +1,59 @@ +""" +Security middleware stack. + +1. RequestSizeLimiter — reject bodies > 4 KB (waitlist only needs ~100 bytes) +2. OriginValidator — in production, reject requests without a valid Origin/Referer +""" + +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse + +from app.config import settings + + +class RequestSizeLimiter(BaseHTTPMiddleware): + """Reject request bodies larger than max_bytes.""" + + MAX_BYTES = 4_096 # 4 KB — more than enough for a JSON email payload + + async def dispatch(self, request: Request, call_next) -> Response: + content_length = request.headers.get("content-length") + if content_length and int(content_length) > self.MAX_BYTES: + return JSONResponse( + status_code=413, + content={"detail": "Request body too large."}, + ) + return await call_next(request) + + +class OriginValidator(BaseHTTPMiddleware): + """ + In production, only allow requests whose Origin or Referer + matches the allowed origins list. This mitigates CSRF/cross-origin abuse. + + Skipped in development so local testing works without custom headers. + """ + + async def dispatch(self, request: Request, call_next) -> Response: + if settings.ENVIRONMENT != "production": + return await call_next(request) + + # Only check mutating methods + if request.method not in ("POST", "PUT", "PATCH", "DELETE"): + return await call_next(request) + + origin = request.headers.get("origin") or "" + referer = request.headers.get("referer") or "" + + allowed = settings.origins_list + origin_ok = any(origin.startswith(o) for o in allowed) if origin else False + referer_ok = any(referer.startswith(o) for o in allowed) if referer else False + + if not origin_ok and not referer_ok: + return JSONResponse( + status_code=403, + content={"detail": "Forbidden."}, + ) + + return await call_next(request) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ecc2b88 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: waitlist + POSTGRES_PASSWORD: changeme + POSTGRES_DB: waitlist_db + ports: + - "5433:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U waitlist -d waitlist_db"] + interval: 5s + timeout: 3s + retries: 5 + + app: + build: . + ports: + - "8001:8001" + environment: + DATABASE_URL: postgresql+asyncpg://waitlist:changeme@db:5432/waitlist_db + ALLOWED_ORIGINS: https://adiuvai.com,https://www.adiuvai.com + RATE_LIMIT_PER_MINUTE: 5 + ENVIRONMENT: production + depends_on: + db: + condition: service_healthy + +volumes: + pgdata: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2e8d622 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +fastapi[standard]>=0.115,<1.0 +uvicorn[standard]>=0.34,<1.0 +gunicorn>=23,<24 +sqlalchemy[asyncio]>=2.0,<3.0 +asyncpg>=0.30,<1.0 +alembic>=1.14,<2.0 +pydantic>=2.0,<3.0 +pydantic-settings>=2.0,<3.0 +email-validator>=2.0,<3.0 +python-dotenv>=1.0,<2.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_waitlist.py b/tests/test_waitlist.py new file mode 100644 index 0000000..8d9a70f --- /dev/null +++ b/tests/test_waitlist.py @@ -0,0 +1,109 @@ +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.main import app +from app.models import Base +from app.db import get_db +from app.rate_limit import _hits_store + +# Use SQLite for tests (no Postgres dependency) +TEST_DB_URL = "sqlite+aiosqlite:///./test_waitlist.db" + + +@pytest_asyncio.fixture +async def db_session(): + engine = create_async_engine(TEST_DB_URL) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + async with session_factory() as session: + yield session + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await engine.dispose() + + +@pytest_asyncio.fixture +async def client(db_session): + async def _override_db(): + yield db_session + + app.dependency_overrides[get_db] = _override_db + + # Reset rate limiter state between tests + _hits_store.clear() + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + app.dependency_overrides.clear() + + +@pytest.mark.asyncio +async def test_join_waitlist_success(client): + resp = await client.post( + "/api/v1/waitlist", + json={"email": "user@example.com"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["ok"] is True + assert "list" in data["message"].lower() + + +@pytest.mark.asyncio +async def test_duplicate_email_is_idempotent(client): + payload = {"email": "dup@example.com"} + r1 = await client.post("/api/v1/waitlist", json=payload) + r2 = await client.post("/api/v1/waitlist", json=payload) + assert r1.status_code == 200 + assert r2.status_code == 200 + + +@pytest.mark.asyncio +async def test_invalid_email_rejected(client): + resp = await client.post( + "/api/v1/waitlist", + json={"email": "not-an-email"}, + ) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_honeypot_silently_succeeds(client): + resp = await client.post( + "/api/v1/waitlist", + json={"email": "bot@spam.com", "website": "http://spam.site"}, + ) + # Honeypot field filled → validation error (max_length=0) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_missing_email_rejected(client): + resp = await client.post("/api/v1/waitlist", json={}) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_health_endpoint(client): + resp = await client.get("/health") + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" + + +@pytest.mark.asyncio +async def test_rate_limit(client): + """Submit more than the per-minute limit and expect 429.""" + for i in range(6): + resp = await client.post( + "/api/v1/waitlist", + json={"email": f"rate{i}@example.com"}, + ) + # The 6th request should be rate-limited (limit is 5) + assert resp.status_code == 429 + assert "Retry-After" in resp.headers