Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
SETTINGS_ENV_KEYS: dict[str, str] = {
|
||||
"enable_rate_limit": "ENABLE_RATE_LIMIT",
|
||||
"enable_docs": "ENABLE_DOCS",
|
||||
"cookie_secure": "COOKIE_SECURE",
|
||||
"jwt_access_ttl_min": "JWT_ACCESS_TTL_MIN",
|
||||
"auth_lockout_attempts": "AUTH_LOCKOUT_ATTEMPTS",
|
||||
"auth_lockout_minutes": "AUTH_LOCKOUT_MINUTES",
|
||||
"cors_origins": "CORS_ORIGINS",
|
||||
"frontend_url": "FRONTEND_URL",
|
||||
"public_base_url": "PUBLIC_BASE_URL",
|
||||
"smtp_host": "SMTP_HOST",
|
||||
"smtp_port": "SMTP_PORT",
|
||||
"smtp_from": "SMTP_FROM",
|
||||
"avatar_max_bytes": "AVATAR_MAX_BYTES",
|
||||
"media_url_ttl_seconds": "MEDIA_URL_TTL_SECONDS",
|
||||
"log_level": "LOG_LEVEL",
|
||||
"audit_retention_days": "AUDIT_RETENTION_DAYS",
|
||||
"jwt_refresh_ttl_days": "JWT_REFRESH_TTL_DAYS",
|
||||
}
|
||||
|
||||
MANAGED_KEYS = tuple(SETTINGS_ENV_KEYS.keys())
|
||||
|
||||
|
||||
def _settings_file() -> Path:
|
||||
return Path(settings.compton_settings_path)
|
||||
|
||||
|
||||
def get_settings_values() -> dict[str, Any]:
|
||||
return {key: getattr(settings, key) for key in MANAGED_KEYS}
|
||||
|
||||
|
||||
def _coerce_value(key: str, value: Any) -> Any:
|
||||
current = getattr(settings, key)
|
||||
if isinstance(current, bool):
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.lower() in {"1", "true", "yes", "on"}
|
||||
return bool(value)
|
||||
if isinstance(current, int):
|
||||
return int(value)
|
||||
if isinstance(current, list):
|
||||
if isinstance(value, list):
|
||||
return [str(item) for item in value]
|
||||
if isinstance(value, str):
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
raise ValueError(f"INVALID_LIST_{key}")
|
||||
return value
|
||||
|
||||
|
||||
def env_locks() -> dict[str, bool]:
|
||||
return {key: os.getenv(env_key) is not None for key, env_key in SETTINGS_ENV_KEYS.items()}
|
||||
|
||||
|
||||
def apply_settings_to_app(values: dict[str, Any]) -> None:
|
||||
for key, value in values.items():
|
||||
if key not in MANAGED_KEYS:
|
||||
continue
|
||||
setattr(settings, key, _coerce_value(key, value))
|
||||
|
||||
|
||||
def read_settings() -> dict[str, Any]:
|
||||
path = _settings_file()
|
||||
if not path.exists():
|
||||
return {}
|
||||
with path.open("r", encoding="utf-8") as file:
|
||||
payload = json.load(file)
|
||||
if not isinstance(payload, dict):
|
||||
return {}
|
||||
return {key: payload[key] for key in MANAGED_KEYS if key in payload}
|
||||
|
||||
|
||||
def write_settings(partial: dict[str, Any]) -> dict[str, Any]:
|
||||
locks = env_locks()
|
||||
current = get_settings_values()
|
||||
for key, value in partial.items():
|
||||
if key not in MANAGED_KEYS:
|
||||
continue
|
||||
if locks[key]:
|
||||
continue
|
||||
current[key] = _coerce_value(key, value)
|
||||
|
||||
path = _settings_file()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8") as file:
|
||||
json.dump(current, file, ensure_ascii=False, indent=2)
|
||||
return current
|
||||
|
||||
|
||||
def bootstrap_settings() -> None:
|
||||
apply_settings_to_app(read_settings())
|
||||
|
||||
|
||||
def get_settings_payload() -> dict[str, Any]:
|
||||
locks = env_locks()
|
||||
values = get_settings_values()
|
||||
secrets = {
|
||||
"jwt_access_secret_configured": bool(settings.jwt_access_secret),
|
||||
"jwt_refresh_pepper_configured": bool(settings.jwt_refresh_pepper),
|
||||
"smtp_password_configured": bool(settings.smtp_password),
|
||||
"s3_secret_key_configured": bool(settings.s3_secret_key),
|
||||
}
|
||||
return {
|
||||
"values": values,
|
||||
"locks": locks,
|
||||
"settings_path": str(_settings_file()),
|
||||
"secrets": secrets,
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def _audit_path() -> Path:
|
||||
return Path(settings.admin_audit_log_path)
|
||||
|
||||
|
||||
def write_audit_event(
|
||||
action: str,
|
||||
actor_user_id: str,
|
||||
actor_email: str,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
payload = {
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"action": action,
|
||||
"actor_user_id": actor_user_id,
|
||||
"actor_email": actor_email,
|
||||
"details": details or {},
|
||||
}
|
||||
path = _audit_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("a", encoding="utf-8") as file:
|
||||
file.write(json.dumps(payload, ensure_ascii=False))
|
||||
file.write("\n")
|
||||
|
||||
|
||||
def read_audit_events(limit: int = 200) -> list[dict[str, Any]]:
|
||||
path = _audit_path()
|
||||
if not path.exists():
|
||||
return []
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
tail = lines[-limit:]
|
||||
events: list[dict[str, Any]] = []
|
||||
for line in tail:
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
events.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return list(reversed(events))
|
||||
@@ -0,0 +1,55 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
from app.core.install_secrets import load_install_secrets_to_env
|
||||
|
||||
load_install_secrets_to_env()
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
database_url: str = "postgresql+psycopg://user:pass@localhost:5432/compton"
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
jwt_access_secret: str = "change-me-access-secret-with-at-least-32-bytes"
|
||||
jwt_refresh_pepper: str = "change-me-refresh-pepper-with-at-least-32-bytes"
|
||||
jwt_access_ttl_min: int = 15
|
||||
jwt_refresh_ttl_days: int = 30
|
||||
enable_docs: bool = True
|
||||
cookie_secure: bool = False
|
||||
cors_origins: list[str] = ["http://localhost:5173"]
|
||||
enable_rate_limit: bool = True
|
||||
auth_lockout_attempts: int = 5
|
||||
auth_lockout_minutes: int = 15
|
||||
admin_initial_password: str = "Admin1234"
|
||||
demo_user_password: str = "User1234"
|
||||
demo_ops_password: str = "OpsAdmin1234"
|
||||
seed_demo_users: bool = True
|
||||
smtp_host: str = "localhost"
|
||||
smtp_port: int = 1025
|
||||
smtp_user: str = ""
|
||||
smtp_password: str = ""
|
||||
smtp_from: str = "noreply@compton.example"
|
||||
frontend_url: str = "http://localhost:5173"
|
||||
public_base_url: str = "http://localhost:5173"
|
||||
auth_token_ttl_hours: int = 1
|
||||
email_delivery_mode: str = "smtp"
|
||||
s3_endpoint: str = "http://localhost:9000"
|
||||
s3_access_key: str = "minio"
|
||||
s3_secret_key: str = "minio123"
|
||||
s3_bucket: str = "compton"
|
||||
s3_region: str = "us-east-1"
|
||||
storage_mode: str = "s3"
|
||||
avatar_max_bytes: int = 2 * 1024 * 1024
|
||||
media_url_ttl_seconds: int = 600
|
||||
log_level: str = "INFO"
|
||||
audit_retention_days: int = 90
|
||||
password_denylist_path: str = "data/security/password-denylist.txt"
|
||||
compton_settings_path: str = "data/compton_settings.json"
|
||||
admin_audit_log_path: str = "data/logs/admin-audit.jsonl"
|
||||
server_log_path: str = "data/logs/server.log"
|
||||
enable_test_routes: bool = False
|
||||
app_env: str = "development"
|
||||
trusted_proxy_ips: str = ""
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import bcrypt
|
||||
from jose import jwt
|
||||
|
||||
def generate_secret_token_urlsafe(length: int = 32) -> str:
|
||||
return secrets.token_urlsafe(length)
|
||||
|
||||
|
||||
def generate_secret_token_hex(length: int = 32) -> str:
|
||||
return secrets.token_hex(length)
|
||||
|
||||
|
||||
def generate_install_bundle() -> dict[str, str]:
|
||||
postgres_user = "compton_app"
|
||||
postgres_password = generate_secret_token_urlsafe(32)
|
||||
postgres_db = "compton"
|
||||
minio_root_user = "minio"
|
||||
minio_root_password = generate_secret_token_urlsafe(32)
|
||||
return {
|
||||
"POSTGRES_USER": postgres_user,
|
||||
"POSTGRES_PASSWORD": postgres_password,
|
||||
"POSTGRES_DB": postgres_db,
|
||||
"DATABASE_URL": f"postgresql+psycopg://{postgres_user}:{postgres_password}@postgres:5432/{postgres_db}",
|
||||
"JWT_ACCESS_SECRET": generate_secret_token_hex(32),
|
||||
"JWT_REFRESH_PEPPER": generate_secret_token_hex(32),
|
||||
"S3_ACCESS_KEY": minio_root_user,
|
||||
"S3_SECRET_KEY": minio_root_password,
|
||||
"MINIO_ROOT_USER": minio_root_user,
|
||||
"MINIO_ROOT_PASSWORD": minio_root_password,
|
||||
}
|
||||
|
||||
|
||||
def hash_password(raw_password: str) -> str:
|
||||
return bcrypt.hashpw(raw_password.encode("utf-8"), bcrypt.gensalt(rounds=12)).decode("utf-8")
|
||||
|
||||
|
||||
def verify_password(raw_password: str, password_hash: str) -> bool:
|
||||
return bcrypt.checkpw(raw_password.encode("utf-8"), password_hash.encode("utf-8"))
|
||||
|
||||
|
||||
def create_access_token(
|
||||
user_id: str,
|
||||
role: str,
|
||||
is_superuser: bool = False,
|
||||
*,
|
||||
enterprise_id: str | None = None,
|
||||
enterprise_role: str | None = None,
|
||||
farm_ids: list[str] | None = None,
|
||||
) -> str:
|
||||
from app.core.config import settings
|
||||
from app.core.jwt_denylist import get_auth_epoch
|
||||
|
||||
now = datetime.now(UTC)
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"role": role,
|
||||
"is_superuser": is_superuser,
|
||||
"auth_epoch": get_auth_epoch(user_id),
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int((now + timedelta(minutes=settings.jwt_access_ttl_min)).timestamp()),
|
||||
"jti": generate_secret_token_hex(16),
|
||||
}
|
||||
if enterprise_id:
|
||||
payload["enterprise_id"] = enterprise_id
|
||||
if enterprise_role:
|
||||
payload["enterprise_role"] = enterprise_role
|
||||
if farm_ids is not None:
|
||||
payload["farm_ids"] = farm_ids
|
||||
return jwt.encode(payload, settings.jwt_access_secret, algorithm="HS256")
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict:
|
||||
from app.core.config import settings
|
||||
|
||||
return jwt.decode(token, settings.jwt_access_secret, algorithms=["HS256"])
|
||||
|
||||
|
||||
def generate_refresh_token() -> str:
|
||||
return generate_secret_token_urlsafe(48)
|
||||
|
||||
|
||||
def generate_opaque_token() -> str:
|
||||
return generate_secret_token_urlsafe(32)
|
||||
|
||||
|
||||
def hash_opaque_token(token: str) -> str:
|
||||
return hash_refresh_token(token)
|
||||
|
||||
|
||||
def hash_refresh_token(token: str) -> str:
|
||||
from app.core.config import settings
|
||||
|
||||
return hashlib.sha256(f"{token}:{settings.jwt_refresh_pepper}".encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def build_signed_media_url(stored_url: str | None) -> str | None:
|
||||
from app.core.config import settings
|
||||
|
||||
if not stored_url:
|
||||
return None
|
||||
if not stored_url.startswith("/api/v1/media/files/"):
|
||||
return stored_url
|
||||
path = stored_url.removeprefix("/api/v1/media/files/")
|
||||
expires = int(time.time()) + settings.media_url_ttl_seconds
|
||||
signature = _sign_media_path(path, expires)
|
||||
query = urlencode({"expires": expires, "sig": signature})
|
||||
return f"/api/v1/media/files/{path}?{query}"
|
||||
|
||||
|
||||
def verify_signed_media(path: str, expires: int, signature: str) -> bool:
|
||||
if expires < int(time.time()):
|
||||
return False
|
||||
expected = _sign_media_path(path, expires)
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
||||
|
||||
def _sign_media_path(path: str, expires: int) -> str:
|
||||
from app.core.config import settings
|
||||
|
||||
payload = f"{path}:{expires}"
|
||||
return hmac.new(
|
||||
settings.jwt_access_secret.encode("utf-8"),
|
||||
payload.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
_connect_args: dict[str, object] = {}
|
||||
_engine_kwargs: dict[str, object] = {
|
||||
"pool_pre_ping": True,
|
||||
"future": True,
|
||||
"pool_size": 5,
|
||||
"max_overflow": 10,
|
||||
"pool_recycle": 1800,
|
||||
}
|
||||
|
||||
if settings.database_url.startswith("sqlite"):
|
||||
_connect_args["check_same_thread"] = False
|
||||
_engine_kwargs["poolclass"] = StaticPool
|
||||
_engine_kwargs.pop("pool_size", None)
|
||||
_engine_kwargs.pop("max_overflow", None)
|
||||
_engine_kwargs.pop("pool_recycle", None)
|
||||
|
||||
engine = create_engine(settings.database_url, connect_args=_connect_args, **_engine_kwargs)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def session_scope() -> Generator[Session, None, None]:
|
||||
session = SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
session = SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
|
||||
def ensure_utc(value: datetime | None) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value.astimezone(UTC)
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
@@ -0,0 +1,45 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from app.core.jwt_denylist import validate_access_claims
|
||||
from app.core.security import decode_access_token
|
||||
from app.modules.users.repository import get_user_by_id
|
||||
|
||||
bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def get_current_user(credentials: HTTPAuthorizationCredentials | None = Depends(bearer)):
|
||||
if credentials is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="UNAUTHORIZED")
|
||||
try:
|
||||
payload = decode_access_token(credentials.credentials)
|
||||
validate_access_claims(payload)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "TOKEN_REVOKED":
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="TOKEN_REVOKED")
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_TOKEN") from exc
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_TOKEN") from exc
|
||||
user = get_user_by_id(payload["sub"])
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="UNAUTHORIZED")
|
||||
if user.status == "pending":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="EMAIL_NOT_VERIFIED")
|
||||
if user.status == "blocked":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ACCOUNT_BLOCKED")
|
||||
return user
|
||||
|
||||
|
||||
def require_admin(user=Depends(get_current_user)):
|
||||
if user.role != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ADMIN_ONLY")
|
||||
return user
|
||||
|
||||
|
||||
def require_superuser(user=Depends(get_current_user)):
|
||||
if user.role != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ADMIN_ONLY")
|
||||
if not user.is_superuser:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="SUPERUSER_ONLY")
|
||||
return user
|
||||
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import smtplib
|
||||
from dataclasses import dataclass
|
||||
from email.message import EmailMessage
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class SentEmail:
|
||||
to: str
|
||||
subject: str
|
||||
body: str
|
||||
template: str
|
||||
|
||||
|
||||
class MemoryMailer:
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[SentEmail] = []
|
||||
|
||||
def send(self, to: str, subject: str, body: str, template: str) -> None:
|
||||
self.sent.append(SentEmail(to=to, subject=subject, body=body, template=template))
|
||||
|
||||
def clear(self) -> None:
|
||||
self.sent.clear()
|
||||
|
||||
def latest_token(self, recipient: str, template: str) -> str | None:
|
||||
for message in reversed(self.sent):
|
||||
if message.to == recipient and message.template == template:
|
||||
for line in message.body.splitlines():
|
||||
if line.startswith("TOKEN:"):
|
||||
return line.split(":", 1)[1].strip()
|
||||
return None
|
||||
|
||||
|
||||
class SmtpMailer:
|
||||
def send(self, to: str, subject: str, body: str, template: str) -> None:
|
||||
_ = template
|
||||
message = EmailMessage()
|
||||
message["From"] = settings.smtp_from
|
||||
message["To"] = to
|
||||
message["Subject"] = subject
|
||||
message.set_content(body)
|
||||
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as smtp:
|
||||
if settings.smtp_user:
|
||||
smtp.login(settings.smtp_user, settings.smtp_password)
|
||||
smtp.send_message(message)
|
||||
|
||||
|
||||
memory_mailer = MemoryMailer()
|
||||
|
||||
|
||||
def get_mailer():
|
||||
if settings.email_delivery_mode == "memory":
|
||||
return memory_mailer
|
||||
return SmtpMailer()
|
||||
|
||||
|
||||
def send_template_email(to: str, template: str, subject: str, body: str) -> None:
|
||||
get_mailer().send(to=to, subject=subject, body=body, template=template)
|
||||
@@ -0,0 +1,2 @@
|
||||
class DomainError(Exception):
|
||||
"""Base domain error."""
|
||||
@@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import ParseResult, urlparse, urlunparse
|
||||
from uuid import uuid4
|
||||
|
||||
from app.core.crypto import generate_install_bundle
|
||||
|
||||
_API_ROOT = Path(__file__).resolve().parents[2]
|
||||
_INSTALL_SECRETS_ROOT = Path(os.getenv("COMPTON_INSTALL_SECRETS_DIR", str(_API_ROOT / "data" / "secrets")))
|
||||
INSTALL_SECRETS_DIR = _INSTALL_SECRETS_ROOT
|
||||
INSTALL_SECRETS_FILE = INSTALL_SECRETS_DIR / "install.env"
|
||||
INSTALL_SECRETS_META_FILE = INSTALL_SECRETS_DIR / "install.meta.json"
|
||||
REQUIRED_KEYS = (
|
||||
"POSTGRES_USER",
|
||||
"POSTGRES_PASSWORD",
|
||||
"POSTGRES_DB",
|
||||
"DATABASE_URL",
|
||||
"JWT_ACCESS_SECRET",
|
||||
"JWT_REFRESH_PEPPER",
|
||||
"S3_ACCESS_KEY",
|
||||
"S3_SECRET_KEY",
|
||||
"MINIO_ROOT_USER",
|
||||
"MINIO_ROOT_PASSWORD",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstallSecretsStatus:
|
||||
initialized: bool
|
||||
locked: bool
|
||||
path: str
|
||||
created: bool
|
||||
|
||||
|
||||
def _parse_env_text(raw: str) -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
for line in raw.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
values[key.strip()] = value.strip()
|
||||
return values
|
||||
|
||||
|
||||
def _render_env(values: dict[str, str]) -> str:
|
||||
ordered = [f"{key}={values[key]}" for key in sorted(values.keys())]
|
||||
return "\n".join(ordered) + "\n"
|
||||
|
||||
|
||||
def read_install_secrets() -> dict[str, str]:
|
||||
if not INSTALL_SECRETS_FILE.exists():
|
||||
return {}
|
||||
return _parse_env_text(INSTALL_SECRETS_FILE.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _write_install_secrets(values: dict[str, str]) -> None:
|
||||
INSTALL_SECRETS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
INSTALL_SECRETS_FILE.write_text(_render_env(values), encoding="utf-8")
|
||||
|
||||
|
||||
def _write_meta() -> None:
|
||||
payload = (
|
||||
"{\n"
|
||||
f' "install_id": "{uuid4()}",\n'
|
||||
f' "locked_at": "{datetime.now(UTC).isoformat()}"\n'
|
||||
"}\n"
|
||||
)
|
||||
INSTALL_SECRETS_META_FILE.write_text(payload, encoding="utf-8")
|
||||
|
||||
|
||||
def _is_postgres_database_url(database_url: str) -> bool:
|
||||
scheme = urlparse(database_url).scheme.lower()
|
||||
return scheme.startswith("postgresql") or scheme.startswith("postgres+")
|
||||
|
||||
|
||||
def _adopt_from_environment() -> dict[str, str]:
|
||||
env_values = {key: os.getenv(key, "") for key in REQUIRED_KEYS if key != "DATABASE_URL"}
|
||||
db_url = os.getenv("DATABASE_URL", "")
|
||||
if db_url and _is_postgres_database_url(db_url):
|
||||
parsed = urlparse(db_url)
|
||||
if parsed.username:
|
||||
env_values["POSTGRES_USER"] = parsed.username
|
||||
if parsed.password:
|
||||
env_values["POSTGRES_PASSWORD"] = parsed.password
|
||||
if parsed.path and parsed.path != "/":
|
||||
env_values["POSTGRES_DB"] = parsed.path.lstrip("/")
|
||||
env_values["DATABASE_URL"] = db_url
|
||||
return {key: value for key, value in env_values.items() if value}
|
||||
|
||||
|
||||
def _sync_minio_s3_secrets(values: dict[str, str]) -> dict[str, str]:
|
||||
"""MinIO root credentials are the S3 access key pair — keep them aligned."""
|
||||
if (
|
||||
values.get("S3_ACCESS_KEY") == values.get("MINIO_ROOT_USER")
|
||||
and values.get("MINIO_ROOT_PASSWORD")
|
||||
and values.get("S3_SECRET_KEY") != values["MINIO_ROOT_PASSWORD"]
|
||||
):
|
||||
values = dict(values)
|
||||
values["S3_SECRET_KEY"] = values["MINIO_ROOT_PASSWORD"]
|
||||
return values
|
||||
|
||||
|
||||
def load_install_secrets_to_env() -> None:
|
||||
values = _sync_minio_s3_secrets(read_install_secrets())
|
||||
for key, value in values.items():
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def ensure_install_secrets() -> InstallSecretsStatus:
|
||||
existing = read_install_secrets()
|
||||
if existing:
|
||||
db_url = existing.get("DATABASE_URL", "")
|
||||
if db_url and not _is_postgres_database_url(db_url):
|
||||
raise RuntimeError(
|
||||
"install.env DATABASE_URL must be PostgreSQL (got SQLite). "
|
||||
"Remove apps/api/data/secrets/install.env and run: python apps/api/scripts/bootstrap_install.py"
|
||||
)
|
||||
synced = _sync_minio_s3_secrets(existing)
|
||||
if synced != existing:
|
||||
synced["SECRETS_LOCKED"] = existing.get("SECRETS_LOCKED", "true")
|
||||
_write_install_secrets(synced)
|
||||
existing = synced
|
||||
load_install_secrets_to_env()
|
||||
return InstallSecretsStatus(True, existing.get("SECRETS_LOCKED", "false") == "true", str(INSTALL_SECRETS_FILE), False)
|
||||
|
||||
adopted = _adopt_from_environment()
|
||||
generated = generate_install_bundle()
|
||||
values = generated | adopted
|
||||
values["SECRETS_LOCKED"] = "true"
|
||||
_write_install_secrets(values)
|
||||
_write_meta()
|
||||
load_install_secrets_to_env()
|
||||
return InstallSecretsStatus(True, True, str(INSTALL_SECRETS_FILE), True)
|
||||
|
||||
|
||||
def masked_database_url(database_url: str) -> str:
|
||||
parsed = urlparse(database_url)
|
||||
if not parsed.username:
|
||||
return database_url
|
||||
password = "***" if parsed.password else ""
|
||||
credentials = f"{parsed.username}:{password}" if password else parsed.username
|
||||
host = parsed.hostname or ""
|
||||
if parsed.port:
|
||||
host = f"{host}:{parsed.port}"
|
||||
netloc = f"{credentials}@{host}"
|
||||
sanitized = ParseResult(
|
||||
scheme=parsed.scheme,
|
||||
netloc=netloc,
|
||||
path=parsed.path,
|
||||
params=parsed.params,
|
||||
query=parsed.query,
|
||||
fragment=parsed.fragment,
|
||||
)
|
||||
return urlunparse(sanitized)
|
||||
|
||||
|
||||
def install_secrets_payload() -> dict:
|
||||
values = read_install_secrets()
|
||||
db = urlparse(values.get("DATABASE_URL", ""))
|
||||
return {
|
||||
"initialized": bool(values),
|
||||
"locked": values.get("SECRETS_LOCKED") == "true",
|
||||
"secrets_path": str(INSTALL_SECRETS_FILE),
|
||||
"database": {
|
||||
"host": db.hostname,
|
||||
"port": db.port,
|
||||
"database": db.path.lstrip("/") if db.path else "",
|
||||
"user": db.username,
|
||||
"password_configured": bool(values.get("POSTGRES_PASSWORD")),
|
||||
},
|
||||
"connection_string_masked": masked_database_url(values.get("DATABASE_URL", "")),
|
||||
"secrets_status": {
|
||||
"jwt_access_secret": "configured" if bool(values.get("JWT_ACCESS_SECRET")) else "missing",
|
||||
"jwt_refresh_pepper": "configured" if bool(values.get("JWT_REFRESH_PEPPER")) else "missing",
|
||||
"postgres_password": "configured" if bool(values.get("POSTGRES_PASSWORD")) else "missing",
|
||||
"s3_secret_key": "configured" if bool(values.get("S3_SECRET_KEY")) else "missing",
|
||||
"password_bcrypt_salt": "per_user_in_db",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def reveal_install_secret(key: str) -> str:
|
||||
mapping = {
|
||||
"database_password": "POSTGRES_PASSWORD",
|
||||
"jwt_access_secret": "JWT_ACCESS_SECRET",
|
||||
"jwt_refresh_pepper": "JWT_REFRESH_PEPPER",
|
||||
"s3_secret_key": "S3_SECRET_KEY",
|
||||
}
|
||||
env_key = mapping.get(key)
|
||||
if not env_key:
|
||||
raise ValueError("UNSUPPORTED_SECRET_KEY")
|
||||
values = read_install_secrets()
|
||||
if env_key in values:
|
||||
return values[env_key]
|
||||
if env_key == "POSTGRES_PASSWORD":
|
||||
database_url = values.get("DATABASE_URL") or os.getenv("DATABASE_URL", "")
|
||||
parsed = urlparse(database_url)
|
||||
return parsed.password or ""
|
||||
return os.getenv(env_key, "")
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Redis-backed JWT jti denylist and per-user auth_epoch for instant access revocation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.redis import get_redis_client
|
||||
|
||||
AUTH_EPOCH_PREFIX = "auth:epoch:"
|
||||
JWT_DENY_PREFIX = "jwt:deny:"
|
||||
|
||||
_memory_epochs: dict[str, int] = {}
|
||||
_memory_denied_jti: dict[str, float] = {}
|
||||
|
||||
|
||||
def _purge_expired_memory_jtis() -> None:
|
||||
now = time.time()
|
||||
expired = [jti for jti, exp in _memory_denied_jti.items() if exp <= now]
|
||||
for jti in expired:
|
||||
_memory_denied_jti.pop(jti, None)
|
||||
|
||||
|
||||
def ensure_jwt_revocation_backend() -> None:
|
||||
if settings.app_env.lower() != "production":
|
||||
return
|
||||
if get_redis_client() is None:
|
||||
raise RuntimeError("Redis is required for JWT revocation in production")
|
||||
|
||||
|
||||
def get_auth_epoch(user_id: str) -> int:
|
||||
client = get_redis_client()
|
||||
if client is not None:
|
||||
try:
|
||||
value = client.get(f"{AUTH_EPOCH_PREFIX}{user_id}")
|
||||
return int(value) if value is not None else 0
|
||||
except Exception:
|
||||
if settings.app_env.lower() == "production":
|
||||
raise
|
||||
return _memory_epochs.get(user_id, 0)
|
||||
|
||||
|
||||
def bump_auth_epoch(user_id: str) -> int:
|
||||
client = get_redis_client()
|
||||
if client is not None:
|
||||
try:
|
||||
return int(client.incr(f"{AUTH_EPOCH_PREFIX}{user_id}"))
|
||||
except Exception:
|
||||
if settings.app_env.lower() == "production":
|
||||
raise
|
||||
next_epoch = _memory_epochs.get(user_id, 0) + 1
|
||||
_memory_epochs[user_id] = next_epoch
|
||||
return next_epoch
|
||||
|
||||
|
||||
def deny_jti(jti: str, exp: int) -> None:
|
||||
if not jti:
|
||||
return
|
||||
ttl = max(int(exp - time.time()), 1)
|
||||
client = get_redis_client()
|
||||
if client is not None:
|
||||
try:
|
||||
client.setex(f"{JWT_DENY_PREFIX}{jti}", ttl, "1")
|
||||
return
|
||||
except Exception:
|
||||
if settings.app_env.lower() == "production":
|
||||
raise
|
||||
_purge_expired_memory_jtis()
|
||||
_memory_denied_jti[jti] = time.time() + ttl
|
||||
|
||||
|
||||
def is_jti_denied(jti: str) -> bool:
|
||||
if not jti:
|
||||
return False
|
||||
client = get_redis_client()
|
||||
if client is not None:
|
||||
try:
|
||||
return bool(client.exists(f"{JWT_DENY_PREFIX}{jti}"))
|
||||
except Exception:
|
||||
if settings.app_env.lower() == "production":
|
||||
return True
|
||||
_purge_expired_memory_jtis()
|
||||
return jti in _memory_denied_jti
|
||||
|
||||
|
||||
def revoke_access_token(token: str) -> None:
|
||||
from app.core.security import decode_access_token
|
||||
|
||||
try:
|
||||
payload = decode_access_token(token)
|
||||
except Exception:
|
||||
return
|
||||
jti = payload.get("jti")
|
||||
exp = payload.get("exp")
|
||||
if jti and exp:
|
||||
deny_jti(str(jti), int(exp))
|
||||
|
||||
|
||||
def validate_access_claims(payload: dict) -> None:
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
raise ValueError("INVALID_TOKEN")
|
||||
jti = payload.get("jti")
|
||||
if jti and is_jti_denied(str(jti)):
|
||||
raise ValueError("TOKEN_REVOKED")
|
||||
token_epoch = int(payload.get("auth_epoch", 0))
|
||||
if token_epoch != get_auth_epoch(str(user_id)):
|
||||
raise ValueError("TOKEN_REVOKED")
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.core.crypto import build_signed_media_url, verify_signed_media
|
||||
|
||||
__all__ = ["build_signed_media_url", "verify_signed_media"]
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
COMMON_PASSWORDS = frozenset(
|
||||
{
|
||||
"password",
|
||||
"password1",
|
||||
"password123",
|
||||
"12345678",
|
||||
"123456789",
|
||||
"qwerty123",
|
||||
"admin123",
|
||||
"admin1234",
|
||||
"letmein1",
|
||||
"welcome1",
|
||||
"iloveyou1",
|
||||
"sunshine1",
|
||||
"football1",
|
||||
"baseball1",
|
||||
"monkey123",
|
||||
"dragon123",
|
||||
"master123",
|
||||
"trustno1",
|
||||
"passw0rd",
|
||||
"passw0rd1",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def load_denylist() -> set[str]:
|
||||
denylist = set(COMMON_PASSWORDS)
|
||||
path = Path(settings.password_denylist_path)
|
||||
if not path.exists():
|
||||
return denylist
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
candidate = line.strip().lower()
|
||||
if candidate and not candidate.startswith("#"):
|
||||
denylist.add(candidate)
|
||||
return denylist
|
||||
|
||||
|
||||
def is_denied_password(password: str) -> bool:
|
||||
return password.lower() in load_denylist()
|
||||
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
|
||||
from app.core.password_denylist import is_denied_password
|
||||
|
||||
|
||||
def validate_password_strength(password: str) -> str:
|
||||
if len(password) < 8:
|
||||
raise ValueError("Password must be at least 8 characters long")
|
||||
if is_denied_password(password):
|
||||
raise ValueError("Password is too common")
|
||||
if not re.search(r"[A-Z]", password):
|
||||
raise ValueError("Password must include at least one uppercase letter")
|
||||
if not re.search(r"[a-z]", password):
|
||||
raise ValueError("Password must include at least one lowercase letter")
|
||||
if not re.search(r"\d", password):
|
||||
raise ValueError("Password must include at least one digit")
|
||||
return password
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Redis-first rate limiter with in-memory fallback."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
from redis import Redis
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
_buckets: dict[str, list[datetime]] = defaultdict(list)
|
||||
_redis_client: Redis | None = None
|
||||
|
||||
|
||||
def get_redis_client() -> Redis | None:
|
||||
global _redis_client
|
||||
if _redis_client is not None:
|
||||
return _redis_client
|
||||
try:
|
||||
_redis_client = Redis.from_url(settings.redis_url, decode_responses=True)
|
||||
_redis_client.ping()
|
||||
return _redis_client
|
||||
except RedisError:
|
||||
_redis_client = None
|
||||
return None
|
||||
|
||||
|
||||
def check_rate_limit(key: str, limit: int, window_seconds: int) -> None:
|
||||
if not settings.enable_rate_limit:
|
||||
return
|
||||
redis_client = get_redis_client()
|
||||
if redis_client is not None:
|
||||
redis_key = f"rl:{key}"
|
||||
try:
|
||||
current = redis_client.incr(redis_key)
|
||||
if current == 1:
|
||||
redis_client.expire(redis_key, window_seconds)
|
||||
if current > limit:
|
||||
ttl = redis_client.ttl(redis_key)
|
||||
retry_after = ttl if ttl and ttl > 0 else window_seconds
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="RATE_LIMIT_EXCEEDED",
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
return
|
||||
except RedisError:
|
||||
pass
|
||||
|
||||
now = datetime.now(UTC)
|
||||
cutoff = now - timedelta(seconds=window_seconds)
|
||||
timestamps = [moment for moment in _buckets[key] if moment > cutoff]
|
||||
if len(timestamps) >= limit:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="RATE_LIMIT_EXCEEDED",
|
||||
headers={"Retry-After": str(window_seconds)},
|
||||
)
|
||||
timestamps.append(now)
|
||||
_buckets[key] = timestamps
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str:
|
||||
trusted_proxy_ips = {item.strip() for item in settings.trusted_proxy_ips.split(",") if item.strip()}
|
||||
forwarded = request.headers.get("X-Forwarded-For")
|
||||
request_ip = request.client.host if request.client else ""
|
||||
if forwarded and request_ip in trusted_proxy_ips:
|
||||
return forwarded.split(",")[0].strip()
|
||||
if request.client:
|
||||
return request_ip
|
||||
return "unknown"
|
||||
@@ -0,0 +1,21 @@
|
||||
from app.core.crypto import (
|
||||
create_access_token,
|
||||
decode_access_token,
|
||||
generate_opaque_token,
|
||||
generate_refresh_token,
|
||||
hash_opaque_token,
|
||||
hash_password,
|
||||
hash_refresh_token,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"create_access_token",
|
||||
"decode_access_token",
|
||||
"generate_opaque_token",
|
||||
"generate_refresh_token",
|
||||
"hash_opaque_token",
|
||||
"hash_password",
|
||||
"hash_refresh_token",
|
||||
"verify_password",
|
||||
]
|
||||
@@ -0,0 +1,63 @@
|
||||
"""File logging for WESP admin server-log viewer (SERVER_LOG_PATH)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
_LOG_FORMAT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
||||
|
||||
|
||||
def server_log_path() -> Path:
|
||||
return Path(settings.server_log_path)
|
||||
|
||||
|
||||
def ensure_server_log_file() -> Path:
|
||||
path = server_log_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not path.is_file():
|
||||
started = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
path.write_text(f"{started} [INFO] compton.api: server log initialized\n", encoding="utf-8")
|
||||
return path.resolve()
|
||||
|
||||
|
||||
def build_uvicorn_log_config() -> dict[str, Any]:
|
||||
log_path = str(ensure_server_log_file())
|
||||
return {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"default": {
|
||||
"format": _LOG_FORMAT,
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"file": {
|
||||
"class": "logging.FileHandler",
|
||||
"filename": log_path,
|
||||
"encoding": "utf-8",
|
||||
"formatter": "default",
|
||||
},
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "default",
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"uvicorn": {"handlers": ["file", "console"], "level": settings.log_level, "propagate": False},
|
||||
"uvicorn.error": {"handlers": ["file", "console"], "level": settings.log_level, "propagate": False},
|
||||
"uvicorn.access": {"handlers": ["file", "console"], "level": "INFO", "propagate": False},
|
||||
},
|
||||
"root": {"handlers": ["file", "console"], "level": settings.log_level},
|
||||
}
|
||||
|
||||
|
||||
def append_server_log_line(message: str, level: str = "INFO") -> None:
|
||||
path = ensure_server_log_file()
|
||||
ts = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
with path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(f"{ts} [{level}] compton.api: {message}\n")
|
||||
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
_s3_client = None
|
||||
|
||||
|
||||
def get_s3_client():
|
||||
global _s3_client
|
||||
if _s3_client is None:
|
||||
import boto3
|
||||
|
||||
_s3_client = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=settings.s3_endpoint,
|
||||
aws_access_key_id=settings.s3_access_key,
|
||||
aws_secret_access_key=settings.s3_secret_key,
|
||||
region_name=settings.s3_region,
|
||||
)
|
||||
return _s3_client
|
||||
|
||||
|
||||
def ensure_bucket() -> None:
|
||||
if settings.storage_mode != "s3":
|
||||
return
|
||||
client = get_s3_client()
|
||||
bucket = settings.s3_bucket
|
||||
try:
|
||||
client.head_bucket(Bucket=bucket)
|
||||
except Exception:
|
||||
client.create_bucket(Bucket=bucket)
|
||||
|
||||
|
||||
def upload_object(key: str, body: bytes, content_type: str) -> None:
|
||||
if settings.storage_mode == "memory":
|
||||
memory_store[key] = (body, content_type)
|
||||
return
|
||||
client = get_s3_client()
|
||||
ensure_bucket()
|
||||
client.put_object(
|
||||
Bucket=settings.s3_bucket,
|
||||
Key=key,
|
||||
Body=body,
|
||||
ContentType=content_type,
|
||||
)
|
||||
|
||||
|
||||
def download_object(key: str) -> tuple[bytes, str] | None:
|
||||
if settings.storage_mode == "memory":
|
||||
return memory_store.get(key)
|
||||
client = get_s3_client()
|
||||
try:
|
||||
response = client.get_object(Bucket=settings.s3_bucket, Key=key)
|
||||
body = response["Body"].read()
|
||||
content_type = response.get("ContentType", "application/octet-stream")
|
||||
return body, content_type
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
memory_store: dict[str, tuple[bytes, str]] = {}
|
||||
Reference in New Issue
Block a user