Private
Public Access
Phase 7: Bot/extension system
Bot accounts (User rows with is_bot=True), scoped API tokens (read:messages, write:messages, manage:rooms) authenticated via Authorization: Bearer on both REST and the WS handshake, live bot WebSocket access on the same /ws/chat endpoint humans use, message editing (WS "edit" envelope -> message_update broadcast, fans out cross-instance for free via the existing broadcaster), incoming webhooks (room-scoped, no auth beyond the URL token), and outgoing webhooks/event subscriptions (HMAC-SHA256 signed, backgrounded delivery, creation-time SSRF validation against private/loopback/link-local targets). Token auth is additive, not a parallel system: a bearer-token-authenticated bot goes through the exact same room-membership/role checks a session- authenticated human does everywhere; only read:messages/write:messages are separately scope-gated (the two message endpoints). manage:rooms scope enforcement, full per-delivery SSRF re-validation, and bot API rate limiting were explicitly scoped out (confirmed with the repo owner) as disproportionate to this phase -- documented as known gaps in backend/README.md rather than silently skipped. Admin portal gains a Bots tab (create bots, issue/revoke scoped tokens, cross-room webhook visibility); RoomInfoPanel gains room-scoped webhook/ subscription management, mirroring how invites already work there. The chat UI also gets a minimal "edit your own message" affordance -- not asked for by the issue, but the only practical way to exercise the edit pipeline by hand instead of only via a scripted bot client. Along the way: fixed a real bug caught while writing the incoming-webhook test -- offline-push notification relied on the sender being "connected" to exclude themselves, true for WS-originated messages but not for the new webhook path, which has no WS connection for the attributed sender at all. Now explicitly excluded. Also discovered the REST-only test fixture never triggered ASGI lifespan, so app.state.broadcaster/presence didn't exist for it; moved their construction out of the lifespan into create_app() itself (Redis client construction is synchronous/lazy) so both the WS and REST-only paths always have them. New tests/test_bots.py, test_message_edit.py, test_webhooks.py (full suite now 78/78, stable across repeated runs) plus a scripted end-to-end smoke test (bot WS join/post/edit, incoming webhook, SSRF rejection, outgoing delivery) and a full browser walkthrough of the new admin/room UI. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+105
-23
@@ -1,11 +1,13 @@
|
||||
# KeepItTalking backend (Phase 1 + 2 + 4 + 5 + 6)
|
||||
# KeepItTalking backend (Phase 1 + 2 + 4 + 5 + 6 + 7)
|
||||
|
||||
FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL + Redis. Implements auth, room
|
||||
CRUD (open and private), room roles (owner/admin/member) and invites, a
|
||||
WebSocket chat endpoint that fans out across multiple app-server instances
|
||||
via Redis pub/sub, Web Push notifications for offline room members, and a
|
||||
site-admin portal (user/room management + an audit log). See
|
||||
`../ARCHITECTURE.md` for the full system design and the phased build plan.
|
||||
via Redis pub/sub, Web Push notifications for offline room members, a
|
||||
site-admin portal (user/room/bot management + an audit log), and a bot/
|
||||
extension layer (scoped API tokens, live bot WebSocket access, incoming and
|
||||
outgoing webhooks, message editing). See `../ARCHITECTURE.md` for the full
|
||||
system design and the phased build plan.
|
||||
|
||||
This is an **invite-only site**: there is no public registration endpoint.
|
||||
Accounts are created by an operator on the app server — see step 4 below.
|
||||
@@ -108,15 +110,17 @@ app/
|
||||
main.py create_app(), session middleware, router/WS mounting
|
||||
config.py environment-driven settings (pydantic-settings)
|
||||
database.py async engine/session, get_db() dependency
|
||||
dependencies.py get_current_user, require_room_member, require_room_role,
|
||||
require_site_admin
|
||||
security.py argon2 password hashing
|
||||
dependencies.py get_current_user (session cookie or Bearer token),
|
||||
require_room_member, require_room_role,
|
||||
require_site_admin, require_scope
|
||||
security.py argon2 password hashing + token generate/hash (sha256)
|
||||
cli.py `python -m app.cli create-user` / `generate-vapid-keys`
|
||||
models/ SQLAlchemy models (users, rooms, room_memberships,
|
||||
messages, room_invites, push_subscriptions,
|
||||
admin_audit_log)
|
||||
admin_audit_log, api_tokens, webhooks_incoming,
|
||||
event_subscriptions)
|
||||
schemas/ Pydantic request/response models
|
||||
routers/ auth, rooms, invites, push, admin, health
|
||||
routers/ auth, rooms, invites, push, admin, bots, webhooks, health
|
||||
services/ business logic called by routers
|
||||
ws/ connection_manager (local sockets), presence +
|
||||
broadcaster (Redis), /ws/chat handler
|
||||
@@ -147,15 +151,90 @@ Every `/api/admin/*` route (`app/routers/admin.py`) requires
|
||||
(actor, action, target type/id, JSON metadata) in the same transaction as
|
||||
the change, listed newest-first via `GET /api/admin/audit-log`.
|
||||
|
||||
Two items from the original phase scope are deliberately not here yet:
|
||||
- **Bot/integration management** — nothing to manage until Phase 7 builds
|
||||
the actual bot data model (`api_tokens`, `webhooks_incoming`,
|
||||
`event_subscriptions` per `ARCHITECTURE.md` §4); it'll be built alongside
|
||||
that data model instead of as an empty panel now.
|
||||
Bot/integration management (deferred from this phase originally) is now in
|
||||
place — see Phase 7 below. One item is still deliberately not here:
|
||||
- **System settings** — no settings storage or concrete setting exists yet.
|
||||
The frontend has an empty "Settings" tab as a placeholder for when one
|
||||
does.
|
||||
|
||||
## Bot/extension system (Phase 7)
|
||||
|
||||
Bots are `User` rows with `is_bot=True` (`app/services/bot_service.py`,
|
||||
admin-only, `/api/admin/bots/*`) — a generated-and-discarded password since
|
||||
bots never log in with one, and a `{username}@bots.example.com` placeholder
|
||||
email (`.local`/`.invalid` are rejected by `EmailStr`'s special-use-TLD
|
||||
check; a subdomain of the real, if reserved-for-docs, `.com` isn't). A bot
|
||||
authenticates instead with a **scoped API token** (`read:messages`,
|
||||
`write:messages`, `manage:rooms`) — shown once at issuance, stored as a
|
||||
SHA-256 hash (`security.hash_token`, deliberately *not* argon2: a bearer
|
||||
token has to be looked up by itself with no username to key off first,
|
||||
which argon2's per-call random salt makes impossible; a fast hash of a
|
||||
256-bit random token is the standard approach, same as GitHub/Stripe keys).
|
||||
|
||||
**Auth**: `get_current_user` (`app/dependencies.py`) checks for an
|
||||
`Authorization: Bearer` header before falling back to the session cookie;
|
||||
a resolved token is stashed on `request.state.api_token` so `require_scope`
|
||||
can gate specific actions. A token-authenticated bot is subject to the
|
||||
*exact same* room-membership/role checks as a session-authenticated human
|
||||
on every existing endpoint — the token only narrows things further, it
|
||||
doesn't grant anything a plain room membership wouldn't. Only
|
||||
`read:messages`/`write:messages` are actually scope-gated (on
|
||||
`GET /api/rooms/{id}/messages` and the WS message/edit handlers) —
|
||||
`manage:rooms` is a recognized, issuable scope with no separate enforcement
|
||||
yet, so a bot's room-management ability is bounded by its ordinary room
|
||||
role, same as any user; wiring real `manage:rooms` gating into the dozen
|
||||
room-management endpoints was cut from this phase's scope (confirmed with
|
||||
the repo owner) as disproportionate to the win. The WS handshake
|
||||
(`app/ws/chat.py`) accepts the same header directly (bots set it on the
|
||||
handshake; browsers use the cookie) — same `/ws/chat` endpoint a human
|
||||
client uses, per `ARCHITECTURE.md`'s "same connection type" design.
|
||||
|
||||
**Message editing**: `{"type": "edit", "room_id", "message_id", "content"}`
|
||||
over the existing WS connection (`message_service.edit_message` — 403 if
|
||||
you're not the author), broadcasts `{"type": "message_update", ...}` via the
|
||||
same `RoomBroadcaster.publish()` new messages use, so it fans out
|
||||
cross-instance for free. `Message.edited_at` (present in the schema since
|
||||
Phase 1, unused until now) is exposed on `MessageRead`. The frontend also
|
||||
gets a minimal "edit your own message" UI affordance (hover a bubble you
|
||||
own) — not asked for by the issue, but the only practical way to exercise
|
||||
the pipeline by hand instead of only via a scripted bot client, and it's a
|
||||
small addition once the WS envelope exists anyway.
|
||||
|
||||
**Incoming webhooks** (`POST /api/rooms/{id}/webhooks/incoming`, room-admin
|
||||
managed, mirrors how invites are nested under rooms): a room-scoped URL
|
||||
with no auth beyond the token in it being correct
|
||||
(`webhooks_incoming.token` is stored **in the clear**, unlike API tokens —
|
||||
the room admin needs to view/copy the full URL anytime). `POST
|
||||
/api/webhooks/incoming/{token}` (public, no auth dependency) creates a
|
||||
message attributed to the webhook's creator and runs the identical
|
||||
post-message pipeline a WS-originated message does
|
||||
(`app/services/message_events.py`'s `broadcast_new_message`, shared by both
|
||||
call sites rather than duplicated).
|
||||
|
||||
**Outgoing webhooks / event subscriptions** (`POST
|
||||
/api/rooms/{id}/event-subscriptions`, room-admin managed; room-scoped or
|
||||
global via `room_id=null`): fires an HMAC-SHA256-signed POST
|
||||
(`X-KeepItTalking-Signature: sha256=...`) on `message.created`/
|
||||
`message.updated`, delivered via a backgrounded `asyncio.create_task`
|
||||
(`app/services/webhook_delivery.py`) — safe to background here, unlike the
|
||||
Phase 4 push lesson, since there's no DB session involved, just the
|
||||
already-serialized payload and secret. Fire-once, no retry/backoff — a
|
||||
failed delivery is logged and dropped, documented limitation, not a
|
||||
guarantee.
|
||||
|
||||
**SSRF protection** (`app/services/ssrf.py`): target URLs are validated at
|
||||
*subscription-creation time* — non-http(s) schemes rejected, hostname
|
||||
resolved and rejected if any address is private/loopback/link-local/
|
||||
reserved/multicast. Not re-validated per delivery, so DNS rebinding between
|
||||
creation and a later send isn't defended against — a real gap, deliberately
|
||||
left open (confirmed with the repo owner) rather than building the
|
||||
meaningfully more involved per-request IP-pinning that would close it.
|
||||
|
||||
**Rate limiting**: `ARCHITECTURE.md` calls for rate-limiting bot API calls
|
||||
the same as human ones. Not implemented — there's no rate limiting
|
||||
anywhere in the app today (human or bot) to extend, and building one well
|
||||
is its own scope. Documented gap, not an oversight.
|
||||
|
||||
## Cross-instance broadcast (Phase 5)
|
||||
|
||||
The WebSocket layer is split into three pieces so that running one app
|
||||
@@ -186,15 +265,18 @@ below.
|
||||
`POST /api/push/subscribe` (upserts by `endpoint`) / `DELETE /api/push/subscribe`
|
||||
manage a user's `push_subscriptions` rows; `GET /api/push/vapid-public-key` gives
|
||||
the frontend the key it needs for `PushManager.subscribe()`. On every chat
|
||||
message, `app/ws/chat.py` computes `room members - Presence.
|
||||
connected_user_ids(room_id)` (who's actually connected to *that room* right
|
||||
now, across every app instance — see Phase 5 below) and sends each offline
|
||||
member a push via `pywebpush`, awaited inline against the same
|
||||
request-scoped session rather than fired as a background task — the
|
||||
broadcast to online members already happened by that point, so nothing
|
||||
online-facing is delayed, and it sidesteps `asyncio.create_task()`s outliving
|
||||
the session/event loop they were created on. An expired/invalid subscription
|
||||
(pywebpush 404/410) is deleted automatically.
|
||||
message, `app/services/message_events.py` computes `room members -
|
||||
Presence.connected_user_ids(room_id) - {sender}` (who's actually connected
|
||||
to *that room* right now, across every app instance — see Phase 5 below;
|
||||
the sender is subtracted explicitly rather than relied on to be "connected,"
|
||||
since that's only true for WS-originated messages, not the Phase 7
|
||||
incoming-webhook path) and sends each offline member a push via `pywebpush`,
|
||||
awaited inline against the same request-scoped session rather than fired as
|
||||
a background task — the broadcast to online members already happened by
|
||||
that point, so nothing online-facing is delayed, and it sidesteps
|
||||
`asyncio.create_task()`s outliving the session/event loop they were created
|
||||
on. An expired/invalid subscription (pywebpush 404/410) is deleted
|
||||
automatically.
|
||||
|
||||
## Room roles and invites (Phase 2)
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""bots and webhooks
|
||||
|
||||
Revision ID: 3350c67553ad
|
||||
Revises: 6ee71c8d5e07
|
||||
Create Date: 2026-08-14 07:47:25.761745
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '3350c67553ad'
|
||||
down_revision: Union[str, Sequence[str], None] = '6ee71c8d5e07'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('api_tokens',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('owner_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('token_hash', sa.String(length=64), nullable=False),
|
||||
sa.Column('scopes', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_api_tokens_owner_id'), 'api_tokens', ['owner_id'], unique=False)
|
||||
op.create_index(op.f('ix_api_tokens_token_hash'), 'api_tokens', ['token_hash'], unique=True)
|
||||
op.create_table('event_subscriptions',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('room_id', sa.Uuid(), nullable=True),
|
||||
sa.Column('event_types', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column('target_url', sa.String(length=2048), nullable=False),
|
||||
sa.Column('signing_secret', sa.String(length=64), nullable=False),
|
||||
sa.Column('created_by', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
|
||||
sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_event_subscriptions_room_id'), 'event_subscriptions', ['room_id'], unique=False)
|
||||
op.create_table('webhooks_incoming',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('room_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('token', sa.String(length=64), nullable=False),
|
||||
sa.Column('created_by', sa.Uuid(), nullable=False),
|
||||
sa.Column('description', sa.String(length=500), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
|
||||
sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_webhooks_incoming_room_id'), 'webhooks_incoming', ['room_id'], unique=False)
|
||||
op.create_index(op.f('ix_webhooks_incoming_token'), 'webhooks_incoming', ['token'], unique=True)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_webhooks_incoming_token'), table_name='webhooks_incoming')
|
||||
op.drop_index(op.f('ix_webhooks_incoming_room_id'), table_name='webhooks_incoming')
|
||||
op.drop_table('webhooks_incoming')
|
||||
op.drop_index(op.f('ix_event_subscriptions_room_id'), table_name='event_subscriptions')
|
||||
op.drop_table('event_subscriptions')
|
||||
op.drop_index(op.f('ix_api_tokens_token_hash'), table_name='api_tokens')
|
||||
op.drop_index(op.f('ix_api_tokens_owner_id'), table_name='api_tokens')
|
||||
op.drop_table('api_tokens')
|
||||
# ### end Alembic commands ###
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import RoomMembership, RoomRole, User
|
||||
from app.services.bot_service import resolve_token
|
||||
|
||||
_ROLE_RANK = {RoomRole.member: 0, RoomRole.admin: 1, RoomRole.owner: 2}
|
||||
|
||||
@@ -13,6 +14,20 @@ _ROLE_RANK = {RoomRole.member: 0, RoomRole.admin: 1, RoomRole.owner: 2}
|
||||
async def get_current_user(
|
||||
request: Request, db: AsyncSession = Depends(get_db)
|
||||
) -> User:
|
||||
# Bearer token (bots) takes priority over the session cookie (humans) --
|
||||
# a request either carries one or the other, never meaningfully both.
|
||||
# Downstream, a token-authenticated bot is subject to the exact same
|
||||
# room-membership/role checks as a session-authenticated human; the
|
||||
# token additionally narrows what it can do via require_scope below.
|
||||
auth_header = request.headers.get("authorization")
|
||||
if auth_header and auth_header.lower().startswith("bearer "):
|
||||
resolved = await resolve_token(db, auth_header[len("bearer ") :].strip())
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid or revoked API token")
|
||||
user, token = resolved
|
||||
request.state.api_token = token
|
||||
return user
|
||||
|
||||
user_id = request.session.get("user_id")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
@@ -25,6 +40,12 @@ async def get_current_user(
|
||||
return user
|
||||
|
||||
|
||||
def require_scope(request: Request, scope: str) -> None:
|
||||
token = getattr(request.state, "api_token", None)
|
||||
if token is not None and scope not in token.scopes:
|
||||
raise HTTPException(status_code=403, detail=f"Token missing required scope: {scope}")
|
||||
|
||||
|
||||
async def require_room_member(
|
||||
room_id: uuid.UUID, user: User, db: AsyncSession
|
||||
) -> RoomMembership:
|
||||
|
||||
+16
-7
@@ -8,7 +8,7 @@ from redis.asyncio import Redis
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.routers import admin, auth, health, invites, push, rooms
|
||||
from app.routers import admin, auth, bots, health, invites, push, rooms, webhooks
|
||||
from app.ws.broadcaster import RoomBroadcaster
|
||||
from app.ws.chat import router as ws_router
|
||||
from app.ws.connection_manager import ConnectionManager
|
||||
@@ -17,18 +17,22 @@ from app.ws.presence import Presence
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
redis = Redis.from_url(settings.redis_url, decode_responses=True)
|
||||
app.state.presence = Presence(redis)
|
||||
broadcaster = RoomBroadcaster(redis, app.state.connection_manager)
|
||||
app.state.broadcaster = broadcaster
|
||||
listener_task = asyncio.create_task(broadcaster.listen())
|
||||
# redis/presence/broadcaster are constructed in create_app(), not here --
|
||||
# Redis.from_url() is synchronous/lazy (no connection opens until the
|
||||
# first command), so app.state.presence/broadcaster are always present
|
||||
# even for callers that never trigger the ASGI lifespan (e.g. httpx's
|
||||
# ASGITransport, used by the plain REST test fixtures -- only
|
||||
# TestClient's websocket_connect-based tests actually run lifespan).
|
||||
# The background listener task genuinely needs a running event loop
|
||||
# though, so that part stays here.
|
||||
listener_task = asyncio.create_task(app.state.broadcaster.listen())
|
||||
|
||||
yield
|
||||
|
||||
listener_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await listener_task
|
||||
await redis.aclose()
|
||||
await app.state.redis.aclose()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
@@ -43,6 +47,9 @@ def create_app() -> FastAPI:
|
||||
)
|
||||
|
||||
app.state.connection_manager = ConnectionManager()
|
||||
app.state.redis = Redis.from_url(settings.redis_url, decode_responses=True)
|
||||
app.state.presence = Presence(app.state.redis)
|
||||
app.state.broadcaster = RoomBroadcaster(app.state.redis, app.state.connection_manager)
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(auth.router)
|
||||
@@ -50,6 +57,8 @@ def create_app() -> FastAPI:
|
||||
app.include_router(invites.router)
|
||||
app.include_router(push.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(bots.router)
|
||||
app.include_router(webhooks.router)
|
||||
app.include_router(ws_router)
|
||||
|
||||
return app
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
from app.models.admin_audit_log import AdminAuditLog
|
||||
from app.models.api_token import ApiToken
|
||||
from app.models.base import Base
|
||||
from app.models.event_subscription import EventSubscription
|
||||
from app.models.invite import InviteStatus, RoomInvite
|
||||
from app.models.membership import RoomMembership, RoomRole
|
||||
from app.models.message import Message
|
||||
from app.models.push_subscription import PushSubscription
|
||||
from app.models.room import Room
|
||||
from app.models.user import User
|
||||
from app.models.webhook_incoming import WebhookIncoming
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
@@ -18,4 +21,7 @@ __all__ = [
|
||||
"InviteStatus",
|
||||
"PushSubscription",
|
||||
"AdminAuditLog",
|
||||
"ApiToken",
|
||||
"WebhookIncoming",
|
||||
"EventSubscription",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class ApiToken(Base):
|
||||
__tablename__ = "api_tokens"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
owner_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), index=True, nullable=False)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||||
scopes: Mapped[list[str]] = mapped_column(JSONB, nullable=False)
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
owner = relationship("User")
|
||||
@@ -0,0 +1,25 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class EventSubscription(Base):
|
||||
__tablename__ = "event_subscriptions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
room_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("rooms.id"), index=True)
|
||||
event_types: Mapped[list[str]] = mapped_column(JSONB, nullable=False)
|
||||
target_url: Mapped[str] = mapped_column(String(2048), nullable=False)
|
||||
signing_secret: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
created_by: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
room = relationship("Room")
|
||||
creator = relationship("User")
|
||||
@@ -0,0 +1,25 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class WebhookIncoming(Base):
|
||||
__tablename__ = "webhooks_incoming"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
room_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("rooms.id"), index=True, nullable=False)
|
||||
# Stored in the clear (not hashed) -- the whole point is the room admin
|
||||
# can view/copy the full webhook URL again anytime, unlike an API token.
|
||||
token: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||||
created_by: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(String(500))
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
room = relationship("Room")
|
||||
creator = relationship("User")
|
||||
@@ -14,12 +14,12 @@ from app.schemas.admin import (
|
||||
ResetPasswordRequest,
|
||||
TransferOwnershipRequest,
|
||||
)
|
||||
from app.schemas.webhook import EventSubscriptionAdminRead, WebhookIncomingAdminRead
|
||||
from app.services.admin_service import (
|
||||
CannotActOnSelfError,
|
||||
RoomNotFoundError,
|
||||
TargetNotRoomMemberError,
|
||||
UserNotFoundError,
|
||||
list_audit_log,
|
||||
list_rooms_admin,
|
||||
list_users,
|
||||
reset_user_password,
|
||||
@@ -28,6 +28,11 @@ from app.services.admin_service import (
|
||||
set_user_site_admin,
|
||||
transfer_ownership_admin,
|
||||
)
|
||||
from app.services.audit import list_audit_log
|
||||
from app.services.webhook_service import (
|
||||
list_all_event_subscriptions_admin,
|
||||
list_all_incoming_webhooks_admin,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
@@ -223,3 +228,47 @@ async def list_audit_log_endpoint(
|
||||
)
|
||||
for e in entries
|
||||
]
|
||||
|
||||
|
||||
@router.get("/webhooks/incoming", response_model=list[WebhookIncomingAdminRead])
|
||||
async def list_incoming_webhooks_admin_endpoint(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
require_site_admin(current_user)
|
||||
webhooks = await list_all_incoming_webhooks_admin(db)
|
||||
return [
|
||||
WebhookIncomingAdminRead(
|
||||
id=w.id,
|
||||
room_id=w.room_id,
|
||||
token=w.token,
|
||||
created_by=w.created_by,
|
||||
description=w.description,
|
||||
created_at=w.created_at,
|
||||
room_name=w.room.name,
|
||||
created_by_username=w.creator.username,
|
||||
)
|
||||
for w in webhooks
|
||||
]
|
||||
|
||||
|
||||
@router.get("/event-subscriptions", response_model=list[EventSubscriptionAdminRead])
|
||||
async def list_event_subscriptions_admin_endpoint(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
require_site_admin(current_user)
|
||||
subscriptions = await list_all_event_subscriptions_admin(db)
|
||||
return [
|
||||
EventSubscriptionAdminRead(
|
||||
id=s.id,
|
||||
room_id=s.room_id,
|
||||
event_types=s.event_types,
|
||||
target_url=s.target_url,
|
||||
created_by=s.created_by,
|
||||
created_at=s.created_at,
|
||||
room_name=s.room.name if s.room else None,
|
||||
created_by_username=s.creator.username,
|
||||
)
|
||||
for s in subscriptions
|
||||
]
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, require_site_admin
|
||||
from app.models import User
|
||||
from app.schemas.bot import ApiTokenCreate, ApiTokenCreated, ApiTokenRead, BotCreate, BotRead
|
||||
from app.services.bot_service import (
|
||||
BotNotFoundError,
|
||||
DuplicateBotError,
|
||||
InvalidScopeError,
|
||||
TokenNotFoundError,
|
||||
create_api_token,
|
||||
create_bot,
|
||||
list_api_tokens,
|
||||
list_bots,
|
||||
revoke_api_token,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/admin/bots", tags=["bots"])
|
||||
|
||||
|
||||
@router.post("", response_model=BotRead, status_code=201)
|
||||
async def create_bot_endpoint(
|
||||
data: BotCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
require_site_admin(current_user)
|
||||
try:
|
||||
return await create_bot(db, current_user, data.username)
|
||||
except DuplicateBotError:
|
||||
raise HTTPException(status_code=409, detail="A user with this username already exists")
|
||||
|
||||
|
||||
@router.get("", response_model=list[BotRead])
|
||||
async def list_bots_endpoint(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
require_site_admin(current_user)
|
||||
return await list_bots(db)
|
||||
|
||||
|
||||
@router.post("/{bot_id}/tokens", response_model=ApiTokenCreated, status_code=201)
|
||||
async def create_token_endpoint(
|
||||
bot_id: uuid.UUID,
|
||||
data: ApiTokenCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
require_site_admin(current_user)
|
||||
try:
|
||||
token, plaintext = await create_api_token(db, current_user, bot_id, data.scopes)
|
||||
except BotNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Bot not found")
|
||||
except InvalidScopeError:
|
||||
raise HTTPException(status_code=400, detail="Unrecognized scope")
|
||||
return ApiTokenCreated(
|
||||
id=token.id,
|
||||
owner_id=token.owner_id,
|
||||
scopes=token.scopes,
|
||||
last_used_at=token.last_used_at,
|
||||
created_at=token.created_at,
|
||||
token=plaintext,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{bot_id}/tokens", response_model=list[ApiTokenRead])
|
||||
async def list_tokens_endpoint(
|
||||
bot_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
require_site_admin(current_user)
|
||||
return await list_api_tokens(db, bot_id)
|
||||
|
||||
|
||||
@router.delete("/tokens/{token_id}", status_code=204)
|
||||
async def revoke_token_endpoint(
|
||||
token_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
require_site_admin(current_user)
|
||||
try:
|
||||
await revoke_api_token(db, current_user, token_id)
|
||||
except TokenNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Token not found")
|
||||
@@ -1,10 +1,15 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, require_room_member, require_room_role
|
||||
from app.dependencies import (
|
||||
get_current_user,
|
||||
require_room_member,
|
||||
require_room_role,
|
||||
require_scope,
|
||||
)
|
||||
from app.models import RoomRole, User
|
||||
from app.schemas.invite import InviteCreate, InviteRead
|
||||
from app.schemas.message import MessageRead
|
||||
@@ -28,6 +33,13 @@ from app.services.invite_service import (
|
||||
list_room_invites,
|
||||
revoke_invite,
|
||||
)
|
||||
from app.schemas.webhook import (
|
||||
EventSubscriptionCreate,
|
||||
EventSubscriptionCreated,
|
||||
EventSubscriptionRead,
|
||||
WebhookIncomingCreate,
|
||||
WebhookIncomingRead,
|
||||
)
|
||||
from app.services.message_service import list_recent_messages
|
||||
from app.services.room_service import (
|
||||
CannotRemoveOwnerError,
|
||||
@@ -50,6 +62,18 @@ from app.services.room_service import (
|
||||
transfer_ownership,
|
||||
update_room,
|
||||
)
|
||||
from app.services.webhook_service import (
|
||||
InvalidEventTypeError,
|
||||
SubscriptionNotFoundError,
|
||||
WebhookNotFoundError,
|
||||
create_event_subscription,
|
||||
create_incoming_webhook,
|
||||
list_event_subscriptions,
|
||||
list_incoming_webhooks,
|
||||
revoke_event_subscription,
|
||||
revoke_incoming_webhook,
|
||||
)
|
||||
from app.services.ssrf import UnsafeWebhookUrlError
|
||||
|
||||
router = APIRouter(prefix="/api/rooms", tags=["rooms"])
|
||||
|
||||
@@ -252,10 +276,12 @@ async def transfer_ownership_endpoint(
|
||||
@router.get("/{room_id}/messages", response_model=list[MessageRead])
|
||||
async def get_room_messages_endpoint(
|
||||
room_id: uuid.UUID,
|
||||
request: Request,
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
require_scope(request, "read:messages")
|
||||
await require_room_member(room_id, current_user, db)
|
||||
messages = await list_recent_messages(db, room_id, limit)
|
||||
return [
|
||||
@@ -266,6 +292,7 @@ async def get_room_messages_endpoint(
|
||||
username=m.user.username,
|
||||
content=m.content,
|
||||
created_at=m.created_at,
|
||||
edited_at=m.edited_at,
|
||||
)
|
||||
for m in messages
|
||||
]
|
||||
@@ -328,3 +355,93 @@ async def revoke_invite_endpoint(
|
||||
raise HTTPException(status_code=404, detail="Invite not found")
|
||||
except InviteNotPendingError:
|
||||
raise HTTPException(status_code=400, detail="Invite is no longer pending")
|
||||
|
||||
|
||||
@router.post("/{room_id}/webhooks/incoming", response_model=WebhookIncomingRead, status_code=201)
|
||||
async def create_incoming_webhook_endpoint(
|
||||
room_id: uuid.UUID,
|
||||
data: WebhookIncomingCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_room_role(room_id, current_user, db, RoomRole.admin)
|
||||
return await create_incoming_webhook(db, current_user, room_id, data.description)
|
||||
|
||||
|
||||
@router.get("/{room_id}/webhooks/incoming", response_model=list[WebhookIncomingRead])
|
||||
async def list_incoming_webhooks_endpoint(
|
||||
room_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_room_role(room_id, current_user, db, RoomRole.admin)
|
||||
return await list_incoming_webhooks(db, room_id)
|
||||
|
||||
|
||||
@router.delete("/{room_id}/webhooks/incoming/{webhook_id}", status_code=204)
|
||||
async def revoke_incoming_webhook_endpoint(
|
||||
room_id: uuid.UUID,
|
||||
webhook_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_room_role(room_id, current_user, db, RoomRole.admin)
|
||||
try:
|
||||
await revoke_incoming_webhook(db, room_id, webhook_id)
|
||||
except WebhookNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Webhook not found")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{room_id}/event-subscriptions", response_model=EventSubscriptionCreated, status_code=201
|
||||
)
|
||||
async def create_event_subscription_endpoint(
|
||||
room_id: uuid.UUID,
|
||||
data: EventSubscriptionCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_room_role(room_id, current_user, db, RoomRole.admin)
|
||||
try:
|
||||
subscription, secret = await create_event_subscription(
|
||||
db, current_user, room_id, data.event_types, data.target_url
|
||||
)
|
||||
except InvalidEventTypeError:
|
||||
raise HTTPException(status_code=400, detail="Unrecognized event type")
|
||||
except UnsafeWebhookUrlError:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="target_url is not allowed (internal/private address)"
|
||||
)
|
||||
return EventSubscriptionCreated(
|
||||
id=subscription.id,
|
||||
room_id=subscription.room_id,
|
||||
event_types=subscription.event_types,
|
||||
target_url=subscription.target_url,
|
||||
created_by=subscription.created_by,
|
||||
created_at=subscription.created_at,
|
||||
signing_secret=secret,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{room_id}/event-subscriptions", response_model=list[EventSubscriptionRead])
|
||||
async def list_event_subscriptions_endpoint(
|
||||
room_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_room_role(room_id, current_user, db, RoomRole.admin)
|
||||
return await list_event_subscriptions(db, room_id)
|
||||
|
||||
|
||||
@router.delete("/{room_id}/event-subscriptions/{subscription_id}", status_code=204)
|
||||
async def revoke_event_subscription_endpoint(
|
||||
room_id: uuid.UUID,
|
||||
subscription_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_room_role(room_id, current_user, db, RoomRole.admin)
|
||||
try:
|
||||
await revoke_event_subscription(db, room_id, subscription_id)
|
||||
except SubscriptionNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Event subscription not found")
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.schemas.webhook import IncomingWebhookPost
|
||||
from app.services.message_events import broadcast_new_message
|
||||
from app.services.webhook_service import WebhookNotFoundError, post_via_webhook
|
||||
|
||||
router = APIRouter(prefix="/api/webhooks", tags=["webhooks"])
|
||||
|
||||
|
||||
@router.post("/incoming/{token}", status_code=204)
|
||||
async def incoming_webhook_endpoint(
|
||||
token: str,
|
||||
data: IncomingWebhookPost,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
# No auth dependency at all -- the token in the URL is the credential,
|
||||
# per ARCHITECTURE.md's incoming-webhook design.
|
||||
try:
|
||||
message, room, sender = await post_via_webhook(db, token, data.content)
|
||||
except WebhookNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Unknown webhook")
|
||||
|
||||
broadcaster = request.app.state.broadcaster
|
||||
presence = request.app.state.presence
|
||||
await broadcast_new_message(db, broadcaster, presence, room.id, message, sender)
|
||||
@@ -0,0 +1,35 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class BotCreate(BaseModel):
|
||||
username: str = Field(min_length=3, max_length=50)
|
||||
|
||||
|
||||
class BotRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
username: str
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ApiTokenCreate(BaseModel):
|
||||
scopes: list[str] = Field(min_length=1)
|
||||
|
||||
|
||||
class ApiTokenRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
owner_id: uuid.UUID
|
||||
scopes: list[str]
|
||||
last_used_at: datetime | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ApiTokenCreated(ApiTokenRead):
|
||||
token: str
|
||||
@@ -13,3 +13,4 @@ class MessageRead(BaseModel):
|
||||
username: str
|
||||
content: str
|
||||
created_at: datetime
|
||||
edited_at: datetime | None
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class WebhookIncomingCreate(BaseModel):
|
||||
description: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class WebhookIncomingRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
room_id: uuid.UUID
|
||||
token: str
|
||||
created_by: uuid.UUID
|
||||
description: str | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class WebhookIncomingAdminRead(WebhookIncomingRead):
|
||||
room_name: str
|
||||
created_by_username: str
|
||||
|
||||
|
||||
class IncomingWebhookPost(BaseModel):
|
||||
content: str = Field(min_length=1)
|
||||
|
||||
|
||||
class EventSubscriptionCreate(BaseModel):
|
||||
event_types: list[str] = Field(min_length=1)
|
||||
target_url: str = Field(min_length=1, max_length=2048)
|
||||
|
||||
|
||||
class EventSubscriptionRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
room_id: uuid.UUID | None
|
||||
event_types: list[str]
|
||||
target_url: str
|
||||
created_by: uuid.UUID
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class EventSubscriptionCreated(EventSubscriptionRead):
|
||||
signing_secret: str
|
||||
|
||||
|
||||
class EventSubscriptionAdminRead(EventSubscriptionRead):
|
||||
room_name: str | None
|
||||
created_by_username: str
|
||||
@@ -1,3 +1,6 @@
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
|
||||
@@ -13,3 +16,16 @@ def verify_password(password: str, password_hash: str) -> bool:
|
||||
return _hasher.verify(password_hash, password)
|
||||
except VerifyMismatchError:
|
||||
return False
|
||||
|
||||
|
||||
def generate_token() -> str:
|
||||
return f"kit_{secrets.token_urlsafe(32)}"
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
# Deterministic (not argon2) is deliberate: a bearer token has to be
|
||||
# looked up *by itself* (no username to look up first, unlike a
|
||||
# password), and argon2's per-call random salt makes that impossible.
|
||||
# A fast hash of a high-entropy 256-bit random token is the standard
|
||||
# approach for API keys (same as GitHub/Stripe).
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
@@ -2,10 +2,10 @@ import uuid
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import AdminAuditLog, Room, RoomMembership, RoomRole, User
|
||||
from app.models import Room, RoomMembership, RoomRole, User
|
||||
from app.security import hash_password
|
||||
from app.services.audit import record_audit_log
|
||||
|
||||
|
||||
class UserNotFoundError(Exception):
|
||||
@@ -52,25 +52,6 @@ async def _get_membership(
|
||||
return membership
|
||||
|
||||
|
||||
def _log(
|
||||
db: AsyncSession,
|
||||
actor: User,
|
||||
action: str,
|
||||
target_type: str,
|
||||
target_id: uuid.UUID,
|
||||
metadata: dict | None = None,
|
||||
) -> None:
|
||||
db.add(
|
||||
AdminAuditLog(
|
||||
actor_id=actor.id,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
metadata_=metadata,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def list_users(db: AsyncSession) -> list[User]:
|
||||
result = await db.execute(select(User).order_by(User.created_at))
|
||||
return list(result.scalars().all())
|
||||
@@ -83,7 +64,7 @@ async def set_user_active(
|
||||
raise CannotActOnSelfError()
|
||||
user = await _get_user(db, target_user_id)
|
||||
user.is_active = active
|
||||
_log(db, actor, "user.activate" if active else "user.deactivate", "user", user.id)
|
||||
record_audit_log(db, actor, "user.activate" if active else "user.deactivate", "user", user.id)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
@@ -94,7 +75,7 @@ async def reset_user_password(
|
||||
) -> None:
|
||||
user = await _get_user(db, target_user_id)
|
||||
user.password_hash = hash_password(new_password)
|
||||
_log(db, actor, "user.reset_password", "user", user.id)
|
||||
record_audit_log(db, actor, "user.reset_password", "user", user.id)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -105,7 +86,7 @@ async def set_user_site_admin(
|
||||
raise CannotActOnSelfError()
|
||||
user = await _get_user(db, target_user_id)
|
||||
user.is_site_admin = is_admin
|
||||
_log(db, actor, "user.promote" if is_admin else "user.demote", "user", user.id)
|
||||
record_audit_log(db, actor, "user.promote" if is_admin else "user.demote", "user", user.id)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
@@ -126,7 +107,7 @@ async def set_room_archived(
|
||||
) -> Room:
|
||||
room = await _get_room(db, room_id)
|
||||
room.is_archived = archived
|
||||
_log(db, actor, "room.archive" if archived else "room.unarchive", "room", room.id)
|
||||
record_audit_log(db, actor, "room.archive" if archived else "room.unarchive", "room", room.id)
|
||||
await db.commit()
|
||||
await db.refresh(room)
|
||||
return room
|
||||
@@ -145,7 +126,7 @@ async def transfer_ownership_admin(
|
||||
new_owner_membership.role = RoomRole.owner
|
||||
current_owner_membership.role = RoomRole.admin
|
||||
room.owner_id = new_owner_id
|
||||
_log(
|
||||
record_audit_log(
|
||||
db,
|
||||
actor,
|
||||
"room.transfer_ownership",
|
||||
@@ -156,16 +137,3 @@ async def transfer_ownership_admin(
|
||||
await db.commit()
|
||||
await db.refresh(room)
|
||||
return room
|
||||
|
||||
|
||||
async def list_audit_log(
|
||||
db: AsyncSession, limit: int = 50, offset: int = 0
|
||||
) -> list[AdminAuditLog]:
|
||||
result = await db.execute(
|
||||
select(AdminAuditLog)
|
||||
.options(selectinload(AdminAuditLog.actor))
|
||||
.order_by(AdminAuditLog.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import AdminAuditLog, User
|
||||
|
||||
|
||||
def record_audit_log(
|
||||
db: AsyncSession,
|
||||
actor: User,
|
||||
action: str,
|
||||
target_type: str,
|
||||
target_id: uuid.UUID,
|
||||
metadata: dict | None = None,
|
||||
) -> None:
|
||||
db.add(
|
||||
AdminAuditLog(
|
||||
actor_id=actor.id,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
metadata_=metadata,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def list_audit_log(
|
||||
db: AsyncSession, limit: int = 50, offset: int = 0
|
||||
) -> list[AdminAuditLog]:
|
||||
result = await db.execute(
|
||||
select(AdminAuditLog)
|
||||
.options(selectinload(AdminAuditLog.actor))
|
||||
.order_by(AdminAuditLog.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
@@ -0,0 +1,119 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import ApiToken, User
|
||||
from app.security import generate_token, hash_password, hash_token
|
||||
from app.services.audit import record_audit_log
|
||||
|
||||
VALID_SCOPES = {"read:messages", "write:messages", "manage:rooms"}
|
||||
|
||||
|
||||
class DuplicateBotError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class BotNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidScopeError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class TokenNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
async def create_bot(db: AsyncSession, actor: User, username: str) -> User:
|
||||
# Bots never authenticate with a password -- generate one and discard
|
||||
# it. email is NOT NULL/unique today; a placeholder avoids widening the
|
||||
# schema just for accounts that will never receive real mail.
|
||||
# bots.example.com (not e.g. bots.local/.invalid) deliberately, since
|
||||
# pydantic's EmailStr rejects addresses whose *top-level* domain is one
|
||||
# of the IANA special-use TLDs (.local, .invalid, .test, ...) -- a
|
||||
# subdomain of the real (if reserved-for-docs) .com TLD isn't affected.
|
||||
bot = User(
|
||||
username=username,
|
||||
email=f"{username}@bots.example.com",
|
||||
password_hash=hash_password(generate_token()),
|
||||
is_bot=True,
|
||||
)
|
||||
db.add(bot)
|
||||
try:
|
||||
await db.flush()
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
raise DuplicateBotError() from exc
|
||||
|
||||
record_audit_log(db, actor, "bot.create", "user", bot.id)
|
||||
await db.commit()
|
||||
await db.refresh(bot)
|
||||
return bot
|
||||
|
||||
|
||||
async def list_bots(db: AsyncSession) -> list[User]:
|
||||
result = await db.execute(
|
||||
select(User).where(User.is_bot.is_(True)).order_by(User.created_at)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def _get_bot(db: AsyncSession, bot_id: uuid.UUID) -> User:
|
||||
bot = await db.get(User, bot_id)
|
||||
if bot is None or not bot.is_bot:
|
||||
raise BotNotFoundError()
|
||||
return bot
|
||||
|
||||
|
||||
async def create_api_token(
|
||||
db: AsyncSession, actor: User, bot_id: uuid.UUID, scopes: list[str]
|
||||
) -> tuple[ApiToken, str]:
|
||||
bot = await _get_bot(db, bot_id)
|
||||
if not set(scopes) <= VALID_SCOPES:
|
||||
raise InvalidScopeError()
|
||||
|
||||
plaintext = generate_token()
|
||||
token = ApiToken(owner_id=bot.id, token_hash=hash_token(plaintext), scopes=scopes)
|
||||
db.add(token)
|
||||
record_audit_log(
|
||||
db, actor, "bot.issue_token", "user", bot.id, {"scopes": scopes}
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(token)
|
||||
return token, plaintext
|
||||
|
||||
|
||||
async def list_api_tokens(db: AsyncSession, bot_id: uuid.UUID) -> list[ApiToken]:
|
||||
result = await db.execute(
|
||||
select(ApiToken).where(ApiToken.owner_id == bot_id).order_by(ApiToken.created_at)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def revoke_api_token(db: AsyncSession, actor: User, token_id: uuid.UUID) -> None:
|
||||
token = await db.get(ApiToken, token_id)
|
||||
if token is None:
|
||||
raise TokenNotFoundError()
|
||||
await db.delete(token)
|
||||
record_audit_log(db, actor, "bot.revoke_token", "user", token.owner_id)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def resolve_token(db: AsyncSession, plaintext: str) -> tuple[User, ApiToken] | None:
|
||||
result = await db.execute(
|
||||
select(ApiToken)
|
||||
.where(ApiToken.token_hash == hash_token(plaintext))
|
||||
.options(selectinload(ApiToken.owner))
|
||||
)
|
||||
token = result.scalar_one_or_none()
|
||||
if token is None or not token.owner.is_active:
|
||||
return None
|
||||
|
||||
token.last_used_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
return token.owner, token
|
||||
@@ -0,0 +1,79 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Message, Room, RoomMembership, User
|
||||
from app.services.push_service import send_push_to_user
|
||||
from app.services.webhook_service import dispatch_event
|
||||
from app.ws.broadcaster import RoomBroadcaster
|
||||
from app.ws.presence import Presence
|
||||
|
||||
|
||||
async def _notify_offline_members(
|
||||
db: AsyncSession, presence: Presence, room_id: uuid.UUID, sender: User, content: str
|
||||
) -> None:
|
||||
result = await db.execute(
|
||||
select(RoomMembership.user_id).where(RoomMembership.room_id == room_id)
|
||||
)
|
||||
member_ids = {row[0] for row in result.all()}
|
||||
# Subtract the sender explicitly rather than relying on them being
|
||||
# "connected" (true for the WS path, since they just sent this over an
|
||||
# active connection -- not true for the incoming-webhook REST path,
|
||||
# which has no WS connection for the attributed sender at all).
|
||||
offline_ids = member_ids - await presence.connected_user_ids(room_id) - {sender.id}
|
||||
if not offline_ids:
|
||||
return
|
||||
|
||||
room = await db.get(Room, room_id)
|
||||
payload = {
|
||||
"title": f"#{room.name}" if room else "New message",
|
||||
"body": f"{sender.username}: {content}"[:120],
|
||||
"room_id": str(room_id),
|
||||
}
|
||||
for user_id in offline_ids:
|
||||
await send_push_to_user(db, user_id, payload)
|
||||
|
||||
|
||||
def _message_payload(message: Message, username: str) -> dict:
|
||||
return {
|
||||
"type": "message",
|
||||
"id": str(message.id),
|
||||
"room_id": str(message.room_id),
|
||||
"user_id": str(message.user_id),
|
||||
"username": username,
|
||||
"content": message.content,
|
||||
"created_at": message.created_at.isoformat(),
|
||||
"edited_at": message.edited_at.isoformat() if message.edited_at else None,
|
||||
}
|
||||
|
||||
|
||||
async def broadcast_new_message(
|
||||
db: AsyncSession,
|
||||
broadcaster: RoomBroadcaster,
|
||||
presence: Presence,
|
||||
room_id: uuid.UUID,
|
||||
message: Message,
|
||||
sender: User,
|
||||
) -> None:
|
||||
"""The full side-effect sequence for a newly created message, shared by
|
||||
the WS "message" handler and the incoming-webhook receiver so both
|
||||
trigger identical fan-out/push/event behavior."""
|
||||
payload = _message_payload(message, sender.username)
|
||||
await broadcaster.publish(room_id, payload)
|
||||
await _notify_offline_members(db, presence, room_id, sender, message.content)
|
||||
await dispatch_event(db, "message.created", room_id, payload)
|
||||
|
||||
|
||||
async def broadcast_message_update(
|
||||
db: AsyncSession, broadcaster: RoomBroadcaster, room_id: uuid.UUID, message: Message
|
||||
) -> None:
|
||||
payload = {
|
||||
"type": "message_update",
|
||||
"id": str(message.id),
|
||||
"room_id": str(room_id),
|
||||
"content": message.content,
|
||||
"edited_at": message.edited_at.isoformat() if message.edited_at else None,
|
||||
}
|
||||
await broadcaster.publish(room_id, payload)
|
||||
await dispatch_event(db, "message.updated", room_id, payload)
|
||||
@@ -1,4 +1,5 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -7,6 +8,14 @@ from sqlalchemy.orm import selectinload
|
||||
from app.models import Message
|
||||
|
||||
|
||||
class MessageNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NotMessageAuthorError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
async def create_message(
|
||||
db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID, content: str
|
||||
) -> Message:
|
||||
@@ -17,6 +26,22 @@ async def create_message(
|
||||
return message
|
||||
|
||||
|
||||
async def edit_message(
|
||||
db: AsyncSession, message_id: uuid.UUID, editor_id: uuid.UUID, content: str
|
||||
) -> Message:
|
||||
message = await db.get(Message, message_id)
|
||||
if message is None:
|
||||
raise MessageNotFoundError()
|
||||
if message.user_id != editor_id:
|
||||
raise NotMessageAuthorError()
|
||||
|
||||
message.content = content
|
||||
message.edited_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
return message
|
||||
|
||||
|
||||
async def list_recent_messages(
|
||||
db: AsyncSession, room_id: uuid.UUID, limit: int = 50
|
||||
) -> list[Message]:
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import ipaddress
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
class UnsafeWebhookUrlError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def validate_target_url(url: str) -> None:
|
||||
"""Creation-time-only SSRF check: rejects non-http(s) schemes and any
|
||||
target whose hostname resolves to a private/loopback/link-local/
|
||||
reserved/multicast address. Not re-checked per delivery, so this doesn't
|
||||
defend against DNS rebinding between creation and a later send -- a
|
||||
documented known limitation, not an oversight.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise UnsafeWebhookUrlError()
|
||||
if not parsed.hostname:
|
||||
raise UnsafeWebhookUrlError()
|
||||
|
||||
try:
|
||||
addrinfo = socket.getaddrinfo(parsed.hostname, None)
|
||||
except socket.gaierror as exc:
|
||||
raise UnsafeWebhookUrlError() from exc
|
||||
|
||||
for *_rest, sockaddr in addrinfo:
|
||||
ip = ipaddress.ip_address(sockaddr[0])
|
||||
if (
|
||||
ip.is_private
|
||||
or ip.is_loopback
|
||||
or ip.is_link_local
|
||||
or ip.is_reserved
|
||||
or ip.is_multicast
|
||||
or ip.is_unspecified
|
||||
):
|
||||
raise UnsafeWebhookUrlError()
|
||||
@@ -0,0 +1,41 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from app.models import EventSubscription
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
|
||||
async def deliver_event(subscription: EventSubscription, event_type: str, payload: dict) -> None:
|
||||
"""POST a signed event to a subscription's target_url.
|
||||
|
||||
Fire-once, best-effort: no retry/backoff, any failure is logged and
|
||||
swallowed rather than raised -- a slow or dead third-party endpoint must
|
||||
never affect message delivery to real room members. Safe to run in a
|
||||
background asyncio.create_task (unlike the Phase 4 push lesson) because
|
||||
there's no DB session involved here, just the already-serialized
|
||||
payload and secret -- nothing that can outlive an event loop.
|
||||
"""
|
||||
body = json.dumps({"event": event_type, "data": payload}).encode()
|
||||
signature = hmac.new(subscription.signing_secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT_SECONDS) as client:
|
||||
await client.post(
|
||||
subscription.target_url,
|
||||
content=body,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-KeepItTalking-Signature": f"sha256={signature}",
|
||||
},
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
logger.warning(
|
||||
"Failed to deliver %s event to subscription %s", event_type, subscription.id
|
||||
)
|
||||
@@ -0,0 +1,153 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import EventSubscription, Message, Room, User, WebhookIncoming
|
||||
from app.security import generate_token
|
||||
from app.services.message_service import create_message
|
||||
from app.services.ssrf import validate_target_url
|
||||
from app.services.webhook_delivery import deliver_event
|
||||
|
||||
VALID_EVENT_TYPES = {"message.created", "message.updated"}
|
||||
|
||||
|
||||
class WebhookNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidEventTypeError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SubscriptionNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
async def create_incoming_webhook(
|
||||
db: AsyncSession, actor: User, room_id: uuid.UUID, description: str | None
|
||||
) -> WebhookIncoming:
|
||||
webhook = WebhookIncoming(
|
||||
room_id=room_id, token=generate_token(), created_by=actor.id, description=description
|
||||
)
|
||||
db.add(webhook)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
# A token collision is astronomically unlikely (256 bits of
|
||||
# randomness) -- surface it rather than silently masking it.
|
||||
await db.rollback()
|
||||
raise
|
||||
await db.refresh(webhook)
|
||||
return webhook
|
||||
|
||||
|
||||
async def list_incoming_webhooks(db: AsyncSession, room_id: uuid.UUID) -> list[WebhookIncoming]:
|
||||
result = await db.execute(
|
||||
select(WebhookIncoming)
|
||||
.where(WebhookIncoming.room_id == room_id)
|
||||
.order_by(WebhookIncoming.created_at)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def revoke_incoming_webhook(
|
||||
db: AsyncSession, room_id: uuid.UUID, webhook_id: uuid.UUID
|
||||
) -> None:
|
||||
webhook = await db.get(WebhookIncoming, webhook_id)
|
||||
if webhook is None or webhook.room_id != room_id:
|
||||
raise WebhookNotFoundError()
|
||||
await db.delete(webhook)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def list_all_incoming_webhooks_admin(db: AsyncSession) -> list[WebhookIncoming]:
|
||||
result = await db.execute(
|
||||
select(WebhookIncoming)
|
||||
.options(selectinload(WebhookIncoming.room), selectinload(WebhookIncoming.creator))
|
||||
.order_by(WebhookIncoming.created_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def post_via_webhook(db: AsyncSession, token: str, content: str) -> tuple[Message, Room, User]:
|
||||
result = await db.execute(
|
||||
select(WebhookIncoming)
|
||||
.where(WebhookIncoming.token == token)
|
||||
.options(selectinload(WebhookIncoming.room), selectinload(WebhookIncoming.creator))
|
||||
)
|
||||
webhook = result.scalar_one_or_none()
|
||||
if webhook is None:
|
||||
raise WebhookNotFoundError()
|
||||
|
||||
message = await create_message(db, webhook.room_id, webhook.created_by, content)
|
||||
return message, webhook.room, webhook.creator
|
||||
|
||||
|
||||
async def create_event_subscription(
|
||||
db: AsyncSession,
|
||||
actor: User,
|
||||
room_id: uuid.UUID | None,
|
||||
event_types: list[str],
|
||||
target_url: str,
|
||||
) -> tuple[EventSubscription, str]:
|
||||
if not set(event_types) <= VALID_EVENT_TYPES:
|
||||
raise InvalidEventTypeError()
|
||||
validate_target_url(target_url)
|
||||
|
||||
secret = generate_token()
|
||||
subscription = EventSubscription(
|
||||
room_id=room_id,
|
||||
event_types=event_types,
|
||||
target_url=target_url,
|
||||
signing_secret=secret,
|
||||
created_by=actor.id,
|
||||
)
|
||||
db.add(subscription)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
return subscription, secret
|
||||
|
||||
|
||||
async def list_event_subscriptions(db: AsyncSession, room_id: uuid.UUID) -> list[EventSubscription]:
|
||||
result = await db.execute(
|
||||
select(EventSubscription)
|
||||
.where(EventSubscription.room_id == room_id)
|
||||
.order_by(EventSubscription.created_at)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def revoke_event_subscription(
|
||||
db: AsyncSession, room_id: uuid.UUID, subscription_id: uuid.UUID
|
||||
) -> None:
|
||||
subscription = await db.get(EventSubscription, subscription_id)
|
||||
if subscription is None or subscription.room_id != room_id:
|
||||
raise SubscriptionNotFoundError()
|
||||
await db.delete(subscription)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def list_all_event_subscriptions_admin(db: AsyncSession) -> list[EventSubscription]:
|
||||
result = await db.execute(
|
||||
select(EventSubscription)
|
||||
.options(selectinload(EventSubscription.room), selectinload(EventSubscription.creator))
|
||||
.order_by(EventSubscription.created_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def dispatch_event(
|
||||
db: AsyncSession, event_type: str, room_id: uuid.UUID, payload: dict
|
||||
) -> None:
|
||||
result = await db.execute(
|
||||
select(EventSubscription).where(
|
||||
or_(EventSubscription.room_id == room_id, EventSubscription.room_id.is_(None))
|
||||
)
|
||||
)
|
||||
for subscription in result.scalars().all():
|
||||
if event_type in subscription.event_types:
|
||||
asyncio.create_task(deliver_event(subscription, event_type, payload))
|
||||
+69
-51
@@ -6,10 +6,15 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Room, RoomMembership, User
|
||||
from app.services.message_service import create_message
|
||||
from app.services.push_service import send_push_to_user
|
||||
from app.ws.presence import Presence
|
||||
from app.models import ApiToken, RoomMembership, User
|
||||
from app.services.bot_service import resolve_token
|
||||
from app.services.message_events import broadcast_message_update, broadcast_new_message
|
||||
from app.services.message_service import (
|
||||
MessageNotFoundError,
|
||||
NotMessageAuthorError,
|
||||
create_message,
|
||||
edit_message,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["ws"])
|
||||
|
||||
@@ -20,6 +25,7 @@ class ClientEnvelope(BaseModel):
|
||||
type: str
|
||||
room_id: uuid.UUID | None = None
|
||||
content: str | None = None
|
||||
message_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
async def _is_room_member(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> bool:
|
||||
@@ -31,46 +37,37 @@ async def _is_room_member(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UU
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def _notify_offline_members(
|
||||
db: AsyncSession,
|
||||
presence: Presence,
|
||||
room_id: uuid.UUID,
|
||||
sender: User,
|
||||
content: str,
|
||||
) -> None:
|
||||
result = await db.execute(
|
||||
select(RoomMembership.user_id).where(RoomMembership.room_id == room_id)
|
||||
)
|
||||
member_ids = {row[0] for row in result.all()}
|
||||
offline_ids = member_ids - await presence.connected_user_ids(room_id)
|
||||
if not offline_ids:
|
||||
return
|
||||
|
||||
room = await db.get(Room, room_id)
|
||||
payload = {
|
||||
"title": f"#{room.name}" if room else "New message",
|
||||
"body": f"{sender.username}: {content}"[:120],
|
||||
"room_id": str(room_id),
|
||||
}
|
||||
for user_id in offline_ids:
|
||||
await send_push_to_user(db, user_id, payload)
|
||||
def _missing_scope(api_token: ApiToken | None, scope: str) -> bool:
|
||||
return api_token is not None and scope not in api_token.scopes
|
||||
|
||||
|
||||
@router.websocket("/ws/chat")
|
||||
async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)) -> None:
|
||||
user_id_raw = websocket.session.get("user_id")
|
||||
if not user_id_raw:
|
||||
await websocket.close(code=WS_UNAUTHENTICATED)
|
||||
return
|
||||
api_token: ApiToken | None = None
|
||||
|
||||
user = await db.get(User, uuid.UUID(user_id_raw))
|
||||
if user is None:
|
||||
await websocket.close(code=WS_UNAUTHENTICATED)
|
||||
return
|
||||
# Bots authenticate by setting Authorization on the WS handshake itself
|
||||
# (not a browser cookie) -- same connection type/endpoint a human client
|
||||
# uses, just a different credential.
|
||||
auth_header = websocket.headers.get("authorization")
|
||||
if auth_header and auth_header.lower().startswith("bearer "):
|
||||
resolved = await resolve_token(db, auth_header[len("bearer ") :].strip())
|
||||
if resolved is None:
|
||||
await websocket.close(code=WS_UNAUTHENTICATED)
|
||||
return
|
||||
user, api_token = resolved
|
||||
else:
|
||||
user_id_raw = websocket.session.get("user_id")
|
||||
if not user_id_raw:
|
||||
await websocket.close(code=WS_UNAUTHENTICATED)
|
||||
return
|
||||
user = await db.get(User, uuid.UUID(user_id_raw))
|
||||
if user is None or not user.is_active:
|
||||
await websocket.close(code=WS_UNAUTHENTICATED)
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
manager = websocket.app.state.connection_manager
|
||||
presence: Presence = websocket.app.state.presence
|
||||
presence = websocket.app.state.presence
|
||||
broadcaster = websocket.app.state.broadcaster
|
||||
joined_rooms: set[uuid.UUID] = set()
|
||||
|
||||
@@ -111,6 +108,11 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
|
||||
{"type": "error", "detail": "room_id and content required"}
|
||||
)
|
||||
continue
|
||||
if _missing_scope(api_token, "write:messages"):
|
||||
await websocket.send_json(
|
||||
{"type": "error", "detail": "Token missing required scope: write:messages"}
|
||||
)
|
||||
continue
|
||||
if envelope.room_id not in joined_rooms or not await _is_room_member(
|
||||
db, envelope.room_id, user.id
|
||||
):
|
||||
@@ -119,21 +121,37 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
|
||||
)
|
||||
continue
|
||||
message = await create_message(db, envelope.room_id, user.id, envelope.content)
|
||||
await broadcaster.publish(
|
||||
envelope.room_id,
|
||||
{
|
||||
"type": "message",
|
||||
"id": str(message.id),
|
||||
"room_id": str(message.room_id),
|
||||
"user_id": str(message.user_id),
|
||||
"username": user.username,
|
||||
"content": message.content,
|
||||
"created_at": message.created_at.isoformat(),
|
||||
},
|
||||
)
|
||||
await _notify_offline_members(
|
||||
db, presence, envelope.room_id, user, envelope.content
|
||||
)
|
||||
await broadcast_new_message(db, broadcaster, presence, envelope.room_id, message, user)
|
||||
|
||||
elif envelope.type == "edit":
|
||||
if envelope.room_id is None or envelope.message_id is None or not envelope.content:
|
||||
await websocket.send_json(
|
||||
{"type": "error", "detail": "room_id, message_id, and content required"}
|
||||
)
|
||||
continue
|
||||
if _missing_scope(api_token, "write:messages"):
|
||||
await websocket.send_json(
|
||||
{"type": "error", "detail": "Token missing required scope: write:messages"}
|
||||
)
|
||||
continue
|
||||
if envelope.room_id not in joined_rooms or not await _is_room_member(
|
||||
db, envelope.room_id, user.id
|
||||
):
|
||||
await websocket.send_json(
|
||||
{"type": "error", "detail": "Not a member of this room"}
|
||||
)
|
||||
continue
|
||||
try:
|
||||
message = await edit_message(db, envelope.message_id, user.id, envelope.content)
|
||||
except MessageNotFoundError:
|
||||
await websocket.send_json({"type": "error", "detail": "Message not found"})
|
||||
continue
|
||||
except NotMessageAuthorError:
|
||||
await websocket.send_json(
|
||||
{"type": "error", "detail": "You can only edit your own messages"}
|
||||
)
|
||||
continue
|
||||
await broadcast_message_update(db, broadcaster, envelope.room_id, message)
|
||||
|
||||
else:
|
||||
await websocket.send_json(
|
||||
|
||||
@@ -16,6 +16,7 @@ dependencies = [
|
||||
"itsdangerous>=2.2",
|
||||
"pywebpush>=2.0",
|
||||
"redis>=5.0",
|
||||
"httpx>=0.27",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -25,7 +26,6 @@ chatapp-create-user = "app.cli:main"
|
||||
dev = [
|
||||
"pytest>=8",
|
||||
"pytest-asyncio>=0.24",
|
||||
"httpx>=0.27",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import AdminAuditLog, User
|
||||
from tests.conftest import login_as, register_and_login
|
||||
|
||||
|
||||
async def _make_admin(db_session, user_id: str) -> None:
|
||||
user = await db_session.get(User, uuid.UUID(user_id))
|
||||
user.is_site_admin = True
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
async def test_create_bot_requires_site_admin(client, db_session):
|
||||
await register_and_login(client, db_session, username="alice")
|
||||
resp = await client.post("/api/admin/bots", json={"username": "helper-bot"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_create_bot_and_issue_token(client, db_session):
|
||||
admin = await register_and_login(client, db_session, username="admin1")
|
||||
await _make_admin(db_session, admin["id"])
|
||||
|
||||
bot_resp = await client.post("/api/admin/bots", json={"username": "helper-bot"})
|
||||
assert bot_resp.status_code == 201
|
||||
bot = bot_resp.json()
|
||||
assert bot["username"] == "helper-bot"
|
||||
assert bot["is_active"] is True
|
||||
|
||||
token_resp = await client.post(
|
||||
f"/api/admin/bots/{bot['id']}/tokens",
|
||||
json={"scopes": ["read:messages", "write:messages"]},
|
||||
)
|
||||
assert token_resp.status_code == 201
|
||||
token = token_resp.json()
|
||||
assert token["token"].startswith("kit_")
|
||||
assert token["scopes"] == ["read:messages", "write:messages"]
|
||||
|
||||
result = await db_session.execute(
|
||||
select(AdminAuditLog).where(AdminAuditLog.target_id == uuid.UUID(bot["id"]))
|
||||
)
|
||||
actions = {e.action for e in result.scalars().all()}
|
||||
assert actions == {"bot.create", "bot.issue_token"}
|
||||
|
||||
|
||||
async def test_create_token_rejects_unknown_scope(client, db_session):
|
||||
admin = await register_and_login(client, db_session, username="admin1")
|
||||
await _make_admin(db_session, admin["id"])
|
||||
bot = (await client.post("/api/admin/bots", json={"username": "helper-bot"})).json()
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/admin/bots/{bot['id']}/tokens", json={"scopes": ["delete:everything"]}
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
async def test_list_bots_includes_created_bot(client, db_session):
|
||||
admin = await register_and_login(client, db_session, username="admin1")
|
||||
await _make_admin(db_session, admin["id"])
|
||||
await client.post("/api/admin/bots", json={"username": "helper-bot"})
|
||||
|
||||
resp = await client.get("/api/admin/bots")
|
||||
assert resp.status_code == 200
|
||||
usernames = {b["username"] for b in resp.json()}
|
||||
assert "helper-bot" in usernames
|
||||
|
||||
|
||||
async def test_revoke_token_invalidates_it(client, db_session):
|
||||
admin = await register_and_login(client, db_session, username="admin1")
|
||||
await _make_admin(db_session, admin["id"])
|
||||
bot = (await client.post("/api/admin/bots", json={"username": "helper-bot"})).json()
|
||||
token = (
|
||||
await client.post(f"/api/admin/bots/{bot['id']}/tokens", json={"scopes": ["read:messages"]})
|
||||
).json()
|
||||
|
||||
revoke_resp = await client.delete(f"/api/admin/bots/tokens/{token['id']}")
|
||||
assert revoke_resp.status_code == 204
|
||||
|
||||
# A revoked token should no longer authenticate anything.
|
||||
resp = await client.get(
|
||||
"/api/rooms", headers={"Authorization": f"Bearer {token['token']}"}
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_invalid_bearer_token_rejected(client):
|
||||
resp = await client.get("/api/rooms", headers={"Authorization": "Bearer kit_not-a-real-token"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def _unique(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _register_ws(ws_client, username: str) -> dict:
|
||||
from app.schemas.user import UserCreate
|
||||
from app.services.auth_service import register_user
|
||||
|
||||
async def _seed():
|
||||
async with ws_client.session_factory() as session:
|
||||
await register_user(
|
||||
session,
|
||||
UserCreate(username=username, email=f"{username}@example.com", password="password123"),
|
||||
)
|
||||
|
||||
ws_client.portal.call(_seed)
|
||||
resp = ws_client.post(
|
||||
"/api/auth/login", json={"username_or_email": username, "password": "password123"}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _make_admin_ws(ws_client, user_id: str) -> None:
|
||||
async def _promote():
|
||||
async with ws_client.session_factory() as session:
|
||||
user = await session.get(User, uuid.UUID(user_id))
|
||||
user.is_site_admin = True
|
||||
await session.commit()
|
||||
|
||||
ws_client.portal.call(_promote)
|
||||
|
||||
|
||||
def test_bot_ws_message_with_write_scope_succeeds(ws_client_factory):
|
||||
ws_client = ws_client_factory()
|
||||
admin = _register_ws(ws_client, _unique("admin"))
|
||||
_make_admin_ws(ws_client, admin["id"])
|
||||
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
bot = ws_client.post("/api/admin/bots", json={"username": _unique("bot")}).json()
|
||||
token = ws_client.post(
|
||||
f"/api/admin/bots/{bot['id']}/tokens", json={"scopes": ["read:messages", "write:messages"]}
|
||||
).json()["token"]
|
||||
|
||||
# Bot joins the room via REST using its own bearer token -- exercises
|
||||
# bearer-token auth on the plain REST path, not just WS.
|
||||
join_resp = ws_client.post(
|
||||
f"/api/rooms/{room['id']}/join", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert join_resp.status_code == 200
|
||||
|
||||
with ws_client.websocket_connect(
|
||||
"/ws/chat", headers={"Authorization": f"Bearer {token}"}
|
||||
) as ws:
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello from bot"})
|
||||
message = ws.receive_json()
|
||||
assert message["type"] == "message"
|
||||
assert message["content"] == "hello from bot"
|
||||
assert message["username"] == bot["username"]
|
||||
|
||||
|
||||
def test_bot_ws_message_without_write_scope_rejected(ws_client_factory):
|
||||
ws_client = ws_client_factory()
|
||||
admin = _register_ws(ws_client, _unique("admin"))
|
||||
_make_admin_ws(ws_client, admin["id"])
|
||||
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
bot = ws_client.post("/api/admin/bots", json={"username": _unique("bot")}).json()
|
||||
token = ws_client.post(
|
||||
f"/api/admin/bots/{bot['id']}/tokens", json={"scopes": ["read:messages"]}
|
||||
).json()["token"]
|
||||
|
||||
ws_client.post(f"/api/rooms/{room['id']}/join", headers={"Authorization": f"Bearer {token}"})
|
||||
|
||||
with ws_client.websocket_connect(
|
||||
"/ws/chat", headers={"Authorization": f"Bearer {token}"}
|
||||
) as ws:
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||
resp = ws.receive_json()
|
||||
assert resp["type"] == "error"
|
||||
assert "write:messages" in resp["detail"]
|
||||
|
||||
|
||||
def test_bot_rest_message_history_requires_read_scope(ws_client_factory):
|
||||
ws_client = ws_client_factory()
|
||||
admin = _register_ws(ws_client, _unique("admin"))
|
||||
_make_admin_ws(ws_client, admin["id"])
|
||||
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
bot = ws_client.post("/api/admin/bots", json={"username": _unique("bot")}).json()
|
||||
write_only_token = ws_client.post(
|
||||
f"/api/admin/bots/{bot['id']}/tokens", json={"scopes": ["write:messages"]}
|
||||
).json()["token"]
|
||||
read_token = ws_client.post(
|
||||
f"/api/admin/bots/{bot['id']}/tokens", json={"scopes": ["read:messages"]}
|
||||
).json()["token"]
|
||||
|
||||
ws_client.post(
|
||||
f"/api/rooms/{room['id']}/join", headers={"Authorization": f"Bearer {write_only_token}"}
|
||||
)
|
||||
|
||||
no_scope_resp = ws_client.get(
|
||||
f"/api/rooms/{room['id']}/messages",
|
||||
headers={"Authorization": f"Bearer {write_only_token}"},
|
||||
)
|
||||
assert no_scope_resp.status_code == 403
|
||||
|
||||
with_scope_resp = ws_client.get(
|
||||
f"/api/rooms/{room['id']}/messages", headers={"Authorization": f"Bearer {read_token}"}
|
||||
)
|
||||
assert with_scope_resp.status_code == 200
|
||||
@@ -0,0 +1,130 @@
|
||||
import uuid
|
||||
|
||||
from app.schemas.user import UserCreate
|
||||
from app.services.auth_service import register_user
|
||||
|
||||
|
||||
def _unique(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _register_ws(ws_client, username: str) -> dict:
|
||||
async def _seed():
|
||||
async with ws_client.session_factory() as session:
|
||||
await register_user(
|
||||
session,
|
||||
UserCreate(username=username, email=f"{username}@example.com", password="password123"),
|
||||
)
|
||||
|
||||
ws_client.portal.call(_seed)
|
||||
resp = ws_client.post(
|
||||
"/api/auth/login", json={"username_or_email": username, "password": "password123"}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
def test_ws_edit_updates_content_and_broadcasts(ws_client):
|
||||
username = _unique("alice")
|
||||
_register_ws(ws_client, username=username)
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
|
||||
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||
message = ws.receive_json()
|
||||
assert message["edited_at"] is None
|
||||
|
||||
ws.send_json(
|
||||
{
|
||||
"type": "edit",
|
||||
"room_id": room["id"],
|
||||
"message_id": message["id"],
|
||||
"content": "hello, edited",
|
||||
}
|
||||
)
|
||||
update = ws.receive_json()
|
||||
assert update["type"] == "message_update"
|
||||
assert update["id"] == message["id"]
|
||||
assert update["content"] == "hello, edited"
|
||||
assert update["edited_at"] is not None
|
||||
|
||||
resp = ws_client.get(f"/api/rooms/{room['id']}/messages")
|
||||
history = resp.json()
|
||||
edited = next(m for m in history if m["id"] == message["id"])
|
||||
assert edited["content"] == "hello, edited"
|
||||
assert edited["edited_at"] is not None
|
||||
|
||||
|
||||
def test_ws_edit_rejects_non_author(ws_client):
|
||||
alice = _register_ws(ws_client, username=_unique("alice"))
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
bob = _register_ws(ws_client, username=_unique("bob"))
|
||||
ws_client.post(f"/api/rooms/{room['id']}/join")
|
||||
|
||||
ws_client.post(
|
||||
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
|
||||
)
|
||||
with ws_client.websocket_connect("/ws/chat") as alice_ws:
|
||||
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert alice_ws.receive_json()["type"] == "joined"
|
||||
alice_ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||
message = alice_ws.receive_json()
|
||||
|
||||
ws_client.post(
|
||||
"/api/auth/login",
|
||||
json={"username_or_email": bob["username"], "password": "password123"},
|
||||
)
|
||||
with ws_client.websocket_connect("/ws/chat") as bob_ws:
|
||||
bob_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert bob_ws.receive_json()["type"] == "joined"
|
||||
bob_ws.send_json(
|
||||
{
|
||||
"type": "edit",
|
||||
"room_id": room["id"],
|
||||
"message_id": message["id"],
|
||||
"content": "hacked",
|
||||
}
|
||||
)
|
||||
resp = bob_ws.receive_json()
|
||||
assert resp["type"] == "error"
|
||||
assert "own messages" in resp["detail"]
|
||||
|
||||
|
||||
def test_edit_fans_out_across_instances(ws_client_factory):
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
alice = _register_ws(instance1, _unique("alice"))
|
||||
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
bob = _register_ws(instance2, _unique("bob"))
|
||||
instance2.post(f"/api/rooms/{room['id']}/join")
|
||||
|
||||
with instance2.websocket_connect("/ws/chat") as bob_ws:
|
||||
bob_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert bob_ws.receive_json()["type"] == "joined"
|
||||
|
||||
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
||||
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert alice_ws.receive_json()["type"] == "joined"
|
||||
alice_ws.send_json({"type": "message", "room_id": room["id"], "content": "hi"})
|
||||
message = alice_ws.receive_json()
|
||||
assert bob_ws.receive_json()["type"] == "message"
|
||||
|
||||
alice_ws.send_json(
|
||||
{
|
||||
"type": "edit",
|
||||
"room_id": room["id"],
|
||||
"message_id": message["id"],
|
||||
"content": "hi, edited",
|
||||
}
|
||||
)
|
||||
assert alice_ws.receive_json()["type"] == "message_update"
|
||||
|
||||
update = bob_ws.receive_json()
|
||||
assert update["type"] == "message_update"
|
||||
assert update["content"] == "hi, edited"
|
||||
@@ -0,0 +1,164 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models import PushSubscription
|
||||
from app.schemas.user import UserCreate
|
||||
from app.services.auth_service import register_user
|
||||
from app.services.room_service import join_room
|
||||
from app.services.ssrf import UnsafeWebhookUrlError, validate_target_url
|
||||
from tests.conftest import register_and_login
|
||||
|
||||
|
||||
def test_validate_target_url_rejects_loopback():
|
||||
with pytest.raises(UnsafeWebhookUrlError):
|
||||
validate_target_url("http://127.0.0.1/hook")
|
||||
|
||||
|
||||
def test_validate_target_url_rejects_private_range():
|
||||
with pytest.raises(UnsafeWebhookUrlError):
|
||||
validate_target_url("http://10.0.0.5/hook")
|
||||
|
||||
|
||||
def test_validate_target_url_rejects_non_http_scheme():
|
||||
with pytest.raises(UnsafeWebhookUrlError):
|
||||
validate_target_url("ftp://8.8.8.8/hook")
|
||||
|
||||
|
||||
def test_validate_target_url_accepts_public_address():
|
||||
# 8.8.8.8 is a stable, well-known public IP (Google's public DNS
|
||||
# resolver) -- a literal IP so this resolves without any real network
|
||||
# access (getaddrinfo parses a literal IP without touching DNS/the
|
||||
# network), and it isn't flagged by any of ipaddress's private/
|
||||
# reserved/loopback/etc checks, so it exercises the "allowed" path.
|
||||
# (203.0.113.0/24, the usual RFC 5737 documentation-only choice, is
|
||||
# actually flagged is_private by Python's ipaddress module -- not
|
||||
# usable here.)
|
||||
validate_target_url("http://8.8.8.8/hook")
|
||||
|
||||
|
||||
async def test_event_subscription_rejects_private_target(client, db_session):
|
||||
await register_and_login(client, db_session, username="alice")
|
||||
room = (await client.post("/api/rooms", json={"name": "general"})).json()
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/rooms/{room['id']}/event-subscriptions",
|
||||
json={"event_types": ["message.created"], "target_url": "http://127.0.0.1/hook"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
async def test_incoming_webhook_unknown_token_404s(client, db_session):
|
||||
await register_and_login(client, db_session, username="alice")
|
||||
resp = await client.post("/api/webhooks/incoming/not-a-real-token", json={"content": "hi"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
async def test_incoming_webhook_posts_message_and_pushes_offline_members(
|
||||
client, db_session, monkeypatch
|
||||
):
|
||||
calls = []
|
||||
monkeypatch.setattr("app.services.push_service.webpush", lambda **kw: calls.append(kw))
|
||||
|
||||
alice = await register_and_login(client, db_session, username="alice")
|
||||
room = (await client.post("/api/rooms", json={"name": "general"})).json()
|
||||
|
||||
webhook_resp = await client.post(
|
||||
f"/api/rooms/{room['id']}/webhooks/incoming", json={"description": "CI bot"}
|
||||
)
|
||||
assert webhook_resp.status_code == 201
|
||||
webhook = webhook_resp.json()
|
||||
assert webhook["token"]
|
||||
|
||||
bob = await register_user(
|
||||
db_session, UserCreate(username="bob", email="bob@example.com", password="password123")
|
||||
)
|
||||
await join_room(db_session, uuid.UUID(room["id"]), bob.id)
|
||||
db_session.add(
|
||||
PushSubscription(
|
||||
user_id=bob.id,
|
||||
endpoint="https://push.example.com/bob",
|
||||
p256dh_key="p256dh",
|
||||
auth_key="auth",
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
post_resp = await client.post(
|
||||
f"/api/webhooks/incoming/{webhook['token']}", json={"content": "deploy succeeded"}
|
||||
)
|
||||
assert post_resp.status_code == 204
|
||||
|
||||
history = (await client.get(f"/api/rooms/{room['id']}/messages")).json()
|
||||
assert any(m["content"] == "deploy succeeded" for m in history)
|
||||
|
||||
# Exactly one push -- to bob. If the sender (webhook creator, alice) were
|
||||
# incorrectly included in "offline members" (no WS connection exists for
|
||||
# either party in this REST-only test), this would be 2.
|
||||
assert len(calls) == 1
|
||||
assert "deploy succeeded" in calls[0]["data"]
|
||||
|
||||
|
||||
async def test_outgoing_webhook_delivers_signed_payload(client, db_session, monkeypatch):
|
||||
captured_tasks: list[asyncio.Task] = []
|
||||
real_create_task = asyncio.create_task
|
||||
|
||||
def fake_create_task(coro):
|
||||
task = real_create_task(coro)
|
||||
captured_tasks.append(task)
|
||||
return task
|
||||
|
||||
monkeypatch.setattr("app.services.webhook_service.asyncio.create_task", fake_create_task)
|
||||
|
||||
posts = []
|
||||
|
||||
class FakeResponse:
|
||||
status_code = 200
|
||||
|
||||
class FakeAsyncClient:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
return False
|
||||
|
||||
async def post(self, url, content=None, headers=None):
|
||||
posts.append({"url": url, "content": content, "headers": headers})
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setattr("app.services.webhook_delivery.httpx.AsyncClient", FakeAsyncClient)
|
||||
|
||||
await register_and_login(client, db_session, username="alice")
|
||||
room = (await client.post("/api/rooms", json={"name": "general"})).json()
|
||||
|
||||
sub_resp = await client.post(
|
||||
f"/api/rooms/{room['id']}/event-subscriptions",
|
||||
json={"event_types": ["message.created"], "target_url": "http://8.8.8.8/hook"},
|
||||
)
|
||||
assert sub_resp.status_code == 201
|
||||
secret = sub_resp.json()["signing_secret"]
|
||||
|
||||
webhook = (
|
||||
await client.post(f"/api/rooms/{room['id']}/webhooks/incoming", json={})
|
||||
).json()
|
||||
resp = await client.post(
|
||||
f"/api/webhooks/incoming/{webhook['token']}", json={"content": "ping"}
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
await asyncio.gather(*captured_tasks)
|
||||
|
||||
assert len(posts) == 1
|
||||
body = posts[0]["content"]
|
||||
expected_signature = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
assert posts[0]["headers"]["X-KeepItTalking-Signature"] == f"sha256={expected_signature}"
|
||||
payload = json.loads(body)
|
||||
assert payload["event"] == "message.created"
|
||||
assert payload["data"]["content"] == "ping"
|
||||
Reference in New Issue
Block a user