Update admin theme/layout and refresh README details.

Align the project baseline with the latest admin interface styling and layout structure while documenting setup and usage updates in README.
This commit is contained in:
vlad
2026-07-14 17:12:28 +03:00
commit 86cc3fa541
278 changed files with 19416 additions and 0 deletions
+117
View File
@@ -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,
}
+49
View File
@@ -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))
+54
View File
@@ -0,0 +1,54 @@
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"
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()
+117
View File
@@ -0,0 +1,117 @@
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) -> str:
from app.core.config import settings
now = datetime.now(UTC)
payload = {
"sub": user_id,
"role": role,
"is_superuser": is_superuser,
"iat": int(now.timestamp()),
"exp": int((now + timedelta(minutes=settings.jwt_access_ttl_min)).timestamp()),
"jti": generate_secret_token_hex(16),
}
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()
+50
View File
@@ -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()
+15
View File
@@ -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)
+38
View File
@@ -0,0 +1,38 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
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)
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
+61
View File
@@ -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)
+2
View File
@@ -0,0 +1,2 @@
class DomainError(Exception):
"""Base domain error."""
+191
View File
@@ -0,0 +1,191 @@
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
INSTALL_SECRETS_DIR = Path("data/secrets")
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 _adopt_from_environment() -> dict[str, str]:
env_values = {key: os.getenv(key, "") for key in REQUIRED_KEYS}
db_url = os.getenv("DATABASE_URL", "")
if 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("/")
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:
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, "")
+3
View File
@@ -0,0 +1,3 @@
from app.core.crypto import build_signed_media_url, verify_signed_media
__all__ = ["build_signed_media_url", "verify_signed_media"]
+46
View File
@@ -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()
+20
View File
@@ -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
+74
View File
@@ -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"
+21
View File
@@ -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",
]
+61
View File
@@ -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]] = {}