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:
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import RoomMembership, RoomRole, User
|
||||
from app.services.bot_service import resolve_token
|
||||
|
||||
_ROLE_RANK = {RoomRole.member: 0, RoomRole.admin: 1, RoomRole.owner: 2}
|
||||
|
||||
@@ -13,6 +14,20 @@ _ROLE_RANK = {RoomRole.member: 0, RoomRole.admin: 1, RoomRole.owner: 2}
|
||||
async def get_current_user(
|
||||
request: Request, db: AsyncSession = Depends(get_db)
|
||||
) -> User:
|
||||
# Bearer token (bots) takes priority over the session cookie (humans) --
|
||||
# a request either carries one or the other, never meaningfully both.
|
||||
# Downstream, a token-authenticated bot is subject to the exact same
|
||||
# room-membership/role checks as a session-authenticated human; the
|
||||
# token additionally narrows what it can do via require_scope below.
|
||||
auth_header = request.headers.get("authorization")
|
||||
if auth_header and auth_header.lower().startswith("bearer "):
|
||||
resolved = await resolve_token(db, auth_header[len("bearer ") :].strip())
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid or revoked API token")
|
||||
user, token = resolved
|
||||
request.state.api_token = token
|
||||
return user
|
||||
|
||||
user_id = request.session.get("user_id")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
@@ -25,6 +40,12 @@ async def get_current_user(
|
||||
return user
|
||||
|
||||
|
||||
def require_scope(request: Request, scope: str) -> None:
|
||||
token = getattr(request.state, "api_token", None)
|
||||
if token is not None and scope not in token.scopes:
|
||||
raise HTTPException(status_code=403, detail=f"Token missing required scope: {scope}")
|
||||
|
||||
|
||||
async def require_room_member(
|
||||
room_id: uuid.UUID, user: User, db: AsyncSession
|
||||
) -> RoomMembership:
|
||||
|
||||
+16
-7
@@ -8,7 +8,7 @@ from redis.asyncio import Redis
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.routers import admin, auth, health, invites, push, rooms
|
||||
from app.routers import admin, auth, bots, health, invites, push, rooms, webhooks
|
||||
from app.ws.broadcaster import RoomBroadcaster
|
||||
from app.ws.chat import router as ws_router
|
||||
from app.ws.connection_manager import ConnectionManager
|
||||
@@ -17,18 +17,22 @@ from app.ws.presence import Presence
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
redis = Redis.from_url(settings.redis_url, decode_responses=True)
|
||||
app.state.presence = Presence(redis)
|
||||
broadcaster = RoomBroadcaster(redis, app.state.connection_manager)
|
||||
app.state.broadcaster = broadcaster
|
||||
listener_task = asyncio.create_task(broadcaster.listen())
|
||||
# redis/presence/broadcaster are constructed in create_app(), not here --
|
||||
# Redis.from_url() is synchronous/lazy (no connection opens until the
|
||||
# first command), so app.state.presence/broadcaster are always present
|
||||
# even for callers that never trigger the ASGI lifespan (e.g. httpx's
|
||||
# ASGITransport, used by the plain REST test fixtures -- only
|
||||
# TestClient's websocket_connect-based tests actually run lifespan).
|
||||
# The background listener task genuinely needs a running event loop
|
||||
# though, so that part stays here.
|
||||
listener_task = asyncio.create_task(app.state.broadcaster.listen())
|
||||
|
||||
yield
|
||||
|
||||
listener_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await listener_task
|
||||
await redis.aclose()
|
||||
await app.state.redis.aclose()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
@@ -43,6 +47,9 @@ def create_app() -> FastAPI:
|
||||
)
|
||||
|
||||
app.state.connection_manager = ConnectionManager()
|
||||
app.state.redis = Redis.from_url(settings.redis_url, decode_responses=True)
|
||||
app.state.presence = Presence(app.state.redis)
|
||||
app.state.broadcaster = RoomBroadcaster(app.state.redis, app.state.connection_manager)
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(auth.router)
|
||||
@@ -50,6 +57,8 @@ def create_app() -> FastAPI:
|
||||
app.include_router(invites.router)
|
||||
app.include_router(push.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(bots.router)
|
||||
app.include_router(webhooks.router)
|
||||
app.include_router(ws_router)
|
||||
|
||||
return app
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
from app.models.admin_audit_log import AdminAuditLog
|
||||
from app.models.api_token import ApiToken
|
||||
from app.models.base import Base
|
||||
from app.models.event_subscription import EventSubscription
|
||||
from app.models.invite import InviteStatus, RoomInvite
|
||||
from app.models.membership import RoomMembership, RoomRole
|
||||
from app.models.message import Message
|
||||
from app.models.push_subscription import PushSubscription
|
||||
from app.models.room import Room
|
||||
from app.models.user import User
|
||||
from app.models.webhook_incoming import WebhookIncoming
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
@@ -18,4 +21,7 @@ __all__ = [
|
||||
"InviteStatus",
|
||||
"PushSubscription",
|
||||
"AdminAuditLog",
|
||||
"ApiToken",
|
||||
"WebhookIncoming",
|
||||
"EventSubscription",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class ApiToken(Base):
|
||||
__tablename__ = "api_tokens"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
owner_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), index=True, nullable=False)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||||
scopes: Mapped[list[str]] = mapped_column(JSONB, nullable=False)
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
owner = relationship("User")
|
||||
@@ -0,0 +1,25 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class EventSubscription(Base):
|
||||
__tablename__ = "event_subscriptions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
room_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("rooms.id"), index=True)
|
||||
event_types: Mapped[list[str]] = mapped_column(JSONB, nullable=False)
|
||||
target_url: Mapped[str] = mapped_column(String(2048), nullable=False)
|
||||
signing_secret: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
created_by: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
room = relationship("Room")
|
||||
creator = relationship("User")
|
||||
@@ -0,0 +1,25 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class WebhookIncoming(Base):
|
||||
__tablename__ = "webhooks_incoming"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
room_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("rooms.id"), index=True, nullable=False)
|
||||
# Stored in the clear (not hashed) -- the whole point is the room admin
|
||||
# can view/copy the full webhook URL again anytime, unlike an API token.
|
||||
token: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||||
created_by: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(String(500))
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
room = relationship("Room")
|
||||
creator = relationship("User")
|
||||
@@ -14,12 +14,12 @@ from app.schemas.admin import (
|
||||
ResetPasswordRequest,
|
||||
TransferOwnershipRequest,
|
||||
)
|
||||
from app.schemas.webhook import EventSubscriptionAdminRead, WebhookIncomingAdminRead
|
||||
from app.services.admin_service import (
|
||||
CannotActOnSelfError,
|
||||
RoomNotFoundError,
|
||||
TargetNotRoomMemberError,
|
||||
UserNotFoundError,
|
||||
list_audit_log,
|
||||
list_rooms_admin,
|
||||
list_users,
|
||||
reset_user_password,
|
||||
@@ -28,6 +28,11 @@ from app.services.admin_service import (
|
||||
set_user_site_admin,
|
||||
transfer_ownership_admin,
|
||||
)
|
||||
from app.services.audit import list_audit_log
|
||||
from app.services.webhook_service import (
|
||||
list_all_event_subscriptions_admin,
|
||||
list_all_incoming_webhooks_admin,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
@@ -223,3 +228,47 @@ async def list_audit_log_endpoint(
|
||||
)
|
||||
for e in entries
|
||||
]
|
||||
|
||||
|
||||
@router.get("/webhooks/incoming", response_model=list[WebhookIncomingAdminRead])
|
||||
async def list_incoming_webhooks_admin_endpoint(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
require_site_admin(current_user)
|
||||
webhooks = await list_all_incoming_webhooks_admin(db)
|
||||
return [
|
||||
WebhookIncomingAdminRead(
|
||||
id=w.id,
|
||||
room_id=w.room_id,
|
||||
token=w.token,
|
||||
created_by=w.created_by,
|
||||
description=w.description,
|
||||
created_at=w.created_at,
|
||||
room_name=w.room.name,
|
||||
created_by_username=w.creator.username,
|
||||
)
|
||||
for w in webhooks
|
||||
]
|
||||
|
||||
|
||||
@router.get("/event-subscriptions", response_model=list[EventSubscriptionAdminRead])
|
||||
async def list_event_subscriptions_admin_endpoint(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
require_site_admin(current_user)
|
||||
subscriptions = await list_all_event_subscriptions_admin(db)
|
||||
return [
|
||||
EventSubscriptionAdminRead(
|
||||
id=s.id,
|
||||
room_id=s.room_id,
|
||||
event_types=s.event_types,
|
||||
target_url=s.target_url,
|
||||
created_by=s.created_by,
|
||||
created_at=s.created_at,
|
||||
room_name=s.room.name if s.room else None,
|
||||
created_by_username=s.creator.username,
|
||||
)
|
||||
for s in subscriptions
|
||||
]
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, require_site_admin
|
||||
from app.models import User
|
||||
from app.schemas.bot import ApiTokenCreate, ApiTokenCreated, ApiTokenRead, BotCreate, BotRead
|
||||
from app.services.bot_service import (
|
||||
BotNotFoundError,
|
||||
DuplicateBotError,
|
||||
InvalidScopeError,
|
||||
TokenNotFoundError,
|
||||
create_api_token,
|
||||
create_bot,
|
||||
list_api_tokens,
|
||||
list_bots,
|
||||
revoke_api_token,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/admin/bots", tags=["bots"])
|
||||
|
||||
|
||||
@router.post("", response_model=BotRead, status_code=201)
|
||||
async def create_bot_endpoint(
|
||||
data: BotCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
require_site_admin(current_user)
|
||||
try:
|
||||
return await create_bot(db, current_user, data.username)
|
||||
except DuplicateBotError:
|
||||
raise HTTPException(status_code=409, detail="A user with this username already exists")
|
||||
|
||||
|
||||
@router.get("", response_model=list[BotRead])
|
||||
async def list_bots_endpoint(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
require_site_admin(current_user)
|
||||
return await list_bots(db)
|
||||
|
||||
|
||||
@router.post("/{bot_id}/tokens", response_model=ApiTokenCreated, status_code=201)
|
||||
async def create_token_endpoint(
|
||||
bot_id: uuid.UUID,
|
||||
data: ApiTokenCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
require_site_admin(current_user)
|
||||
try:
|
||||
token, plaintext = await create_api_token(db, current_user, bot_id, data.scopes)
|
||||
except BotNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Bot not found")
|
||||
except InvalidScopeError:
|
||||
raise HTTPException(status_code=400, detail="Unrecognized scope")
|
||||
return ApiTokenCreated(
|
||||
id=token.id,
|
||||
owner_id=token.owner_id,
|
||||
scopes=token.scopes,
|
||||
last_used_at=token.last_used_at,
|
||||
created_at=token.created_at,
|
||||
token=plaintext,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{bot_id}/tokens", response_model=list[ApiTokenRead])
|
||||
async def list_tokens_endpoint(
|
||||
bot_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
require_site_admin(current_user)
|
||||
return await list_api_tokens(db, bot_id)
|
||||
|
||||
|
||||
@router.delete("/tokens/{token_id}", status_code=204)
|
||||
async def revoke_token_endpoint(
|
||||
token_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
require_site_admin(current_user)
|
||||
try:
|
||||
await revoke_api_token(db, current_user, token_id)
|
||||
except TokenNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Token not found")
|
||||
@@ -1,10 +1,15 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, require_room_member, require_room_role
|
||||
from app.dependencies import (
|
||||
get_current_user,
|
||||
require_room_member,
|
||||
require_room_role,
|
||||
require_scope,
|
||||
)
|
||||
from app.models import RoomRole, User
|
||||
from app.schemas.invite import InviteCreate, InviteRead
|
||||
from app.schemas.message import MessageRead
|
||||
@@ -28,6 +33,13 @@ from app.services.invite_service import (
|
||||
list_room_invites,
|
||||
revoke_invite,
|
||||
)
|
||||
from app.schemas.webhook import (
|
||||
EventSubscriptionCreate,
|
||||
EventSubscriptionCreated,
|
||||
EventSubscriptionRead,
|
||||
WebhookIncomingCreate,
|
||||
WebhookIncomingRead,
|
||||
)
|
||||
from app.services.message_service import list_recent_messages
|
||||
from app.services.room_service import (
|
||||
CannotRemoveOwnerError,
|
||||
@@ -50,6 +62,18 @@ from app.services.room_service import (
|
||||
transfer_ownership,
|
||||
update_room,
|
||||
)
|
||||
from app.services.webhook_service import (
|
||||
InvalidEventTypeError,
|
||||
SubscriptionNotFoundError,
|
||||
WebhookNotFoundError,
|
||||
create_event_subscription,
|
||||
create_incoming_webhook,
|
||||
list_event_subscriptions,
|
||||
list_incoming_webhooks,
|
||||
revoke_event_subscription,
|
||||
revoke_incoming_webhook,
|
||||
)
|
||||
from app.services.ssrf import UnsafeWebhookUrlError
|
||||
|
||||
router = APIRouter(prefix="/api/rooms", tags=["rooms"])
|
||||
|
||||
@@ -252,10 +276,12 @@ async def transfer_ownership_endpoint(
|
||||
@router.get("/{room_id}/messages", response_model=list[MessageRead])
|
||||
async def get_room_messages_endpoint(
|
||||
room_id: uuid.UUID,
|
||||
request: Request,
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
require_scope(request, "read:messages")
|
||||
await require_room_member(room_id, current_user, db)
|
||||
messages = await list_recent_messages(db, room_id, limit)
|
||||
return [
|
||||
@@ -266,6 +292,7 @@ async def get_room_messages_endpoint(
|
||||
username=m.user.username,
|
||||
content=m.content,
|
||||
created_at=m.created_at,
|
||||
edited_at=m.edited_at,
|
||||
)
|
||||
for m in messages
|
||||
]
|
||||
@@ -328,3 +355,93 @@ async def revoke_invite_endpoint(
|
||||
raise HTTPException(status_code=404, detail="Invite not found")
|
||||
except InviteNotPendingError:
|
||||
raise HTTPException(status_code=400, detail="Invite is no longer pending")
|
||||
|
||||
|
||||
@router.post("/{room_id}/webhooks/incoming", response_model=WebhookIncomingRead, status_code=201)
|
||||
async def create_incoming_webhook_endpoint(
|
||||
room_id: uuid.UUID,
|
||||
data: WebhookIncomingCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_room_role(room_id, current_user, db, RoomRole.admin)
|
||||
return await create_incoming_webhook(db, current_user, room_id, data.description)
|
||||
|
||||
|
||||
@router.get("/{room_id}/webhooks/incoming", response_model=list[WebhookIncomingRead])
|
||||
async def list_incoming_webhooks_endpoint(
|
||||
room_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_room_role(room_id, current_user, db, RoomRole.admin)
|
||||
return await list_incoming_webhooks(db, room_id)
|
||||
|
||||
|
||||
@router.delete("/{room_id}/webhooks/incoming/{webhook_id}", status_code=204)
|
||||
async def revoke_incoming_webhook_endpoint(
|
||||
room_id: uuid.UUID,
|
||||
webhook_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_room_role(room_id, current_user, db, RoomRole.admin)
|
||||
try:
|
||||
await revoke_incoming_webhook(db, room_id, webhook_id)
|
||||
except WebhookNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Webhook not found")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{room_id}/event-subscriptions", response_model=EventSubscriptionCreated, status_code=201
|
||||
)
|
||||
async def create_event_subscription_endpoint(
|
||||
room_id: uuid.UUID,
|
||||
data: EventSubscriptionCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_room_role(room_id, current_user, db, RoomRole.admin)
|
||||
try:
|
||||
subscription, secret = await create_event_subscription(
|
||||
db, current_user, room_id, data.event_types, data.target_url
|
||||
)
|
||||
except InvalidEventTypeError:
|
||||
raise HTTPException(status_code=400, detail="Unrecognized event type")
|
||||
except UnsafeWebhookUrlError:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="target_url is not allowed (internal/private address)"
|
||||
)
|
||||
return EventSubscriptionCreated(
|
||||
id=subscription.id,
|
||||
room_id=subscription.room_id,
|
||||
event_types=subscription.event_types,
|
||||
target_url=subscription.target_url,
|
||||
created_by=subscription.created_by,
|
||||
created_at=subscription.created_at,
|
||||
signing_secret=secret,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{room_id}/event-subscriptions", response_model=list[EventSubscriptionRead])
|
||||
async def list_event_subscriptions_endpoint(
|
||||
room_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_room_role(room_id, current_user, db, RoomRole.admin)
|
||||
return await list_event_subscriptions(db, room_id)
|
||||
|
||||
|
||||
@router.delete("/{room_id}/event-subscriptions/{subscription_id}", status_code=204)
|
||||
async def revoke_event_subscription_endpoint(
|
||||
room_id: uuid.UUID,
|
||||
subscription_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_room_role(room_id, current_user, db, RoomRole.admin)
|
||||
try:
|
||||
await revoke_event_subscription(db, room_id, subscription_id)
|
||||
except SubscriptionNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Event subscription not found")
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.schemas.webhook import IncomingWebhookPost
|
||||
from app.services.message_events import broadcast_new_message
|
||||
from app.services.webhook_service import WebhookNotFoundError, post_via_webhook
|
||||
|
||||
router = APIRouter(prefix="/api/webhooks", tags=["webhooks"])
|
||||
|
||||
|
||||
@router.post("/incoming/{token}", status_code=204)
|
||||
async def incoming_webhook_endpoint(
|
||||
token: str,
|
||||
data: IncomingWebhookPost,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
# No auth dependency at all -- the token in the URL is the credential,
|
||||
# per ARCHITECTURE.md's incoming-webhook design.
|
||||
try:
|
||||
message, room, sender = await post_via_webhook(db, token, data.content)
|
||||
except WebhookNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Unknown webhook")
|
||||
|
||||
broadcaster = request.app.state.broadcaster
|
||||
presence = request.app.state.presence
|
||||
await broadcast_new_message(db, broadcaster, presence, room.id, message, sender)
|
||||
@@ -0,0 +1,35 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class BotCreate(BaseModel):
|
||||
username: str = Field(min_length=3, max_length=50)
|
||||
|
||||
|
||||
class BotRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
username: str
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ApiTokenCreate(BaseModel):
|
||||
scopes: list[str] = Field(min_length=1)
|
||||
|
||||
|
||||
class ApiTokenRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
owner_id: uuid.UUID
|
||||
scopes: list[str]
|
||||
last_used_at: datetime | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ApiTokenCreated(ApiTokenRead):
|
||||
token: str
|
||||
@@ -13,3 +13,4 @@ class MessageRead(BaseModel):
|
||||
username: str
|
||||
content: str
|
||||
created_at: datetime
|
||||
edited_at: datetime | None
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class WebhookIncomingCreate(BaseModel):
|
||||
description: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class WebhookIncomingRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
room_id: uuid.UUID
|
||||
token: str
|
||||
created_by: uuid.UUID
|
||||
description: str | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class WebhookIncomingAdminRead(WebhookIncomingRead):
|
||||
room_name: str
|
||||
created_by_username: str
|
||||
|
||||
|
||||
class IncomingWebhookPost(BaseModel):
|
||||
content: str = Field(min_length=1)
|
||||
|
||||
|
||||
class EventSubscriptionCreate(BaseModel):
|
||||
event_types: list[str] = Field(min_length=1)
|
||||
target_url: str = Field(min_length=1, max_length=2048)
|
||||
|
||||
|
||||
class EventSubscriptionRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
room_id: uuid.UUID | None
|
||||
event_types: list[str]
|
||||
target_url: str
|
||||
created_by: uuid.UUID
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class EventSubscriptionCreated(EventSubscriptionRead):
|
||||
signing_secret: str
|
||||
|
||||
|
||||
class EventSubscriptionAdminRead(EventSubscriptionRead):
|
||||
room_name: str | None
|
||||
created_by_username: str
|
||||
@@ -1,3 +1,6 @@
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
|
||||
@@ -13,3 +16,16 @@ def verify_password(password: str, password_hash: str) -> bool:
|
||||
return _hasher.verify(password_hash, password)
|
||||
except VerifyMismatchError:
|
||||
return False
|
||||
|
||||
|
||||
def generate_token() -> str:
|
||||
return f"kit_{secrets.token_urlsafe(32)}"
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
# Deterministic (not argon2) is deliberate: a bearer token has to be
|
||||
# looked up *by itself* (no username to look up first, unlike a
|
||||
# password), and argon2's per-call random salt makes that impossible.
|
||||
# A fast hash of a high-entropy 256-bit random token is the standard
|
||||
# approach for API keys (same as GitHub/Stripe).
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
@@ -2,10 +2,10 @@ import uuid
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import AdminAuditLog, Room, RoomMembership, RoomRole, User
|
||||
from app.models import Room, RoomMembership, RoomRole, User
|
||||
from app.security import hash_password
|
||||
from app.services.audit import record_audit_log
|
||||
|
||||
|
||||
class UserNotFoundError(Exception):
|
||||
@@ -52,25 +52,6 @@ async def _get_membership(
|
||||
return membership
|
||||
|
||||
|
||||
def _log(
|
||||
db: AsyncSession,
|
||||
actor: User,
|
||||
action: str,
|
||||
target_type: str,
|
||||
target_id: uuid.UUID,
|
||||
metadata: dict | None = None,
|
||||
) -> None:
|
||||
db.add(
|
||||
AdminAuditLog(
|
||||
actor_id=actor.id,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
metadata_=metadata,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def list_users(db: AsyncSession) -> list[User]:
|
||||
result = await db.execute(select(User).order_by(User.created_at))
|
||||
return list(result.scalars().all())
|
||||
@@ -83,7 +64,7 @@ async def set_user_active(
|
||||
raise CannotActOnSelfError()
|
||||
user = await _get_user(db, target_user_id)
|
||||
user.is_active = active
|
||||
_log(db, actor, "user.activate" if active else "user.deactivate", "user", user.id)
|
||||
record_audit_log(db, actor, "user.activate" if active else "user.deactivate", "user", user.id)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
@@ -94,7 +75,7 @@ async def reset_user_password(
|
||||
) -> None:
|
||||
user = await _get_user(db, target_user_id)
|
||||
user.password_hash = hash_password(new_password)
|
||||
_log(db, actor, "user.reset_password", "user", user.id)
|
||||
record_audit_log(db, actor, "user.reset_password", "user", user.id)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -105,7 +86,7 @@ async def set_user_site_admin(
|
||||
raise CannotActOnSelfError()
|
||||
user = await _get_user(db, target_user_id)
|
||||
user.is_site_admin = is_admin
|
||||
_log(db, actor, "user.promote" if is_admin else "user.demote", "user", user.id)
|
||||
record_audit_log(db, actor, "user.promote" if is_admin else "user.demote", "user", user.id)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
@@ -126,7 +107,7 @@ async def set_room_archived(
|
||||
) -> Room:
|
||||
room = await _get_room(db, room_id)
|
||||
room.is_archived = archived
|
||||
_log(db, actor, "room.archive" if archived else "room.unarchive", "room", room.id)
|
||||
record_audit_log(db, actor, "room.archive" if archived else "room.unarchive", "room", room.id)
|
||||
await db.commit()
|
||||
await db.refresh(room)
|
||||
return room
|
||||
@@ -145,7 +126,7 @@ async def transfer_ownership_admin(
|
||||
new_owner_membership.role = RoomRole.owner
|
||||
current_owner_membership.role = RoomRole.admin
|
||||
room.owner_id = new_owner_id
|
||||
_log(
|
||||
record_audit_log(
|
||||
db,
|
||||
actor,
|
||||
"room.transfer_ownership",
|
||||
@@ -156,16 +137,3 @@ async def transfer_ownership_admin(
|
||||
await db.commit()
|
||||
await db.refresh(room)
|
||||
return room
|
||||
|
||||
|
||||
async def list_audit_log(
|
||||
db: AsyncSession, limit: int = 50, offset: int = 0
|
||||
) -> list[AdminAuditLog]:
|
||||
result = await db.execute(
|
||||
select(AdminAuditLog)
|
||||
.options(selectinload(AdminAuditLog.actor))
|
||||
.order_by(AdminAuditLog.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import AdminAuditLog, User
|
||||
|
||||
|
||||
def record_audit_log(
|
||||
db: AsyncSession,
|
||||
actor: User,
|
||||
action: str,
|
||||
target_type: str,
|
||||
target_id: uuid.UUID,
|
||||
metadata: dict | None = None,
|
||||
) -> None:
|
||||
db.add(
|
||||
AdminAuditLog(
|
||||
actor_id=actor.id,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
metadata_=metadata,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def list_audit_log(
|
||||
db: AsyncSession, limit: int = 50, offset: int = 0
|
||||
) -> list[AdminAuditLog]:
|
||||
result = await db.execute(
|
||||
select(AdminAuditLog)
|
||||
.options(selectinload(AdminAuditLog.actor))
|
||||
.order_by(AdminAuditLog.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
@@ -0,0 +1,119 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import ApiToken, User
|
||||
from app.security import generate_token, hash_password, hash_token
|
||||
from app.services.audit import record_audit_log
|
||||
|
||||
VALID_SCOPES = {"read:messages", "write:messages", "manage:rooms"}
|
||||
|
||||
|
||||
class DuplicateBotError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class BotNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidScopeError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class TokenNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
async def create_bot(db: AsyncSession, actor: User, username: str) -> User:
|
||||
# Bots never authenticate with a password -- generate one and discard
|
||||
# it. email is NOT NULL/unique today; a placeholder avoids widening the
|
||||
# schema just for accounts that will never receive real mail.
|
||||
# bots.example.com (not e.g. bots.local/.invalid) deliberately, since
|
||||
# pydantic's EmailStr rejects addresses whose *top-level* domain is one
|
||||
# of the IANA special-use TLDs (.local, .invalid, .test, ...) -- a
|
||||
# subdomain of the real (if reserved-for-docs) .com TLD isn't affected.
|
||||
bot = User(
|
||||
username=username,
|
||||
email=f"{username}@bots.example.com",
|
||||
password_hash=hash_password(generate_token()),
|
||||
is_bot=True,
|
||||
)
|
||||
db.add(bot)
|
||||
try:
|
||||
await db.flush()
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
raise DuplicateBotError() from exc
|
||||
|
||||
record_audit_log(db, actor, "bot.create", "user", bot.id)
|
||||
await db.commit()
|
||||
await db.refresh(bot)
|
||||
return bot
|
||||
|
||||
|
||||
async def list_bots(db: AsyncSession) -> list[User]:
|
||||
result = await db.execute(
|
||||
select(User).where(User.is_bot.is_(True)).order_by(User.created_at)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def _get_bot(db: AsyncSession, bot_id: uuid.UUID) -> User:
|
||||
bot = await db.get(User, bot_id)
|
||||
if bot is None or not bot.is_bot:
|
||||
raise BotNotFoundError()
|
||||
return bot
|
||||
|
||||
|
||||
async def create_api_token(
|
||||
db: AsyncSession, actor: User, bot_id: uuid.UUID, scopes: list[str]
|
||||
) -> tuple[ApiToken, str]:
|
||||
bot = await _get_bot(db, bot_id)
|
||||
if not set(scopes) <= VALID_SCOPES:
|
||||
raise InvalidScopeError()
|
||||
|
||||
plaintext = generate_token()
|
||||
token = ApiToken(owner_id=bot.id, token_hash=hash_token(plaintext), scopes=scopes)
|
||||
db.add(token)
|
||||
record_audit_log(
|
||||
db, actor, "bot.issue_token", "user", bot.id, {"scopes": scopes}
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(token)
|
||||
return token, plaintext
|
||||
|
||||
|
||||
async def list_api_tokens(db: AsyncSession, bot_id: uuid.UUID) -> list[ApiToken]:
|
||||
result = await db.execute(
|
||||
select(ApiToken).where(ApiToken.owner_id == bot_id).order_by(ApiToken.created_at)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def revoke_api_token(db: AsyncSession, actor: User, token_id: uuid.UUID) -> None:
|
||||
token = await db.get(ApiToken, token_id)
|
||||
if token is None:
|
||||
raise TokenNotFoundError()
|
||||
await db.delete(token)
|
||||
record_audit_log(db, actor, "bot.revoke_token", "user", token.owner_id)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def resolve_token(db: AsyncSession, plaintext: str) -> tuple[User, ApiToken] | None:
|
||||
result = await db.execute(
|
||||
select(ApiToken)
|
||||
.where(ApiToken.token_hash == hash_token(plaintext))
|
||||
.options(selectinload(ApiToken.owner))
|
||||
)
|
||||
token = result.scalar_one_or_none()
|
||||
if token is None or not token.owner.is_active:
|
||||
return None
|
||||
|
||||
token.last_used_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
return token.owner, token
|
||||
@@ -0,0 +1,79 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Message, Room, RoomMembership, User
|
||||
from app.services.push_service import send_push_to_user
|
||||
from app.services.webhook_service import dispatch_event
|
||||
from app.ws.broadcaster import RoomBroadcaster
|
||||
from app.ws.presence import Presence
|
||||
|
||||
|
||||
async def _notify_offline_members(
|
||||
db: AsyncSession, presence: Presence, room_id: uuid.UUID, sender: User, content: str
|
||||
) -> None:
|
||||
result = await db.execute(
|
||||
select(RoomMembership.user_id).where(RoomMembership.room_id == room_id)
|
||||
)
|
||||
member_ids = {row[0] for row in result.all()}
|
||||
# Subtract the sender explicitly rather than relying on them being
|
||||
# "connected" (true for the WS path, since they just sent this over an
|
||||
# active connection -- not true for the incoming-webhook REST path,
|
||||
# which has no WS connection for the attributed sender at all).
|
||||
offline_ids = member_ids - await presence.connected_user_ids(room_id) - {sender.id}
|
||||
if not offline_ids:
|
||||
return
|
||||
|
||||
room = await db.get(Room, room_id)
|
||||
payload = {
|
||||
"title": f"#{room.name}" if room else "New message",
|
||||
"body": f"{sender.username}: {content}"[:120],
|
||||
"room_id": str(room_id),
|
||||
}
|
||||
for user_id in offline_ids:
|
||||
await send_push_to_user(db, user_id, payload)
|
||||
|
||||
|
||||
def _message_payload(message: Message, username: str) -> dict:
|
||||
return {
|
||||
"type": "message",
|
||||
"id": str(message.id),
|
||||
"room_id": str(message.room_id),
|
||||
"user_id": str(message.user_id),
|
||||
"username": username,
|
||||
"content": message.content,
|
||||
"created_at": message.created_at.isoformat(),
|
||||
"edited_at": message.edited_at.isoformat() if message.edited_at else None,
|
||||
}
|
||||
|
||||
|
||||
async def broadcast_new_message(
|
||||
db: AsyncSession,
|
||||
broadcaster: RoomBroadcaster,
|
||||
presence: Presence,
|
||||
room_id: uuid.UUID,
|
||||
message: Message,
|
||||
sender: User,
|
||||
) -> None:
|
||||
"""The full side-effect sequence for a newly created message, shared by
|
||||
the WS "message" handler and the incoming-webhook receiver so both
|
||||
trigger identical fan-out/push/event behavior."""
|
||||
payload = _message_payload(message, sender.username)
|
||||
await broadcaster.publish(room_id, payload)
|
||||
await _notify_offline_members(db, presence, room_id, sender, message.content)
|
||||
await dispatch_event(db, "message.created", room_id, payload)
|
||||
|
||||
|
||||
async def broadcast_message_update(
|
||||
db: AsyncSession, broadcaster: RoomBroadcaster, room_id: uuid.UUID, message: Message
|
||||
) -> None:
|
||||
payload = {
|
||||
"type": "message_update",
|
||||
"id": str(message.id),
|
||||
"room_id": str(room_id),
|
||||
"content": message.content,
|
||||
"edited_at": message.edited_at.isoformat() if message.edited_at else None,
|
||||
}
|
||||
await broadcaster.publish(room_id, payload)
|
||||
await dispatch_event(db, "message.updated", room_id, payload)
|
||||
@@ -1,4 +1,5 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -7,6 +8,14 @@ from sqlalchemy.orm import selectinload
|
||||
from app.models import Message
|
||||
|
||||
|
||||
class MessageNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NotMessageAuthorError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
async def create_message(
|
||||
db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID, content: str
|
||||
) -> Message:
|
||||
@@ -17,6 +26,22 @@ async def create_message(
|
||||
return message
|
||||
|
||||
|
||||
async def edit_message(
|
||||
db: AsyncSession, message_id: uuid.UUID, editor_id: uuid.UUID, content: str
|
||||
) -> Message:
|
||||
message = await db.get(Message, message_id)
|
||||
if message is None:
|
||||
raise MessageNotFoundError()
|
||||
if message.user_id != editor_id:
|
||||
raise NotMessageAuthorError()
|
||||
|
||||
message.content = content
|
||||
message.edited_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
return message
|
||||
|
||||
|
||||
async def list_recent_messages(
|
||||
db: AsyncSession, room_id: uuid.UUID, limit: int = 50
|
||||
) -> list[Message]:
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import ipaddress
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
class UnsafeWebhookUrlError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def validate_target_url(url: str) -> None:
|
||||
"""Creation-time-only SSRF check: rejects non-http(s) schemes and any
|
||||
target whose hostname resolves to a private/loopback/link-local/
|
||||
reserved/multicast address. Not re-checked per delivery, so this doesn't
|
||||
defend against DNS rebinding between creation and a later send -- a
|
||||
documented known limitation, not an oversight.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise UnsafeWebhookUrlError()
|
||||
if not parsed.hostname:
|
||||
raise UnsafeWebhookUrlError()
|
||||
|
||||
try:
|
||||
addrinfo = socket.getaddrinfo(parsed.hostname, None)
|
||||
except socket.gaierror as exc:
|
||||
raise UnsafeWebhookUrlError() from exc
|
||||
|
||||
for *_rest, sockaddr in addrinfo:
|
||||
ip = ipaddress.ip_address(sockaddr[0])
|
||||
if (
|
||||
ip.is_private
|
||||
or ip.is_loopback
|
||||
or ip.is_link_local
|
||||
or ip.is_reserved
|
||||
or ip.is_multicast
|
||||
or ip.is_unspecified
|
||||
):
|
||||
raise UnsafeWebhookUrlError()
|
||||
@@ -0,0 +1,41 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from app.models import EventSubscription
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
|
||||
async def deliver_event(subscription: EventSubscription, event_type: str, payload: dict) -> None:
|
||||
"""POST a signed event to a subscription's target_url.
|
||||
|
||||
Fire-once, best-effort: no retry/backoff, any failure is logged and
|
||||
swallowed rather than raised -- a slow or dead third-party endpoint must
|
||||
never affect message delivery to real room members. Safe to run in a
|
||||
background asyncio.create_task (unlike the Phase 4 push lesson) because
|
||||
there's no DB session involved here, just the already-serialized
|
||||
payload and secret -- nothing that can outlive an event loop.
|
||||
"""
|
||||
body = json.dumps({"event": event_type, "data": payload}).encode()
|
||||
signature = hmac.new(subscription.signing_secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT_SECONDS) as client:
|
||||
await client.post(
|
||||
subscription.target_url,
|
||||
content=body,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-KeepItTalking-Signature": f"sha256={signature}",
|
||||
},
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
logger.warning(
|
||||
"Failed to deliver %s event to subscription %s", event_type, subscription.id
|
||||
)
|
||||
@@ -0,0 +1,153 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import EventSubscription, Message, Room, User, WebhookIncoming
|
||||
from app.security import generate_token
|
||||
from app.services.message_service import create_message
|
||||
from app.services.ssrf import validate_target_url
|
||||
from app.services.webhook_delivery import deliver_event
|
||||
|
||||
VALID_EVENT_TYPES = {"message.created", "message.updated"}
|
||||
|
||||
|
||||
class WebhookNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidEventTypeError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SubscriptionNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
async def create_incoming_webhook(
|
||||
db: AsyncSession, actor: User, room_id: uuid.UUID, description: str | None
|
||||
) -> WebhookIncoming:
|
||||
webhook = WebhookIncoming(
|
||||
room_id=room_id, token=generate_token(), created_by=actor.id, description=description
|
||||
)
|
||||
db.add(webhook)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
# A token collision is astronomically unlikely (256 bits of
|
||||
# randomness) -- surface it rather than silently masking it.
|
||||
await db.rollback()
|
||||
raise
|
||||
await db.refresh(webhook)
|
||||
return webhook
|
||||
|
||||
|
||||
async def list_incoming_webhooks(db: AsyncSession, room_id: uuid.UUID) -> list[WebhookIncoming]:
|
||||
result = await db.execute(
|
||||
select(WebhookIncoming)
|
||||
.where(WebhookIncoming.room_id == room_id)
|
||||
.order_by(WebhookIncoming.created_at)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def revoke_incoming_webhook(
|
||||
db: AsyncSession, room_id: uuid.UUID, webhook_id: uuid.UUID
|
||||
) -> None:
|
||||
webhook = await db.get(WebhookIncoming, webhook_id)
|
||||
if webhook is None or webhook.room_id != room_id:
|
||||
raise WebhookNotFoundError()
|
||||
await db.delete(webhook)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def list_all_incoming_webhooks_admin(db: AsyncSession) -> list[WebhookIncoming]:
|
||||
result = await db.execute(
|
||||
select(WebhookIncoming)
|
||||
.options(selectinload(WebhookIncoming.room), selectinload(WebhookIncoming.creator))
|
||||
.order_by(WebhookIncoming.created_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def post_via_webhook(db: AsyncSession, token: str, content: str) -> tuple[Message, Room, User]:
|
||||
result = await db.execute(
|
||||
select(WebhookIncoming)
|
||||
.where(WebhookIncoming.token == token)
|
||||
.options(selectinload(WebhookIncoming.room), selectinload(WebhookIncoming.creator))
|
||||
)
|
||||
webhook = result.scalar_one_or_none()
|
||||
if webhook is None:
|
||||
raise WebhookNotFoundError()
|
||||
|
||||
message = await create_message(db, webhook.room_id, webhook.created_by, content)
|
||||
return message, webhook.room, webhook.creator
|
||||
|
||||
|
||||
async def create_event_subscription(
|
||||
db: AsyncSession,
|
||||
actor: User,
|
||||
room_id: uuid.UUID | None,
|
||||
event_types: list[str],
|
||||
target_url: str,
|
||||
) -> tuple[EventSubscription, str]:
|
||||
if not set(event_types) <= VALID_EVENT_TYPES:
|
||||
raise InvalidEventTypeError()
|
||||
validate_target_url(target_url)
|
||||
|
||||
secret = generate_token()
|
||||
subscription = EventSubscription(
|
||||
room_id=room_id,
|
||||
event_types=event_types,
|
||||
target_url=target_url,
|
||||
signing_secret=secret,
|
||||
created_by=actor.id,
|
||||
)
|
||||
db.add(subscription)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
return subscription, secret
|
||||
|
||||
|
||||
async def list_event_subscriptions(db: AsyncSession, room_id: uuid.UUID) -> list[EventSubscription]:
|
||||
result = await db.execute(
|
||||
select(EventSubscription)
|
||||
.where(EventSubscription.room_id == room_id)
|
||||
.order_by(EventSubscription.created_at)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def revoke_event_subscription(
|
||||
db: AsyncSession, room_id: uuid.UUID, subscription_id: uuid.UUID
|
||||
) -> None:
|
||||
subscription = await db.get(EventSubscription, subscription_id)
|
||||
if subscription is None or subscription.room_id != room_id:
|
||||
raise SubscriptionNotFoundError()
|
||||
await db.delete(subscription)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def list_all_event_subscriptions_admin(db: AsyncSession) -> list[EventSubscription]:
|
||||
result = await db.execute(
|
||||
select(EventSubscription)
|
||||
.options(selectinload(EventSubscription.room), selectinload(EventSubscription.creator))
|
||||
.order_by(EventSubscription.created_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def dispatch_event(
|
||||
db: AsyncSession, event_type: str, room_id: uuid.UUID, payload: dict
|
||||
) -> None:
|
||||
result = await db.execute(
|
||||
select(EventSubscription).where(
|
||||
or_(EventSubscription.room_id == room_id, EventSubscription.room_id.is_(None))
|
||||
)
|
||||
)
|
||||
for subscription in result.scalars().all():
|
||||
if event_type in subscription.event_types:
|
||||
asyncio.create_task(deliver_event(subscription, event_type, payload))
|
||||
+69
-51
@@ -6,10 +6,15 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Room, RoomMembership, User
|
||||
from app.services.message_service import create_message
|
||||
from app.services.push_service import send_push_to_user
|
||||
from app.ws.presence import Presence
|
||||
from app.models import ApiToken, RoomMembership, User
|
||||
from app.services.bot_service import resolve_token
|
||||
from app.services.message_events import broadcast_message_update, broadcast_new_message
|
||||
from app.services.message_service import (
|
||||
MessageNotFoundError,
|
||||
NotMessageAuthorError,
|
||||
create_message,
|
||||
edit_message,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["ws"])
|
||||
|
||||
@@ -20,6 +25,7 @@ class ClientEnvelope(BaseModel):
|
||||
type: str
|
||||
room_id: uuid.UUID | None = None
|
||||
content: str | None = None
|
||||
message_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
async def _is_room_member(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> bool:
|
||||
@@ -31,46 +37,37 @@ async def _is_room_member(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UU
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def _notify_offline_members(
|
||||
db: AsyncSession,
|
||||
presence: Presence,
|
||||
room_id: uuid.UUID,
|
||||
sender: User,
|
||||
content: str,
|
||||
) -> None:
|
||||
result = await db.execute(
|
||||
select(RoomMembership.user_id).where(RoomMembership.room_id == room_id)
|
||||
)
|
||||
member_ids = {row[0] for row in result.all()}
|
||||
offline_ids = member_ids - await presence.connected_user_ids(room_id)
|
||||
if not offline_ids:
|
||||
return
|
||||
|
||||
room = await db.get(Room, room_id)
|
||||
payload = {
|
||||
"title": f"#{room.name}" if room else "New message",
|
||||
"body": f"{sender.username}: {content}"[:120],
|
||||
"room_id": str(room_id),
|
||||
}
|
||||
for user_id in offline_ids:
|
||||
await send_push_to_user(db, user_id, payload)
|
||||
def _missing_scope(api_token: ApiToken | None, scope: str) -> bool:
|
||||
return api_token is not None and scope not in api_token.scopes
|
||||
|
||||
|
||||
@router.websocket("/ws/chat")
|
||||
async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)) -> None:
|
||||
user_id_raw = websocket.session.get("user_id")
|
||||
if not user_id_raw:
|
||||
await websocket.close(code=WS_UNAUTHENTICATED)
|
||||
return
|
||||
api_token: ApiToken | None = None
|
||||
|
||||
user = await db.get(User, uuid.UUID(user_id_raw))
|
||||
if user is None:
|
||||
await websocket.close(code=WS_UNAUTHENTICATED)
|
||||
return
|
||||
# Bots authenticate by setting Authorization on the WS handshake itself
|
||||
# (not a browser cookie) -- same connection type/endpoint a human client
|
||||
# uses, just a different credential.
|
||||
auth_header = websocket.headers.get("authorization")
|
||||
if auth_header and auth_header.lower().startswith("bearer "):
|
||||
resolved = await resolve_token(db, auth_header[len("bearer ") :].strip())
|
||||
if resolved is None:
|
||||
await websocket.close(code=WS_UNAUTHENTICATED)
|
||||
return
|
||||
user, api_token = resolved
|
||||
else:
|
||||
user_id_raw = websocket.session.get("user_id")
|
||||
if not user_id_raw:
|
||||
await websocket.close(code=WS_UNAUTHENTICATED)
|
||||
return
|
||||
user = await db.get(User, uuid.UUID(user_id_raw))
|
||||
if user is None or not user.is_active:
|
||||
await websocket.close(code=WS_UNAUTHENTICATED)
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
manager = websocket.app.state.connection_manager
|
||||
presence: Presence = websocket.app.state.presence
|
||||
presence = websocket.app.state.presence
|
||||
broadcaster = websocket.app.state.broadcaster
|
||||
joined_rooms: set[uuid.UUID] = set()
|
||||
|
||||
@@ -111,6 +108,11 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
|
||||
{"type": "error", "detail": "room_id and content required"}
|
||||
)
|
||||
continue
|
||||
if _missing_scope(api_token, "write:messages"):
|
||||
await websocket.send_json(
|
||||
{"type": "error", "detail": "Token missing required scope: write:messages"}
|
||||
)
|
||||
continue
|
||||
if envelope.room_id not in joined_rooms or not await _is_room_member(
|
||||
db, envelope.room_id, user.id
|
||||
):
|
||||
@@ -119,21 +121,37 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
|
||||
)
|
||||
continue
|
||||
message = await create_message(db, envelope.room_id, user.id, envelope.content)
|
||||
await broadcaster.publish(
|
||||
envelope.room_id,
|
||||
{
|
||||
"type": "message",
|
||||
"id": str(message.id),
|
||||
"room_id": str(message.room_id),
|
||||
"user_id": str(message.user_id),
|
||||
"username": user.username,
|
||||
"content": message.content,
|
||||
"created_at": message.created_at.isoformat(),
|
||||
},
|
||||
)
|
||||
await _notify_offline_members(
|
||||
db, presence, envelope.room_id, user, envelope.content
|
||||
)
|
||||
await broadcast_new_message(db, broadcaster, presence, envelope.room_id, message, user)
|
||||
|
||||
elif envelope.type == "edit":
|
||||
if envelope.room_id is None or envelope.message_id is None or not envelope.content:
|
||||
await websocket.send_json(
|
||||
{"type": "error", "detail": "room_id, message_id, and content required"}
|
||||
)
|
||||
continue
|
||||
if _missing_scope(api_token, "write:messages"):
|
||||
await websocket.send_json(
|
||||
{"type": "error", "detail": "Token missing required scope: write:messages"}
|
||||
)
|
||||
continue
|
||||
if envelope.room_id not in joined_rooms or not await _is_room_member(
|
||||
db, envelope.room_id, user.id
|
||||
):
|
||||
await websocket.send_json(
|
||||
{"type": "error", "detail": "Not a member of this room"}
|
||||
)
|
||||
continue
|
||||
try:
|
||||
message = await edit_message(db, envelope.message_id, user.id, envelope.content)
|
||||
except MessageNotFoundError:
|
||||
await websocket.send_json({"type": "error", "detail": "Message not found"})
|
||||
continue
|
||||
except NotMessageAuthorError:
|
||||
await websocket.send_json(
|
||||
{"type": "error", "detail": "You can only edit your own messages"}
|
||||
)
|
||||
continue
|
||||
await broadcast_message_update(db, broadcaster, envelope.room_id, message)
|
||||
|
||||
else:
|
||||
await websocket.send_json(
|
||||
|
||||
Reference in New Issue
Block a user