Initial MVP: local visual novel library with Ren'Py → PGVN converter
- 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>
This commit is contained in:
commit
364b312064
72 files changed
+9707
No files matched your search
@@ -0,0 +1,78 @@
|
||||
from fastapi import APIRouter, HTTPException, Response, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import get_settings
|
||||
from app.deps import DB, SessionUser
|
||||
from app.models import User
|
||||
from app.schemas import ChangeCredentialsIn, LoginIn, MeOut
|
||||
from app.security import (
|
||||
COOKIE_NAME,
|
||||
create_token,
|
||||
hash_password,
|
||||
validate_new_password,
|
||||
validate_username,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
def _set_cookie(response: Response, user: User) -> None:
|
||||
settings = get_settings()
|
||||
response.set_cookie(
|
||||
COOKIE_NAME,
|
||||
create_token(user.id, user.token_version),
|
||||
max_age=settings.session_ttl_hours * 3600,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=settings.cookie_secure,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=MeOut)
|
||||
async def login(body: LoginIn, response: Response, db: DB) -> User:
|
||||
user = await db.scalar(select(User).where(User.username == body.username))
|
||||
if not user or not user.is_active or not verify_password(user.password_hash, body.password):
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Неверный логин или пароль")
|
||||
_set_cookie(response, user)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/logout", status_code=204)
|
||||
async def logout(response: Response) -> None:
|
||||
response.delete_cookie(COOKIE_NAME)
|
||||
|
||||
|
||||
@router.get("/me", response_model=MeOut)
|
||||
async def me(user: SessionUser) -> User:
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/change-credentials", response_model=MeOut)
|
||||
async def change_credentials(
|
||||
body: ChangeCredentialsIn, response: Response, user: SessionUser, db: DB
|
||||
) -> User:
|
||||
if not verify_password(user.password_hash, body.current_password):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Текущий пароль неверен")
|
||||
if body.new_password == body.current_password:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Новый пароль совпадает со старым")
|
||||
if err := validate_new_password(body.new_password):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, err)
|
||||
|
||||
new_username = (body.new_username or "").strip()
|
||||
if user.must_change_username and (not new_username or new_username == user.username):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Необходимо задать новый логин")
|
||||
if new_username and new_username != user.username:
|
||||
if err := validate_username(new_username):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, err)
|
||||
if await db.scalar(select(User.id).where(User.username == new_username)):
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "Логин уже занят")
|
||||
user.username = new_username
|
||||
|
||||
user.password_hash = hash_password(body.new_password)
|
||||
user.must_change_password = False
|
||||
user.must_change_username = False
|
||||
user.token_version += 1
|
||||
await db.commit()
|
||||
_set_cookie(response, user)
|
||||
return user
|
||||
Reference in new issue
Block a user