diff --git a/spacetimedb/src/reducers.rs b/spacetimedb/src/reducers.rs index 2e1f3b4..ddb8b59 100644 --- a/spacetimedb/src/reducers.rs +++ b/spacetimedb/src/reducers.rs @@ -81,14 +81,16 @@ pub fn clear_upload_status(ctx: &ReducerContext, client_id: String) { #[spacetimedb::reducer] pub fn upload_custom_emoji(ctx: &ReducerContext, name: String, category: String, data: Vec) { if data.len() > 256 * 1024 { - panic!("Emoji image exceeds 256KB limit"); + return report_error(&ctx.db, ctx.sender(), "upload_emoji", "Emoji image exceeds 256KB limit", ctx.timestamp); } + // Note: Emojis are currently global. ctx.db.custom_emoji().insert(CustomEmoji { id: 0, name, category, data, }); + report_success(&ctx.db, ctx.sender(), "upload_emoji", ctx.timestamp); } #[spacetimedb::reducer] @@ -141,12 +143,16 @@ pub fn upload_server_avatar( data: Vec, mime_type: String, ) { - let mut s = ctx - .db - .server() - .id() - .find(server_id) - .expect("Server not found"); + if !has_permission(&ctx.db, ctx.sender(), server_id, PERM_MANAGE_SERVER) { + return report_error(&ctx.db, ctx.sender(), "update_server", "Insufficient permissions", ctx.timestamp); + } + let mut s = match ctx.db.server().id().find(server_id) { + Some(s) => s, + None => return report_error(&ctx.db, ctx.sender(), "update_server", "Server not found", ctx.timestamp), + }; + if data.len() > 4 * 1024 * 1024 { + return report_error(&ctx.db, ctx.sender(), "update_server", "Image exceeds 4MB limit", ctx.timestamp); + } let img = ctx.db.image().insert(Image { id: 0, mime_type, @@ -158,23 +164,29 @@ pub fn upload_server_avatar( }); s.avatar_id = Some(img.id); ctx.db.server().id().update(s); + report_success(&ctx.db, ctx.sender(), "update_server", ctx.timestamp); } #[spacetimedb::reducer] pub fn update_server_name(ctx: &ReducerContext, server_id: u64, name: String) { - validate_name(&name).expect("Invalid name"); - let mut s = ctx - .db - .server() - .id() - .find(server_id) - .expect("Server not found"); - s.name = name; - ctx.db.server().id().update(s); + if !has_permission(&ctx.db, ctx.sender(), server_id, PERM_MANAGE_SERVER) { + return report_error(&ctx.db, ctx.sender(), "update_server", "Insufficient permissions", ctx.timestamp); + } + if let Err(e) = validate_name(&name) { + return report_error(&ctx.db, ctx.sender(), "update_server", &e, ctx.timestamp); + } + if let Some(mut s) = ctx.db.server().id().find(server_id) { + s.name = name; + ctx.db.server().id().update(s); + report_success(&ctx.db, ctx.sender(), "update_server", ctx.timestamp); + } } #[spacetimedb::reducer] pub fn delete_server(ctx: &ReducerContext, server_id: u64) { + if !has_permission(&ctx.db, ctx.sender(), server_id, PERM_DELETE_SERVER) { + return report_error(&ctx.db, ctx.sender(), "delete_server", "Insufficient permissions", ctx.timestamp); + } for c in ctx.db.channel().server_id().filter(server_id) { for msg in ctx.db.message().channel_id().filter(c.id) { ctx.db.message().id().delete(msg.id); @@ -185,6 +197,7 @@ pub fn delete_server(ctx: &ReducerContext, server_id: u64) { ctx.db.server_member().id().delete(m.id); } ctx.db.server().id().delete(server_id); + report_success(&ctx.db, ctx.sender(), "delete_server", ctx.timestamp); } #[spacetimedb::reducer] @@ -202,7 +215,10 @@ pub fn edit_message(ctx: &ReducerContext, message_id: u64, new_text: String) { #[spacetimedb::reducer] pub fn delete_message(ctx: &ReducerContext, message_id: u64) { if let Some(msg) = ctx.db.message().id().find(message_id) { - if msg.sender == ctx.sender() { + let is_owner = msg.sender == ctx.sender(); + let can_moderate = has_permission(&ctx.db, ctx.sender(), msg.server_id, PERM_MODERATE_MESSAGES); + + if is_owner || can_moderate { ctx.db.message().id().delete(message_id); } } @@ -210,18 +226,32 @@ pub fn delete_message(ctx: &ReducerContext, message_id: u64) { #[spacetimedb::reducer] pub fn set_server_public(ctx: &ReducerContext, server_id: u64, public: bool) { - let mut s = ctx - .db - .server() - .id() - .find(server_id) - .expect("Server not found"); - if s.owner == Some(ctx.sender()) { + if !has_permission(&ctx.db, ctx.sender(), server_id, PERM_MANAGE_SERVER) { + return report_error(&ctx.db, ctx.sender(), "update_server", "Insufficient permissions", ctx.timestamp); + } + if let Some(mut s) = ctx.db.server().id().find(server_id) { s.public = public; ctx.db.server().id().update(s); + report_success(&ctx.db, ctx.sender(), "update_server", ctx.timestamp); } } +#[spacetimedb::reducer] +pub fn set_member_permissions(ctx: &ReducerContext, server_id: u64, identity: Identity, permissions: u128) { + if !has_permission(&ctx.db, ctx.sender(), server_id, PERM_MANAGE_ROLES) { + return report_error(&ctx.db, ctx.sender(), "set_permissions", "Insufficient permissions", ctx.timestamp); + } + + let existing = ctx.db.server_permission().server_id().filter(server_id).find(|p| p.identity == identity); + if let Some(mut p) = existing { + p.permissions = permissions; + ctx.db.server_permission().id().update(p); + } else { + ctx.db.server_permission().insert(ServerPermission { id: 0, server_id, identity, permissions }); + } + report_success(&ctx.db, ctx.sender(), "set_permissions", ctx.timestamp); +} + #[spacetimedb::reducer] pub fn toggle_reaction( ctx: &ReducerContext, @@ -398,8 +428,16 @@ pub fn create_server(ctx: &ReducerContext, name: String) { kind: c2.kind, }); ctx.db.server().id().update(s.clone()); - sync_server_access(&ctx.db, ctx.sender(), s.id); + // RBAC: Grant all permissions to the creator + ctx.db.server_permission().insert(ServerPermission { + id: 0, + server_id: s.id, + identity: ctx.sender(), + permissions: u128::MAX, + }); + + sync_server_access(&ctx.db, ctx.sender(), s.id); report_success(&ctx.db, ctx.sender(), "create_server", ctx.timestamp); } @@ -412,19 +450,13 @@ pub fn create_invite( max_uses: Option, expires_in_hrs: Option, ) { - // Only members can invite - if !ctx - .db - .server_member() - .identity() - .filter(ctx.sender()) - .any(|m| m.server_id == server_id) - { + // RBAC: Only members with permission can invite + if !has_permission(&ctx.db, ctx.sender(), server_id, PERM_CREATE_INVITES) { return report_error( &ctx.db, ctx.sender(), "create_invite", - "Only server members can create invites", + "Insufficient permissions", ctx.timestamp, ); } @@ -648,6 +680,9 @@ pub fn leave_server(ctx: &ReducerContext, server_id: u64) { #[spacetimedb::reducer] pub fn create_channel(ctx: &ReducerContext, name: String, server_id: u64, is_voice: bool) { + if !has_permission(&ctx.db, ctx.sender(), server_id, PERM_MANAGE_CHANNELS) { + return report_error(&ctx.db, ctx.sender(), "create_channel", "Insufficient permissions", ctx.timestamp); + } if let Err(e) = validate_name(&name) { return report_error(&ctx.db, ctx.sender(), "create_channel", &e, ctx.timestamp); } @@ -663,15 +698,6 @@ pub fn create_channel(ctx: &ReducerContext, name: String, server_id: u64, is_voi ); } }; - if s.owner != Some(ctx.sender()) { - return report_error( - &ctx.db, - ctx.sender(), - "create_channel", - "Only owner can create channels", - ctx.timestamp, - ); - } let kind = if is_voice { ChannelKind::Voice @@ -698,6 +724,16 @@ pub fn create_channel(ctx: &ReducerContext, name: String, server_id: u64, is_voi #[spacetimedb::reducer] pub fn join_voice(ctx: &ReducerContext, channel_id: u64) { + let chan = match ctx.db.channel().id().find(channel_id) { + Some(c) => c, + None => return report_error(&ctx.db, ctx.sender(), "join_voice", "Channel not found", ctx.timestamp), + }; + + // RBAC: Check voice permission if it's a server channel + if chan.server_id != 0 && !has_permission(&ctx.db, ctx.sender(), chan.server_id, PERM_USE_VOICE) { + return report_error(&ctx.db, ctx.sender(), "join_voice", "Insufficient permissions", ctx.timestamp); + } + if let Some(mut state) = ctx.db.user_state().identity().find(ctx.sender()) { if state.channel_id != channel_id { clear_signaling_for_user(&ctx.db, ctx.sender()); @@ -718,6 +754,7 @@ pub fn join_voice(ctx: &ReducerContext, channel_id: u64) { watching: None, }); } + report_success(&ctx.db, ctx.sender(), "join_voice", ctx.timestamp); } #[spacetimedb::reducer] diff --git a/spacetimedb/src/tables.rs b/spacetimedb/src/tables.rs index f080569..3e2ef47 100644 --- a/spacetimedb/src/tables.rs +++ b/spacetimedb/src/tables.rs @@ -247,6 +247,19 @@ impl From for Message { } } +#[spacetimedb::table(accessor = server_permission)] +#[derive(Clone)] +pub struct ServerPermission { + #[primary_key] + #[auto_inc] + pub id: u64, + #[index(btree)] + pub server_id: u64, + #[index(btree)] + pub identity: Identity, + pub permissions: u128, +} + #[spacetimedb::table(accessor = custom_emoji, public)] #[derive(Clone)] pub struct CustomEmoji { diff --git a/spacetimedb/src/utils.rs b/spacetimedb/src/utils.rs index 75ae167..b2af2ff 100644 --- a/spacetimedb/src/utils.rs +++ b/spacetimedb/src/utils.rs @@ -475,3 +475,24 @@ pub fn report_success_with_payload( last_update: timestamp, }); } + +pub const PERM_MANAGE_SERVER: u128 = 0x01; +pub const PERM_MANAGE_ROLES: u128 = 0x02; +pub const PERM_MANAGE_CHANNELS: u128 = 0x04; +pub const PERM_CREATE_INVITES: u128 = 0x08; +pub const PERM_KICK_MEMBERS: u128 = 0x10; +pub const PERM_BAN_MEMBERS: u128 = 0x20; +pub const PERM_MODERATE_MESSAGES: u128 = 0x40; +pub const PERM_USE_VOICE: u128 = 0x80; +pub const PERM_SHARE_SCREEN: u128 = 0x100; +pub const PERM_USE_THREADS: u128 = 0x200; +pub const PERM_MANAGE_EMOJIS: u128 = 0x400; +pub const PERM_DELETE_SERVER: u128 = 0x800; + +pub fn has_permission(db: &Local, identity: Identity, server_id: u64, bit: u128) -> bool { + if let Some(perm) = db.server_permission().server_id().filter(server_id) + .find(|p| p.identity == identity) { + return (perm.permissions & bit) != 0; + } + false +} diff --git a/spacetimedb/src/views.rs b/spacetimedb/src/views.rs index 93aa411..9eb3790 100644 --- a/spacetimedb/src/views.rs +++ b/spacetimedb/src/views.rs @@ -242,6 +242,22 @@ pub fn visible_thread_messages(ctx: &ViewContext) -> Vec { results } +#[spacetimedb::view(accessor = visible_server_permissions, public)] +pub fn visible_server_permissions(ctx: &ViewContext) -> Vec { + let identity = ctx.sender(); + let mut results = Vec::new(); + + // Show me everyone's permissions for servers I share with them + // (This allows mods to see who else is a mod) + for member in ctx.db.server_member().identity().filter(identity) { + for perm in ctx.db.server_permission().server_id().filter(member.server_id) { + results.push(perm.clone()); + } + } + + results +} + #[spacetimedb::view(accessor = visible_images, public)] pub fn visible_images(ctx: &ViewContext) -> Vec { let image_ids = get_visible_image_ids_read_only(&ctx.db, ctx.sender()); diff --git a/src/chat/ChatContainer.svelte b/src/chat/ChatContainer.svelte index 78f5c80..7457685 100644 --- a/src/chat/ChatContainer.svelte +++ b/src/chat/ChatContainer.svelte @@ -158,17 +158,19 @@
- + {#if chat.can(Permissions.SHARE_SCREEN)} + + {/if} diff --git a/src/chat/components/ChatInput.svelte b/src/chat/components/ChatInput.svelte index 6076697..95e23d8 100644 --- a/src/chat/components/ChatInput.svelte +++ b/src/chat/components/ChatInput.svelte @@ -332,6 +332,19 @@ const currentText = messageText; const currentStaged = [...stagedImages]; + // RBAC: Thread Creation requires PERM_USE_THREADS + if (!activeThreadId && chat.pendingThreadParentMessageId) { + if (!chat.can(Permissions.USE_THREADS)) { + chat.confirmModal = { + title: "Insufficient Permissions", + message: "You do not have permission to start threads in this channel.", + confirmText: "Close", + onConfirm: () => {} + }; + return; + } + } + const byteLength = new TextEncoder().encode(currentText).length; if (byteLength > chat.maxMessageLength) { uploadError = `Message exceeds maximum length of ${chat.maxMessageLength} bytes (${Math.round(chat.maxMessageLength / 1024)}KB).`; diff --git a/src/chat/components/MessageItem.svelte b/src/chat/components/MessageItem.svelte index c114fee..0cddff1 100644 --- a/src/chat/components/MessageItem.svelte +++ b/src/chat/components/MessageItem.svelte @@ -2,6 +2,7 @@ import { getContext, tick, untrack } from "svelte"; import { portal } from "../../portal"; import type { ChatService } from "../services/chat.svelte"; + import { Permissions } from "../services/chat.svelte"; import RichText from "./RichText.svelte"; import EmojiPicker from "./EmojiPicker.svelte"; import Avatar from "./Avatar.svelte"; @@ -164,6 +165,10 @@ const msgUsername = $derived(chat.getUsername(msg.sender)); const existingThread = $derived(chat.allThreads.find((t) => t.parentMessageId === msg.id)); + const isMe = $derived(chat.identity?.isEqual(msg.sender)); + const canModerate = $derived(chat.can(Permissions.MODERATE_MESSAGES)); + const canUseThreads = $derived(chat.can(Permissions.USE_THREADS)); + // Reactions and images are now nested in the message object const emojiGroups = $derived.by(() => { const groups: Record = {}; @@ -355,7 +360,7 @@
{/if} - {#if !isThread && chat.isFullyAuthenticated} + {#if !isThread && (canUseThreads || existingThread)} {/if} + + {#if isMe || canModerate} + + {/if} {#if isGrouped} diff --git a/src/chat/components/ServerDiscovery.svelte b/src/chat/components/ServerDiscovery.svelte index 5a4a658..f2884b8 100644 --- a/src/chat/components/ServerDiscovery.svelte +++ b/src/chat/components/ServerDiscovery.svelte @@ -68,7 +68,7 @@ if (codeFromUrl) { finalCode = codeFromUrl; } - } catch (e) { + } catch { // Not a valid URL, treat as raw code } } diff --git a/src/chat/components/ServerSettingsPanel.svelte b/src/chat/components/ServerSettingsPanel.svelte index 418e4c2..3e4bbf9 100644 --- a/src/chat/components/ServerSettingsPanel.svelte +++ b/src/chat/components/ServerSettingsPanel.svelte @@ -2,6 +2,7 @@ import { getContext } from "svelte"; import type { ChatService } from "../services/chat.svelte"; import { optimizeEmoji } from "../utils"; + import { Permissions } from "../services/chat.svelte"; import Button from "./ui/Button.svelte"; import Input from "./ui/Input.svelte"; import Switch from "./ui/Switch.svelte"; @@ -19,7 +20,8 @@ let errorMessage = $state(null); const server = $derived(chat.activeServer); - const isOwner = true; // temporarily enabled for everyone + const canManageServer = $derived(chat.can(Permissions.MANAGE_SERVER)); + const canDeleteServer = $derived(chat.can(Permissions.DELETE_SERVER)); $effect(() => { if (server) { @@ -143,29 +145,31 @@ Leave Server - + }; + }} + > + + Delete Server + + {/if}
@@ -192,7 +196,7 @@
{/if}
- {#if isOwner} + {#if canManageServer}