Private
Public Access
is_archived was previously only exposed on the admin-only AdminRoom schema and checked in one place (excluding a room from Browse rooms) -- for anyone already a member it was a complete no-op: still in their sidebar, still fully postable, no indication anywhere it was archived. Expose is_archived on the regular RoomRead/MyRoomItem schemas, drop archived rooms from the sidebar list (while keeping them directly reachable via URL so history stays readable), and reject new messages in one -- both the WS "message" handler and incoming webhooks -- with a clear "archived and read-only" response instead of silently no-op'ing or a confusing membership error. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
163 lines
5.1 KiB
Python
163 lines
5.1 KiB
Python
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
|
|
|
|
|
|
class RoomArchivedError(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()
|
|
# #57: same read-only rule as a human posting from the composer -- an
|
|
# archived room shouldn't gain new messages through a bot integration
|
|
# either.
|
|
if webhook.room.is_archived:
|
|
raise RoomArchivedError()
|
|
|
|
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))
|