diff --git a/backend/README.md b/backend/README.md index 9eb0eb5..dbf28a8 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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) diff --git a/backend/alembic/versions/3350c67553ad_bots_and_webhooks.py b/backend/alembic/versions/3350c67553ad_bots_and_webhooks.py new file mode 100644 index 0000000..7aa914e --- /dev/null +++ b/backend/alembic/versions/3350c67553ad_bots_and_webhooks.py @@ -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 ### diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index c882077..8bc6752 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -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: diff --git a/backend/app/main.py b/backend/app/main.py index e3e83ce..8cb901d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index e5954fb..49fc152 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/api_token.py b/backend/app/models/api_token.py new file mode 100644 index 0000000..3b752cd --- /dev/null +++ b/backend/app/models/api_token.py @@ -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") diff --git a/backend/app/models/event_subscription.py b/backend/app/models/event_subscription.py new file mode 100644 index 0000000..4cdb4b8 --- /dev/null +++ b/backend/app/models/event_subscription.py @@ -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") diff --git a/backend/app/models/webhook_incoming.py b/backend/app/models/webhook_incoming.py new file mode 100644 index 0000000..8e35ca2 --- /dev/null +++ b/backend/app/models/webhook_incoming.py @@ -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") diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index f451626..72ddedb 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -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 + ] diff --git a/backend/app/routers/bots.py b/backend/app/routers/bots.py new file mode 100644 index 0000000..d3860e7 --- /dev/null +++ b/backend/app/routers/bots.py @@ -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") diff --git a/backend/app/routers/rooms.py b/backend/app/routers/rooms.py index eaba35b..dd7ba01 100644 --- a/backend/app/routers/rooms.py +++ b/backend/app/routers/rooms.py @@ -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") diff --git a/backend/app/routers/webhooks.py b/backend/app/routers/webhooks.py new file mode 100644 index 0000000..4c46e0f --- /dev/null +++ b/backend/app/routers/webhooks.py @@ -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) diff --git a/backend/app/schemas/bot.py b/backend/app/schemas/bot.py new file mode 100644 index 0000000..9fd0a79 --- /dev/null +++ b/backend/app/schemas/bot.py @@ -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 diff --git a/backend/app/schemas/message.py b/backend/app/schemas/message.py index 82a9d51..7358ead 100644 --- a/backend/app/schemas/message.py +++ b/backend/app/schemas/message.py @@ -13,3 +13,4 @@ class MessageRead(BaseModel): username: str content: str created_at: datetime + edited_at: datetime | None diff --git a/backend/app/schemas/webhook.py b/backend/app/schemas/webhook.py new file mode 100644 index 0000000..bfe4646 --- /dev/null +++ b/backend/app/schemas/webhook.py @@ -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 diff --git a/backend/app/security.py b/backend/app/security.py index a466dc4..cbe1517 100644 --- a/backend/app/security.py +++ b/backend/app/security.py @@ -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() diff --git a/backend/app/services/admin_service.py b/backend/app/services/admin_service.py index 7df6e77..4a462b6 100644 --- a/backend/app/services/admin_service.py +++ b/backend/app/services/admin_service.py @@ -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()) diff --git a/backend/app/services/audit.py b/backend/app/services/audit.py new file mode 100644 index 0000000..9ad3931 --- /dev/null +++ b/backend/app/services/audit.py @@ -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()) diff --git a/backend/app/services/bot_service.py b/backend/app/services/bot_service.py new file mode 100644 index 0000000..350fb72 --- /dev/null +++ b/backend/app/services/bot_service.py @@ -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 diff --git a/backend/app/services/message_events.py b/backend/app/services/message_events.py new file mode 100644 index 0000000..425e364 --- /dev/null +++ b/backend/app/services/message_events.py @@ -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) diff --git a/backend/app/services/message_service.py b/backend/app/services/message_service.py index 97b4424..f033594 100644 --- a/backend/app/services/message_service.py +++ b/backend/app/services/message_service.py @@ -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]: diff --git a/backend/app/services/ssrf.py b/backend/app/services/ssrf.py new file mode 100644 index 0000000..6b2eead --- /dev/null +++ b/backend/app/services/ssrf.py @@ -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() diff --git a/backend/app/services/webhook_delivery.py b/backend/app/services/webhook_delivery.py new file mode 100644 index 0000000..925e4d3 --- /dev/null +++ b/backend/app/services/webhook_delivery.py @@ -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 + ) diff --git a/backend/app/services/webhook_service.py b/backend/app/services/webhook_service.py new file mode 100644 index 0000000..87c42bd --- /dev/null +++ b/backend/app/services/webhook_service.py @@ -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)) diff --git a/backend/app/ws/chat.py b/backend/app/ws/chat.py index 9d3beec..e4414fd 100644 --- a/backend/app/ws/chat.py +++ b/backend/app/ws/chat.py @@ -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( diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 95c954e..6c22478 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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] diff --git a/backend/tests/test_bots.py b/backend/tests/test_bots.py new file mode 100644 index 0000000..7d56bd6 --- /dev/null +++ b/backend/tests/test_bots.py @@ -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 diff --git a/backend/tests/test_message_edit.py b/backend/tests/test_message_edit.py new file mode 100644 index 0000000..49f06eb --- /dev/null +++ b/backend/tests/test_message_edit.py @@ -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" diff --git a/backend/tests/test_webhooks.py b/backend/tests/test_webhooks.py new file mode 100644 index 0000000..466eb8c --- /dev/null +++ b/backend/tests/test_webhooks.py @@ -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" diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index c204ee7..663257c 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -1,5 +1,11 @@ import { apiFetch } from './client' -import type { AdminRoom, AdminUser, AuditLogEntry } from '../types' +import type { + AdminRoom, + AdminUser, + AuditLogEntry, + EventSubscriptionAdmin, + WebhookIncomingAdmin, +} from '../types' export function listAdminUsers(): Promise { return apiFetch('/api/admin/users') @@ -50,3 +56,11 @@ export function transferOwnershipAdmin(roomId: string, newOwnerId: string): Prom export function listAuditLog(limit = 50, offset = 0): Promise { return apiFetch(`/api/admin/audit-log?limit=${limit}&offset=${offset}`) } + +export function listAllIncomingWebhooks(): Promise { + return apiFetch('/api/admin/webhooks/incoming') +} + +export function listAllEventSubscriptions(): Promise { + return apiFetch('/api/admin/event-subscriptions') +} diff --git a/frontend/src/api/bots.ts b/frontend/src/api/bots.ts new file mode 100644 index 0000000..d481aa5 --- /dev/null +++ b/frontend/src/api/bots.ts @@ -0,0 +1,28 @@ +import { apiFetch } from './client' +import type { ApiScope, ApiToken, ApiTokenCreated, Bot } from '../types' + +export function createBot(username: string): Promise { + return apiFetch('/api/admin/bots', { + method: 'POST', + body: JSON.stringify({ username }), + }) +} + +export function listBots(): Promise { + return apiFetch('/api/admin/bots') +} + +export function createApiToken(botId: string, scopes: ApiScope[]): Promise { + return apiFetch(`/api/admin/bots/${botId}/tokens`, { + method: 'POST', + body: JSON.stringify({ scopes }), + }) +} + +export function listApiTokens(botId: string): Promise { + return apiFetch(`/api/admin/bots/${botId}/tokens`) +} + +export function revokeApiToken(tokenId: string): Promise { + return apiFetch(`/api/admin/bots/tokens/${tokenId}`, { method: 'DELETE' }) +} diff --git a/frontend/src/api/webhooks.ts b/frontend/src/api/webhooks.ts new file mode 100644 index 0000000..ed5e946 --- /dev/null +++ b/frontend/src/api/webhooks.ts @@ -0,0 +1,48 @@ +import { apiFetch } from './client' +import type { + EventSubscription, + EventSubscriptionCreated, + EventType, + WebhookIncoming, +} from '../types' + +export function createIncomingWebhook( + roomId: string, + description?: string, +): Promise { + return apiFetch(`/api/rooms/${roomId}/webhooks/incoming`, { + method: 'POST', + body: JSON.stringify({ description: description || null }), + }) +} + +export function listIncomingWebhooks(roomId: string): Promise { + return apiFetch(`/api/rooms/${roomId}/webhooks/incoming`) +} + +export function revokeIncomingWebhook(roomId: string, webhookId: string): Promise { + return apiFetch(`/api/rooms/${roomId}/webhooks/incoming/${webhookId}`, { + method: 'DELETE', + }) +} + +export function createEventSubscription( + roomId: string, + eventTypes: EventType[], + targetUrl: string, +): Promise { + return apiFetch(`/api/rooms/${roomId}/event-subscriptions`, { + method: 'POST', + body: JSON.stringify({ event_types: eventTypes, target_url: targetUrl }), + }) +} + +export function listEventSubscriptions(roomId: string): Promise { + return apiFetch(`/api/rooms/${roomId}/event-subscriptions`) +} + +export function revokeEventSubscription(roomId: string, subscriptionId: string): Promise { + return apiFetch(`/api/rooms/${roomId}/event-subscriptions/${subscriptionId}`, { + method: 'DELETE', + }) +} diff --git a/frontend/src/components/ChatPane.tsx b/frontend/src/components/ChatPane.tsx index a206ddf..db0cb69 100644 --- a/frontend/src/components/ChatPane.tsx +++ b/frontend/src/components/ChatPane.tsx @@ -43,6 +43,17 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp const onMessage = useCallback((envelope: ServerEnvelope) => { if (envelope.type === 'message') { setLive((prev) => [...prev, envelope]) + } else if (envelope.type === 'message_update') { + setHistory((prev) => + prev.map((m) => + m.id === envelope.id ? { ...m, content: envelope.content, edited_at: envelope.edited_at } : m, + ), + ) + setLive((prev) => + prev.map((m) => + m.id === envelope.id ? { ...m, content: envelope.content, edited_at: envelope.edited_at } : m, + ), + ) } else if (envelope.type === 'error') { setWsError(envelope.detail) } @@ -50,7 +61,7 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp const onUnauthenticated = useCallback(() => navigate('/login'), [navigate]) - const { connected, send } = useChatSocket({ roomId: room.id, onMessage, onUnauthenticated }) + const { connected, send, sendEdit } = useChatSocket({ roomId: room.id, onMessage, onUnauthenticated }) return (
@@ -88,7 +99,7 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp

)} - +
) diff --git a/frontend/src/components/MessageList.css b/frontend/src/components/MessageList.css index 65be36b..ccb9594 100644 --- a/frontend/src/components/MessageList.css +++ b/frontend/src/components/MessageList.css @@ -42,6 +42,7 @@ } .message-bubble { + position: relative; background: var(--ds-surface-2); color: var(--ds-text); padding: 8px 12px; @@ -56,9 +57,45 @@ background: var(--ds-accent-2); } +.message-edit-link { + display: none; + position: absolute; + top: -18px; + right: 0; + background: transparent; + border: none; + color: var(--ds-muted); + font-size: 0.68rem; + cursor: pointer; + padding: 2px 4px; +} + +.message-edit-link:hover { + color: var(--ds-text); +} + +.message-bubble:hover .message-edit-link { + display: inline; +} + +.message-edit-input { + background: var(--ds-surface-2); + border: 1px solid var(--ds-accent); + border-radius: 10px; + padding: 8px 12px; + font-size: 0.88rem; + color: var(--ds-text); + font-family: var(--sans); + width: 100%; +} + .message-time { font-size: 0.68rem; color: var(--ds-muted); margin-top: 3px; font-family: var(--mono); } + +.message-edited { + font-style: italic; +} diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index 2349489..48264f8 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import { useAuth } from '../context/AuthContext' import { senderColorIndex } from '../lib/messageGrouping' import type { ChatMessageEnvelope, Message, RoomMember } from '../types' @@ -8,16 +8,30 @@ import './MessageList.css' interface MessageListProps { messages: (Message | ChatMessageEnvelope)[] members: RoomMember[] + onEdit: (messageId: string, content: string) => void } -export function MessageList({ messages, members }: MessageListProps) { +export function MessageList({ messages, members, onEdit }: MessageListProps) { const { user } = useAuth() const bottomRef = useRef(null) + const [editingId, setEditingId] = useState(null) + const [draft, setDraft] = useState('') useEffect(() => { bottomRef.current?.scrollIntoView({ block: 'end' }) }, [messages.length]) + function startEdit(msg: Message | ChatMessageEnvelope) { + setEditingId(msg.id) + setDraft(msg.content) + } + + function commitEdit(messageId: string) { + const trimmed = draft.trim() + if (trimmed) onEdit(messageId, trimmed) + setEditingId(null) + } + return (
{messages.map((msg, i) => { @@ -25,6 +39,7 @@ export function MessageList({ messages, members }: MessageListProps) { const prev = messages[i - 1] const showAvatar = !mine && (!prev || prev.user_id !== msg.user_id) const showName = showAvatar + const editing = editingId === msg.id return (
@@ -37,9 +52,36 @@ export function MessageList({ messages, members }: MessageListProps) { )}
{showName &&
{msg.username}
} -
{msg.content}
+ {editing ? ( + setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') commitEdit(msg.id) + if (e.key === 'Escape') setEditingId(null) + }} + onBlur={() => commitEdit(msg.id)} + /> + ) : ( +
+ {msg.content} + {mine && ( + + )} +
+ )}
{new Date(msg.created_at).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })} + {msg.edited_at && (edited)}
diff --git a/frontend/src/components/RoomInfoPanel.css b/frontend/src/components/RoomInfoPanel.css index 6279147..1a47bfe 100644 --- a/frontend/src/components/RoomInfoPanel.css +++ b/frontend/src/components/RoomInfoPanel.css @@ -206,6 +206,90 @@ resize: vertical; } +.room-info-integrations { + display: flex; + flex-direction: column; + gap: var(--sp-4); + margin-top: var(--sp-2); +} + +.room-info-integration-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.room-info-webhook-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--sp-2); + font-size: 0.78rem; +} + +.room-info-webhook-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.room-info-webhook-info code { + background: var(--ds-surface-2); + padding: 3px 6px; + border-radius: 6px; + font-size: 0.72rem; + word-break: break-all; +} + +.room-info-webhook-desc { + color: var(--ds-muted); + font-size: 0.74rem; +} + +.room-info-subscription-form { + display: flex; + flex-direction: column; + gap: 6px; +} + +.room-info-subscription-form input { + background: var(--ds-surface-2); + border: 1px solid var(--ds-border); + border-radius: var(--radius); + padding: 7px 10px; + color: var(--ds-text); + font-size: 0.82rem; +} + +.room-info-event-types { + display: flex; + gap: var(--sp-3); + flex-wrap: wrap; +} + +.room-info-event-type-checkbox { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.74rem; + font-family: var(--mono); + color: var(--ds-muted); +} + +.room-info-signing-secret { + font-size: 0.76rem; + color: var(--ds-accent); + margin: 0; +} + +.room-info-signing-secret code { + background: var(--ds-surface-2); + padding: 3px 6px; + border-radius: 6px; + word-break: break-all; +} + .room-info-danger-link { background: transparent; border: none; diff --git a/frontend/src/components/RoomInfoPanel.tsx b/frontend/src/components/RoomInfoPanel.tsx index da90044..06032ef 100644 --- a/frontend/src/components/RoomInfoPanel.tsx +++ b/frontend/src/components/RoomInfoPanel.tsx @@ -9,12 +9,30 @@ import { transferOwnership, updateRoom, } from '../api/rooms' +import { + createEventSubscription, + createIncomingWebhook, + listEventSubscriptions, + listIncomingWebhooks, + revokeEventSubscription, + revokeIncomingWebhook, +} from '../api/webhooks' import { useAuth } from '../context/AuthContext' -import type { Invite, MyRoomItem, RoomMember, RoomRole } from '../types' +import type { + EventSubscription, + EventType, + Invite, + MyRoomItem, + RoomMember, + RoomRole, + WebhookIncoming, +} from '../types' import { RoomAvatar } from './RoomAvatar' import { UserAvatar } from './UserAvatar' import './RoomInfoPanel.css' +const EVENT_TYPES: EventType[] = ['message.created', 'message.updated'] + interface RoomInfoPanelProps { room: MyRoomItem members: RoomMember[] @@ -46,6 +64,15 @@ export function RoomInfoPanel({ const [descDraft, setDescDraft] = useState(room.description ?? '') const [roomError, setRoomError] = useState(null) + const [integrationsOpen, setIntegrationsOpen] = useState(false) + const [incomingWebhooks, setIncomingWebhooks] = useState([]) + const [webhookDescription, setWebhookDescription] = useState('') + const [eventSubscriptions, setEventSubscriptions] = useState([]) + const [targetUrl, setTargetUrl] = useState('') + const [selectedEventTypes, setSelectedEventTypes] = useState([]) + const [newSigningSecret, setNewSigningSecret] = useState(null) + const [integrationsError, setIntegrationsError] = useState(null) + const canManage = myRole === 'admin' || myRole === 'owner' useEffect(() => { @@ -53,8 +80,12 @@ export function RoomInfoPanel({ setDescDraft(room.description ?? '') if (canManage) { listRoomInvites(room.id).then(setPendingInvites).catch(() => setPendingInvites([])) + listIncomingWebhooks(room.id).then(setIncomingWebhooks).catch(() => setIncomingWebhooks([])) + listEventSubscriptions(room.id).then(setEventSubscriptions).catch(() => setEventSubscriptions([])) } else { setPendingInvites([]) + setIncomingWebhooks([]) + setEventSubscriptions([]) } }, [room.id, room.name, room.description, canManage]) @@ -77,6 +108,50 @@ export function RoomInfoPanel({ setPendingInvites(await listRoomInvites(room.id)) } + async function handleCreateWebhook(e: FormEvent) { + e.preventDefault() + setIntegrationsError(null) + try { + await createIncomingWebhook(room.id, webhookDescription.trim()) + setWebhookDescription('') + setIncomingWebhooks(await listIncomingWebhooks(room.id)) + } catch (err) { + setIntegrationsError(err instanceof ApiError ? err.message : String(err)) + } + } + + async function handleRevokeWebhook(webhookId: string) { + await revokeIncomingWebhook(room.id, webhookId) + setIncomingWebhooks(await listIncomingWebhooks(room.id)) + } + + function toggleEventType(eventType: EventType) { + setSelectedEventTypes((prev) => + prev.includes(eventType) ? prev.filter((t) => t !== eventType) : [...prev, eventType], + ) + } + + async function handleCreateSubscription(e: FormEvent) { + e.preventDefault() + setIntegrationsError(null) + const url = targetUrl.trim() + if (!url || selectedEventTypes.length === 0) return + try { + const created = await createEventSubscription(room.id, selectedEventTypes, url) + setNewSigningSecret(created.signing_secret) + setTargetUrl('') + setSelectedEventTypes([]) + setEventSubscriptions(await listEventSubscriptions(room.id)) + } catch (err) { + setIntegrationsError(err instanceof ApiError ? err.message : String(err)) + } + } + + async function handleRevokeSubscription(subscriptionId: string) { + await revokeEventSubscription(room.id, subscriptionId) + setEventSubscriptions(await listEventSubscriptions(room.id)) + } + async function handleRemove(userId: string) { setBusyUserId(userId) try { @@ -252,6 +327,105 @@ export function RoomInfoPanel({
)} + {canManage && ( +
+ + {integrationsOpen && ( +
+ {integrationsError &&

{integrationsError}

} + +
+
Incoming webhooks
+ {incomingWebhooks.map((w) => ( +
+
+ {`${window.location.origin}/api/webhooks/incoming/${w.token}`} + {w.description && {w.description}} +
+ +
+ ))} +
+ setWebhookDescription(e.target.value)} + /> + +
+
+ +
+
Outgoing event subscriptions
+ {eventSubscriptions.map((s) => ( +
+
+ {s.target_url} + {s.event_types.join(', ')} +
+ +
+ ))} +
+ setTargetUrl(e.target.value)} + /> +
+ {EVENT_TYPES.map((eventType) => ( + + ))} +
+ +
+ {newSigningSecret && ( +

+ New signing secret (copy it now, it won't be shown again):{' '} + {newSigningSecret} +

+ )} +
+
+ )} +
+ )} + {myRole === 'owner' && (
@@ -237,6 +326,147 @@ export function AdminPage() { )} + {tab === 'bots' && ( + <> +
+ setNewBotUsername(e.target.value)} + /> + +
+ + + + + + + + + + + + {bots.map((b) => ( + + + + + + + + {expandedBotId === b.id && ( + + + + )} + + ))} + +
UsernameStatusCreatedActions
{b.username} + + {b.is_active ? 'Active' : 'Deactivated'} + + {new Date(b.created_at).toLocaleDateString()} + +
+
+ {(tokensByBot[b.id] ?? []).map((t) => ( +
+ {t.scopes.join(', ')} + + {t.last_used_at + ? `last used ${new Date(t.last_used_at).toLocaleDateString()}` + : 'never used'} + + +
+ ))} + {(tokensByBot[b.id] ?? []).length === 0 && ( +

No tokens yet.

+ )} +
+ +
+ {ALL_SCOPES.map((scope) => ( + + ))} + +
+ + {justCreatedToken && ( +

+ New token (copy it now, it won't be shown again):{' '} + {justCreatedToken} +

+ )} +
+ +

Registered webhooks

+ + + + + + + + + + + {incomingWebhooks.map((w) => ( + + + + + + + ))} + {eventSubscriptions.map((s) => ( + + + + + + + ))} + {incomingWebhooks.length === 0 && eventSubscriptions.length === 0 && ( + + + + )} + +
RoomTypeDetailsCreated by
#{w.room_name}Incoming{w.description || '—'}{w.created_by_username}
{s.room_name ? `#${s.room_name}` : 'Global'}Outgoing ({s.event_types.join(', ')}){s.target_url}{s.created_by_username}
+ No webhooks registered yet. +
+ + )} + {tab === 'audit' && ( <> diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 4c4ba55..c8cdea0 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -58,6 +58,7 @@ export interface Message { username: string content: string created_at: string + edited_at: string | null } export interface ChatMessageEnvelope { @@ -68,6 +69,15 @@ export interface ChatMessageEnvelope { username: string content: string created_at: string + edited_at: string | null +} + +export interface ChatMessageUpdateEnvelope { + type: 'message_update' + id: string + room_id: string + content: string + edited_at: string | null } export interface ChatJoinedEnvelope { @@ -80,7 +90,11 @@ export interface ChatErrorEnvelope { detail: string } -export type ServerEnvelope = ChatMessageEnvelope | ChatJoinedEnvelope | ChatErrorEnvelope +export type ServerEnvelope = + | ChatMessageEnvelope + | ChatMessageUpdateEnvelope + | ChatJoinedEnvelope + | ChatErrorEnvelope export interface AdminUser { id: string @@ -113,3 +127,58 @@ export interface AuditLogEntry { metadata: Record | null created_at: string } + +export type ApiScope = 'read:messages' | 'write:messages' | 'manage:rooms' + +export interface Bot { + id: string + username: string + is_active: boolean + created_at: string +} + +export interface ApiToken { + id: string + owner_id: string + scopes: ApiScope[] + last_used_at: string | null + created_at: string +} + +export interface ApiTokenCreated extends ApiToken { + token: string +} + +export interface WebhookIncoming { + id: string + room_id: string + token: string + created_by: string + description: string | null + created_at: string +} + +export interface WebhookIncomingAdmin extends WebhookIncoming { + room_name: string + created_by_username: string +} + +export type EventType = 'message.created' | 'message.updated' + +export interface EventSubscription { + id: string + room_id: string | null + event_types: EventType[] + target_url: string + created_by: string + created_at: string +} + +export interface EventSubscriptionCreated extends EventSubscription { + signing_secret: string +} + +export interface EventSubscriptionAdmin extends EventSubscription { + room_name: string | null + created_by_username: string +} diff --git a/frontend/src/ws/useChatSocket.ts b/frontend/src/ws/useChatSocket.ts index 839c19c..17d7bed 100644 --- a/frontend/src/ws/useChatSocket.ts +++ b/frontend/src/ws/useChatSocket.ts @@ -48,5 +48,11 @@ export function useChatSocket({ roomId, onMessage, onUnauthenticated }: UseChatS ws.send(JSON.stringify({ type: 'message', room_id: roomId, content })) }, [roomId]) - return { connected, send } + const sendEdit = useCallback((messageId: string, content: string) => { + const ws = socketRef.current + if (!ws || ws.readyState !== WebSocket.OPEN) return + ws.send(JSON.stringify({ type: 'edit', room_id: roomId, message_id: messageId, content })) + }, [roomId]) + + return { connected, send, sendEdit } }