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
+42
View File
@@ -0,0 +1,42 @@
DATABASE_URL=postgresql+psycopg://user:pass@localhost:5432/compton
REDIS_URL=redis://localhost:6379/0
JWT_ACCESS_SECRET=replace-with-32-byte-secret
JWT_REFRESH_PEPPER=replace-with-32-byte-pepper
JWT_ACCESS_TTL_MIN=15
JWT_REFRESH_TTL_DAYS=30
ENABLE_DOCS=true
COOKIE_SECURE=false
ENABLE_RATE_LIMIT=true
APP_ENV=development
AUTH_LOCKOUT_ATTEMPTS=5
AUTH_LOCKOUT_MINUTES=15
CORS_ORIGINS=["http://localhost:5173"]
TRUSTED_PROXY_IPS=
SMTP_HOST=localhost
SMTP_PORT=1025
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=noreply@compton.example
FRONTEND_URL=http://localhost:5173
PUBLIC_BASE_URL=http://localhost:5173
AUTH_TOKEN_TTL_HOURS=1
EMAIL_DELIVERY_MODE=memory
S3_ENDPOINT=http://localhost:9000
S3_ACCESS_KEY=minio
S3_SECRET_KEY=minio123
S3_BUCKET=compton
S3_REGION=us-east-1
STORAGE_MODE=s3
AVATAR_MAX_BYTES=2097152
MEDIA_URL_TTL_SECONDS=600
LOG_LEVEL=INFO
AUDIT_RETENTION_DAYS=90
COMPTON_SETTINGS_PATH=data/compton_settings.json
ADMIN_AUDIT_LOG_PATH=data/logs/admin-audit.jsonl
SERVER_LOG_PATH=data/logs/server.log
PASSWORD_DENYLIST_PATH=data/security/password-denylist.txt
ADMIN_INITIAL_PASSWORD=Admin1234
DEMO_USER_PASSWORD=User1234
DEMO_OPS_PASSWORD=OpsAdmin1234
# E2E only, never enable in production.
ENABLE_TEST_ROUTES=false
+11
View File
@@ -0,0 +1,11 @@
DATABASE_URL=postgresql+psycopg://test:test@localhost:5433/compton_test
REDIS_URL=redis://localhost:6380/0
JWT_ACCESS_SECRET=test-access-secret-32-bytes-minimum
JWT_REFRESH_PEPPER=test-refresh-pepper-32-bytes-min
JWT_ACCESS_TTL_MIN=15
JWT_REFRESH_TTL_DAYS=30
ENABLE_DOCS=true
CORS_ORIGINS=["http://localhost:5175"]
EMAIL_DELIVERY_MODE=memory
STORAGE_MODE=memory
ENABLE_TEST_ROUTES=true
+8
View File
@@ -0,0 +1,8 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONPATH=/app
COPY requirements-dev.txt .
RUN pip install --no-cache-dir -r requirements-dev.txt
COPY . .
EXPOSE 8000
CMD ["python", "scripts/docker_entrypoint.py"]
+42
View File
@@ -0,0 +1,42 @@
[alembic]
script_location = migrations
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+1
View File
@@ -0,0 +1 @@
"""Compton API application package."""
+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]] = {}
+3
View File
@@ -0,0 +1,3 @@
from app.db.base import Base
__all__ = ["Base"]
+5
View File
@@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
+12
View File
@@ -0,0 +1,12 @@
from app.modules.auth.models import EmailVerificationToken, PasswordResetToken, RefreshToken
from app.modules.content.models import ContentPage
from app.modules.users.models import User, UserProfile
__all__ = [
"User",
"UserProfile",
"RefreshToken",
"PasswordResetToken",
"EmailVerificationToken",
"ContentPage",
]
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
from datetime import UTC, datetime
from app.core.config import settings
from app.core.security import hash_password
from app.modules.content.repository import create_page, get_page_by_slug
from app.modules.users.repository import create_user, get_user_by_email
def _ensure_user(email: str, password: str, role: str, is_superuser: bool, status: str):
from app.modules.users import repository
user = get_user_by_email(email)
if user:
changed = False
if user.role != role:
user.role = role
changed = True
if user.is_superuser != is_superuser:
user.is_superuser = is_superuser
changed = True
if user.status != status:
user.status = status
changed = True
if user.email_verified_at is None and status == "active":
user.email_verified_at = datetime.now(UTC)
changed = True
if changed:
repository.update_user(user)
return user
user = create_user(
email=email,
password_hash=hash_password(password),
role=role,
is_superuser=is_superuser,
status=status,
)
user.email_verified_at = datetime.now(UTC)
repository.update_user(user)
return user
def run_seed(include_demo_pages: bool = True) -> None:
admin = _ensure_user(
email="admin@compton.example",
password=settings.admin_initial_password,
role="admin",
is_superuser=True,
status="active",
)
_ensure_user(
email="user@compton.example",
password=settings.demo_user_password,
role="user",
is_superuser=False,
status="active",
)
_ensure_user(
email="ops@compton.example",
password=settings.demo_ops_password,
role="admin",
is_superuser=False,
status="active",
)
if not include_demo_pages:
return
demo_pages = [
{
"slug": "about",
"title": "О бренде",
"body": "<p>Compton — платформа Organic Tech.</p>",
},
{
"slug": "privacy",
"title": "Политика конфиденциальности",
"body": "<p>Мы обрабатываем персональные данные согласно политике.</p>",
},
{
"slug": "terms",
"title": "Условия использования",
"body": "<p>Используя сервис, вы принимаете условия.</p>",
},
]
for page in demo_pages:
if get_page_by_slug(page["slug"], include_draft=True):
continue
create_page(
slug=page["slug"],
title=page["title"],
body=page["body"],
status="published",
author_id=admin.id,
)
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from sqlalchemy import delete, or_
from app.core.database import session_scope
from app.modules.auth.models import EmailVerificationToken, PasswordResetToken, RefreshToken
def cleanup_expired_tokens(retention_days: int = 30) -> dict[str, int]:
now = datetime.now(UTC)
revoked_cutoff = now - timedelta(days=retention_days)
with session_scope() as db:
refresh_deleted = db.execute(
delete(RefreshToken).where(
or_(
RefreshToken.expires_at < now,
RefreshToken.revoked_at.is_not(None) & (RefreshToken.revoked_at < revoked_cutoff),
)
)
).rowcount or 0
reset_deleted = db.execute(
delete(PasswordResetToken).where(
or_(
PasswordResetToken.expires_at < now,
PasswordResetToken.used_at.is_not(None) & (PasswordResetToken.used_at < now),
)
)
).rowcount or 0
verify_deleted = db.execute(
delete(EmailVerificationToken).where(
or_(
EmailVerificationToken.expires_at < now,
EmailVerificationToken.used_at.is_not(None) & (EmailVerificationToken.used_at < now),
)
)
).rowcount or 0
return {
"refresh_tokens_deleted": int(refresh_deleted),
"password_reset_tokens_deleted": int(reset_deleted),
"email_verification_tokens_deleted": int(verify_deleted),
}
+90
View File
@@ -0,0 +1,90 @@
from contextlib import asynccontextmanager
from urllib.parse import urlparse, parse_qs
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.install_secrets import ensure_install_secrets
from app.core.config import settings
from app.core.app_settings import bootstrap_settings
from app.core.storage import ensure_bucket
from app.core import database as db_module
from app.db.token_cleanup import cleanup_expired_tokens
from app.db.base import Base
from app.db import models as _models # noqa: F401
from app.db.seed import run_seed
from app.modules.auth.router import router as auth_router
from app.modules.users.router import router as users_router
from app.modules.content.router import router as content_router
from app.modules.admin.router import router as admin_router
from app.modules.media.router import router as media_router
from app.modules.test.router import router as test_router
def _assert_production_guards() -> None:
if settings.app_env.lower() != "production":
return
if settings.enable_test_routes:
raise RuntimeError("ENABLE_TEST_ROUTES must be false in production")
if settings.enable_docs:
raise RuntimeError("ENABLE_DOCS must be false in production")
if not settings.enable_rate_limit:
raise RuntimeError("ENABLE_RATE_LIMIT must be true in production")
if not settings.cookie_secure:
raise RuntimeError("COOKIE_SECURE must be true in production")
if settings.jwt_access_secret.startswith("change-me-"):
raise RuntimeError("JWT_ACCESS_SECRET placeholder is not allowed in production")
if settings.jwt_refresh_pepper.startswith("change-me-"):
raise RuntimeError("JWT_REFRESH_PEPPER placeholder is not allowed in production")
parsed = urlparse(settings.database_url)
if parsed.username == "user" and parsed.password == "pass":
raise RuntimeError("Default database credentials are not allowed in production")
if parsed.scheme.startswith("postgresql"):
sslmode = parse_qs(parsed.query).get("sslmode", [""])[0]
if sslmode != "require":
raise RuntimeError("DATABASE_URL must contain sslmode=require in production")
def create_app() -> FastAPI:
@asynccontextmanager
async def lifespan(_: FastAPI):
ensure_install_secrets()
bootstrap_settings()
_assert_production_guards()
if settings.enable_test_routes:
Base.metadata.create_all(db_module.engine)
run_seed()
cleanup_expired_tokens()
ensure_bucket()
yield
app = FastAPI(
title="Compton API",
version="1.0.0",
docs_url="/api/v1/docs" if settings.enable_docs else None,
openapi_url="/api/v1/openapi.json" if settings.enable_docs else None,
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type", "X-Request-ID"],
)
@app.get("/api/v1/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
app.include_router(auth_router, prefix="/api/v1/auth", tags=["auth"])
app.include_router(users_router, prefix="/api/v1/users", tags=["users"])
app.include_router(content_router, prefix="/api/v1/content", tags=["content"])
app.include_router(admin_router, prefix="/api/v1/admin", tags=["admin"])
app.include_router(media_router, prefix="/api/v1/media", tags=["media"])
if settings.enable_test_routes:
app.include_router(test_router, prefix="/api/v1/test", tags=["test"])
return app
app = create_app()
+1
View File
@@ -0,0 +1 @@
"""Application modules."""
+1
View File
@@ -0,0 +1 @@
"""Admin module."""
+142
View File
@@ -0,0 +1,142 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from app.core.dependencies import require_admin, require_superuser
from app.modules.admin.schemas import (
AdminSettingsPatchIn,
AdminRevealSecretIn,
AdminUiActivityIn,
AdminUserCreateIn,
AdminUserPasswordPatchIn,
AdminUserPatchIn,
)
from app.modules.admin.service import (
create_admin_user,
delete_admin_user,
get_admin_settings,
get_admin_summary,
get_install_secrets,
get_diagnostics_report,
get_server_log_tail,
list_activity_feed,
list_admin_users,
patch_admin_settings,
patch_user,
record_ui_activity,
reveal_secret,
reset_user_password,
)
from app.modules.analytics.service import get_admin_dashboard_metrics
router = APIRouter()
@router.get("/users")
async def list_users_route(
page: int = Query(default=1, ge=1),
limit: int = Query(default=20, ge=1, le=100),
_admin=Depends(require_admin),
):
return list_admin_users(page, limit)
@router.patch("/users/{user_id}")
async def patch_user_route(
user_id: str,
payload: AdminUserPatchIn,
admin=Depends(require_admin),
):
if payload.is_superuser is not None and not admin.is_superuser:
raise HTTPException(status_code=403, detail="SUPERUSER_ONLY")
try:
return patch_user(admin, user_id, payload.role, payload.status, payload.is_superuser)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.post("/users")
async def create_user_route(payload: AdminUserCreateIn, admin=Depends(require_superuser)):
try:
return create_admin_user(
admin,
email=payload.email,
password=payload.password,
role=payload.role,
is_superuser=payload.is_superuser,
status=payload.status,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.patch("/users/{user_id}/password")
async def reset_user_password_route(
user_id: str, payload: AdminUserPasswordPatchIn, admin=Depends(require_superuser)
):
try:
return reset_user_password(admin, user_id, payload.password)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.delete("/users/{user_id}")
async def delete_user_route(user_id: str, admin=Depends(require_superuser)):
try:
return delete_admin_user(admin, user_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.get("/summary")
async def summary_route(_admin=Depends(require_admin)):
return get_admin_summary()
@router.get("/stats")
async def stats_route(_admin=Depends(require_admin)):
return get_admin_dashboard_metrics()
@router.get("/settings")
async def admin_settings_route(_admin=Depends(require_superuser)):
return get_admin_settings()
@router.patch("/settings")
async def admin_settings_patch_route(payload: AdminSettingsPatchIn, admin=Depends(require_superuser)):
return patch_admin_settings(admin, payload.values)
@router.get("/diagnostics/report")
async def diagnostics_route(_admin=Depends(require_superuser)):
return get_diagnostics_report()
@router.get("/activity-feed")
async def activity_feed_route(limit: int = Query(default=200, ge=1, le=1000), _admin=Depends(require_admin)):
return list_activity_feed(limit=limit)
@router.post("/ui-activity")
async def ui_activity_route(payload: AdminUiActivityIn, admin=Depends(require_admin)):
return record_ui_activity(admin, payload.event, payload.meta)
@router.get("/server-log")
async def server_log_route(
lines: int = Query(default=200, ge=1, le=1000),
_admin=Depends(require_superuser),
):
return get_server_log_tail(lines=lines)
@router.get("/secrets")
async def install_secrets_route(_admin=Depends(require_superuser)):
return get_install_secrets()
@router.post("/secrets/reveal")
async def reveal_secret_route(payload: AdminRevealSecretIn, admin=Depends(require_superuser)):
try:
return reveal_secret(admin, payload.key)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
+46
View File
@@ -0,0 +1,46 @@
from typing import Literal
from pydantic import BaseModel, EmailStr, Field, field_validator
from app.core.password_policy import validate_password_strength
class AdminUserPatchIn(BaseModel):
role: Literal["user", "admin"] | None = None
status: Literal["pending", "active", "blocked"] | None = None
is_superuser: bool | None = None
class AdminUserCreateIn(BaseModel):
email: EmailStr
password: str = Field(min_length=8)
role: Literal["user", "admin"] = "user"
is_superuser: bool = False
status: Literal["pending", "active", "blocked"] = "active"
@field_validator("password")
@classmethod
def password_policy(cls, value: str) -> str:
return validate_password_strength(value)
class AdminUserPasswordPatchIn(BaseModel):
password: str = Field(min_length=8)
@field_validator("password")
@classmethod
def password_policy(cls, value: str) -> str:
return validate_password_strength(value)
class AdminSettingsPatchIn(BaseModel):
values: dict
class AdminUiActivityIn(BaseModel):
event: str
meta: dict | None = None
class AdminRevealSecretIn(BaseModel):
key: Literal["database_password", "jwt_access_secret", "jwt_refresh_pepper", "s3_secret_key"]
@@ -0,0 +1,94 @@
from __future__ import annotations
from urllib.parse import parse_qs, urlparse
from app.core.config import settings
from app.core.install_secrets import read_install_secrets
from app.core.password_denylist import load_denylist
from app.core.redis import get_redis_client
from app.core.database import session_scope
from app.modules.auth.models import RefreshToken
from app.modules.users import repository
from sqlalchemy import func, select
def build_security_diagnostics_report() -> dict:
redis_client = get_redis_client()
db = urlparse(settings.database_url)
sslmode = parse_qs(db.query).get("sslmode", [""])[0]
install_secrets = read_install_secrets()
with session_scope() as session:
refresh_count = session.scalar(select(func.count()).select_from(RefreshToken)) or 0
checks = [
{
"id": "jwt_access_secret",
"status": "ok" if len(settings.jwt_access_secret) >= 32 else "fail",
"message": "JWT access secret configured",
},
{
"id": "jwt_refresh_pepper",
"status": "ok" if len(settings.jwt_refresh_pepper) >= 32 else "fail",
"message": "JWT refresh pepper configured",
},
{
"id": "cookie_secure",
"status": "ok" if settings.cookie_secure else "warn",
"message": "Refresh cookie Secure flag is enabled",
},
{
"id": "rate_limit_backend",
"status": "ok" if redis_client is not None else "warn",
"message": "Rate limiter uses Redis backend",
},
{
"id": "password_denylist",
"status": "ok" if len(load_denylist()) >= 1000 else "warn",
"message": "Password denylist has strong coverage",
},
{
"id": "cors_wildcard",
"status": "ok" if "*" not in settings.cors_origins else "fail",
"message": "CORS does not include wildcard origin",
},
{
"id": "superuser_exists",
"status": "ok" if repository.count_superusers() > 0 else "fail",
"message": "At least one superuser exists",
},
{
"id": "docs_production",
"status": "warn" if settings.enable_docs else "ok",
"message": "API docs are disabled in production",
},
{
"id": "db_default_credentials",
"status": "fail" if db.username == "user" and db.password == "pass" else "ok",
"message": "Database does not use default credentials",
},
{
"id": "db_localhost_exposed",
"status": "fail" if settings.app_env.lower() == "production" and db.hostname in {"localhost", "127.0.0.1"} else "ok",
"message": "Production database host is not localhost",
},
{
"id": "db_ssl_mode",
"status": "ok" if settings.app_env.lower() != "production" or sslmode == "require" else "warn",
"message": "Production database URL uses sslmode=require",
},
{
"id": "db_refresh_token_table_size",
"status": "warn" if refresh_count > 10000 else "ok",
"message": "Refresh token table size is under threshold",
},
{
"id": "install_secrets_initialized",
"status": "ok" if bool(install_secrets) else "fail",
"message": "Install secrets file is initialized",
},
{
"id": "install_secrets_locked",
"status": "ok" if install_secrets.get("SECRETS_LOCKED") == "true" else "warn",
"message": "Install secrets are locked after bootstrap",
},
]
return {"checks": checks}
+206
View File
@@ -0,0 +1,206 @@
from pathlib import Path
from app.core.app_settings import apply_settings_to_app, get_settings_payload, write_settings
from app.core.audit_log import read_audit_events, write_audit_event
from app.core.config import settings
from app.core.install_secrets import install_secrets_payload, reveal_install_secret
from app.core.security import hash_password
from app.modules.admin.security_diagnostics import build_security_diagnostics_report
from app.modules.auth.service import revoke_user_refresh_family
from app.modules.users import repository
def list_admin_users(page: int, limit: int) -> dict:
users, total = repository.list_users(page, limit)
return {
"data": [
{
"id": user.id,
"email": user.email,
"role": user.role,
"is_superuser": user.is_superuser,
"status": user.status,
}
for user in users
],
"meta": {"total": total, "page": page, "limit": limit},
}
def _ensure_last_superuser_protection(target, role: str | None, is_superuser: bool | None) -> None:
super_count = repository.count_superusers()
role_becomes_admin = target.role if role is None else role
super_becomes_true = target.is_superuser if is_superuser is None else is_superuser
is_losing_super = target.role == "admin" and target.is_superuser and (
role_becomes_admin != "admin" or not super_becomes_true
)
if is_losing_super and super_count <= 1:
raise ValueError("LAST_SUPERUSER_PROTECTED")
def patch_user(
admin_user, target_user_id: str, role: str | None, status: str | None, is_superuser: bool | None = None
) -> dict:
target = repository.get_user_by_id(target_user_id)
if not target:
raise ValueError("USER_NOT_FOUND")
if admin_user.id == target_user_id and role and role != "admin":
raise ValueError("SELF_DEMOTION_FORBIDDEN")
if admin_user.id == target_user_id and is_superuser is False:
raise ValueError("SELF_DEMOTION_FORBIDDEN")
if admin_user.id == target_user_id and status == "blocked":
raise ValueError("SELF_BLOCK_FORBIDDEN")
admin_count = repository.count_admins()
if target.role == "admin" and role and role != "admin" and admin_count <= 1:
raise ValueError("LAST_ADMIN_PROTECTED")
_ensure_last_superuser_protection(target, role, is_superuser)
if role:
target.role = role
if is_superuser is not None:
target.is_superuser = bool(is_superuser)
if status:
target.status = status
if status == "blocked":
revoke_user_refresh_family(target.id)
repository.update_user(target)
write_audit_event(
action="admin.user.patch",
actor_user_id=admin_user.id,
actor_email=admin_user.email,
details={"target_user_id": target.id},
)
return {
"id": target.id,
"email": target.email,
"role": target.role,
"is_superuser": target.is_superuser,
"status": target.status,
}
def create_admin_user(admin_user, email: str, password: str, role: str, is_superuser: bool, status: str) -> dict:
if repository.get_user_by_email(email):
raise ValueError("USER_EXISTS")
user = repository.create_user(
email=email,
password_hash=hash_password(password),
role=role,
is_superuser=is_superuser,
status=status,
)
write_audit_event(
action="admin.user.create",
actor_user_id=admin_user.id,
actor_email=admin_user.email,
details={"target_user_id": user.id},
)
return {
"id": user.id,
"email": user.email,
"role": user.role,
"is_superuser": user.is_superuser,
"status": user.status,
}
def reset_user_password(admin_user, target_user_id: str, password: str) -> dict:
target = repository.get_user_by_id(target_user_id)
if not target:
raise ValueError("USER_NOT_FOUND")
target.password_hash = hash_password(password)
repository.update_user(target)
revoke_user_refresh_family(target.id)
write_audit_event(
action="admin.user.reset_password",
actor_user_id=admin_user.id,
actor_email=admin_user.email,
details={"target_user_id": target.id},
)
return {"status": "ok"}
def delete_admin_user(admin_user, target_user_id: str) -> dict:
target = repository.get_user_by_id(target_user_id)
if not target:
raise ValueError("USER_NOT_FOUND")
if admin_user.id == target.id:
raise ValueError("SELF_DELETE_FORBIDDEN")
_ensure_last_superuser_protection(target, "user", False)
if target.role == "admin" and repository.count_admins() <= 1:
raise ValueError("LAST_ADMIN_PROTECTED")
repository.delete_user(target.id)
revoke_user_refresh_family(target.id)
write_audit_event(
action="admin.user.delete",
actor_user_id=admin_user.id,
actor_email=admin_user.email,
details={"target_user_id": target.id},
)
return {"status": "deleted"}
def get_admin_summary() -> dict:
return {
"users_count": repository.count_users(),
"registrations_day": repository.count_users_registered_today(),
"admins_count": repository.count_admins(),
"superusers_count": repository.count_superusers(),
}
def get_admin_settings() -> dict:
return get_settings_payload()
def patch_admin_settings(admin_user, values: dict) -> dict:
merged = write_settings(values)
apply_settings_to_app(merged)
write_audit_event(
action="admin.settings.patch",
actor_user_id=admin_user.id,
actor_email=admin_user.email,
details={"updated_keys": sorted(values.keys())},
)
return get_settings_payload()
def get_diagnostics_report() -> dict:
return build_security_diagnostics_report()
def list_activity_feed(limit: int = 200) -> dict:
return {"events": read_audit_events(limit=limit)}
def record_ui_activity(admin_user, event: str, meta: dict | None) -> dict:
write_audit_event(
action=f"ui.{event}",
actor_user_id=admin_user.id,
actor_email=admin_user.email,
details=meta or {},
)
return {"status": "ok"}
def get_server_log_tail(lines: int = 200) -> dict:
path = Path(settings.server_log_path)
if not path.exists():
return {"lines": []}
return {"lines": path.read_text(encoding="utf-8", errors="ignore").splitlines()[-lines:]}
def get_install_secrets() -> dict:
return install_secrets_payload()
def reveal_secret(admin_user, key: str) -> dict:
value = reveal_install_secret(key)
write_audit_event(
action="admin.secrets.reveal",
actor_user_id=admin_user.id,
actor_email=admin_user.email,
details={"key": key},
)
return {"key": key, "value": value}
@@ -0,0 +1,8 @@
from app.modules.users import repository
def get_admin_dashboard_metrics() -> dict:
return {
"users_count": repository.count_users(),
"registrations_day": repository.count_users_registered_today(),
}
+1
View File
@@ -0,0 +1 @@
"""Auth module."""
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
from datetime import UTC, datetime
from uuid import uuid4
from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class RefreshToken(Base):
__tablename__ = "refresh_tokens"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True)
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
family_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
)
class PasswordResetToken(Base):
__tablename__ = "password_reset_tokens"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True)
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class EmailVerificationToken(Base):
__tablename__ = "email_verification_tokens"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True)
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+132
View File
@@ -0,0 +1,132 @@
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import delete, select
from app.core.database import session_scope
from app.modules.auth.models import EmailVerificationToken, PasswordResetToken, RefreshToken
def _detach(db, instance):
db.refresh(instance)
db.expunge(instance)
return instance
def create_refresh_token(
user_id: str,
token_hash: str,
family_id: str,
expires_at: datetime,
) -> RefreshToken:
with session_scope() as db:
token = RefreshToken(
user_id=user_id,
token_hash=token_hash,
family_id=family_id,
expires_at=expires_at,
)
db.add(token)
db.flush()
return _detach(db, token)
def get_refresh_token(token_hash: str) -> RefreshToken | None:
with session_scope() as db:
token = db.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash))
if not token:
return None
return _detach(db, token)
def revoke_refresh_token(token_hash: str) -> RefreshToken | None:
with session_scope() as db:
token = db.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash))
if not token:
return None
token.revoked_at = datetime.now(UTC)
db.flush()
return _detach(db, token)
def revoke_user_families(user_id: str) -> None:
with session_scope() as db:
tokens = db.scalars(
select(RefreshToken).where(
RefreshToken.user_id == user_id,
RefreshToken.revoked_at.is_(None),
)
).all()
now = datetime.now(UTC)
for token in tokens:
token.revoked_at = now
def revoke_family_tokens(family_id: str) -> None:
with session_scope() as db:
tokens = db.scalars(
select(RefreshToken).where(
RefreshToken.family_id == family_id,
RefreshToken.revoked_at.is_(None),
)
).all()
now = datetime.now(UTC)
for token in tokens:
token.revoked_at = now
def create_email_verification_token(user_id: str, token_hash: str, expires_at: datetime) -> None:
with session_scope() as db:
db.execute(
delete(EmailVerificationToken).where(
EmailVerificationToken.user_id == user_id,
EmailVerificationToken.used_at.is_(None),
)
)
db.add(EmailVerificationToken(user_id=user_id, token_hash=token_hash, expires_at=expires_at))
def get_email_verification_token(token_hash: str) -> EmailVerificationToken | None:
with session_scope() as db:
token = db.scalar(
select(EmailVerificationToken).where(EmailVerificationToken.token_hash == token_hash)
)
if not token:
return None
return _detach(db, token)
def mark_email_verification_token_used(token_hash: str) -> None:
with session_scope() as db:
token = db.scalar(
select(EmailVerificationToken).where(EmailVerificationToken.token_hash == token_hash)
)
if token:
token.used_at = datetime.now(UTC)
def create_password_reset_token(user_id: str, token_hash: str, expires_at: datetime) -> None:
with session_scope() as db:
db.execute(
delete(PasswordResetToken).where(
PasswordResetToken.user_id == user_id,
PasswordResetToken.used_at.is_(None),
)
)
db.add(PasswordResetToken(user_id=user_id, token_hash=token_hash, expires_at=expires_at))
def get_password_reset_token(token_hash: str) -> PasswordResetToken | None:
with session_scope() as db:
token = db.scalar(select(PasswordResetToken).where(PasswordResetToken.token_hash == token_hash))
if not token:
return None
return _detach(db, token)
def mark_password_reset_token_used(token_hash: str) -> None:
with session_scope() as db:
token = db.scalar(select(PasswordResetToken).where(PasswordResetToken.token_hash == token_hash))
if token:
token.used_at = datetime.now(UTC)
+206
View File
@@ -0,0 +1,206 @@
from app.core.datetime_utils import ensure_utc, utc_now
from fastapi import APIRouter, HTTPException, Request, Response, status
from app.core.config import settings
from app.core.redis import check_rate_limit, client_ip
from app.modules.auth.schemas import (
ForgotPasswordIn,
LoginIn,
LoginOut,
RegisterIn,
ResendVerificationIn,
ResetPasswordIn,
VerifyEmailIn,
)
from app.modules.auth.service import (
forgot_password,
login,
refresh,
register,
resend_verification,
reset_password,
revoke_refresh_token,
verify_email_token,
)
from app.modules.users import repository
router = APIRouter()
def _allowed_origins() -> set[str]:
origins: set[str] = set()
for raw in (settings.frontend_url, settings.public_base_url, *settings.cors_origins):
if not raw:
continue
normalized = raw.rstrip("/")
origins.add(normalized)
if "://localhost" in normalized:
origins.add(normalized.replace("://localhost", "://127.0.0.1"))
if "://127.0.0.1" in normalized:
origins.add(normalized.replace("://127.0.0.1", "://localhost"))
return origins
def _enforce_origin(request: Request, *, require_header: bool = False) -> None:
allowed = _allowed_origins()
origin = (request.headers.get("Origin") or "").rstrip("/")
referer = request.headers.get("Referer") or ""
if require_header and not origin and not referer:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="INVALID_ORIGIN")
if origin and origin not in allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="INVALID_ORIGIN")
if not origin and referer:
if not any(referer.startswith(base) for base in allowed):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="INVALID_ORIGIN")
def _set_refresh_cookie(response: Response, refresh_token: str) -> None:
response.set_cookie(
key="refresh_token",
value=refresh_token,
httponly=True,
secure=settings.cookie_secure,
samesite="lax",
path="/api/v1/auth",
max_age=settings.jwt_refresh_ttl_days * 24 * 60 * 60,
)
@router.post("/register")
async def register_route(payload: RegisterIn, request: Request):
_enforce_origin(request)
check_rate_limit(f"register:{client_ip(request)}", limit=3, window_seconds=3600)
user = register(payload.email, payload.password)
_ = user
return {"message": "If email is valid, verification has been sent."}
@router.post("/verify-email")
async def verify_email_route(payload: VerifyEmailIn):
try:
user = verify_email_token(payload.token)
except ValueError as exc:
detail = str(exc)
if detail == "TOKEN_EXPIRED":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOKEN_EXPIRED")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="INVALID_TOKEN")
_ = user
return {"status": "active"}
@router.post("/login", response_model=LoginOut)
async def login_route(payload: LoginIn, request: Request, response: Response):
_enforce_origin(request)
user = repository.get_user_by_email(payload.email)
if user:
locked_until = ensure_utc(user.locked_until)
if locked_until and locked_until > utc_now():
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="ACCOUNT_TEMPORARILY_LOCKED",
)
check_rate_limit(
f"login:{client_ip(request)}:{payload.email.lower()}",
limit=5,
window_seconds=60,
)
try:
access_token, refresh_token, user = login(payload.email, payload.password)
except ValueError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_CREDENTIALS")
except PermissionError as exc:
detail = str(exc)
if detail == "EMAIL_NOT_VERIFIED":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="EMAIL_NOT_VERIFIED")
if detail == "ACCOUNT_TEMPORARILY_LOCKED":
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="ACCOUNT_TEMPORARILY_LOCKED")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ACCOUNT_BLOCKED")
_set_refresh_cookie(response, refresh_token)
return {
"access_token": access_token,
"expires_in": settings.jwt_access_ttl_min * 60,
"user": {
"id": user.id,
"email": user.email,
"role": user.role,
"is_superuser": user.is_superuser,
"status": user.status,
},
}
@router.post("/refresh", response_model=LoginOut)
async def refresh_route(request: Request, response: Response):
refresh_token = request.cookies.get("refresh_token")
if not refresh_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="REFRESH_MISSING")
_enforce_origin(request, require_header=True)
check_rate_limit(f"refresh:{client_ip(request)}", limit=30, window_seconds=60)
try:
access_token, new_refresh, user = refresh(refresh_token)
except PermissionError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_REFRESH")
_set_refresh_cookie(response, new_refresh)
return {
"access_token": access_token,
"expires_in": settings.jwt_access_ttl_min * 60,
"user": {
"id": user.id,
"email": user.email,
"role": user.role,
"is_superuser": user.is_superuser,
"status": user.status,
},
}
@router.post("/logout")
async def logout_route(request: Request, response: Response):
_enforce_origin(request, require_header=True)
refresh_token = request.cookies.get("refresh_token")
if refresh_token:
revoke_refresh_token(refresh_token)
response.delete_cookie(
"refresh_token",
path="/api/v1/auth",
secure=settings.cookie_secure,
samesite="lax",
)
return {"message": "logged_out"}
@router.post("/forgot-password")
async def forgot_password_route(payload: ForgotPasswordIn, request: Request):
_enforce_origin(request)
check_rate_limit(
f"forgot:{client_ip(request)}:{payload.email.lower()}",
limit=3,
window_seconds=3600,
)
forgot_password(payload.email)
return {"message": "If email is registered, reset instructions have been sent."}
@router.post("/reset-password")
async def reset_password_route(payload: ResetPasswordIn):
try:
reset_password(payload.token, payload.new_password)
except ValueError as exc:
detail = str(exc)
if detail == "TOKEN_EXPIRED":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOKEN_EXPIRED")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="INVALID_TOKEN")
return {"message": "password_updated"}
@router.post("/resend-verification")
async def resend_verification_route(payload: ResendVerificationIn, request: Request):
_enforce_origin(request)
check_rate_limit(
f"resend:{client_ip(request)}:{payload.email.lower()}",
limit=3,
window_seconds=3600,
)
resend_verification(payload.email)
return {"message": "If email is registered, verification has been sent."}
+69
View File
@@ -0,0 +1,69 @@
from pydantic import BaseModel, EmailStr, Field, field_validator
from app.core.password_policy import validate_password_strength
class RegisterIn(BaseModel):
email: EmailStr
password: str = Field(min_length=8)
@field_validator("email", "password", mode="before")
@classmethod
def strip_whitespace(cls, value: str) -> str:
if isinstance(value, str):
return value.strip()
return value
@field_validator("password")
@classmethod
def password_policy(cls, value: str) -> str:
return validate_password_strength(value)
class LoginIn(BaseModel):
email: EmailStr
password: str = Field(min_length=8)
@field_validator("email", "password", mode="before")
@classmethod
def strip_whitespace(cls, value: str) -> str:
if isinstance(value, str):
return value.strip()
return value
class VerifyEmailIn(BaseModel):
token: str
class ForgotPasswordIn(BaseModel):
email: EmailStr
class ResendVerificationIn(BaseModel):
email: EmailStr
class ResetPasswordIn(BaseModel):
token: str
new_password: str = Field(min_length=8)
@field_validator("new_password")
@classmethod
def password_policy(cls, value: str) -> str:
return validate_password_strength(value)
class AuthUserOut(BaseModel):
id: str
email: EmailStr
role: str
is_superuser: bool
status: str
class LoginOut(BaseModel):
access_token: str
token_type: str = "bearer"
expires_in: int = 900
user: AuthUserOut
+228
View File
@@ -0,0 +1,228 @@
from __future__ import annotations
from datetime import timedelta
from uuid import uuid4
from app.core.config import settings
from app.core.datetime_utils import ensure_utc, utc_now
from app.core.email import send_template_email
from app.core.security import (
create_access_token,
generate_opaque_token,
generate_refresh_token,
hash_opaque_token,
hash_password,
hash_refresh_token,
verify_password,
)
from app.modules.auth import repository as auth_repository
from app.modules.users import repository
from app.modules.users.models import User
def _token_expires_at():
return utc_now() + timedelta(hours=settings.auth_token_ttl_hours)
def _send_verification_email(user: User, token: str) -> None:
verify_url = f"{settings.frontend_url}/verify?token={token}"
show_token = settings.email_delivery_mode == "memory" or settings.enable_test_routes
token_line = f"TOKEN:{token}\n" if show_token else ""
body = "Confirm your Compton account.\n\n" + f"Open: {verify_url}\n" + token_line
send_template_email(
to=user.email,
template="verify_email",
subject="Confirm your Compton account",
body=body,
)
def _send_password_reset_email(user: User, token: str) -> None:
reset_url = f"{settings.frontend_url}/reset-password?token={token}"
show_token = settings.email_delivery_mode == "memory" or settings.enable_test_routes
token_line = f"TOKEN:{token}\n" if show_token else ""
body = "Reset your Compton password.\n\n" + f"Open: {reset_url}\n" + token_line
send_template_email(
to=user.email,
template="reset_password",
subject="Reset your Compton password",
body=body,
)
def _issue_verification_token(user_id: str) -> str:
token = generate_opaque_token()
auth_repository.create_email_verification_token(
user_id=user_id,
token_hash=hash_opaque_token(token),
expires_at=_token_expires_at(),
)
return token
def _issue_password_reset_token(user_id: str) -> str:
token = generate_opaque_token()
auth_repository.create_password_reset_token(
user_id=user_id,
token_hash=hash_opaque_token(token),
expires_at=_token_expires_at(),
)
return token
def register(email: str, password: str) -> User:
existing = repository.get_user_by_email(email)
if existing:
if existing.status == "pending":
token = _issue_verification_token(existing.id)
_send_verification_email(existing, token)
return existing
user = repository.create_user(email=email, password_hash=hash_password(password), status="pending")
token = _issue_verification_token(user.id)
_send_verification_email(user, token)
return user
def verify_email_token(token: str) -> User:
token_hash = hash_opaque_token(token)
token_row = auth_repository.get_email_verification_token(token_hash)
if not token_row or token_row.used_at is not None:
raise ValueError("INVALID_TOKEN")
if ensure_utc(token_row.expires_at) < utc_now():
raise ValueError("TOKEN_EXPIRED")
user = repository.get_user_by_id(token_row.user_id)
if not user:
raise ValueError("INVALID_TOKEN")
user.status = "active"
user.email_verified_at = utc_now()
repository.update_user(user)
auth_repository.mark_email_verification_token_used(token_hash)
return user
def resend_verification(email: str) -> None:
user = repository.get_user_by_email(email)
if not user or user.status != "pending":
return
token = _issue_verification_token(user.id)
_send_verification_email(user, token)
def forgot_password(email: str) -> None:
user = repository.get_user_by_email(email)
if not user:
return
token = _issue_password_reset_token(user.id)
_send_password_reset_email(user, token)
def reset_password(token: str, new_password: str) -> None:
token_hash = hash_opaque_token(token)
token_row = auth_repository.get_password_reset_token(token_hash)
if not token_row or token_row.used_at is not None:
raise ValueError("INVALID_TOKEN")
if ensure_utc(token_row.expires_at) < utc_now():
raise ValueError("TOKEN_EXPIRED")
user = repository.get_user_by_id(token_row.user_id)
if not user:
raise ValueError("INVALID_TOKEN")
user.password_hash = hash_password(new_password)
repository.update_user(user)
auth_repository.mark_password_reset_token_used(token_hash)
revoke_user_refresh_family(user.id)
def _is_locked(user: User) -> bool:
locked_until = ensure_utc(user.locked_until)
if locked_until and locked_until > utc_now():
return True
if locked_until and locked_until <= utc_now():
user.failed_login_attempts = 0
user.locked_until = None
repository.update_user(user)
return False
def _record_failed_login(user: User) -> None:
locked_until = ensure_utc(user.locked_until)
if locked_until and locked_until <= utc_now():
user.failed_login_attempts = 0
user.locked_until = None
user.failed_login_attempts += 1
if user.failed_login_attempts >= settings.auth_lockout_attempts:
user.locked_until = utc_now() + timedelta(minutes=settings.auth_lockout_minutes)
repository.update_user(user)
def _reset_login_attempts(user: User) -> None:
user.failed_login_attempts = 0
user.locked_until = None
repository.update_user(user)
def login(email: str, password: str) -> tuple[str, str, User]:
user = repository.get_user_by_email(email)
if user and _is_locked(user):
raise PermissionError("ACCOUNT_TEMPORARILY_LOCKED")
if not user or not verify_password(password, user.password_hash):
if user:
_record_failed_login(user)
raise ValueError("INVALID_CREDENTIALS")
if user.status == "pending":
raise PermissionError("EMAIL_NOT_VERIFIED")
if user.status == "blocked":
raise PermissionError("ACCOUNT_BLOCKED")
_reset_login_attempts(user)
access_token = create_access_token(user.id, user.role, user.is_superuser)
refresh_token = issue_refresh_token(user.id)
return access_token, refresh_token, user
def issue_refresh_token(user_id: str, family_id: str | None = None) -> str:
token = generate_refresh_token()
token_hash = hash_refresh_token(token)
family = family_id or str(uuid4())
auth_repository.create_refresh_token(
user_id=user_id,
token_hash=token_hash,
family_id=family,
expires_at=utc_now() + timedelta(days=settings.jwt_refresh_ttl_days),
)
return token
def refresh(refresh_token: str) -> tuple[str, str, User]:
token_hash = hash_refresh_token(refresh_token)
token_row = auth_repository.get_refresh_token(token_hash)
if not token_row:
raise PermissionError("INVALID_REFRESH")
if token_row.revoked_at is not None:
auth_repository.revoke_family_tokens(token_row.family_id)
raise PermissionError("INVALID_REFRESH")
if ensure_utc(token_row.expires_at) < utc_now():
raise PermissionError("EXPIRED_REFRESH")
user = repository.get_user_by_id(token_row.user_id)
if not user:
raise PermissionError("INVALID_REFRESH")
if user.status in {"pending", "blocked"}:
raise PermissionError("INVALID_REFRESH")
auth_repository.revoke_refresh_token(token_hash)
new_refresh = issue_refresh_token(user.id, family_id=token_row.family_id)
access = create_access_token(user.id, user.role, user.is_superuser)
return access, new_refresh, user
def revoke_refresh_token(refresh_token: str) -> None:
token_hash = hash_refresh_token(refresh_token)
token_row = auth_repository.revoke_refresh_token(token_hash)
if token_row:
auth_repository.revoke_family_tokens(token_row.family_id)
def revoke_user_refresh_family(user_id: str) -> None:
auth_repository.revoke_user_families(user_id)
+1
View File
@@ -0,0 +1 @@
"""Content module."""
+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
from datetime import UTC, datetime
from uuid import uuid4
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class ContentPage(Base):
__tablename__ = "content_pages"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
slug: Mapped[str] = mapped_column(String(120), unique=True, nullable=False, index=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
body: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="draft", index=True)
author_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("users.id"), nullable=True)
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
+105
View File
@@ -0,0 +1,105 @@
from __future__ import annotations
from datetime import UTC, datetime
from uuid import uuid4
from sqlalchemy import select
from app.core.database import session_scope
from app.modules.content.models import ContentPage
ALLOWED_PAGE_STATUSES = {"draft", "published"}
def _detach(db, instance):
db.refresh(instance)
db.expunge(instance)
return instance
def list_published_pages() -> list[ContentPage]:
with session_scope() as db:
pages = list(
db.scalars(select(ContentPage).where(ContentPage.status == "published").order_by(ContentPage.slug))
)
return [_detach(db, page) for page in pages]
def list_all_pages() -> list[ContentPage]:
with session_scope() as db:
pages = list(db.scalars(select(ContentPage).order_by(ContentPage.slug)))
return [_detach(db, page) for page in pages]
def get_page_by_slug(slug: str, include_draft: bool = False) -> ContentPage | None:
with session_scope() as db:
page = db.scalar(select(ContentPage).where(ContentPage.slug == slug))
if not page:
return None
if include_draft or page.status == "published":
return _detach(db, page)
return None
def get_page_by_id(page_id: str) -> ContentPage | None:
with session_scope() as db:
page = db.get(ContentPage, page_id)
if not page:
return None
return _detach(db, page)
def create_page(
slug: str,
title: str,
body: str,
status: str,
author_id: str,
) -> ContentPage:
if status not in ALLOWED_PAGE_STATUSES:
raise ValueError("INVALID_STATUS")
with session_scope() as db:
page = ContentPage(
id=str(uuid4()),
slug=slug,
title=title,
body=body,
status=status,
author_id=author_id,
published_at=datetime.now(UTC) if status == "published" else None,
)
db.add(page)
db.flush()
return _detach(db, page)
def update_page(
page_id: str,
title: str | None,
body: str | None,
status: str | None,
) -> ContentPage:
with session_scope() as db:
page = db.get(ContentPage, page_id)
if not page:
raise KeyError(page_id)
if title:
page.title = title
if body:
page.body = body
if status:
if status not in ALLOWED_PAGE_STATUSES:
raise ValueError("INVALID_STATUS")
page.status = status
if status == "published" and page.published_at is None:
page.published_at = datetime.now(UTC)
page.updated_at = datetime.now(UTC)
db.flush()
return _detach(db, page)
def delete_page(page_id: str) -> None:
with session_scope() as db:
page = db.get(ContentPage, page_id)
if page:
db.delete(page)
+56
View File
@@ -0,0 +1,56 @@
from fastapi import APIRouter, Depends, HTTPException
from app.core.dependencies import require_admin
from app.modules.content.schemas import ContentPageIn, ContentPagePatchIn
from app.modules.content.service import (
create_page,
delete_page,
get_page_by_slug,
list_all_pages,
list_published_pages,
page_exists,
page_to_dict,
update_page,
)
router = APIRouter()
@router.get("/pages")
async def list_pages_route():
pages = list_published_pages()
return {"data": [page_to_dict(page) for page in pages]}
@router.get("/pages/manage/all")
async def list_all_pages_route(_admin=Depends(require_admin)):
pages = list_all_pages()
return {"data": [page_to_dict(page) for page in pages]}
@router.get("/pages/{slug}")
async def get_page_route(slug: str):
page = get_page_by_slug(slug)
if not page:
raise HTTPException(status_code=404, detail="PAGE_NOT_FOUND")
return page_to_dict(page)
@router.post("/pages")
async def create_page_route(payload: ContentPageIn, admin=Depends(require_admin)):
page = create_page(payload.slug, payload.title, payload.body, payload.status, admin.id)
return page_to_dict(page)
@router.patch("/pages/{page_id}")
async def update_page_route(page_id: str, payload: ContentPagePatchIn, _admin=Depends(require_admin)):
if not page_exists(page_id):
raise HTTPException(status_code=404, detail="PAGE_NOT_FOUND")
page = update_page(page_id, payload.title, payload.body, payload.status)
return page_to_dict(page)
@router.delete("/pages/{page_id}")
async def delete_page_route(page_id: str, _admin=Depends(require_admin)):
delete_page(page_id)
return {"message": "deleted"}
+24
View File
@@ -0,0 +1,24 @@
from typing import Literal
from pydantic import BaseModel, Field
class ContentPageIn(BaseModel):
slug: str = Field(min_length=2, max_length=120)
title: str = Field(min_length=2, max_length=200)
body: str
status: Literal["draft", "published"] = "draft"
class ContentPagePatchIn(BaseModel):
title: str | None = Field(default=None, min_length=2, max_length=200)
body: str | None = None
status: Literal["draft", "published"] | None = None
class ContentPageOut(BaseModel):
id: str
slug: str
title: str
body: str
status: str
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
import bleach
from app.modules.content import repository
from app.modules.content.models import ContentPage
ALLOWED_TAGS = ["p", "h1", "h2", "h3", "h4", "ul", "ol", "li", "a", "strong", "em", "br", "img"]
def sanitize_html(raw_html: str) -> str:
return bleach.clean(
raw_html,
tags=ALLOWED_TAGS,
attributes={"a": ["href"], "img": ["src", "alt"]},
protocols=["http", "https", "mailto"],
strip=True,
)
def page_to_dict(page: ContentPage) -> dict:
return {
"id": page.id,
"slug": page.slug,
"title": page.title,
"body": page.body,
"status": page.status,
"author_id": page.author_id,
"published_at": page.published_at.isoformat() if page.published_at else None,
"updated_at": page.updated_at.isoformat() if page.updated_at else None,
}
def list_published_pages() -> list[ContentPage]:
return repository.list_published_pages()
def list_all_pages() -> list[ContentPage]:
return repository.list_all_pages()
def get_page_by_slug(slug: str, include_draft: bool = False) -> ContentPage | None:
return repository.get_page_by_slug(slug, include_draft=include_draft)
def create_page(slug: str, title: str, body: str, status: str, author_id: str) -> ContentPage:
return repository.create_page(
slug=slug,
title=title,
body=sanitize_html(body),
status=status,
author_id=author_id,
)
def update_page(page_id: str, title: str | None, body: str | None, status: str | None) -> ContentPage:
return repository.update_page(
page_id,
title,
sanitize_html(body) if body else None,
status,
)
def delete_page(page_id: str) -> None:
repository.delete_page(page_id)
def page_exists(page_id: str) -> bool:
return repository.get_page_by_id(page_id) is not None
+24
View File
@@ -0,0 +1,24 @@
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import Response
from app.core.media_signing import verify_signed_media
from app.core.storage import download_object
router = APIRouter()
@router.get("/files/{file_path:path}")
async def get_media_file(
file_path: str,
expires: int = Query(...),
sig: str = Query(...),
):
if not file_path.startswith("avatars/"):
raise HTTPException(status_code=404, detail="FILE_NOT_FOUND")
if not verify_signed_media(file_path, expires, sig):
raise HTTPException(status_code=403, detail="INVALID_SIGNATURE")
stored = download_object(file_path)
if not stored:
raise HTTPException(status_code=404, detail="FILE_NOT_FOUND")
body, content_type = stored
return Response(content=body, media_type=content_type)
+5
View File
@@ -0,0 +1,5 @@
from pydantic import BaseModel
class AvatarUploadOut(BaseModel):
message: str
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
from io import BytesIO
from uuid import uuid4
from PIL import Image
from app.core.config import settings
from app.core.storage import upload_object
ALLOWED_MIME = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
}
FORMAT_TO_MIME = {
"JPEG": "image/jpeg",
"PNG": "image/png",
"WEBP": "image/webp",
}
class AvatarValidationError(ValueError):
pass
def validate_and_process_avatar(content: bytes) -> tuple[str, bytes]:
if len(content) > settings.avatar_max_bytes:
raise AvatarValidationError("FILE_TOO_LARGE")
if not content:
raise AvatarValidationError("INVALID_IMAGE")
try:
with Image.open(BytesIO(content)) as image:
image.verify()
with Image.open(BytesIO(content)) as image:
mime = FORMAT_TO_MIME.get(image.format or "")
if mime not in ALLOWED_MIME:
raise AvatarValidationError("INVALID_MIME")
buffer = BytesIO()
if mime == "image/jpeg":
rgb = image.convert("RGB")
rgb.save(buffer, format="JPEG", quality=85, optimize=True)
elif mime == "image/png":
image.save(buffer, format="PNG", optimize=True)
else:
image.save(buffer, format="WEBP", quality=85, method=6)
return mime, buffer.getvalue()
except AvatarValidationError:
raise
except Exception as exc:
raise AvatarValidationError("INVALID_IMAGE") from exc
def upload_user_avatar(user_id: str, content: bytes) -> str:
mime, processed = validate_and_process_avatar(content)
extension = ALLOWED_MIME[mime]
key = f"avatars/{user_id}/{uuid4()}{extension}"
upload_object(key, processed, mime)
return f"/api/v1/media/files/{key}"
@@ -0,0 +1,8 @@
"""Celery-ready notifications service placeholder.
MVP keeps SMTP sync path in auth; v1.0 should move to async queue.
"""
def enqueue_email(template: str, recipient: str, context: dict) -> dict:
return {"queued": True, "template": template, "recipient": recipient, "context": context}
+5
View File
@@ -0,0 +1,5 @@
"""Orders module placeholder (request/booking mode without payment)."""
def create_order_request(user_id: str, items: list[dict]) -> dict:
return {"id": "order-placeholder", "user_id": user_id, "items": items, "status": "submitted"}
+16
View File
@@ -0,0 +1,16 @@
from fastapi import APIRouter, HTTPException, Query
from app.core.config import settings
from app.core.email import memory_mailer
router = APIRouter()
@router.get("/emails/latest-token")
async def latest_email_token(to: str = Query(...), template: str = Query(...)):
if not settings.enable_test_routes or settings.email_delivery_mode != "memory":
raise HTTPException(status_code=404, detail="NOT_FOUND")
token = memory_mailer.latest_token(to, template)
if not token:
raise HTTPException(status_code=404, detail="TOKEN_NOT_FOUND")
return {"token": token}
+1
View File
@@ -0,0 +1 @@
"""Users module."""
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
from datetime import UTC, datetime
from uuid import uuid4
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
class User(Base):
__tablename__ = "users"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[str] = mapped_column(String(16), nullable=False, default="user")
is_superuser: Mapped[bool] = mapped_column(nullable=False, default=False)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True)
failed_login_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
profile: Mapped["UserProfile"] = relationship(
back_populates="user",
uselist=False,
cascade="all, delete-orphan",
passive_deletes=True,
)
class UserProfile(Base):
__tablename__ = "user_profiles"
user_id: Mapped[str] = mapped_column(
String(36), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
)
display_name: Mapped[str] = mapped_column(String(120), nullable=False)
avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
metadata_json: Mapped[str | None] = mapped_column(Text, nullable=True)
user: Mapped[User] = relationship(back_populates="profile")
+164
View File
@@ -0,0 +1,164 @@
from __future__ import annotations
from datetime import UTC, datetime
from uuid import uuid4
from sqlalchemy import func, select
from app.core.database import session_scope
from app.modules.users.models import User, UserProfile
ALLOWED_ROLES = {"user", "admin"}
ALLOWED_STATUSES = {"pending", "active", "blocked"}
def _detach(db, instance):
db.refresh(instance)
db.expunge(instance)
return instance
def create_user(
email: str,
password_hash: str,
role: str = "user",
is_superuser: bool = False,
status: str = "pending",
) -> User:
if role not in ALLOWED_ROLES:
raise ValueError("INVALID_ROLE")
if status not in ALLOWED_STATUSES:
raise ValueError("INVALID_STATUS")
if is_superuser and role != "admin":
raise ValueError("SUPERUSER_REQUIRES_ADMIN")
with session_scope() as db:
user = User(
id=str(uuid4()),
email=email.lower(),
password_hash=password_hash,
role=role,
is_superuser=is_superuser,
status=status,
)
db.add(user)
db.flush()
profile = UserProfile(user_id=user.id, display_name=email.split("@")[0])
db.add(profile)
db.flush()
return _detach(db, user)
def get_user_by_email(email: str) -> User | None:
with session_scope() as db:
user = db.scalar(select(User).where(User.email == email.lower()))
if not user:
return None
return _detach(db, user)
def get_user_by_id(user_id: str) -> User | None:
with session_scope() as db:
user = db.get(User, user_id)
if not user:
return None
return _detach(db, user)
def update_user(user: User) -> None:
if user.role not in ALLOWED_ROLES:
raise ValueError("INVALID_ROLE")
if user.status not in ALLOWED_STATUSES:
raise ValueError("INVALID_STATUS")
if user.is_superuser and user.role != "admin":
raise ValueError("SUPERUSER_REQUIRES_ADMIN")
with session_scope() as db:
db_user = db.get(User, user.id)
if not db_user:
return
db_user.email = user.email
db_user.password_hash = user.password_hash
db_user.role = user.role
db_user.is_superuser = user.is_superuser
db_user.status = user.status
db_user.failed_login_attempts = user.failed_login_attempts
db_user.locked_until = user.locked_until
db_user.email_verified_at = user.email_verified_at
db_user.updated_at = datetime.now(UTC)
def list_users(page: int, limit: int) -> tuple[list[User], int]:
with session_scope() as db:
total = db.scalar(select(func.count()).select_from(User)) or 0
users = db.scalars(
select(User).order_by(User.created_at.desc()).offset((page - 1) * limit).limit(limit)
).all()
return [_detach(db, user) for user in users], total
def count_users() -> int:
with session_scope() as db:
return db.scalar(select(func.count()).select_from(User)) or 0
def count_users_registered_today() -> int:
today = datetime.now(UTC).date()
with session_scope() as db:
return (
db.scalar(
select(func.count())
.select_from(User)
.where(func.date(User.created_at) == today)
)
or 0
)
def count_admins() -> int:
with session_scope() as db:
return db.scalar(select(func.count()).select_from(User).where(User.role == "admin")) or 0
def count_superusers() -> int:
with session_scope() as db:
return (
db.scalar(
select(func.count())
.select_from(User)
.where(User.role == "admin", User.is_superuser.is_(True))
)
or 0
)
def delete_user(user_id: str) -> bool:
with session_scope() as db:
user = db.get(User, user_id)
if not user:
return False
db.delete(user)
return True
def get_profile(user_id: str) -> UserProfile:
with session_scope() as db:
profile = db.get(UserProfile, user_id)
if not profile:
raise KeyError(user_id)
return _detach(db, profile)
def update_profile(
user_id: str,
display_name: str | None = None,
avatar_url: str | None = None,
) -> UserProfile:
with session_scope() as db:
profile = db.get(UserProfile, user_id)
if not profile:
raise KeyError(user_id)
if display_name is not None:
profile.display_name = display_name
if avatar_url is not None:
profile.avatar_url = avatar_url
db.flush()
return _detach(db, profile)
+59
View File
@@ -0,0 +1,59 @@
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
from app.core.dependencies import get_current_user
from app.core.media_signing import build_signed_media_url
from app.core.redis import check_rate_limit
from app.modules.media.service import AvatarValidationError
from app.modules.users.schemas import PasswordChangeIn, UserPatchIn
from app.modules.users.service import change_password, get_me, update_me, upload_avatar
router = APIRouter()
def _profile_response(data: dict) -> dict:
return {
"user": {
"id": data["user"].id,
"email": data["user"].email,
"role": data["user"].role,
"status": data["user"].status,
},
"profile": {
"display_name": data["profile"].display_name,
"avatar_url": build_signed_media_url(data["profile"].avatar_url),
},
}
@router.get("/me")
async def me_route(current_user=Depends(get_current_user)):
return _profile_response(get_me(current_user))
@router.patch("/me")
async def patch_me_route(payload: UserPatchIn, current_user=Depends(get_current_user)):
return _profile_response(update_me(current_user, payload.display_name))
@router.post("/me/password")
async def change_password_route(payload: PasswordChangeIn, current_user=Depends(get_current_user)):
try:
change_password(current_user, payload.current_password, payload.new_password)
except ValueError:
raise HTTPException(status_code=400, detail="INVALID_CURRENT_PASSWORD")
return {"message": "password_changed"}
@router.post("/me/avatar")
async def upload_avatar_route(
request: Request,
file: UploadFile = File(...),
current_user=Depends(get_current_user),
):
check_rate_limit(f"avatar:{current_user.id}", limit=10, window_seconds=3600)
content = await file.read()
try:
data = upload_avatar(current_user, content)
except AvatarValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return _profile_response(data)
+34
View File
@@ -0,0 +1,34 @@
from pydantic import BaseModel, EmailStr, Field, field_validator
from app.core.password_policy import validate_password_strength
class UserOut(BaseModel):
id: str
email: EmailStr
role: str
status: str
class UserProfileOut(BaseModel):
display_name: str
avatar_url: str | None = None
class UserMeOut(BaseModel):
user: UserOut
profile: UserProfileOut
class UserPatchIn(BaseModel):
display_name: str | None = Field(default=None, min_length=1, max_length=120)
class PasswordChangeIn(BaseModel):
current_password: str
new_password: str = Field(min_length=8)
@field_validator("new_password")
@classmethod
def password_policy(cls, value: str) -> str:
return validate_password_strength(value)
+29
View File
@@ -0,0 +1,29 @@
from app.core.security import hash_password, verify_password
from app.modules.auth.service import revoke_user_refresh_family
from app.modules.media.service import upload_user_avatar
from app.modules.users import repository
from app.modules.users.models import User
def get_me(user: User) -> dict:
profile = repository.get_profile(user.id)
return {"user": user, "profile": profile}
def update_me(user: User, display_name: str | None) -> dict:
profile = repository.update_profile(user.id, display_name=display_name)
return {"user": user, "profile": profile}
def upload_avatar(user: User, content: bytes) -> dict:
avatar_url = upload_user_avatar(user.id, content)
profile = repository.update_profile(user.id, avatar_url=avatar_url)
return {"user": user, "profile": profile}
def change_password(user: User, current_password: str, new_password: str) -> None:
if not verify_password(current_password, user.password_hash):
raise ValueError("INVALID_CURRENT_PASSWORD")
user.password_hash = hash_password(new_password)
repository.update_user(user)
revoke_user_refresh_family(user.id)
+1
View File
@@ -0,0 +1 @@
"""Celery worker entrypoint placeholder for v1.0 notifications."""
+21
View File
@@ -0,0 +1,21 @@
{
"enable_rate_limit": false,
"enable_docs": false,
"cookie_secure": false,
"jwt_access_ttl_min": 15,
"auth_lockout_attempts": 5,
"auth_lockout_minutes": 15,
"cors_origins": [
"http://localhost:5173"
],
"frontend_url": "http://localhost:5173",
"public_base_url": "http://localhost:5173",
"smtp_host": "localhost",
"smtp_port": 1025,
"smtp_from": "noreply@compton.example",
"avatar_max_bytes": 2097152,
"media_url_ttl_seconds": 600,
"log_level": "INFO",
"audit_retention_days": 90,
"jwt_refresh_ttl_days": 30
}
+52
View File
@@ -0,0 +1,52 @@
{"timestamp": "2026-07-14T09:45:09.679160+00:00", "action": "admin.settings.patch", "actor_user_id": "2b0a09b4-4f73-4a98-887f-e2a90357b443", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}}
{"timestamp": "2026-07-14T09:45:10.079262+00:00", "action": "admin.user.create", "actor_user_id": "2b0a09b4-4f73-4a98-887f-e2a90357b443", "actor_email": "admin@compton.example", "details": {"target_user_id": "2effcf29-377b-417d-a804-d19cb4c9e2a1"}}
{"timestamp": "2026-07-14T09:45:11.267271+00:00", "action": "admin.user.patch", "actor_user_id": "2b0a09b4-4f73-4a98-887f-e2a90357b443", "actor_email": "admin@compton.example", "details": {"target_user_id": "a508d851-1f17-4397-a272-c67ac65d0b12"}}
{"timestamp": "2026-07-14T09:45:24.615324+00:00", "action": "admin.settings.patch", "actor_user_id": "077a2a71-17ed-407c-be59-3d8cd138a77b", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}}
{"timestamp": "2026-07-14T09:45:25.017710+00:00", "action": "admin.user.create", "actor_user_id": "077a2a71-17ed-407c-be59-3d8cd138a77b", "actor_email": "admin@compton.example", "details": {"target_user_id": "d1b2ba42-b482-45b4-b0cb-1296aa4015ad"}}
{"timestamp": "2026-07-14T09:45:25.217777+00:00", "action": "admin.user.delete", "actor_user_id": "077a2a71-17ed-407c-be59-3d8cd138a77b", "actor_email": "admin@compton.example", "details": {"target_user_id": "d1b2ba42-b482-45b4-b0cb-1296aa4015ad"}}
{"timestamp": "2026-07-14T09:45:25.431192+00:00", "action": "admin.user.patch", "actor_user_id": "077a2a71-17ed-407c-be59-3d8cd138a77b", "actor_email": "admin@compton.example", "details": {"target_user_id": "55c1800f-e445-4eae-b84a-836c27ec88de"}}
{"timestamp": "2026-07-14T09:45:43.063910+00:00", "action": "admin.settings.patch", "actor_user_id": "caa8d97e-c943-4f83-8ee8-48f3d838c3d4", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}}
{"timestamp": "2026-07-14T09:45:43.471557+00:00", "action": "admin.user.create", "actor_user_id": "caa8d97e-c943-4f83-8ee8-48f3d838c3d4", "actor_email": "admin@compton.example", "details": {"target_user_id": "588ca17f-528d-44fc-987a-f9f87a294c4e"}}
{"timestamp": "2026-07-14T09:45:43.677436+00:00", "action": "admin.user.delete", "actor_user_id": "caa8d97e-c943-4f83-8ee8-48f3d838c3d4", "actor_email": "admin@compton.example", "details": {"target_user_id": "588ca17f-528d-44fc-987a-f9f87a294c4e"}}
{"timestamp": "2026-07-14T09:45:43.890842+00:00", "action": "admin.user.patch", "actor_user_id": "caa8d97e-c943-4f83-8ee8-48f3d838c3d4", "actor_email": "admin@compton.example", "details": {"target_user_id": "5094baf7-5c73-4e9d-a1bb-8baa98516303"}}
{"timestamp": "2026-07-14T09:46:32.753967+00:00", "action": "admin.settings.patch", "actor_user_id": "bf753562-e005-4f6e-9ee5-4e7d4c854841", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}}
{"timestamp": "2026-07-14T09:46:33.160994+00:00", "action": "admin.user.create", "actor_user_id": "bf753562-e005-4f6e-9ee5-4e7d4c854841", "actor_email": "admin@compton.example", "details": {"target_user_id": "d78de899-6f88-4dc8-a1c1-fb2bea3fd6e8"}}
{"timestamp": "2026-07-14T09:46:33.371828+00:00", "action": "admin.user.delete", "actor_user_id": "bf753562-e005-4f6e-9ee5-4e7d4c854841", "actor_email": "admin@compton.example", "details": {"target_user_id": "d78de899-6f88-4dc8-a1c1-fb2bea3fd6e8"}}
{"timestamp": "2026-07-14T09:46:33.574496+00:00", "action": "admin.user.patch", "actor_user_id": "bf753562-e005-4f6e-9ee5-4e7d4c854841", "actor_email": "admin@compton.example", "details": {"target_user_id": "9c93dc2b-b171-486e-ac8b-855a657dd4db"}}
{"timestamp": "2026-07-14T10:17:13.813043+00:00", "action": "admin.settings.patch", "actor_user_id": "1cc5290b-cb44-4b44-91ee-33aaba63c5d5", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}}
{"timestamp": "2026-07-14T10:17:15.032768+00:00", "action": "admin.user.patch", "actor_user_id": "1cc5290b-cb44-4b44-91ee-33aaba63c5d5", "actor_email": "admin@compton.example", "details": {"target_user_id": "a7f9dad1-652a-4a78-8eb4-559d73633cbb"}}
{"timestamp": "2026-07-14T10:17:17.964910+00:00", "action": "admin.user.patch", "actor_user_id": "1cc5290b-cb44-4b44-91ee-33aaba63c5d5", "actor_email": "admin@compton.example", "details": {"target_user_id": "14c90906-7e65-4c2f-bc10-3e2c37771f77"}}
{"timestamp": "2026-07-14T10:17:21.299378+00:00", "action": "admin.user.patch", "actor_user_id": "1cc5290b-cb44-4b44-91ee-33aaba63c5d5", "actor_email": "admin@compton.example", "details": {"target_user_id": "4fb379b6-d751-4b74-8441-dacfc765a1b6"}}
{"timestamp": "2026-07-14T10:18:21.413246+00:00", "action": "admin.settings.patch", "actor_user_id": "37f53597-5bbd-47cd-970c-2838641a9813", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}}
{"timestamp": "2026-07-14T10:18:21.831392+00:00", "action": "admin.user.create", "actor_user_id": "37f53597-5bbd-47cd-970c-2838641a9813", "actor_email": "admin@compton.example", "details": {"target_user_id": "3ed45afa-b03b-4599-ab09-129d35b5050a"}}
{"timestamp": "2026-07-14T10:18:22.043824+00:00", "action": "admin.user.delete", "actor_user_id": "37f53597-5bbd-47cd-970c-2838641a9813", "actor_email": "admin@compton.example", "details": {"target_user_id": "3ed45afa-b03b-4599-ab09-129d35b5050a"}}
{"timestamp": "2026-07-14T10:18:22.667006+00:00", "action": "admin.secrets.reveal", "actor_user_id": "37f53597-5bbd-47cd-970c-2838641a9813", "actor_email": "admin@compton.example", "details": {"key": "database_password"}}
{"timestamp": "2026-07-14T10:18:23.044151+00:00", "action": "admin.user.patch", "actor_user_id": "37f53597-5bbd-47cd-970c-2838641a9813", "actor_email": "admin@compton.example", "details": {"target_user_id": "19bd08e1-1fd2-45d6-9d2e-910b8021060d"}}
{"timestamp": "2026-07-14T10:18:25.817132+00:00", "action": "admin.user.patch", "actor_user_id": "37f53597-5bbd-47cd-970c-2838641a9813", "actor_email": "admin@compton.example", "details": {"target_user_id": "3e2fb3bf-a9c3-47e0-9c7d-5b9a4fff37d5"}}
{"timestamp": "2026-07-14T10:18:29.042541+00:00", "action": "admin.user.patch", "actor_user_id": "37f53597-5bbd-47cd-970c-2838641a9813", "actor_email": "admin@compton.example", "details": {"target_user_id": "25c9b2af-b46b-4c38-96e8-ca504e3d05ef"}}
{"timestamp": "2026-07-14T10:18:59.773024+00:00", "action": "admin.settings.patch", "actor_user_id": "df0394ad-000c-458b-9504-eccd2eae731f", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}}
{"timestamp": "2026-07-14T10:19:00.184666+00:00", "action": "admin.user.create", "actor_user_id": "df0394ad-000c-458b-9504-eccd2eae731f", "actor_email": "admin@compton.example", "details": {"target_user_id": "d3891a54-1000-41e4-ad5a-7f39b271025f"}}
{"timestamp": "2026-07-14T10:19:00.387212+00:00", "action": "admin.user.delete", "actor_user_id": "df0394ad-000c-458b-9504-eccd2eae731f", "actor_email": "admin@compton.example", "details": {"target_user_id": "d3891a54-1000-41e4-ad5a-7f39b271025f"}}
{"timestamp": "2026-07-14T10:19:01.013462+00:00", "action": "admin.secrets.reveal", "actor_user_id": "df0394ad-000c-458b-9504-eccd2eae731f", "actor_email": "admin@compton.example", "details": {"key": "database_password"}}
{"timestamp": "2026-07-14T10:19:01.232252+00:00", "action": "admin.user.patch", "actor_user_id": "df0394ad-000c-458b-9504-eccd2eae731f", "actor_email": "admin@compton.example", "details": {"target_user_id": "2a167305-8874-4b4b-9485-2f01dbc239e7"}}
{"timestamp": "2026-07-14T10:19:03.987813+00:00", "action": "admin.user.patch", "actor_user_id": "df0394ad-000c-458b-9504-eccd2eae731f", "actor_email": "admin@compton.example", "details": {"target_user_id": "022de58f-d06e-42a7-bd93-76e1df17848c"}}
{"timestamp": "2026-07-14T10:19:07.219981+00:00", "action": "admin.user.patch", "actor_user_id": "df0394ad-000c-458b-9504-eccd2eae731f", "actor_email": "admin@compton.example", "details": {"target_user_id": "589bffa1-74dd-401a-a21e-93721d7d9c4e"}}
{"timestamp": "2026-07-14T10:19:24.488155+00:00", "action": "admin.settings.patch", "actor_user_id": "051cb4c9-5be2-43d3-bb98-0c3fa086835b", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}}
{"timestamp": "2026-07-14T10:19:24.903805+00:00", "action": "admin.user.create", "actor_user_id": "051cb4c9-5be2-43d3-bb98-0c3fa086835b", "actor_email": "admin@compton.example", "details": {"target_user_id": "a96246c0-5e47-4150-8df5-85ab36f5d6da"}}
{"timestamp": "2026-07-14T10:19:25.101700+00:00", "action": "admin.user.delete", "actor_user_id": "051cb4c9-5be2-43d3-bb98-0c3fa086835b", "actor_email": "admin@compton.example", "details": {"target_user_id": "a96246c0-5e47-4150-8df5-85ab36f5d6da"}}
{"timestamp": "2026-07-14T10:19:25.741900+00:00", "action": "admin.secrets.reveal", "actor_user_id": "051cb4c9-5be2-43d3-bb98-0c3fa086835b", "actor_email": "admin@compton.example", "details": {"key": "database_password"}}
{"timestamp": "2026-07-14T10:19:25.941995+00:00", "action": "admin.user.patch", "actor_user_id": "051cb4c9-5be2-43d3-bb98-0c3fa086835b", "actor_email": "admin@compton.example", "details": {"target_user_id": "3c69f5f3-af00-4d5c-85d6-80a65cb7cf08"}}
{"timestamp": "2026-07-14T10:19:28.720348+00:00", "action": "admin.user.patch", "actor_user_id": "051cb4c9-5be2-43d3-bb98-0c3fa086835b", "actor_email": "admin@compton.example", "details": {"target_user_id": "a4c56a38-9f0a-48dd-80e2-96ee38f1bde1"}}
{"timestamp": "2026-07-14T10:19:31.957425+00:00", "action": "admin.user.patch", "actor_user_id": "051cb4c9-5be2-43d3-bb98-0c3fa086835b", "actor_email": "admin@compton.example", "details": {"target_user_id": "d91c9691-8277-44f5-8f02-534f6a1b75e4"}}
{"timestamp": "2026-07-14T10:20:50.255505+00:00", "action": "admin.settings.patch", "actor_user_id": "fe5b0efe-76af-4a5d-8d0e-92787fe4b6d6", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}}
{"timestamp": "2026-07-14T10:20:50.666682+00:00", "action": "admin.user.create", "actor_user_id": "fe5b0efe-76af-4a5d-8d0e-92787fe4b6d6", "actor_email": "admin@compton.example", "details": {"target_user_id": "48345f72-7498-4507-9f3c-be1069f2dfdf"}}
{"timestamp": "2026-07-14T10:20:50.885241+00:00", "action": "admin.user.delete", "actor_user_id": "fe5b0efe-76af-4a5d-8d0e-92787fe4b6d6", "actor_email": "admin@compton.example", "details": {"target_user_id": "48345f72-7498-4507-9f3c-be1069f2dfdf"}}
{"timestamp": "2026-07-14T10:20:51.518235+00:00", "action": "admin.secrets.reveal", "actor_user_id": "fe5b0efe-76af-4a5d-8d0e-92787fe4b6d6", "actor_email": "admin@compton.example", "details": {"key": "database_password"}}
{"timestamp": "2026-07-14T10:20:51.717954+00:00", "action": "admin.user.patch", "actor_user_id": "fe5b0efe-76af-4a5d-8d0e-92787fe4b6d6", "actor_email": "admin@compton.example", "details": {"target_user_id": "81911a53-d750-480b-aca2-a6c79488cf84"}}
{"timestamp": "2026-07-14T10:20:54.465598+00:00", "action": "admin.user.patch", "actor_user_id": "fe5b0efe-76af-4a5d-8d0e-92787fe4b6d6", "actor_email": "admin@compton.example", "details": {"target_user_id": "2c535720-06d4-4c30-8d00-36c43419c9bf"}}
{"timestamp": "2026-07-14T10:20:57.747476+00:00", "action": "admin.user.patch", "actor_user_id": "fe5b0efe-76af-4a5d-8d0e-92787fe4b6d6", "actor_email": "admin@compton.example", "details": {"target_user_id": "6de83480-a867-4166-97e4-6c5a053ae771"}}
{"timestamp": "2026-07-14T10:50:15.053615+00:00", "action": "admin.settings.patch", "actor_user_id": "7ffda8c7-0641-4d43-b13c-7ba1c6e85b71", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}}
{"timestamp": "2026-07-14T10:50:15.469330+00:00", "action": "admin.user.create", "actor_user_id": "7ffda8c7-0641-4d43-b13c-7ba1c6e85b71", "actor_email": "admin@compton.example", "details": {"target_user_id": "d3c5cc11-fe4e-4844-a1e7-d0a68ac71220"}}
{"timestamp": "2026-07-14T10:50:15.684274+00:00", "action": "admin.user.delete", "actor_user_id": "7ffda8c7-0641-4d43-b13c-7ba1c6e85b71", "actor_email": "admin@compton.example", "details": {"target_user_id": "d3c5cc11-fe4e-4844-a1e7-d0a68ac71220"}}
{"timestamp": "2026-07-14T10:50:16.307486+00:00", "action": "admin.secrets.reveal", "actor_user_id": "7ffda8c7-0641-4d43-b13c-7ba1c6e85b71", "actor_email": "admin@compton.example", "details": {"key": "database_password"}}
{"timestamp": "2026-07-14T10:50:16.524434+00:00", "action": "admin.user.patch", "actor_user_id": "7ffda8c7-0641-4d43-b13c-7ba1c6e85b71", "actor_email": "admin@compton.example", "details": {"target_user_id": "5afc1722-6ef5-4c0e-817a-fd1f4af99188"}}
+1
View File
@@ -0,0 +1 @@
+52
View File
@@ -0,0 +1,52 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.core.config import settings
from app.db.base import Base
# Import models so Alembic can discover metadata.
from app.modules.auth.models import EmailVerificationToken, PasswordResetToken, RefreshToken # noqa: F401
from app.modules.content.models import ContentPage # noqa: F401
from app.modules.users.models import User, UserProfile # noqa: F401
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
config.set_main_option("sqlalchemy.url", settings.database_url)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
transaction_per_migration=True,
)
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+25
View File
@@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,120 @@
"""initial schema
Revision ID: 20260711_0001
Revises:
Create Date: 2026-07-11 14:00:00
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260711_0001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("email", sa.String(length=255), nullable=False),
sa.Column("password_hash", sa.String(length=255), nullable=False),
sa.Column("role", sa.String(length=16), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False),
sa.Column("failed_login_attempts", sa.Integer(), nullable=False),
sa.Column("locked_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("email_verified_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("email"),
)
op.create_index("idx_users_email", "users", ["email"], unique=True)
op.create_index("idx_users_status", "users", ["status"], unique=False)
op.create_table(
"user_profiles",
sa.Column("user_id", sa.String(length=36), nullable=False),
sa.Column("display_name", sa.String(length=120), nullable=False),
sa.Column("avatar_url", sa.String(length=512), nullable=True),
sa.Column("metadata_json", sa.Text(), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("user_id"),
)
op.create_table(
"refresh_tokens",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("user_id", sa.String(length=36), nullable=False),
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("family_id", sa.String(length=36), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("token_hash"),
)
op.create_index("idx_refresh_tokens_user", "refresh_tokens", ["user_id"], unique=False)
op.create_index("idx_refresh_tokens_family", "refresh_tokens", ["family_id"], unique=False)
op.create_table(
"password_reset_tokens",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("user_id", sa.String(length=36), nullable=False),
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("token_hash"),
)
op.create_index("idx_password_reset_user", "password_reset_tokens", ["user_id"], unique=False)
op.create_table(
"email_verification_tokens",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("user_id", sa.String(length=36), nullable=False),
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("token_hash"),
)
op.create_table(
"content_pages",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("slug", sa.String(length=120), nullable=False),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("body", sa.Text(), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False),
sa.Column("author_id", sa.String(length=36), nullable=True),
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["author_id"], ["users.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("slug"),
)
op.create_index("idx_content_slug", "content_pages", ["slug"], unique=True)
op.create_index("idx_content_status", "content_pages", ["status"], unique=False)
def downgrade() -> None:
op.drop_index("idx_content_status", table_name="content_pages")
op.drop_index("idx_content_slug", table_name="content_pages")
op.drop_table("content_pages")
op.drop_table("email_verification_tokens")
op.drop_index("idx_password_reset_user", table_name="password_reset_tokens")
op.drop_table("password_reset_tokens")
op.drop_index("idx_refresh_tokens_family", table_name="refresh_tokens")
op.drop_index("idx_refresh_tokens_user", table_name="refresh_tokens")
op.drop_table("refresh_tokens")
op.drop_table("user_profiles")
op.drop_index("idx_users_status", table_name="users")
op.drop_index("idx_users_email", table_name="users")
op.drop_table("users")
@@ -0,0 +1,33 @@
"""seed admin and demo content
Revision ID: 20260711_0002
Revises: 20260711_0001
Create Date: 2026-07-11 14:05:00
"""
from typing import Sequence, Union
from alembic import op
revision: str = "20260711_0002"
down_revision: Union[str, None] = "20260711_0001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Demo users and CMS pages are seeded on API startup (app.main lifespan → run_seed)
# after all schema migrations, including is_superuser (20260714_0003).
pass
def downgrade() -> None:
from sqlalchemy import delete
from app.core.database import session_scope
from app.modules.content.models import ContentPage
from app.modules.users.models import User
with session_scope() as db:
db.execute(delete(ContentPage).where(ContentPage.slug.in_(["about", "privacy", "terms"])))
db.execute(delete(User).where(User.email == "admin@compton.example"))
@@ -0,0 +1,36 @@
"""add is_superuser to users
Revision ID: 20260714_0003
Revises: 20260711_0002
Create Date: 2026-07-14 12:45:00
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260714_0003"
down_revision: Union[str, None] = "20260711_0002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column("is_superuser", sa.Boolean(), nullable=False, server_default=sa.false()),
)
bind = op.get_bind()
if bind.dialect.name != "sqlite":
op.alter_column("users", "is_superuser", server_default=None)
op.execute(
sa.text(
"UPDATE users SET is_superuser = true "
"WHERE email = 'admin@compton.example' AND role = 'admin'"
)
)
def downgrade() -> None:
op.drop_column("users", "is_superuser")
@@ -0,0 +1,55 @@
"""db security constraints and token indexes
Revision ID: 20260714_0004
Revises: 20260714_0003
Create Date: 2026-07-14 13:30:00
"""
from typing import Sequence, Union
from alembic import op
revision: str = "20260714_0004"
down_revision: Union[str, None] = "20260714_0003"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
if bind.dialect.name != "sqlite":
op.create_check_constraint(
"ck_users_role_allowed",
"users",
"role IN ('user', 'admin')",
)
op.create_check_constraint(
"ck_users_status_allowed",
"users",
"status IN ('pending', 'active', 'blocked')",
)
op.create_check_constraint(
"ck_users_superuser_requires_admin",
"users",
"(is_superuser = false) OR (role = 'admin')",
)
op.create_check_constraint(
"ck_content_pages_status_allowed",
"content_pages",
"status IN ('draft', 'published')",
)
op.create_index("idx_refresh_tokens_expires_at", "refresh_tokens", ["expires_at"], unique=False)
op.create_index("idx_password_reset_expires_at", "password_reset_tokens", ["expires_at"], unique=False)
op.create_index("idx_email_verify_expires_at", "email_verification_tokens", ["expires_at"], unique=False)
def downgrade() -> None:
op.drop_index("idx_email_verify_expires_at", table_name="email_verification_tokens")
op.drop_index("idx_password_reset_expires_at", table_name="password_reset_tokens")
op.drop_index("idx_refresh_tokens_expires_at", table_name="refresh_tokens")
bind = op.get_bind()
if bind.dialect.name != "sqlite":
op.drop_constraint("ck_content_pages_status_allowed", "content_pages", type_="check")
op.drop_constraint("ck_users_superuser_requires_admin", "users", type_="check")
op.drop_constraint("ck_users_status_allowed", "users", type_="check")
op.drop_constraint("ck_users_role_allowed", "users", type_="check")
@@ -0,0 +1,35 @@
"""backfill superuser flag for seeded admin
Revision ID: 20260714_0005
Revises: 20260714_0004
Create Date: 2026-07-14 14:00:00
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260714_0005"
down_revision: Union[str, None] = "20260714_0004"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
sa.text(
"UPDATE users SET is_superuser = true "
"WHERE email = 'admin@compton.example' AND role = 'admin'"
)
)
op.execute(
sa.text(
"UPDATE users SET is_superuser = false "
"WHERE email IN ('ops@compton.example', 'user@compton.example')"
)
)
def downgrade() -> None:
pass
+13
View File
@@ -0,0 +1,13 @@
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
[tool.mypy]
python_version = "3.12"
warn_unused_configs = true
check_untyped_defs = true
ignore_missing_imports = true
+24
View File
@@ -0,0 +1,24 @@
fastapi>=0.115.0
uvicorn>=0.32.0
sqlalchemy>=2.0.36
alembic>=1.13.3
psycopg[binary]>=3.2.0
asyncpg>=0.30.0
redis>=5.1.1
python-jose>=3.3.0
bcrypt>=4.0.0,<5.0.0
pydantic>=2.9.2
email-validator>=2.2.0
pydantic-settings>=2.6.0
httpx>=0.27.2
pytest>=8.3.3
pytest-asyncio>=0.24.0
pytest-cov>=5.0.0
mypy>=1.11.2
ruff>=0.7.1
pip-audit>=2.7.3
bandit>=1.7.9
bleach>=6.1.0
python-multipart>=0.0.12
boto3>=1.35.0
Pillow>=10.4.0
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app.core.install_secrets import ensure_install_secrets
def main() -> None:
status = ensure_install_secrets()
if status.created:
print(f"Created install secrets at {status.path}")
print(
"\nIf docker compose was already started without install.env, reset the Postgres volume "
"before the next start (local dev only — deletes DB data):\n"
" docker compose --profile docker-web down -v\n"
" docker compose --profile docker-web up -d --build"
)
else:
print(f"Install secrets already exist at {status.path}")
if __name__ == "__main__":
main()
+101
View File
@@ -0,0 +1,101 @@
"""Docker entrypoint: wait for DB, reconcile Alembic state, migrate, start API."""
from __future__ import annotations
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, inspect, text
from app.core.install_secrets import ensure_install_secrets
from app.core.config import settings
INITIAL_REVISION = "20260711_0001"
SCHEMA_TABLES = (
"content_pages",
"email_verification_tokens",
"password_reset_tokens",
"refresh_tokens",
"user_profiles",
"users",
)
def wait_for_database(max_attempts: int = 30, delay_seconds: float = 1.0):
engine = create_engine(settings.database_url)
last_error: Exception | None = None
for _ in range(max_attempts):
try:
with engine.connect() as connection:
connection.execute(text("SELECT 1"))
return engine
except Exception as exc:
last_error = exc
time.sleep(delay_seconds)
detail = str(last_error or "unknown error")
hint = ""
if "password authentication failed" in detail or "does not exist" in detail:
hint = (
"\n\nPostgres credentials in install.env do not match the existing database volume "
"(common if docker compose ran before bootstrap_install.py).\n"
"Local dev fix — deletes Docker DB data:\n"
" docker compose --profile docker-web down -v\n"
" docker compose --profile docker-web up -d --build\n"
"See docs/secrets-recovery.md"
)
raise RuntimeError(f"Database is unavailable: {detail}{hint}") from last_error
def current_revision(engine) -> str | None:
inspector = inspect(engine)
if "alembic_version" not in inspector.get_table_names():
return None
with engine.connect() as connection:
return connection.execute(text("SELECT version_num FROM alembic_version")).scalar()
def _reset_schema(engine) -> None:
cascade = " CASCADE" if engine.dialect.name == "postgresql" else ""
with engine.begin() as connection:
for table in SCHEMA_TABLES:
connection.execute(text(f'DROP TABLE IF EXISTS "{table}"{cascade}'))
connection.execute(text(f'DROP TABLE IF EXISTS "alembic_version"{cascade}'))
def run_migrations(engine) -> None:
config = Config("alembic.ini")
tables = set(inspect(engine).get_table_names())
revision = current_revision(engine)
schema_tables = set(SCHEMA_TABLES)
existing_schema = tables & schema_tables
if existing_schema:
if schema_tables.issubset(tables):
if revision is None:
command.stamp(config, INITIAL_REVISION)
else:
_reset_schema(engine)
command.upgrade(config, "head")
def main() -> None:
ensure_install_secrets()
engine = wait_for_database()
run_migrations(engine)
subprocess.run(
[sys.executable, "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"],
check=True,
)
if __name__ == "__main__":
main()
+14
View File
@@ -0,0 +1,14 @@
from pathlib import Path
import json
from app.main import app
def main() -> None:
schema = app.openapi()
output = Path(__file__).resolve().parent.parent / "openapi.json"
output.write_text(json.dumps(schema, indent=2), encoding="utf-8")
if __name__ == "__main__":
main()
+13
View File
@@ -0,0 +1,13 @@
"""Apply Alembic migrations."""
from alembic import command
from alembic.config import Config
def main() -> None:
config = Config("alembic.ini")
command.upgrade(config, "head")
if __name__ == "__main__":
main()
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
API_DIR = Path(__file__).resolve().parents[1]
os.chdir(API_DIR)
sys.path.insert(0, str(API_DIR))
db_file = API_DIR / ".e2e.sqlite"
if db_file.exists():
db_file.unlink()
os.environ["DATABASE_URL"] = f"sqlite+pysqlite:///{db_file.as_posix()}"
os.environ["EMAIL_DELIVERY_MODE"] = "memory"
os.environ["STORAGE_MODE"] = "memory"
os.environ["ENABLE_RATE_LIMIT"] = "false"
os.environ["ENABLE_TEST_ROUTES"] = "true"
web_port = os.environ.get("E2E_WEB_PORT", "5175")
os.environ["CORS_ORIGINS"] = f'["http://127.0.0.1:{web_port}","http://localhost:{web_port}"]'
port = os.environ.get("E2E_API_PORT", "8001")
subprocess.run(
[sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", port],
cwd=API_DIR,
check=True,
)
+50
View File
@@ -0,0 +1,50 @@
import os
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
os.environ.setdefault("EMAIL_DELIVERY_MODE", "memory")
from fastapi.testclient import TestClient
import pytest
from app.core import database as db_module
from app.core.email import memory_mailer
from app.core.storage import memory_store
from app.db.base import Base
from app.db.seed import run_seed
from app.main import app
@pytest.fixture(scope="session", autouse=True)
def setup_database():
Base.metadata.create_all(db_module.engine)
run_seed(include_demo_pages=False)
yield
Base.metadata.drop_all(db_module.engine)
@pytest.fixture(autouse=True)
def disable_rate_limits(monkeypatch):
from app.core.config import settings
monkeypatch.setattr(settings, "enable_rate_limit", False)
monkeypatch.setattr(settings, "email_delivery_mode", "memory")
monkeypatch.setattr(settings, "storage_mode", "memory")
@pytest.fixture(autouse=True)
def clear_email_outbox():
memory_mailer.clear()
yield
memory_mailer.clear()
@pytest.fixture(autouse=True)
def clear_storage():
memory_store.clear()
yield
memory_store.clear()
@pytest.fixture()
def client() -> TestClient:
return TestClient(app)
+38
View File
@@ -0,0 +1,38 @@
from app.core.media_signing import build_signed_media_url, verify_signed_media
from app.core.config import settings
import time
def test_build_and_verify_signed_media_url():
signed = build_signed_media_url("/api/v1/media/files/avatars/user/file.png")
assert signed is not None
assert "expires=" in signed
assert "sig=" in signed
path = "avatars/user/file.png"
query = signed.split("?", 1)[1]
params = dict(part.split("=") for part in query.split("&"))
assert int(params["expires"]) - int(time.time()) <= settings.media_url_ttl_seconds
assert verify_signed_media(path, int(params["expires"]), params["sig"])
def test_build_signed_media_url_none():
assert build_signed_media_url(None) is None
def test_verify_signed_media_rejects_expired_signature():
signed = build_signed_media_url("/api/v1/media/files/avatars/user/file.png")
assert signed is not None
path = "avatars/user/file.png"
query = signed.split("?", 1)[1]
params = dict(part.split("=") for part in query.split("&"))
assert not verify_signed_media(path, int(params["expires"]) - 10_000, params["sig"])
def test_verify_signed_media_rejects_tampered_signature():
signed = build_signed_media_url("/api/v1/media/files/avatars/user/file.png")
assert signed is not None
path = "avatars/user/file.png"
query = signed.split("?", 1)[1]
params = dict(part.split("=") for part in query.split("&"))
assert not verify_signed_media(path, int(params["expires"]), "invalid")
@@ -0,0 +1,6 @@
from app.core.media_signing import build_signed_media_url
def test_build_signed_media_url_passthrough():
external = "https://cdn.example.com/avatar.png"
assert build_signed_media_url(external) == external
@@ -0,0 +1,5 @@
from app.core.password_denylist import is_denied_password
def test_password_denylist_blocks_common_password():
assert is_denied_password("password123")
+5
View File
@@ -0,0 +1,5 @@
from app.core.storage import ensure_bucket
def test_ensure_bucket_noop_in_memory():
ensure_bucket()
+32
View File
@@ -0,0 +1,32 @@
from app.db.seed import run_seed
from app.modules.users.repository import get_user_by_email
def test_seed_ensures_admin_is_superuser():
admin = get_user_by_email("admin@compton.example")
assert admin is not None
admin.is_superuser = False
from app.modules.users import repository
repository.update_user(admin)
run_seed(include_demo_pages=False)
refreshed = get_user_by_email("admin@compton.example")
assert refreshed is not None
assert refreshed.is_superuser is True
def test_seed_ensures_ops_is_not_superuser():
ops = get_user_by_email("ops@compton.example")
assert ops is not None
ops.is_superuser = True
from app.modules.users import repository
repository.update_user(ops)
run_seed(include_demo_pages=False)
refreshed = get_user_by_email("ops@compton.example")
assert refreshed is not None
assert refreshed.is_superuser is False
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from app.core.database import session_scope
from app.db.token_cleanup import cleanup_expired_tokens
from app.modules.auth.models import EmailVerificationToken, PasswordResetToken, RefreshToken
from app.modules.users.repository import get_user_by_email
def test_cleanup_expired_tokens_removes_stale_rows():
user = get_user_by_email("admin@compton.example")
now = datetime.now(UTC)
with session_scope() as db:
db.add(
RefreshToken(
user_id=user.id,
token_hash="f" * 64,
family_id="fam-cleanup-1",
expires_at=now - timedelta(days=2),
revoked_at=now - timedelta(days=2),
)
)
db.add(
PasswordResetToken(
user_id=user.id,
token_hash="e" * 64,
expires_at=now - timedelta(days=1),
used_at=None,
)
)
db.add(
EmailVerificationToken(
user_id=user.id,
token_hash="d" * 64,
expires_at=now - timedelta(days=1),
used_at=None,
)
)
result = cleanup_expired_tokens(retention_days=1)
assert result["refresh_tokens_deleted"] >= 1
assert result["password_reset_tokens_deleted"] >= 1
assert result["email_verification_tokens_deleted"] >= 1
+3
View File
@@ -0,0 +1,3 @@
def test_health_smoke(client):
response = client.get("/api/v1/health")
assert response.status_code == 200
+12
View File
@@ -0,0 +1,12 @@
from __future__ import annotations
from io import BytesIO
from PIL import Image
def make_test_png() -> bytes:
image = Image.new("RGB", (8, 8), color=(70, 129, 109))
buffer = BytesIO()
image.save(buffer, format="PNG")
return buffer.getvalue()
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
from fastapi.testclient import TestClient
from app.core.email import memory_mailer
def clear_sent_emails() -> None:
memory_mailer.clear()
def latest_token(recipient: str, template: str) -> str:
token = memory_mailer.latest_token(recipient, template)
if not token:
raise AssertionError(f"No {template} email sent to {recipient}")
return token
def register_and_verify(client: TestClient, email: str, password: str = "Valid123") -> None:
client.post("/api/v1/auth/register", json={"email": email, "password": password})
token = latest_token(email, "verify_email")
response = client.post("/api/v1/auth/verify-email", json={"token": token})
assert response.status_code == 200
def register_verify_login(client: TestClient, email: str, password: str = "Valid123") -> dict[str, str]:
register_and_verify(client, email, password)
login = client.post("/api/v1/auth/login", json={"email": email, "password": password})
assert login.status_code == 200
return {"Authorization": f"Bearer {login.json()['access_token']}"}
@@ -0,0 +1,26 @@
from __future__ import annotations
def _super_headers(client) -> dict[str, str]:
login = client.post(
"/api/v1/auth/login",
json={"email": "admin@compton.example", "password": "Admin1234"},
)
assert login.status_code == 200
return {"Authorization": f"Bearer {login.json()['access_token']}"}
def test_admin_create_user_rejects_weak_password(client):
headers = _super_headers(client)
response = client.post(
"/api/v1/admin/users",
headers=headers,
json={
"email": "weak-pass@example.com",
"password": "password",
"role": "user",
"is_superuser": False,
"status": "active",
},
)
assert response.status_code == 422
@@ -0,0 +1,73 @@
from app.core.security import hash_password
from app.modules.users import repository
def _admin_headers(client):
login = client.post(
"/api/v1/auth/login",
json={"email": "admin@compton.example", "password": "Admin1234"},
)
token = login.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
def _plain_admin_headers(client):
email = "ops-admin@compton.example"
if not repository.get_user_by_email(email):
repository.create_user(
email=email,
password_hash=hash_password("Admin1234"),
role="admin",
is_superuser=False,
status="active",
)
login = client.post("/api/v1/auth/login", json={"email": email, "password": "Admin1234"})
token = login.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
def test_admin_users_list(client):
response = client.get("/api/v1/admin/users", headers=_admin_headers(client))
assert response.status_code == 200
assert "data" in response.json()
def test_non_superuser_cannot_patch_settings(client):
response = client.patch(
"/api/v1/admin/settings",
json={"values": {"enable_docs": False}},
headers=_plain_admin_headers(client),
)
assert response.status_code == 403
assert response.json()["detail"] == "SUPERUSER_ONLY"
def test_superuser_can_patch_settings(client):
response = client.patch(
"/api/v1/admin/settings",
json={"values": {"enable_docs": False}},
headers=_admin_headers(client),
)
assert response.status_code == 200
payload = response.json()
assert "values" in payload
def test_superuser_can_create_and_delete_user(client):
created = client.post(
"/api/v1/admin/users",
json={
"email": "created-by-admin@compton.example",
"password": "StrongPass123A",
"role": "user",
"is_superuser": False,
"status": "active",
},
headers=_admin_headers(client),
)
assert created.status_code == 200
user_id = created.json()["id"]
deleted = client.delete(f"/api/v1/admin/users/{user_id}", headers=_admin_headers(client))
assert deleted.status_code == 200
assert deleted.json()["status"] == "deleted"
@@ -0,0 +1,35 @@
from __future__ import annotations
def _login(client, email: str, password: str) -> dict[str, str]:
response = client.post("/api/v1/auth/login", json={"email": email, "password": password})
assert response.status_code == 200
return {"Authorization": f"Bearer {response.json()['access_token']}"}
def test_superuser_can_read_install_secrets(client):
headers = _login(client, "admin@compton.example", "Admin1234")
response = client.get("/api/v1/admin/secrets", headers=headers)
assert response.status_code == 200
payload = response.json()
assert "secrets_status" in payload
assert payload["secrets_status"]["postgres_password"] in {"configured", "missing"}
assert "POSTGRES_PASSWORD" not in str(payload)
def test_non_superuser_cannot_read_install_secrets(client):
headers = _login(client, "ops@compton.example", "OpsAdmin1234")
response = client.get("/api/v1/admin/secrets", headers=headers)
assert response.status_code == 403
def test_superuser_can_reveal_db_password(client):
headers = _login(client, "admin@compton.example", "Admin1234")
response = client.post(
"/api/v1/admin/secrets/reveal",
headers=headers,
json={"key": "database_password"},
)
assert response.status_code == 200
assert response.json()["key"] == "database_password"
assert "value" in response.json()
@@ -0,0 +1,33 @@
from unittest.mock import patch
from app.modules.admin.service import patch_user
from app.modules.users import repository
from app.modules.users.repository import create_user, get_user_by_email
from app.core.security import hash_password
def test_admin_cannot_self_demote():
admin = get_user_by_email("admin@compton.example")
try:
patch_user(admin, admin.id, "user", None)
assert False, "Expected self-demotion error"
except ValueError as exc:
assert str(exc) == "SELF_DEMOTION_FORBIDDEN"
def test_admin_can_promote_user():
admin = get_user_by_email("admin@compton.example")
regular = create_user("sample@example.com", hash_password("Valid123"), role="user", status="active")
result = patch_user(admin, regular.id, "admin", None)
assert result["role"] == "admin"
def test_last_admin_protected():
admin = get_user_by_email("admin@compton.example")
target = create_user("target@example.com", hash_password("Valid123"), role="admin", status="active")
with patch.object(repository, "count_admins", return_value=1):
try:
patch_user(admin, target.id, "user", None)
assert False, "Expected last-admin protection"
except ValueError as exc:
assert str(exc) == "LAST_ADMIN_PROTECTED"
@@ -0,0 +1,42 @@
from tests.helpers import latest_token, register_and_verify
def test_health(client):
response = client.get("/api/v1/health")
assert response.status_code == 200
assert response.json()["status"] == "ok"
def test_register_and_verify_and_login(client):
register_response = client.post(
"/api/v1/auth/register",
json={"email": "user@example.com", "password": "Valid123"},
)
assert register_response.status_code == 200
assert "user_id" not in register_response.json()
duplicate = client.post(
"/api/v1/auth/register",
json={"email": "user@example.com", "password": "Valid123"},
)
assert duplicate.status_code == 200
assert "user_id" not in duplicate.json()
verify_response = client.post(
"/api/v1/auth/verify-email",
json={"token": latest_token("user@example.com", "verify_email")},
)
assert verify_response.status_code == 200
login_response = client.post(
"/api/v1/auth/login",
json={"email": "user@example.com", "password": "Valid123"},
)
assert login_response.status_code == 200
assert "access_token" in login_response.json()
def test_forgot_password_anti_enumeration(client):
response = client.post("/api/v1/auth/forgot-password", json={"email": "missing@example.com"})
assert response.status_code == 200
assert "If email is registered" in response.json()["message"]
@@ -0,0 +1,20 @@
from tests.helpers import latest_token
def test_verify_email_rejects_invalid_token(client):
client.post("/api/v1/auth/register", json={"email": "bad@example.com", "password": "Valid123"})
response = client.post("/api/v1/auth/verify-email", json={"token": "invalid-token"})
assert response.status_code == 400
assert response.json()["detail"] == "INVALID_TOKEN"
def test_resend_verification_sends_new_token(client):
client.post("/api/v1/auth/register", json={"email": "resend@example.com", "password": "Valid123"})
first_token = latest_token("resend@example.com", "verify_email")
client.post("/api/v1/auth/resend-verification", json={"email": "resend@example.com"})
second_token = latest_token("resend@example.com", "verify_email")
assert first_token != second_token
verify = client.post("/api/v1/auth/verify-email", json={"token": second_token})
assert verify.status_code == 200
@@ -0,0 +1,19 @@
from tests.helpers import latest_token, register_and_verify
def test_auth_brute_force_lockout(client):
register_and_verify(client, "locked@example.com")
for _ in range(5):
response = client.post(
"/api/v1/auth/login",
json={"email": "locked@example.com", "password": "WrongPass1"},
)
assert response.status_code == 401
locked = client.post(
"/api/v1/auth/login",
json={"email": "locked@example.com", "password": "Valid123"},
)
assert locked.status_code == 429
assert locked.json()["detail"] == "ACCOUNT_TEMPORARILY_LOCKED"
@@ -0,0 +1,7 @@
from app.core.password_policy import validate_password_strength
import pytest
def test_password_policy_rejects_denied_password():
with pytest.raises(ValueError, match="too common"):
validate_password_strength("Password123")
@@ -0,0 +1,12 @@
import pytest
from app.core.password_policy import validate_password_strength
def test_password_policy_accepts_valid_password():
assert validate_password_strength("Valid123") == "Valid123"
def test_password_policy_rejects_weak_password():
with pytest.raises(ValueError, match="uppercase letter"):
validate_password_strength("valid123")
@@ -0,0 +1,22 @@
from uuid import uuid4
def test_login_rate_limit(client, monkeypatch):
from app.core.config import settings
monkeypatch.setattr(settings, "enable_rate_limit", True)
email = f"missing-{uuid4().hex}@example.com"
for _ in range(5):
response = client.post(
"/api/v1/auth/login",
json={"email": email, "password": "Valid123"},
)
assert response.status_code == 401
blocked = client.post(
"/api/v1/auth/login",
json={"email": email, "password": "Valid123"},
)
assert blocked.status_code == 429
assert blocked.json()["detail"] == "RATE_LIMIT_EXCEEDED"
@@ -0,0 +1,47 @@
from __future__ import annotations
from tests.helpers import register_and_verify
def _admin_headers(client) -> dict[str, str]:
from app.core.security import create_access_token
from app.modules.users.repository import get_user_by_email
admin = get_user_by_email("admin@compton.example")
token = create_access_token(admin.id, admin.role, admin.is_superuser)
return {"Authorization": f"Bearer {token}"}
def test_refresh_fails_for_blocked_user(client):
register_and_verify(client, "blocked-refresh@example.com")
login = client.post(
"/api/v1/auth/login",
json={"email": "blocked-refresh@example.com", "password": "Valid123"},
)
assert login.status_code == 200
admin_headers = _admin_headers(client)
me = client.get(
"/api/v1/users/me",
headers={"Authorization": f"Bearer {login.json()['access_token']}"},
)
user_id = me.json()["user"]["id"]
blocked = client.patch(
f"/api/v1/admin/users/{user_id}",
headers=admin_headers,
json={"status": "blocked"},
)
assert blocked.status_code == 200
refresh = client.post("/api/v1/auth/refresh", headers={"Origin": "http://localhost:5173"})
assert refresh.status_code == 401
def test_refresh_requires_origin_header_when_cookie_present(client):
register_and_verify(client, "origin-required@example.com")
login = client.post(
"/api/v1/auth/login",
json={"email": "origin-required@example.com", "password": "Valid123"},
)
assert login.status_code == 200
response = client.post("/api/v1/auth/refresh")
assert response.status_code == 403
assert response.json()["detail"] == "INVALID_ORIGIN"
@@ -0,0 +1,26 @@
from app.core.security import (
create_access_token,
decode_access_token,
generate_refresh_token,
hash_password,
hash_refresh_token,
verify_password,
)
def test_password_hashing_roundtrip():
hashed = hash_password("Strong123")
assert verify_password("Strong123", hashed) is True
def test_access_token_encode_decode():
token = create_access_token("u1", "user", False)
payload = decode_access_token(token)
assert payload["sub"] == "u1"
assert payload["role"] == "user"
assert payload["is_superuser"] is False
def test_refresh_token_hashing():
token = generate_refresh_token()
assert hash_refresh_token(token) == hash_refresh_token(token)
@@ -0,0 +1,45 @@
def _admin_headers(client):
login = client.post(
"/api/v1/auth/login",
json={"email": "admin@compton.example", "password": "Admin1234"},
)
token = login.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
def test_create_and_read_published_page(client):
headers = _admin_headers(client)
created = client.post(
"/api/v1/content/pages",
headers=headers,
json={"slug": "about", "title": "About", "body": "<p>Hello</p>", "status": "published"},
)
assert created.status_code == 200
fetched = client.get("/api/v1/content/pages/about")
assert fetched.status_code == 200
assert fetched.json()["slug"] == "about"
def test_list_all_pages_requires_admin(client):
response = client.get("/api/v1/content/pages/manage/all")
assert response.status_code == 401
def test_list_all_pages_includes_drafts(client):
headers = _admin_headers(client)
created = client.post(
"/api/v1/content/pages",
headers=headers,
json={"slug": "draft-page", "title": "Draft", "body": "<p>Draft</p>", "status": "draft"},
)
assert created.status_code == 200
listed = client.get("/api/v1/content/pages/manage/all", headers=headers)
assert listed.status_code == 200
slugs = [page["slug"] for page in listed.json()["data"]]
assert "draft-page" in slugs
public = client.get("/api/v1/content/pages")
public_slugs = [page["slug"] for page in public.json()["data"]]
assert "draft-page" not in public_slugs
@@ -0,0 +1,9 @@
from __future__ import annotations
from app.modules.content.service import sanitize_html
def test_sanitize_html_strips_javascript_protocol():
raw = '<a href="javascript:alert(1)">x</a><img src="javascript:alert(1)" alt="x" />'
clean = sanitize_html(raw)
assert "javascript:" not in clean

Some files were not shown because too many files have changed in this diff Show More