İlk commit: Stepstead backend (FastAPI)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jts 2026-07-11 11:30:24 +03:00
commit ea049b8ccd
45 changed files with 6751 additions and 0 deletions

267
routers/admin.py Normal file
View file

@ -0,0 +1,267 @@
"""
Admin asset upload (harita görselleri vb.).
Sadece ADMIN_EMAILS listesindeki hesaplar kullanabilir (oyun JWT'si ile).
Dosyalar /var/www/stepstead.com/assets/uploads/ altına yazılır ve
https://stepstead.com/assets/uploads/<ad> ile servis edilir.
"""
import re
import unicodedata
from pathlib import Path
from urllib.parse import quote
from fastapi import APIRouter, Depends, Form, HTTPException, UploadFile
from pydantic import BaseModel, EmailStr, Field
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from deps import LEGACY_ADMIN_EMAILS as ADMIN_EMAILS
from deps import PERMISSIONS, require_perm
from models import Role, User, get_db
from security import hash_password
router = APIRouter(prefix="/admin", tags=["admin"])
UPLOAD_DIR = Path("/var/www/stepstead.com/assets/uploads")
MAX_SIZE = 30 * 1024 * 1024 # 30 MB
ALLOWED_EXT = {".png", ".jpg", ".jpeg", ".webp", ".svg", ".json", ".zip"}
def _clean_name(s: str) -> str:
"""Dosya adı temizliği: Türkçe dahil Unicode harf/rakam korunur (NFC'ye
normalize edilir iOS NFD gönderir), sadece tehlikeli karakterler atılır."""
s = unicodedata.normalize("NFC", s)
s = re.sub(r"[/\\\x00-\x1f]", "", s)
return s.strip().strip(".")[:80]
def _url(name: str) -> str:
return "https://stepstead.com/assets/uploads/" + quote(name)
# Geriye dönük: eski kod yolu; artık rol tabanlı require_perm kullanılıyor.
def _require_admin(user: User) -> None:
if user.email not in ADMIN_EMAILS:
raise HTTPException(403, "Yetkin yok")
class UploadOut(BaseModel):
filename: str
url: str
size: int
@router.post("/upload", response_model=UploadOut)
async def upload_asset(
file: UploadFile,
target_name: str | None = Form(None),
user: User = Depends(require_perm("upload")),
):
orig = Path(file.filename or "dosya").name
ext = Path(orig).suffix.lower()
if ext not in ALLOWED_EXT:
raise HTTPException(400, f"İzinli uzantılar: {', '.join(sorted(ALLOWED_EXT))}")
if target_name:
# Asset listesinden yükleme: "up_<asset adı>.<ext>" — up_ öneki
# "buradan geldi, boyut/uygunluk kontrolü bekliyor" demek.
clean = _clean_name(target_name)
if not clean:
raise HTTPException(400, "Geçersiz hedef ad")
name = f"up_{clean}{ext}"
else:
stem = _clean_name(Path(orig).stem) or "dosya"
name = f"{stem}{ext}"
data = await file.read()
if len(data) > MAX_SIZE:
raise HTTPException(400, "Dosya çok büyük (max 30 MB)")
if not data:
raise HTTPException(400, "Boş dosya")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
(UPLOAD_DIR / name).write_bytes(data)
return UploadOut(filename=name, url=_url(name), size=len(data))
class FileRow(BaseModel):
filename: str
url: str
size: int
@router.get("/uploads", response_model=list[FileRow])
async def list_uploads(user: User = Depends(require_perm("upload"))):
out: list[FileRow] = []
if UPLOAD_DIR.exists():
for p in sorted(UPLOAD_DIR.iterdir()):
if p.is_file():
out.append(FileRow(filename=p.name, url=_url(p.name),
size=p.stat().st_size))
return out
# ---------------------------------------------------------------------------
# Kullanıcı & rol yönetimi — sadece "users" izni (admin)
# ---------------------------------------------------------------------------
class RoleOut(BaseModel):
id: int
name: str
permissions: list[str]
is_system: bool
user_count: int = 0
class RoleIn(BaseModel):
name: str = Field(min_length=2, max_length=40)
permissions: list[str] = []
class AdminUserOut(BaseModel):
id: int
email: str
display_name: str | None
role_id: int | None
role_name: str | None
is_active: bool
created_at: str
class UserCreateIn(BaseModel):
email: EmailStr
password: str = Field(min_length=8, max_length=128)
display_name: str | None = Field(default=None, max_length=64)
role_id: int | None = None
class UserUpdateIn(BaseModel):
role_id: int | None = None
is_active: bool | None = None
def _valid_perms(perms: list[str]) -> list[str]:
bad = [p for p in perms if p not in PERMISSIONS and p != "*"]
if bad:
raise HTTPException(400, f"Bilinmeyen izin: {', '.join(bad)}")
return sorted(set(perms))
@router.get("/permissions")
async def list_permissions(user: User = Depends(require_perm("users"))):
return PERMISSIONS
@router.get("/roles", response_model=list[RoleOut])
async def list_roles(user: User = Depends(require_perm("users")),
db: AsyncSession = Depends(get_db)):
counts = dict((await db.execute(
select(User.role_id, func.count()).where(User.role_id.isnot(None)).group_by(User.role_id)
)).all())
roles = (await db.execute(select(Role).order_by(Role.id))).scalars().all()
return [RoleOut(id=r.id, name=r.name, permissions=list(r.permissions or []),
is_system=r.is_system, user_count=counts.get(r.id, 0)) for r in roles]
@router.post("/roles", response_model=RoleOut)
async def create_role(data: RoleIn, user: User = Depends(require_perm("users")),
db: AsyncSession = Depends(get_db)):
exists = (await db.execute(select(Role).where(Role.name == data.name))).scalar_one_or_none()
if exists:
raise HTTPException(409, "Bu isimde rol zaten var")
role = Role(name=data.name, permissions=_valid_perms(data.permissions))
db.add(role)
await db.commit()
await db.refresh(role)
return RoleOut(id=role.id, name=role.name, permissions=list(role.permissions),
is_system=role.is_system)
@router.post("/roles/{role_id}", response_model=RoleOut)
async def update_role(role_id: int, data: RoleIn,
user: User = Depends(require_perm("users")),
db: AsyncSession = Depends(get_db)):
role = (await db.execute(select(Role).where(Role.id == role_id))).scalar_one_or_none()
if not role:
raise HTTPException(404, "Rol yok")
if role.is_system:
raise HTTPException(400, "Sistem rolü (admin) değiştirilemez")
role.name = data.name
role.permissions = _valid_perms(data.permissions)
await db.commit()
return RoleOut(id=role.id, name=role.name, permissions=list(role.permissions),
is_system=role.is_system)
@router.post("/roles/{role_id}/delete")
async def delete_role(role_id: int, user: User = Depends(require_perm("users")),
db: AsyncSession = Depends(get_db)):
role = (await db.execute(select(Role).where(Role.id == role_id))).scalar_one_or_none()
if not role:
raise HTTPException(404, "Rol yok")
if role.is_system:
raise HTTPException(400, "Sistem rolü silinemez")
in_use = (await db.execute(select(func.count()).where(User.role_id == role_id))).scalar()
if in_use:
raise HTTPException(400, f"Rol {in_use} kullanıcıda atanmış — önce onları değiştir")
await db.delete(role)
await db.commit()
return {"deleted": role_id}
def _user_out(u: User, role_name: str | None) -> AdminUserOut:
return AdminUserOut(id=u.id, email=u.email, display_name=u.display_name,
role_id=u.role_id, role_name=role_name,
is_active=u.is_active, created_at=u.created_at.isoformat())
@router.get("/users", response_model=list[AdminUserOut])
async def list_users(user: User = Depends(require_perm("users")),
db: AsyncSession = Depends(get_db)):
rows = (await db.execute(
select(User, Role.name).outerjoin(Role, Role.id == User.role_id).order_by(User.id)
)).all()
return [_user_out(u, rn) for u, rn in rows]
@router.post("/users", response_model=AdminUserOut)
async def create_user(data: UserCreateIn, user: User = Depends(require_perm("users")),
db: AsyncSession = Depends(get_db)):
email = str(data.email).lower()
exists = (await db.execute(select(User).where(User.email == email))).scalar_one_or_none()
if exists:
raise HTTPException(409, "Bu e-posta zaten kayıtlı")
role_name = None
if data.role_id is not None:
role = (await db.execute(select(Role).where(Role.id == data.role_id))).scalar_one_or_none()
if not role:
raise HTTPException(400, "Rol yok")
role_name = role.name
u = User(email=email, password_hash=hash_password(data.password),
display_name=data.display_name, email_verified=True, role_id=data.role_id)
db.add(u)
await db.commit()
await db.refresh(u)
return _user_out(u, role_name)
@router.post("/users/{user_id}", response_model=AdminUserOut)
async def update_user(user_id: int, data: UserUpdateIn,
user: User = Depends(require_perm("users")),
db: AsyncSession = Depends(get_db)):
u = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
if not u:
raise HTTPException(404, "Kullanıcı yok")
if "role_id" in data.model_fields_set: # role_id:null gönderilirse rol kaldırılır
if data.role_id is not None:
role = (await db.execute(select(Role).where(Role.id == data.role_id))).scalar_one_or_none()
if not role:
raise HTTPException(400, "Rol yok")
u.role_id = data.role_id
if data.is_active is not None:
if u.id == user.id and not data.is_active:
raise HTTPException(400, "Kendini pasifleştiremezsin")
u.is_active = data.is_active
await db.commit()
role_name = None
if u.role_id:
role_name = (await db.execute(select(Role.name).where(Role.id == u.role_id))).scalar_one_or_none()
return _user_out(u, role_name)