Private
Public Access
is_private was previously only settable at room creation. RoomUpdate now
accepts it, update_room() applies it, and PATCH /api/rooms/{id} allows a
site admin to make the change even for a room they haven't joined (in
addition to the existing room owner/admin gate) -- require_room_role
normally 403s a non-member before the role check ever runs, so this is a
deliberate bypass for site admins specifically.
Flipping the flag has no effect on existing members either direction
(confirmed is_private is only ever checked at self-serve join time) --
it purely controls Browse Rooms visibility and future self-joins.
Frontend: RoomInfoPanel's "Room settings" section is now visible to room
owner, room admin, or site admin (was owner-only), with a privacy toggle
reusing NewRoomModal's existing toggle-switch UI. "Delete room" stays
owner-only, now nested inside that wider section rather than gating the
whole thing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
660 lines
23 KiB
Python
660 lines
23 KiB
Python
import pathlib
|
|
import uuid
|
|
|
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
|
|
from fastapi.responses import FileResponse
|
|
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,
|
|
require_scope,
|
|
)
|
|
from app.models import MessageFile, MessageImage, RoomRole, User
|
|
from app.schemas.message import LinkPreviewInfo, MessageFileInfo, MessageRead
|
|
from app.schemas.message_file import MessageFileCreated
|
|
from app.schemas.message_image import MessageImageCreated
|
|
from app.schemas.room import (
|
|
MyRoomItem,
|
|
RoomAttachmentRead,
|
|
RoomCreate,
|
|
RoomListItem,
|
|
RoomMemberAdd,
|
|
RoomMemberRead,
|
|
RoomMemberRoleUpdate,
|
|
RoomRead,
|
|
RoomUpdate,
|
|
TransferOwnershipRequest,
|
|
)
|
|
from app.schemas.webhook import (
|
|
EventSubscriptionCreate,
|
|
EventSubscriptionCreated,
|
|
EventSubscriptionRead,
|
|
WebhookIncomingCreate,
|
|
WebhookIncomingRead,
|
|
)
|
|
from app.services.link_preview_service import get_link_previews_for_urls
|
|
from app.services.message_events import broadcast_room_added
|
|
from app.services.message_service import (
|
|
get_reactions_for_messages,
|
|
list_recent_messages,
|
|
list_room_attachments,
|
|
)
|
|
from app.services.upload_settings_service import format_mb, get_upload_settings
|
|
from app.services.room_service import (
|
|
AlreadyMemberError,
|
|
CannotRemoveOwnerError,
|
|
DuplicateRoomError,
|
|
InsufficientRoleError,
|
|
MembershipNotFoundError,
|
|
OwnerMustTransferError,
|
|
RoomIsPrivateError,
|
|
RoomNotFoundError,
|
|
TargetUserNotFoundError,
|
|
add_member,
|
|
change_member_role,
|
|
create_room,
|
|
delete_room,
|
|
get_room,
|
|
join_room,
|
|
leave_room,
|
|
list_member_rooms,
|
|
list_open_rooms,
|
|
list_room_members,
|
|
mark_room_read,
|
|
remove_member,
|
|
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 UnsafeUrlError
|
|
from app.storage import (
|
|
ALLOWED_IMAGE_CONTENT_TYPES,
|
|
UPLOADS_DIR,
|
|
InvalidImageError,
|
|
UploadTooLargeError,
|
|
process_image,
|
|
read_capped,
|
|
save_file,
|
|
)
|
|
|
|
router = APIRouter(prefix="/api/rooms", tags=["rooms"])
|
|
|
|
|
|
@router.post("", response_model=RoomRead, status_code=201)
|
|
async def create_room_endpoint(
|
|
data: RoomCreate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
try:
|
|
return await create_room(db, current_user.id, data)
|
|
except DuplicateRoomError:
|
|
raise HTTPException(status_code=409, detail="A room with this name already exists")
|
|
|
|
|
|
@router.get("", response_model=list[RoomListItem])
|
|
async def list_rooms_endpoint(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
rooms = await list_open_rooms(db, current_user.id)
|
|
return [
|
|
RoomListItem(
|
|
id=room.id,
|
|
name=room.name,
|
|
description=room.description,
|
|
is_private=room.is_private,
|
|
owner_id=room.owner_id,
|
|
created_at=room.created_at,
|
|
is_member=is_member,
|
|
)
|
|
for room, is_member in rooms
|
|
]
|
|
|
|
|
|
@router.get("/mine", response_model=list[MyRoomItem])
|
|
async def list_my_rooms_endpoint(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
rooms = await list_member_rooms(db, current_user.id)
|
|
return [
|
|
MyRoomItem(
|
|
id=room.id,
|
|
name=room.name,
|
|
description=room.description,
|
|
is_private=room.is_private,
|
|
owner_id=room.owner_id,
|
|
created_at=room.created_at,
|
|
role=role,
|
|
has_unread=has_unread,
|
|
has_mention=has_mention,
|
|
)
|
|
for room, role, has_unread, has_mention in rooms
|
|
]
|
|
|
|
|
|
@router.patch("/{room_id}", response_model=RoomRead)
|
|
async def update_room_endpoint(
|
|
room_id: uuid.UUID,
|
|
data: RoomUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
try:
|
|
room = await get_room(db, room_id)
|
|
# Room owner/admin (the pre-existing gate for name/description) or a
|
|
# site admin regardless of membership -- #48 explicitly wants site
|
|
# admins able to toggle is_private even for rooms they haven't
|
|
# joined, unlike require_room_role's normal membership requirement.
|
|
if not current_user.is_site_admin:
|
|
await require_room_role(room_id, current_user, db, RoomRole.admin)
|
|
return await update_room(db, room, data)
|
|
except RoomNotFoundError:
|
|
raise HTTPException(status_code=404, detail="Room not found")
|
|
except DuplicateRoomError:
|
|
raise HTTPException(status_code=409, detail="A room with this name already exists")
|
|
|
|
|
|
@router.delete("/{room_id}", status_code=204)
|
|
async def delete_room_endpoint(
|
|
room_id: uuid.UUID,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
try:
|
|
room = await get_room(db, room_id)
|
|
await require_room_role(room_id, current_user, db, RoomRole.owner)
|
|
await delete_room(db, room)
|
|
except RoomNotFoundError:
|
|
raise HTTPException(status_code=404, detail="Room not found")
|
|
|
|
|
|
@router.post("/{room_id}/join", response_model=RoomRead)
|
|
async def join_room_endpoint(
|
|
room_id: uuid.UUID,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
try:
|
|
await join_room(db, room_id, current_user.id)
|
|
return await get_room(db, room_id)
|
|
except RoomNotFoundError:
|
|
raise HTTPException(status_code=404, detail="Room not found")
|
|
except RoomIsPrivateError:
|
|
raise HTTPException(status_code=400, detail="Cannot join a private room directly")
|
|
|
|
|
|
@router.post("/{room_id}/leave", status_code=204)
|
|
async def leave_room_endpoint(
|
|
room_id: uuid.UUID,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
await require_room_member(room_id, current_user, db)
|
|
try:
|
|
await leave_room(db, room_id, current_user.id)
|
|
except OwnerMustTransferError:
|
|
raise HTTPException(
|
|
status_code=400, detail="Transfer ownership before leaving this room"
|
|
)
|
|
except MembershipNotFoundError:
|
|
raise HTTPException(status_code=404, detail="Not a member of this room")
|
|
|
|
|
|
@router.post("/{room_id}/read", status_code=204)
|
|
async def mark_room_read_endpoint(
|
|
room_id: uuid.UUID,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
await require_room_member(room_id, current_user, db)
|
|
await mark_room_read(db, room_id, current_user.id)
|
|
|
|
|
|
def _member_status(user: User, online_ids: set[uuid.UUID]) -> str:
|
|
# appear_offline always wins, regardless of actual connection -- that's
|
|
# the whole point of the override (lurking in a room undetected).
|
|
return "offline" if user.appear_offline or user.id not in online_ids else "online"
|
|
|
|
|
|
@router.get("/{room_id}/members", response_model=list[RoomMemberRead])
|
|
async def list_room_members_endpoint(
|
|
request: Request,
|
|
room_id: uuid.UUID,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
await require_room_member(room_id, current_user, db)
|
|
memberships = await list_room_members(db, room_id)
|
|
online_ids = await request.app.state.global_presence.online_user_ids(
|
|
[m.user_id for m in memberships]
|
|
)
|
|
return [
|
|
RoomMemberRead(
|
|
user_id=m.user_id,
|
|
username=m.user.username,
|
|
display_name=m.user.display_name,
|
|
avatar_filename=m.user.avatar_filename,
|
|
role=m.role,
|
|
joined_at=m.joined_at,
|
|
status=_member_status(m.user, online_ids),
|
|
)
|
|
for m in memberships
|
|
]
|
|
|
|
|
|
@router.delete("/{room_id}/members/{user_id}", status_code=204)
|
|
async def remove_member_endpoint(
|
|
room_id: uuid.UUID,
|
|
user_id: uuid.UUID,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
membership = await require_room_role(room_id, current_user, db, RoomRole.admin)
|
|
try:
|
|
await remove_member(db, room_id, user_id, acting_role=membership.role)
|
|
except MembershipNotFoundError:
|
|
raise HTTPException(status_code=404, detail="That user is not a member of this room")
|
|
except CannotRemoveOwnerError:
|
|
raise HTTPException(
|
|
status_code=400, detail="Room owner must transfer ownership before being removed"
|
|
)
|
|
except InsufficientRoleError:
|
|
raise HTTPException(status_code=403, detail="Only the owner can remove an admin")
|
|
|
|
|
|
@router.patch("/{room_id}/members/{user_id}", response_model=RoomMemberRead)
|
|
async def change_member_role_endpoint(
|
|
request: Request,
|
|
room_id: uuid.UUID,
|
|
user_id: uuid.UUID,
|
|
data: RoomMemberRoleUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
await require_room_role(room_id, current_user, db, RoomRole.owner)
|
|
try:
|
|
membership = await change_member_role(db, room_id, user_id, data.role)
|
|
except MembershipNotFoundError:
|
|
raise HTTPException(status_code=404, detail="That user is not a member of this room")
|
|
except InsufficientRoleError:
|
|
raise HTTPException(
|
|
status_code=400, detail="Use transfer-ownership to change the room owner"
|
|
)
|
|
online_ids = await request.app.state.global_presence.online_user_ids([membership.user_id])
|
|
return RoomMemberRead(
|
|
user_id=membership.user_id,
|
|
username=membership.user.username,
|
|
display_name=membership.user.display_name,
|
|
avatar_filename=membership.user.avatar_filename,
|
|
role=membership.role,
|
|
joined_at=membership.joined_at,
|
|
status=_member_status(membership.user, online_ids),
|
|
)
|
|
|
|
|
|
@router.post("/{room_id}/transfer-ownership", response_model=RoomRead)
|
|
async def transfer_ownership_endpoint(
|
|
room_id: uuid.UUID,
|
|
data: TransferOwnershipRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
try:
|
|
room = await get_room(db, room_id)
|
|
await require_room_role(room_id, current_user, db, RoomRole.owner)
|
|
return await transfer_ownership(db, room, current_user.id, data.new_owner_user_id)
|
|
except RoomNotFoundError:
|
|
raise HTTPException(status_code=404, detail="Room not found")
|
|
except MembershipNotFoundError:
|
|
raise HTTPException(
|
|
status_code=404, detail="The new owner must already be a member of this room"
|
|
)
|
|
|
|
|
|
def _to_message_file_info(file: MessageFile) -> MessageFileInfo:
|
|
return MessageFileInfo(
|
|
id=file.id,
|
|
filename=file.original_filename,
|
|
size_bytes=file.size_bytes,
|
|
content_type=file.content_type,
|
|
)
|
|
|
|
|
|
@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)
|
|
reactions_by_message = await get_reactions_for_messages(db, [m.id for m in messages])
|
|
preview_urls = {m.preview_url for m in messages if m.preview_url}
|
|
previews_by_url = await get_link_previews_for_urls(db, list(preview_urls))
|
|
return [
|
|
MessageRead(
|
|
id=m.id,
|
|
room_id=m.room_id,
|
|
user_id=m.user_id,
|
|
username=m.user.username,
|
|
content=m.content,
|
|
image_id=m.image_id,
|
|
file=_to_message_file_info(m.file) if m.file else None,
|
|
link_preview=(
|
|
LinkPreviewInfo.model_validate(previews_by_url[m.preview_url])
|
|
if m.preview_url and m.preview_url in previews_by_url
|
|
else None
|
|
),
|
|
reactions=reactions_by_message.get(m.id, []),
|
|
created_at=m.created_at,
|
|
edited_at=m.edited_at,
|
|
)
|
|
for m in messages
|
|
]
|
|
|
|
|
|
@router.post("/{room_id}/images", response_model=MessageImageCreated, status_code=201)
|
|
async def upload_room_image_endpoint(
|
|
room_id: uuid.UUID,
|
|
file: UploadFile = File(...),
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
await require_room_member(room_id, current_user, db)
|
|
|
|
if file.content_type not in ALLOWED_IMAGE_CONTENT_TYPES:
|
|
raise HTTPException(status_code=400, detail="Unsupported image type")
|
|
|
|
upload_settings = await get_upload_settings(db)
|
|
try:
|
|
data = await read_capped(file, cap=upload_settings.max_upload_bytes)
|
|
except UploadTooLargeError:
|
|
raise HTTPException(
|
|
status_code=413,
|
|
detail=f"Image exceeds {format_mb(upload_settings.max_upload_bytes)} limit",
|
|
)
|
|
|
|
try:
|
|
data, ext = process_image(data, file.content_type)
|
|
except InvalidImageError:
|
|
raise HTTPException(status_code=400, detail="File is not a valid image")
|
|
|
|
storage_filename = save_file(data, ext)
|
|
image = MessageImage(
|
|
room_id=room_id,
|
|
uploaded_by=current_user.id,
|
|
storage_filename=storage_filename,
|
|
content_type=file.content_type,
|
|
size_bytes=len(data),
|
|
)
|
|
db.add(image)
|
|
await db.commit()
|
|
await db.refresh(image)
|
|
return MessageImageCreated(id=image.id)
|
|
|
|
|
|
@router.get("/{room_id}/images/{image_id}")
|
|
async def get_room_image_endpoint(
|
|
room_id: uuid.UUID,
|
|
image_id: uuid.UUID,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
await require_room_member(room_id, current_user, db)
|
|
image = await db.get(MessageImage, image_id)
|
|
if image is None or image.room_id != room_id:
|
|
raise HTTPException(status_code=404, detail="Image not found")
|
|
return FileResponse(
|
|
UPLOADS_DIR / image.storage_filename,
|
|
media_type=image.content_type,
|
|
headers={"Cache-Control": "private, max-age=31536000, immutable"},
|
|
)
|
|
|
|
|
|
@router.post("/{room_id}/files", response_model=MessageFileCreated, status_code=201)
|
|
async def upload_room_file_endpoint(
|
|
room_id: uuid.UUID,
|
|
file: UploadFile = File(...),
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
await require_room_member(room_id, current_user, db)
|
|
|
|
upload_settings = await get_upload_settings(db)
|
|
try:
|
|
data = await read_capped(file, cap=upload_settings.max_upload_bytes)
|
|
except UploadTooLargeError:
|
|
raise HTTPException(
|
|
status_code=413,
|
|
detail=f"File exceeds {format_mb(upload_settings.max_upload_bytes)} limit",
|
|
)
|
|
|
|
original_filename = file.filename or "file"
|
|
ext = pathlib.Path(original_filename).suffix
|
|
storage_filename = save_file(data, ext)
|
|
message_file = MessageFile(
|
|
room_id=room_id,
|
|
uploaded_by=current_user.id,
|
|
storage_filename=storage_filename,
|
|
original_filename=original_filename,
|
|
content_type=file.content_type or "application/octet-stream",
|
|
size_bytes=len(data),
|
|
)
|
|
db.add(message_file)
|
|
await db.commit()
|
|
await db.refresh(message_file)
|
|
return MessageFileCreated(
|
|
id=message_file.id,
|
|
filename=message_file.original_filename,
|
|
size_bytes=message_file.size_bytes,
|
|
content_type=message_file.content_type,
|
|
)
|
|
|
|
|
|
@router.get("/{room_id}/files/{file_id}")
|
|
async def get_room_file_endpoint(
|
|
room_id: uuid.UUID,
|
|
file_id: uuid.UUID,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
await require_room_member(room_id, current_user, db)
|
|
message_file = await db.get(MessageFile, file_id)
|
|
if message_file is None or message_file.room_id != room_id:
|
|
raise HTTPException(status_code=404, detail="File not found")
|
|
# `filename=` makes Starlette set Content-Disposition: attachment,
|
|
# forcing a download instead of an inline render regardless of
|
|
# content-type -- the mitigation for a same-origin-served, user-
|
|
# uploaded file (e.g. .html/.svg) executing script in this app's own
|
|
# origin if opened directly. No content-type allowlist needed on top
|
|
# of this; see backend/README.md.
|
|
return FileResponse(
|
|
UPLOADS_DIR / message_file.storage_filename,
|
|
media_type=message_file.content_type,
|
|
filename=message_file.original_filename,
|
|
headers={"Cache-Control": "private, max-age=31536000, immutable"},
|
|
)
|
|
|
|
|
|
@router.get("/{room_id}/attachments", response_model=list[RoomAttachmentRead])
|
|
async def list_room_attachments_endpoint(
|
|
room_id: uuid.UUID,
|
|
limit: int = Query(default=100, ge=1, le=200),
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
await require_room_member(room_id, current_user, db)
|
|
messages = await list_room_attachments(db, room_id, limit)
|
|
attachments: list[RoomAttachmentRead] = []
|
|
for m in messages:
|
|
# A message could in principle carry both an image and a file (the
|
|
# DB doesn't forbid it, even though the composer's UI only ever
|
|
# attaches one) -- emit an entry per attachment actually present
|
|
# rather than assuming exactly one.
|
|
if m.file:
|
|
attachments.append(
|
|
RoomAttachmentRead(
|
|
id=m.file.id,
|
|
kind="file",
|
|
filename=m.file.original_filename,
|
|
content_type=m.file.content_type,
|
|
size_bytes=m.file.size_bytes,
|
|
uploaded_by=m.user.username,
|
|
message_id=m.id,
|
|
created_at=m.created_at,
|
|
)
|
|
)
|
|
if m.image:
|
|
attachments.append(
|
|
RoomAttachmentRead(
|
|
id=m.image.id,
|
|
kind="image",
|
|
filename=None,
|
|
content_type=m.image.content_type,
|
|
size_bytes=m.image.size_bytes,
|
|
uploaded_by=m.user.username,
|
|
message_id=m.id,
|
|
created_at=m.created_at,
|
|
)
|
|
)
|
|
return attachments
|
|
|
|
|
|
@router.post("/{room_id}/members", response_model=RoomMemberRead, status_code=201)
|
|
async def add_member_endpoint(
|
|
room_id: uuid.UUID,
|
|
data: RoomMemberAdd,
|
|
request: Request,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
try:
|
|
room = await get_room(db, room_id)
|
|
await require_room_role(room_id, current_user, db, RoomRole.admin)
|
|
membership = await add_member(db, room, data.user_id, str(request.base_url))
|
|
except RoomNotFoundError:
|
|
raise HTTPException(status_code=404, detail="Room not found")
|
|
except TargetUserNotFoundError:
|
|
raise HTTPException(status_code=404, detail="No user with that ID")
|
|
except AlreadyMemberError:
|
|
raise HTTPException(status_code=409, detail="That user is already a member")
|
|
await broadcast_room_added(request.app.state.broadcaster, data.user_id, room)
|
|
online_ids = await request.app.state.global_presence.online_user_ids([membership.user_id])
|
|
return RoomMemberRead(
|
|
user_id=membership.user_id,
|
|
username=membership.user.username,
|
|
display_name=membership.user.display_name,
|
|
avatar_filename=membership.user.avatar_filename,
|
|
role=membership.role,
|
|
joined_at=membership.joined_at,
|
|
status=_member_status(membership.user, online_ids),
|
|
)
|
|
|
|
|
|
@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 UnsafeUrlError:
|
|
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")
|