- FastAPI backend: admin bootstrap with forced credential change, admin-created users with one-time generated passwords, roles as permission sets - Games and versions with content-addressed blob storage; forks copy only the manifest, so mods share assets with the original - Worker: archive/RPA extraction, unrpyc for .rpyc, own .rpy parser, Python subset translated via ast, media converted to WebP/Opus/WebM - React panel (library search, game page, upload, users, roles) and a framework-free web player with saves, history, skip/auto, transitions - docker compose: postgres, backend, worker, nginx on a single HTTP port Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
89 lines
3.3 KiB
Python
89 lines
3.3 KiB
Python
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from sqlalchemy import select
|
|
|
|
from app.deps import DB, require
|
|
from app.models import Role, User
|
|
from app.schemas import UserCreateIn, UserOut, UserUpdateIn, UserWithPasswordOut
|
|
from app.security import generate_password, hash_password, validate_username
|
|
|
|
router = APIRouter(prefix="/api/users", tags=["users"])
|
|
Admin = Annotated[User, Depends(require("users.manage"))]
|
|
|
|
|
|
async def _get_user(db: DB, user_id: int) -> User:
|
|
user = await db.get(User, user_id)
|
|
if not user:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Пользователь не найден")
|
|
return user
|
|
|
|
|
|
async def _get_role(db: DB, role_id: int) -> Role:
|
|
role = await db.get(Role, role_id)
|
|
if not role:
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Роль не найдена")
|
|
return role
|
|
|
|
|
|
@router.get("", response_model=list[UserOut])
|
|
async def list_users(_: Admin, db: DB) -> list[User]:
|
|
return list((await db.scalars(select(User).order_by(User.username))).all())
|
|
|
|
|
|
@router.post("", response_model=UserWithPasswordOut, status_code=201)
|
|
async def create_user(body: UserCreateIn, _: Admin, db: DB) -> dict:
|
|
username = body.username.strip()
|
|
if err := validate_username(username):
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, err)
|
|
if await db.scalar(select(User.id).where(User.username == username)):
|
|
raise HTTPException(status.HTTP_409_CONFLICT, "Логин уже занят")
|
|
role = await _get_role(db, body.role_id)
|
|
password = generate_password()
|
|
user = User(
|
|
username=username,
|
|
password_hash=hash_password(password),
|
|
role_id=role.id,
|
|
must_change_password=True,
|
|
)
|
|
db.add(user)
|
|
await db.commit()
|
|
await db.refresh(user)
|
|
return {"user": user, "password": password}
|
|
|
|
|
|
@router.patch("/{user_id}", response_model=UserOut)
|
|
async def update_user(user_id: int, body: UserUpdateIn, admin: Admin, db: DB) -> User:
|
|
user = await _get_user(db, user_id)
|
|
if user.id == admin.id and (body.is_active is False or body.role_id not in (None, user.role_id)):
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Нельзя отключить или понизить самого себя")
|
|
if body.role_id is not None:
|
|
user.role_id = (await _get_role(db, body.role_id)).id
|
|
if body.is_active is not None and body.is_active != user.is_active:
|
|
user.is_active = body.is_active
|
|
user.token_version += 1
|
|
await db.commit()
|
|
await db.refresh(user)
|
|
return user
|
|
|
|
|
|
@router.post("/{user_id}/reset-password", response_model=UserWithPasswordOut)
|
|
async def reset_password(user_id: int, _: Admin, db: DB) -> dict:
|
|
user = await _get_user(db, user_id)
|
|
password = generate_password()
|
|
user.password_hash = hash_password(password)
|
|
user.must_change_password = True
|
|
user.token_version += 1
|
|
await db.commit()
|
|
await db.refresh(user)
|
|
return {"user": user, "password": password}
|
|
|
|
|
|
@router.delete("/{user_id}", status_code=204)
|
|
async def delete_user(user_id: int, admin: Admin, db: DB) -> None:
|
|
user = await _get_user(db, user_id)
|
|
if user.id == admin.id:
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Нельзя удалить самого себя")
|
|
await db.delete(user)
|
|
await db.commit()
|