gabriel / musehub public
admin.py python
163 lines 5.3 KB
Raw
sha256:8e05daa29ba6702b4a2380a16d690ba31cc099d69c5c859bc6e6f16a0e945f99 Merge 'fix/deploy-memory-limits-and-log-group' into 'dev' —… Human 9 days ago
1 """Admin-only API endpoints.
2
3 All routes require is_admin=True. Non-admin callers receive 403.
4 """
5 from __future__ import annotations
6
7 from typing import Sequence
8
9 from fastapi import APIRouter, Depends, HTTPException, status
10 from pydantic import BaseModel
11 from sqlalchemy import select, update
12 from sqlalchemy.dialects.postgresql import insert as pg_insert
13 from sqlalchemy.ext.asyncio import AsyncSession
14
15 from musehub.auth.request_signing import MSignContext, require_signed_request
16 from musehub.db.musehub_abuse_models import MusehubBlockedHash
17 from musehub.db.musehub_auth_models import MusehubAuthKey
18 from musehub.db.musehub_identity_models import MusehubIdentity
19 from musehub.db.musehub_repo_models import MusehubRepo
20 from musehub.db.database import get_db
21
22 router = APIRouter(prefix="/api/admin", tags=["admin"])
23
24
25 class TakedownRequest(BaseModel):
26 object_ids: list[str]
27 reason: str
28 repo_ids: list[str] = []
29
30
31 class TakedownResponse(BaseModel):
32 blocked_count: int
33 repos_held: int
34 quarantined_count: int
35
36
37 @router.post("/takedown", response_model=TakedownResponse)
38 async def admin_takedown(
39 body: TakedownRequest,
40 session: AsyncSession = Depends(get_db),
41 claims: MSignContext = Depends(require_signed_request),
42 ) -> TakedownResponse:
43 """Add object_ids to the content blocklist and quarantine any already-stored blobs.
44
45 - Inserts rows into musehub_blocked_hashes (ON CONFLICT DO NOTHING — idempotent).
46 - For each object_id already in MinIO, moves the blob to the quarantine prefix.
47 - Marks any supplied repo_ids with dmca_hold=True.
48
49 Requires is_admin=True.
50 """
51 if not claims.is_admin:
52 raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="admin only")
53
54 if not body.object_ids:
55 return TakedownResponse(blocked_count=0, repos_held=0, quarantined_count=0)
56
57 # 1. Add to blocklist (idempotent)
58 rows = [
59 {"object_id": oid, "reason": body.reason, "added_by": claims.handle}
60 for oid in body.object_ids
61 ]
62 await session.execute(
63 pg_insert(MusehubBlockedHash).values(rows)
64 .on_conflict_do_nothing(index_elements=["object_id"])
65 )
66
67 # 2. Quarantine any blobs already in MinIO
68 from musehub.storage.backends import get_backend
69 backend = get_backend()
70 quarantined_count = 0
71 for oid in body.object_ids:
72 exists = await backend.exists(oid)
73 if exists:
74 await backend.quarantine_object(oid)
75 quarantined_count += 1
76
77 # 3. Mark repos with dmca_hold
78 repos_held = 0
79 if body.repo_ids:
80 result = await session.execute(
81 update(MusehubRepo)
82 .where(MusehubRepo.repo_id.in_(body.repo_ids))
83 .values(dmca_hold=True)
84 )
85 repos_held = result.rowcount
86
87 await session.commit()
88 return TakedownResponse(
89 blocked_count=len(body.object_ids),
90 repos_held=repos_held,
91 quarantined_count=quarantined_count,
92 )
93
94
95 class RepairIdentityRepoRequest(BaseModel):
96 handle: str
97
98
99 class RepairIdentityRepoResponse(BaseModel):
100 created: bool
101
102
103 @router.post("/repair-identity-repo", response_model=RepairIdentityRepoResponse)
104 async def admin_repair_identity_repo(
105 body: RepairIdentityRepoRequest,
106 session: AsyncSession = Depends(get_db),
107 claims: MSignContext = Depends(require_signed_request),
108 ) -> RepairIdentityRepoResponse:
109 """Create the canonical {handle}/identity repo for an identity that doesn't have one.
110
111 Idempotent: if the repo already exists, returns created=false without error.
112 This repairs identities whose repo was lost during a DB reset or migration.
113
114 Requires is_admin=True.
115 """
116 if not claims.is_admin:
117 raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="admin only")
118
119 # Look up the identity.
120 identity_result = await session.execute(
121 select(MusehubIdentity).where(MusehubIdentity.handle == body.handle)
122 )
123 identity = identity_result.scalar_one_or_none()
124 if identity is None:
125 raise HTTPException(
126 status_code=status.HTTP_404_NOT_FOUND,
127 detail=f"identity '{body.handle}' not found",
128 )
129
130 # Check whether the repo already exists.
131 repo_result = await session.execute(
132 select(MusehubRepo).where(
133 MusehubRepo.owner == body.handle,
134 MusehubRepo.slug == "identity",
135 )
136 )
137 if repo_result.scalar_one_or_none() is not None:
138 return RepairIdentityRepoResponse(created=False)
139
140 # Fetch the most recently created key for this identity.
141 key_result = await session.execute(
142 select(MusehubAuthKey)
143 .where(MusehubAuthKey.identity_id == identity.identity_id)
144 .order_by(MusehubAuthKey.created_at.desc())
145 .limit(1)
146 )
147 key_row = key_result.scalar_one_or_none()
148 if key_row is None:
149 raise HTTPException(
150 status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
151 detail=f"identity '{body.handle}' has no registered keys; cannot create identity repo",
152 )
153
154 from musehub.services.musehub_auth import _create_identity_repo
155 await _create_identity_repo(
156 session,
157 identity_id=identity.identity_id,
158 handle=body.handle,
159 public_key_b64=key_row.public_key_b64,
160 identity_type=identity.identity_type,
161 )
162 await session.commit()
163 return RepairIdentityRepoResponse(created=True)
File History 2 commits
sha256:8e05daa29ba6702b4a2380a16d690ba31cc099d69c5c859bc6e6f16a0e945f99 Merge 'fix/deploy-memory-limits-and-log-group' into 'dev' —… Human 9 days ago
sha256:3fadb0439bba9451b89229676971c0d4a40900dec7810e9d5f8791b8d950d505 fix: install.sh version from latest published tarball, not … Sonnet 4.6 minor 100 days ago