"""Личный кабинет любого аутентифицированного пользователя gnexus-auth.
Роли user (не admin) в админ-часть не пускаются (require_admin), но личное
им доступно: /me — профиль; /me/events — личный лог (канал user);
/me/push* — web-push подписки (channel push, docs/06-settings-pwa.md).
Без ролевого гейта: get_current_user — только аутентификация call-home.
"""
import uuid
from datetime import UTC, datetime
from fastapi import Depends, HTTPException, Query, Response, status
from fastapi import APIRouter
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.auth.apikeys import generate_mcp_token, token_hash_of
from app.auth.deps import get_current_user
from app.config import get_settings
from app.database import get_db
from app.api.routes import AdminMe
from app.models import Delivery, Event, McpToken, PushSubscription, Source
from app.settings_store import get_setting_row
router = APIRouter(prefix="/api/v1", tags=["me"])
# Сколько активных (не отозванных) MCP-ключей можно иметь одновременно.
MCP_TOKEN_ACTIVE_LIMIT = 10
class MeEventOut(BaseModel):
id: uuid.UUID
source_name: str
subject: str
action: str
priority: str
payload: dict
created_at: datetime
delivered_at: datetime | None
rendered_message: str | None
class MePushOut(BaseModel):
"""VAPID для подписки браузера; configured=False — пуш ещё не настроен."""
configured: bool
public_key: str | None
class PushSubscriptionOut(BaseModel):
id: int
# endpoint целиком — не секрет; UI укорачивает для показа сам
endpoint: str
ua: str | None
created_at: datetime
class PushSubscriptionIn(BaseModel):
endpoint: str = Field(min_length=10, max_length=500)
keys: dict # {"p256dh": ..., "auth": ...}
ua: str | None = Field(None, max_length=255)
@router.get("/me", response_model=AdminMe)
def me(user=Depends(get_current_user)) -> AdminMe:
"""Профиль по токену gnexus-auth (любая роль). SPA-гейт решает по system_role."""
return AdminMe(
sub=str(user.user_id),
email=user.email,
system_role=user.system_role or "unknown",
username=user.profile.get("username"),
display_name=user.profile.get("display_name"),
avatar_url=user.profile.get("avatar_url"),
account_url=f"{get_settings().gauth_base_url.rstrip('/')}/account/profile",
)
@router.get("/me/events", response_model=list[MeEventOut])
def my_events(
user=Depends(get_current_user),
db: Session = Depends(get_db),
limit: int = Query(50, ge=1, le=200),
) -> list[MeEventOut]:
"""События, направленные лично этому пользователю.
Только доставки канала user его адресата: событие попадает сюда
исключительно через правило маршрутизации. Строгий фильтр по sub —
чужое не видно никогда. Дедуп по событию (одно событие могут направить
два правила — показываем один раз).
"""
sub = str(user.user_id)
rows = (
db.execute(
select(Delivery, Event, Source.name)
.join(Event, Event.id == Delivery.event_id)
.join(Source, Source.id == Event.source_id)
.where(
Delivery.channel == "user",
Delivery.recipient_user_id == sub,
Delivery.status == "delivered",
)
.order_by(Delivery.created_at.desc(), Delivery.id.desc())
.limit(limit * 2) # запас на дубли двух правил
)
.all()
)
seen: set[uuid.UUID] = set()
out: list[MeEventOut] = []
for delivery, event, source_name in rows:
if event.id in seen:
continue
seen.add(event.id)
out.append(
MeEventOut(
id=event.id,
source_name=source_name,
subject=event.subject,
action=event.action,
priority=event.priority,
payload=dict(event.payload or {}),
created_at=event.created_at,
delivered_at=delivery.delivered_at,
rendered_message=delivery.rendered_message,
)
)
if len(out) >= limit:
break
return out
# --- Персональные MCP-токены (доступ к /mcp, docs/07; модель ApiKey) ---
class McpTokenOut(BaseModel):
"""Plaintext никогда не возвращается (только token_hint — хвост)."""
id: int
name: str
system_role: str
token_hint: str
created_at: datetime
last_used_at: datetime | None
revoked_at: datetime | None
class McpTokenCreated(McpTokenOut):
"""Токен в поле token — показывается ОДИН РАЗ (хэш — в БД)."""
token: str
class McpTokenIn(BaseModel):
name: str = Field(min_length=1, max_length=120)
def _mcp_token_out(row: McpToken) -> McpTokenOut:
return McpTokenOut(
id=row.id, name=row.name, system_role=row.system_role,
token_hint=row.token_hint, created_at=row.created_at,
last_used_at=row.last_used_at, revoked_at=row.revoked_at,
)
@router.get("/me/mcp_tokens", response_model=list[McpTokenOut])
def my_mcp_tokens(
user=Depends(get_current_user),
db: Session = Depends(get_db),
) -> list[McpTokenOut]:
"""MCP-токены текущего пользователя (его ключи, включая отозванные)."""
return my_mcp_token_rows(str(user.user_id), db)
def my_mcp_token_rows(user_id: str, db: Session) -> list[McpTokenOut]:
"""Все (включая отозванные) MCP-токены пользователя, новые сверху."""
rows = (
db.execute(
select(McpToken)
.where(McpToken.user_id == user_id)
.order_by(McpToken.created_at.desc(), McpToken.id.desc())
)
.scalars()
.all()
)
return [_mcp_token_out(row) for row in rows]
@router.post("/me/mcp_tokens", response_model=McpTokenCreated, status_code=201)
def create_mcp_token(
body: McpTokenIn,
user=Depends(get_current_user),
db: Session = Depends(get_db),
) -> McpTokenCreated:
"""Выпустить себе MCP-токен; plaintext в ответе ровно один раз.
Роль снейпшотится: MCP будет действовать этим system_role (смена роли
на gnexus-auth — выпустить ключ заново, старый отозвать)."""
sub = str(user.user_id)
active = db.scalar(
select(func.count(McpToken.id)).where(
McpToken.user_id == sub, McpToken.revoked_at.is_(None)
)
)
if active >= MCP_TOKEN_ACTIVE_LIMIT:
raise HTTPException(
status.HTTP_409_CONFLICT,
f"Достигнут лимит активных MCP-токенов ({MCP_TOKEN_ACTIVE_LIMIT}) — отзовите лишний",
)
token = generate_mcp_token()
row = McpToken(
user_id=sub,
name=body.name,
user_email=user.email or None,
system_role=user.system_role or "unknown",
token_hash=token_hash_of(token),
token_hint=token[-4:],
)
db.add(row)
db.commit()
db.refresh(row)
return McpTokenCreated(
**_mcp_token_out(row).model_dump(), token=token,
)
@router.post("/me/mcp_tokens/{token_id}/revoke", response_model=McpTokenOut)
def revoke_mcp_token(
token_id: int,
user=Depends(get_current_user),
db: Session = Depends(get_db),
) -> McpTokenOut:
"""Отозвать свой MCP-токен; чужой — 404 (как у push-подписок)."""
row = db.get(McpToken, token_id)
if row is None or row.user_id != str(user.user_id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Токен не найден")
if row.revoked_at is not None:
raise HTTPException(status.HTTP_409_CONFLICT, "Токен уже отозван")
row.revoked_at = datetime.now(UTC)
db.commit()
db.refresh(row)
return _mcp_token_out(row)
# --- Web-push подписки (канал push; самопользовательская привязка, docs/06) ---
@router.get("/me/push", response_model=MePushOut)
def push_config(user=Depends(get_current_user), db: Session = Depends(get_db)):
"""Public-ключ VAPID для pushManager.subscribe браузера клиента."""
public = get_setting_row(db, "vapid_public_key")
return MePushOut(configured=bool(public), public_key=public)
@router.get("/me/push/subscriptions", response_model=list[PushSubscriptionOut])
def my_subscriptions(
user=Depends(get_current_user), db: Session = Depends(get_db)
) -> list[PushSubscriptionOut]:
"""Подписки текущего пользователя (его браузерные устройства)."""
sub = str(user.user_id)
rows = (
db.execute(
select(PushSubscription)
.where(PushSubscription.user_id == sub)
.order_by(PushSubscription.created_at.desc())
)
.scalars()
.all()
)
return [PushSubscriptionOut(id=r.id, endpoint=r.endpoint, ua=r.ua, created_at=r.created_at) for r in rows]
@router.post("/me/push/subscriptions", response_model=PushSubscriptionOut, status_code=201)
def save_subscription(
body: PushSubscriptionIn,
user=Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Регистрация/обновление подписки этого браузера (upsert по endpoint)."""
keys = body.keys or {}
p256dh = str(keys.get("p256dh") or "").strip()
auth = str(keys.get("auth") or "").strip()
if not p256dh or not auth:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
"В подписке нет ключей keys.p256dh/keys.auth от pushManager",
)
sub = (
db.execute(
select(PushSubscription).where(PushSubscription.endpoint == body.endpoint)
)
.scalars()
.first()
)
if sub is None:
sub = PushSubscription(endpoint=body.endpoint, p256dh=p256dh, auth=auth, ua=body.ua,
user_id=str(user.user_id))
db.add(sub)
else:
# endpoint уникален: могла остаться под прошлым владельцем (браузер тот же,
# а пользователь в нём другой) — теперь подписка принадлежит этому юзеру.
sub.user_id = str(user.user_id)
sub.p256dh, sub.auth, sub.ua = p256dh, auth, body.ua
db.commit()
db.refresh(sub)
return PushSubscriptionOut(id=sub.id, endpoint=sub.endpoint, ua=sub.ua, created_at=sub.created_at)
@router.delete("/me/push/subscriptions/{sub_id}", status_code=204)
def remove_subscription(
sub_id: int,
user=Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Отписать свой (user_id совпадает) endpoint; чужой — 404."""
sub = db.get(PushSubscription, sub_id)
if sub is None or sub.user_id != str(user.user_id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Подписка не найдена")
db.delete(sub)
db.commit()
return Response(status_code=204)