Private
Public Access
Site admins can invite a brand-new person by email from the Admin portal Users tab -- a signup-link email lets them set their own username/password and lands them in the app already logged in. Existing users invited to a room now also get an email. Closes the "invited but never notified" gap from both directions. SMTP is configured through the Admin Settings tab at runtime (not the env file), persisted in a new smtp_settings table with the password encrypted at rest via a Fernet key derived from SESSION_SECRET -- the first reversible secret this app stores in the database. A "send test email" button surfaces real delivery errors; the invite/notification paths themselves never fail loudly, since an SMTP outage shouldn't block an action that already succeeded in the database. New site_invites table mirrors RoomInvite's shape but targets an email address with no room context; the raw signup token is hashed the same way API tokens are, and only ever exists in the email link. POST /api/signup is the first genuinely public, unauthenticated account-creation endpoint in this app, reusing the existing register_user path for identical validation.
526 lines
17 KiB
Python
526 lines
17 KiB
Python
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 MessageImage, RoomRole, User
|
|
from app.schemas.invite import InviteCreate, InviteRead
|
|
from app.schemas.message import MessageRead
|
|
from app.schemas.message_image import MessageImageCreated
|
|
from app.schemas.room import (
|
|
MyRoomItem,
|
|
RoomCreate,
|
|
RoomListItem,
|
|
RoomMemberRead,
|
|
RoomMemberRoleUpdate,
|
|
RoomRead,
|
|
RoomUpdate,
|
|
TransferOwnershipRequest,
|
|
)
|
|
from app.services.invite_service import (
|
|
AlreadyMemberError,
|
|
DuplicateInviteError,
|
|
InviteNotFoundError,
|
|
InviteNotPendingError,
|
|
TargetUserNotFoundError,
|
|
create_invite,
|
|
list_room_invites,
|
|
revoke_invite,
|
|
)
|
|
from app.schemas.webhook import (
|
|
EventSubscriptionCreate,
|
|
EventSubscriptionCreated,
|
|
EventSubscriptionRead,
|
|
WebhookIncomingCreate,
|
|
WebhookIncomingRead,
|
|
)
|
|
from app.services.message_service import get_reactions_for_messages, list_recent_messages
|
|
from app.services.room_service import (
|
|
CannotRemoveOwnerError,
|
|
DuplicateRoomError,
|
|
InsufficientRoleError,
|
|
MembershipNotFoundError,
|
|
OwnerMustTransferError,
|
|
RoomIsPrivateError,
|
|
RoomNotFoundError,
|
|
change_member_role,
|
|
create_room,
|
|
delete_room,
|
|
get_room,
|
|
join_room,
|
|
leave_room,
|
|
list_member_rooms,
|
|
list_open_rooms,
|
|
list_room_members,
|
|
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 UnsafeWebhookUrlError
|
|
from app.storage import (
|
|
ALLOWED_IMAGE_CONTENT_TYPES,
|
|
UPLOADS_DIR,
|
|
ImageTooLargeError,
|
|
InvalidImageError,
|
|
process_image,
|
|
read_capped,
|
|
save_image,
|
|
)
|
|
|
|
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,
|
|
)
|
|
for room, role 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)
|
|
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.get("/{room_id}/members", response_model=list[RoomMemberRead])
|
|
async def list_room_members_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)
|
|
memberships = await list_room_members(db, room_id)
|
|
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,
|
|
)
|
|
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(
|
|
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"
|
|
)
|
|
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,
|
|
)
|
|
|
|
|
|
@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"
|
|
)
|
|
|
|
|
|
@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])
|
|
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,
|
|
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")
|
|
|
|
try:
|
|
data = await read_capped(file)
|
|
except ImageTooLargeError:
|
|
raise HTTPException(status_code=413, detail="Image exceeds 8 MB 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_image(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"},
|
|
)
|
|
|
|
|
|
def _to_invite_read(invite) -> InviteRead:
|
|
return InviteRead(
|
|
id=invite.id,
|
|
room_id=invite.room_id,
|
|
invited_by=invite.invited_by,
|
|
target_user_id=invite.target_user_id,
|
|
target_username=invite.target_user.username if invite.target_user else None,
|
|
status=invite.status,
|
|
expires_at=invite.expires_at,
|
|
created_at=invite.created_at,
|
|
)
|
|
|
|
|
|
@router.post("/{room_id}/invites", response_model=InviteRead, status_code=201)
|
|
async def create_invite_endpoint(
|
|
room_id: uuid.UUID,
|
|
data: InviteCreate,
|
|
request: Request,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
await require_room_role(room_id, current_user, db, RoomRole.admin)
|
|
try:
|
|
invite = await create_invite(
|
|
db, room_id, current_user.id, data.target_username, str(request.base_url)
|
|
)
|
|
except TargetUserNotFoundError:
|
|
raise HTTPException(status_code=404, detail="No user with that username")
|
|
except AlreadyMemberError:
|
|
raise HTTPException(status_code=409, detail="That user is already a member")
|
|
except DuplicateInviteError:
|
|
raise HTTPException(status_code=409, detail="That user already has a pending invite")
|
|
return _to_invite_read(invite)
|
|
|
|
|
|
@router.get("/{room_id}/invites", response_model=list[InviteRead])
|
|
async def list_room_invites_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)
|
|
invites = await list_room_invites(db, room_id)
|
|
return [_to_invite_read(i) for i in invites]
|
|
|
|
|
|
@router.delete("/{room_id}/invites/{invite_id}", status_code=204)
|
|
async def revoke_invite_endpoint(
|
|
room_id: uuid.UUID,
|
|
invite_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_invite(db, room_id, invite_id)
|
|
except InviteNotFoundError:
|
|
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")
|