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
38 changes: 38 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.12"
cache: pip
- name: Install test dependencies
run: pip install fastapi pydantic-settings sqlalchemy loguru pytest
- name: Run regression tests
run: python -m pytest tests -q
- name: Parse PowerShell scripts
shell: pwsh
run: |
$errors = @()
Get-ChildItem -Recurse -Include *.ps1,*.psm1 | ForEach-Object {
$tokens = $null
$parseErrors = $null
[System.Management.Automation.Language.Parser]::ParseFile($_.FullName, [ref]$tokens, [ref]$parseErrors) > $null
$errors += $parseErrors
}
if ($errors.Count -gt 0) {
$errors | Format-List
exit 1
}
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,7 @@ data/storage/LegalAssociatesLLP/2024-09-08/4f380a04-02af-4a46-8b3d-f39bae42e9da_
data/storage/PropertyManagementInc/2025-01-27/53b571aa-a79f-4be7-b867-7f0c258f39c8_2025-01-27_Property_Management_Inc_Medical Record.pdf
data/storage/TechStartGmbH/2025-06-09/75d86917-31e1-4a2c-b5c4-3433f9841d1c_2025-06-09_TechStart_GmbH_Contract.pdf
.DS_Store
# Python bytecode and test caches
__pycache__/
*.py[cod]
.pytest_cache/
5 changes: 3 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ ENV PYTHONUNBUFFERED=1 \
# Application settings (should be overridden in production)
DATABASE_URL=sqlite:///./data/documents.db \
SECRET_KEY=MUST-BE-SET-IN-PRODUCTION \
AI_PROVIDER=openai
AI_PROVIDER=openai \
TRUSTED_PROXY_IPS=127.0.0.1

# Add metadata labels
LABEL maintainer="Document Manager Team" \
Expand All @@ -106,4 +107,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:8000/api/health || exit 1

# Use entrypoint script for initialization
ENTRYPOINT ["/app/docker-entrypoint.sh"]
ENTRYPOINT ["/app/docker-entrypoint.sh"]
2 changes: 1 addition & 1 deletion app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

# Configure loguru to write logs to the logs directory
log_dir = Path(__file__).parent.parent / "data" / "logs"
log_dir.mkdir(exist_ok=True)
log_dir.mkdir(parents=True, exist_ok=True)

# Remove default logger
logger.remove()
Expand Down
39 changes: 22 additions & 17 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,6 @@ class Settings(BaseSettings):
azure_openai_chat_deployment: str = ""
azure_openai_embeddings_deployment: str = ""

def __init__(self, **kwargs):
# Don't use any values from kwargs that might come from env vars
# Only use explicitly passed values (which should be none for base Settings)
# Filter out any kwargs that might come from env vars
filtered_kwargs = {k: v for k, v in kwargs.items() if not k.startswith('NEVER_MATCH_THIS_PREFIX_')}
super().__init__(**filtered_kwargs)

# ChromaDB
chroma_host: str = "localhost"
chroma_port: int = 8001
Expand Down Expand Up @@ -61,7 +54,10 @@ def __init__(self, **kwargs):
jwt_secret_key: Optional[str] = None # JWT secret key for authentication
algorithm: str = "HS256"
access_token_expire_minutes: int = 30
environment: str = "development"
production_mode: bool = False # Set to True in production for secure cookies
cors_origins: str = "http://localhost:3000,http://localhost:8000,http://127.0.0.1:8000"
trusted_proxy_ips: str = "127.0.0.1,::1"

# Logging
log_level: str = "INFO"
Expand All @@ -73,21 +69,31 @@ def __init__(self, **kwargs):
ai_max_retries: int = 2 # Maximum number of retries for failed AI requests

model_config = SettingsConfigDict(
# No env_file - all settings come from database or defaults
# Runtime values may be supplied by Docker/Kubernetes environment
# variables. Database-backed settings still override these defaults.
case_sensitive=False,
# Explicitly disable reading from environment variables
env_file=None,
# Don't read from environment variables at all
# This ensures settings only come from database or defaults
env_ignore_empty=True,
# This is the key setting to disable env vars completely
# By setting a prefix that will never match, we prevent env var loading
env_prefix="NEVER_MATCH_THIS_PREFIX_"
env_prefix=""
)

def model_post_init(self, __context: Any) -> None:
# Keep the existing ENVIRONMENT=production setup contract while still
# allowing an explicit PRODUCTION_MODE value to override it.
if "production_mode" not in self.model_fields_set:
self.production_mode = self.environment.lower() == "production"

@property
def allowed_extensions_list(self) -> list:
return [ext.strip().lower() for ext in self.allowed_extensions.split(",")]

@property
def cors_origins_list(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]

@property
def trusted_proxy_ips_list(self) -> list[str]:
return [ip.strip() for ip in self.trusted_proxy_ips.split(",") if ip.strip()]

@property
def max_file_size_bytes(self) -> int:
Expand All @@ -107,8 +113,8 @@ class DatabaseSettings(Settings):
"""Settings that loads configuration from database"""

def __init__(self, db: Session = None, **kwargs):
# First, load defaults WITHOUT environment variables
# We pass _env_file=None to ensure no env vars are loaded
# Load defaults and runtime environment variables first. Persisted
# application settings take precedence when a database is available.
super().__init__(_env_file=None, **kwargs)

# Then override with database values if available
Expand Down Expand Up @@ -188,4 +194,3 @@ def reset_settings():
"""Reset the global settings instance"""
global _settings
_settings = None

7 changes: 4 additions & 3 deletions app/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,18 @@
from sqlalchemy.orm import sessionmaker, Session
from loguru import logger
import os
from .config import get_settings

# Use simple SQLite database with default path
DATABASE_URL = "sqlite:///./data/documents.db"
# Honour the documented DATABASE_URL runtime setting.
DATABASE_URL = get_settings().database_url

# Ensure data directory exists
os.makedirs("data", exist_ok=True)

# Create engine with minimal configuration
engine = create_engine(
DATABASE_URL,
connect_args={"check_same_thread": False}
connect_args={"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
)

# Create session factory
Expand Down
10 changes: 5 additions & 5 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,12 @@
version="1.0.0"
)

app_settings = get_settings()

# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://localhost:8000", "http://127.0.0.1:8000"], # Restrict origins
allow_origins=app_settings.cors_origins_list,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Content-Type", "Authorization", "X-CSRF-Token"], # Restrict headers
Expand All @@ -44,9 +46,6 @@
app.add_exception_handler(StarletteHTTPException, ErrorHandler.starlette_exception_handler)
app.add_exception_handler(Exception, ErrorHandler.general_exception_handler)

# Get settings to determine production mode
app_settings = get_settings()

# Enable CSRF protection
csrf_protect = CSRFProtect(
secure=app_settings.production_mode, # Use secure cookies in production
Expand All @@ -71,7 +70,8 @@
default_limit=100, # 100 requests per minute for general endpoints
window_seconds=60,
login_limit=5, # 5 login attempts per 5 minutes
login_window_seconds=300
login_window_seconds=300,
trusted_proxy_ips=app_settings.trusted_proxy_ips_list,
)
rate_limit.init_app(app)

Expand Down
36 changes: 20 additions & 16 deletions app/middleware/rate_limit_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
Rate limiting middleware for FastAPI to prevent brute force attacks and API abuse.
"""
import time
from typing import Dict, Optional, Tuple
from typing import Dict, Iterable, Optional, Tuple
from collections import defaultdict
from fastapi import Request
from fastapi.responses import JSONResponse
Expand All @@ -29,14 +29,16 @@ def __init__(
window_seconds: int = 60, # 1 minute window
login_limit: int = 5, # stricter limit for login attempts
login_window_seconds: int = 300, # 5 minute window for login
cleanup_interval: int = 300 # cleanup every 5 minutes
cleanup_interval: int = 300, # cleanup every 5 minutes
trusted_proxy_ips: Optional[Iterable[str]] = None,
):
super().__init__(app)
self.default_limit = default_limit
self.window_seconds = window_seconds
self.login_limit = login_limit
self.login_window_seconds = login_window_seconds
self.cleanup_interval = cleanup_interval
self.trusted_proxy_ips = set(trusted_proxy_ips or ())

# Store request counts: {ip: {endpoint: [(timestamp, count)]}}
self.request_counts: Dict[str, Dict[str, list]] = defaultdict(lambda: defaultdict(list))
Expand Down Expand Up @@ -81,19 +83,21 @@ async def _cleanup_old_entries(self):

def get_client_ip(self, request: Request) -> str:
"""Extract client IP address from request."""
# Check for proxy headers
forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
# Take the first IP in the chain
return forwarded_for.split(",")[0].strip()

# Check for other proxy headers
real_ip = request.headers.get("X-Real-IP")
if real_ip:
return real_ip

# Fallback to direct connection
return request.client.host if request.client else "unknown"
direct_ip = request.client.host if request.client else "unknown"

# Forwarding headers are attacker-controlled unless the direct peer is
# a configured reverse proxy. Never let an arbitrary client choose the
# rate-limit bucket used for its request.
if direct_ip in self.trusted_proxy_ips:
forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
return forwarded_for.split(",")[0].strip()

real_ip = request.headers.get("X-Real-IP")
if real_ip:
return real_ip.strip()

return direct_ip

def get_rate_limit(self, path: str) -> Tuple[int, int]:
"""Get rate limit for a specific path."""
Expand Down Expand Up @@ -212,4 +216,4 @@ def __init__(self, app=None, **kwargs):
def init_app(self, app):
"""Initialize rate limiting for the FastAPI app."""
# Add rate limit middleware
app.add_middleware(RateLimitMiddleware, **self.config)
app.add_middleware(RateLimitMiddleware, **self.config)
7 changes: 5 additions & 2 deletions frontend/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,10 @@ <h4><i class="fas fa-user-shield me-2"></i>Admin User Setup</h4>

<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
const API_BASE = 'http://localhost:8000/api';
// Keep API requests on the same scheme, host and externally mapped port
// as the page. Hard-coding port 8000 breaks Docker mappings such as
// `-p 8042:8000` and HTTPS reverse proxies.
const API_BASE = `${window.location.origin}/api`;

// Check if initial setup is needed
async function checkSetupStatus() {
Expand Down Expand Up @@ -793,4 +796,4 @@ <h4><i class="fas fa-user-shield me-2"></i>Admin User Setup</h4>
</script>
</body>

</html>
</html>
37 changes: 22 additions & 15 deletions setup.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,18 @@ function Require-Docker() {
function New-EnvFile() {
if (-not (Test-Path .env)) {
Info 'Creating .env file...'
$secret = try { python - <<'PY'
import secrets
print(secrets.token_urlsafe(32))
PY
} catch { 'change-me-in-production' }
$secret = 'change-me-in-production'
$pythonCommand = Get-Command python3, python -ErrorAction SilentlyContinue | Select-Object -First 1
if ($pythonCommand) {
try {
$generatedSecret = & $pythonCommand.Source -c 'import secrets; print(secrets.token_urlsafe(32))'
if ($LASTEXITCODE -eq 0 -and $generatedSecret) {
$secret = $generatedSecret.Trim()
}
} catch {
Warn 'Could not generate a random secret with Python; update SECRET_KEY manually.'
}
}

@"
# Security - CHANGE THIS IN PRODUCTION!
Expand Down Expand Up @@ -94,22 +101,23 @@ function Invoke-Prod() {
exit 1
}

$env = Get-Content .env | Where-Object { $_ -notmatch '^#' -and $_.Trim() }
foreach ($line in $env) {
$envValues = @{}
$envLines = Get-Content .env | Where-Object { $_ -notmatch '^#' -and $_.Trim() }
foreach ($line in $envLines) {
$kv = $line.Split('=',2)
if ($kv.Length -eq 2) { $env:$($kv[0]) = $kv[1] }
if ($kv.Length -eq 2) { $envValues[$kv[0].Trim()] = $kv[1] }
}

$pwdPath = (Get-Location).Path
docker run -d `
--name "$ContainerName" `
-p 8000:8000 `
-e SECRET_KEY="$env:SECRET_KEY" `
-e DATABASE_URL="$env:DATABASE_URL" `
-e AI_PROVIDER="$env:AI_PROVIDER" `
-e OPENAI_API_KEY="$env:OPENAI_API_KEY" `
-e ENVIRONMENT="$env:ENVIRONMENT" `
-e LOG_LEVEL="$env:LOG_LEVEL" `
-e "SECRET_KEY=$($envValues['SECRET_KEY'])" `
-e "DATABASE_URL=$($envValues['DATABASE_URL'])" `
-e "AI_PROVIDER=$($envValues['AI_PROVIDER'])" `
-e "OPENAI_API_KEY=$($envValues['OPENAI_API_KEY'])" `
-e "ENVIRONMENT=$($envValues['ENVIRONMENT'])" `
-e "LOG_LEVEL=$($envValues['LOG_LEVEL'])" `
-v "$pwdPath/data:/app/data" `
-v "$pwdPath/staging:/app/staging" `
-v "$pwdPath/storage:/app/storage" `
Expand Down Expand Up @@ -176,4 +184,3 @@ Examples:
"@
}
}

4 changes: 2 additions & 2 deletions supervisord.conf
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ stderr_logfile=/app/data/logs/chromadb.stderr.log
environment=IS_PERSISTENT="TRUE",ANONYMIZED_TELEMETRY="FALSE",ALLOW_RESET="FALSE"

[program:documentmanager]
command=python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips='*'
command=python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips=%(ENV_TRUSTED_PROXY_IPS)s
directory=/app
autostart=true
autorestart=true
Expand All @@ -35,4 +35,4 @@ chmod=0700
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface

[supervisorctl]
serverurl=unix:///tmp/supervisor.sock
serverurl=unix:///tmp/supervisor.sock
Loading