505 lines
14 KiB
Python
505 lines
14 KiB
Python
import hashlib
|
|
import json
|
|
import uuid
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, Header, Query, Request, Response
|
|
from fastapi.responses import JSONResponse
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.auth import AuthError
|
|
from app.db import UserIdentity
|
|
from app.notification_models import NotificationSource
|
|
from app.notification_schemas import (
|
|
NotificationCancelRequest,
|
|
NotificationCreateRequest,
|
|
UploadCompleteRequest,
|
|
UploadInitRequest,
|
|
)
|
|
from app.notification_service import (
|
|
apply_read_or_hide,
|
|
authenticate_source,
|
|
cancel_notification,
|
|
catalog,
|
|
complete_upload,
|
|
create_notification,
|
|
discard_upload,
|
|
document_download,
|
|
init_upload,
|
|
invoke_cta_state,
|
|
list_notifications,
|
|
list_uploads,
|
|
notification_dto,
|
|
owned_notification,
|
|
press_button,
|
|
public_notifications,
|
|
unread_count,
|
|
)
|
|
from app.schemas import TextMessageRequest
|
|
from app.services import (
|
|
AuditContext,
|
|
DomainError,
|
|
SettingsSnapshot,
|
|
create_dialog,
|
|
load_settings,
|
|
resolve_user,
|
|
send_message,
|
|
)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
async def db_session(request: Request):
|
|
async for value in request.app.state.db.session():
|
|
yield value
|
|
|
|
|
|
Session = Annotated[AsyncSession, Depends(db_session)]
|
|
|
|
|
|
async def user_dependency(
|
|
request: Request,
|
|
db: Session,
|
|
authorization: Annotated[str | None, Header()] = None,
|
|
) -> UserIdentity:
|
|
if not authorization or not authorization.startswith("Bearer "):
|
|
raise AuthError()
|
|
principal = await request.app.state.jwks.validate(authorization.removeprefix("Bearer ").strip())
|
|
return await resolve_user(db, principal)
|
|
|
|
|
|
User = Annotated[UserIdentity, Depends(user_dependency)]
|
|
|
|
|
|
async def snapshot_dependency(db: Session) -> SettingsSnapshot:
|
|
return await load_settings(db)
|
|
|
|
|
|
Snapshot = Annotated[SettingsSnapshot, Depends(snapshot_dependency)]
|
|
|
|
|
|
async def source_dependency(
|
|
db: Session, authorization: Annotated[str | None, Header()] = None
|
|
) -> NotificationSource:
|
|
return await authenticate_source(db, authorization)
|
|
|
|
|
|
Source = Annotated[NotificationSource, Depends(source_dependency)]
|
|
|
|
|
|
def context(request: Request, ux_session: str | None = None) -> AuditContext:
|
|
try:
|
|
ux_id = uuid.UUID(ux_session) if ux_session else None
|
|
except ValueError:
|
|
raise DomainError("validation_error", 400, "X-Ux-Session-Id must be UUID") from None
|
|
return AuditContext(
|
|
request_id=request.state.request_id,
|
|
trace_id=request.state.trace_id,
|
|
ux_session_id=ux_id,
|
|
user_agent_hash=request.state.user_agent_hash,
|
|
client_ip=None,
|
|
)
|
|
|
|
|
|
async def rate_limit(
|
|
request: Request,
|
|
identity: str,
|
|
route: str,
|
|
limit: tuple[int, int],
|
|
*,
|
|
fail_closed: bool,
|
|
) -> None:
|
|
key = request.app.state.rate_limiter.key("notification", identity, route, limit[1])
|
|
try:
|
|
retry_after = await request.app.state.rate_limiter.consume(key, *limit)
|
|
except Exception:
|
|
if fail_closed:
|
|
raise DomainError(
|
|
"dependency_unavailable", 503, "Rate limit service is unavailable"
|
|
) from None
|
|
return
|
|
if retry_after:
|
|
raise DomainError(
|
|
"rate_limit_exceeded",
|
|
429,
|
|
"Rate limit exceeded",
|
|
{"retry_after": retry_after},
|
|
)
|
|
|
|
|
|
@router.get("/api/v1/public/notifications", tags=["notifications"])
|
|
async def public_list(request: Request, db: Session, settings: Snapshot):
|
|
await rate_limit(
|
|
request,
|
|
request.client.host if request.client else "unknown",
|
|
"public",
|
|
settings.limit("rate_limit.notifications_public.per_ip"),
|
|
fail_closed=False,
|
|
)
|
|
return {
|
|
"items": await public_notifications(db, settings.integer("notification.home.max_items"))
|
|
}
|
|
|
|
|
|
@router.get("/api/v1/public/notification-types", tags=["notifications"])
|
|
async def type_catalog(
|
|
request: Request,
|
|
response: Response,
|
|
db: Session,
|
|
settings: Snapshot,
|
|
if_none_match: Annotated[str | None, Header()] = None,
|
|
):
|
|
await rate_limit(
|
|
request,
|
|
request.client.host if request.client else "unknown",
|
|
"public",
|
|
settings.limit("rate_limit.notifications_public.per_ip"),
|
|
fail_closed=False,
|
|
)
|
|
items = await catalog(db)
|
|
digest = hashlib.sha256(
|
|
json.dumps(items, default=str, sort_keys=True, separators=(",", ":")).encode()
|
|
).hexdigest()
|
|
etag = f'"{digest}"'
|
|
if if_none_match == etag:
|
|
return Response(status_code=304, headers={"ETag": etag})
|
|
response.headers["ETag"] = etag
|
|
response.headers["Cache-Control"] = "public, max-age=3600"
|
|
return {"items": items}
|
|
|
|
|
|
@router.get("/api/v1/notifications", tags=["notifications"])
|
|
async def personal_list(
|
|
request: Request,
|
|
db: Session,
|
|
user: User,
|
|
settings: Snapshot,
|
|
place: str = Query(pattern="^(home|center)$"),
|
|
):
|
|
await rate_limit(
|
|
request,
|
|
str(user.id),
|
|
"read",
|
|
settings.limit("rate_limit.notifications_read.per_user"),
|
|
fail_closed=False,
|
|
)
|
|
limit = settings.integer(
|
|
"notification.home.max_items" if place == "home" else "notification.center.max_items"
|
|
)
|
|
return {"items": await list_notifications(db, user.id, place, limit)}
|
|
|
|
|
|
@router.get("/api/v1/notifications/counter", tags=["notifications"])
|
|
async def counter(request: Request, db: Session, user: User, settings: Snapshot):
|
|
await rate_limit(
|
|
request,
|
|
str(user.id),
|
|
"read",
|
|
settings.limit("rate_limit.notifications_read.per_user"),
|
|
fail_closed=False,
|
|
)
|
|
return {
|
|
"unread_count": await unread_count(
|
|
db, user.id, settings.integer("notification.center.max_items")
|
|
)
|
|
}
|
|
|
|
|
|
@router.get("/api/v1/notifications/{notification_id}", tags=["notifications"])
|
|
async def detail(
|
|
notification_id: uuid.UUID,
|
|
request: Request,
|
|
db: Session,
|
|
user: User,
|
|
settings: Snapshot,
|
|
):
|
|
await rate_limit(
|
|
request,
|
|
str(user.id),
|
|
"read",
|
|
settings.limit("rate_limit.notifications_read.per_user"),
|
|
fail_closed=False,
|
|
)
|
|
item, kind = await owned_notification(db, user.id, notification_id)
|
|
if kind.cta_action != "open_detail":
|
|
raise DomainError("not_found", 404, "Resource was not found")
|
|
return await notification_dto(db, item, kind)
|
|
|
|
|
|
@router.post("/api/v1/notifications/{notification_id}/read", tags=["notifications"])
|
|
async def mark_read(
|
|
notification_id: uuid.UUID,
|
|
request: Request,
|
|
db: Session,
|
|
user: User,
|
|
settings: Snapshot,
|
|
x_ux_session_id: Annotated[str | None, Header(alias="X-Ux-Session-Id")] = None,
|
|
):
|
|
await rate_limit(
|
|
request,
|
|
str(user.id),
|
|
"action",
|
|
settings.limit("rate_limit.notifications_action.per_user"),
|
|
fail_closed=False,
|
|
)
|
|
return await apply_read_or_hide(
|
|
db,
|
|
user.id,
|
|
notification_id,
|
|
"read",
|
|
settings,
|
|
request.app.state.realtime,
|
|
context(request, x_ux_session_id),
|
|
)
|
|
|
|
|
|
@router.post("/api/v1/notifications/{notification_id}/hide", tags=["notifications"])
|
|
async def hide(
|
|
notification_id: uuid.UUID,
|
|
request: Request,
|
|
db: Session,
|
|
user: User,
|
|
settings: Snapshot,
|
|
x_ux_session_id: Annotated[str | None, Header(alias="X-Ux-Session-Id")] = None,
|
|
):
|
|
await rate_limit(
|
|
request,
|
|
str(user.id),
|
|
"action",
|
|
settings.limit("rate_limit.notifications_action.per_user"),
|
|
fail_closed=False,
|
|
)
|
|
return await apply_read_or_hide(
|
|
db,
|
|
user.id,
|
|
notification_id,
|
|
"hide",
|
|
settings,
|
|
request.app.state.realtime,
|
|
context(request, x_ux_session_id),
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/api/v1/notifications/{notification_id}/buttons/{button_code}",
|
|
tags=["notifications"],
|
|
)
|
|
async def button(
|
|
notification_id: uuid.UUID,
|
|
button_code: str,
|
|
request: Request,
|
|
db: Session,
|
|
user: User,
|
|
settings: Snapshot,
|
|
x_ux_session_id: Annotated[str | None, Header(alias="X-Ux-Session-Id")] = None,
|
|
):
|
|
await rate_limit(
|
|
request,
|
|
str(user.id),
|
|
"action",
|
|
settings.limit("rate_limit.notifications_action.per_user"),
|
|
fail_closed=False,
|
|
)
|
|
return await press_button(
|
|
db,
|
|
user.id,
|
|
notification_id,
|
|
button_code,
|
|
settings,
|
|
request.app.state.realtime,
|
|
context(request, x_ux_session_id),
|
|
)
|
|
|
|
|
|
@router.post("/api/v1/notifications/{notification_id}/cta", tags=["notifications"])
|
|
async def cta(
|
|
notification_id: uuid.UUID,
|
|
request: Request,
|
|
db: Session,
|
|
user: User,
|
|
settings: Snapshot,
|
|
x_ux_session_id: Annotated[str | None, Header(alias="X-Ux-Session-Id")] = None,
|
|
):
|
|
await rate_limit(
|
|
request,
|
|
str(user.id),
|
|
"action",
|
|
settings.limit("rate_limit.notifications_action.per_user"),
|
|
fail_closed=False,
|
|
)
|
|
audit_context = context(request, x_ux_session_id)
|
|
item, kind = await owned_notification(db, user.id, notification_id, action=True)
|
|
chat_result = None
|
|
if kind.cta_action == "send_chat_message":
|
|
dialog, _status = await create_dialog(
|
|
db, user, audit_context, f"notification-dialog:{item.id}"
|
|
)
|
|
chat_result = await send_message(
|
|
db,
|
|
user,
|
|
uuid.UUID(str(dialog["dialog_id"])),
|
|
TextMessageRequest(content_kind="text", text=item.chat_message_text or ""),
|
|
f"notification-message:{item.id}",
|
|
audit_context,
|
|
request.app.state.settings,
|
|
request.app.state.safety,
|
|
request.app.state.openlines,
|
|
request.app.state.s3,
|
|
request.app.state.realtime,
|
|
)
|
|
_item, _kind, result = await invoke_cta_state(
|
|
db,
|
|
user.id,
|
|
notification_id,
|
|
settings,
|
|
request.app.state.realtime,
|
|
audit_context,
|
|
chat_result=chat_result,
|
|
)
|
|
return result
|
|
|
|
|
|
@router.get(
|
|
"/api/v1/notifications/{notification_id}/documents/{document_id}/download-url",
|
|
tags=["notifications"],
|
|
)
|
|
async def download(
|
|
notification_id: uuid.UUID,
|
|
document_id: uuid.UUID,
|
|
request: Request,
|
|
db: Session,
|
|
user: User,
|
|
settings: Snapshot,
|
|
x_ux_session_id: Annotated[str | None, Header(alias="X-Ux-Session-Id")] = None,
|
|
):
|
|
await rate_limit(
|
|
request,
|
|
str(user.id),
|
|
"download",
|
|
settings.limit("rate_limit.download_url.per_user"),
|
|
fail_closed=True,
|
|
)
|
|
return await document_download(
|
|
db,
|
|
user.id,
|
|
notification_id,
|
|
document_id,
|
|
settings,
|
|
request.app.state.s3,
|
|
request.app.state.realtime,
|
|
context(request, x_ux_session_id),
|
|
)
|
|
|
|
|
|
@router.post("/api/v1/uploads/init", status_code=201, tags=["uploads"])
|
|
async def upload_init(
|
|
body: UploadInitRequest,
|
|
request: Request,
|
|
db: Session,
|
|
user: User,
|
|
settings: Snapshot,
|
|
):
|
|
await rate_limit(
|
|
request,
|
|
str(user.id),
|
|
"upload",
|
|
settings.limit("rate_limit.notification_upload.per_user"),
|
|
fail_closed=True,
|
|
)
|
|
return await init_upload(db, user.id, body, settings, request.app.state.s3)
|
|
|
|
|
|
@router.post("/api/v1/uploads/{draft_id}/complete", tags=["uploads"])
|
|
async def upload_complete(
|
|
draft_id: uuid.UUID,
|
|
body: UploadCompleteRequest,
|
|
request: Request,
|
|
db: Session,
|
|
user: User,
|
|
settings: Snapshot,
|
|
):
|
|
await rate_limit(
|
|
request,
|
|
str(user.id),
|
|
"upload",
|
|
settings.limit("rate_limit.notification_upload.per_user"),
|
|
fail_closed=True,
|
|
)
|
|
return await complete_upload(
|
|
db,
|
|
user.id,
|
|
draft_id,
|
|
body,
|
|
request.app.state.s3,
|
|
request.app.state.safety,
|
|
request.state.request_id,
|
|
)
|
|
|
|
|
|
@router.get("/api/v1/uploads", tags=["uploads"])
|
|
async def uploads(
|
|
request: Request,
|
|
db: Session,
|
|
user: User,
|
|
settings: Snapshot,
|
|
context_type: str,
|
|
context_id: uuid.UUID,
|
|
):
|
|
await rate_limit(
|
|
request,
|
|
str(user.id),
|
|
"upload",
|
|
settings.limit("rate_limit.notification_upload.per_user"),
|
|
fail_closed=True,
|
|
)
|
|
if context_type != "notification":
|
|
raise DomainError("validation_error", 400, "Unsupported upload context")
|
|
return {"items": await list_uploads(db, user.id, context_type, context_id)}
|
|
|
|
|
|
@router.delete("/api/v1/uploads/{draft_id}", status_code=204, tags=["uploads"])
|
|
async def upload_delete(
|
|
draft_id: uuid.UUID,
|
|
request: Request,
|
|
db: Session,
|
|
user: User,
|
|
settings: Snapshot,
|
|
):
|
|
await rate_limit(
|
|
request,
|
|
str(user.id),
|
|
"upload",
|
|
settings.limit("rate_limit.notification_upload.per_user"),
|
|
fail_closed=True,
|
|
)
|
|
await discard_upload(db, user.id, draft_id, request.app.state.s3)
|
|
return Response(status_code=204)
|
|
|
|
|
|
@router.post("/internal/notifications/v1/notifications", tags=["internal"])
|
|
async def internal_create(
|
|
body: NotificationCreateRequest,
|
|
request: Request,
|
|
db: Session,
|
|
source: Source,
|
|
):
|
|
result, status = await create_notification(
|
|
db,
|
|
body,
|
|
source,
|
|
request.app.state.s3,
|
|
request.app.state.realtime,
|
|
context(request),
|
|
)
|
|
return JSONResponse(json.loads(json.dumps(result, default=str)), status_code=status)
|
|
|
|
|
|
@router.post("/internal/notifications/v1/notifications/cancel", tags=["internal"])
|
|
async def internal_cancel(
|
|
body: NotificationCancelRequest,
|
|
request: Request,
|
|
db: Session,
|
|
source: Source,
|
|
):
|
|
return await cancel_notification(db, body, source, request.app.state.realtime, context(request))
|