feat: add Sentry, Cloudflare R2, and CloudFlare CDN integrations
Production quick wins for improved observability and scalability: Sentry Error Tracking: - Add sentry-sdk[fastapi] dependency - Initialize Sentry in main.py with FastAPI/SQLAlchemy integrations - Add Celery integration for background task error tracking - Feature-flagged via SENTRY_DSN (disabled when empty) Cloudflare R2 Storage: - Add boto3 dependency for S3-compatible API - Create storage_service.py with StorageBackend abstraction - LocalStorageBackend for development (default) - R2StorageBackend for production cloud storage - Feature-flagged via STORAGE_BACKEND setting CloudFlare CDN/Proxy: - Create middleware/cloudflare.py for CF header handling - Extract real client IP from CF-Connecting-IP - Support CF-IPCountry for geo features - Feature-flagged via CLOUDFLARE_ENABLED setting Documentation: - Add docs/deployment/cloudflare.md setup guide - Update infrastructure.md with dev vs prod requirements - Add enterprise upgrade checklist for scaling beyond 1000 users - Update installation.md with new environment variables All features are optional and disabled by default for development. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
35
.env.example
35
.env.example
@@ -163,3 +163,38 @@ FLOWER_URL=http://localhost:5555
|
||||
# Flower basic authentication password
|
||||
# ⚠️ CHANGE THIS IN PRODUCTION!
|
||||
FLOWER_PASSWORD=changeme
|
||||
|
||||
# =============================================================================
|
||||
# SENTRY ERROR TRACKING
|
||||
# =============================================================================
|
||||
# Get your DSN from https://sentry.io (free tier available)
|
||||
# Leave empty to disable Sentry
|
||||
SENTRY_DSN=
|
||||
SENTRY_ENVIRONMENT=production
|
||||
SENTRY_TRACES_SAMPLE_RATE=0.1
|
||||
|
||||
# =============================================================================
|
||||
# CLOUDFLARE R2 STORAGE
|
||||
# =============================================================================
|
||||
# Storage backend: "local" (default) or "r2" for Cloudflare R2
|
||||
# Set to "r2" for production to enable cloud storage
|
||||
STORAGE_BACKEND=local
|
||||
|
||||
# Cloudflare R2 credentials (required when STORAGE_BACKEND=r2)
|
||||
# Get these from Cloudflare Dashboard > R2 > Manage R2 API Tokens
|
||||
R2_ACCOUNT_ID=
|
||||
R2_ACCESS_KEY_ID=
|
||||
R2_SECRET_ACCESS_KEY=
|
||||
R2_BUCKET_NAME=wizamart-media
|
||||
|
||||
# Public URL for R2 bucket (optional - for custom domain)
|
||||
# If not set, uses Cloudflare's default R2 public URL
|
||||
# Example: https://media.yoursite.com
|
||||
R2_PUBLIC_URL=
|
||||
|
||||
# =============================================================================
|
||||
# CLOUDFLARE CDN / PROXY
|
||||
# =============================================================================
|
||||
# Set to true when your domain is proxied through CloudFlare
|
||||
# This enables proper handling of CF-Connecting-IP and other CloudFlare headers
|
||||
CLOUDFLARE_ENABLED=false
|
||||
|
||||
@@ -7,16 +7,33 @@ It includes:
|
||||
- Task routing to separate queues (default, long_running, scheduled)
|
||||
- Celery Beat schedule for periodic tasks
|
||||
- Task retry policies
|
||||
- Sentry integration for error tracking
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import sentry_sdk
|
||||
from celery import Celery
|
||||
from celery.schedules import crontab
|
||||
from sentry_sdk.integrations.celery import CeleryIntegration
|
||||
|
||||
# Redis URL from environment or default
|
||||
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||
|
||||
# =============================================================================
|
||||
# SENTRY INITIALIZATION FOR CELERY WORKERS
|
||||
# =============================================================================
|
||||
# Celery workers run in separate processes, so Sentry must be initialized here too
|
||||
SENTRY_DSN = os.getenv("SENTRY_DSN")
|
||||
if SENTRY_DSN:
|
||||
sentry_sdk.init(
|
||||
dsn=SENTRY_DSN,
|
||||
environment=os.getenv("SENTRY_ENVIRONMENT", "development"),
|
||||
traces_sample_rate=float(os.getenv("SENTRY_TRACES_SAMPLE_RATE", "0.1")),
|
||||
integrations=[CeleryIntegration()],
|
||||
send_default_pii=True,
|
||||
)
|
||||
|
||||
# Create Celery application
|
||||
celery_app = Celery(
|
||||
"wizamart",
|
||||
|
||||
@@ -187,6 +187,28 @@ class Settings(BaseSettings):
|
||||
flower_url: str = "http://localhost:5555"
|
||||
flower_password: str = "changeme" # CHANGE IN PRODUCTION!
|
||||
|
||||
# =============================================================================
|
||||
# SENTRY ERROR TRACKING
|
||||
# =============================================================================
|
||||
sentry_dsn: str | None = None # Set to enable Sentry
|
||||
sentry_environment: str = "development" # development, staging, production
|
||||
sentry_traces_sample_rate: float = 0.1 # 10% of transactions for performance monitoring
|
||||
|
||||
# =============================================================================
|
||||
# CLOUDFLARE R2 STORAGE
|
||||
# =============================================================================
|
||||
storage_backend: str = "local" # "local" or "r2"
|
||||
r2_account_id: str | None = None
|
||||
r2_access_key_id: str | None = None
|
||||
r2_secret_access_key: str | None = None
|
||||
r2_bucket_name: str = "wizamart-media"
|
||||
r2_public_url: str | None = None # Custom domain for public access (e.g., https://media.yoursite.com)
|
||||
|
||||
# =============================================================================
|
||||
# CLOUDFLARE CDN / PROXY
|
||||
# =============================================================================
|
||||
cloudflare_enabled: bool = False # Set to True when using CloudFlare proxy
|
||||
|
||||
model_config = {"env_file": ".env"}
|
||||
|
||||
|
||||
|
||||
295
app/services/storage_service.py
Normal file
295
app/services/storage_service.py
Normal file
@@ -0,0 +1,295 @@
|
||||
# app/services/storage_service.py
|
||||
"""
|
||||
Storage abstraction service for file uploads.
|
||||
|
||||
Provides a unified interface for file storage with support for:
|
||||
- Local filesystem (default, development)
|
||||
- Cloudflare R2 (production, S3-compatible)
|
||||
|
||||
Usage:
|
||||
from app.services.storage_service import get_storage_backend
|
||||
|
||||
storage = get_storage_backend()
|
||||
url = await storage.upload("path/to/file.jpg", file_bytes, "image/jpeg")
|
||||
await storage.delete("path/to/file.jpg")
|
||||
"""
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StorageBackend(ABC):
|
||||
"""Abstract base class for storage backends."""
|
||||
|
||||
@abstractmethod
|
||||
async def upload(self, file_path: str, content: bytes, content_type: str) -> str:
|
||||
"""
|
||||
Upload a file to storage.
|
||||
|
||||
Args:
|
||||
file_path: Relative path where file should be stored
|
||||
content: File content as bytes
|
||||
content_type: MIME type of the file
|
||||
|
||||
Returns:
|
||||
Public URL to access the file
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def delete(self, file_path: str) -> bool:
|
||||
"""
|
||||
Delete a file from storage.
|
||||
|
||||
Args:
|
||||
file_path: Relative path of file to delete
|
||||
|
||||
Returns:
|
||||
True if file was deleted, False if not found
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_url(self, file_path: str) -> str:
|
||||
"""
|
||||
Get the public URL for a file.
|
||||
|
||||
Args:
|
||||
file_path: Relative path of the file
|
||||
|
||||
Returns:
|
||||
Public URL to access the file
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def exists(self, file_path: str) -> bool:
|
||||
"""
|
||||
Check if a file exists in storage.
|
||||
|
||||
Args:
|
||||
file_path: Relative path of the file
|
||||
|
||||
Returns:
|
||||
True if file exists
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class LocalStorageBackend(StorageBackend):
|
||||
"""Local filesystem storage backend."""
|
||||
|
||||
def __init__(self, base_dir: str = "uploads"):
|
||||
"""
|
||||
Initialize local storage backend.
|
||||
|
||||
Args:
|
||||
base_dir: Base directory for file storage (relative to project root)
|
||||
"""
|
||||
self.base_dir = Path(base_dir)
|
||||
self.base_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.info(f"LocalStorageBackend initialized with base_dir: {self.base_dir}")
|
||||
|
||||
async def upload(self, file_path: str, content: bytes, content_type: str) -> str:
|
||||
"""Upload file to local filesystem."""
|
||||
full_path = self.base_dir / file_path
|
||||
|
||||
# Ensure parent directory exists
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write file
|
||||
full_path.write_bytes(content)
|
||||
|
||||
logger.debug(f"Uploaded to local: {file_path} ({len(content)} bytes)")
|
||||
|
||||
return self.get_url(file_path)
|
||||
|
||||
async def delete(self, file_path: str) -> bool:
|
||||
"""Delete file from local filesystem."""
|
||||
full_path = self.base_dir / file_path
|
||||
|
||||
if full_path.exists():
|
||||
full_path.unlink()
|
||||
logger.debug(f"Deleted from local: {file_path}")
|
||||
|
||||
# Clean up empty parent directories
|
||||
self._cleanup_empty_dirs(full_path.parent)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_url(self, file_path: str) -> str:
|
||||
"""Get URL for local file (served via /uploads mount)."""
|
||||
return f"/uploads/{file_path}"
|
||||
|
||||
async def exists(self, file_path: str) -> bool:
|
||||
"""Check if file exists locally."""
|
||||
return (self.base_dir / file_path).exists()
|
||||
|
||||
def _cleanup_empty_dirs(self, dir_path: Path) -> None:
|
||||
"""Remove empty directories up to base_dir."""
|
||||
try:
|
||||
while dir_path != self.base_dir and dir_path.exists():
|
||||
if not any(dir_path.iterdir()):
|
||||
dir_path.rmdir()
|
||||
dir_path = dir_path.parent
|
||||
else:
|
||||
break
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
class R2StorageBackend(StorageBackend):
|
||||
"""Cloudflare R2 storage backend (S3-compatible)."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize R2 storage backend."""
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
|
||||
if not all([
|
||||
settings.r2_account_id,
|
||||
settings.r2_access_key_id,
|
||||
settings.r2_secret_access_key,
|
||||
]):
|
||||
raise ValueError(
|
||||
"R2 storage requires R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, "
|
||||
"and R2_SECRET_ACCESS_KEY environment variables"
|
||||
)
|
||||
|
||||
# R2 endpoint URL
|
||||
endpoint_url = f"https://{settings.r2_account_id}.r2.cloudflarestorage.com"
|
||||
|
||||
# Configure boto3 client for R2
|
||||
self.client = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=endpoint_url,
|
||||
aws_access_key_id=settings.r2_access_key_id,
|
||||
aws_secret_access_key=settings.r2_secret_access_key,
|
||||
config=Config(
|
||||
signature_version="s3v4",
|
||||
retries={"max_attempts": 3, "mode": "adaptive"},
|
||||
),
|
||||
)
|
||||
|
||||
self.bucket_name = settings.r2_bucket_name
|
||||
self.public_url = settings.r2_public_url
|
||||
|
||||
logger.info(
|
||||
f"R2StorageBackend initialized: bucket={self.bucket_name}, "
|
||||
f"public_url={self.public_url or 'default'}"
|
||||
)
|
||||
|
||||
async def upload(self, file_path: str, content: bytes, content_type: str) -> str:
|
||||
"""Upload file to R2."""
|
||||
try:
|
||||
self.client.put_object(
|
||||
Bucket=self.bucket_name,
|
||||
Key=file_path,
|
||||
Body=content,
|
||||
ContentType=content_type,
|
||||
)
|
||||
|
||||
logger.debug(f"Uploaded to R2: {file_path} ({len(content)} bytes)")
|
||||
|
||||
return self.get_url(file_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"R2 upload failed for {file_path}: {e}")
|
||||
raise
|
||||
|
||||
async def delete(self, file_path: str) -> bool:
|
||||
"""Delete file from R2."""
|
||||
try:
|
||||
# Check if file exists first
|
||||
if not await self.exists(file_path):
|
||||
return False
|
||||
|
||||
self.client.delete_object(
|
||||
Bucket=self.bucket_name,
|
||||
Key=file_path,
|
||||
)
|
||||
|
||||
logger.debug(f"Deleted from R2: {file_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"R2 delete failed for {file_path}: {e}")
|
||||
return False
|
||||
|
||||
def get_url(self, file_path: str) -> str:
|
||||
"""Get public URL for R2 file."""
|
||||
if self.public_url:
|
||||
# Use custom domain
|
||||
return f"{self.public_url.rstrip('/')}/{file_path}"
|
||||
else:
|
||||
# Use default R2 public URL pattern
|
||||
# Note: Bucket must have public access enabled
|
||||
return f"https://{self.bucket_name}.{settings.r2_account_id}.r2.dev/{file_path}"
|
||||
|
||||
async def exists(self, file_path: str) -> bool:
|
||||
"""Check if file exists in R2."""
|
||||
try:
|
||||
self.client.head_object(Bucket=self.bucket_name, Key=file_path)
|
||||
return True
|
||||
except self.client.exceptions.ClientError as e:
|
||||
if e.response.get("Error", {}).get("Code") == "404":
|
||||
return False
|
||||
raise
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# STORAGE BACKEND FACTORY
|
||||
# =============================================================================
|
||||
|
||||
_storage_backend: StorageBackend | None = None
|
||||
|
||||
|
||||
def get_storage_backend() -> StorageBackend:
|
||||
"""
|
||||
Get the configured storage backend instance.
|
||||
|
||||
Returns:
|
||||
Storage backend based on STORAGE_BACKEND setting
|
||||
|
||||
Raises:
|
||||
ValueError: If storage backend is misconfigured
|
||||
"""
|
||||
global _storage_backend
|
||||
|
||||
if _storage_backend is not None:
|
||||
return _storage_backend
|
||||
|
||||
backend_type = settings.storage_backend.lower()
|
||||
|
||||
if backend_type == "r2":
|
||||
_storage_backend = R2StorageBackend()
|
||||
elif backend_type == "local":
|
||||
_storage_backend = LocalStorageBackend()
|
||||
else:
|
||||
raise ValueError(f"Unknown storage backend: {backend_type}")
|
||||
|
||||
return _storage_backend
|
||||
|
||||
|
||||
def reset_storage_backend() -> None:
|
||||
"""Reset the storage backend (useful for testing)."""
|
||||
global _storage_backend
|
||||
_storage_backend = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PUBLIC API
|
||||
# =============================================================================
|
||||
__all__ = [
|
||||
"StorageBackend",
|
||||
"LocalStorageBackend",
|
||||
"R2StorageBackend",
|
||||
"get_storage_backend",
|
||||
"reset_storage_backend",
|
||||
]
|
||||
299
docs/deployment/cloudflare.md
Normal file
299
docs/deployment/cloudflare.md
Normal file
@@ -0,0 +1,299 @@
|
||||
# CloudFlare Setup Guide
|
||||
|
||||
This guide covers setting up CloudFlare for Wizamart, including CDN, proxy, WAF, and R2 storage.
|
||||
|
||||
## Overview
|
||||
|
||||
CloudFlare provides:
|
||||
|
||||
| Feature | Benefit |
|
||||
|---------|---------|
|
||||
| **CDN** | Global edge caching for static assets |
|
||||
| **Proxy** | Hide origin IP, DDoS protection |
|
||||
| **WAF** | Web Application Firewall (basic rules free) |
|
||||
| **R2** | S3-compatible object storage (~$5/mo) |
|
||||
| **SSL** | Free SSL certificates |
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Add Your Domain to CloudFlare
|
||||
|
||||
1. Create a CloudFlare account at [cloudflare.com](https://cloudflare.com)
|
||||
2. Add your domain and follow the setup wizard
|
||||
3. Update your domain's nameservers to CloudFlare's
|
||||
|
||||
### 2. Configure DNS Records
|
||||
|
||||
Create these DNS records (with proxy enabled - orange cloud):
|
||||
|
||||
| Type | Name | Content | Proxy |
|
||||
|------|------|---------|-------|
|
||||
| A | @ | Your server IP | ✅ Proxied |
|
||||
| A | www | Your server IP | ✅ Proxied |
|
||||
| A | api | Your server IP | ✅ Proxied |
|
||||
| CNAME | media | your-bucket.r2.dev | ✅ Proxied |
|
||||
|
||||
### 3. Enable CloudFlare in Wizamart
|
||||
|
||||
```env
|
||||
# .env
|
||||
CLOUDFLARE_ENABLED=true
|
||||
```
|
||||
|
||||
This enables the CloudFlare middleware to:
|
||||
- Extract real client IPs from `CF-Connecting-IP`
|
||||
- Read client country from `CF-IPCountry`
|
||||
- Track requests via `CF-Ray` header
|
||||
|
||||
---
|
||||
|
||||
## SSL/TLS Configuration
|
||||
|
||||
### Recommended: Full (Strict) Mode
|
||||
|
||||
1. Go to **SSL/TLS** > **Overview**
|
||||
2. Select **Full (strict)**
|
||||
3. This requires a valid SSL certificate on your origin server
|
||||
|
||||
### Origin Certificates
|
||||
|
||||
For the origin server, you can use:
|
||||
|
||||
1. **Let's Encrypt** (recommended for VPS):
|
||||
```bash
|
||||
sudo certbot --nginx -d yourdomain.com
|
||||
```
|
||||
|
||||
2. **CloudFlare Origin Certificate** (15-year free cert):
|
||||
- Go to **SSL/TLS** > **Origin Server**
|
||||
- Create Certificate
|
||||
- Install on your server
|
||||
|
||||
---
|
||||
|
||||
## Caching Configuration
|
||||
|
||||
### Page Rules for Static Assets
|
||||
|
||||
Create page rules for optimal caching:
|
||||
|
||||
**Rule 1: Static Assets**
|
||||
- URL: `*yourdomain.com/static/*`
|
||||
- Setting: Cache Level → Cache Everything
|
||||
- Setting: Edge Cache TTL → 1 month
|
||||
|
||||
**Rule 2: Uploads**
|
||||
- URL: `*yourdomain.com/uploads/*`
|
||||
- Setting: Cache Level → Cache Everything
|
||||
- Setting: Edge Cache TTL → 1 week
|
||||
|
||||
**Rule 3: API (No Cache)**
|
||||
- URL: `*yourdomain.com/api/*`
|
||||
- Setting: Cache Level → Bypass
|
||||
|
||||
### Cache Rules (New Interface)
|
||||
|
||||
Or use the newer Cache Rules:
|
||||
|
||||
```
|
||||
Expression: (http.request.uri.path starts with "/static/")
|
||||
Action: Cache eligible → Override → 30 days
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cloudflare R2 Storage
|
||||
|
||||
### Create R2 Bucket
|
||||
|
||||
1. Go to **R2** in CloudFlare dashboard
|
||||
2. Click **Create bucket**
|
||||
3. Name: `wizamart-media`
|
||||
4. Location: Choose region closest to your users
|
||||
|
||||
### Create API Token
|
||||
|
||||
1. Go to **R2** > **Manage R2 API Tokens**
|
||||
2. Create new token with:
|
||||
- Permission: Object Read & Write
|
||||
- Bucket: Select your bucket
|
||||
3. Save the **Access Key ID** and **Secret Access Key**
|
||||
|
||||
### Configure Wizamart
|
||||
|
||||
```env
|
||||
# .env
|
||||
STORAGE_BACKEND=r2
|
||||
R2_ACCOUNT_ID=your_account_id
|
||||
R2_ACCESS_KEY_ID=your_access_key
|
||||
R2_SECRET_ACCESS_KEY=your_secret_key
|
||||
R2_BUCKET_NAME=wizamart-media
|
||||
```
|
||||
|
||||
### Enable Public Access (Optional)
|
||||
|
||||
For direct public access to uploaded files:
|
||||
|
||||
1. Go to **R2** > Your bucket > **Settings**
|
||||
2. Enable **Public Access**
|
||||
3. Note the public URL: `https://your-bucket.account-id.r2.dev`
|
||||
|
||||
Or use a custom domain:
|
||||
|
||||
1. Go to **R2** > Your bucket > **Settings** > **Custom Domains**
|
||||
2. Add `media.yourdomain.com`
|
||||
3. Update `.env`:
|
||||
```env
|
||||
R2_PUBLIC_URL=https://media.yourdomain.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Settings
|
||||
|
||||
### WAF Rules (Free Tier)
|
||||
|
||||
Enable these managed rules:
|
||||
|
||||
1. **CloudFlare Managed Ruleset** - Basic protection
|
||||
2. **OWASP Core Ruleset** - SQL injection, XSS protection
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
Create rate limiting rules for the API:
|
||||
|
||||
- URL: `/api/*`
|
||||
- Rate: 100 requests per minute
|
||||
- Action: Challenge or Block
|
||||
|
||||
### Bot Fight Mode
|
||||
|
||||
1. Go to **Security** > **Bots**
|
||||
2. Enable **Bot Fight Mode**
|
||||
|
||||
---
|
||||
|
||||
## Nginx Configuration for CloudFlare
|
||||
|
||||
When using CloudFlare proxy, update Nginx to trust CloudFlare IPs:
|
||||
|
||||
```nginx
|
||||
# /etc/nginx/conf.d/cloudflare.conf
|
||||
|
||||
# CloudFlare IP ranges
|
||||
set_real_ip_from 103.21.244.0/22;
|
||||
set_real_ip_from 103.22.200.0/22;
|
||||
set_real_ip_from 103.31.4.0/22;
|
||||
set_real_ip_from 104.16.0.0/13;
|
||||
set_real_ip_from 104.24.0.0/14;
|
||||
set_real_ip_from 108.162.192.0/18;
|
||||
set_real_ip_from 131.0.72.0/22;
|
||||
set_real_ip_from 141.101.64.0/18;
|
||||
set_real_ip_from 162.158.0.0/15;
|
||||
set_real_ip_from 172.64.0.0/13;
|
||||
set_real_ip_from 173.245.48.0/20;
|
||||
set_real_ip_from 188.114.96.0/20;
|
||||
set_real_ip_from 190.93.240.0/20;
|
||||
set_real_ip_from 197.234.240.0/22;
|
||||
set_real_ip_from 198.41.128.0/17;
|
||||
|
||||
# IPv6
|
||||
set_real_ip_from 2400:cb00::/32;
|
||||
set_real_ip_from 2606:4700::/32;
|
||||
set_real_ip_from 2803:f800::/32;
|
||||
set_real_ip_from 2405:b500::/32;
|
||||
set_real_ip_from 2405:8100::/32;
|
||||
set_real_ip_from 2a06:98c0::/29;
|
||||
set_real_ip_from 2c0f:f248::/32;
|
||||
|
||||
real_ip_header CF-Connecting-IP;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables Reference
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `CLOUDFLARE_ENABLED` | Enable CF header processing | `false` |
|
||||
| `STORAGE_BACKEND` | Storage backend (`local` or `r2`) | `local` |
|
||||
| `R2_ACCOUNT_ID` | CloudFlare account ID | - |
|
||||
| `R2_ACCESS_KEY_ID` | R2 API access key | - |
|
||||
| `R2_SECRET_ACCESS_KEY` | R2 API secret key | - |
|
||||
| `R2_BUCKET_NAME` | R2 bucket name | `wizamart-media` |
|
||||
| `R2_PUBLIC_URL` | Custom public URL for R2 | - |
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
### Check CloudFlare is Working
|
||||
|
||||
1. **Check headers** in browser DevTools:
|
||||
- `CF-Ray` header should be present
|
||||
- `CF-Cache-Status` shows caching status
|
||||
|
||||
2. **Test from command line**:
|
||||
```bash
|
||||
curl -I https://yourdomain.com/static/css/main.css
|
||||
# Should see CF-Ray and CF-Cache-Status headers
|
||||
```
|
||||
|
||||
### Check R2 is Working
|
||||
|
||||
1. **Upload a test file** via the admin media library
|
||||
2. **Check the URL** - should point to R2 or your custom domain
|
||||
3. **Verify in CloudFlare dashboard** - file should appear in bucket
|
||||
|
||||
### Check Real IP Logging
|
||||
|
||||
With `CLOUDFLARE_ENABLED=true`:
|
||||
|
||||
```bash
|
||||
# Check application logs
|
||||
journalctl -u wizamart | grep "real_ip"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### 521 Error (Web Server Down)
|
||||
|
||||
- Ensure your origin server is running
|
||||
- Check firewall allows CloudFlare IPs
|
||||
- Verify SSL certificate is valid
|
||||
|
||||
### 522 Error (Connection Timed Out)
|
||||
|
||||
- Check origin server is responding
|
||||
- Verify port 443 is open
|
||||
- Check server isn't overloaded
|
||||
|
||||
### 525 Error (SSL Handshake Failed)
|
||||
|
||||
- Ensure origin has valid SSL certificate
|
||||
- Try CloudFlare Origin Certificate
|
||||
- Check SSL mode is correct (Full vs Full Strict)
|
||||
|
||||
### R2 Access Denied
|
||||
|
||||
- Verify API token has correct permissions
|
||||
- Check bucket name is correct
|
||||
- Ensure bucket policy allows the operation
|
||||
|
||||
---
|
||||
|
||||
## Cost Estimate
|
||||
|
||||
| Service | Free Tier | Paid Usage |
|
||||
|---------|-----------|------------|
|
||||
| CDN | Unlimited | Free |
|
||||
| WAF | Basic rules | $20/mo for advanced |
|
||||
| R2 Storage | 10 GB/mo | $0.015/GB |
|
||||
| R2 Requests | 10M Class A, 10M Class B | $0.36/M, $0.0036/M |
|
||||
| SSL | Free | Free |
|
||||
|
||||
**Typical monthly cost for small-medium site: ~$5-15**
|
||||
@@ -73,31 +73,52 @@ This guide documents the complete infrastructure for the Wizamart platform, from
|
||||
|
||||
### What We Have Now
|
||||
|
||||
| Component | Technology | Status |
|
||||
|-----------|------------|--------|
|
||||
| Web Framework | FastAPI + Uvicorn | ✅ Production Ready |
|
||||
| Database | PostgreSQL 15 | ✅ Production Ready |
|
||||
| ORM | SQLAlchemy 2.0 | ✅ Production Ready |
|
||||
| Migrations | Alembic | ✅ Production Ready |
|
||||
| Templates | Jinja2 + Tailwind CSS | ✅ Production Ready |
|
||||
| Authentication | JWT (PyJWT) | ✅ Production Ready |
|
||||
| Email | SMTP/SendGrid/Mailgun/SES | ✅ Production Ready |
|
||||
| Payments | Stripe | ✅ Production Ready |
|
||||
| Task Queue | Celery 5.3 + Redis | ✅ Production Ready |
|
||||
| Task Scheduler | Celery Beat | ✅ Production Ready |
|
||||
| Task Monitoring | Flower | ✅ Production Ready |
|
||||
| Caching | Redis 7 | ✅ Production Ready |
|
||||
| File Storage | Local filesystem | ⏳ Needs S3 for prod |
|
||||
| Component | Technology | Dev Required | Prod Required | Status |
|
||||
|-----------|------------|--------------|---------------|--------|
|
||||
| Web Framework | FastAPI + Uvicorn | ✅ | ✅ | ✅ Production Ready |
|
||||
| Database | PostgreSQL 15 | ✅ | ✅ | ✅ Production Ready |
|
||||
| ORM | SQLAlchemy 2.0 | ✅ | ✅ | ✅ Production Ready |
|
||||
| Migrations | Alembic | ✅ | ✅ | ✅ Production Ready |
|
||||
| Templates | Jinja2 + Tailwind CSS | ✅ | ✅ | ✅ Production Ready |
|
||||
| Authentication | JWT (PyJWT) | ✅ | ✅ | ✅ Production Ready |
|
||||
| Email | SMTP/SendGrid/Mailgun/SES | ❌ | ✅ | ✅ Production Ready |
|
||||
| Payments | Stripe | ❌ | ✅ | ✅ Production Ready |
|
||||
| Task Queue | Celery 5.3 + Redis | ❌ | ✅ | ✅ Production Ready |
|
||||
| Task Scheduler | Celery Beat | ❌ | ✅ | ✅ Production Ready |
|
||||
| Task Monitoring | Flower | ❌ | ⚪ Optional | ✅ Production Ready |
|
||||
| Caching | Redis 7 | ❌ | ✅ | ✅ Production Ready |
|
||||
| File Storage | Local / Cloudflare R2 | Local | R2 | ✅ Production Ready |
|
||||
| Error Tracking | Sentry | ❌ | ⚪ Recommended | ✅ Production Ready |
|
||||
| CDN / WAF | CloudFlare | ❌ | ⚪ Recommended | ✅ Production Ready |
|
||||
|
||||
### What We Need to Add
|
||||
**Legend:** ✅ Required | ⚪ Optional/Recommended | ❌ Not needed
|
||||
|
||||
| Component | Priority | Reason |
|
||||
|-----------|----------|--------|
|
||||
| S3/MinIO | High | Scalable file storage |
|
||||
| Sentry | High | Error tracking |
|
||||
| CloudFlare | Medium | CDN + DDoS protection |
|
||||
| Load Balancer | Medium | Horizontal scaling |
|
||||
| Prometheus/Grafana | Low | Metrics and dashboards |
|
||||
### Development vs Production
|
||||
|
||||
**Development** requires only:
|
||||
- PostgreSQL (via Docker: `make docker-up`)
|
||||
- Python 3.11+ with dependencies
|
||||
|
||||
**Production** adds:
|
||||
- Redis (for Celery task queue)
|
||||
- Celery workers (for background tasks)
|
||||
- Reverse proxy (Nginx)
|
||||
- SSL certificates
|
||||
|
||||
**Optional but recommended for Production:**
|
||||
- Sentry (error tracking) - Set `SENTRY_DSN` to enable
|
||||
- Cloudflare R2 (cloud storage) - Set `STORAGE_BACKEND=r2` to enable
|
||||
- CloudFlare CDN (caching/DDoS) - Set `CLOUDFLARE_ENABLED=true` to enable
|
||||
|
||||
### What We Need for Enterprise (Future Growth)
|
||||
|
||||
| Component | Priority | When Needed | Estimated Users |
|
||||
|-----------|----------|-------------|-----------------|
|
||||
| Load Balancer | Medium | Horizontal scaling | 1,000+ concurrent |
|
||||
| Database Replica | Medium | Read-heavy workloads | 1,000+ concurrent |
|
||||
| Redis Sentinel | Low | Cache redundancy | 5,000+ concurrent |
|
||||
| Prometheus/Grafana | Low | Advanced metrics | Any (nice to have) |
|
||||
| Kubernetes | Low | Multi-region/HA | 10,000+ concurrent |
|
||||
|
||||
---
|
||||
|
||||
@@ -838,7 +859,7 @@ python -c "from app.core.database import engine; print(engine.connect())"
|
||||
|
||||
## Migration Path
|
||||
|
||||
### Phase 1: Current (Development) ✅ COMPLETE
|
||||
### Phase 1: Development ✅ COMPLETE
|
||||
- ✅ PostgreSQL 15 (Docker)
|
||||
- ✅ FastAPI + Uvicorn
|
||||
- ✅ Local file storage
|
||||
@@ -850,35 +871,93 @@ python -c "from app.core.database import engine; print(engine.connect())"
|
||||
- ✅ Celery 5.3 (background jobs)
|
||||
- ✅ Celery Beat (scheduled tasks)
|
||||
- ✅ Flower (task monitoring)
|
||||
- ⏳ S3/MinIO (file storage)
|
||||
- ⏳ Sentry (error tracking)
|
||||
- ✅ Cloudflare R2 (cloud file storage)
|
||||
- ✅ Sentry (error tracking)
|
||||
- ✅ CloudFlare CDN (caching + DDoS protection)
|
||||
|
||||
### Phase 3: Scale
|
||||
- Horizontal app scaling (multiple Uvicorn instances)
|
||||
- Load balancer (Nginx/HAProxy)
|
||||
- PostgreSQL read replicas
|
||||
- Redis Sentinel/cluster
|
||||
- CDN for static assets (CloudFlare)
|
||||
- Dedicated Celery workers per queue
|
||||
### Phase 3: Scale (1,000+ Users)
|
||||
- ⏳ Load balancer (Nginx/HAProxy/ALB)
|
||||
- ⏳ Horizontal app scaling (2-4 Uvicorn instances)
|
||||
- ⏳ PostgreSQL read replica
|
||||
- ⏳ Dedicated Celery workers per queue
|
||||
|
||||
### Phase 4: High Availability
|
||||
- Multi-region deployment
|
||||
- Database failover
|
||||
- Container orchestration (Kubernetes)
|
||||
- Full monitoring stack (Prometheus/Grafana/Loki)
|
||||
### Phase 4: Enterprise (5,000+ Users)
|
||||
- ⏳ Redis Sentinel/cluster
|
||||
- ⏳ Database connection pooling (PgBouncer)
|
||||
- ⏳ Full monitoring stack (Prometheus/Grafana)
|
||||
- ⏳ Log aggregation (Loki/ELK)
|
||||
|
||||
### Phase 5: High Availability (10,000+ Users)
|
||||
- ⏳ Multi-region deployment
|
||||
- ⏳ Database failover (streaming replication)
|
||||
- ⏳ Container orchestration (Kubernetes)
|
||||
- ⏳ Global CDN with edge caching
|
||||
|
||||
---
|
||||
|
||||
## Enterprise Upgrade Checklist
|
||||
|
||||
When you're ready to scale beyond 1,000 concurrent users:
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- [ ] **Load Balancer** - Add Nginx/HAProxy in front of API servers
|
||||
- Enables horizontal scaling
|
||||
- Health checks and automatic failover
|
||||
- SSL termination at edge
|
||||
|
||||
- [ ] **Multiple API Servers** - Run 2-4 Uvicorn instances
|
||||
- Scale horizontally instead of vertically
|
||||
- Blue-green deployments possible
|
||||
|
||||
- [ ] **Database Read Replica** - Add PostgreSQL replica
|
||||
- Offload read queries from primary
|
||||
- Backup without impacting production
|
||||
|
||||
- [ ] **Connection Pooling** - Add PgBouncer
|
||||
- Reduce database connection overhead
|
||||
- Handle connection spikes
|
||||
|
||||
### Monitoring & Observability
|
||||
|
||||
- [ ] **Prometheus + Grafana** - Metrics dashboards
|
||||
- Request latency, error rates, saturation
|
||||
- Database connection pool metrics
|
||||
- Celery queue lengths
|
||||
|
||||
- [ ] **Log Aggregation** - Loki or ELK stack
|
||||
- Centralized logs from all services
|
||||
- Search and alerting
|
||||
|
||||
- [ ] **Alerting** - PagerDuty/OpsGenie integration
|
||||
- On-call rotation
|
||||
- Escalation policies
|
||||
|
||||
### Security
|
||||
|
||||
- [ ] **WAF Rules** - CloudFlare or AWS WAF
|
||||
- SQL injection protection
|
||||
- Rate limiting at edge
|
||||
- Bot protection
|
||||
|
||||
- [ ] **Secrets Management** - HashiCorp Vault
|
||||
- Rotate credentials automatically
|
||||
- Audit access to secrets
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Configure S3/MinIO** - For production file storage (high priority)
|
||||
2. **Set up Sentry** - Error tracking (high priority)
|
||||
3. **Add CloudFlare** - CDN + DDoS protection (medium priority)
|
||||
4. **Configure load balancer** - When scaling horizontally
|
||||
5. **Choose production deployment** - VPS or Docker based on team preference
|
||||
**You're production-ready now!** Optional improvements:
|
||||
|
||||
1. **Enable Sentry** - Add `SENTRY_DSN` for error tracking (free tier)
|
||||
2. **Enable R2** - Set `STORAGE_BACKEND=r2` for cloud storage (~$5/mo)
|
||||
3. **Enable CloudFlare** - Proxy domain for CDN + DDoS protection (free tier)
|
||||
4. **Add load balancer** - When you need horizontal scaling
|
||||
|
||||
See also:
|
||||
- [Production Deployment Guide](production.md)
|
||||
- [CloudFlare Setup Guide](cloudflare.md)
|
||||
- [Docker Deployment](docker.md)
|
||||
- [Environment Configuration](environment.md)
|
||||
- [Background Tasks Architecture](../architecture/background-tasks.md)
|
||||
|
||||
@@ -393,11 +393,70 @@ free -h
|
||||
|
||||
### Set Up Sentry (Error Tracking)
|
||||
|
||||
Sentry provides real-time error tracking and performance monitoring.
|
||||
|
||||
1. **Create a Sentry account** at [sentry.io](https://sentry.io) (free tier available)
|
||||
2. **Create a new project** (Python/FastAPI)
|
||||
3. **Add to `.env`**:
|
||||
```env
|
||||
SENTRY_DSN=https://your-key@sentry.io/project-id
|
||||
SENTRY_ENVIRONMENT=production
|
||||
SENTRY_TRACES_SAMPLE_RATE=0.1
|
||||
```
|
||||
4. **Restart services**:
|
||||
```bash
|
||||
sudo systemctl restart wizamart wizamart-celery
|
||||
```
|
||||
|
||||
Sentry will now capture:
|
||||
- Unhandled exceptions
|
||||
- API errors with request context
|
||||
- Celery task failures
|
||||
- Performance traces (10% sample rate)
|
||||
|
||||
---
|
||||
|
||||
## Cloudflare R2 Storage
|
||||
|
||||
For production, use Cloudflare R2 instead of local storage for scalability and CDN integration.
|
||||
|
||||
### Setup
|
||||
|
||||
1. **Create R2 bucket** in CloudFlare dashboard
|
||||
2. **Create API token** with Object Read/Write permissions
|
||||
3. **Add to `.env`**:
|
||||
```env
|
||||
STORAGE_BACKEND=r2
|
||||
R2_ACCOUNT_ID=your_account_id
|
||||
R2_ACCESS_KEY_ID=your_access_key
|
||||
R2_SECRET_ACCESS_KEY=your_secret_key
|
||||
R2_BUCKET_NAME=wizamart-media
|
||||
R2_PUBLIC_URL=https://media.yourdomain.com
|
||||
```
|
||||
|
||||
See [CloudFlare Setup Guide](cloudflare.md) for detailed instructions.
|
||||
|
||||
---
|
||||
|
||||
## CloudFlare CDN & Proxy
|
||||
|
||||
For production, proxy your domain through CloudFlare for:
|
||||
- Global CDN caching
|
||||
- DDoS protection
|
||||
- Free SSL certificates
|
||||
- WAF (Web Application Firewall)
|
||||
|
||||
### Enable CloudFlare Headers
|
||||
|
||||
Add to `.env`:
|
||||
```env
|
||||
SENTRY_DSN=https://your-sentry-dsn
|
||||
CLOUDFLARE_ENABLED=true
|
||||
```
|
||||
|
||||
This enables proper handling of `CF-Connecting-IP` for real client IPs.
|
||||
|
||||
See [CloudFlare Setup Guide](cloudflare.md) for complete configuration.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -236,6 +236,31 @@ pytest -m integration
|
||||
| `FLOWER_URL` | Flower dashboard URL | `http://localhost:5555` | ❌ |
|
||||
| `FLOWER_PASSWORD` | Flower authentication password | `changeme` | ❌ |
|
||||
|
||||
### Sentry Error Tracking
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `SENTRY_DSN` | Sentry DSN (leave empty to disable) | - | ❌ |
|
||||
| `SENTRY_ENVIRONMENT` | Environment name | `development` | ❌ |
|
||||
| `SENTRY_TRACES_SAMPLE_RATE` | Performance tracing rate (0.0-1.0) | `0.1` | ❌ |
|
||||
|
||||
### Cloudflare R2 Storage
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `STORAGE_BACKEND` | Storage backend (`local` or `r2`) | `local` | ❌ |
|
||||
| `R2_ACCOUNT_ID` | Cloudflare account ID | - | ❌ (if r2) |
|
||||
| `R2_ACCESS_KEY_ID` | R2 access key | - | ❌ (if r2) |
|
||||
| `R2_SECRET_ACCESS_KEY` | R2 secret key | - | ❌ (if r2) |
|
||||
| `R2_BUCKET_NAME` | R2 bucket name | `wizamart-media` | ❌ |
|
||||
| `R2_PUBLIC_URL` | Custom public URL for R2 | - | ❌ |
|
||||
|
||||
### CloudFlare CDN
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `CLOUDFLARE_ENABLED` | Enable CloudFlare header handling | `false` | ❌ |
|
||||
|
||||
### Stripe Billing
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|
||||
@@ -435,9 +435,12 @@ We welcome contributions! Please see our [Contributing Guide](development/contri
|
||||
- ✅ Marketplace integration
|
||||
- ✅ Team management
|
||||
|
||||
**Production Ready**:
|
||||
- ✅ Error tracking (Sentry integration)
|
||||
- ✅ Cloud storage (Cloudflare R2)
|
||||
- ✅ CDN & WAF (CloudFlare)
|
||||
|
||||
**In Development**:
|
||||
- 🚧 Payment integration (Stripe-ready)
|
||||
- 🚧 Email notifications
|
||||
- 🚧 Advanced analytics
|
||||
|
||||
---
|
||||
|
||||
25
main.py
25
main.py
@@ -21,16 +21,41 @@ import logging
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import sentry_sdk
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sentry_sdk.integrations.fastapi import FastApiIntegration
|
||||
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.main import api_router
|
||||
from app.core.config import settings
|
||||
|
||||
# =============================================================================
|
||||
# SENTRY INITIALIZATION
|
||||
# =============================================================================
|
||||
# Initialize Sentry for error tracking (only if DSN is configured)
|
||||
if settings.sentry_dsn:
|
||||
sentry_sdk.init(
|
||||
dsn=settings.sentry_dsn,
|
||||
environment=settings.sentry_environment,
|
||||
traces_sample_rate=settings.sentry_traces_sample_rate,
|
||||
integrations=[
|
||||
FastApiIntegration(transaction_style="endpoint"),
|
||||
SqlalchemyIntegration(),
|
||||
],
|
||||
# Send PII data (emails, usernames) - set to False if privacy is critical
|
||||
send_default_pii=True,
|
||||
# Release version for tracking deployments
|
||||
release=f"wizamart@{settings.version}",
|
||||
)
|
||||
logging.getLogger(__name__).info(
|
||||
f"Sentry initialized for environment: {settings.sentry_environment}"
|
||||
)
|
||||
from app.core.database import get_db
|
||||
from app.core.lifespan import lifespan
|
||||
from app.exceptions import ServiceUnavailableException
|
||||
|
||||
149
middleware/cloudflare.py
Normal file
149
middleware/cloudflare.py
Normal file
@@ -0,0 +1,149 @@
|
||||
# middleware/cloudflare.py
|
||||
"""
|
||||
CloudFlare proxy middleware.
|
||||
|
||||
When enabled, this middleware handles CloudFlare-specific headers:
|
||||
- CF-Connecting-IP: Real client IP address
|
||||
- CF-IPCountry: Client's country code
|
||||
- CF-Ray: CloudFlare Ray ID for request tracing
|
||||
- CF-Visitor: Original request scheme
|
||||
|
||||
Usage:
|
||||
Enable by setting CLOUDFLARE_ENABLED=true in .env
|
||||
|
||||
The middleware will:
|
||||
1. Extract real client IP from CF-Connecting-IP header
|
||||
2. Store country code in request.state.client_country
|
||||
3. Log CF-Ray for request correlation
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CloudFlareMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware to handle CloudFlare proxy headers.
|
||||
|
||||
Only active when CLOUDFLARE_ENABLED=true.
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
"""Process request with CloudFlare headers."""
|
||||
|
||||
if not settings.cloudflare_enabled:
|
||||
return await call_next(request)
|
||||
|
||||
# Extract CloudFlare headers
|
||||
cf_connecting_ip = request.headers.get("CF-Connecting-IP")
|
||||
cf_country = request.headers.get("CF-IPCountry")
|
||||
cf_ray = request.headers.get("CF-Ray")
|
||||
cf_visitor = request.headers.get("CF-Visitor") # {"scheme":"https"}
|
||||
|
||||
# Store real client IP
|
||||
if cf_connecting_ip:
|
||||
# Override the client host with real IP
|
||||
# Note: Starlette's request.client is immutable, so we store in state
|
||||
request.state.real_ip = cf_connecting_ip
|
||||
else:
|
||||
# Fallback to standard client IP
|
||||
request.state.real_ip = (
|
||||
request.client.host if request.client else "unknown"
|
||||
)
|
||||
|
||||
# Store country code for geo features
|
||||
if cf_country:
|
||||
request.state.client_country = cf_country
|
||||
|
||||
# Store Ray ID for request tracing
|
||||
if cf_ray:
|
||||
request.state.cf_ray = cf_ray
|
||||
|
||||
# Determine original scheme
|
||||
if cf_visitor:
|
||||
try:
|
||||
import json
|
||||
visitor_info = json.loads(cf_visitor)
|
||||
request.state.original_scheme = visitor_info.get("scheme", "https")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
request.state.original_scheme = "https"
|
||||
|
||||
# Log request with CloudFlare context
|
||||
logger.debug(
|
||||
f"CloudFlare request: ip={cf_connecting_ip}, "
|
||||
f"country={cf_country}, ray={cf_ray}"
|
||||
)
|
||||
|
||||
# Process request
|
||||
response = await call_next(request)
|
||||
|
||||
# Add CF-Ray to response for debugging
|
||||
if cf_ray:
|
||||
response.headers["X-CF-Ray"] = cf_ray
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def get_real_client_ip(request: Request) -> str:
|
||||
"""
|
||||
Get the real client IP address.
|
||||
|
||||
When behind CloudFlare, this returns CF-Connecting-IP.
|
||||
Otherwise, returns the standard client IP.
|
||||
|
||||
Args:
|
||||
request: Starlette Request object
|
||||
|
||||
Returns:
|
||||
Client IP address string
|
||||
"""
|
||||
# First check if CloudFlare middleware set real_ip
|
||||
if hasattr(request.state, "real_ip"):
|
||||
return request.state.real_ip
|
||||
|
||||
# Check for CF-Connecting-IP header directly
|
||||
cf_ip = request.headers.get("CF-Connecting-IP")
|
||||
if cf_ip:
|
||||
return cf_ip
|
||||
|
||||
# Check for X-Forwarded-For (generic proxy header)
|
||||
forwarded_for = request.headers.get("X-Forwarded-For")
|
||||
if forwarded_for:
|
||||
# Take the first IP (client IP)
|
||||
return forwarded_for.split(",")[0].strip()
|
||||
|
||||
# Fallback to request client
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def get_client_country(request: Request) -> str | None:
|
||||
"""
|
||||
Get the client's country code from CloudFlare.
|
||||
|
||||
Args:
|
||||
request: Starlette Request object
|
||||
|
||||
Returns:
|
||||
ISO 3166-1 alpha-2 country code, or None if not available
|
||||
"""
|
||||
if hasattr(request.state, "client_country"):
|
||||
return request.state.client_country
|
||||
|
||||
return request.headers.get("CF-IPCountry")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PUBLIC API
|
||||
# =============================================================================
|
||||
__all__ = [
|
||||
"CloudFlareMiddleware",
|
||||
"get_real_client_ip",
|
||||
"get_client_country",
|
||||
]
|
||||
@@ -188,6 +188,7 @@ nav:
|
||||
- Launch Readiness: deployment/launch-readiness.md
|
||||
- Traditional VPS: deployment/production.md
|
||||
- Docker: deployment/docker.md
|
||||
- CloudFlare Setup: deployment/cloudflare.md
|
||||
- GitLab CI/CD: deployment/gitlab.md
|
||||
- Environment Variables: deployment/environment.md
|
||||
- Stripe Integration: deployment/stripe-integration.md
|
||||
|
||||
@@ -41,4 +41,10 @@ stripe>=7.0.0
|
||||
celery[redis]==5.3.6
|
||||
redis==5.0.1
|
||||
kombu==5.3.4
|
||||
flower==2.0.1
|
||||
flower==2.0.1
|
||||
|
||||
# Error tracking
|
||||
sentry-sdk[fastapi]>=2.0.0
|
||||
|
||||
# Cloud storage (S3-compatible - Cloudflare R2)
|
||||
boto3>=1.34.0
|
||||
Reference in New Issue
Block a user