chore: stage changes before history rewrite

This commit is contained in:
2026-07-07 11:28:18 +08:00
parent a619b919be
commit 137304090a
13 changed files with 670 additions and 83 deletions

View File

@@ -7,26 +7,63 @@ from pydantic import BaseModel
from sqlalchemy.orm import Session as DbSession
from app.database import get_db
from app.models import ApiKey, Profile, Postcard, User
from app.models import (
ApiKey, Profile, Postcard, User,
PERM_PROFILES_READ, PERM_PROFILES_WRITE,
PERM_POSTCARDS_READ, PERM_POSTCARDS_WRITE,
PERM_IMAGES_UPLOAD,
)
from app.config import UPLOAD_DIR
router = APIRouter(prefix="/api/v1", tags=["api"])
# ─── Auth Dependency ──────────────────────────────────────────────
# ─── Auth Dependencies ────────────────────────────────────────────
def get_api_user(
def _parse_permissions(raw: str) -> list[str]:
"""Parse JSON permissions string from DB, return list of scope strings."""
import json
try:
val = json.loads(raw)
return val if isinstance(val, list) else []
except (json.JSONDecodeError, TypeError):
return []
def get_api_key(
x_api_key: str = Header(..., alias="X-API-Key"),
db: DbSession = Depends(get_db),
) -> User:
"""Validate X-API-Key header and return the associated user."""
api_key = db.query(ApiKey).filter(ApiKey.key == x_api_key).first()
if not api_key or not api_key.is_active:
) -> ApiKey:
"""Validate X-API-Key header and return the ApiKey object (with permissions)."""
ak = db.query(ApiKey).filter(ApiKey.key == x_api_key).first()
if not ak or not ak.is_active:
raise HTTPException(status_code=401, detail="Invalid or inactive API key")
# Update last_used_at
api_key.last_used_at = datetime.now(timezone.utc)
ak.last_used_at = datetime.now(timezone.utc)
db.commit()
return api_key.user
return ak
def get_api_user(ak: ApiKey = Depends(get_api_key)) -> User:
"""Return the User associated with a valid API key (no permission check)."""
return ak.user
def require_scopes(*scopes: str):
"""Return a dependency that checks the API key has ALL given scopes.
Keys with empty permissions list (legacy) are treated as full-access.
"""
def _dep(ak: ApiKey = Depends(get_api_key)) -> ApiKey:
perms = _parse_permissions(ak.permissions)
if perms:
missing = [s for s in scopes if s not in perms]
if missing:
raise HTTPException(
status_code=403,
detail=f"Missing required permissions: {', '.join(missing)}",
)
return ak
return _dep
def get_api_user_optional(
@@ -36,7 +73,8 @@ def get_api_user_optional(
"""Like get_api_user but returns None if no key provided (for public endpoints)."""
if not x_api_key:
return None
return get_api_user(x_api_key, db)
ak = get_api_key(x_api_key, db)
return ak.user
# ─── Pydantic Schemas ────────────────────────────────────────────
@@ -102,12 +140,14 @@ class PostcardOut(BaseModel):
class ApiKeyCreate(BaseModel):
name: str = ""
permissions: list[str] = []
class ApiKeyOut(BaseModel):
id: int
key: str
name: str
is_active: bool
permissions: list[str]
created_at: datetime
last_used_at: datetime | None
@@ -150,14 +190,22 @@ def _postcard_or_404(user: User, profile_id: int, postcard_id: int, db: DbSessio
# Profiles
# ═══════════════════════════════════════════════════════════════════
@router.get("/profiles", response_model=list[ProfileOut])
def list_profiles(user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
return db.query(Profile).filter(Profile.user_id == user.id).order_by(Profile.id).all()
def list_profiles(
db: DbSession = Depends(get_db),
ak: ApiKey = Depends(require_scopes(PERM_PROFILES_READ)),
):
return db.query(Profile).filter(Profile.user_id == ak.user_id).order_by(Profile.id).all()
@router.post("/profiles", response_model=ProfileOut, status_code=201)
def create_profile(body: ProfileCreate, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
p = Profile(nickname=body.nickname.strip(), user_id=user.id)
def create_profile(
body: ProfileCreate,
db: DbSession = Depends(get_db),
ak: ApiKey = Depends(require_scopes(PERM_PROFILES_WRITE)),
):
p = Profile(nickname=body.nickname.strip(), user_id=ak.user_id)
db.add(p)
db.commit()
db.refresh(p)
@@ -165,13 +213,22 @@ def create_profile(body: ProfileCreate, user: User = Depends(get_api_user), db:
@router.get("/profiles/{profile_id}", response_model=ProfileOut)
def get_profile(profile_id: int, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
return _profile_or_404(user, profile_id, db)
def get_profile(
profile_id: int,
db: DbSession = Depends(get_db),
ak: ApiKey = Depends(require_scopes(PERM_PROFILES_READ)),
):
return _profile_or_404(ak.user, profile_id, db)
@router.put("/profiles/{profile_id}", response_model=ProfileOut)
def update_profile(profile_id: int, body: ProfileUpdate, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
p = _profile_or_404(user, profile_id, db)
def update_profile(
profile_id: int,
body: ProfileUpdate,
db: DbSession = Depends(get_db),
ak: ApiKey = Depends(require_scopes(PERM_PROFILES_WRITE)),
):
p = _profile_or_404(ak.user, profile_id, db)
if body.nickname is not None:
p.nickname = body.nickname.strip()
db.commit()
@@ -180,8 +237,12 @@ def update_profile(profile_id: int, body: ProfileUpdate, user: User = Depends(ge
@router.delete("/profiles/{profile_id}")
def delete_profile(profile_id: int, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
p = _profile_or_404(user, profile_id, db)
def delete_profile(
profile_id: int,
db: DbSession = Depends(get_db),
ak: ApiKey = Depends(require_scopes(PERM_PROFILES_WRITE)),
):
p = _profile_or_404(ak.user, profile_id, db)
db.delete(p)
db.commit()
return {"ok": True}
@@ -195,10 +256,10 @@ def delete_profile(profile_id: int, user: User = Depends(get_api_user), db: DbSe
def list_postcards(
profile_id: int,
status: str | None = Query(None),
user: User = Depends(get_api_user),
db: DbSession = Depends(get_db),
ak: ApiKey = Depends(require_scopes(PERM_POSTCARDS_READ)),
):
_profile_or_404(user, profile_id, db)
_profile_or_404(ak.user, profile_id, db)
q = db.query(Postcard).filter(Postcard.profile_id == profile_id)
if status:
q = q.filter(Postcard.status == status)
@@ -209,10 +270,10 @@ def list_postcards(
def create_postcard(
profile_id: int,
body: PostcardCreate,
user: User = Depends(get_api_user),
db: DbSession = Depends(get_db),
ak: ApiKey = Depends(require_scopes(PERM_POSTCARDS_WRITE)),
):
_profile_or_404(user, profile_id, db)
_profile_or_404(ak.user, profile_id, db)
cn = body.card_number.strip()
if db.query(Postcard).filter(Postcard.card_number == cn).first():
raise HTTPException(status_code=409, detail="Card number already exists")
@@ -238,10 +299,10 @@ def create_postcard(
def get_postcard(
profile_id: int,
postcard_id: int,
user: User = Depends(get_api_user),
db: DbSession = Depends(get_db),
ak: ApiKey = Depends(require_scopes(PERM_POSTCARDS_READ)),
):
return _postcard_or_404(user, profile_id, postcard_id, db)
return _postcard_or_404(ak.user, profile_id, postcard_id, db)
@router.put("/profiles/{profile_id}/postcards/{postcard_id}", response_model=PostcardOut)
@@ -249,10 +310,10 @@ def update_postcard(
profile_id: int,
postcard_id: int,
body: PostcardUpdate,
user: User = Depends(get_api_user),
db: DbSession = Depends(get_db),
ak: ApiKey = Depends(require_scopes(PERM_POSTCARDS_WRITE)),
):
pc = _postcard_or_404(user, profile_id, postcard_id, db)
pc = _postcard_or_404(ak.user, profile_id, postcard_id, db)
for field in ("status", "recipient_name", "sender_name", "notes"):
val = getattr(body, field)
if val is not None:
@@ -278,10 +339,10 @@ def update_postcard(
def delete_postcard(
profile_id: int,
postcard_id: int,
user: User = Depends(get_api_user),
db: DbSession = Depends(get_db),
ak: ApiKey = Depends(require_scopes(PERM_POSTCARDS_WRITE)),
):
pc = _postcard_or_404(user, profile_id, postcard_id, db)
pc = _postcard_or_404(ak.user, profile_id, postcard_id, db)
db.delete(pc)
db.commit()
return {"ok": True}
@@ -297,10 +358,10 @@ async def upload_image(
postcard_id: int,
side: str = Query("front", pattern="^(front|back)$"),
file: UploadFile = File(...),
user: User = Depends(get_api_user),
db: DbSession = Depends(get_db),
ak: ApiKey = Depends(require_scopes(PERM_IMAGES_UPLOAD)),
):
pc = _postcard_or_404(user, profile_id, postcard_id, db)
pc = _postcard_or_404(ak.user, profile_id, postcard_id, db)
ext = Path(file.filename).suffix if file.filename else ".jpg"
save_name = f"pc_{pc.id}_{side}{ext}"
save_path = UPLOAD_DIR / save_name
@@ -320,26 +381,65 @@ async def upload_image(
# ═══════════════════════════════════════════════════════════════════
@router.get("/keys", response_model=list[ApiKeyOut])
def list_api_keys(user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
def list_api_keys(
db: DbSession = Depends(get_db),
user: User = Depends(get_api_user),
):
return db.query(ApiKey).filter(ApiKey.user_id == user.id).order_by(ApiKey.created_at.desc()).all()
@router.post("/keys", response_model=ApiKeyOut, status_code=201)
def create_api_key(body: ApiKeyCreate, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
def create_api_key(
body: ApiKeyCreate,
db: DbSession = Depends(get_db),
user: User = Depends(get_api_user),
):
key = f"mv_{token_hex(24)}"
ak = ApiKey(key=key, name=body.name.strip(), user_id=user.id)
db.add(ak)
import json
perms = json.dumps(body.permissions) if body.permissions else "[]"
new_ak = ApiKey(key=key, name=body.name.strip(), user_id=user.id, permissions=perms)
db.add(new_ak)
db.commit()
db.refresh(new_ak)
return new_ak
class ApiKeyUpdate(BaseModel):
name: str | None = None
permissions: list[str] | None = None
@router.put("/keys/{key_id}", response_model=ApiKeyOut)
def update_api_key(
key_id: int,
body: ApiKeyUpdate,
db: DbSession = Depends(get_db),
user: User = Depends(get_api_user),
):
import json
ak = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user.id).first()
if not ak:
raise HTTPException(status_code=404, detail="API key not found")
if body.name is not None:
ak.name = body.name.strip()
if body.permissions is not None:
valid = [p for p in body.permissions if p in ALL_PERMISSIONS]
ak.permissions = json.dumps(valid) if valid else "[]"
db.commit()
db.refresh(ak)
return ak
@router.delete("/keys/{key_id}")
def delete_api_key(key_id: int, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
ak = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user.id).first()
if not ak:
def delete_api_key(
key_id: int,
db: DbSession = Depends(get_db),
user: User = Depends(get_api_user),
):
target = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user.id).first()
if not target:
raise HTTPException(status_code=404, detail="API key not found")
db.delete(ak)
db.delete(target)
db.commit()
return {"ok": True}