chore(home): mirror from kalinamall (9883e6a) with papatramp URLs

This commit is contained in:
2026-07-14 20:43:52 +10:00
commit ed4e78f6c3
312 changed files with 42790 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
import hashlib
import hmac
import secrets
from fastapi import Depends, HTTPException, Security
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.database import get_db
from app.models import ApiKey
security_scheme = HTTPBearer(auto_error=False)
def _legacy_hash_api_key(raw_key: str) -> str:
return hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
def hash_api_key(raw_key: str) -> str:
secret = get_settings().jwt_secret.encode("utf-8")
return hmac.new(secret, raw_key.encode("utf-8"), hashlib.sha256).hexdigest()
def verify_api_key_hash(raw_key: str, stored_hash: str) -> bool:
if hmac.compare_digest(hash_api_key(raw_key), stored_hash):
return True
return hmac.compare_digest(_legacy_hash_api_key(raw_key), stored_hash)
def generate_api_key() -> tuple[str, str, str]:
"""Returns (full_key, prefix, hash)."""
raw = f"sac_{secrets.token_urlsafe(32)}"
prefix = raw[:12]
return raw, prefix, hash_api_key(raw)
def verify_api_key(db: Session, raw_key: str) -> bool:
rows = db.scalars(select(ApiKey).where(ApiKey.is_active.is_(True))).all()
for row in rows:
if verify_api_key_hash(raw_key, row.key_hash):
return True
return False
def get_api_key_auth(
credentials: HTTPAuthorizationCredentials | None = Security(security_scheme),
db: Session = Depends(get_db),
) -> str:
if credentials is None or credentials.scheme.lower() != "bearer":
raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
if not verify_api_key(db, credentials.credentials):
raise HTTPException(status_code=401, detail="Invalid API key")
return credentials.credentials
+352
View File
@@ -0,0 +1,352 @@
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.database import get_db
from app.models.user import USER_ROLE_ADMIN, User
bearer_scheme = HTTPBearer(auto_error=False)
@dataclass(frozen=True)
class CurrentUser:
username: str
role: str
device_id: int | None = None
@property
def is_admin(self) -> bool:
return self.role == USER_ROLE_ADMIN
def create_access_token(subject: str, role: str, *, device_id: int | None = None) -> str:
settings = get_settings()
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
payload: dict[str, object] = {"sub": subject, "role": role, "exp": expire}
if device_id is not None:
payload["device_id"] = device_id
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def _assert_mobile_device_active(db: Session, device_id: int, user_id: int) -> None:
from app.models.mobile_device import MobileDevice
device = db.get(MobileDevice, device_id)
if device is None or device.user_id != user_id or device.revoked_at is not None:
raise HTTPException(status_code=401, detail="Mobile device revoked or invalid")
def get_token_device_id(token: str) -> int | None:
settings = get_settings()
try:
payload = jwt.decode(
token,
settings.jwt_secret,
algorithms=[settings.jwt_algorithm],
)
except JWTError:
return None
raw = payload.get("device_id")
if raw is None:
return None
try:
return int(raw)
except (TypeError, ValueError):
return None
def _user_from_db(db: Session, username: str) -> User:
normalized = username.strip()
user = db.scalar(select(User).where(func.lower(User.username) == normalized.lower()))
if user is None or not user.is_active:
raise HTTPException(status_code=401, detail="Invalid token")
return user
def _decode_current_user(token: str, db: Session | None = None) -> CurrentUser:
settings = get_settings()
try:
payload = jwt.decode(
token,
settings.jwt_secret,
algorithms=[settings.jwt_algorithm],
)
username = payload.get("sub")
if not username:
raise HTTPException(status_code=401, detail="Invalid token")
parsed_device_id: int | None = None
raw_device_id = payload.get("device_id")
if raw_device_id is not None:
try:
parsed_device_id = int(raw_device_id)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=401, detail="Invalid token") from exc
if db is not None:
user = _user_from_db(db, str(username))
if parsed_device_id is not None:
_assert_mobile_device_active(db, parsed_device_id, user.id)
return CurrentUser(username=user.username, role=user.role, device_id=parsed_device_id)
role = str(payload.get("role") or USER_ROLE_ADMIN)
return CurrentUser(username=str(username), role=role, device_id=parsed_device_id)
except JWTError as exc:
raise HTTPException(status_code=401, detail="Invalid token") from exc
def verify_access_token(token: str, db: Session | None = None) -> str:
"""Проверка JWT (в т.ч. query-параметр для SSE). Возвращает username."""
return _decode_current_user(token, db=db).username
def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
db: Session = Depends(get_db),
) -> CurrentUser:
if credentials is None or credentials.scheme.lower() != "bearer":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
)
return _decode_current_user(credentials.credentials, db=db)
def require_admin(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
if not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
return user
+37
View File
@@ -0,0 +1,37 @@
"""httpOnly cookie auth for SSE (EventSource cannot send Authorization header)."""
from __future__ import annotations
from fastapi import Response
from app.config import Settings
STREAM_COOKIE_NAME = "sac_stream"
STREAM_COOKIE_PATH = "/api/v1/stream"
def stream_cookie_kwargs(settings: Settings) -> dict[str, object]:
secure = settings.sac_public_url.lower().startswith("https://")
return {
"key": STREAM_COOKIE_NAME,
"httponly": True,
"secure": secure,
"samesite": "strict",
"path": STREAM_COOKIE_PATH,
"max_age": max(60, int(settings.jwt_expire_minutes) * 60),
}
def set_stream_auth_cookie(response: Response, token: str, settings: Settings) -> None:
response.set_cookie(value=token, **stream_cookie_kwargs(settings))
def clear_stream_auth_cookie(response: Response, settings: Settings) -> None:
kwargs = stream_cookie_kwargs(settings)
response.delete_cookie(
key=kwargs["key"],
path=kwargs["path"],
secure=bool(kwargs["secure"]),
httponly=True,
samesite="strict",
)