gabriel / musehub public
users.py python
625 lines 21.9 KB
Raw
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago
1 """MuseHub user-profile route handlers (JSON API).
2
3 Endpoint summary:
4 GET /musehub/users/{username} — fetch full profile (public, no auth required)
5 GET /musehub/users/{username}/forks — list repos forked by this user (public)
6 POST /musehub/users — create a profile for the authenticated user
7 PUT /musehub/users/{username} — update bio/avatar/pinned repos (owner only)
8 GET /musehub/users/{username}/followers-list — list followers as user cards (public)
9 GET /musehub/users/{username}/following-list — list following as user cards (public)
10
11 Content negotiation: all endpoints return JSON. The browser UI fetches from
12 these endpoints using the client-side MSign key pair.
13
14 The GET endpoints are intentionally unauthenticated so that profile pages are
15 publicly discoverable without login — matching the behaviour of GitHub profiles.
16 """
17 from __future__ import annotations
18
19 import logging
20 from datetime import datetime, timezone
21
22 from fastapi import APIRouter, Depends, HTTPException, Query, status
23 from sqlalchemy import delete, or_, select
24 from sqlalchemy import update as sa_update
25 from sqlalchemy.ext.asyncio import AsyncSession
26
27 from pydantic import Field
28
29 from sqlalchemy import func
30
31 from musehub.auth.dependencies import TokenClaims, optional_token, require_valid_token
32 from musehub.db import get_db
33 from musehub.db.musehub_models import MusehubIdentity, MusehubCommit, MusehubRepo
34 from musehub.db.musehub_auth_models import MusehubAuthKey
35 from musehub.muse_contracts.json_types import JSONObject
36 from musehub.models.base import CamelModel
37 from musehub.models.musehub import AgentCardEntry, AgentFleetResponse, ProfileResponse, ProfileUpdateRequest, UserForksResponse, UserStarredResponse, UserWatchedResponse
38 from musehub.services import musehub_profile as profile_svc
39 from musehub.services import musehub_repository as repo_svc
40
41 logger = logging.getLogger(__name__)
42
43 router = APIRouter()
44
45
46 # ---------------------------------------------------------------------------
47 # Request models
48 # ---------------------------------------------------------------------------
49
50
51 class CreateProfileBody(CamelModel):
52 """Body for POST /api/musehub/users — create a public profile for the authenticated user."""
53
54 username: str = Field(
55 ...,
56 min_length=1,
57 max_length=64,
58 pattern=r"^[a-z0-9_-]+$",
59 description="URL-friendly username (lowercase alphanumeric, hyphens, underscores)",
60 )
61 bio: str | None = Field(None, max_length=500, description="Short bio (Markdown supported)")
62 avatar_url: str | None = Field(None, max_length=2048, description="Avatar image URL")
63
64
65 class UserCardResponse(CamelModel):
66 """Compact user card returned by followers-list and following-list endpoints.
67
68 Designed for rendering avatar circles, linked usernames, and bio previews
69 in the Followers / Following tabs on the profile page.
70 """
71
72 username: str
73 bio: str | None = None
74 avatar_url: str | None = None
75
76
77 @router.get(
78 "/users/{username}",
79 response_model=ProfileResponse,
80 operation_id="getUserProfile",
81 summary="Get a MuseHub user profile (public)",
82 )
83 async def get_user_profile(
84 username: str,
85 db: AsyncSession = Depends(get_db),
86 ) -> ProfileResponse:
87 """Return the full profile for a user: bio, avatar, pinned repos, public repos,
88 contribution graph, and session credits.
89
90 No auth required — profiles are publicly accessible. Returns 404 when the
91 username does not match any registered profile.
92 """
93 profile = await profile_svc.get_full_profile(db, username)
94 if profile is None:
95 raise HTTPException(
96 status_code=status.HTTP_404_NOT_FOUND,
97 detail=f"No profile found for username '{username}'",
98 )
99 logger.info("✅ Served profile for username=%s", username)
100 return profile
101
102
103 @router.get(
104 "/users/{username}/forks",
105 response_model=UserForksResponse,
106 operation_id="getUserForks",
107 summary="List repos forked by a user (public)",
108 )
109 async def get_user_forks(
110 username: str,
111 db: AsyncSession = Depends(get_db),
112 ) -> UserForksResponse:
113 """Return all repos that ``username`` has forked, with source attribution.
114
115 Joins ``musehub_forks`` (where ``forked_by`` matches the given username)
116 with ``musehub_repos`` twice — once for the fork repo metadata and once
117 for the source repo's owner/slug so the profile page can render
118 "forked from {source_owner}/{source_slug}" under each card.
119
120 No auth required — the forked tab is publicly visible on profile pages.
121 Returns 404 when the username does not exist.
122 """
123 profile = await profile_svc.get_profile_by_username(db, username)
124 if profile is None:
125 raise HTTPException(
126 status_code=status.HTTP_404_NOT_FOUND,
127 detail=f"No profile found for username '{username}'",
128 )
129
130 result = await repo_svc.get_user_forks(db, username)
131 logger.info("✅ Served %d forks for username=%s", result.total, username)
132 return result
133
134
135 @router.get(
136 "/users/{username}/starred",
137 response_model=UserStarredResponse,
138 operation_id="getUserStarred",
139 summary="List repos starred by a user (public)",
140 )
141 async def get_user_starred(
142 username: str,
143 db: AsyncSession = Depends(get_db),
144 ) -> UserStarredResponse:
145 """Return all repos that ``username`` has starred, newest first.
146
147 Joins ``musehub_stars`` (where user_id matches the profile's user_id)
148 with ``musehub_repos`` to surface full repo metadata for each starred repo.
149
150 No auth required — starred repo lists are publicly accessible.
151 Returns 404 when the username does not exist.
152 """
153 profile = await profile_svc.get_profile_by_username(db, username)
154 if profile is None:
155 raise HTTPException(
156 status_code=status.HTTP_404_NOT_FOUND,
157 detail=f"No profile found for username '{username}'",
158 )
159
160 result = await repo_svc.get_user_starred(db, username)
161 logger.info("✅ Served %d starred repos for username=%s", result.total, username)
162 return result
163
164
165 @router.get(
166 "/users/{username}/watched",
167 response_model=UserWatchedResponse,
168 operation_id="getUserWatched",
169 summary="List repos watched by a user (public)",
170 )
171 async def get_user_watched(
172 username: str,
173 db: AsyncSession = Depends(get_db),
174 ) -> UserWatchedResponse:
175 """Return all repos that ``username`` is currently watching, newest first.
176
177 Joins ``musehub_watches`` (where user_id matches the profile's user_id)
178 with ``musehub_repos`` to surface full repo metadata for each watched repo.
179
180 No auth required — watched repo lists are publicly accessible.
181 Returns 404 when the username does not exist.
182 """
183 profile = await profile_svc.get_profile_by_username(db, username)
184 if profile is None:
185 raise HTTPException(
186 status_code=status.HTTP_404_NOT_FOUND,
187 detail=f"No profile found for username '{username}'",
188 )
189
190 result = await repo_svc.get_user_watched(db, username)
191 logger.info("✅ Served %d watched repos for username=%s", result.total, username)
192 return result
193
194
195 @router.post(
196 "/users",
197 response_model=ProfileResponse,
198 status_code=status.HTTP_201_CREATED,
199 operation_id="createUserProfile",
200 summary="Create a MuseHub user profile",
201 )
202 async def create_user_profile(
203 body: CreateProfileBody,
204 db: AsyncSession = Depends(get_db),
205 claims: TokenClaims = Depends(require_valid_token),
206 ) -> ProfileResponse:
207 """Create a public profile for the authenticated user.
208
209 The ``username`` must be globally unique and URL-safe. Returns 409 if the
210 username is already taken, or if the caller already has a profile.
211 """
212 user_id: str = claims.handle
213 if not user_id:
214 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token: no sub")
215
216 existing_by_user = await profile_svc.get_profile_by_user_id(db, user_id)
217 if existing_by_user is not None:
218 raise HTTPException(
219 status_code=status.HTTP_409_CONFLICT,
220 detail="You already have a profile. Use PUT to update it.",
221 )
222
223 existing_by_name = await profile_svc.get_profile_by_username(db, body.username)
224 if existing_by_name is not None:
225 raise HTTPException(
226 status_code=status.HTTP_409_CONFLICT,
227 detail=f"Username '{body.username}' is already taken.",
228 )
229
230 await profile_svc.create_profile(
231 db,
232 user_id=user_id,
233 username=body.username,
234 bio=body.bio,
235 avatar_url=body.avatar_url,
236 )
237 await db.commit()
238
239 full = await profile_svc.get_full_profile(db, body.username)
240 if full is None:
241 raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Profile created but not found")
242 logger.info("✅ Created profile username=%s user_id=%s", body.username, user_id)
243 return full
244
245
246 @router.put(
247 "/users/{username}",
248 response_model=ProfileResponse,
249 operation_id="updateUserProfile",
250 summary="Update a MuseHub user profile (owner only)",
251 )
252 async def update_user_profile(
253 username: str,
254 body: ProfileUpdateRequest,
255 db: AsyncSession = Depends(get_db),
256 claims: TokenClaims = Depends(require_valid_token),
257 ) -> ProfileResponse:
258 """Partially update the authenticated user's profile: bio, avatar_url, pinned_repo_ids.
259
260 Returns 403 if the caller does not own the profile, 404 if the username
261 does not exist.
262 """
263 profile = await profile_svc.get_profile_by_username(db, username)
264 if profile is None:
265 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
266
267 caller_id: str = claims.handle
268 if profile.user_id != caller_id:
269 raise HTTPException(
270 status_code=status.HTTP_403_FORBIDDEN,
271 detail="You can only edit your own profile.",
272 )
273
274 await profile_svc.update_profile(db, profile, body)
275 await db.commit()
276
277 full = await profile_svc.get_full_profile(db, username)
278 if full is None:
279 raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Profile updated but not found")
280 logger.info("✅ Updated profile username=%s", username)
281 return full
282
283
284 # ---------------------------------------------------------------------------
285 # Followers / following lists
286 # ---------------------------------------------------------------------------
287
288
289 async def _resolve_user_id(db: AsyncSession, username: str) -> str:
290 """Return the identity id for a given handle, falling back to the handle itself."""
291 row = (await db.execute(
292 select(MusehubIdentity.id).where(
293 MusehubIdentity.handle == username,
294 MusehubIdentity.deleted_at.is_(None),
295 )
296 )).scalar_one_or_none()
297 return row if row else username
298
299
300 async def _profile_to_card(db: AsyncSession, id_value: str) -> UserCardResponse | None:
301 row = (await db.execute(
302 select(MusehubIdentity).where(
303 or_(MusehubIdentity.id == id_value, MusehubIdentity.handle == id_value),
304 MusehubIdentity.deleted_at.is_(None),
305 )
306 )).scalar_one_or_none()
307 if row is None:
308 return None
309 return UserCardResponse(username=row.handle, bio=row.bio, avatar_url=row.avatar_url)
310
311
312 @router.get(
313 "/users/{username}/followers-list",
314 response_model=list[UserCardResponse],
315 operation_id="listFollowerCards",
316 summary="List followers as user cards (public)",
317 )
318 async def list_followers(
319 username: str,
320 limit: int = Query(100, ge=1, le=500),
321 db: AsyncSession = Depends(get_db),
322 claims: TokenClaims | None = Depends(optional_token),
323 ) -> list[UserCardResponse]:
324 """Return user cards for everyone who follows *username*."""
325 profile = await profile_svc.get_profile_by_username(db, username)
326 if profile is None:
327 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"No profile found for '{username}'")
328 return []
329
330
331 @router.get(
332 "/users/{username}/following-list",
333 response_model=list[UserCardResponse],
334 operation_id="listFollowingCards",
335 summary="List following as user cards (public)",
336 )
337 async def list_following(
338 username: str,
339 limit: int = Query(100, ge=1, le=500),
340 db: AsyncSession = Depends(get_db),
341 claims: TokenClaims | None = Depends(optional_token),
342 ) -> list[UserCardResponse]:
343 """Return user cards for everyone that *username* follows."""
344 profile = await profile_svc.get_profile_by_username(db, username)
345 if profile is None:
346 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"No profile found for '{username}'")
347 return []
348
349
350 # ---------------------------------------------------------------------------
351 # Agent Fleet
352 # ---------------------------------------------------------------------------
353
354 def _model_label(model_id: str | None) -> str:
355 """Derive a short human-readable label from a model_id string.
356
357 Examples:
358 ``claude-sonnet-4-6`` → ``Sonnet 4.6``
359 ``claude-opus-4-6`` → ``Opus 4.6``
360 ``claude-haiku-4-5`` → ``Haiku 4.5``
361 ``gpt-4o`` → ``gpt-4o``
362 """
363 if not model_id:
364 return "unknown"
365 stripped = model_id.removeprefix("claude-")
366 parts = stripped.split("-")
367 if not parts:
368 return model_id
369 name = parts[0].capitalize()
370 version = ".".join(parts[1:]) if len(parts) > 1 else ""
371 return f"{name} {version}".strip()
372
373
374 async def _query_agent_fleet(
375 db: AsyncSession, handle: str, user_id: str | None
376 ) -> list[AgentCardEntry]:
377 """Aggregate agents that have committed to repos owned by *handle*.
378
379 Resolves ownership via *user_id* when available, falls back to repo.owner
380 string match. Returns entries sorted by commit_count DESC.
381 """
382 # Resolve owned repo IDs
383 if user_id:
384 repo_rows = (await db.execute(
385 select(MusehubRepo.repo_id)
386 .where(MusehubRepo.owner_user_id == user_id, MusehubRepo.deleted_at.is_(None))
387 )).all()
388 else:
389 repo_rows = (await db.execute(
390 select(MusehubRepo.repo_id)
391 .where(MusehubRepo.owner == handle, MusehubRepo.deleted_at.is_(None))
392 )).all()
393
394 repo_ids = [r[0] for r in repo_rows]
395 if not repo_ids:
396 return []
397
398 agent_id_col = MusehubCommit.commit_meta["agent_id"].as_string()
399 model_id_col = MusehubCommit.commit_meta["model_id"].as_string()
400
401 rows = (await db.execute(
402 select(
403 agent_id_col.label("agent_id"),
404 model_id_col.label("model_id"),
405 func.count(MusehubCommit.commit_id).label("commit_count"),
406 func.count(func.distinct(MusehubCommit.repo_id)).label("repo_count"),
407 func.max(MusehubCommit.timestamp).label("last_seen"),
408 )
409 .where(
410 MusehubCommit.repo_id.in_(repo_ids),
411 agent_id_col.isnot(None),
412 agent_id_col != "",
413 )
414 .group_by(agent_id_col, model_id_col)
415 .order_by(func.count(MusehubCommit.commit_id).desc())
416 )).all()
417
418 return [
419 AgentCardEntry(
420 agent_id=row.agent_id,
421 model_id=row.model_id or None,
422 model_label=_model_label(row.model_id),
423 commit_count=int(row.commit_count),
424 repo_count=int(row.repo_count),
425 last_seen=row.last_seen,
426 )
427 for row in rows
428 ]
429
430
431 @router.get(
432 "/users/{handle}/agent-fleet",
433 response_model=AgentFleetResponse,
434 operation_id="getAgentFleet",
435 summary="List agents deployed by a handle (public)",
436 )
437 async def get_agent_fleet(
438 handle: str,
439 db: AsyncSession = Depends(get_db),
440 ) -> AgentFleetResponse:
441 """Return all agents that have committed to repos owned by *handle*.
442
443 Aggregated from ``commit_meta.agent_id`` / ``commit_meta.model_id`` across
444 every commit in repos owned by the identity. Sorted by commit volume DESC.
445
446 No auth required — agent fleet is publicly visible provenance data.
447 Returns 404 only when the handle has no repos at all.
448 """
449 agents = await _query_agent_fleet(db, handle, None)
450
451 if not agents:
452 # Verify the handle owns at least one repo so we return 404 for truly unknown handles
453 exists = (await db.execute(
454 select(MusehubRepo.repo_id)
455 .where(MusehubRepo.owner == handle, MusehubRepo.deleted_at.is_(None))
456 .limit(1)
457 )).first()
458 if exists is None:
459 raise HTTPException(
460 status_code=status.HTTP_404_NOT_FOUND,
461 detail=f"No repos found for handle '{handle}'",
462 )
463
464 logger.info("✅ Agent fleet handle=%s count=%d", handle, len(agents))
465 return AgentFleetResponse(handle=handle, agents=agents, total=len(agents))
466
467
468 # ---------------------------------------------------------------------------
469 # GDPR / CCPA: data export and account deletion
470 # ---------------------------------------------------------------------------
471
472
473 @router.get("/me/export", summary="Export all personal data (GDPR Article 20)")
474 async def export_my_data(
475 claims: TokenClaims = Depends(require_valid_token),
476 db: AsyncSession = Depends(get_db),
477 ) -> JSONObject:
478 """Return a JSON dump of all data MuseHub holds for the authenticated identity.
479
480 Covers: identity profile, registered public keys, owned repos (metadata only —
481 not object blobs), and commits authored by this identity.
482
483 This endpoint satisfies the GDPR right to data portability (Article 20) and
484 CCPA right to know / right to access. No object blobs are included because
485 those are already accessible via the standard repository API.
486 """
487 identity_id = claims.identity_id
488
489 # Identity profile
490 identity: MusehubIdentity | None = (
491 await db.execute(
492 select(MusehubIdentity).where(MusehubIdentity.id == identity_id)
493 )
494 ).scalar_one_or_none()
495
496 if identity is None:
497 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Identity not found")
498
499 # Registered public keys (no key material — fingerprints only)
500 keys = (
501 await db.execute(
502 select(MusehubAuthKey).where(MusehubAuthKey.identity_id == identity_id)
503 )
504 ).scalars().all()
505
506 # Owned repos (metadata only)
507 repos = (
508 await db.execute(
509 select(MusehubRepo).where(
510 MusehubRepo.owner == identity.handle,
511 MusehubRepo.deleted_at.is_(None),
512 )
513 )
514 ).scalars().all()
515
516 # Commits authored by this identity
517 commits = (
518 await db.execute(
519 select(MusehubCommit).where(MusehubCommit.author == identity.handle).limit(10_000)
520 )
521 ).scalars().all()
522
523 return {
524 "schema_version": "1.0",
525 "exported_at": datetime.now(timezone.utc).isoformat(),
526 "identity": {
527 "id": identity.id,
528 "handle": identity.handle,
529 "identity_type": identity.identity_type,
530 "display_name": identity.display_name,
531 "bio": identity.bio,
532 "email": identity.email,
533 "website_url": identity.website_url,
534 "location": identity.location,
535 "created_at": identity.created_at.isoformat(),
536 "tos_accepted_at": identity.tos_accepted_at.isoformat() if identity.tos_accepted_at else None,
537 "tos_version": identity.tos_version,
538 },
539 "keys": [
540 {
541 "key_id": k.key_id,
542 "algorithm": k.algorithm,
543 "fingerprint": k.fingerprint,
544 "label": k.label,
545 "created_at": k.created_at.isoformat(),
546 "last_used_at": k.last_used_at.isoformat() if k.last_used_at else None,
547 }
548 for k in keys
549 ],
550 "repos": [
551 {
552 "repo_id": r.repo_id,
553 "name": r.name,
554 "slug": r.slug,
555 "visibility": r.visibility,
556 "description": r.description,
557 "tags": r.tags,
558 "training_opt_out": r.training_opt_out,
559 "created_at": r.created_at.isoformat(),
560 }
561 for r in repos
562 ],
563 "commits": [
564 {
565 "commit_id": c.commit_id,
566 "repo_id": c.repo_id,
567 "branch": c.branch,
568 "message": c.message,
569 "timestamp": c.timestamp.isoformat() if c.timestamp else None,
570 }
571 for c in commits
572 ],
573 }
574
575
576 @router.delete(
577 "/me",
578 status_code=status.HTTP_204_NO_CONTENT,
579 summary="Delete account and all associated data (GDPR Article 17)",
580 )
581 async def delete_my_account(
582 claims: TokenClaims = Depends(require_valid_token),
583 db: AsyncSession = Depends(get_db),
584 ) -> None:
585 """Soft-delete the authenticated identity and hard-delete all auth keys.
586
587 After this call:
588 - The identity row has ``deleted_at`` set to now.
589 - All registered public keys are hard-deleted (login is no longer possible).
590 - Private repos are soft-deleted (hard-deleted after the retention window).
591 - Public repos are soft-deleted to preserve commit history integrity; they will
592 be hard-deleted after the retention window unless overridden by an admin.
593
594 This satisfies the GDPR right to erasure (Article 17).
595 """
596 identity_id = claims.identity_id
597 now = datetime.now(timezone.utc)
598
599 # Load identity
600 identity: MusehubIdentity | None = (
601 await db.execute(
602 select(MusehubIdentity).where(MusehubIdentity.id == identity_id)
603 )
604 ).scalar_one_or_none()
605
606 if identity is None:
607 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Identity not found")
608
609 # Hard-delete all auth keys so the handle cannot be re-authenticated
610 await db.execute(
611 delete(MusehubAuthKey).where(MusehubAuthKey.identity_id == identity_id)
612 )
613
614 # Soft-delete all owned repos
615 await db.execute(
616 sa_update(MusehubRepo)
617 .where(MusehubRepo.owner == identity.handle, MusehubRepo.deleted_at.is_(None))
618 .values(deleted_at=now)
619 )
620
621 # Soft-delete the identity
622 identity.deleted_at = now
623
624 await db.commit()
625 logger.info("✅ GDPR account deletion: handle=%s identity_id=%s", identity.handle, identity_id)
File History 1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago