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
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,27 @@ Public, bilingual (IT/EN), SEO-optimized, with a protected admin panel.

```
Browser → CloudFront → React (S3) ─┐
├→ FastAPI (ECS Fargate) → DynamoDB / S3 / Cognito / SES
├→ FastAPI (Lambda) → DynamoDB / S3 / Cognito / SES
Browser → CloudFront → /api ───────┘
```

| Layer | Technology |
|----------|-------------------------------------------------|
| Frontend | React + Vite + TypeScript + Tailwind CSS |
| State | Redux Toolkit + RTK Query |
| Backend | FastAPI (Python 3.12) |
| Backend | FastAPI (Python 3.12) on AWS Lambda (container image) |
| Database | AWS DynamoDB |
| Storage | AWS S3 (project/learning images, downloadable CV PDF) |
| Auth | AWS Cognito (Administrators group) |
| Hosting | AWS ECS Fargate (backend), S3 + CloudFront (SPA) |
| Hosting | AWS Lambda + Function URL (backend), S3 + CloudFront (SPA) |
| DNS/TLS | Route 53 + ACM |
| IaC | Terraform |

## Repository structure

```
.
├── backend/ # FastAPI application (routers, services, schemas, models, utils)
├── backend/ # FastAPI application (routers, services, schemas, models, utils); runs on Lambda
├── frontend/ # React SPA (components, pages, hooks, services, store, i18n)
├── infra/ # Terraform modules + deployment guide
├── docker-compose.yml
Expand Down Expand Up @@ -80,7 +80,7 @@ See [infra/README.md](infra/README.md) for the full AWS deployment guide, includ
1. Registering `marcomanduca.dev` on Route 53
2. Issuing the ACM certificate (us-east-1 for CloudFront)
3. Provisioning all resources with Terraform
4. Deploying backend (ECS) and frontend (S3 + CloudFront invalidation)
4. Deploying backend (Lambda) and frontend (S3 + CloudFront invalidation)

## Documentation

Expand Down
3 changes: 2 additions & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ AWS_REGION=eu-west-1
PROJECTS_TABLE_NAME=portfolio-projects
LEARNING_TABLE_NAME=portfolio-learning
TECHNOLOGIES_TABLE_NAME=portfolio-technologies
RATELIMIT_TABLE_NAME=portfolio-ratelimit

# Optional: point boto3 at DynamoDB Local for development
# DYNAMODB_ENDPOINT_URL=http://localhost:8001
Expand All @@ -27,6 +28,6 @@ SES_RECIPIENT_EMAIL=owner@marcomanduca.dev
# CORS (comma-separated origins)
CORS_ORIGINS=http://localhost:5173,https://marcomanduca.dev

# Contact form rate limit (per IP, per instance)
# Contact form rate limit (per IP, fixed window; state in the ratelimit table)
CONTACT_RATE_LIMIT_MAX_REQUESTS=5
CONTACT_RATE_LIMIT_WINDOW_SECONDS=900
9 changes: 9 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ RUN pip install --no-cache-dir --prefix=/install .
# --- Runtime stage: slim image, non-root user, no build tooling --------------
FROM python:3.12-slim AS runtime

# AWS Lambda Web Adapter: lets the unmodified Uvicorn server run on Lambda
# behind a Function URL (translates the Lambda event to a local HTTP request).
# It is a Lambda extension and is inert outside Lambda, so the exact same
# image still runs locally and in docker-compose. AWS_LWA_PORT must match the
# port Uvicorn binds; the readiness path gates cold-start traffic on /health.
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.8.4 /lambda-adapter /opt/extensions/lambda-adapter
ENV AWS_LWA_PORT=8000
ENV AWS_LWA_READINESS_CHECK_PATH=/api/v1/health

RUN addgroup --system app && adduser --system --ingroup app app

WORKDIR /app
Expand Down
15 changes: 10 additions & 5 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,15 @@ belonging to the `Administrators` group.

## Architecture

- **API**: FastAPI under `/api/v1`, app factory in `src/main.py`.
- **API**: FastAPI under `/api/v1`, app factory in `src/main.py`. In
production it runs on AWS Lambda (container image + Lambda Web Adapter); the
same image runs locally via Uvicorn with no code changes.
- **Storage**: DynamoDB (on-demand) for content, S3 for media.
- **Auth**: Cognito JWT validation (JWKS, PyJWT) in `src/utils/auth.py`.
- **Email**: AWS SES for contact form delivery.
- **Anti-spam**: honeypot field + in-memory per-IP sliding-window rate
limit (per instance; production can move this to API Gateway/WAF).
- **Anti-spam**: honeypot field + per-IP fixed-window rate limit backed by a
DynamoDB TTL table (`src/utils/rate_limit.py`), so the limit is shared
across Lambda invocations and survives cold starts.

## Endpoints

Expand Down Expand Up @@ -51,6 +54,7 @@ See `.env.example` for the full annotated list.
| `PROJECTS_TABLE_NAME` | DynamoDB Projects table | `portfolio-projects` |
| `LEARNING_TABLE_NAME` | DynamoDB Learning table | `portfolio-learning` |
| `TECHNOLOGIES_TABLE_NAME` | DynamoDB Technologies table | `portfolio-technologies` |
| `RATELIMIT_TABLE_NAME` | DynamoDB contact rate-limit table | `portfolio-ratelimit` |
| `DYNAMODB_ENDPOINT_URL` | Optional DynamoDB Local endpoint | unset |
| `MEDIA_BUCKET_NAME` | S3 bucket for media | `marcomanduca-dev-media` |
| `PRESIGN_EXPIRATION_SECONDS` | Presigned URL validity | `900` |
Expand All @@ -74,7 +78,7 @@ cp .env.example .env # adjust values
# Optional: DynamoDB Local
docker run -d -p 8001:8000 amazon/dynamodb-local
# then set DYNAMODB_ENDPOINT_URL=http://localhost:8001 in .env
# and create the three tables (projects: slug / learning: slug+version / technologies: id).
# and create the tables (projects: slug / learning: slug+version / technologies: id / ratelimit: pk).

uvicorn src.main:app --reload --port 8000
```
Expand Down Expand Up @@ -125,7 +129,8 @@ Runtime (kept minimal on purpose):

Notably avoided: `email-validator` (a lightweight regex is enough for
a contact form; SES is the real gatekeeper) and any rate-limit library
(a ~40-line sliding window covers the need).
(a small DynamoDB fixed-window counter with TTL covers the need and works
across Lambda invocations).

Dev only: **pytest**, **pytest-cov**, **pytest-asyncio**, **httpx**
(ASGI test client), **moto** (AWS mocks), **ruff** (format + lint).
1 change: 1 addition & 0 deletions backend/src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class Settings(BaseSettings):
projects_table_name: str = "portfolio-projects"
learning_table_name: str = "portfolio-learning"
technologies_table_name: str = "portfolio-technologies"
ratelimit_table_name: str = "portfolio-ratelimit"

media_bucket_name: str = "marcomanduca-dev-media"
presign_expiration_seconds: int = 900
Expand Down
98 changes: 59 additions & 39 deletions backend/src/utils/rate_limit.py
Original file line number Diff line number Diff line change
@@ -1,81 +1,101 @@
"""In-memory sliding-window rate limiting.

The limiter is intentionally per-instance: state lives in process memory
and is not shared across replicas. This is acceptable for a low-traffic
portfolio backend; production hardening can move the limit to API
Gateway throttling or AWS WAF rate-based rules.
"""DynamoDB-backed fixed-window rate limiting.

State lives in a small DynamoDB table with a TTL attribute instead of in
process memory, so the limit is shared across every Lambda execution
environment and survives cold starts. Each request atomically increments a
per-key, per-window counter; DynamoDB drops the item automatically once its
window has elapsed (TTL). This replaces the earlier per-instance in-memory
limiter, which reset on every cold start and could not be shared.
"""

import time
from collections import defaultdict, deque
from typing import Any

from botocore.exceptions import BotoCoreError, ClientError
from fastapi import HTTPException, Request, status

from src.config import get_settings
from src.models.base import get_dynamodb_resource

# Small grace added to the TTL so an item never expires mid-window.
_TTL_GRACE_SECONDS = 60


class SlidingWindowRateLimiter:
"""Sliding-window request counter keyed by an arbitrary string.
class DynamoRateLimiter:
"""Fixed-window request counter stored in DynamoDB.

Parameters
----------
table : Any
A boto3 DynamoDB ``Table`` resource with a string hash key ``pk``
and a numeric TTL attribute ``expires_at``.
max_requests : int
Maximum number of requests allowed inside the window.
window_seconds : float
Length of the sliding window in seconds.
Maximum number of requests allowed inside a single window.
window_seconds : int
Length of the fixed window in seconds.
"""

def __init__(self, max_requests: int, window_seconds: float) -> None:
def __init__(self, table: Any, max_requests: int, window_seconds: int) -> None:
self._table = table
self._max_requests = max_requests
self._window_seconds = window_seconds
self._hits: dict[str, deque[float]] = defaultdict(deque)

def is_allowed(self, key: str, now: float | None = None) -> bool:
"""Record a hit for ``key`` and report whether it is allowed.

Fails open: if DynamoDB is unreachable the request is allowed, so a
transient backend fault never blocks a genuine visitor (the honeypot
remains the second line of defence against spam).

Parameters
----------
key : str
Identifier of the caller (typically a client IP).
now : float, optional
Monotonic timestamp override, used by tests to control the
clock. Defaults to ``time.monotonic()``.
Epoch timestamp override, used by tests to control the clock.
Defaults to ``time.time()``.

Returns
-------
bool
``True`` if the request fits in the window, ``False`` if
the caller exceeded the limit.
``True`` if the request fits in the window, ``False`` otherwise.
"""
current = time.monotonic() if now is None else now
bucket = self._hits[key]
while bucket and current - bucket[0] >= self._window_seconds:
bucket.popleft()
if len(bucket) >= self._max_requests:
return False
bucket.append(current)
return True

def reset(self) -> None:
"""Drop all recorded hits."""
self._hits.clear()


_contact_limiter: SlidingWindowRateLimiter | None = None


def get_contact_limiter() -> SlidingWindowRateLimiter:
current = time.time() if now is None else now
window_start = int(current // self._window_seconds) * self._window_seconds
try:
response = self._table.update_item(
Key={"pk": f"{key}#{window_start}"},
UpdateExpression=(
"ADD hits :one SET expires_at = if_not_exists(expires_at, :ttl)"
),
ExpressionAttributeValues={
":one": 1,
":ttl": window_start + self._window_seconds + _TTL_GRACE_SECONDS,
},
ReturnValues="UPDATED_NEW",
)
except (ClientError, BotoCoreError):
return True
return int(response["Attributes"]["hits"]) <= self._max_requests


_contact_limiter: DynamoRateLimiter | None = None


def get_contact_limiter() -> DynamoRateLimiter:
"""Return the lazily-built limiter for the contact endpoint.

Returns
-------
SlidingWindowRateLimiter
Process-wide limiter configured from settings.
DynamoRateLimiter
Limiter bound to the rate-limit table and configured from settings.
"""
global _contact_limiter
if _contact_limiter is None:
settings = get_settings()
_contact_limiter = SlidingWindowRateLimiter(
table = get_dynamodb_resource().Table(settings.ratelimit_table_name)
_contact_limiter = DynamoRateLimiter(
table=table,
max_requests=settings.contact_rate_limit_max_requests,
window_seconds=settings.contact_rate_limit_window_seconds,
)
Expand Down
7 changes: 7 additions & 0 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"PROJECTS_TABLE_NAME": "test-projects",
"LEARNING_TABLE_NAME": "test-learning",
"TECHNOLOGIES_TABLE_NAME": "test-technologies",
"RATELIMIT_TABLE_NAME": "test-ratelimit",
"MEDIA_BUCKET_NAME": "test-media-bucket",
"COGNITO_USER_POOL_ID": "eu-west-1_testpool",
"COGNITO_CLIENT_ID": "test-client-id",
Expand Down Expand Up @@ -168,6 +169,12 @@ def _create_tables() -> None:
AttributeDefinitions=[{"AttributeName": "id", "AttributeType": "S"}],
BillingMode="PAY_PER_REQUEST",
)
dynamodb.create_table(
TableName=os.environ["RATELIMIT_TABLE_NAME"],
KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}],
AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}],
BillingMode="PAY_PER_REQUEST",
)


def _create_bucket() -> None:
Expand Down
54 changes: 40 additions & 14 deletions backend/tests/unit/utils/test_rate_limit.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,32 @@
"""Unit tests for the sliding-window rate limiter."""
"""Unit tests for the DynamoDB-backed rate limiter."""

from src.utils.rate_limit import SlidingWindowRateLimiter
from typing import Any

import boto3
from moto import mock_aws

from src.utils.rate_limit import DynamoRateLimiter

_REGION = "eu-west-1"
_TABLE = "test-ratelimit"


def _make_table() -> Any:
"""Create the rate-limit table in moto and return the Table resource."""
dynamodb = boto3.resource("dynamodb", region_name=_REGION)
dynamodb.create_table(
TableName=_TABLE,
KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}],
AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}],
BillingMode="PAY_PER_REQUEST",
)
return dynamodb.Table(_TABLE)


@mock_aws
def test_is_allowed_accepts_requests_under_the_limit() -> None:
# Arrange
limiter = SlidingWindowRateLimiter(max_requests=2, window_seconds=60)
limiter = DynamoRateLimiter(_make_table(), max_requests=2, window_seconds=60)

# Act
first = limiter.is_allowed("1.2.3.4", now=0.0)
Expand All @@ -16,9 +37,10 @@ def test_is_allowed_accepts_requests_under_the_limit() -> None:
assert second is True


@mock_aws
def test_is_allowed_blocks_requests_over_the_limit() -> None:
# Arrange
limiter = SlidingWindowRateLimiter(max_requests=2, window_seconds=60)
limiter = DynamoRateLimiter(_make_table(), max_requests=2, window_seconds=60)
limiter.is_allowed("1.2.3.4", now=0.0)
limiter.is_allowed("1.2.3.4", now=1.0)

Expand All @@ -29,9 +51,10 @@ def test_is_allowed_blocks_requests_over_the_limit() -> None:
assert third is False


def test_is_allowed_accepts_again_after_window_expiry() -> None:
@mock_aws
def test_is_allowed_accepts_again_in_a_new_window() -> None:
# Arrange
limiter = SlidingWindowRateLimiter(max_requests=1, window_seconds=60)
limiter = DynamoRateLimiter(_make_table(), max_requests=1, window_seconds=60)
limiter.is_allowed("1.2.3.4", now=0.0)

# Act
Expand All @@ -41,9 +64,10 @@ def test_is_allowed_accepts_again_after_window_expiry() -> None:
assert after_window is True


@mock_aws
def test_is_allowed_tracks_keys_independently() -> None:
# Arrange
limiter = SlidingWindowRateLimiter(max_requests=1, window_seconds=60)
limiter = DynamoRateLimiter(_make_table(), max_requests=1, window_seconds=60)
limiter.is_allowed("1.1.1.1", now=0.0)

# Act
Expand All @@ -53,14 +77,16 @@ def test_is_allowed_tracks_keys_independently() -> None:
assert other_key is True


def test_reset_clears_recorded_hits() -> None:
# Arrange
limiter = SlidingWindowRateLimiter(max_requests=1, window_seconds=60)
limiter.is_allowed("1.2.3.4", now=0.0)
@mock_aws
def test_is_allowed_fails_open_when_dynamodb_errors() -> None:
# Arrange: the table is never created, so update_item raises a ClientError.
dynamodb = boto3.resource("dynamodb", region_name=_REGION)
limiter = DynamoRateLimiter(
dynamodb.Table("missing-table"), max_requests=1, window_seconds=60
)

# Act
limiter.reset()
after_reset = limiter.is_allowed("1.2.3.4", now=1.0)
allowed = limiter.is_allowed("1.2.3.4", now=0.0)

# Assert
assert after_reset is True
assert allowed is True
Loading
Loading