Private
Public Access
Phase 7: Bot/extension system
Bot accounts (User rows with is_bot=True), scoped API tokens (read:messages, write:messages, manage:rooms) authenticated via Authorization: Bearer on both REST and the WS handshake, live bot WebSocket access on the same /ws/chat endpoint humans use, message editing (WS "edit" envelope -> message_update broadcast, fans out cross-instance for free via the existing broadcaster), incoming webhooks (room-scoped, no auth beyond the URL token), and outgoing webhooks/event subscriptions (HMAC-SHA256 signed, backgrounded delivery, creation-time SSRF validation against private/loopback/link-local targets). Token auth is additive, not a parallel system: a bearer-token-authenticated bot goes through the exact same room-membership/role checks a session- authenticated human does everywhere; only read:messages/write:messages are separately scope-gated (the two message endpoints). manage:rooms scope enforcement, full per-delivery SSRF re-validation, and bot API rate limiting were explicitly scoped out (confirmed with the repo owner) as disproportionate to this phase -- documented as known gaps in backend/README.md rather than silently skipped. Admin portal gains a Bots tab (create bots, issue/revoke scoped tokens, cross-room webhook visibility); RoomInfoPanel gains room-scoped webhook/ subscription management, mirroring how invites already work there. The chat UI also gets a minimal "edit your own message" affordance -- not asked for by the issue, but the only practical way to exercise the edit pipeline by hand instead of only via a scripted bot client. Along the way: fixed a real bug caught while writing the incoming-webhook test -- offline-push notification relied on the sender being "connected" to exclude themselves, true for WS-originated messages but not for the new webhook path, which has no WS connection for the attributed sender at all. Now explicitly excluded. Also discovered the REST-only test fixture never triggered ASGI lifespan, so app.state.broadcaster/presence didn't exist for it; moved their construction out of the lifespan into create_app() itself (Redis client construction is synchronous/lazy) so both the WS and REST-only paths always have them. New tests/test_bots.py, test_message_edit.py, test_webhooks.py (full suite now 78/78, stable across repeated runs) plus a scripted end-to-end smoke test (bot WS join/post/edit, incoming webhook, SSRF rejection, outgoing delivery) and a full browser walkthrough of the new admin/room UI. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+105
-23
@@ -1,11 +1,13 @@
|
|||||||
# KeepItTalking backend (Phase 1 + 2 + 4 + 5 + 6)
|
# KeepItTalking backend (Phase 1 + 2 + 4 + 5 + 6 + 7)
|
||||||
|
|
||||||
FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL + Redis. Implements auth, room
|
FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL + Redis. Implements auth, room
|
||||||
CRUD (open and private), room roles (owner/admin/member) and invites, a
|
CRUD (open and private), room roles (owner/admin/member) and invites, a
|
||||||
WebSocket chat endpoint that fans out across multiple app-server instances
|
WebSocket chat endpoint that fans out across multiple app-server instances
|
||||||
via Redis pub/sub, Web Push notifications for offline room members, and a
|
via Redis pub/sub, Web Push notifications for offline room members, a
|
||||||
site-admin portal (user/room management + an audit log). See
|
site-admin portal (user/room/bot management + an audit log), and a bot/
|
||||||
`../ARCHITECTURE.md` for the full system design and the phased build plan.
|
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.
|
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.
|
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
|
main.py create_app(), session middleware, router/WS mounting
|
||||||
config.py environment-driven settings (pydantic-settings)
|
config.py environment-driven settings (pydantic-settings)
|
||||||
database.py async engine/session, get_db() dependency
|
database.py async engine/session, get_db() dependency
|
||||||
dependencies.py get_current_user, require_room_member, require_room_role,
|
dependencies.py get_current_user (session cookie or Bearer token),
|
||||||
require_site_admin
|
require_room_member, require_room_role,
|
||||||
security.py argon2 password hashing
|
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`
|
cli.py `python -m app.cli create-user` / `generate-vapid-keys`
|
||||||
models/ SQLAlchemy models (users, rooms, room_memberships,
|
models/ SQLAlchemy models (users, rooms, room_memberships,
|
||||||
messages, room_invites, push_subscriptions,
|
messages, room_invites, push_subscriptions,
|
||||||
admin_audit_log)
|
admin_audit_log, api_tokens, webhooks_incoming,
|
||||||
|
event_subscriptions)
|
||||||
schemas/ Pydantic request/response models
|
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
|
services/ business logic called by routers
|
||||||
ws/ connection_manager (local sockets), presence +
|
ws/ connection_manager (local sockets), presence +
|
||||||
broadcaster (Redis), /ws/chat handler
|
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
|
(actor, action, target type/id, JSON metadata) in the same transaction as
|
||||||
the change, listed newest-first via `GET /api/admin/audit-log`.
|
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 (deferred from this phase originally) is now in
|
||||||
- **Bot/integration management** — nothing to manage until Phase 7 builds
|
place — see Phase 7 below. One item is still deliberately not here:
|
||||||
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.
|
|
||||||
- **System settings** — no settings storage or concrete setting exists yet.
|
- **System settings** — no settings storage or concrete setting exists yet.
|
||||||
The frontend has an empty "Settings" tab as a placeholder for when one
|
The frontend has an empty "Settings" tab as a placeholder for when one
|
||||||
does.
|
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)
|
## Cross-instance broadcast (Phase 5)
|
||||||
|
|
||||||
The WebSocket layer is split into three pieces so that running one app
|
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`
|
`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
|
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
|
the frontend the key it needs for `PushManager.subscribe()`. On every chat
|
||||||
message, `app/ws/chat.py` computes `room members - Presence.
|
message, `app/services/message_events.py` computes `room members -
|
||||||
connected_user_ids(room_id)` (who's actually connected to *that room* right
|
Presence.connected_user_ids(room_id) - {sender}` (who's actually connected
|
||||||
now, across every app instance — see Phase 5 below) and sends each offline
|
to *that room* right now, across every app instance — see Phase 5 below;
|
||||||
member a push via `pywebpush`, awaited inline against the same
|
the sender is subtracted explicitly rather than relied on to be "connected,"
|
||||||
request-scoped session rather than fired as a background task — the
|
since that's only true for WS-originated messages, not the Phase 7
|
||||||
broadcast to online members already happened by that point, so nothing
|
incoming-webhook path) and sends each offline member a push via `pywebpush`,
|
||||||
online-facing is delayed, and it sidesteps `asyncio.create_task()`s outliving
|
awaited inline against the same request-scoped session rather than fired as
|
||||||
the session/event loop they were created on. An expired/invalid subscription
|
a background task — the broadcast to online members already happened by
|
||||||
(pywebpush 404/410) is deleted automatically.
|
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)
|
## Room roles and invites (Phase 2)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""bots and webhooks
|
||||||
|
|
||||||
|
Revision ID: 3350c67553ad
|
||||||
|
Revises: 6ee71c8d5e07
|
||||||
|
Create Date: 2026-08-14 07:47:25.761745
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '3350c67553ad'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '6ee71c8d5e07'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('api_tokens',
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('owner_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('token_hash', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('scopes', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_api_tokens_owner_id'), 'api_tokens', ['owner_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_api_tokens_token_hash'), 'api_tokens', ['token_hash'], unique=True)
|
||||||
|
op.create_table('event_subscriptions',
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('room_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.Column('event_types', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.Column('target_url', sa.String(length=2048), nullable=False),
|
||||||
|
sa.Column('signing_secret', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('created_by', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_event_subscriptions_room_id'), 'event_subscriptions', ['room_id'], unique=False)
|
||||||
|
op.create_table('webhooks_incoming',
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('room_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('token', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('created_by', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('description', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_webhooks_incoming_room_id'), 'webhooks_incoming', ['room_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_webhooks_incoming_token'), 'webhooks_incoming', ['token'], unique=True)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_webhooks_incoming_token'), table_name='webhooks_incoming')
|
||||||
|
op.drop_index(op.f('ix_webhooks_incoming_room_id'), table_name='webhooks_incoming')
|
||||||
|
op.drop_table('webhooks_incoming')
|
||||||
|
op.drop_index(op.f('ix_event_subscriptions_room_id'), table_name='event_subscriptions')
|
||||||
|
op.drop_table('event_subscriptions')
|
||||||
|
op.drop_index(op.f('ix_api_tokens_token_hash'), table_name='api_tokens')
|
||||||
|
op.drop_index(op.f('ix_api_tokens_owner_id'), table_name='api_tokens')
|
||||||
|
op.drop_table('api_tokens')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import RoomMembership, RoomRole, User
|
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}
|
_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(
|
async def get_current_user(
|
||||||
request: Request, db: AsyncSession = Depends(get_db)
|
request: Request, db: AsyncSession = Depends(get_db)
|
||||||
) -> User:
|
) -> 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")
|
user_id = request.session.get("user_id")
|
||||||
if not user_id:
|
if not user_id:
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
@@ -25,6 +40,12 @@ async def get_current_user(
|
|||||||
return 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(
|
async def require_room_member(
|
||||||
room_id: uuid.UUID, user: User, db: AsyncSession
|
room_id: uuid.UUID, user: User, db: AsyncSession
|
||||||
) -> RoomMembership:
|
) -> RoomMembership:
|
||||||
|
|||||||
+16
-7
@@ -8,7 +8,7 @@ from redis.asyncio import Redis
|
|||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
from app.config import settings
|
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.broadcaster import RoomBroadcaster
|
||||||
from app.ws.chat import router as ws_router
|
from app.ws.chat import router as ws_router
|
||||||
from app.ws.connection_manager import ConnectionManager
|
from app.ws.connection_manager import ConnectionManager
|
||||||
@@ -17,18 +17,22 @@ from app.ws.presence import Presence
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||||
redis = Redis.from_url(settings.redis_url, decode_responses=True)
|
# redis/presence/broadcaster are constructed in create_app(), not here --
|
||||||
app.state.presence = Presence(redis)
|
# Redis.from_url() is synchronous/lazy (no connection opens until the
|
||||||
broadcaster = RoomBroadcaster(redis, app.state.connection_manager)
|
# first command), so app.state.presence/broadcaster are always present
|
||||||
app.state.broadcaster = broadcaster
|
# even for callers that never trigger the ASGI lifespan (e.g. httpx's
|
||||||
listener_task = asyncio.create_task(broadcaster.listen())
|
# 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
|
yield
|
||||||
|
|
||||||
listener_task.cancel()
|
listener_task.cancel()
|
||||||
with contextlib.suppress(asyncio.CancelledError):
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
await listener_task
|
await listener_task
|
||||||
await redis.aclose()
|
await app.state.redis.aclose()
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
@@ -43,6 +47,9 @@ def create_app() -> FastAPI:
|
|||||||
)
|
)
|
||||||
|
|
||||||
app.state.connection_manager = ConnectionManager()
|
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(health.router)
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
@@ -50,6 +57,8 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(invites.router)
|
app.include_router(invites.router)
|
||||||
app.include_router(push.router)
|
app.include_router(push.router)
|
||||||
app.include_router(admin.router)
|
app.include_router(admin.router)
|
||||||
|
app.include_router(bots.router)
|
||||||
|
app.include_router(webhooks.router)
|
||||||
app.include_router(ws_router)
|
app.include_router(ws_router)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
from app.models.admin_audit_log import AdminAuditLog
|
from app.models.admin_audit_log import AdminAuditLog
|
||||||
|
from app.models.api_token import ApiToken
|
||||||
from app.models.base import Base
|
from app.models.base import Base
|
||||||
|
from app.models.event_subscription import EventSubscription
|
||||||
from app.models.invite import InviteStatus, RoomInvite
|
from app.models.invite import InviteStatus, RoomInvite
|
||||||
from app.models.membership import RoomMembership, RoomRole
|
from app.models.membership import RoomMembership, RoomRole
|
||||||
from app.models.message import Message
|
from app.models.message import Message
|
||||||
from app.models.push_subscription import PushSubscription
|
from app.models.push_subscription import PushSubscription
|
||||||
from app.models.room import Room
|
from app.models.room import Room
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.models.webhook_incoming import WebhookIncoming
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
@@ -18,4 +21,7 @@ __all__ = [
|
|||||||
"InviteStatus",
|
"InviteStatus",
|
||||||
"PushSubscription",
|
"PushSubscription",
|
||||||
"AdminAuditLog",
|
"AdminAuditLog",
|
||||||
|
"ApiToken",
|
||||||
|
"WebhookIncoming",
|
||||||
|
"EventSubscription",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.models.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ApiToken(Base):
|
||||||
|
__tablename__ = "api_tokens"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
owner_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), index=True, nullable=False)
|
||||||
|
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||||||
|
scopes: Mapped[list[str]] = mapped_column(JSONB, nullable=False)
|
||||||
|
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
owner = relationship("User")
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.models.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class EventSubscription(Base):
|
||||||
|
__tablename__ = "event_subscriptions"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
room_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("rooms.id"), index=True)
|
||||||
|
event_types: Mapped[list[str]] = mapped_column(JSONB, nullable=False)
|
||||||
|
target_url: Mapped[str] = mapped_column(String(2048), nullable=False)
|
||||||
|
signing_secret: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
created_by: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
room = relationship("Room")
|
||||||
|
creator = relationship("User")
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.models.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookIncoming(Base):
|
||||||
|
__tablename__ = "webhooks_incoming"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
room_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("rooms.id"), index=True, nullable=False)
|
||||||
|
# Stored in the clear (not hashed) -- the whole point is the room admin
|
||||||
|
# can view/copy the full webhook URL again anytime, unlike an API token.
|
||||||
|
token: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||||||
|
created_by: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(String(500))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
room = relationship("Room")
|
||||||
|
creator = relationship("User")
|
||||||
@@ -14,12 +14,12 @@ from app.schemas.admin import (
|
|||||||
ResetPasswordRequest,
|
ResetPasswordRequest,
|
||||||
TransferOwnershipRequest,
|
TransferOwnershipRequest,
|
||||||
)
|
)
|
||||||
|
from app.schemas.webhook import EventSubscriptionAdminRead, WebhookIncomingAdminRead
|
||||||
from app.services.admin_service import (
|
from app.services.admin_service import (
|
||||||
CannotActOnSelfError,
|
CannotActOnSelfError,
|
||||||
RoomNotFoundError,
|
RoomNotFoundError,
|
||||||
TargetNotRoomMemberError,
|
TargetNotRoomMemberError,
|
||||||
UserNotFoundError,
|
UserNotFoundError,
|
||||||
list_audit_log,
|
|
||||||
list_rooms_admin,
|
list_rooms_admin,
|
||||||
list_users,
|
list_users,
|
||||||
reset_user_password,
|
reset_user_password,
|
||||||
@@ -28,6 +28,11 @@ from app.services.admin_service import (
|
|||||||
set_user_site_admin,
|
set_user_site_admin,
|
||||||
transfer_ownership_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"])
|
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||||
|
|
||||||
@@ -223,3 +228,47 @@ async def list_audit_log_endpoint(
|
|||||||
)
|
)
|
||||||
for e in entries
|
for e in entries
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/webhooks/incoming", response_model=list[WebhookIncomingAdminRead])
|
||||||
|
async def list_incoming_webhooks_admin_endpoint(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
require_site_admin(current_user)
|
||||||
|
webhooks = await list_all_incoming_webhooks_admin(db)
|
||||||
|
return [
|
||||||
|
WebhookIncomingAdminRead(
|
||||||
|
id=w.id,
|
||||||
|
room_id=w.room_id,
|
||||||
|
token=w.token,
|
||||||
|
created_by=w.created_by,
|
||||||
|
description=w.description,
|
||||||
|
created_at=w.created_at,
|
||||||
|
room_name=w.room.name,
|
||||||
|
created_by_username=w.creator.username,
|
||||||
|
)
|
||||||
|
for w in webhooks
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/event-subscriptions", response_model=list[EventSubscriptionAdminRead])
|
||||||
|
async def list_event_subscriptions_admin_endpoint(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
require_site_admin(current_user)
|
||||||
|
subscriptions = await list_all_event_subscriptions_admin(db)
|
||||||
|
return [
|
||||||
|
EventSubscriptionAdminRead(
|
||||||
|
id=s.id,
|
||||||
|
room_id=s.room_id,
|
||||||
|
event_types=s.event_types,
|
||||||
|
target_url=s.target_url,
|
||||||
|
created_by=s.created_by,
|
||||||
|
created_at=s.created_at,
|
||||||
|
room_name=s.room.name if s.room else None,
|
||||||
|
created_by_username=s.creator.username,
|
||||||
|
)
|
||||||
|
for s in subscriptions
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.dependencies import get_current_user, require_site_admin
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas.bot import ApiTokenCreate, ApiTokenCreated, ApiTokenRead, BotCreate, BotRead
|
||||||
|
from app.services.bot_service import (
|
||||||
|
BotNotFoundError,
|
||||||
|
DuplicateBotError,
|
||||||
|
InvalidScopeError,
|
||||||
|
TokenNotFoundError,
|
||||||
|
create_api_token,
|
||||||
|
create_bot,
|
||||||
|
list_api_tokens,
|
||||||
|
list_bots,
|
||||||
|
revoke_api_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/admin/bots", tags=["bots"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=BotRead, status_code=201)
|
||||||
|
async def create_bot_endpoint(
|
||||||
|
data: BotCreate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
require_site_admin(current_user)
|
||||||
|
try:
|
||||||
|
return await create_bot(db, current_user, data.username)
|
||||||
|
except DuplicateBotError:
|
||||||
|
raise HTTPException(status_code=409, detail="A user with this username already exists")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[BotRead])
|
||||||
|
async def list_bots_endpoint(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
require_site_admin(current_user)
|
||||||
|
return await list_bots(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{bot_id}/tokens", response_model=ApiTokenCreated, status_code=201)
|
||||||
|
async def create_token_endpoint(
|
||||||
|
bot_id: uuid.UUID,
|
||||||
|
data: ApiTokenCreate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
require_site_admin(current_user)
|
||||||
|
try:
|
||||||
|
token, plaintext = await create_api_token(db, current_user, bot_id, data.scopes)
|
||||||
|
except BotNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail="Bot not found")
|
||||||
|
except InvalidScopeError:
|
||||||
|
raise HTTPException(status_code=400, detail="Unrecognized scope")
|
||||||
|
return ApiTokenCreated(
|
||||||
|
id=token.id,
|
||||||
|
owner_id=token.owner_id,
|
||||||
|
scopes=token.scopes,
|
||||||
|
last_used_at=token.last_used_at,
|
||||||
|
created_at=token.created_at,
|
||||||
|
token=plaintext,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{bot_id}/tokens", response_model=list[ApiTokenRead])
|
||||||
|
async def list_tokens_endpoint(
|
||||||
|
bot_id: uuid.UUID,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
require_site_admin(current_user)
|
||||||
|
return await list_api_tokens(db, bot_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/tokens/{token_id}", status_code=204)
|
||||||
|
async def revoke_token_endpoint(
|
||||||
|
token_id: uuid.UUID,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
require_site_admin(current_user)
|
||||||
|
try:
|
||||||
|
await revoke_api_token(db, current_user, token_id)
|
||||||
|
except TokenNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail="Token not found")
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
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.models import RoomRole, User
|
||||||
from app.schemas.invite import InviteCreate, InviteRead
|
from app.schemas.invite import InviteCreate, InviteRead
|
||||||
from app.schemas.message import MessageRead
|
from app.schemas.message import MessageRead
|
||||||
@@ -28,6 +33,13 @@ from app.services.invite_service import (
|
|||||||
list_room_invites,
|
list_room_invites,
|
||||||
revoke_invite,
|
revoke_invite,
|
||||||
)
|
)
|
||||||
|
from app.schemas.webhook import (
|
||||||
|
EventSubscriptionCreate,
|
||||||
|
EventSubscriptionCreated,
|
||||||
|
EventSubscriptionRead,
|
||||||
|
WebhookIncomingCreate,
|
||||||
|
WebhookIncomingRead,
|
||||||
|
)
|
||||||
from app.services.message_service import list_recent_messages
|
from app.services.message_service import list_recent_messages
|
||||||
from app.services.room_service import (
|
from app.services.room_service import (
|
||||||
CannotRemoveOwnerError,
|
CannotRemoveOwnerError,
|
||||||
@@ -50,6 +62,18 @@ from app.services.room_service import (
|
|||||||
transfer_ownership,
|
transfer_ownership,
|
||||||
update_room,
|
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"])
|
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])
|
@router.get("/{room_id}/messages", response_model=list[MessageRead])
|
||||||
async def get_room_messages_endpoint(
|
async def get_room_messages_endpoint(
|
||||||
room_id: uuid.UUID,
|
room_id: uuid.UUID,
|
||||||
|
request: Request,
|
||||||
limit: int = Query(default=50, ge=1, le=200),
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
require_scope(request, "read:messages")
|
||||||
await require_room_member(room_id, current_user, db)
|
await require_room_member(room_id, current_user, db)
|
||||||
messages = await list_recent_messages(db, room_id, limit)
|
messages = await list_recent_messages(db, room_id, limit)
|
||||||
return [
|
return [
|
||||||
@@ -266,6 +292,7 @@ async def get_room_messages_endpoint(
|
|||||||
username=m.user.username,
|
username=m.user.username,
|
||||||
content=m.content,
|
content=m.content,
|
||||||
created_at=m.created_at,
|
created_at=m.created_at,
|
||||||
|
edited_at=m.edited_at,
|
||||||
)
|
)
|
||||||
for m in messages
|
for m in messages
|
||||||
]
|
]
|
||||||
@@ -328,3 +355,93 @@ async def revoke_invite_endpoint(
|
|||||||
raise HTTPException(status_code=404, detail="Invite not found")
|
raise HTTPException(status_code=404, detail="Invite not found")
|
||||||
except InviteNotPendingError:
|
except InviteNotPendingError:
|
||||||
raise HTTPException(status_code=400, detail="Invite is no longer pending")
|
raise HTTPException(status_code=400, detail="Invite is no longer pending")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{room_id}/webhooks/incoming", response_model=WebhookIncomingRead, status_code=201)
|
||||||
|
async def create_incoming_webhook_endpoint(
|
||||||
|
room_id: uuid.UUID,
|
||||||
|
data: WebhookIncomingCreate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await require_room_role(room_id, current_user, db, RoomRole.admin)
|
||||||
|
return await create_incoming_webhook(db, current_user, room_id, data.description)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{room_id}/webhooks/incoming", response_model=list[WebhookIncomingRead])
|
||||||
|
async def list_incoming_webhooks_endpoint(
|
||||||
|
room_id: uuid.UUID,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await require_room_role(room_id, current_user, db, RoomRole.admin)
|
||||||
|
return await list_incoming_webhooks(db, room_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{room_id}/webhooks/incoming/{webhook_id}", status_code=204)
|
||||||
|
async def revoke_incoming_webhook_endpoint(
|
||||||
|
room_id: uuid.UUID,
|
||||||
|
webhook_id: uuid.UUID,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await require_room_role(room_id, current_user, db, RoomRole.admin)
|
||||||
|
try:
|
||||||
|
await revoke_incoming_webhook(db, room_id, webhook_id)
|
||||||
|
except WebhookNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail="Webhook not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{room_id}/event-subscriptions", response_model=EventSubscriptionCreated, status_code=201
|
||||||
|
)
|
||||||
|
async def create_event_subscription_endpoint(
|
||||||
|
room_id: uuid.UUID,
|
||||||
|
data: EventSubscriptionCreate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await require_room_role(room_id, current_user, db, RoomRole.admin)
|
||||||
|
try:
|
||||||
|
subscription, secret = await create_event_subscription(
|
||||||
|
db, current_user, room_id, data.event_types, data.target_url
|
||||||
|
)
|
||||||
|
except InvalidEventTypeError:
|
||||||
|
raise HTTPException(status_code=400, detail="Unrecognized event type")
|
||||||
|
except UnsafeWebhookUrlError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="target_url is not allowed (internal/private address)"
|
||||||
|
)
|
||||||
|
return EventSubscriptionCreated(
|
||||||
|
id=subscription.id,
|
||||||
|
room_id=subscription.room_id,
|
||||||
|
event_types=subscription.event_types,
|
||||||
|
target_url=subscription.target_url,
|
||||||
|
created_by=subscription.created_by,
|
||||||
|
created_at=subscription.created_at,
|
||||||
|
signing_secret=secret,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{room_id}/event-subscriptions", response_model=list[EventSubscriptionRead])
|
||||||
|
async def list_event_subscriptions_endpoint(
|
||||||
|
room_id: uuid.UUID,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await require_room_role(room_id, current_user, db, RoomRole.admin)
|
||||||
|
return await list_event_subscriptions(db, room_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{room_id}/event-subscriptions/{subscription_id}", status_code=204)
|
||||||
|
async def revoke_event_subscription_endpoint(
|
||||||
|
room_id: uuid.UUID,
|
||||||
|
subscription_id: uuid.UUID,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await require_room_role(room_id, current_user, db, RoomRole.admin)
|
||||||
|
try:
|
||||||
|
await revoke_event_subscription(db, room_id, subscription_id)
|
||||||
|
except SubscriptionNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail="Event subscription not found")
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.schemas.webhook import IncomingWebhookPost
|
||||||
|
from app.services.message_events import broadcast_new_message
|
||||||
|
from app.services.webhook_service import WebhookNotFoundError, post_via_webhook
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/webhooks", tags=["webhooks"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/incoming/{token}", status_code=204)
|
||||||
|
async def incoming_webhook_endpoint(
|
||||||
|
token: str,
|
||||||
|
data: IncomingWebhookPost,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> None:
|
||||||
|
# No auth dependency at all -- the token in the URL is the credential,
|
||||||
|
# per ARCHITECTURE.md's incoming-webhook design.
|
||||||
|
try:
|
||||||
|
message, room, sender = await post_via_webhook(db, token, data.content)
|
||||||
|
except WebhookNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown webhook")
|
||||||
|
|
||||||
|
broadcaster = request.app.state.broadcaster
|
||||||
|
presence = request.app.state.presence
|
||||||
|
await broadcast_new_message(db, broadcaster, presence, room.id, message, sender)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class BotCreate(BaseModel):
|
||||||
|
username: str = Field(min_length=3, max_length=50)
|
||||||
|
|
||||||
|
|
||||||
|
class BotRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
username: str
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ApiTokenCreate(BaseModel):
|
||||||
|
scopes: list[str] = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiTokenRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
owner_id: uuid.UUID
|
||||||
|
scopes: list[str]
|
||||||
|
last_used_at: datetime | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ApiTokenCreated(ApiTokenRead):
|
||||||
|
token: str
|
||||||
@@ -13,3 +13,4 @@ class MessageRead(BaseModel):
|
|||||||
username: str
|
username: str
|
||||||
content: str
|
content: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
edited_at: datetime | None
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookIncomingCreate(BaseModel):
|
||||||
|
description: str | None = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookIncomingRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
room_id: uuid.UUID
|
||||||
|
token: str
|
||||||
|
created_by: uuid.UUID
|
||||||
|
description: str | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookIncomingAdminRead(WebhookIncomingRead):
|
||||||
|
room_name: str
|
||||||
|
created_by_username: str
|
||||||
|
|
||||||
|
|
||||||
|
class IncomingWebhookPost(BaseModel):
|
||||||
|
content: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class EventSubscriptionCreate(BaseModel):
|
||||||
|
event_types: list[str] = Field(min_length=1)
|
||||||
|
target_url: str = Field(min_length=1, max_length=2048)
|
||||||
|
|
||||||
|
|
||||||
|
class EventSubscriptionRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
room_id: uuid.UUID | None
|
||||||
|
event_types: list[str]
|
||||||
|
target_url: str
|
||||||
|
created_by: uuid.UUID
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class EventSubscriptionCreated(EventSubscriptionRead):
|
||||||
|
signing_secret: str
|
||||||
|
|
||||||
|
|
||||||
|
class EventSubscriptionAdminRead(EventSubscriptionRead):
|
||||||
|
room_name: str | None
|
||||||
|
created_by_username: str
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
|
||||||
from argon2 import PasswordHasher
|
from argon2 import PasswordHasher
|
||||||
from argon2.exceptions import VerifyMismatchError
|
from argon2.exceptions import VerifyMismatchError
|
||||||
|
|
||||||
@@ -13,3 +16,16 @@ def verify_password(password: str, password_hash: str) -> bool:
|
|||||||
return _hasher.verify(password_hash, password)
|
return _hasher.verify(password_hash, password)
|
||||||
except VerifyMismatchError:
|
except VerifyMismatchError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def generate_token() -> str:
|
||||||
|
return f"kit_{secrets.token_urlsafe(32)}"
|
||||||
|
|
||||||
|
|
||||||
|
def hash_token(token: str) -> str:
|
||||||
|
# Deterministic (not argon2) is deliberate: a bearer token has to be
|
||||||
|
# looked up *by itself* (no username to look up first, unlike a
|
||||||
|
# password), and argon2's per-call random salt makes that impossible.
|
||||||
|
# A fast hash of a high-entropy 256-bit random token is the standard
|
||||||
|
# approach for API keys (same as GitHub/Stripe).
|
||||||
|
return hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import uuid
|
|||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.security import hash_password
|
||||||
|
from app.services.audit import record_audit_log
|
||||||
|
|
||||||
|
|
||||||
class UserNotFoundError(Exception):
|
class UserNotFoundError(Exception):
|
||||||
@@ -52,25 +52,6 @@ async def _get_membership(
|
|||||||
return 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]:
|
async def list_users(db: AsyncSession) -> list[User]:
|
||||||
result = await db.execute(select(User).order_by(User.created_at))
|
result = await db.execute(select(User).order_by(User.created_at))
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
@@ -83,7 +64,7 @@ async def set_user_active(
|
|||||||
raise CannotActOnSelfError()
|
raise CannotActOnSelfError()
|
||||||
user = await _get_user(db, target_user_id)
|
user = await _get_user(db, target_user_id)
|
||||||
user.is_active = active
|
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.commit()
|
||||||
await db.refresh(user)
|
await db.refresh(user)
|
||||||
return user
|
return user
|
||||||
@@ -94,7 +75,7 @@ async def reset_user_password(
|
|||||||
) -> None:
|
) -> None:
|
||||||
user = await _get_user(db, target_user_id)
|
user = await _get_user(db, target_user_id)
|
||||||
user.password_hash = hash_password(new_password)
|
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()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -105,7 +86,7 @@ async def set_user_site_admin(
|
|||||||
raise CannotActOnSelfError()
|
raise CannotActOnSelfError()
|
||||||
user = await _get_user(db, target_user_id)
|
user = await _get_user(db, target_user_id)
|
||||||
user.is_site_admin = is_admin
|
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.commit()
|
||||||
await db.refresh(user)
|
await db.refresh(user)
|
||||||
return user
|
return user
|
||||||
@@ -126,7 +107,7 @@ async def set_room_archived(
|
|||||||
) -> Room:
|
) -> Room:
|
||||||
room = await _get_room(db, room_id)
|
room = await _get_room(db, room_id)
|
||||||
room.is_archived = archived
|
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.commit()
|
||||||
await db.refresh(room)
|
await db.refresh(room)
|
||||||
return room
|
return room
|
||||||
@@ -145,7 +126,7 @@ async def transfer_ownership_admin(
|
|||||||
new_owner_membership.role = RoomRole.owner
|
new_owner_membership.role = RoomRole.owner
|
||||||
current_owner_membership.role = RoomRole.admin
|
current_owner_membership.role = RoomRole.admin
|
||||||
room.owner_id = new_owner_id
|
room.owner_id = new_owner_id
|
||||||
_log(
|
record_audit_log(
|
||||||
db,
|
db,
|
||||||
actor,
|
actor,
|
||||||
"room.transfer_ownership",
|
"room.transfer_ownership",
|
||||||
@@ -156,16 +137,3 @@ async def transfer_ownership_admin(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(room)
|
await db.refresh(room)
|
||||||
return room
|
return room
|
||||||
|
|
||||||
|
|
||||||
async def list_audit_log(
|
|
||||||
db: AsyncSession, limit: int = 50, offset: int = 0
|
|
||||||
) -> list[AdminAuditLog]:
|
|
||||||
result = await db.execute(
|
|
||||||
select(AdminAuditLog)
|
|
||||||
.options(selectinload(AdminAuditLog.actor))
|
|
||||||
.order_by(AdminAuditLog.created_at.desc())
|
|
||||||
.limit(limit)
|
|
||||||
.offset(offset)
|
|
||||||
)
|
|
||||||
return list(result.scalars().all())
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.models import AdminAuditLog, User
|
||||||
|
|
||||||
|
|
||||||
|
def record_audit_log(
|
||||||
|
db: AsyncSession,
|
||||||
|
actor: User,
|
||||||
|
action: str,
|
||||||
|
target_type: str,
|
||||||
|
target_id: uuid.UUID,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
) -> None:
|
||||||
|
db.add(
|
||||||
|
AdminAuditLog(
|
||||||
|
actor_id=actor.id,
|
||||||
|
action=action,
|
||||||
|
target_type=target_type,
|
||||||
|
target_id=target_id,
|
||||||
|
metadata_=metadata,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_audit_log(
|
||||||
|
db: AsyncSession, limit: int = 50, offset: int = 0
|
||||||
|
) -> list[AdminAuditLog]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(AdminAuditLog)
|
||||||
|
.options(selectinload(AdminAuditLog.actor))
|
||||||
|
.order_by(AdminAuditLog.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.models import ApiToken, User
|
||||||
|
from app.security import generate_token, hash_password, hash_token
|
||||||
|
from app.services.audit import record_audit_log
|
||||||
|
|
||||||
|
VALID_SCOPES = {"read:messages", "write:messages", "manage:rooms"}
|
||||||
|
|
||||||
|
|
||||||
|
class DuplicateBotError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class BotNotFoundError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidScopeError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TokenNotFoundError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def create_bot(db: AsyncSession, actor: User, username: str) -> User:
|
||||||
|
# Bots never authenticate with a password -- generate one and discard
|
||||||
|
# it. email is NOT NULL/unique today; a placeholder avoids widening the
|
||||||
|
# schema just for accounts that will never receive real mail.
|
||||||
|
# bots.example.com (not e.g. bots.local/.invalid) deliberately, since
|
||||||
|
# pydantic's EmailStr rejects addresses whose *top-level* domain is one
|
||||||
|
# of the IANA special-use TLDs (.local, .invalid, .test, ...) -- a
|
||||||
|
# subdomain of the real (if reserved-for-docs) .com TLD isn't affected.
|
||||||
|
bot = User(
|
||||||
|
username=username,
|
||||||
|
email=f"{username}@bots.example.com",
|
||||||
|
password_hash=hash_password(generate_token()),
|
||||||
|
is_bot=True,
|
||||||
|
)
|
||||||
|
db.add(bot)
|
||||||
|
try:
|
||||||
|
await db.flush()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
await db.rollback()
|
||||||
|
raise DuplicateBotError() from exc
|
||||||
|
|
||||||
|
record_audit_log(db, actor, "bot.create", "user", bot.id)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(bot)
|
||||||
|
return bot
|
||||||
|
|
||||||
|
|
||||||
|
async def list_bots(db: AsyncSession) -> list[User]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.is_bot.is_(True)).order_by(User.created_at)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_bot(db: AsyncSession, bot_id: uuid.UUID) -> User:
|
||||||
|
bot = await db.get(User, bot_id)
|
||||||
|
if bot is None or not bot.is_bot:
|
||||||
|
raise BotNotFoundError()
|
||||||
|
return bot
|
||||||
|
|
||||||
|
|
||||||
|
async def create_api_token(
|
||||||
|
db: AsyncSession, actor: User, bot_id: uuid.UUID, scopes: list[str]
|
||||||
|
) -> tuple[ApiToken, str]:
|
||||||
|
bot = await _get_bot(db, bot_id)
|
||||||
|
if not set(scopes) <= VALID_SCOPES:
|
||||||
|
raise InvalidScopeError()
|
||||||
|
|
||||||
|
plaintext = generate_token()
|
||||||
|
token = ApiToken(owner_id=bot.id, token_hash=hash_token(plaintext), scopes=scopes)
|
||||||
|
db.add(token)
|
||||||
|
record_audit_log(
|
||||||
|
db, actor, "bot.issue_token", "user", bot.id, {"scopes": scopes}
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(token)
|
||||||
|
return token, plaintext
|
||||||
|
|
||||||
|
|
||||||
|
async def list_api_tokens(db: AsyncSession, bot_id: uuid.UUID) -> list[ApiToken]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(ApiToken).where(ApiToken.owner_id == bot_id).order_by(ApiToken.created_at)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def revoke_api_token(db: AsyncSession, actor: User, token_id: uuid.UUID) -> None:
|
||||||
|
token = await db.get(ApiToken, token_id)
|
||||||
|
if token is None:
|
||||||
|
raise TokenNotFoundError()
|
||||||
|
await db.delete(token)
|
||||||
|
record_audit_log(db, actor, "bot.revoke_token", "user", token.owner_id)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_token(db: AsyncSession, plaintext: str) -> tuple[User, ApiToken] | None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(ApiToken)
|
||||||
|
.where(ApiToken.token_hash == hash_token(plaintext))
|
||||||
|
.options(selectinload(ApiToken.owner))
|
||||||
|
)
|
||||||
|
token = result.scalar_one_or_none()
|
||||||
|
if token is None or not token.owner.is_active:
|
||||||
|
return None
|
||||||
|
|
||||||
|
token.last_used_at = datetime.now(timezone.utc)
|
||||||
|
await db.commit()
|
||||||
|
return token.owner, token
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models import Message, Room, RoomMembership, User
|
||||||
|
from app.services.push_service import send_push_to_user
|
||||||
|
from app.services.webhook_service import dispatch_event
|
||||||
|
from app.ws.broadcaster import RoomBroadcaster
|
||||||
|
from app.ws.presence import Presence
|
||||||
|
|
||||||
|
|
||||||
|
async def _notify_offline_members(
|
||||||
|
db: AsyncSession, presence: Presence, room_id: uuid.UUID, sender: User, content: str
|
||||||
|
) -> None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(RoomMembership.user_id).where(RoomMembership.room_id == room_id)
|
||||||
|
)
|
||||||
|
member_ids = {row[0] for row in result.all()}
|
||||||
|
# Subtract the sender explicitly rather than relying on them being
|
||||||
|
# "connected" (true for the WS path, since they just sent this over an
|
||||||
|
# active connection -- not true for the incoming-webhook REST path,
|
||||||
|
# which has no WS connection for the attributed sender at all).
|
||||||
|
offline_ids = member_ids - await presence.connected_user_ids(room_id) - {sender.id}
|
||||||
|
if not offline_ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
room = await db.get(Room, room_id)
|
||||||
|
payload = {
|
||||||
|
"title": f"#{room.name}" if room else "New message",
|
||||||
|
"body": f"{sender.username}: {content}"[:120],
|
||||||
|
"room_id": str(room_id),
|
||||||
|
}
|
||||||
|
for user_id in offline_ids:
|
||||||
|
await send_push_to_user(db, user_id, payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _message_payload(message: Message, username: str) -> dict:
|
||||||
|
return {
|
||||||
|
"type": "message",
|
||||||
|
"id": str(message.id),
|
||||||
|
"room_id": str(message.room_id),
|
||||||
|
"user_id": str(message.user_id),
|
||||||
|
"username": username,
|
||||||
|
"content": message.content,
|
||||||
|
"created_at": message.created_at.isoformat(),
|
||||||
|
"edited_at": message.edited_at.isoformat() if message.edited_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def broadcast_new_message(
|
||||||
|
db: AsyncSession,
|
||||||
|
broadcaster: RoomBroadcaster,
|
||||||
|
presence: Presence,
|
||||||
|
room_id: uuid.UUID,
|
||||||
|
message: Message,
|
||||||
|
sender: User,
|
||||||
|
) -> None:
|
||||||
|
"""The full side-effect sequence for a newly created message, shared by
|
||||||
|
the WS "message" handler and the incoming-webhook receiver so both
|
||||||
|
trigger identical fan-out/push/event behavior."""
|
||||||
|
payload = _message_payload(message, sender.username)
|
||||||
|
await broadcaster.publish(room_id, payload)
|
||||||
|
await _notify_offline_members(db, presence, room_id, sender, message.content)
|
||||||
|
await dispatch_event(db, "message.created", room_id, payload)
|
||||||
|
|
||||||
|
|
||||||
|
async def broadcast_message_update(
|
||||||
|
db: AsyncSession, broadcaster: RoomBroadcaster, room_id: uuid.UUID, message: Message
|
||||||
|
) -> None:
|
||||||
|
payload = {
|
||||||
|
"type": "message_update",
|
||||||
|
"id": str(message.id),
|
||||||
|
"room_id": str(room_id),
|
||||||
|
"content": message.content,
|
||||||
|
"edited_at": message.edited_at.isoformat() if message.edited_at else None,
|
||||||
|
}
|
||||||
|
await broadcaster.publish(room_id, payload)
|
||||||
|
await dispatch_event(db, "message.updated", room_id, payload)
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -7,6 +8,14 @@ from sqlalchemy.orm import selectinload
|
|||||||
from app.models import Message
|
from app.models import Message
|
||||||
|
|
||||||
|
|
||||||
|
class MessageNotFoundError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NotMessageAuthorError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def create_message(
|
async def create_message(
|
||||||
db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID, content: str
|
db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID, content: str
|
||||||
) -> Message:
|
) -> Message:
|
||||||
@@ -17,6 +26,22 @@ async def create_message(
|
|||||||
return 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(
|
async def list_recent_messages(
|
||||||
db: AsyncSession, room_id: uuid.UUID, limit: int = 50
|
db: AsyncSession, room_id: uuid.UUID, limit: int = 50
|
||||||
) -> list[Message]:
|
) -> list[Message]:
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import ipaddress
|
||||||
|
import socket
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
||||||
|
class UnsafeWebhookUrlError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def validate_target_url(url: str) -> None:
|
||||||
|
"""Creation-time-only SSRF check: rejects non-http(s) schemes and any
|
||||||
|
target whose hostname resolves to a private/loopback/link-local/
|
||||||
|
reserved/multicast address. Not re-checked per delivery, so this doesn't
|
||||||
|
defend against DNS rebinding between creation and a later send -- a
|
||||||
|
documented known limitation, not an oversight.
|
||||||
|
"""
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in ("http", "https"):
|
||||||
|
raise UnsafeWebhookUrlError()
|
||||||
|
if not parsed.hostname:
|
||||||
|
raise UnsafeWebhookUrlError()
|
||||||
|
|
||||||
|
try:
|
||||||
|
addrinfo = socket.getaddrinfo(parsed.hostname, None)
|
||||||
|
except socket.gaierror as exc:
|
||||||
|
raise UnsafeWebhookUrlError() from exc
|
||||||
|
|
||||||
|
for *_rest, sockaddr in addrinfo:
|
||||||
|
ip = ipaddress.ip_address(sockaddr[0])
|
||||||
|
if (
|
||||||
|
ip.is_private
|
||||||
|
or ip.is_loopback
|
||||||
|
or ip.is_link_local
|
||||||
|
or ip.is_reserved
|
||||||
|
or ip.is_multicast
|
||||||
|
or ip.is_unspecified
|
||||||
|
):
|
||||||
|
raise UnsafeWebhookUrlError()
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.models import EventSubscription
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_TIMEOUT_SECONDS = 5.0
|
||||||
|
|
||||||
|
|
||||||
|
async def deliver_event(subscription: EventSubscription, event_type: str, payload: dict) -> None:
|
||||||
|
"""POST a signed event to a subscription's target_url.
|
||||||
|
|
||||||
|
Fire-once, best-effort: no retry/backoff, any failure is logged and
|
||||||
|
swallowed rather than raised -- a slow or dead third-party endpoint must
|
||||||
|
never affect message delivery to real room members. Safe to run in a
|
||||||
|
background asyncio.create_task (unlike the Phase 4 push lesson) because
|
||||||
|
there's no DB session involved here, just the already-serialized
|
||||||
|
payload and secret -- nothing that can outlive an event loop.
|
||||||
|
"""
|
||||||
|
body = json.dumps({"event": event_type, "data": payload}).encode()
|
||||||
|
signature = hmac.new(subscription.signing_secret.encode(), body, hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT_SECONDS) as client:
|
||||||
|
await client.post(
|
||||||
|
subscription.target_url,
|
||||||
|
content=body,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-KeepItTalking-Signature": f"sha256={signature}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except httpx.HTTPError:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to deliver %s event to subscription %s", event_type, subscription.id
|
||||||
|
)
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import asyncio
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import or_, select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.models import EventSubscription, Message, Room, User, WebhookIncoming
|
||||||
|
from app.security import generate_token
|
||||||
|
from app.services.message_service import create_message
|
||||||
|
from app.services.ssrf import validate_target_url
|
||||||
|
from app.services.webhook_delivery import deliver_event
|
||||||
|
|
||||||
|
VALID_EVENT_TYPES = {"message.created", "message.updated"}
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookNotFoundError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidEventTypeError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionNotFoundError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def create_incoming_webhook(
|
||||||
|
db: AsyncSession, actor: User, room_id: uuid.UUID, description: str | None
|
||||||
|
) -> WebhookIncoming:
|
||||||
|
webhook = WebhookIncoming(
|
||||||
|
room_id=room_id, token=generate_token(), created_by=actor.id, description=description
|
||||||
|
)
|
||||||
|
db.add(webhook)
|
||||||
|
try:
|
||||||
|
await db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
# A token collision is astronomically unlikely (256 bits of
|
||||||
|
# randomness) -- surface it rather than silently masking it.
|
||||||
|
await db.rollback()
|
||||||
|
raise
|
||||||
|
await db.refresh(webhook)
|
||||||
|
return webhook
|
||||||
|
|
||||||
|
|
||||||
|
async def list_incoming_webhooks(db: AsyncSession, room_id: uuid.UUID) -> list[WebhookIncoming]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(WebhookIncoming)
|
||||||
|
.where(WebhookIncoming.room_id == room_id)
|
||||||
|
.order_by(WebhookIncoming.created_at)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def revoke_incoming_webhook(
|
||||||
|
db: AsyncSession, room_id: uuid.UUID, webhook_id: uuid.UUID
|
||||||
|
) -> None:
|
||||||
|
webhook = await db.get(WebhookIncoming, webhook_id)
|
||||||
|
if webhook is None or webhook.room_id != room_id:
|
||||||
|
raise WebhookNotFoundError()
|
||||||
|
await db.delete(webhook)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_all_incoming_webhooks_admin(db: AsyncSession) -> list[WebhookIncoming]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(WebhookIncoming)
|
||||||
|
.options(selectinload(WebhookIncoming.room), selectinload(WebhookIncoming.creator))
|
||||||
|
.order_by(WebhookIncoming.created_at.desc())
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def post_via_webhook(db: AsyncSession, token: str, content: str) -> tuple[Message, Room, User]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(WebhookIncoming)
|
||||||
|
.where(WebhookIncoming.token == token)
|
||||||
|
.options(selectinload(WebhookIncoming.room), selectinload(WebhookIncoming.creator))
|
||||||
|
)
|
||||||
|
webhook = result.scalar_one_or_none()
|
||||||
|
if webhook is None:
|
||||||
|
raise WebhookNotFoundError()
|
||||||
|
|
||||||
|
message = await create_message(db, webhook.room_id, webhook.created_by, content)
|
||||||
|
return message, webhook.room, webhook.creator
|
||||||
|
|
||||||
|
|
||||||
|
async def create_event_subscription(
|
||||||
|
db: AsyncSession,
|
||||||
|
actor: User,
|
||||||
|
room_id: uuid.UUID | None,
|
||||||
|
event_types: list[str],
|
||||||
|
target_url: str,
|
||||||
|
) -> tuple[EventSubscription, str]:
|
||||||
|
if not set(event_types) <= VALID_EVENT_TYPES:
|
||||||
|
raise InvalidEventTypeError()
|
||||||
|
validate_target_url(target_url)
|
||||||
|
|
||||||
|
secret = generate_token()
|
||||||
|
subscription = EventSubscription(
|
||||||
|
room_id=room_id,
|
||||||
|
event_types=event_types,
|
||||||
|
target_url=target_url,
|
||||||
|
signing_secret=secret,
|
||||||
|
created_by=actor.id,
|
||||||
|
)
|
||||||
|
db.add(subscription)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(subscription)
|
||||||
|
return subscription, secret
|
||||||
|
|
||||||
|
|
||||||
|
async def list_event_subscriptions(db: AsyncSession, room_id: uuid.UUID) -> list[EventSubscription]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(EventSubscription)
|
||||||
|
.where(EventSubscription.room_id == room_id)
|
||||||
|
.order_by(EventSubscription.created_at)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def revoke_event_subscription(
|
||||||
|
db: AsyncSession, room_id: uuid.UUID, subscription_id: uuid.UUID
|
||||||
|
) -> None:
|
||||||
|
subscription = await db.get(EventSubscription, subscription_id)
|
||||||
|
if subscription is None or subscription.room_id != room_id:
|
||||||
|
raise SubscriptionNotFoundError()
|
||||||
|
await db.delete(subscription)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_all_event_subscriptions_admin(db: AsyncSession) -> list[EventSubscription]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(EventSubscription)
|
||||||
|
.options(selectinload(EventSubscription.room), selectinload(EventSubscription.creator))
|
||||||
|
.order_by(EventSubscription.created_at.desc())
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def dispatch_event(
|
||||||
|
db: AsyncSession, event_type: str, room_id: uuid.UUID, payload: dict
|
||||||
|
) -> None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(EventSubscription).where(
|
||||||
|
or_(EventSubscription.room_id == room_id, EventSubscription.room_id.is_(None))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for subscription in result.scalars().all():
|
||||||
|
if event_type in subscription.event_types:
|
||||||
|
asyncio.create_task(deliver_event(subscription, event_type, payload))
|
||||||
+69
-51
@@ -6,10 +6,15 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import Room, RoomMembership, User
|
from app.models import ApiToken, RoomMembership, User
|
||||||
from app.services.message_service import create_message
|
from app.services.bot_service import resolve_token
|
||||||
from app.services.push_service import send_push_to_user
|
from app.services.message_events import broadcast_message_update, broadcast_new_message
|
||||||
from app.ws.presence import Presence
|
from app.services.message_service import (
|
||||||
|
MessageNotFoundError,
|
||||||
|
NotMessageAuthorError,
|
||||||
|
create_message,
|
||||||
|
edit_message,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(tags=["ws"])
|
router = APIRouter(tags=["ws"])
|
||||||
|
|
||||||
@@ -20,6 +25,7 @@ class ClientEnvelope(BaseModel):
|
|||||||
type: str
|
type: str
|
||||||
room_id: uuid.UUID | None = None
|
room_id: uuid.UUID | None = None
|
||||||
content: str | 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:
|
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
|
return result.scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
|
||||||
async def _notify_offline_members(
|
def _missing_scope(api_token: ApiToken | None, scope: str) -> bool:
|
||||||
db: AsyncSession,
|
return api_token is not None and scope not in api_token.scopes
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/ws/chat")
|
@router.websocket("/ws/chat")
|
||||||
async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)) -> None:
|
async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)) -> None:
|
||||||
user_id_raw = websocket.session.get("user_id")
|
api_token: ApiToken | None = None
|
||||||
if not user_id_raw:
|
|
||||||
await websocket.close(code=WS_UNAUTHENTICATED)
|
|
||||||
return
|
|
||||||
|
|
||||||
user = await db.get(User, uuid.UUID(user_id_raw))
|
# Bots authenticate by setting Authorization on the WS handshake itself
|
||||||
if user is None:
|
# (not a browser cookie) -- same connection type/endpoint a human client
|
||||||
await websocket.close(code=WS_UNAUTHENTICATED)
|
# uses, just a different credential.
|
||||||
return
|
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()
|
await websocket.accept()
|
||||||
manager = websocket.app.state.connection_manager
|
manager = websocket.app.state.connection_manager
|
||||||
presence: Presence = websocket.app.state.presence
|
presence = websocket.app.state.presence
|
||||||
broadcaster = websocket.app.state.broadcaster
|
broadcaster = websocket.app.state.broadcaster
|
||||||
joined_rooms: set[uuid.UUID] = set()
|
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"}
|
{"type": "error", "detail": "room_id and content required"}
|
||||||
)
|
)
|
||||||
continue
|
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(
|
if envelope.room_id not in joined_rooms or not await _is_room_member(
|
||||||
db, envelope.room_id, user.id
|
db, envelope.room_id, user.id
|
||||||
):
|
):
|
||||||
@@ -119,21 +121,37 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
message = await create_message(db, envelope.room_id, user.id, envelope.content)
|
message = await create_message(db, envelope.room_id, user.id, envelope.content)
|
||||||
await broadcaster.publish(
|
await broadcast_new_message(db, broadcaster, presence, envelope.room_id, message, user)
|
||||||
envelope.room_id,
|
|
||||||
{
|
elif envelope.type == "edit":
|
||||||
"type": "message",
|
if envelope.room_id is None or envelope.message_id is None or not envelope.content:
|
||||||
"id": str(message.id),
|
await websocket.send_json(
|
||||||
"room_id": str(message.room_id),
|
{"type": "error", "detail": "room_id, message_id, and content required"}
|
||||||
"user_id": str(message.user_id),
|
)
|
||||||
"username": user.username,
|
continue
|
||||||
"content": message.content,
|
if _missing_scope(api_token, "write:messages"):
|
||||||
"created_at": message.created_at.isoformat(),
|
await websocket.send_json(
|
||||||
},
|
{"type": "error", "detail": "Token missing required scope: write:messages"}
|
||||||
)
|
)
|
||||||
await _notify_offline_members(
|
continue
|
||||||
db, presence, envelope.room_id, user, envelope.content
|
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:
|
else:
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ dependencies = [
|
|||||||
"itsdangerous>=2.2",
|
"itsdangerous>=2.2",
|
||||||
"pywebpush>=2.0",
|
"pywebpush>=2.0",
|
||||||
"redis>=5.0",
|
"redis>=5.0",
|
||||||
|
"httpx>=0.27",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
@@ -25,7 +26,6 @@ chatapp-create-user = "app.cli:main"
|
|||||||
dev = [
|
dev = [
|
||||||
"pytest>=8",
|
"pytest>=8",
|
||||||
"pytest-asyncio>=0.24",
|
"pytest-asyncio>=0.24",
|
||||||
"httpx>=0.27",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.models import AdminAuditLog, User
|
||||||
|
from tests.conftest import login_as, register_and_login
|
||||||
|
|
||||||
|
|
||||||
|
async def _make_admin(db_session, user_id: str) -> None:
|
||||||
|
user = await db_session.get(User, uuid.UUID(user_id))
|
||||||
|
user.is_site_admin = True
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_bot_requires_site_admin(client, db_session):
|
||||||
|
await register_and_login(client, db_session, username="alice")
|
||||||
|
resp = await client.post("/api/admin/bots", json={"username": "helper-bot"})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_bot_and_issue_token(client, db_session):
|
||||||
|
admin = await register_and_login(client, db_session, username="admin1")
|
||||||
|
await _make_admin(db_session, admin["id"])
|
||||||
|
|
||||||
|
bot_resp = await client.post("/api/admin/bots", json={"username": "helper-bot"})
|
||||||
|
assert bot_resp.status_code == 201
|
||||||
|
bot = bot_resp.json()
|
||||||
|
assert bot["username"] == "helper-bot"
|
||||||
|
assert bot["is_active"] is True
|
||||||
|
|
||||||
|
token_resp = await client.post(
|
||||||
|
f"/api/admin/bots/{bot['id']}/tokens",
|
||||||
|
json={"scopes": ["read:messages", "write:messages"]},
|
||||||
|
)
|
||||||
|
assert token_resp.status_code == 201
|
||||||
|
token = token_resp.json()
|
||||||
|
assert token["token"].startswith("kit_")
|
||||||
|
assert token["scopes"] == ["read:messages", "write:messages"]
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(AdminAuditLog).where(AdminAuditLog.target_id == uuid.UUID(bot["id"]))
|
||||||
|
)
|
||||||
|
actions = {e.action for e in result.scalars().all()}
|
||||||
|
assert actions == {"bot.create", "bot.issue_token"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_token_rejects_unknown_scope(client, db_session):
|
||||||
|
admin = await register_and_login(client, db_session, username="admin1")
|
||||||
|
await _make_admin(db_session, admin["id"])
|
||||||
|
bot = (await client.post("/api/admin/bots", json={"username": "helper-bot"})).json()
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/admin/bots/{bot['id']}/tokens", json={"scopes": ["delete:everything"]}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_bots_includes_created_bot(client, db_session):
|
||||||
|
admin = await register_and_login(client, db_session, username="admin1")
|
||||||
|
await _make_admin(db_session, admin["id"])
|
||||||
|
await client.post("/api/admin/bots", json={"username": "helper-bot"})
|
||||||
|
|
||||||
|
resp = await client.get("/api/admin/bots")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
usernames = {b["username"] for b in resp.json()}
|
||||||
|
assert "helper-bot" in usernames
|
||||||
|
|
||||||
|
|
||||||
|
async def test_revoke_token_invalidates_it(client, db_session):
|
||||||
|
admin = await register_and_login(client, db_session, username="admin1")
|
||||||
|
await _make_admin(db_session, admin["id"])
|
||||||
|
bot = (await client.post("/api/admin/bots", json={"username": "helper-bot"})).json()
|
||||||
|
token = (
|
||||||
|
await client.post(f"/api/admin/bots/{bot['id']}/tokens", json={"scopes": ["read:messages"]})
|
||||||
|
).json()
|
||||||
|
|
||||||
|
revoke_resp = await client.delete(f"/api/admin/bots/tokens/{token['id']}")
|
||||||
|
assert revoke_resp.status_code == 204
|
||||||
|
|
||||||
|
# A revoked token should no longer authenticate anything.
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/rooms", headers={"Authorization": f"Bearer {token['token']}"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_invalid_bearer_token_rejected(client):
|
||||||
|
resp = await client.get("/api/rooms", headers={"Authorization": "Bearer kit_not-a-real-token"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def _unique(prefix: str) -> str:
|
||||||
|
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _register_ws(ws_client, username: str) -> dict:
|
||||||
|
from app.schemas.user import UserCreate
|
||||||
|
from app.services.auth_service import register_user
|
||||||
|
|
||||||
|
async def _seed():
|
||||||
|
async with ws_client.session_factory() as session:
|
||||||
|
await register_user(
|
||||||
|
session,
|
||||||
|
UserCreate(username=username, email=f"{username}@example.com", password="password123"),
|
||||||
|
)
|
||||||
|
|
||||||
|
ws_client.portal.call(_seed)
|
||||||
|
resp = ws_client.post(
|
||||||
|
"/api/auth/login", json={"username_or_email": username, "password": "password123"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_admin_ws(ws_client, user_id: str) -> None:
|
||||||
|
async def _promote():
|
||||||
|
async with ws_client.session_factory() as session:
|
||||||
|
user = await session.get(User, uuid.UUID(user_id))
|
||||||
|
user.is_site_admin = True
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
ws_client.portal.call(_promote)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bot_ws_message_with_write_scope_succeeds(ws_client_factory):
|
||||||
|
ws_client = ws_client_factory()
|
||||||
|
admin = _register_ws(ws_client, _unique("admin"))
|
||||||
|
_make_admin_ws(ws_client, admin["id"])
|
||||||
|
|
||||||
|
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||||
|
|
||||||
|
bot = ws_client.post("/api/admin/bots", json={"username": _unique("bot")}).json()
|
||||||
|
token = ws_client.post(
|
||||||
|
f"/api/admin/bots/{bot['id']}/tokens", json={"scopes": ["read:messages", "write:messages"]}
|
||||||
|
).json()["token"]
|
||||||
|
|
||||||
|
# Bot joins the room via REST using its own bearer token -- exercises
|
||||||
|
# bearer-token auth on the plain REST path, not just WS.
|
||||||
|
join_resp = ws_client.post(
|
||||||
|
f"/api/rooms/{room['id']}/join", headers={"Authorization": f"Bearer {token}"}
|
||||||
|
)
|
||||||
|
assert join_resp.status_code == 200
|
||||||
|
|
||||||
|
with ws_client.websocket_connect(
|
||||||
|
"/ws/chat", headers={"Authorization": f"Bearer {token}"}
|
||||||
|
) as ws:
|
||||||
|
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||||
|
assert ws.receive_json()["type"] == "joined"
|
||||||
|
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello from bot"})
|
||||||
|
message = ws.receive_json()
|
||||||
|
assert message["type"] == "message"
|
||||||
|
assert message["content"] == "hello from bot"
|
||||||
|
assert message["username"] == bot["username"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_bot_ws_message_without_write_scope_rejected(ws_client_factory):
|
||||||
|
ws_client = ws_client_factory()
|
||||||
|
admin = _register_ws(ws_client, _unique("admin"))
|
||||||
|
_make_admin_ws(ws_client, admin["id"])
|
||||||
|
|
||||||
|
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||||
|
|
||||||
|
bot = ws_client.post("/api/admin/bots", json={"username": _unique("bot")}).json()
|
||||||
|
token = ws_client.post(
|
||||||
|
f"/api/admin/bots/{bot['id']}/tokens", json={"scopes": ["read:messages"]}
|
||||||
|
).json()["token"]
|
||||||
|
|
||||||
|
ws_client.post(f"/api/rooms/{room['id']}/join", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
|
||||||
|
with ws_client.websocket_connect(
|
||||||
|
"/ws/chat", headers={"Authorization": f"Bearer {token}"}
|
||||||
|
) as ws:
|
||||||
|
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||||
|
assert ws.receive_json()["type"] == "joined"
|
||||||
|
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||||
|
resp = ws.receive_json()
|
||||||
|
assert resp["type"] == "error"
|
||||||
|
assert "write:messages" in resp["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_bot_rest_message_history_requires_read_scope(ws_client_factory):
|
||||||
|
ws_client = ws_client_factory()
|
||||||
|
admin = _register_ws(ws_client, _unique("admin"))
|
||||||
|
_make_admin_ws(ws_client, admin["id"])
|
||||||
|
|
||||||
|
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||||
|
|
||||||
|
bot = ws_client.post("/api/admin/bots", json={"username": _unique("bot")}).json()
|
||||||
|
write_only_token = ws_client.post(
|
||||||
|
f"/api/admin/bots/{bot['id']}/tokens", json={"scopes": ["write:messages"]}
|
||||||
|
).json()["token"]
|
||||||
|
read_token = ws_client.post(
|
||||||
|
f"/api/admin/bots/{bot['id']}/tokens", json={"scopes": ["read:messages"]}
|
||||||
|
).json()["token"]
|
||||||
|
|
||||||
|
ws_client.post(
|
||||||
|
f"/api/rooms/{room['id']}/join", headers={"Authorization": f"Bearer {write_only_token}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
no_scope_resp = ws_client.get(
|
||||||
|
f"/api/rooms/{room['id']}/messages",
|
||||||
|
headers={"Authorization": f"Bearer {write_only_token}"},
|
||||||
|
)
|
||||||
|
assert no_scope_resp.status_code == 403
|
||||||
|
|
||||||
|
with_scope_resp = ws_client.get(
|
||||||
|
f"/api/rooms/{room['id']}/messages", headers={"Authorization": f"Bearer {read_token}"}
|
||||||
|
)
|
||||||
|
assert with_scope_resp.status_code == 200
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.schemas.user import UserCreate
|
||||||
|
from app.services.auth_service import register_user
|
||||||
|
|
||||||
|
|
||||||
|
def _unique(prefix: str) -> str:
|
||||||
|
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _register_ws(ws_client, username: str) -> dict:
|
||||||
|
async def _seed():
|
||||||
|
async with ws_client.session_factory() as session:
|
||||||
|
await register_user(
|
||||||
|
session,
|
||||||
|
UserCreate(username=username, email=f"{username}@example.com", password="password123"),
|
||||||
|
)
|
||||||
|
|
||||||
|
ws_client.portal.call(_seed)
|
||||||
|
resp = ws_client.post(
|
||||||
|
"/api/auth/login", json={"username_or_email": username, "password": "password123"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_ws_edit_updates_content_and_broadcasts(ws_client):
|
||||||
|
username = _unique("alice")
|
||||||
|
_register_ws(ws_client, username=username)
|
||||||
|
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||||
|
|
||||||
|
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||||
|
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||||
|
assert ws.receive_json()["type"] == "joined"
|
||||||
|
|
||||||
|
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||||
|
message = ws.receive_json()
|
||||||
|
assert message["edited_at"] is None
|
||||||
|
|
||||||
|
ws.send_json(
|
||||||
|
{
|
||||||
|
"type": "edit",
|
||||||
|
"room_id": room["id"],
|
||||||
|
"message_id": message["id"],
|
||||||
|
"content": "hello, edited",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
update = ws.receive_json()
|
||||||
|
assert update["type"] == "message_update"
|
||||||
|
assert update["id"] == message["id"]
|
||||||
|
assert update["content"] == "hello, edited"
|
||||||
|
assert update["edited_at"] is not None
|
||||||
|
|
||||||
|
resp = ws_client.get(f"/api/rooms/{room['id']}/messages")
|
||||||
|
history = resp.json()
|
||||||
|
edited = next(m for m in history if m["id"] == message["id"])
|
||||||
|
assert edited["content"] == "hello, edited"
|
||||||
|
assert edited["edited_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_ws_edit_rejects_non_author(ws_client):
|
||||||
|
alice = _register_ws(ws_client, username=_unique("alice"))
|
||||||
|
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||||
|
|
||||||
|
bob = _register_ws(ws_client, username=_unique("bob"))
|
||||||
|
ws_client.post(f"/api/rooms/{room['id']}/join")
|
||||||
|
|
||||||
|
ws_client.post(
|
||||||
|
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
|
||||||
|
)
|
||||||
|
with ws_client.websocket_connect("/ws/chat") as alice_ws:
|
||||||
|
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||||
|
assert alice_ws.receive_json()["type"] == "joined"
|
||||||
|
alice_ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||||
|
message = alice_ws.receive_json()
|
||||||
|
|
||||||
|
ws_client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username_or_email": bob["username"], "password": "password123"},
|
||||||
|
)
|
||||||
|
with ws_client.websocket_connect("/ws/chat") as bob_ws:
|
||||||
|
bob_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||||
|
assert bob_ws.receive_json()["type"] == "joined"
|
||||||
|
bob_ws.send_json(
|
||||||
|
{
|
||||||
|
"type": "edit",
|
||||||
|
"room_id": room["id"],
|
||||||
|
"message_id": message["id"],
|
||||||
|
"content": "hacked",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
resp = bob_ws.receive_json()
|
||||||
|
assert resp["type"] == "error"
|
||||||
|
assert "own messages" in resp["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_fans_out_across_instances(ws_client_factory):
|
||||||
|
instance1 = ws_client_factory()
|
||||||
|
instance2 = ws_client_factory()
|
||||||
|
|
||||||
|
alice = _register_ws(instance1, _unique("alice"))
|
||||||
|
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||||
|
|
||||||
|
bob = _register_ws(instance2, _unique("bob"))
|
||||||
|
instance2.post(f"/api/rooms/{room['id']}/join")
|
||||||
|
|
||||||
|
with instance2.websocket_connect("/ws/chat") as bob_ws:
|
||||||
|
bob_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||||
|
assert bob_ws.receive_json()["type"] == "joined"
|
||||||
|
|
||||||
|
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
||||||
|
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||||
|
assert alice_ws.receive_json()["type"] == "joined"
|
||||||
|
alice_ws.send_json({"type": "message", "room_id": room["id"], "content": "hi"})
|
||||||
|
message = alice_ws.receive_json()
|
||||||
|
assert bob_ws.receive_json()["type"] == "message"
|
||||||
|
|
||||||
|
alice_ws.send_json(
|
||||||
|
{
|
||||||
|
"type": "edit",
|
||||||
|
"room_id": room["id"],
|
||||||
|
"message_id": message["id"],
|
||||||
|
"content": "hi, edited",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert alice_ws.receive_json()["type"] == "message_update"
|
||||||
|
|
||||||
|
update = bob_ws.receive_json()
|
||||||
|
assert update["type"] == "message_update"
|
||||||
|
assert update["content"] == "hi, edited"
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models import PushSubscription
|
||||||
|
from app.schemas.user import UserCreate
|
||||||
|
from app.services.auth_service import register_user
|
||||||
|
from app.services.room_service import join_room
|
||||||
|
from app.services.ssrf import UnsafeWebhookUrlError, validate_target_url
|
||||||
|
from tests.conftest import register_and_login
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_target_url_rejects_loopback():
|
||||||
|
with pytest.raises(UnsafeWebhookUrlError):
|
||||||
|
validate_target_url("http://127.0.0.1/hook")
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_target_url_rejects_private_range():
|
||||||
|
with pytest.raises(UnsafeWebhookUrlError):
|
||||||
|
validate_target_url("http://10.0.0.5/hook")
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_target_url_rejects_non_http_scheme():
|
||||||
|
with pytest.raises(UnsafeWebhookUrlError):
|
||||||
|
validate_target_url("ftp://8.8.8.8/hook")
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_target_url_accepts_public_address():
|
||||||
|
# 8.8.8.8 is a stable, well-known public IP (Google's public DNS
|
||||||
|
# resolver) -- a literal IP so this resolves without any real network
|
||||||
|
# access (getaddrinfo parses a literal IP without touching DNS/the
|
||||||
|
# network), and it isn't flagged by any of ipaddress's private/
|
||||||
|
# reserved/loopback/etc checks, so it exercises the "allowed" path.
|
||||||
|
# (203.0.113.0/24, the usual RFC 5737 documentation-only choice, is
|
||||||
|
# actually flagged is_private by Python's ipaddress module -- not
|
||||||
|
# usable here.)
|
||||||
|
validate_target_url("http://8.8.8.8/hook")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_event_subscription_rejects_private_target(client, db_session):
|
||||||
|
await register_and_login(client, db_session, username="alice")
|
||||||
|
room = (await client.post("/api/rooms", json={"name": "general"})).json()
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/rooms/{room['id']}/event-subscriptions",
|
||||||
|
json={"event_types": ["message.created"], "target_url": "http://127.0.0.1/hook"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_incoming_webhook_unknown_token_404s(client, db_session):
|
||||||
|
await register_and_login(client, db_session, username="alice")
|
||||||
|
resp = await client.post("/api/webhooks/incoming/not-a-real-token", json={"content": "hi"})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_incoming_webhook_posts_message_and_pushes_offline_members(
|
||||||
|
client, db_session, monkeypatch
|
||||||
|
):
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr("app.services.push_service.webpush", lambda **kw: calls.append(kw))
|
||||||
|
|
||||||
|
alice = await register_and_login(client, db_session, username="alice")
|
||||||
|
room = (await client.post("/api/rooms", json={"name": "general"})).json()
|
||||||
|
|
||||||
|
webhook_resp = await client.post(
|
||||||
|
f"/api/rooms/{room['id']}/webhooks/incoming", json={"description": "CI bot"}
|
||||||
|
)
|
||||||
|
assert webhook_resp.status_code == 201
|
||||||
|
webhook = webhook_resp.json()
|
||||||
|
assert webhook["token"]
|
||||||
|
|
||||||
|
bob = await register_user(
|
||||||
|
db_session, UserCreate(username="bob", email="bob@example.com", password="password123")
|
||||||
|
)
|
||||||
|
await join_room(db_session, uuid.UUID(room["id"]), bob.id)
|
||||||
|
db_session.add(
|
||||||
|
PushSubscription(
|
||||||
|
user_id=bob.id,
|
||||||
|
endpoint="https://push.example.com/bob",
|
||||||
|
p256dh_key="p256dh",
|
||||||
|
auth_key="auth",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
post_resp = await client.post(
|
||||||
|
f"/api/webhooks/incoming/{webhook['token']}", json={"content": "deploy succeeded"}
|
||||||
|
)
|
||||||
|
assert post_resp.status_code == 204
|
||||||
|
|
||||||
|
history = (await client.get(f"/api/rooms/{room['id']}/messages")).json()
|
||||||
|
assert any(m["content"] == "deploy succeeded" for m in history)
|
||||||
|
|
||||||
|
# Exactly one push -- to bob. If the sender (webhook creator, alice) were
|
||||||
|
# incorrectly included in "offline members" (no WS connection exists for
|
||||||
|
# either party in this REST-only test), this would be 2.
|
||||||
|
assert len(calls) == 1
|
||||||
|
assert "deploy succeeded" in calls[0]["data"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_outgoing_webhook_delivers_signed_payload(client, db_session, monkeypatch):
|
||||||
|
captured_tasks: list[asyncio.Task] = []
|
||||||
|
real_create_task = asyncio.create_task
|
||||||
|
|
||||||
|
def fake_create_task(coro):
|
||||||
|
task = real_create_task(coro)
|
||||||
|
captured_tasks.append(task)
|
||||||
|
return task
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.webhook_service.asyncio.create_task", fake_create_task)
|
||||||
|
|
||||||
|
posts = []
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
status_code = 200
|
||||||
|
|
||||||
|
class FakeAsyncClient:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def post(self, url, content=None, headers=None):
|
||||||
|
posts.append({"url": url, "content": content, "headers": headers})
|
||||||
|
return FakeResponse()
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.webhook_delivery.httpx.AsyncClient", FakeAsyncClient)
|
||||||
|
|
||||||
|
await register_and_login(client, db_session, username="alice")
|
||||||
|
room = (await client.post("/api/rooms", json={"name": "general"})).json()
|
||||||
|
|
||||||
|
sub_resp = await client.post(
|
||||||
|
f"/api/rooms/{room['id']}/event-subscriptions",
|
||||||
|
json={"event_types": ["message.created"], "target_url": "http://8.8.8.8/hook"},
|
||||||
|
)
|
||||||
|
assert sub_resp.status_code == 201
|
||||||
|
secret = sub_resp.json()["signing_secret"]
|
||||||
|
|
||||||
|
webhook = (
|
||||||
|
await client.post(f"/api/rooms/{room['id']}/webhooks/incoming", json={})
|
||||||
|
).json()
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/webhooks/incoming/{webhook['token']}", json={"content": "ping"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
await asyncio.gather(*captured_tasks)
|
||||||
|
|
||||||
|
assert len(posts) == 1
|
||||||
|
body = posts[0]["content"]
|
||||||
|
expected_signature = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||||
|
assert posts[0]["headers"]["X-KeepItTalking-Signature"] == f"sha256={expected_signature}"
|
||||||
|
payload = json.loads(body)
|
||||||
|
assert payload["event"] == "message.created"
|
||||||
|
assert payload["data"]["content"] == "ping"
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import { apiFetch } from './client'
|
import { apiFetch } from './client'
|
||||||
import type { AdminRoom, AdminUser, AuditLogEntry } from '../types'
|
import type {
|
||||||
|
AdminRoom,
|
||||||
|
AdminUser,
|
||||||
|
AuditLogEntry,
|
||||||
|
EventSubscriptionAdmin,
|
||||||
|
WebhookIncomingAdmin,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
export function listAdminUsers(): Promise<AdminUser[]> {
|
export function listAdminUsers(): Promise<AdminUser[]> {
|
||||||
return apiFetch<AdminUser[]>('/api/admin/users')
|
return apiFetch<AdminUser[]>('/api/admin/users')
|
||||||
@@ -50,3 +56,11 @@ export function transferOwnershipAdmin(roomId: string, newOwnerId: string): Prom
|
|||||||
export function listAuditLog(limit = 50, offset = 0): Promise<AuditLogEntry[]> {
|
export function listAuditLog(limit = 50, offset = 0): Promise<AuditLogEntry[]> {
|
||||||
return apiFetch<AuditLogEntry[]>(`/api/admin/audit-log?limit=${limit}&offset=${offset}`)
|
return apiFetch<AuditLogEntry[]>(`/api/admin/audit-log?limit=${limit}&offset=${offset}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listAllIncomingWebhooks(): Promise<WebhookIncomingAdmin[]> {
|
||||||
|
return apiFetch<WebhookIncomingAdmin[]>('/api/admin/webhooks/incoming')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAllEventSubscriptions(): Promise<EventSubscriptionAdmin[]> {
|
||||||
|
return apiFetch<EventSubscriptionAdmin[]>('/api/admin/event-subscriptions')
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { apiFetch } from './client'
|
||||||
|
import type { ApiScope, ApiToken, ApiTokenCreated, Bot } from '../types'
|
||||||
|
|
||||||
|
export function createBot(username: string): Promise<Bot> {
|
||||||
|
return apiFetch<Bot>('/api/admin/bots', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listBots(): Promise<Bot[]> {
|
||||||
|
return apiFetch<Bot[]>('/api/admin/bots')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createApiToken(botId: string, scopes: ApiScope[]): Promise<ApiTokenCreated> {
|
||||||
|
return apiFetch<ApiTokenCreated>(`/api/admin/bots/${botId}/tokens`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ scopes }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listApiTokens(botId: string): Promise<ApiToken[]> {
|
||||||
|
return apiFetch<ApiToken[]>(`/api/admin/bots/${botId}/tokens`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeApiToken(tokenId: string): Promise<void> {
|
||||||
|
return apiFetch<void>(`/api/admin/bots/tokens/${tokenId}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { apiFetch } from './client'
|
||||||
|
import type {
|
||||||
|
EventSubscription,
|
||||||
|
EventSubscriptionCreated,
|
||||||
|
EventType,
|
||||||
|
WebhookIncoming,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
export function createIncomingWebhook(
|
||||||
|
roomId: string,
|
||||||
|
description?: string,
|
||||||
|
): Promise<WebhookIncoming> {
|
||||||
|
return apiFetch<WebhookIncoming>(`/api/rooms/${roomId}/webhooks/incoming`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ description: description || null }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listIncomingWebhooks(roomId: string): Promise<WebhookIncoming[]> {
|
||||||
|
return apiFetch<WebhookIncoming[]>(`/api/rooms/${roomId}/webhooks/incoming`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeIncomingWebhook(roomId: string, webhookId: string): Promise<void> {
|
||||||
|
return apiFetch<void>(`/api/rooms/${roomId}/webhooks/incoming/${webhookId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEventSubscription(
|
||||||
|
roomId: string,
|
||||||
|
eventTypes: EventType[],
|
||||||
|
targetUrl: string,
|
||||||
|
): Promise<EventSubscriptionCreated> {
|
||||||
|
return apiFetch<EventSubscriptionCreated>(`/api/rooms/${roomId}/event-subscriptions`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ event_types: eventTypes, target_url: targetUrl }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listEventSubscriptions(roomId: string): Promise<EventSubscription[]> {
|
||||||
|
return apiFetch<EventSubscription[]>(`/api/rooms/${roomId}/event-subscriptions`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeEventSubscription(roomId: string, subscriptionId: string): Promise<void> {
|
||||||
|
return apiFetch<void>(`/api/rooms/${roomId}/event-subscriptions/${subscriptionId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -43,6 +43,17 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp
|
|||||||
const onMessage = useCallback((envelope: ServerEnvelope) => {
|
const onMessage = useCallback((envelope: ServerEnvelope) => {
|
||||||
if (envelope.type === 'message') {
|
if (envelope.type === 'message') {
|
||||||
setLive((prev) => [...prev, envelope])
|
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') {
|
} else if (envelope.type === 'error') {
|
||||||
setWsError(envelope.detail)
|
setWsError(envelope.detail)
|
||||||
}
|
}
|
||||||
@@ -50,7 +61,7 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp
|
|||||||
|
|
||||||
const onUnauthenticated = useCallback(() => navigate('/login'), [navigate])
|
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 (
|
return (
|
||||||
<section className="chat-pane">
|
<section className="chat-pane">
|
||||||
@@ -88,7 +99,7 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<MessageList messages={[...history, ...live]} members={members} />
|
<MessageList messages={[...history, ...live]} members={members} onEdit={sendEdit} />
|
||||||
<Composer roomName={room.name} disabled={!connected} onSend={send} />
|
<Composer roomName={room.name} disabled={!connected} onSend={send} />
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message-bubble {
|
.message-bubble {
|
||||||
|
position: relative;
|
||||||
background: var(--ds-surface-2);
|
background: var(--ds-surface-2);
|
||||||
color: var(--ds-text);
|
color: var(--ds-text);
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
@@ -56,9 +57,45 @@
|
|||||||
background: var(--ds-accent-2);
|
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 {
|
.message-time {
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
color: var(--ds-muted);
|
color: var(--ds-muted);
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-edited {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { senderColorIndex } from '../lib/messageGrouping'
|
import { senderColorIndex } from '../lib/messageGrouping'
|
||||||
import type { ChatMessageEnvelope, Message, RoomMember } from '../types'
|
import type { ChatMessageEnvelope, Message, RoomMember } from '../types'
|
||||||
@@ -8,16 +8,30 @@ import './MessageList.css'
|
|||||||
interface MessageListProps {
|
interface MessageListProps {
|
||||||
messages: (Message | ChatMessageEnvelope)[]
|
messages: (Message | ChatMessageEnvelope)[]
|
||||||
members: RoomMember[]
|
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 { user } = useAuth()
|
||||||
const bottomRef = useRef<HTMLDivElement>(null)
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [draft, setDraft] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
bottomRef.current?.scrollIntoView({ block: 'end' })
|
bottomRef.current?.scrollIntoView({ block: 'end' })
|
||||||
}, [messages.length])
|
}, [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 (
|
return (
|
||||||
<div className="message-list">
|
<div className="message-list">
|
||||||
{messages.map((msg, i) => {
|
{messages.map((msg, i) => {
|
||||||
@@ -25,6 +39,7 @@ export function MessageList({ messages, members }: MessageListProps) {
|
|||||||
const prev = messages[i - 1]
|
const prev = messages[i - 1]
|
||||||
const showAvatar = !mine && (!prev || prev.user_id !== msg.user_id)
|
const showAvatar = !mine && (!prev || prev.user_id !== msg.user_id)
|
||||||
const showName = showAvatar
|
const showName = showAvatar
|
||||||
|
const editing = editingId === msg.id
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={msg.id} className={`message-row${mine ? ' message-row-mine' : ''}`}>
|
<div key={msg.id} className={`message-row${mine ? ' message-row-mine' : ''}`}>
|
||||||
@@ -37,9 +52,36 @@ export function MessageList({ messages, members }: MessageListProps) {
|
|||||||
)}
|
)}
|
||||||
<div className="message-bubble-wrap">
|
<div className="message-bubble-wrap">
|
||||||
{showName && <div className="message-author">{msg.username}</div>}
|
{showName && <div className="message-author">{msg.username}</div>}
|
||||||
<div className={`message-bubble${mine ? ' message-bubble-mine' : ''}`}>{msg.content}</div>
|
{editing ? (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
className="message-edit-input"
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') commitEdit(msg.id)
|
||||||
|
if (e.key === 'Escape') setEditingId(null)
|
||||||
|
}}
|
||||||
|
onBlur={() => commitEdit(msg.id)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className={`message-bubble${mine ? ' message-bubble-mine' : ''}`}>
|
||||||
|
{msg.content}
|
||||||
|
{mine && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="message-edit-link"
|
||||||
|
onClick={() => startEdit(msg)}
|
||||||
|
aria-label="Edit message"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="message-time">
|
<div className="message-time">
|
||||||
{new Date(msg.created_at).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
|
{new Date(msg.created_at).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
|
||||||
|
{msg.edited_at && <span className="message-edited"> (edited)</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -206,6 +206,90 @@
|
|||||||
resize: vertical;
|
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 {
|
.room-info-danger-link {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@@ -9,12 +9,30 @@ import {
|
|||||||
transferOwnership,
|
transferOwnership,
|
||||||
updateRoom,
|
updateRoom,
|
||||||
} from '../api/rooms'
|
} from '../api/rooms'
|
||||||
|
import {
|
||||||
|
createEventSubscription,
|
||||||
|
createIncomingWebhook,
|
||||||
|
listEventSubscriptions,
|
||||||
|
listIncomingWebhooks,
|
||||||
|
revokeEventSubscription,
|
||||||
|
revokeIncomingWebhook,
|
||||||
|
} from '../api/webhooks'
|
||||||
import { useAuth } from '../context/AuthContext'
|
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 { RoomAvatar } from './RoomAvatar'
|
||||||
import { UserAvatar } from './UserAvatar'
|
import { UserAvatar } from './UserAvatar'
|
||||||
import './RoomInfoPanel.css'
|
import './RoomInfoPanel.css'
|
||||||
|
|
||||||
|
const EVENT_TYPES: EventType[] = ['message.created', 'message.updated']
|
||||||
|
|
||||||
interface RoomInfoPanelProps {
|
interface RoomInfoPanelProps {
|
||||||
room: MyRoomItem
|
room: MyRoomItem
|
||||||
members: RoomMember[]
|
members: RoomMember[]
|
||||||
@@ -46,6 +64,15 @@ export function RoomInfoPanel({
|
|||||||
const [descDraft, setDescDraft] = useState(room.description ?? '')
|
const [descDraft, setDescDraft] = useState(room.description ?? '')
|
||||||
const [roomError, setRoomError] = useState<string | null>(null)
|
const [roomError, setRoomError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [integrationsOpen, setIntegrationsOpen] = useState(false)
|
||||||
|
const [incomingWebhooks, setIncomingWebhooks] = useState<WebhookIncoming[]>([])
|
||||||
|
const [webhookDescription, setWebhookDescription] = useState('')
|
||||||
|
const [eventSubscriptions, setEventSubscriptions] = useState<EventSubscription[]>([])
|
||||||
|
const [targetUrl, setTargetUrl] = useState('')
|
||||||
|
const [selectedEventTypes, setSelectedEventTypes] = useState<EventType[]>([])
|
||||||
|
const [newSigningSecret, setNewSigningSecret] = useState<string | null>(null)
|
||||||
|
const [integrationsError, setIntegrationsError] = useState<string | null>(null)
|
||||||
|
|
||||||
const canManage = myRole === 'admin' || myRole === 'owner'
|
const canManage = myRole === 'admin' || myRole === 'owner'
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -53,8 +80,12 @@ export function RoomInfoPanel({
|
|||||||
setDescDraft(room.description ?? '')
|
setDescDraft(room.description ?? '')
|
||||||
if (canManage) {
|
if (canManage) {
|
||||||
listRoomInvites(room.id).then(setPendingInvites).catch(() => setPendingInvites([]))
|
listRoomInvites(room.id).then(setPendingInvites).catch(() => setPendingInvites([]))
|
||||||
|
listIncomingWebhooks(room.id).then(setIncomingWebhooks).catch(() => setIncomingWebhooks([]))
|
||||||
|
listEventSubscriptions(room.id).then(setEventSubscriptions).catch(() => setEventSubscriptions([]))
|
||||||
} else {
|
} else {
|
||||||
setPendingInvites([])
|
setPendingInvites([])
|
||||||
|
setIncomingWebhooks([])
|
||||||
|
setEventSubscriptions([])
|
||||||
}
|
}
|
||||||
}, [room.id, room.name, room.description, canManage])
|
}, [room.id, room.name, room.description, canManage])
|
||||||
|
|
||||||
@@ -77,6 +108,50 @@ export function RoomInfoPanel({
|
|||||||
setPendingInvites(await listRoomInvites(room.id))
|
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) {
|
async function handleRemove(userId: string) {
|
||||||
setBusyUserId(userId)
|
setBusyUserId(userId)
|
||||||
try {
|
try {
|
||||||
@@ -252,6 +327,105 @@ export function RoomInfoPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{canManage && (
|
||||||
|
<div className="room-info-section">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="room-info-settings-toggle"
|
||||||
|
onClick={() => setIntegrationsOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
Integrations {integrationsOpen ? '−' : '+'}
|
||||||
|
</button>
|
||||||
|
{integrationsOpen && (
|
||||||
|
<div className="room-info-integrations">
|
||||||
|
{integrationsError && <p className="room-info-error">{integrationsError}</p>}
|
||||||
|
|
||||||
|
<div className="room-info-integration-group">
|
||||||
|
<div className="room-info-label">Incoming webhooks</div>
|
||||||
|
{incomingWebhooks.map((w) => (
|
||||||
|
<div key={w.id} className="room-info-webhook-row">
|
||||||
|
<div className="room-info-webhook-info">
|
||||||
|
<code>{`${window.location.origin}/api/webhooks/incoming/${w.token}`}</code>
|
||||||
|
{w.description && <span className="room-info-webhook-desc">{w.description}</span>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="room-info-danger-link"
|
||||||
|
onClick={() => handleRevokeWebhook(w.id)}
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<form className="room-info-invite-form" onSubmit={handleCreateWebhook}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Description (optional)"
|
||||||
|
value={webhookDescription}
|
||||||
|
onChange={(e) => setWebhookDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn-secondary">
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="room-info-integration-group">
|
||||||
|
<div className="room-info-label">Outgoing event subscriptions</div>
|
||||||
|
{eventSubscriptions.map((s) => (
|
||||||
|
<div key={s.id} className="room-info-webhook-row">
|
||||||
|
<div className="room-info-webhook-info">
|
||||||
|
<code>{s.target_url}</code>
|
||||||
|
<span className="room-info-webhook-desc">{s.event_types.join(', ')}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="room-info-danger-link"
|
||||||
|
onClick={() => handleRevokeSubscription(s.id)}
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<form className="room-info-subscription-form" onSubmit={handleCreateSubscription}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="https://example.com/hook"
|
||||||
|
value={targetUrl}
|
||||||
|
onChange={(e) => setTargetUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="room-info-event-types">
|
||||||
|
{EVENT_TYPES.map((eventType) => (
|
||||||
|
<label key={eventType} className="room-info-event-type-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedEventTypes.includes(eventType)}
|
||||||
|
onChange={() => toggleEventType(eventType)}
|
||||||
|
/>
|
||||||
|
{eventType}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-secondary"
|
||||||
|
disabled={!targetUrl.trim() || selectedEventTypes.length === 0}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{newSigningSecret && (
|
||||||
|
<p className="room-info-signing-secret">
|
||||||
|
New signing secret (copy it now, it won't be shown again):{' '}
|
||||||
|
<code>{newSigningSecret}</code>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{myRole === 'owner' && (
|
{myRole === 'owner' && (
|
||||||
<div className="room-info-section">
|
<div className="room-info-section">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -141,3 +141,90 @@
|
|||||||
.admin-load-more {
|
.admin-load-more {
|
||||||
margin-top: var(--sp-4);
|
margin-top: var(--sp-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-create-form {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
margin-bottom: var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-create-form input {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 280px;
|
||||||
|
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.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-bot-detail {
|
||||||
|
background: var(--ds-void-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-token-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-token-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-token-scopes {
|
||||||
|
font-family: var(--mono);
|
||||||
|
color: var(--ds-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-token-meta {
|
||||||
|
color: var(--ds-muted);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-token-revoke {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--ds-danger);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-issue-token {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-scope-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-family: var(--mono);
|
||||||
|
color: var(--ds-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-new-token {
|
||||||
|
margin-top: var(--sp-3);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--ds-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-new-token code {
|
||||||
|
background: var(--ds-surface-2);
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-subheading {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin: var(--sp-6) 0 var(--sp-3);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { Fragment, useEffect, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
archiveRoom,
|
archiveRoom,
|
||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
demoteUser,
|
demoteUser,
|
||||||
listAdminRooms,
|
listAdminRooms,
|
||||||
listAdminUsers,
|
listAdminUsers,
|
||||||
|
listAllEventSubscriptions,
|
||||||
|
listAllIncomingWebhooks,
|
||||||
listAuditLog,
|
listAuditLog,
|
||||||
promoteUser,
|
promoteUser,
|
||||||
reactivateUser,
|
reactivateUser,
|
||||||
@@ -14,14 +16,25 @@ import {
|
|||||||
unarchiveRoom,
|
unarchiveRoom,
|
||||||
} from '../api/admin'
|
} from '../api/admin'
|
||||||
import { ApiError } from '../api/client'
|
import { ApiError } from '../api/client'
|
||||||
|
import { createApiToken, createBot, listApiTokens, listBots, revokeApiToken } from '../api/bots'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import type { AdminRoom, AdminUser, AuditLogEntry } from '../types'
|
import type {
|
||||||
|
AdminRoom,
|
||||||
|
AdminUser,
|
||||||
|
ApiScope,
|
||||||
|
ApiToken,
|
||||||
|
AuditLogEntry,
|
||||||
|
Bot,
|
||||||
|
EventSubscriptionAdmin,
|
||||||
|
WebhookIncomingAdmin,
|
||||||
|
} from '../types'
|
||||||
import { TopBar } from '../components/TopBar'
|
import { TopBar } from '../components/TopBar'
|
||||||
import './AdminPage.css'
|
import './AdminPage.css'
|
||||||
|
|
||||||
type Tab = 'users' | 'rooms' | 'audit' | 'settings'
|
type Tab = 'users' | 'rooms' | 'bots' | 'audit' | 'settings'
|
||||||
|
|
||||||
const AUDIT_PAGE_SIZE = 50
|
const AUDIT_PAGE_SIZE = 50
|
||||||
|
const ALL_SCOPES: ApiScope[] = ['read:messages', 'write:messages', 'manage:rooms']
|
||||||
|
|
||||||
export function AdminPage() {
|
export function AdminPage() {
|
||||||
const { user: currentUser } = useAuth()
|
const { user: currentUser } = useAuth()
|
||||||
@@ -33,6 +46,15 @@ export function AdminPage() {
|
|||||||
const [busyId, setBusyId] = useState<string | null>(null)
|
const [busyId, setBusyId] = useState<string | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [bots, setBots] = useState<Bot[]>([])
|
||||||
|
const [newBotUsername, setNewBotUsername] = useState('')
|
||||||
|
const [expandedBotId, setExpandedBotId] = useState<string | null>(null)
|
||||||
|
const [tokensByBot, setTokensByBot] = useState<Record<string, ApiToken[]>>({})
|
||||||
|
const [newTokenScopes, setNewTokenScopes] = useState<ApiScope[]>([])
|
||||||
|
const [justCreatedToken, setJustCreatedToken] = useState<string | null>(null)
|
||||||
|
const [incomingWebhooks, setIncomingWebhooks] = useState<WebhookIncomingAdmin[]>([])
|
||||||
|
const [eventSubscriptions, setEventSubscriptions] = useState<EventSubscriptionAdmin[]>([])
|
||||||
|
|
||||||
function reportError(err: unknown) {
|
function reportError(err: unknown) {
|
||||||
setError(err instanceof ApiError ? err.message : String(err))
|
setError(err instanceof ApiError ? err.message : String(err))
|
||||||
}
|
}
|
||||||
@@ -54,12 +76,25 @@ export function AdminPage() {
|
|||||||
.catch(reportError)
|
.catch(reportError)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadBots() {
|
||||||
|
listBots().then(setBots).catch(reportError)
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadWebhooksAdmin() {
|
||||||
|
listAllIncomingWebhooks().then(setIncomingWebhooks).catch(reportError)
|
||||||
|
listAllEventSubscriptions().then(setEventSubscriptions).catch(reportError)
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tab === 'users') loadUsers()
|
if (tab === 'users') loadUsers()
|
||||||
if (tab === 'rooms') {
|
if (tab === 'rooms') {
|
||||||
loadRooms()
|
loadRooms()
|
||||||
if (users.length === 0) loadUsers() // needed to resolve usernames for ownership transfer
|
if (users.length === 0) loadUsers() // needed to resolve usernames for ownership transfer
|
||||||
}
|
}
|
||||||
|
if (tab === 'bots') {
|
||||||
|
loadBots()
|
||||||
|
loadWebhooksAdmin()
|
||||||
|
}
|
||||||
if (tab === 'audit') loadAuditLog()
|
if (tab === 'audit') loadAuditLog()
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [tab])
|
}, [tab])
|
||||||
@@ -119,6 +154,59 @@ export function AdminPage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCreateBot() {
|
||||||
|
const username = newBotUsername.trim()
|
||||||
|
if (!username) return
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await createBot(username)
|
||||||
|
setNewBotUsername('')
|
||||||
|
loadBots()
|
||||||
|
} catch (err) {
|
||||||
|
reportError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExpandBot(botId: string) {
|
||||||
|
if (expandedBotId === botId) {
|
||||||
|
setExpandedBotId(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setExpandedBotId(botId)
|
||||||
|
setNewTokenScopes([])
|
||||||
|
setJustCreatedToken(null)
|
||||||
|
if (!tokensByBot[botId]) {
|
||||||
|
listApiTokens(botId)
|
||||||
|
.then((tokens) => setTokensByBot((prev) => ({ ...prev, [botId]: tokens })))
|
||||||
|
.catch(reportError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleScope(scope: ApiScope) {
|
||||||
|
setNewTokenScopes((prev) =>
|
||||||
|
prev.includes(scope) ? prev.filter((s) => s !== scope) : [...prev, scope],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleIssueToken(botId: string) {
|
||||||
|
if (newTokenScopes.length === 0) return
|
||||||
|
await withBusy(botId, async () => {
|
||||||
|
const created = await createApiToken(botId, newTokenScopes)
|
||||||
|
setJustCreatedToken(created.token)
|
||||||
|
setNewTokenScopes([])
|
||||||
|
const tokens = await listApiTokens(botId)
|
||||||
|
setTokensByBot((prev) => ({ ...prev, [botId]: tokens }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevokeToken(botId: string, tokenId: string) {
|
||||||
|
await withBusy(tokenId, async () => {
|
||||||
|
await revokeApiToken(tokenId)
|
||||||
|
const tokens = await listApiTokens(botId)
|
||||||
|
setTokensByBot((prev) => ({ ...prev, [botId]: tokens }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-page">
|
<div className="admin-page">
|
||||||
<TopBar />
|
<TopBar />
|
||||||
@@ -131,7 +219,7 @@ export function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-tabs" role="tablist">
|
<div className="admin-tabs" role="tablist">
|
||||||
{(['users', 'rooms', 'audit', 'settings'] as const).map((t) => (
|
{(['users', 'rooms', 'bots', 'audit', 'settings'] as const).map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -142,6 +230,7 @@ export function AdminPage() {
|
|||||||
>
|
>
|
||||||
{t === 'users' && 'Users'}
|
{t === 'users' && 'Users'}
|
||||||
{t === 'rooms' && 'Rooms'}
|
{t === 'rooms' && 'Rooms'}
|
||||||
|
{t === 'bots' && 'Bots'}
|
||||||
{t === 'audit' && 'Audit log'}
|
{t === 'audit' && 'Audit log'}
|
||||||
{t === 'settings' && 'Settings'}
|
{t === 'settings' && 'Settings'}
|
||||||
</button>
|
</button>
|
||||||
@@ -237,6 +326,147 @@ export function AdminPage() {
|
|||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tab === 'bots' && (
|
||||||
|
<>
|
||||||
|
<div className="admin-create-form">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Bot username"
|
||||||
|
value={newBotUsername}
|
||||||
|
onChange={(e) => setNewBotUsername(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="button" className="btn-secondary" onClick={handleCreateBot}>
|
||||||
|
Create bot
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{bots.map((b) => (
|
||||||
|
<Fragment key={b.id}>
|
||||||
|
<tr>
|
||||||
|
<td>{b.username}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`status-badge ${b.is_active ? 'active' : 'inactive'}`}>
|
||||||
|
{b.is_active ? 'Active' : 'Deactivated'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{new Date(b.created_at).toLocaleDateString()}</td>
|
||||||
|
<td className="admin-actions">
|
||||||
|
<button type="button" onClick={() => toggleExpandBot(b.id)}>
|
||||||
|
{expandedBotId === b.id ? 'Hide tokens' : 'Manage tokens'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{expandedBotId === b.id && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="admin-bot-detail">
|
||||||
|
<div className="admin-token-list">
|
||||||
|
{(tokensByBot[b.id] ?? []).map((t) => (
|
||||||
|
<div key={t.id} className="admin-token-row">
|
||||||
|
<span className="admin-token-scopes">{t.scopes.join(', ')}</span>
|
||||||
|
<span className="admin-token-meta">
|
||||||
|
{t.last_used_at
|
||||||
|
? `last used ${new Date(t.last_used_at).toLocaleDateString()}`
|
||||||
|
: 'never used'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-token-revoke"
|
||||||
|
disabled={busyId === t.id}
|
||||||
|
onClick={() => handleRevokeToken(b.id, t.id)}
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(tokensByBot[b.id] ?? []).length === 0 && (
|
||||||
|
<p className="admin-placeholder">No tokens yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-issue-token">
|
||||||
|
{ALL_SCOPES.map((scope) => (
|
||||||
|
<label key={scope} className="admin-scope-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={newTokenScopes.includes(scope)}
|
||||||
|
onChange={() => toggleScope(scope)}
|
||||||
|
/>
|
||||||
|
{scope}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-secondary"
|
||||||
|
disabled={newTokenScopes.length === 0 || busyId === b.id}
|
||||||
|
onClick={() => handleIssueToken(b.id)}
|
||||||
|
>
|
||||||
|
Issue token
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{justCreatedToken && (
|
||||||
|
<p className="admin-new-token">
|
||||||
|
New token (copy it now, it won't be shown again):{' '}
|
||||||
|
<code>{justCreatedToken}</code>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2 className="admin-subheading">Registered webhooks</h2>
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Room</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Details</th>
|
||||||
|
<th>Created by</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{incomingWebhooks.map((w) => (
|
||||||
|
<tr key={w.id}>
|
||||||
|
<td>#{w.room_name}</td>
|
||||||
|
<td>Incoming</td>
|
||||||
|
<td>{w.description || '—'}</td>
|
||||||
|
<td>{w.created_by_username}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{eventSubscriptions.map((s) => (
|
||||||
|
<tr key={s.id}>
|
||||||
|
<td>{s.room_name ? `#${s.room_name}` : 'Global'}</td>
|
||||||
|
<td>Outgoing ({s.event_types.join(', ')})</td>
|
||||||
|
<td>{s.target_url}</td>
|
||||||
|
<td>{s.created_by_username}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{incomingWebhooks.length === 0 && eventSubscriptions.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="admin-placeholder">
|
||||||
|
No webhooks registered yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{tab === 'audit' && (
|
{tab === 'audit' && (
|
||||||
<>
|
<>
|
||||||
<table className="admin-table">
|
<table className="admin-table">
|
||||||
|
|||||||
+70
-1
@@ -58,6 +58,7 @@ export interface Message {
|
|||||||
username: string
|
username: string
|
||||||
content: string
|
content: string
|
||||||
created_at: string
|
created_at: string
|
||||||
|
edited_at: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatMessageEnvelope {
|
export interface ChatMessageEnvelope {
|
||||||
@@ -68,6 +69,15 @@ export interface ChatMessageEnvelope {
|
|||||||
username: string
|
username: string
|
||||||
content: string
|
content: string
|
||||||
created_at: 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 {
|
export interface ChatJoinedEnvelope {
|
||||||
@@ -80,7 +90,11 @@ export interface ChatErrorEnvelope {
|
|||||||
detail: string
|
detail: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServerEnvelope = ChatMessageEnvelope | ChatJoinedEnvelope | ChatErrorEnvelope
|
export type ServerEnvelope =
|
||||||
|
| ChatMessageEnvelope
|
||||||
|
| ChatMessageUpdateEnvelope
|
||||||
|
| ChatJoinedEnvelope
|
||||||
|
| ChatErrorEnvelope
|
||||||
|
|
||||||
export interface AdminUser {
|
export interface AdminUser {
|
||||||
id: string
|
id: string
|
||||||
@@ -113,3 +127,58 @@ export interface AuditLogEntry {
|
|||||||
metadata: Record<string, unknown> | null
|
metadata: Record<string, unknown> | null
|
||||||
created_at: string
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,5 +48,11 @@ export function useChatSocket({ roomId, onMessage, onUnauthenticated }: UseChatS
|
|||||||
ws.send(JSON.stringify({ type: 'message', room_id: roomId, content }))
|
ws.send(JSON.stringify({ type: 'message', room_id: roomId, content }))
|
||||||
}, [roomId])
|
}, [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 }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user