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:
@@ -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,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()
|
||||
@@ -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()
|
||||
@@ -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,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
|
||||
@@ -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,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, "")
|
||||
|
||||
@@ -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,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