Compare commits
5
Commits
d1ce0655fb
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cb8b068a8 | ||
|
|
910eb2b169 | ||
|
|
844eaadee0 | ||
|
|
5bac3174df | ||
|
|
b830abe3f4 |
+2
-1
@@ -1,4 +1,5 @@
|
||||
/target
|
||||
/.idea
|
||||
*.db*
|
||||
/media/*
|
||||
/media/*
|
||||
/certs
|
||||
Generated
+133
-3
@@ -189,6 +189,15 @@ dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.6.0"
|
||||
@@ -704,6 +713,28 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-server"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1ab4a3ec9ea8a657c72d99a03a824af695bd0fb5ec639ccbd9cd3543b41a5f9"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"bytes",
|
||||
"fs-err",
|
||||
"http 1.5.0",
|
||||
"http-body 1.1.0",
|
||||
"hyper 1.11.0",
|
||||
"hyper-util",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base16ct"
|
||||
version = "0.2.0"
|
||||
@@ -1753,6 +1784,16 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs-err"
|
||||
version = "3.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
@@ -1992,6 +2033,25 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http 1.5.0",
|
||||
"indexmap",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
@@ -2191,7 +2251,7 @@ dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"h2 0.3.27",
|
||||
"http 0.2.12",
|
||||
"http-body 0.4.6",
|
||||
"httparse",
|
||||
@@ -2215,6 +2275,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2 0.4.19",
|
||||
"http 1.5.0",
|
||||
"http-body 1.1.0",
|
||||
"httparse",
|
||||
@@ -2957,6 +3018,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"axum-extra",
|
||||
"axum-server",
|
||||
"bitflags",
|
||||
"chrono",
|
||||
"config",
|
||||
@@ -2967,6 +3029,7 @@ dependencies = [
|
||||
"log",
|
||||
"migration",
|
||||
"parking_lot",
|
||||
"rcgen 0.13.2",
|
||||
"rustrtc",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
@@ -3482,6 +3545,19 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rcgen"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
|
||||
dependencies = [
|
||||
"pem 3.0.6",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"time",
|
||||
"yasna 0.5.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rcgen"
|
||||
version = "0.14.10"
|
||||
@@ -3493,7 +3569,7 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
"time",
|
||||
"x509-parser",
|
||||
"yasna",
|
||||
"yasna 0.6.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3703,6 +3779,29 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"once_cell",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
@@ -3712,6 +3811,18 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted 0.9.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustrtc"
|
||||
version = "0.3.138"
|
||||
@@ -3735,7 +3846,7 @@ dependencies = [
|
||||
"p256",
|
||||
"parking_lot",
|
||||
"rand 0.10.2",
|
||||
"rcgen",
|
||||
"rcgen 0.14.10",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1 0.11.0",
|
||||
@@ -4635,6 +4746,16 @@ dependencies = [
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.19"
|
||||
@@ -5446,6 +5567,15 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
|
||||
|
||||
[[package]]
|
||||
name = "yasna"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
|
||||
dependencies = [
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yasna"
|
||||
version = "0.6.0"
|
||||
|
||||
@@ -43,3 +43,5 @@ form_urlencoded = "1.2.2"
|
||||
time = "0.3.55"
|
||||
sha2 = "0.11.0"
|
||||
rustrtc = "0.3.138"
|
||||
axum-server = { version = "0.7", features = ["tls-rustls"] }
|
||||
rcgen = "0.13"
|
||||
|
||||
+10
@@ -7,6 +7,16 @@ host = "0.0.0.0"
|
||||
tcp_port = 8080
|
||||
# Voice/Video port
|
||||
udp_port = 8080
|
||||
# If the server is behind NAT, set its public IPv4 here and forward UDP 8080 to it.
|
||||
# external_ip = "203.0.113.1"
|
||||
# STUN is enabled by default; customize or set [] to disable it.
|
||||
# stun_servers = ["stun:stun.l.google.com:19302"]
|
||||
|
||||
# Uncomment for native self-signed HTTPS/WSS. Keep both files between restarts.
|
||||
[network.tls]
|
||||
cert_path = "certs/server.pem"
|
||||
key_path = "certs/server-key.pem"
|
||||
names = ["localhost", "127.0.0.1", "goesseau.eu"] # include the host clients actually use
|
||||
|
||||
[database]
|
||||
# DSN for database
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"eslint-config-vuetify": "^4.3.4",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"sass-embedded": "^1.98.0",
|
||||
"smol-toml": "^1.9.0",
|
||||
"typescript": "~5.9.3",
|
||||
"unplugin-fonts": "^1.4.0",
|
||||
"vite": "^8.0.0",
|
||||
|
||||
@@ -7,8 +7,10 @@ import ContextMenu from "@/components/ContextMenu.vue";
|
||||
import UserListDrawer from '@/components/UserListDrawer.vue'
|
||||
import ServerSettingsDialog from '@/components/server/ServerSettingsDialog.vue'
|
||||
import {useContextMenu} from '@/composables/useContextMenu'
|
||||
import {useVoiceStore} from '@/stores/voice'
|
||||
|
||||
const serverStore = useServerStore()
|
||||
const voiceStore = useVoiceStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const {openContextMenu} = useContextMenu()
|
||||
@@ -212,6 +214,14 @@ function onServerContextMenu(event: MouseEvent, server: Server) {
|
||||
/>
|
||||
|
||||
<router-view/>
|
||||
<div v-if="voiceStore.channelId" class="voice-controls pa-3 elevation-4 bg-surface">
|
||||
<span class="text-success font-weight-bold"><v-icon icon="mdi-volume-high" /> Vocal connecté</span>
|
||||
<v-btn size="small" prepend-icon="mdi-phone-hangup" @click="voiceStore.leave()">Quitter</v-btn>
|
||||
</div>
|
||||
<v-snackbar :model-value="!!voiceStore.error" color="error" @update:model-value="voiceStore.error = null">
|
||||
{{ voiceStore.error }}
|
||||
<template #actions><v-btn @click="voiceStore.error = null">Fermer</v-btn></template>
|
||||
</v-snackbar>
|
||||
<!-- Menu contextuel global -->
|
||||
<ContextMenu/>
|
||||
<v-dialog v-model="showDialog" width="400">
|
||||
@@ -284,6 +294,17 @@ function onServerContextMenu(event: MouseEvent, server: Server) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.voice-controls {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.space-y-4 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -60,7 +60,7 @@ async function handleRegister() {
|
||||
throw new Error(errData.message || 'Échec de l\'inscription')
|
||||
}
|
||||
|
||||
await router.push(serverId ? {name: 'login', query: {redirect: `/server/${serverId}`}} : '/login')
|
||||
await router.push(serverId ? {name: 'login', query: {redirect: `/server/${serverId}`}} : {name: 'login'})
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Une erreur est survenue'
|
||||
} finally {
|
||||
|
||||
@@ -14,7 +14,8 @@ import type {Channel} from '@/stores/channel'
|
||||
import ChannelPermissionsDialog from '@/components/permissions/ChannelPermissionsDialog.vue'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import ServerSettingsDialog from '@/components/server/ServerSettingsDialog.vue'
|
||||
import {onReloadAll} from '@/plugins/events.ts'
|
||||
import {onGatewayEvent, onReloadAll} from '@/plugins/events.ts'
|
||||
import {useVoiceStore} from '@/stores/voice'
|
||||
|
||||
const props = defineProps<{
|
||||
serverId: string
|
||||
@@ -30,6 +31,11 @@ const serverStore = useServerStore()
|
||||
const {currentTree} = storeToRefs(serverStore)
|
||||
const {openContextMenu} = useContextMenu()
|
||||
const authStore = useAuthStore()
|
||||
const voiceStore = useVoiceStore()
|
||||
const voiceChannels = computed<Channel[]>(() => currentTree.value.flatMap(item =>
|
||||
'Category' in item ? item.Category[1].filter((channel: Channel) => channel.channel_type === 'voice') :
|
||||
'Channel' in item && item.Channel.channel_type === 'voice' ? [item.Channel] : [],
|
||||
))
|
||||
const showPermissionsDialog = ref(false)
|
||||
const selectedChannel = ref<any | null>(null)
|
||||
const channelToEdit = ref<Channel | null>(null)
|
||||
@@ -37,6 +43,19 @@ const showEditChannelDialog = ref(false)
|
||||
const showServerSettings = ref(false)
|
||||
const serverName = computed(() => serverStore.servers.find(server => server.id === props.serverId)?.name || 'Serveur')
|
||||
|
||||
function selectChannel(channel: Channel) {
|
||||
if (channel.channel_type === 'voice') {
|
||||
void voiceStore.join(props.serverId, channel.id)
|
||||
if (route.params.channelId) void router.push(`/server/${props.serverId}`)
|
||||
}
|
||||
else void router.push(`/server/${props.serverId}/channel/${channel.id}`)
|
||||
}
|
||||
|
||||
watch([() => props.channelId, voiceChannels], () => {
|
||||
const channel = voiceChannels.value.find(item => item.id === props.channelId)
|
||||
if (channel) selectChannel(channel)
|
||||
})
|
||||
|
||||
|
||||
const loadServerData = async (targetServerId: string) => {
|
||||
if (!targetServerId) return
|
||||
@@ -55,13 +74,27 @@ const loadServerData = async (targetServerId: string) => {
|
||||
}
|
||||
|
||||
let stopReloadAll: (() => void) | null = null
|
||||
let stopVoicePresence: (() => void) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
stopReloadAll = onReloadAll(() => loadServerData(props.serverId))
|
||||
stopVoicePresence = onGatewayEvent('VoicePresence', ({action, content}) => {
|
||||
const {server_id, channel_id, user} = content ?? {}
|
||||
if (server_id !== props.serverId || !user?.id) return
|
||||
const channel = voiceChannels.value.find(item => item.id === channel_id)
|
||||
if (!channel) return
|
||||
const participants = channel.voice_participants ?? []
|
||||
if (action === 'joined' && !participants.some(participant => participant.id === user.id)) {
|
||||
channel.voice_participants = [...participants, user].sort((a, b) => a.username.localeCompare(b.username))
|
||||
} else if (action === 'left') {
|
||||
channel.voice_participants = participants.filter(participant => participant.id !== user.id)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopReloadAll?.()
|
||||
stopVoicePresence?.()
|
||||
})
|
||||
|
||||
watch(
|
||||
@@ -286,17 +319,17 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-for="channel in item.Category[1]" :key="channel.id">
|
||||
<v-list-item
|
||||
v-for="channel in item.Category[1]"
|
||||
:key="channel.id"
|
||||
:title="channel.name"
|
||||
:to="`/server/${serverId}/channel/${channel.id}`"
|
||||
:active="voiceStore.channelId === channel.id"
|
||||
:class="{
|
||||
'font-weight-bold': (channel.unread_count ?? 0) > 0,
|
||||
'server-item-drop-before': isDropTarget(channel.id, 'before'),
|
||||
'server-item-drop-after': isDropTarget(channel.id, 'after'),
|
||||
}"
|
||||
link
|
||||
@click="selectChannel(channel)"
|
||||
draggable="true"
|
||||
@dragstart="startDragging($event, channel.id, 'channel')"
|
||||
@dragover="dragOver($event, channel.id, 'channel', item.Category[0].id)"
|
||||
@@ -304,6 +337,7 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
|
||||
@dragend="stopDragging"
|
||||
@contextmenu="onChannelContextMenu($event, channel)"
|
||||
>
|
||||
<template #prepend><v-icon :icon="channel.channel_type === 'voice' ? 'mdi-volume-high' : 'mdi-pound'" /></template>
|
||||
<template #append>
|
||||
<v-chip
|
||||
v-if="(channel.unread_count ?? 0) > 0"
|
||||
@@ -316,20 +350,27 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<div v-if="channel.channel_type === 'voice'" class="voice-participants">
|
||||
<div v-for="participant in channel.voice_participants ?? []" :key="participant.id" class="text-body-2 py-1">
|
||||
<v-icon size="small" icon="mdi-account-circle-outline" class="mr-2" />{{ participant.username }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</v-list-group>
|
||||
|
||||
<!-- Canal orphelin (racine) -->
|
||||
<template v-else-if="'Channel' in item">
|
||||
<v-list-item
|
||||
v-else-if="'Channel' in item"
|
||||
:key="item.Channel.id"
|
||||
:title="item.Channel.name"
|
||||
:to="`/server/${serverId}/channel/${item.Channel.id}`"
|
||||
:active="voiceStore.channelId === item.Channel.id"
|
||||
:class="{
|
||||
'font-weight-bold': (item.Channel.unread_count ?? 0) > 0,
|
||||
'server-item-drop-before': isDropTarget(item.Channel.id, 'before'),
|
||||
'server-item-drop-after': isDropTarget(item.Channel.id, 'after'),
|
||||
}"
|
||||
link
|
||||
@click="selectChannel(item.Channel)"
|
||||
draggable="true"
|
||||
@dragstart="startDragging($event, item.Channel.id, 'channel')"
|
||||
@dragover="dragOver($event, item.Channel.id, 'channel', null)"
|
||||
@@ -337,6 +378,7 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
|
||||
@dragend="stopDragging"
|
||||
@contextmenu="onChannelContextMenu($event, item.Channel)"
|
||||
>
|
||||
<template #prepend><v-icon :icon="item.Channel.channel_type === 'voice' ? 'mdi-volume-high' : 'mdi-pound'" /></template>
|
||||
<template #append>
|
||||
<v-chip
|
||||
v-if="(item.Channel.unread_count ?? 0) > 0"
|
||||
@@ -349,9 +391,16 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<div v-if="item.Channel.channel_type === 'voice'" class="voice-participants">
|
||||
<div v-for="participant in item.Channel.voice_participants ?? []" :key="participant.id" class="text-body-2 py-1">
|
||||
<v-icon size="small" icon="mdi-account-circle-outline" class="mr-2" />{{ participant.username }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</v-list>
|
||||
|
||||
|
||||
</v-navigation-drawer>
|
||||
|
||||
<CreateChannelDialog
|
||||
@@ -391,6 +440,11 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.voice-participants {
|
||||
padding-left: 48px;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
opacity: 0.75;
|
||||
}
|
||||
.server-item-drop-before {
|
||||
border-top: 2px solid rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,6 @@ import {defineStore} from 'pinia'
|
||||
|
||||
export const useAppStore = defineStore('app', {
|
||||
state: () => ({
|
||||
baseurl: 'http://localhost:8080',
|
||||
baseurl: '',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {defineStore} from 'pinia'
|
||||
import {useApi} from "@/composables/useApi";
|
||||
import {useVoiceStore} from '@/stores/voice'
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
@@ -55,6 +56,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
},
|
||||
|
||||
async logout() {
|
||||
useVoiceStore().leave()
|
||||
const api = useApi()
|
||||
try {
|
||||
await api.post('/auth/logout')
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface Channel {
|
||||
id: string
|
||||
name?: string
|
||||
channel_type: string
|
||||
voice_participants?: {id: string; username: string}[]
|
||||
server_id?: string | null
|
||||
category_id?: string | null
|
||||
created_at: string
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import {defineStore} from 'pinia'
|
||||
|
||||
export const useVoiceStore = defineStore('voice', {
|
||||
state: () => ({
|
||||
channelId: null as string | null,
|
||||
serverId: null as string | null,
|
||||
connecting: false,
|
||||
error: null as string | null,
|
||||
}),
|
||||
actions: {
|
||||
async join(serverId: string, channelId: string) {
|
||||
if (this.channelId === channelId) return
|
||||
this.leave()
|
||||
this.connecting = true
|
||||
this.error = null
|
||||
const generation = ++voiceGeneration
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({audio: true})
|
||||
if (generation !== voiceGeneration) {
|
||||
stream.getTracks().forEach(track => track.stop())
|
||||
return
|
||||
}
|
||||
microphone = stream
|
||||
const connection = new RTCPeerConnection()
|
||||
peer = connection
|
||||
stream.getAudioTracks().forEach(track => connection.addTrack(track, stream))
|
||||
const url = new URL(`/ws/rtc/${encodeURIComponent(channelId)}`, window.location.href)
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const ws = new WebSocket(url)
|
||||
socket = ws
|
||||
this.channelId = channelId
|
||||
this.serverId = serverId
|
||||
const pendingCandidates: string[] = []
|
||||
const tracks = new Map<string, MediaStreamTrack>()
|
||||
connection.ontrack = ({track, streams}) => {
|
||||
if (peer !== connection) return
|
||||
const audio = new Audio()
|
||||
audio.autoplay = true
|
||||
audio.srcObject = new MediaStream([track])
|
||||
audioElements.set(track, audio)
|
||||
if (streams[0]) tracks.set(streams[0].id, track)
|
||||
track.onended = () => removeAudio(track)
|
||||
void audio.play().catch(() => { this.error = 'Lecture du son distant impossible.' })
|
||||
}
|
||||
connection.onicecandidate = ({candidate}) => {
|
||||
if (candidate && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({action: 'ice-candidate', candidate: candidate.candidate}))
|
||||
}
|
||||
}
|
||||
ws.onopen = async () => {
|
||||
try {
|
||||
const offer = await connection.createOffer()
|
||||
await connection.setLocalDescription(offer)
|
||||
if (socket === ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({action: 'sdp-offer', sdp: connection.localDescription?.sdp}))
|
||||
}
|
||||
} catch (error) {
|
||||
if (socket === ws) { this.error = String(error); this.leave() }
|
||||
}
|
||||
}
|
||||
let queue = Promise.resolve()
|
||||
ws.onmessage = ({data}) => {
|
||||
queue = queue.then(async () => {
|
||||
if (socket !== ws) return
|
||||
const message = JSON.parse(data)
|
||||
if (message.action === 'answer') {
|
||||
await connection.setRemoteDescription({type: 'answer', sdp: message.sdp})
|
||||
for (const candidate of pendingCandidates) await connection.addIceCandidate({candidate, sdpMLineIndex: 0})
|
||||
pendingCandidates.length = 0
|
||||
} else if (message.action === 'sdp-offer') {
|
||||
await connection.setRemoteDescription({type: 'offer', sdp: message.sdp})
|
||||
const answer = await connection.createAnswer()
|
||||
await connection.setLocalDescription(answer)
|
||||
ws.send(JSON.stringify({action: 'sdp-answer', sdp: connection.localDescription?.sdp}))
|
||||
} else if (message.action === 'ice-candidate') {
|
||||
if (connection.remoteDescription) await connection.addIceCandidate({candidate: message.candidate, sdpMLineIndex: 0})
|
||||
else pendingCandidates.push(message.candidate)
|
||||
} else if (message.action === 'source-left') {
|
||||
const track = tracks.get(message.id)
|
||||
if (track) removeAudio(track)
|
||||
tracks.delete(message.id)
|
||||
} else if (message.action === 'error') {
|
||||
this.error = message.message
|
||||
this.leave()
|
||||
}
|
||||
}).catch(error => { if (socket === ws) { this.error = String(error); this.leave() } })
|
||||
}
|
||||
ws.onerror = () => { if (socket === ws) this.error = 'Connexion vocale impossible.' }
|
||||
ws.onclose = () => { if (socket === ws) this.leave() }
|
||||
} catch (error) {
|
||||
if (generation === voiceGeneration) this.error = `Microphone indisponible : ${String(error)}`
|
||||
} finally {
|
||||
if (generation === voiceGeneration) this.connecting = false
|
||||
}
|
||||
},
|
||||
leave() {
|
||||
voiceGeneration++
|
||||
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({action: 'leave'}))
|
||||
socket?.close()
|
||||
socket = null
|
||||
peer?.close()
|
||||
peer = null
|
||||
microphone?.getTracks().forEach(track => track.stop())
|
||||
microphone = null
|
||||
for (const track of audioElements.keys()) removeAudio(track)
|
||||
this.channelId = null
|
||||
this.serverId = null
|
||||
this.connecting = false
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let voiceGeneration = 0
|
||||
let socket: WebSocket | null = null
|
||||
let peer: RTCPeerConnection | null = null
|
||||
let microphone: MediaStream | null = null
|
||||
const audioElements = new Map<MediaStreamTrack, HTMLAudioElement>()
|
||||
|
||||
function removeAudio(track: MediaStreamTrack) {
|
||||
const audio = audioElements.get(track)
|
||||
if (!audio) return
|
||||
audio.pause()
|
||||
audio.srcObject = null
|
||||
audioElements.delete(track)
|
||||
}
|
||||
@@ -1,8 +1,21 @@
|
||||
import {readFileSync} from 'node:fs'
|
||||
import {resolve} from 'node:path'
|
||||
import {fileURLToPath, URL} from 'node:url'
|
||||
import Vue from '@vitejs/plugin-vue'
|
||||
import Fonts from 'unplugin-fonts/vite'
|
||||
import {defineConfig} from 'vite'
|
||||
import Vuetify, {transformAssetUrls} from 'vite-plugin-vuetify'
|
||||
import {parse} from 'smol-toml'
|
||||
|
||||
const projectRoot = fileURLToPath(new URL('..', import.meta.url))
|
||||
const config = parse(readFileSync(resolve(projectRoot, 'config.toml'), 'utf8'))
|
||||
const network = config.network as {tcp_port: number, tls?: {cert_path: string, key_path: string}}
|
||||
const tls = network.tls
|
||||
const https = tls ? {
|
||||
cert: readFileSync(resolve(projectRoot, tls.cert_path)),
|
||||
key: readFileSync(resolve(projectRoot, tls.key_path)),
|
||||
} : undefined
|
||||
const target = `${tls ? 'https' : 'http'}://localhost:${network.tcp_port}`
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
@@ -46,14 +59,19 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0',
|
||||
https,
|
||||
allowedHosts: ["goesseau.eu"],
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
target,
|
||||
changeOrigin: true,
|
||||
secure: !tls,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8080',
|
||||
target,
|
||||
ws: true,
|
||||
secure: !tls,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2519,6 +2519,11 @@ sisteransi@^1.0.5:
|
||||
resolved "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz"
|
||||
integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==
|
||||
|
||||
smol-toml@^1.9.0:
|
||||
version "1.9.0"
|
||||
resolved "https://registry.yarnpkg.com/smol-toml/-/smol-toml-1.9.0.tgz#f36b8dc7eb621c541c48f9fba6925dab95172817"
|
||||
integrity sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==
|
||||
|
||||
"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.2, source-map-js@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz"
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
# Routes HTTP du backend et permissions
|
||||
|
||||
Ce document décrit les routes déclarées dans `src/routes` et leurs contrôles d’accès. Les chemins incluent les préfixes de montage définis dans `src/routes/mod.rs`.
|
||||
|
||||
## Règles générales
|
||||
|
||||
- **Authentification** : les routes sous `/api` listées comme protégées passent par `require_auth`. Un JWT valide doit être transmis comme `Authorization: Bearer <token>`, cookie `token` (ou `jwt` reconnu par le middleware) ou paramètre de requête `token`. L’absence d’identification valide entraîne `401 Unauthorized`.
|
||||
- **Permissions fines** : elles sont évaluées par les handlers à partir des permissions du serveur/canal ou de contrôles d’appartenance. Un utilisateur authentifié qui n’a pas le droit requis reçoit généralement `403 Forbidden`.
|
||||
- **Superuser** : les opérations de gestion globale des utilisateurs l’exigent. Les droits de serveur et de canal s’appliquent par ressource; ne pas déduire qu’un superuser les contourne automatiquement.
|
||||
- **Accès conditionnel** : « membre » signifie appartenir au serveur concerné ou, pour une conversation/canal DM, être autorisé par le contrôle d’appartenance propre à cette ressource.
|
||||
- **WebSocket** : les routes `/ws` sont des upgrades HTTP `GET`; leurs handlers extraient `CurrentUser`, donc elles requièrent aussi une authentification.
|
||||
|
||||
## API (`/api`)
|
||||
|
||||
Les routes ci-dessous, sauf mention contraire, sont protégées par l’authentification globale.
|
||||
|
||||
| Méthode | Route | Permission / condition supplémentaire |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/auth/login` | Publique; identifiants valides requis pour obtenir une session/cookie. |
|
||||
| `POST` | `/api/auth/bearer-login` | Publique; identifiants valides requis pour obtenir un bearer token. |
|
||||
| `GET` | `/api/auth/me` | JWT valide. |
|
||||
| `POST` | `/api/join` | Publique; inscription soumise à la validation et, selon la configuration, au jeton d’initialisation; un serveur cible avec mot de passe ne peut pas être rejoint par ce flux. |
|
||||
| `GET` | `/api/servers` | JWT; renvoie les serveurs de l’utilisateur. |
|
||||
| `POST` | `/api/servers` | JWT; création d’un serveur. |
|
||||
| `GET` | `/api/servers/{id}` | Membre du serveur. |
|
||||
| `PUT` | `/api/servers/{id}` | `MANAGE_SERVER` sur le serveur. |
|
||||
| `DELETE` | `/api/servers/{id}` | `MANAGE_SERVER` sur le serveur. |
|
||||
| `POST` | `/api/servers/{id}/join` | JWT; logique d’adhésion au serveur (conditions d’accès/mot de passe vérifiées par le handler). |
|
||||
| `GET` | `/api/servers/{server_id}/tree` | Membre du serveur. |
|
||||
| `GET` | `/api/servers/{server_id}/permissions/users` | `MANAGE_SERVER` sur le serveur. |
|
||||
| `GET`, `PUT`, `DELETE` | `/api/servers/{server_id}/permissions/users/{user_id}` | `MANAGE_SERVER`; consultation, attribution ou retrait des permissions de l’utilisateur ciblé. |
|
||||
| `GET`, `PUT`, `DELETE` | `/api/servers/{server_id}/permissions/roles/{role_id}` | `MANAGE_SERVER`; consultation, attribution ou retrait des permissions du rôle ciblé. |
|
||||
| `PUT` | `/api/server-item-orders/reorder` | `MANAGE_CHANNELS` **et** `MANAGE_CATEGORIES` sur le serveur ciblé. |
|
||||
| `GET` | `/api/categories` | `server_id` requis; membre du serveur. |
|
||||
| `GET` | `/api/categories/{id}` | Membre du serveur associé à la catégorie. |
|
||||
| `POST`, `PUT`, `DELETE` | `/api/categories`, `/api/categories/{id}` | `MANAGE_CATEGORIES` sur le serveur associé. |
|
||||
| `GET` | `/api/channels` | `server_id` requis et appartenance au serveur; seuls les canaux avec `READ_CHANNEL` sont renvoyés. |
|
||||
| `GET` | `/api/channels/{id}` | `READ_CHANNEL` sur le canal. |
|
||||
| `POST` | `/api/channels` | `MANAGE_CHANNELS` sur le serveur. |
|
||||
| `PUT`, `DELETE` | `/api/channels/{id}` | `MANAGE_CHANNELS` sur le serveur du canal. |
|
||||
| `GET` | `/api/channels/{channel_id}/permissions` | `MANAGE_CHANNEL` sur le canal. |
|
||||
| `GET`, `PUT`, `DELETE` | `/api/channels/{channel_id}/permissions/users/{user_id}` | `MANAGE_CHANNEL`; lecture, attribution ou retrait de permissions utilisateur. |
|
||||
| `GET`, `PUT`, `DELETE` | `/api/channels/{channel_id}/permissions/roles/{role_id}` | `MANAGE_CHANNEL`; lecture, attribution ou retrait de permissions de rôle. |
|
||||
| `GET` | `/api/channels/{channel_id}/read-state` | JWT; état de lecture de l’utilisateur courant pour le canal. |
|
||||
| `PUT` | `/api/channels/{channel_id}/read-state` | JWT; modification de l’état de lecture de l’utilisateur courant pour le canal. |
|
||||
| `GET` | `/api/conversations` | JWT; ne renvoie que les conversations accessibles à l’utilisateur courant. |
|
||||
| `POST` | `/api/conversations` | JWT; création avec les participants fournis. |
|
||||
| `POST` | `/api/conversations/{id}/fork` | JWT et accès/membership à la conversation source. |
|
||||
| `GET` | `/api/roles` | `server_id` requis; membre du serveur. |
|
||||
| `GET` | `/api/roles/{id}` | Membre du serveur du rôle. |
|
||||
| `POST` | `/api/roles` | `MANAGE_ROLES` sur le serveur ciblé. |
|
||||
| `PUT`, `DELETE` | `/api/roles/{id}` | `MANAGE_ROLES` sur le serveur du rôle. |
|
||||
| `GET` | `/api/roles/{id}/members` | Membre du serveur du rôle. |
|
||||
| `PUT`, `DELETE` | `/api/roles/{id}/members/{user_id}` | `MANAGE_ROLES` sur le serveur du rôle. |
|
||||
| `GET` | `/api/messages` | `channel_id` requis et `READ_CHANNEL` sur le canal. |
|
||||
| `POST` | `/api/messages` | `SEND_MESSAGE` sur le canal; les pièces jointes nécessitent également `ATTACH_FILES`. |
|
||||
| `GET` | `/api/messages/{id}` | `READ_CHANNEL` sur le canal du message. |
|
||||
| `PUT` | `/api/messages/{id}` | `EDIT_OWN_MESSAGE` pour son propre message; `EDIT_OTHERS_MESSAGES` pour celui d’un autre utilisateur. |
|
||||
| `DELETE` | `/api/messages/{id}` | `DELETE_OWN_MESSAGE` pour son propre message; `DELETE_OTHERS_MESSAGES` pour celui d’un autre utilisateur. |
|
||||
| `POST` | `/api/messages/{message_id}/reactions` | `ADD_REACTIONS` sur le canal du message. |
|
||||
| `DELETE` | `/api/messages/{message_id}/reactions/{emoji_id}` | `ADD_REACTIONS` sur le canal du message. |
|
||||
| `POST` | `/api/attachments` | `ATTACH_FILES` sur le canal indiqué dans l’upload. |
|
||||
| `GET` | `/api/emojis` | JWT; liste limitée aux emojis accessibles aux serveurs de l’utilisateur. |
|
||||
| `POST` | `/api/emojis` | `MANAGE_SERVER` sur le serveur ciblé. |
|
||||
| `GET` | `/api/emojis/{id}` | Appartenance au serveur associé; emojis globaux selon leur disponibilité. |
|
||||
| `GET` | `/api/emojis/{id}/asset` | Même contrôle d’accès que la lecture de l’emoji. |
|
||||
| `PUT`, `DELETE` | `/api/emojis/{id}` | `MANAGE_SERVER` sur le serveur de l’emoji. |
|
||||
| `GET` | `/api/users` | Superuser uniquement. |
|
||||
| `POST` | `/api/users` | Superuser uniquement. |
|
||||
| `GET`, `PUT`, `DELETE` | `/api/users/{id}` | Superuser uniquement. |
|
||||
|
||||
## Routes avec contrôle d’accès propre au handler
|
||||
|
||||
Ces routes ne sont pas incluses dans le groupe `require_auth` de `/api`; leurs handlers appliquent leur propre authentification et autorisation.
|
||||
|
||||
| Méthode | Route | Permission / condition supplémentaire |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/attachments/{id}/file` | Authentification obligatoire; `READ_CHANNEL` sur le canal lié à la pièce jointe. |
|
||||
|
||||
## WebSocket (`/ws`)
|
||||
|
||||
| Méthode | Route | Permission / condition supplémentaire |
|
||||
|---|---|---|
|
||||
| `GET` | `/ws/gateway` | JWT valide via `CurrentUser`. |
|
||||
| `GET` | `/ws/voice` | JWT valide via `CurrentUser`; droits vocaux vérifiés ensuite lors des messages/offres liés aux canaux. |
|
||||
| `GET` | `/ws/rtc/{channel_id}` | JWT valide; le canal doit exister, être vocal et l’utilisateur doit être membre du serveur associé. |
|
||||
|
||||
## Documentation et services transverses
|
||||
|
||||
| Méthode | Route | Permission / condition supplémentaire |
|
||||
|---|---|---|
|
||||
| `GET` | `/swagger` et routes d’interface Swagger associées | Interface de documentation générée par Swagger UI; aucun contrôle de permission déclaré dans le routeur. |
|
||||
| `GET` | `/api-docs/openapi.json` | Spécification OpenAPI servie par Swagger UI; aucun contrôle de permission déclaré dans le routeur. |
|
||||
|
||||
## Signification des permissions utilisées
|
||||
|
||||
| Permission | Portée / effet |
|
||||
|---|---|
|
||||
| `MANAGE_SERVER` | Modifier les paramètres du serveur et gérer ses permissions. |
|
||||
| `MANAGE_ROLES` | Créer, modifier, supprimer des rôles et gérer leurs membres. |
|
||||
| `MANAGE_CATEGORIES` | Créer, modifier et supprimer des catégories. |
|
||||
| `MANAGE_CHANNELS` | Créer, modifier et supprimer des canaux. |
|
||||
| `READ_CHANNEL` | Voir le canal et son contenu. |
|
||||
| `SEND_MESSAGE` | Envoyer un message. |
|
||||
| `EDIT_OWN_MESSAGE` / `EDIT_OTHERS_MESSAGES` | Modifier respectivement ses messages / ceux d’autres utilisateurs. |
|
||||
| `DELETE_OWN_MESSAGE` / `DELETE_OTHERS_MESSAGES` | Supprimer respectivement ses messages / ceux d’autres utilisateurs. |
|
||||
| `ADD_REACTIONS` | Ajouter ou retirer une réaction sur un message accessible. |
|
||||
| `ATTACH_FILES` | Ajouter ou télécharger une pièce jointe selon le contrôle appliqué par la route. |
|
||||
| `MANAGE_CHANNEL` | Gérer les permissions d’un canal. |
|
||||
|
||||
Les permissions disponibles dans le domaine comprennent aussi les droits de membres (`KICK_MEMBERS`, `BAN_MEMBERS`, `MANAGE_MEMBERS`, `VIEW_MEMBERS`) et de voix (`JOIN_VOICE`, `SPEAK`, `STREAM`, `MUTE_SELF`, `MUTE_OTHERS`, `MOVE_OTHERS`, `DISCONNECT_OTHERS`, `MANAGE_VOICE_CHANNEL`). Leur présence dans le modèle n’implique pas qu’une route HTTP dédiée existe.
|
||||
@@ -50,6 +50,16 @@ host = "0.0.0.0"
|
||||
tcp_port = 8080
|
||||
# WebRTC ICE/Media UDP multiplexing port
|
||||
udp_port = 8080
|
||||
# Public IPv4 address advertised by ICE when behind NAT (forward udp_port to this server).
|
||||
# external_ip = "203.0.113.1"
|
||||
# STUN servers used when external_ip is absent; set [] to disable discovery.
|
||||
# stun_servers = ["stun:stun.l.google.com:19302"]
|
||||
|
||||
# Optional native HTTPS (omit this section to keep plain HTTP behind a proxy).
|
||||
# [network.tls]
|
||||
# cert_path = "certs/server.pem"
|
||||
# key_path = "certs/server-key.pem"
|
||||
# names = ["localhost", "127.0.0.1"]
|
||||
|
||||
[database]
|
||||
# DSN for database
|
||||
@@ -103,6 +113,23 @@ pub struct NetworkConfig {
|
||||
pub hostv6: Option<Ipv6Addr>,
|
||||
pub tcp_port: u16,
|
||||
pub udp_port: u16,
|
||||
#[serde(default)]
|
||||
pub external_ip: Option<Ipv4Addr>,
|
||||
#[serde(default = "default_stun_servers")]
|
||||
pub stun_servers: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub tls: Option<TlsConfig>,
|
||||
}
|
||||
|
||||
fn default_stun_servers() -> Vec<String> {
|
||||
vec!["stun:stun.l.google.com:19302".to_string()]
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct TlsConfig {
|
||||
pub cert_path: std::path::PathBuf,
|
||||
pub key_path: std::path::PathBuf,
|
||||
pub names: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
@@ -155,3 +182,38 @@ impl AppConfig {
|
||||
Self::load()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{AppConfig, DEFAULT_CONFIG_TOML};
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
#[test]
|
||||
fn external_ip_is_optional_and_accepts_public_ipv4() {
|
||||
let default: AppConfig = toml::from_str(DEFAULT_CONFIG_TOML).unwrap();
|
||||
assert_eq!(default.network.external_ip, None);
|
||||
assert_eq!(default.network.stun_servers, vec!["stun:stun.l.google.com:19302"]);
|
||||
|
||||
let configured = DEFAULT_CONFIG_TOML.replace(
|
||||
"# external_ip = \"203.0.113.1\"",
|
||||
"external_ip = \"203.0.113.1\"",
|
||||
);
|
||||
let config: AppConfig = toml::from_str(&configured).unwrap();
|
||||
assert_eq!(config.network.external_ip, Some(Ipv4Addr::new(203, 0, 113, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stun_servers_can_be_disabled_or_replaced() {
|
||||
for (value, expected) in [
|
||||
("[]", vec![]),
|
||||
("[\"stun:example.org:3478\"]", vec!["stun:example.org:3478"]),
|
||||
] {
|
||||
let text = DEFAULT_CONFIG_TOML.replace(
|
||||
"# stun_servers = [\"stun:stun.l.google.com:19302\"]",
|
||||
&format!("stun_servers = {value}"),
|
||||
);
|
||||
let config: AppConfig = toml::from_str(&text).unwrap();
|
||||
assert_eq!(config.network.stun_servers, expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
-6
@@ -83,12 +83,15 @@ impl App {
|
||||
))
|
||||
.start(event_bus.clone());
|
||||
|
||||
let rtc = Arc::new(RTCManager::new(
|
||||
&config.network,
|
||||
repositories.clone(),
|
||||
services.clone(),
|
||||
event_bus.clone(),
|
||||
));
|
||||
let rtc = Arc::new(
|
||||
RTCManager::new(
|
||||
&config.network,
|
||||
repositories.clone(),
|
||||
services.clone(),
|
||||
event_bus.clone(),
|
||||
)
|
||||
.await,
|
||||
);
|
||||
|
||||
let state = AppState {
|
||||
db,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::models::channel::ChannelType;
|
||||
use crate::rtc::VoiceParticipant;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
@@ -49,6 +50,8 @@ pub struct ChannelResponse {
|
||||
/// Some(value) : valeur de computed_permission (0 si absente).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub permission: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub voice_participants: Option<Vec<VoiceParticipant>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
|
||||
@@ -8,3 +8,4 @@ pub mod server;
|
||||
pub mod server_permission;
|
||||
pub mod server_tree;
|
||||
pub mod user;
|
||||
pub mod voice_presence;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
use crate::rtc::VoiceParticipant;
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct VoicePresenceEvent {
|
||||
pub server_id: Uuid,
|
||||
pub channel_id: Uuid,
|
||||
pub user: VoiceParticipant,
|
||||
#[serde(skip)]
|
||||
pub joined: bool,
|
||||
}
|
||||
+5
-3
@@ -23,9 +23,10 @@ pub struct RequestContext {
|
||||
///
|
||||
/// **Usage :**
|
||||
/// ```rust
|
||||
/// pub async fn ma_vue(user: CurrentUser) {
|
||||
/// if user.is_superuser { ... }
|
||||
/// }
|
||||
/// use oxspeak_server_lib::http::context::CurrentUser;
|
||||
/// # fn check(user: CurrentUser) {
|
||||
/// # let _ = user.is_superuser;
|
||||
/// # }
|
||||
/// ```
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CurrentUser(pub user::Model);
|
||||
@@ -71,6 +72,7 @@ where
|
||||
///
|
||||
/// **Usage :**
|
||||
/// ```rust
|
||||
/// use oxspeak_server_lib::http::context::Superuser;
|
||||
/// pub async fn suppression_globale(admin: Superuser) {
|
||||
/// // Ici, nous sommes certains que admin.is_superuser est true.
|
||||
/// }
|
||||
|
||||
+1
-3
@@ -79,9 +79,7 @@ impl IntoResponse for HTTPError {
|
||||
.into_response();
|
||||
}
|
||||
HTTPError::Internal(err) => {
|
||||
// On utilise %err pour un message d'erreur clair sans backtrace brute
|
||||
// mais on garde les détails pour le span tracing si besoin.
|
||||
tracing::error!(%err, "Request error");
|
||||
tracing::error!(error = %format_args!("{err:#}"), "Request error");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error")
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,6 +7,9 @@ pub mod metrics;
|
||||
pub mod middleware;
|
||||
pub mod permissions;
|
||||
pub mod server;
|
||||
mod tls;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_support;
|
||||
pub mod validation;
|
||||
|
||||
pub use permissions::{RequireChannelPermission, RequireServerPermission};
|
||||
|
||||
+81
-88
@@ -1,11 +1,11 @@
|
||||
// Unused
|
||||
|
||||
use super::context::CurrentUser;
|
||||
use super::error::HTTPError;
|
||||
use crate::core::AppState;
|
||||
use crate::permissions::{ChannelPermission, ServerPermission};
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::extract::{FromRequestParts, RawPathParams};
|
||||
use axum::http::request::Parts;
|
||||
use crate::models::{channel_user, role, role_user, server_user, server_role_permission, channel_role_permission};
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
use std::ops::Deref;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -15,23 +15,21 @@ use uuid::Uuid;
|
||||
/// The target `server_id` is automatically extracted from path parameters (supporting
|
||||
/// path parameters named `server_id` or `id`).
|
||||
///
|
||||
/// # Superuser Bypass
|
||||
/// If the user is a superuser (`is_superuser == true`), the permission check automatically passes.
|
||||
///
|
||||
/// # Usage Example
|
||||
/// ```rust
|
||||
/// use axum::extract::State;
|
||||
/// use axum::extract::{Path, State};
|
||||
/// use uuid::Uuid;
|
||||
/// use crate::http::permissions::RequireServerPermission;
|
||||
/// use crate::permissions::ServerPermission;
|
||||
/// use crate::core::AppState;
|
||||
/// use oxspeak_server_lib::http::permissions::RequireServerPermission;
|
||||
/// use oxspeak_server_lib::http::error::HTTPError;
|
||||
/// use oxspeak_server_lib::permissions::ServerPermission;
|
||||
/// use oxspeak_server_lib::core::AppState;
|
||||
///
|
||||
/// pub async fn update_server_settings(
|
||||
/// RequireServerPermission::<{ ServerPermission::MANAGE_SERVER.bits() }>(user): RequireServerPermission<{ ServerPermission::MANAGE_SERVER.bits() }>,
|
||||
/// RequireServerPermission::<{ ServerPermission::MANAGE_SERVER.bits() }>(_user): RequireServerPermission<{ ServerPermission::MANAGE_SERVER.bits() }>,
|
||||
/// State(state): State<AppState>,
|
||||
/// Path(server_id): Path<Uuid>,
|
||||
/// Path(_server_id): Path<Uuid>,
|
||||
/// ) -> Result<(), HTTPError> {
|
||||
/// // User has MANAGE_SERVER or is a superuser
|
||||
/// // User has MANAGE_SERVER
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
@@ -46,30 +44,15 @@ impl<const PERM: u64> Deref for RequireServerPermission<PERM> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, const PERM: u64> FromRequestParts<S> for RequireServerPermission<PERM>
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
impl<const PERM: u64> FromRequestParts<AppState> for RequireServerPermission<PERM> {
|
||||
type Rejection = HTTPError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
|
||||
// 1. Extract CurrentUser (which validates authentication and returns 401 if missing)
|
||||
let current_user = CurrentUser::from_request_parts(parts, state).await?;
|
||||
|
||||
// 2. Superuser bypasses all checks
|
||||
if current_user.is_superuser {
|
||||
return Ok(RequireServerPermission(current_user));
|
||||
}
|
||||
|
||||
// 3. Get AppState from extensions
|
||||
let app_state = match parts.extensions.get::<AppState>() {
|
||||
Some(s) => s.clone(),
|
||||
None => {
|
||||
return Err(HTTPError::InternalServerError(
|
||||
"AppState missing in request extensions".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// 4. Extract server_id from path parameters.
|
||||
let server_id = match extract_path_param_uuid(parts, &["server_id", "id"]) {
|
||||
@@ -82,22 +65,7 @@ where
|
||||
};
|
||||
|
||||
// 5. Check user permission via server repository
|
||||
let permission_result = app_state
|
||||
.repositories
|
||||
.server
|
||||
.get_user_permission(server_id, current_user.id)
|
||||
.await;
|
||||
|
||||
let permission_bits = match permission_result {
|
||||
Ok(Some(p)) => p.permissions,
|
||||
Ok(None) => 0,
|
||||
Err(e) => return Err(HTTPError::InternalServerError(e.to_string())),
|
||||
};
|
||||
|
||||
let required = ServerPermission::from_bits_truncate(PERM);
|
||||
let granted = ServerPermission::from_bits_truncate(permission_bits as u64);
|
||||
|
||||
if granted.contains(required) {
|
||||
if check_server_permission(state, current_user.id, server_id, ServerPermission::from_bits_truncate(PERM)).await? {
|
||||
Ok(RequireServerPermission(current_user))
|
||||
} else {
|
||||
Err(HTTPError::Forbidden)
|
||||
@@ -110,23 +78,21 @@ where
|
||||
///
|
||||
/// The target `channel_id` (or `id`) is automatically extracted from path parameters.
|
||||
///
|
||||
/// # Superuser Bypass
|
||||
/// If the user is a superuser (`is_superuser == true`), the permission check automatically passes.
|
||||
///
|
||||
/// # Usage Example
|
||||
/// ```rust
|
||||
/// use axum::extract::State;
|
||||
/// use axum::extract::{Path, State};
|
||||
/// use uuid::Uuid;
|
||||
/// use crate::http::permissions::RequireChannelPermission;
|
||||
/// use crate::permissions::ChannelPermission;
|
||||
/// use crate::core::AppState;
|
||||
/// use oxspeak_server_lib::http::permissions::RequireChannelPermission;
|
||||
/// use oxspeak_server_lib::http::error::HTTPError;
|
||||
/// use oxspeak_server_lib::permissions::ChannelPermission;
|
||||
/// use oxspeak_server_lib::core::AppState;
|
||||
///
|
||||
/// pub async fn read_channel_messages(
|
||||
/// RequireChannelPermission::<{ ChannelPermission::READ_CHANNEL.bits() }>(user): RequireChannelPermission<{ ChannelPermission::READ_CHANNEL.bits() }>,
|
||||
/// RequireChannelPermission::<{ ChannelPermission::READ_CHANNEL.bits() }>(_user): RequireChannelPermission<{ ChannelPermission::READ_CHANNEL.bits() }>,
|
||||
/// State(state): State<AppState>,
|
||||
/// Path(channel_id): Path<Uuid>,
|
||||
/// Path(_channel_id): Path<Uuid>,
|
||||
/// ) -> Result<(), HTTPError> {
|
||||
/// // User has READ_CHANNEL or is a superuser
|
||||
/// // User has READ_CHANNEL
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
@@ -141,27 +107,13 @@ impl<const PERM: u64> Deref for RequireChannelPermission<PERM> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, const PERM: u64> FromRequestParts<S> for RequireChannelPermission<PERM>
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
impl<const PERM: u64> FromRequestParts<AppState> for RequireChannelPermission<PERM> {
|
||||
type Rejection = HTTPError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
|
||||
let current_user = CurrentUser::from_request_parts(parts, state).await?;
|
||||
|
||||
if current_user.is_superuser {
|
||||
return Ok(RequireChannelPermission(current_user));
|
||||
}
|
||||
|
||||
let app_state = match parts.extensions.get::<AppState>() {
|
||||
Some(s) => s.clone(),
|
||||
None => {
|
||||
return Err(HTTPError::InternalServerError(
|
||||
"AppState missing in request extensions".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let channel_id = match extract_path_param_uuid(parts, &["channel_id", "id"]) {
|
||||
Some(id) => id,
|
||||
@@ -172,22 +124,7 @@ where
|
||||
}
|
||||
};
|
||||
|
||||
let permission_result = app_state
|
||||
.repositories
|
||||
.channel
|
||||
.get_user_permission(channel_id, current_user.id)
|
||||
.await;
|
||||
|
||||
let permission_bits = match permission_result {
|
||||
Ok(Some(p)) => p.permissions,
|
||||
Ok(None) => 0,
|
||||
Err(e) => return Err(HTTPError::InternalServerError(e.to_string())),
|
||||
};
|
||||
|
||||
let required = ChannelPermission::from_bits_truncate(PERM);
|
||||
let granted = ChannelPermission::from_bits_truncate(permission_bits as u64);
|
||||
|
||||
if granted.contains(required) {
|
||||
if check_channel_permission(state, current_user.id, channel_id, ChannelPermission::from_bits_truncate(PERM)).await? {
|
||||
Ok(RequireChannelPermission(current_user))
|
||||
} else {
|
||||
Err(HTTPError::Forbidden)
|
||||
@@ -198,6 +135,13 @@ where
|
||||
/// Helper function to extract a Uuid path parameter matching any of the given key names
|
||||
/// from Axum request extensions.
|
||||
fn extract_path_param_uuid(parts: &Parts, keys: &[&str]) -> Option<Uuid> {
|
||||
if let Some(params) = parts.extensions.get::<RawPathParams>() {
|
||||
for (key, value) in params.iter() {
|
||||
if keys.contains(&key) {
|
||||
if let Ok(id) = Uuid::parse_str(value) { return Some(id); }
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(map) = parts
|
||||
.extensions
|
||||
.get::<std::collections::HashMap<String, String>>()
|
||||
@@ -223,3 +167,52 @@ fn extract_path_param_uuid(parts: &Parts, keys: &[&str]) -> Option<Uuid> {
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn check_server_permission(state: &AppState, user_id: Uuid, server_id: Uuid, required: ServerPermission) -> Result<bool, HTTPError> {
|
||||
let member = server_user::Entity::find()
|
||||
.filter(server_user::Column::ServerId.eq(server_id))
|
||||
.filter(server_user::Column::UserId.eq(user_id))
|
||||
.one(&state.db).await?;
|
||||
if member.is_none() { return Ok(false); }
|
||||
let mut bits = state.repositories.server.get_user_permission(server_id, user_id).await
|
||||
.map_err(|e| HTTPError::InternalServerError(e.to_string()))?
|
||||
.map_or(0, |p| p.permissions as u64);
|
||||
let roles = role_user::Entity::find().filter(role_user::Column::UserId.eq(user_id)).all(&state.db).await?;
|
||||
for assignment in roles {
|
||||
if role::Entity::find_by_id(assignment.role_id).one(&state.db).await?.is_some_and(|r| r.server_id == server_id) {
|
||||
if let Some(p) = server_role_permission::Entity::find()
|
||||
.filter(server_role_permission::Column::ServerId.eq(server_id))
|
||||
.filter(server_role_permission::Column::RoleId.eq(assignment.role_id))
|
||||
.one(&state.db).await? { bits |= p.permissions as u64; }
|
||||
}
|
||||
}
|
||||
Ok(ServerPermission::from_bits_truncate(bits).contains(required))
|
||||
}
|
||||
|
||||
pub async fn check_channel_permission(state: &AppState, user_id: Uuid, channel_id: Uuid, required: ChannelPermission) -> Result<bool, HTTPError> {
|
||||
let channel = state.repositories.channel.get_by_id(channel_id).await
|
||||
.map_err(|e| HTTPError::InternalServerError(e.to_string()))?;
|
||||
let Some(channel) = channel else { return Ok(false) };
|
||||
if let Some(server_id) = channel.server_id {
|
||||
if !check_server_permission(state, user_id, server_id, ServerPermission::empty()).await? { return Ok(false); }
|
||||
} else if channel_user::Entity::find()
|
||||
.filter(channel_user::Column::ChannelId.eq(channel_id))
|
||||
.filter(channel_user::Column::UserId.eq(user_id))
|
||||
.one(&state.db).await?.is_none() { return Ok(false); }
|
||||
let mut bits = if channel.server_id.is_none() { crate::permissions::PermissionSet::DEFAULT.channel.bits() } else { 0 };
|
||||
bits |= state.repositories.channel.get_user_permission(channel_id, user_id).await
|
||||
.map_err(|e| HTTPError::InternalServerError(e.to_string()))?
|
||||
.map_or(0, |p| p.permissions as u64);
|
||||
if let Some(server_id) = channel.server_id {
|
||||
let roles = role_user::Entity::find().filter(role_user::Column::UserId.eq(user_id)).all(&state.db).await?;
|
||||
for assignment in roles {
|
||||
if role::Entity::find_by_id(assignment.role_id).one(&state.db).await?.is_some_and(|r| r.server_id == server_id) {
|
||||
if let Some(p) = channel_role_permission::Entity::find()
|
||||
.filter(channel_role_permission::Column::ChannelId.eq(channel_id))
|
||||
.filter(channel_role_permission::Column::RoleId.eq(assignment.role_id))
|
||||
.one(&state.db).await? { bits |= p.permissions as u64; }
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ChannelPermission::from_bits_truncate(bits).contains(required))
|
||||
}
|
||||
|
||||
+20
-1
@@ -15,12 +15,13 @@ use tower_http::catch_panic::CatchPanicLayer;
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
use crate::config::NetworkConfig;
|
||||
use crate::config::{NetworkConfig, TlsConfig};
|
||||
use crate::core::AppState;
|
||||
use crate::routes;
|
||||
|
||||
use super::metrics::HttpMetrics;
|
||||
use super::middleware;
|
||||
use super::tls;
|
||||
|
||||
// ── Erreurs ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -66,6 +67,7 @@ pub enum HttpServerError {
|
||||
/// ```
|
||||
pub struct HttpServer {
|
||||
bind_addr: SocketAddr,
|
||||
tls: Option<TlsConfig>,
|
||||
app_state: AppState,
|
||||
metrics: Arc<HttpMetrics>,
|
||||
shutdown_rx: broadcast::Receiver<()>,
|
||||
@@ -87,6 +89,7 @@ impl HttpServer {
|
||||
(
|
||||
Self {
|
||||
bind_addr,
|
||||
tls: network_config.tls.clone(),
|
||||
app_state,
|
||||
metrics,
|
||||
shutdown_rx,
|
||||
@@ -149,6 +152,22 @@ impl HttpServer {
|
||||
// Outermost : intercepte les panics et retourne une 500 propre
|
||||
.layer(CatchPanicLayer::new());
|
||||
|
||||
if let Some(tls_config) = &self.tls {
|
||||
let rustls = tls::load_or_create(tls_config).await?;
|
||||
let handle = axum_server::Handle::new();
|
||||
let shutdown = handle.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = self.shutdown_rx.recv().await;
|
||||
shutdown.graceful_shutdown(Some(std::time::Duration::from_secs(10)));
|
||||
});
|
||||
tracing::info!(addr = %self.bind_addr, "HTTPS server listening");
|
||||
axum_server::bind_rustls(self.bind_addr, rustls)
|
||||
.handle(handle)
|
||||
.serve(app.into_make_service())
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let listener =
|
||||
TcpListener::bind(self.bind_addr)
|
||||
.await
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
use crate::config::{AppConfig, DEFAULT_CONFIG_TOML};
|
||||
use crate::core::{App, AppState};
|
||||
use crate::http::context::{CurrentUser, RequestContext};
|
||||
use crate::models::user;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Method, Request};
|
||||
use chrono::Utc;
|
||||
use sea_orm::{ActiveModelTrait, Set};
|
||||
use std::time::Instant;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn state() -> AppState {
|
||||
let path = format!("{}/target/permissions-{}.db", env!("CARGO_MANIFEST_DIR"), Uuid::new_v4());
|
||||
let config: AppConfig = toml::from_str(&DEFAULT_CONFIG_TOML.replace(
|
||||
"sqlite://oxspeak.db",
|
||||
&format!("sqlite://{path}"),
|
||||
))
|
||||
.unwrap();
|
||||
App::build(config).await.unwrap().state
|
||||
}
|
||||
|
||||
pub async fn user(state: &AppState, admin: bool) -> user::Model {
|
||||
user::ActiveModel {
|
||||
username: Set(format!("test-{}", Uuid::new_v4())),
|
||||
password: Set("unused".into()),
|
||||
created_at: Set(Utc::now()),
|
||||
updated_at: Set(Utc::now()),
|
||||
is_superuser: Set(admin),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&state.db)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn request(method: Method, uri: &str, body: Body, user: Option<user::Model>) -> Request<Body> {
|
||||
let mut request = Request::builder().method(method).uri(uri).body(body).unwrap();
|
||||
let method = request.method().clone();
|
||||
let uri = request.uri().clone();
|
||||
request.extensions_mut().insert(RequestContext {
|
||||
request_id: Uuid::new_v4(),
|
||||
started_at: Instant::now(),
|
||||
method,
|
||||
uri,
|
||||
user: user.map(CurrentUser),
|
||||
});
|
||||
request
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
use crate::config::TlsConfig;
|
||||
use axum_server::tls_rustls::RustlsConfig;
|
||||
use std::io;
|
||||
|
||||
pub async fn load_or_create(config: &TlsConfig) -> io::Result<RustlsConfig> {
|
||||
if config.names.is_empty() || config.names.iter().any(|name| name.is_empty()) {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "TLS requires at least one non-empty name"));
|
||||
}
|
||||
if config.cert_path == config.key_path {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "certificate and key paths must differ"));
|
||||
}
|
||||
|
||||
let cert_exists = config.cert_path.exists();
|
||||
let key_exists = config.key_path.exists();
|
||||
if cert_exists != key_exists {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "only one TLS file exists; restore the missing file or remove both to regenerate"));
|
||||
}
|
||||
if !cert_exists {
|
||||
let generated = rcgen::generate_simple_self_signed(config.names.clone())
|
||||
.map_err(io::Error::other)?;
|
||||
for path in [&config.cert_path, &config.key_path] {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
}
|
||||
std::fs::write(&config.cert_path, generated.cert.pem())?;
|
||||
let key = generated.key_pair.serialize_pem();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::io::Write;
|
||||
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(&config.key_path)?;
|
||||
file.write_all(key.as_bytes())?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
std::fs::write(&config.key_path, key)?;
|
||||
tracing::info!(cert = %config.cert_path.display(), "Generated self-signed TLS certificate");
|
||||
}
|
||||
RustlsConfig::from_pem_file(&config.cert_path, &config.key_path).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn certificate_is_reused_and_partial_pair_is_rejected() {
|
||||
let dir = std::env::temp_dir().join(format!("oxspeak-tls-{}", uuid::Uuid::new_v4()));
|
||||
let config = TlsConfig {
|
||||
cert_path: dir.join("cert.pem"), key_path: dir.join("key.pem"),
|
||||
names: vec!["localhost".into()],
|
||||
};
|
||||
load_or_create(&config).await.unwrap();
|
||||
let cert = std::fs::read(&config.cert_path).unwrap();
|
||||
let key = std::fs::read(&config.key_path).unwrap();
|
||||
load_or_create(&config).await.unwrap();
|
||||
assert_eq!(cert, std::fs::read(&config.cert_path).unwrap());
|
||||
assert_eq!(key, std::fs::read(&config.key_path).unwrap());
|
||||
std::fs::remove_file(&config.key_path).unwrap();
|
||||
assert!(load_or_create(&config).await.is_err());
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,9 @@ use crate::domain::dto::attachment::AttachmentUploadResponse;
|
||||
use crate::http::context::CurrentUser;
|
||||
use crate::http::error::HTTPError;
|
||||
use crate::models::attachment;
|
||||
use crate::permissions::ChannelPermission;
|
||||
use crate::routes::attachment::mapper;
|
||||
use crate::routes::message::handlers::can_access;
|
||||
use crate::routes::message::handlers::require_channel_permission;
|
||||
use crate::services::media::{self, PendingMediaFile};
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Multipart, Path, State};
|
||||
@@ -75,9 +76,14 @@ pub async fn create(
|
||||
.content_type()
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
if !can_access(&state, channel, user.id).await? {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
require_channel_permission(
|
||||
&state,
|
||||
channel,
|
||||
user.id,
|
||||
user.is_superuser,
|
||||
ChannelPermission::ATTACH_FILES,
|
||||
)
|
||||
.await?;
|
||||
let id = Uuid::new_v4();
|
||||
let mut output = PendingMediaFile::begin(
|
||||
PathBuf::from(&state.config.media.root).as_path(),
|
||||
@@ -128,9 +134,14 @@ pub async fn create(
|
||||
}
|
||||
let channel_id =
|
||||
channel_id.ok_or_else(|| HTTPError::BadRequest("channel_id is required".into()))?;
|
||||
if !can_access(&state, channel_id, user.id).await? {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
require_channel_permission(
|
||||
&state,
|
||||
channel_id,
|
||||
user.id,
|
||||
user.is_superuser,
|
||||
ChannelPermission::ATTACH_FILES,
|
||||
)
|
||||
.await?;
|
||||
if created.is_empty() {
|
||||
return Err(HTTPError::BadRequest(
|
||||
"at least one file is required".into(),
|
||||
@@ -145,6 +156,7 @@ pub async fn create(
|
||||
}
|
||||
|
||||
pub async fn file(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Response, HTTPError> {
|
||||
@@ -152,6 +164,14 @@ pub async fn file(
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
require_channel_permission(
|
||||
&state,
|
||||
item.channel_id,
|
||||
user.id,
|
||||
user.is_superuser,
|
||||
ChannelPermission::READ_CHANNEL,
|
||||
)
|
||||
.await?;
|
||||
let bytes = tokio::fs::read(PathBuf::from(&state.config.media.root).join(&item.file_path))
|
||||
.await
|
||||
.map_err(|_| HTTPError::NotFound)?;
|
||||
|
||||
@@ -15,3 +15,97 @@ pub fn secure_router() -> Router<AppState> {
|
||||
pub fn public_router() -> Router<AppState> {
|
||||
Router::new().route("/attachments/{id}/file", get(handlers::file))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod permission_tests {
|
||||
use super::*;
|
||||
use crate::models::attachment;
|
||||
use crate::permissions::ChannelPermission;
|
||||
use crate::routes::message::routes::permission_tests::Fixture;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Method, StatusCode},
|
||||
};
|
||||
use sea_orm::{ActiveModelTrait, Set};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_requires_attach_files() {
|
||||
let fixture = Fixture::new().await;
|
||||
// Le contenu multipart est rejoué avant et après l'octroi du droit.
|
||||
let boundary = "attachment-permission-test";
|
||||
let body = format!(
|
||||
"--{boundary}\r\nContent-Disposition: form-data; name=\"channel_id\"\r\n\r\n{}\r\n--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"hello.txt\"\r\nContent-Type: text/plain\r\n\r\nhello\r\n--{boundary}--\r\n",
|
||||
fixture.channel_id
|
||||
);
|
||||
let mime = format!("multipart/form-data; boundary={boundary}");
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(
|
||||
secure_router(),
|
||||
Method::POST,
|
||||
"/attachments",
|
||||
Body::from(body.clone()),
|
||||
Some(&mime)
|
||||
)
|
||||
.await,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
fixture.grant(ChannelPermission::ATTACH_FILES).await;
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(
|
||||
secure_router(),
|
||||
Method::POST,
|
||||
"/attachments",
|
||||
Body::from(body),
|
||||
Some(&mime)
|
||||
)
|
||||
.await,
|
||||
StatusCode::CREATED
|
||||
);
|
||||
std::fs::remove_dir_all(&fixture.state.config.media.root).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn download_requires_read_channel() {
|
||||
let fixture = Fixture::new().await;
|
||||
// Associe un fichier à un canal pour vérifier que son téléchargement suit READ_CHANNEL.
|
||||
let id = Uuid::new_v4();
|
||||
let path = format!("attachments/{id}.txt");
|
||||
let full_path = std::path::Path::new(&fixture.state.config.media.root).join(&path);
|
||||
tokio::fs::create_dir_all(full_path.parent().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(&full_path, b"hello").await.unwrap();
|
||||
attachment::ActiveModel {
|
||||
id: Set(id),
|
||||
message_id: Set(None),
|
||||
channel_id: Set(fixture.channel_id),
|
||||
user_id: Set(fixture.user.id),
|
||||
filename: Set("hello.txt".into()),
|
||||
file_size: Set(5),
|
||||
mime_type: Set("text/plain".into()),
|
||||
file_path: Set(path),
|
||||
created_at: Set(chrono::Utc::now()),
|
||||
}
|
||||
.insert(&fixture.state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let uri = format!("/attachments/{id}/file");
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(public_router(), Method::GET, &uri, Body::empty(), None)
|
||||
.await,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
fixture.grant(ChannelPermission::READ_CHANNEL).await;
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(public_router(), Method::GET, &uri, Body::empty(), None)
|
||||
.await,
|
||||
StatusCode::OK
|
||||
);
|
||||
std::fs::remove_dir_all(&fixture.state.config.media.root).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,3 +9,25 @@ pub fn router() -> OxRouter {
|
||||
.route("/auth/bearer-login", post(handlers::login_bearer))
|
||||
.route("/auth/me", get(handlers::me))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::http::test_support::{request, state, user};
|
||||
use axum::{body::Body, http::{Method, StatusCode}};
|
||||
use tower::ServiceExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_is_public_and_me_requires_authentication() {
|
||||
let state = state().await;
|
||||
let member = user(&state, false).await;
|
||||
let routes = router().with_state(state);
|
||||
for uri in ["/auth/login", "/auth/bearer-login"] {
|
||||
let mut login = request(Method::POST, uri, Body::from(r#"{"username":"nobody","password":"wrong"}"#), None);
|
||||
login.headers_mut().insert("content-type", "application/json".parse().unwrap());
|
||||
assert_eq!(routes.clone().oneshot(login).await.unwrap().status(), StatusCode::UNAUTHORIZED, "{uri}");
|
||||
}
|
||||
assert_eq!(routes.clone().oneshot(request(Method::GET, "/auth/me", Body::empty(), None)).await.unwrap().status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(routes.oneshot(request(Method::GET, "/auth/me", Body::empty(), Some(member))).await.unwrap().status(), StatusCode::OK);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ use crate::core::state::AppState;
|
||||
use crate::domain::dto::category::{
|
||||
CategoryQueryParams, CategoryResponse, CreateCategoryRequest, UpdateCategoryRequest,
|
||||
};
|
||||
use crate::http::context::Superuser;
|
||||
use crate::http::context::CurrentUser;
|
||||
use crate::http::error::HTTPError;
|
||||
use crate::permissions::ServerPermission;
|
||||
use crate::routes::server::handlers::require_server_permission;
|
||||
use crate::routes::category::mapper;
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -26,9 +28,12 @@ use uuid::Uuid;
|
||||
tag = "Categories"
|
||||
)]
|
||||
pub async fn get_all(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Query(filters): Query<CategoryQueryParams>,
|
||||
) -> Result<Json<Vec<CategoryResponse>>, HTTPError> {
|
||||
let server_id = filters.server_id.ok_or(HTTPError::Forbidden)?;
|
||||
state.repositories.server.get_user(server_id, user.id).await?.ok_or(HTTPError::Forbidden)?;
|
||||
let categories = state
|
||||
.repositories
|
||||
.category
|
||||
@@ -57,6 +62,7 @@ pub async fn get_all(
|
||||
tag = "Categories"
|
||||
)]
|
||||
pub async fn get_by_id(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<CategoryResponse>, HTTPError> {
|
||||
@@ -66,6 +72,7 @@ pub async fn get_by_id(
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
state.repositories.server.get_user(category.server_id, user.id).await?.ok_or(HTTPError::Forbidden)?;
|
||||
|
||||
Ok(Json(mapper::category_model_to_category_response(category)))
|
||||
}
|
||||
@@ -86,7 +93,7 @@ pub async fn get_by_id(
|
||||
)
|
||||
)]
|
||||
pub async fn create(
|
||||
_admin: Superuser,
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CreateCategoryRequest>,
|
||||
) -> Result<(StatusCode, Json<CategoryResponse>), HTTPError> {
|
||||
@@ -97,6 +104,7 @@ pub async fn create(
|
||||
.get_by_id(payload.server_id)
|
||||
.await?
|
||||
.ok_or(HTTPError::BadRequest("Server not found".to_string()))?;
|
||||
require_server_permission(&state, &user, payload.server_id, ServerPermission::MANAGE_CATEGORIES).await?;
|
||||
|
||||
let category = state
|
||||
.services
|
||||
@@ -128,18 +136,19 @@ pub async fn create(
|
||||
)
|
||||
)]
|
||||
pub async fn update(
|
||||
_admin: Superuser,
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(payload): Json<UpdateCategoryRequest>,
|
||||
) -> Result<Json<CategoryResponse>, HTTPError> {
|
||||
// Vérifier l'existence
|
||||
let _category = state
|
||||
let category = state
|
||||
.repositories
|
||||
.category
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
require_server_permission(&state, &user, category.server_id, ServerPermission::MANAGE_CATEGORIES).await?;
|
||||
|
||||
let category = state
|
||||
.services
|
||||
@@ -168,10 +177,12 @@ pub async fn update(
|
||||
)
|
||||
)]
|
||||
pub async fn delete(
|
||||
_admin: Superuser,
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<StatusCode, HTTPError> {
|
||||
let category = state.repositories.category.get_by_id(id).await?.ok_or(HTTPError::NotFound)?;
|
||||
require_server_permission(&state, &user, category.server_id, ServerPermission::MANAGE_CATEGORIES).await?;
|
||||
if state.services.category.delete_category(id).await? {
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
} else {
|
||||
|
||||
@@ -3,3 +3,6 @@ pub mod handlers;
|
||||
pub mod mapper;
|
||||
pub mod routes;
|
||||
pub mod service;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
use crate::http::test_support::{request, state, user};
|
||||
use crate::permissions::ServerPermission;
|
||||
use axum::{body::{to_bytes, Body}, http::{Method, StatusCode}, Router};
|
||||
use serde_json::{json, Value};
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn call(router: &Router, method: Method, uri: &str, body: Value, actor: crate::models::user::Model) -> axum::response::Response {
|
||||
let mut req = request(method, uri, Body::from(body.to_string()), Some(actor));
|
||||
req.headers_mut().insert("content-type", "application/json".parse().unwrap());
|
||||
router.clone().oneshot(req).await.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn category_permissions_over_http() {
|
||||
let state = state().await;
|
||||
let server = state.default_server.id;
|
||||
let actor = user(&state, false).await;
|
||||
let router = super::routes::router().with_state(state.clone());
|
||||
let list = format!("/categories?server_id={server}");
|
||||
|
||||
// La lecture est réservée aux membres du serveur.
|
||||
assert_eq!(
|
||||
call(&router, Method::GET, &list, json!(null), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
|
||||
state.repositories.server.add_user(server, actor.id).await.unwrap();
|
||||
assert_eq!(
|
||||
call(&router, Method::GET, &list, json!(null), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
|
||||
let payload = json!({"server_id":server,"name":"test-category"});
|
||||
|
||||
// Être membre suffit pour lire, mais pas pour gérer les catégories.
|
||||
assert_eq!(
|
||||
call(&router, Method::POST, "/categories", payload.clone(), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
|
||||
state.repositories.server.set_user_permission(server, actor.id, ServerPermission::MANAGE_CATEGORIES.bits()).await.unwrap();
|
||||
let created = call(&router, Method::POST, "/categories", payload, actor.clone()).await;
|
||||
|
||||
assert_eq!(created.status(), StatusCode::CREATED);
|
||||
let id: Value = serde_json::from_slice(&to_bytes(created.into_body(), 1024 * 1024).await.unwrap()).unwrap();
|
||||
let uri = format!("/categories/{}", id["id"].as_str().unwrap());
|
||||
assert_eq!(
|
||||
call(&router, Method::GET, &uri, json!(null), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
|
||||
// Le retrait du droit de gestion bloque modification et suppression.
|
||||
state.repositories.server.set_user_permission(server, actor.id, 0).await.unwrap();
|
||||
assert_eq!(
|
||||
call(&router, Method::PUT, &uri, json!({"name":"renamed"}), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
assert_eq!(
|
||||
call(&router, Method::DELETE, &uri, json!(null), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
|
||||
state.repositories.server.set_user_permission(server, actor.id, ServerPermission::MANAGE_CATEGORIES.bits()).await.unwrap();
|
||||
assert_eq!(
|
||||
call(&router, Method::PUT, &uri, json!({"name":"renamed"}), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
call(&router, Method::DELETE, &uri, json!(null), actor)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,11 @@ use crate::domain::dto::channel::{
|
||||
ChannelUserPermissionResponse, CreateChannelRequest, ReadStateResponse,
|
||||
SetChannelPermissionRequest, SetReadStateRequest, UpdateChannelRequest,
|
||||
};
|
||||
use crate::http::context::{CurrentUser, Superuser};
|
||||
use crate::http::context::CurrentUser;
|
||||
use crate::http::error::HTTPError;
|
||||
use crate::permissions::{ChannelPermission, ServerPermission};
|
||||
use crate::http::permissions::check_channel_permission;
|
||||
use crate::routes::server::handlers::require_server_permission;
|
||||
use crate::models::{channel, channel_user};
|
||||
use crate::routes::channel::mapper;
|
||||
use axum::{
|
||||
@@ -16,6 +19,23 @@ use axum::{
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
use uuid::Uuid;
|
||||
|
||||
async fn require_channel_permission(
|
||||
state: &AppState,
|
||||
user: &CurrentUser,
|
||||
channel_id: Uuid,
|
||||
permission: ChannelPermission,
|
||||
) -> Result<channel::Model, HTTPError> {
|
||||
let channel = state.repositories.channel.get_by_id(channel_id).await?.ok_or(HTTPError::NotFound)?;
|
||||
if check_channel_permission(state, user.id, channel_id, permission).await? { Ok(channel) } else { Err(HTTPError::Forbidden) }
|
||||
}
|
||||
|
||||
async fn require_channel_manager(state: &AppState, user: &CurrentUser, channel_id: Uuid) -> Result<channel::Model, HTTPError> {
|
||||
let channel = state.repositories.channel.get_by_id(channel_id).await?.ok_or(HTTPError::NotFound)?;
|
||||
let server_id = channel.server_id.ok_or(HTTPError::Forbidden)?;
|
||||
require_server_permission(state, user, server_id, ServerPermission::MANAGE_CHANNELS).await?;
|
||||
Ok(channel)
|
||||
}
|
||||
|
||||
async fn require_channel_member(
|
||||
state: &AppState,
|
||||
channel_id: Uuid,
|
||||
@@ -54,16 +74,26 @@ async fn require_channel_member(
|
||||
tag = "Channels"
|
||||
)]
|
||||
pub async fn get_all(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Query(filters): Query<ChannelQueryParams>,
|
||||
) -> Result<Json<Vec<ChannelResponse>>, HTTPError> {
|
||||
let server_id = filters.server_id.ok_or(HTTPError::Forbidden)?;
|
||||
state.repositories.server.get_user(server_id, user.id).await?.ok_or(HTTPError::Forbidden)?;
|
||||
let params = mapper::query_params_to_channel_filter(filters);
|
||||
let channels = state.repositories.channel.filter(params).await?;
|
||||
Ok(Json(
|
||||
channels
|
||||
.into_iter()
|
||||
.map(mapper::channel_model_to_channel_response)
|
||||
.collect(),
|
||||
{
|
||||
let mut visible = Vec::new();
|
||||
for channel in channels {
|
||||
match require_channel_permission(&state, &user, channel.id, ChannelPermission::READ_CHANNEL).await {
|
||||
Ok(_) => visible.push(mapper::channel_model_to_channel_response(channel)),
|
||||
Err(HTTPError::Forbidden) => {},
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
visible
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
@@ -80,7 +110,7 @@ pub async fn get_read_state(
|
||||
State(state): State<AppState>,
|
||||
Path(channel_id): Path<Uuid>,
|
||||
) -> Result<Json<ReadStateResponse>, HTTPError> {
|
||||
require_channel_member(&state, channel_id, user.id).await?;
|
||||
require_channel_permission(&state, &user, channel_id, ChannelPermission::READ_CHANNEL).await?;
|
||||
let read_state = state
|
||||
.repositories
|
||||
.read_state
|
||||
@@ -120,7 +150,7 @@ pub async fn set_read_state(
|
||||
Path(channel_id): Path<Uuid>,
|
||||
Json(payload): Json<SetReadStateRequest>,
|
||||
) -> Result<Json<ReadStateResponse>, HTTPError> {
|
||||
require_channel_member(&state, channel_id, user.id).await?;
|
||||
require_channel_permission(&state, &user, channel_id, ChannelPermission::READ_CHANNEL).await?;
|
||||
|
||||
if let Some(message_id) = payload.last_read_message_id {
|
||||
let message = state
|
||||
@@ -173,6 +203,7 @@ pub async fn set_read_state(
|
||||
tag = "Channels"
|
||||
)]
|
||||
pub async fn get_by_id(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ChannelResponse>, HTTPError> {
|
||||
@@ -182,6 +213,7 @@ pub async fn get_by_id(
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
require_channel_permission(&state, &user, id, ChannelPermission::READ_CHANNEL).await?;
|
||||
|
||||
Ok(Json(mapper::channel_model_to_channel_response(channel)))
|
||||
}
|
||||
@@ -195,9 +227,11 @@ pub async fn get_by_id(
|
||||
tag = "Channel Permissions"
|
||||
)]
|
||||
pub async fn list_permissions(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path(channel_id): Path<Uuid>,
|
||||
) -> Result<Json<ChannelPermissionsResponse>, HTTPError> {
|
||||
require_channel_manager(&state, &user, channel_id).await?;
|
||||
state
|
||||
.repositories
|
||||
.channel
|
||||
@@ -227,7 +261,7 @@ pub async fn list_permissions(
|
||||
)
|
||||
)]
|
||||
pub async fn create(
|
||||
_admin: Superuser,
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CreateChannelRequest>,
|
||||
) -> Result<(StatusCode, Json<ChannelResponse>), HTTPError> {
|
||||
@@ -239,16 +273,20 @@ pub async fn create(
|
||||
.get_by_id(server_id)
|
||||
.await?
|
||||
.ok_or(HTTPError::BadRequest("Server not found".to_string()))?;
|
||||
require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_CHANNELS).await?;
|
||||
} else {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
|
||||
// Vérifier que la catégorie existe si fournie
|
||||
if let Some(category_id) = payload.category_id {
|
||||
state
|
||||
let category = state
|
||||
.repositories
|
||||
.category
|
||||
.get_by_id(category_id)
|
||||
.await?
|
||||
.ok_or(HTTPError::BadRequest("Category not found".to_string()))?;
|
||||
if category.server_id != payload.server_id.ok_or(HTTPError::Forbidden)? { return Err(HTTPError::BadRequest("Category belongs to another server".to_string())); }
|
||||
}
|
||||
|
||||
let channel = state.services.channel.create_channel(payload).await?;
|
||||
@@ -278,18 +316,23 @@ pub async fn create(
|
||||
)
|
||||
)]
|
||||
pub async fn update(
|
||||
_admin: Superuser,
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(payload): Json<UpdateChannelRequest>,
|
||||
) -> Result<Json<ChannelResponse>, HTTPError> {
|
||||
// Vérifier l'existence
|
||||
state
|
||||
let original = state
|
||||
.repositories
|
||||
.channel
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
require_channel_manager(&state, &user, id).await?;
|
||||
if payload.server_id != original.server_id {
|
||||
let server_id = payload.server_id.ok_or(HTTPError::Forbidden)?;
|
||||
require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_CHANNELS).await?;
|
||||
}
|
||||
|
||||
// Vérifier que le serveur existe si fourni
|
||||
if let Some(server_id) = payload.server_id {
|
||||
@@ -303,12 +346,13 @@ pub async fn update(
|
||||
|
||||
// Vérifier que la catégorie existe si fournie
|
||||
if let Some(category_id) = payload.category_id {
|
||||
state
|
||||
let category = state
|
||||
.repositories
|
||||
.category
|
||||
.get_by_id(category_id)
|
||||
.await?
|
||||
.ok_or(HTTPError::BadRequest("Category not found".to_string()))?;
|
||||
if Some(category.server_id) != payload.server_id { return Err(HTTPError::BadRequest("Category belongs to another server".to_string())); }
|
||||
}
|
||||
|
||||
let channel = state.services.channel.update_channel(id, payload).await?;
|
||||
@@ -334,10 +378,11 @@ pub async fn update(
|
||||
)
|
||||
)]
|
||||
pub async fn delete(
|
||||
_admin: Superuser,
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<StatusCode, HTTPError> {
|
||||
require_channel_manager(&state, &user, id).await?;
|
||||
if state.services.channel.delete_channel(id).await? {
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
} else {
|
||||
@@ -361,9 +406,11 @@ pub async fn delete(
|
||||
tag = "Channel Permissions"
|
||||
)]
|
||||
pub async fn get_user_permission(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path((channel_id, user_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<Json<ChannelUserPermissionResponse>, HTTPError> {
|
||||
require_channel_manager(&state, &user, channel_id).await?;
|
||||
let permission = state
|
||||
.repositories
|
||||
.channel
|
||||
@@ -392,10 +439,12 @@ pub async fn get_user_permission(
|
||||
tag = "Channel Permissions"
|
||||
)]
|
||||
pub async fn set_user_permission(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path((channel_id, user_id)): Path<(Uuid, Uuid)>,
|
||||
Json(payload): Json<SetChannelPermissionRequest>,
|
||||
) -> Result<Json<ChannelUserPermissionResponse>, HTTPError> {
|
||||
require_channel_manager(&state, &user, channel_id).await?;
|
||||
state
|
||||
.services
|
||||
.channel
|
||||
@@ -430,9 +479,11 @@ pub async fn set_user_permission(
|
||||
tag = "Channel Permissions"
|
||||
)]
|
||||
pub async fn remove_user_permission(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path((channel_id, user_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<StatusCode, HTTPError> {
|
||||
require_channel_manager(&state, &user, channel_id).await?;
|
||||
if state
|
||||
.repositories
|
||||
.channel
|
||||
@@ -468,9 +519,11 @@ pub async fn remove_user_permission(
|
||||
tag = "Channel Permissions"
|
||||
)]
|
||||
pub async fn get_role_permission(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path((channel_id, role_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<Json<ChannelRolePermissionResponse>, HTTPError> {
|
||||
require_channel_manager(&state, &user, channel_id).await?;
|
||||
let permission = state
|
||||
.repositories
|
||||
.channel
|
||||
@@ -499,10 +552,12 @@ pub async fn get_role_permission(
|
||||
tag = "Channel Permissions"
|
||||
)]
|
||||
pub async fn set_role_permission(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path((channel_id, role_id)): Path<(Uuid, Uuid)>,
|
||||
Json(payload): Json<SetChannelPermissionRequest>,
|
||||
) -> Result<Json<ChannelRolePermissionResponse>, HTTPError> {
|
||||
require_channel_manager(&state, &user, channel_id).await?;
|
||||
state
|
||||
.services
|
||||
.channel
|
||||
@@ -537,9 +592,11 @@ pub async fn set_role_permission(
|
||||
tag = "Channel Permissions"
|
||||
)]
|
||||
pub async fn remove_role_permission(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path((channel_id, role_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<StatusCode, HTTPError> {
|
||||
require_channel_manager(&state, &user, channel_id).await?;
|
||||
if state
|
||||
.repositories
|
||||
.channel
|
||||
|
||||
@@ -25,6 +25,7 @@ pub fn channel_model_to_channel_response_with_permission(
|
||||
updated_at: model.updated_at,
|
||||
unread_count: None,
|
||||
permission,
|
||||
voice_participants: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,3 +3,6 @@ pub mod handlers;
|
||||
pub mod mapper;
|
||||
pub mod routes;
|
||||
pub mod service;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
use crate::http::test_support::{request, state, user};
|
||||
use crate::permissions::ServerPermission;
|
||||
use axum::{body::{to_bytes, Body}, http::{Method, StatusCode}, Router};
|
||||
use serde_json::{json, Value};
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn call(router: &Router, method: Method, uri: &str, body: Value, actor: crate::models::user::Model) -> axum::response::Response {
|
||||
let mut req = request(method, uri, Body::from(body.to_string()), Some(actor));
|
||||
req.headers_mut().insert("content-type", "application/json".parse().unwrap());
|
||||
router.clone().oneshot(req).await.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn channel_permissions_over_http() {
|
||||
let state = state().await;
|
||||
let server = state.default_server.id;
|
||||
let actor = user(&state, false).await;
|
||||
let router = super::routes::router().with_state(state.clone());
|
||||
let list = format!("/channels?server_id={server}");
|
||||
|
||||
// Un non-membre ne peut pas consulter les canaux du serveur.
|
||||
assert_eq!(
|
||||
call(&router, Method::GET, &list, json!(null), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
|
||||
state.repositories.server.add_user(server, actor.id).await.unwrap();
|
||||
assert_eq!(
|
||||
call(&router, Method::GET, &list, json!(null), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
|
||||
let payload = json!({"server_id":server,"category_id":null,"channel_type":"text","name":"test-channel"});
|
||||
|
||||
// La création requiert MANAGE_CHANNELS, même pour un membre.
|
||||
assert_eq!(
|
||||
call(&router, Method::POST, "/channels", payload.clone(), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
|
||||
state.repositories.server.set_user_permission(server, actor.id, ServerPermission::MANAGE_CHANNELS.bits()).await.unwrap();
|
||||
let created = call(&router, Method::POST, "/channels", payload.clone(), actor.clone()).await;
|
||||
|
||||
assert_eq!(created.status(), StatusCode::CREATED);
|
||||
let data: Value = serde_json::from_slice(&to_bytes(created.into_body(), 1024 * 1024).await.unwrap()).unwrap();
|
||||
let uri = format!("/channels/{}", data["id"].as_str().unwrap());
|
||||
let permissions = format!("{uri}/permissions");
|
||||
|
||||
// Le droit de gestion permet de consulter et modifier les permissions du canal.
|
||||
assert_eq!(
|
||||
call(&router, Method::GET, &permissions, json!(null), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
let direct = format!("{permissions}/users/{}", actor.id);
|
||||
assert_eq!(
|
||||
call(&router, Method::PUT, &direct, json!({"permissions":1}), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
call(&router, Method::GET, &direct, json!(null), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
|
||||
// Sans droit de gestion, les opérations sur le canal et ses permissions sont refusées.
|
||||
state.repositories.server.set_user_permission(server, actor.id, 0).await.unwrap();
|
||||
assert_eq!(
|
||||
call(&router, Method::GET, &permissions, json!(null), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
|
||||
for method in [Method::GET, Method::PUT, Method::DELETE] {
|
||||
assert_eq!(
|
||||
call(&router, method, &direct, json!({"permissions":1}), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
call(&router, Method::PUT, &uri, payload.clone(), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
assert_eq!(
|
||||
call(&router, Method::DELETE, &uri, json!(null), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
|
||||
state.repositories.server.set_user_permission(server, actor.id, ServerPermission::MANAGE_CHANNELS.bits()).await.unwrap();
|
||||
assert_eq!(
|
||||
call(&router, Method::PUT, &uri, payload, actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
call(&router, Method::DELETE, &direct, json!(null), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
assert_eq!(
|
||||
call(&router, Method::DELETE, &uri, json!(null), actor)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
@@ -10,3 +10,41 @@ pub fn router() -> Router<AppState> {
|
||||
.route("/conversations", get(handlers::list).post(handlers::create))
|
||||
.route("/conversations/{id}/fork", post(handlers::fork))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::http::test_support::{request, state, user};
|
||||
use axum::{body::{to_bytes, Body}, http::{Method, StatusCode}};
|
||||
use tower::ServiceExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn conversation_routes_respect_authentication_and_membership() {
|
||||
let state = state().await;
|
||||
let owner = user(&state, false).await;
|
||||
let guest = user(&state, false).await;
|
||||
let outsider = user(&state, true).await;
|
||||
let routes = router().with_state(state);
|
||||
let list = routes.clone().oneshot(request(Method::GET, "/conversations", Body::empty(), Some(owner.clone()))).await.unwrap();
|
||||
assert_eq!(list.status(), StatusCode::OK);
|
||||
let create_body = format!(r#"{{"user_ids":["{}"]}}"#, guest.id);
|
||||
let mut create = request(Method::POST, "/conversations", Body::from(create_body.clone()), Some(owner.clone()));
|
||||
create.headers_mut().insert("content-type", "application/json".parse().unwrap());
|
||||
let created = routes.clone().oneshot(create).await.unwrap();
|
||||
assert_eq!(created.status(), StatusCode::OK);
|
||||
let bytes = to_bytes(created.into_body(), 1024 * 1024).await.unwrap();
|
||||
let id = serde_json::from_slice::<serde_json::Value>(&bytes).unwrap()["id"].as_str().unwrap().to_string();
|
||||
let fork_uri = format!("/conversations/{id}/fork");
|
||||
let fork_body = r#"{"user_ids":[]}"#;
|
||||
let mut forbidden = request(Method::POST, &fork_uri, Body::from(fork_body), Some(outsider));
|
||||
forbidden.headers_mut().insert("content-type", "application/json".parse().unwrap());
|
||||
assert_eq!(routes.clone().oneshot(forbidden).await.unwrap().status(), StatusCode::FORBIDDEN);
|
||||
let mut allowed = request(Method::POST, &fork_uri, Body::from(fork_body), Some(guest));
|
||||
allowed.headers_mut().insert("content-type", "application/json".parse().unwrap());
|
||||
assert_eq!(routes.clone().oneshot(allowed).await.unwrap().status(), StatusCode::OK);
|
||||
for (method, uri, body) in [(Method::GET, "/conversations", ""), (Method::POST, "/conversations", create_body.as_str()), (Method::POST, fork_uri.as_str(), fork_body)] {
|
||||
let result = routes.clone().oneshot(request(method, uri, Body::from(body.to_string()), None)).await.unwrap();
|
||||
assert_eq!(result.status(), StatusCode::UNAUTHORIZED, "{uri}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use crate::domain::events::emoji::{EmojiCreatedEvent, EmojiDeletedEvent, EmojiUpdatedEvent};
|
||||
use crate::http::context::CurrentUser;
|
||||
use crate::http::permissions::check_server_permission;
|
||||
use crate::permissions::ServerPermission;
|
||||
use crate::services::media;
|
||||
use crate::{
|
||||
core::state::AppState,
|
||||
@@ -29,20 +32,38 @@ fn normalize_type(value: &str) -> Result<String, HTTPError> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn require_manage(
|
||||
state: &AppState,
|
||||
user: &CurrentUser,
|
||||
server_id: Option<Uuid>,
|
||||
) -> Result<(), HTTPError> {
|
||||
let server_id = server_id.ok_or(HTTPError::Forbidden)?;
|
||||
if check_server_permission(state, user.id, server_id, ServerPermission::MANAGE_SERVER).await? {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(HTTPError::Forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/emojis", params(EmojiQueryParams), responses((status = 200, body = [crate::domain::dto::emoji::EmojiResponse])), tag = "Emojis")]
|
||||
pub async fn get_all(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<EmojiQueryParams>,
|
||||
) -> Result<Json<Vec<crate::domain::dto::emoji::EmojiResponse>>, HTTPError> {
|
||||
let mut result = Vec::new();
|
||||
for emoji in state.repositories.emoji.list(query.server_id).await? {
|
||||
result.push(mapper::response(emoji));
|
||||
if match emoji.server_id {
|
||||
Some(id) => check_server_permission(&state, user.id, id, ServerPermission::empty()).await?,
|
||||
None => true,
|
||||
} { result.push(mapper::response(emoji)); }
|
||||
}
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/emojis/{id}", params(("id" = Uuid, Path)), responses((status = 200, body = crate::domain::dto::emoji::EmojiResponse)), tag = "Emojis")]
|
||||
pub async fn get_by_id(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<crate::domain::dto::emoji::EmojiResponse>, HTTPError> {
|
||||
@@ -52,11 +73,15 @@ pub async fn get_by_id(
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
if let Some(server_id) = emoji.server_id {
|
||||
if !check_server_permission(&state, user.id, server_id, ServerPermission::empty()).await? { return Err(HTTPError::Forbidden); }
|
||||
}
|
||||
Ok(Json(mapper::response(emoji)))
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/emojis", responses((status = 201, body = crate::domain::dto::emoji::EmojiResponse)), tag = "Emojis")]
|
||||
pub async fn create(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<(StatusCode, Json<crate::domain::dto::emoji::EmojiResponse>), HTTPError> {
|
||||
@@ -112,6 +137,7 @@ pub async fn create(
|
||||
"file is required for custom emojis".into(),
|
||||
));
|
||||
}
|
||||
require_manage(&state, &user, server_id).await?;
|
||||
if let Some(server_id) = server_id {
|
||||
state
|
||||
.repositories
|
||||
@@ -163,6 +189,7 @@ pub async fn create(
|
||||
|
||||
#[utoipa::path(put, path = "/emojis/{id}", request_body = UpdateEmojiRequest, params(("id" = Uuid, Path)), responses((status = 200, body = crate::domain::dto::emoji::EmojiResponse)), tag = "Emojis")]
|
||||
pub async fn update(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(payload): Json<UpdateEmojiRequest>,
|
||||
@@ -173,6 +200,10 @@ pub async fn update(
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
require_manage(&state, &user, existing.server_id).await?;
|
||||
if let Some(target) = payload.server_id {
|
||||
require_manage(&state, &user, Some(target)).await?;
|
||||
}
|
||||
let target_server_id = payload.server_id.or(existing.server_id);
|
||||
let target_name = payload.name.as_deref().unwrap_or(&existing.name);
|
||||
state
|
||||
@@ -219,6 +250,7 @@ fn detect_mime(bytes: &[u8]) -> Option<String> {
|
||||
|
||||
#[utoipa::path(delete, path = "/emojis/{id}", params(("id" = Uuid, Path)), responses((status = 204)), tag = "Emojis")]
|
||||
pub async fn delete(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<StatusCode, HTTPError> {
|
||||
@@ -228,6 +260,7 @@ pub async fn delete(
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
require_manage(&state, &user, model.server_id).await?;
|
||||
let deleted = state.repositories.emoji.delete(id).await?;
|
||||
if deleted {
|
||||
EmojiService::remove_asset(
|
||||
@@ -243,6 +276,7 @@ pub async fn delete(
|
||||
}
|
||||
|
||||
pub async fn asset(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Response, HTTPError> {
|
||||
@@ -252,6 +286,9 @@ pub async fn asset(
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
if let Some(server_id) = model.server_id {
|
||||
if !check_server_permission(&state, user.id, server_id, ServerPermission::empty()).await? { return Err(HTTPError::Forbidden); }
|
||||
}
|
||||
let path = model.file_path.ok_or(HTTPError::NotFound)?;
|
||||
let bytes = tokio::fs::read(PathBuf::from(&state.config.media.root).join(path))
|
||||
.await
|
||||
|
||||
@@ -13,3 +13,164 @@ pub fn router() -> Router<AppState> {
|
||||
)
|
||||
.route("/emojis/{id}/asset", get(handlers::asset))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod permission_tests {
|
||||
use super::*;
|
||||
use crate::models::{emoji, server_user, server_user_permission};
|
||||
use crate::permissions::ServerPermission;
|
||||
use crate::routes::message::routes::permission_tests::Fixture;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Method, StatusCode},
|
||||
};
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[tokio::test]
|
||||
async fn emoji_create_requires_manage_server() {
|
||||
let fixture = Fixture::new().await;
|
||||
// Un membre sans MANAGE_SERVER ne peut pas créer d'emoji.
|
||||
let boundary = "emoji-permission-test";
|
||||
let body = format!(
|
||||
"--{boundary}\r\nContent-Disposition: form-data; name=\"server_id\"\r\n\r\n{}\r\n--{boundary}\r\nContent-Disposition: form-data; name=\"name\"\r\n\r\nwave\r\n--{boundary}\r\nContent-Disposition: form-data; name=\"emoji_type\"\r\n\r\nunicode\r\n--{boundary}\r\nContent-Disposition: form-data; name=\"unicode_sequence\"\r\n\r\n👋\r\n--{boundary}--\r\n",
|
||||
fixture.state.default_server.id
|
||||
);
|
||||
let mime = format!("multipart/form-data; boundary={boundary}");
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(
|
||||
router(),
|
||||
Method::POST,
|
||||
"/emojis",
|
||||
Body::from(body.clone()),
|
||||
Some(&mime)
|
||||
)
|
||||
.await,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
server_user_permission::ActiveModel {
|
||||
id: Set(Uuid::new_v4()),
|
||||
server_id: Set(fixture.state.default_server.id),
|
||||
user_id: Set(fixture.user.id),
|
||||
permissions: Set(ServerPermission::MANAGE_SERVER.bits() as i64),
|
||||
}
|
||||
.insert(&fixture.state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(
|
||||
router(),
|
||||
Method::POST,
|
||||
"/emojis",
|
||||
Body::from(body),
|
||||
Some(&mime)
|
||||
)
|
||||
.await,
|
||||
StatusCode::CREATED
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn emoji_read_requires_membership() {
|
||||
let fixture = Fixture::new().await;
|
||||
// L'emoji de serveur est lisible par un membre, mais pas par un utilisateur retiré.
|
||||
let item = emoji::ActiveModel {
|
||||
id: Set(Uuid::new_v4()),
|
||||
server_id: Set(Some(fixture.state.default_server.id)),
|
||||
name: Set("wave".into()),
|
||||
emoji_type: Set("unicode".into()),
|
||||
unicode_sequence: Set(Some("👋".into())),
|
||||
supports_skin_tone: Set(false),
|
||||
file_path: Set(None),
|
||||
mime_type: Set(None),
|
||||
file_size: Set(None),
|
||||
is_animated: Set(false),
|
||||
sha256: Set(None),
|
||||
created_at: Set(chrono::Utc::now()),
|
||||
updated_at: Set(chrono::Utc::now()),
|
||||
}
|
||||
.insert(&fixture.state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let uri = format!("/emojis/{}", item.id);
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(router(), Method::GET, &uri, Body::empty(), None)
|
||||
.await,
|
||||
StatusCode::OK
|
||||
);
|
||||
server_user::Entity::delete_many()
|
||||
.filter(server_user::Column::UserId.eq(fixture.user.id))
|
||||
.exec(&fixture.state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(router(), Method::GET, &uri, Body::empty(), None)
|
||||
.await,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn emoji_update_and_delete_require_manage_server() {
|
||||
// Les deux opérations sont contrôlées séparément avec une fixture fraîche.
|
||||
for method in [Method::PUT, Method::DELETE] {
|
||||
let fixture = Fixture::new().await;
|
||||
let item = emoji::ActiveModel {
|
||||
id: Set(Uuid::new_v4()),
|
||||
server_id: Set(Some(fixture.state.default_server.id)),
|
||||
name: Set("wave".into()),
|
||||
emoji_type: Set("unicode".into()),
|
||||
unicode_sequence: Set(Some("👋".into())),
|
||||
supports_skin_tone: Set(false),
|
||||
file_path: Set(None),
|
||||
mime_type: Set(None),
|
||||
file_size: Set(None),
|
||||
is_animated: Set(false),
|
||||
sha256: Set(None),
|
||||
created_at: Set(chrono::Utc::now()),
|
||||
updated_at: Set(chrono::Utc::now()),
|
||||
}
|
||||
.insert(&fixture.state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let uri = format!("/emojis/{}", item.id);
|
||||
let body = || Body::from(r#"{"name":"newwave"}"#);
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(
|
||||
router(),
|
||||
method.clone(),
|
||||
&uri,
|
||||
body(),
|
||||
Some("application/json")
|
||||
)
|
||||
.await,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
server_user_permission::ActiveModel {
|
||||
id: Set(Uuid::new_v4()),
|
||||
server_id: Set(fixture.state.default_server.id),
|
||||
user_id: Set(fixture.user.id),
|
||||
permissions: Set(ServerPermission::MANAGE_SERVER.bits() as i64),
|
||||
}
|
||||
.insert(&fixture.state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let expected = if method == Method::PUT {
|
||||
StatusCode::OK
|
||||
} else {
|
||||
StatusCode::NO_CONTENT
|
||||
};
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(router(), method, &uri, body(), Some("application/json"))
|
||||
.await,
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::domain::events::message::{
|
||||
};
|
||||
use crate::domain::events::server::{ServerCreatedEvent, ServerDeletedEvent, ServerUpdatedEvent};
|
||||
use crate::domain::events::server_tree::ServerTreeInvalidatedEvent;
|
||||
use crate::domain::events::voice_presence::VoicePresenceEvent;
|
||||
use crate::models::{server_user, user};
|
||||
use crate::repositories::Repositories;
|
||||
use crate::routes::category::mapper::category_model_to_category_response;
|
||||
@@ -115,6 +116,15 @@ impl RealtimeRouter {
|
||||
}
|
||||
|
||||
pub fn start(self: &Arc<Self>, event_bus: Arc<EventBus>) {
|
||||
let router = Arc::clone(self);
|
||||
event_bus.on_async::<VoicePresenceEvent>(move |event| {
|
||||
let router = Arc::clone(&router);
|
||||
async move {
|
||||
let users = router.server_users(event.server_id).await;
|
||||
let action = if event.joined { "joined" } else { "left" };
|
||||
router.gateway.send_to_users(users, "VoicePresence", action, event);
|
||||
}
|
||||
});
|
||||
let router = Arc::clone(self);
|
||||
event_bus.on_async::<MessageCreatedEvent>(move |event| {
|
||||
let router = Arc::clone(&router);
|
||||
|
||||
+112
-41
@@ -6,36 +6,59 @@ use crate::domain::dto::message::{
|
||||
use crate::domain::dto::reaction::{CreateReactionRequest, DeleteReactionQuery, ReactionResponse};
|
||||
use crate::http::context::CurrentUser;
|
||||
use crate::http::error::HTTPError;
|
||||
use crate::models::{channel, channel_user};
|
||||
use crate::http::permissions::check_channel_permission;
|
||||
use crate::permissions::ChannelPermission;
|
||||
use crate::routes::message::mapper;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(crate) async fn can_access(
|
||||
pub(crate) async fn require_channel_permission(
|
||||
state: &AppState,
|
||||
channel_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<bool, HTTPError> {
|
||||
let Some(channel) = channel::Entity::find_by_id(channel_id)
|
||||
.one(&state.db)
|
||||
.await?
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
if channel.channel_type != channel::ChannelType::DM {
|
||||
return Ok(true);
|
||||
is_superuser: bool,
|
||||
required: ChannelPermission,
|
||||
) -> Result<(), HTTPError> {
|
||||
let _ = is_superuser;
|
||||
if check_channel_permission(state, user_id, channel_id, required).await? {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(HTTPError::Forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
fn allows_channel_permission(granted: ChannelPermission, required: ChannelPermission) -> bool {
|
||||
granted.contains(required)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn message_permissions_require_every_requested_bit() {
|
||||
let granted = ChannelPermission::READ_CHANNEL | ChannelPermission::SEND_MESSAGE;
|
||||
assert!(allows_channel_permission(
|
||||
granted,
|
||||
ChannelPermission::READ_CHANNEL
|
||||
));
|
||||
assert!(!allows_channel_permission(
|
||||
granted,
|
||||
ChannelPermission::SEND_MESSAGE | ChannelPermission::ATTACH_FILES
|
||||
));
|
||||
assert!(!allows_channel_permission(
|
||||
granted,
|
||||
ChannelPermission::EDIT_OTHERS_MESSAGES
|
||||
));
|
||||
assert!(!allows_channel_permission(
|
||||
ChannelPermission::empty(),
|
||||
ChannelPermission::READ_CHANNEL
|
||||
));
|
||||
}
|
||||
Ok(channel_user::Entity::find()
|
||||
.filter(channel_user::Column::ChannelId.eq(channel_id))
|
||||
.filter(channel_user::Column::UserId.eq(user_id))
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.is_some())
|
||||
}
|
||||
|
||||
/// Liste une fenêtre paginée de messages
|
||||
@@ -64,11 +87,17 @@ pub async fn get_all(
|
||||
}
|
||||
|
||||
let params = mapper::query_params_to_message_filter(filters);
|
||||
if let Some(channel_id) = params.channel_id {
|
||||
if !can_access(&state, channel_id, user.id).await? {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
}
|
||||
let channel_id = params
|
||||
.channel_id
|
||||
.ok_or_else(|| HTTPError::BadRequest("channel_id is required".into()))?;
|
||||
require_channel_permission(
|
||||
&state,
|
||||
channel_id,
|
||||
user.id,
|
||||
user.is_superuser,
|
||||
ChannelPermission::READ_CHANNEL,
|
||||
)
|
||||
.await?;
|
||||
let page = state.repositories.message.filter(params).await?;
|
||||
let message_ids: Vec<_> = page.messages.iter().map(|message| message.id).collect();
|
||||
let mut reactions = state
|
||||
@@ -126,9 +155,14 @@ pub async fn get_by_id(
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
if !can_access(&state, message.channel_id, user.id).await? {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
require_channel_permission(
|
||||
&state,
|
||||
message.channel_id,
|
||||
user.id,
|
||||
user.is_superuser,
|
||||
ChannelPermission::READ_CHANNEL,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let reactions = state
|
||||
.services
|
||||
@@ -180,9 +214,11 @@ pub async fn create(
|
||||
.get_by_id(payload.channel_id)
|
||||
.await?
|
||||
.ok_or(HTTPError::BadRequest("Channel not found".to_string()))?;
|
||||
if !can_access(&state, channel.id, user.id).await? {
|
||||
return Err(HTTPError::Forbidden);
|
||||
let mut required = ChannelPermission::SEND_MESSAGE;
|
||||
if !payload.file_ids.is_empty() {
|
||||
required |= ChannelPermission::ATTACH_FILES;
|
||||
}
|
||||
require_channel_permission(&state, channel.id, user.id, user.is_superuser, required).await?;
|
||||
|
||||
if payload.content.trim().is_empty() && payload.file_ids.is_empty() {
|
||||
return Err(HTTPError::BadRequest(
|
||||
@@ -192,7 +228,7 @@ pub async fn create(
|
||||
|
||||
// Optionnel: vérifier reply_to_id
|
||||
if let Some(reply_id) = payload.reply_to_id {
|
||||
state
|
||||
let parent = state
|
||||
.repositories
|
||||
.message
|
||||
.get_by_id(reply_id)
|
||||
@@ -200,6 +236,11 @@ pub async fn create(
|
||||
.ok_or(HTTPError::BadRequest(
|
||||
"Parent message not found".to_string(),
|
||||
))?;
|
||||
if parent.channel_id != channel.id {
|
||||
return Err(HTTPError::BadRequest(
|
||||
"Parent message belongs to another channel".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let message = state
|
||||
@@ -274,9 +315,19 @@ pub async fn update(
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
|
||||
// Vérifier que l'utilisateur est l'auteur
|
||||
if message.user_id != user.id && !user.is_superuser {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
let required = if message.user_id == user.id {
|
||||
ChannelPermission::EDIT_OWN_MESSAGE
|
||||
} else {
|
||||
ChannelPermission::EDIT_OTHERS_MESSAGES
|
||||
};
|
||||
require_channel_permission(
|
||||
&state,
|
||||
message.channel_id,
|
||||
user.id,
|
||||
user.is_superuser,
|
||||
required,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let message = state
|
||||
.services
|
||||
@@ -332,9 +383,14 @@ pub async fn add_reaction(
|
||||
.get_by_id(message_id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
if !can_access(&state, message.channel_id, user.id).await? {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
require_channel_permission(
|
||||
&state,
|
||||
message.channel_id,
|
||||
user.id,
|
||||
user.is_superuser,
|
||||
ChannelPermission::READ_CHANNEL | ChannelPermission::ADD_REACTIONS,
|
||||
)
|
||||
.await?;
|
||||
let (reaction, created) = state
|
||||
.services
|
||||
.message_reaction
|
||||
@@ -378,9 +434,14 @@ pub async fn remove_reaction(
|
||||
.get_by_id(message_id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
if !can_access(&state, message.channel_id, user.id).await? {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
require_channel_permission(
|
||||
&state,
|
||||
message.channel_id,
|
||||
user.id,
|
||||
user.is_superuser,
|
||||
ChannelPermission::READ_CHANNEL,
|
||||
)
|
||||
.await?;
|
||||
state
|
||||
.services
|
||||
.message_reaction
|
||||
@@ -420,9 +481,19 @@ pub async fn delete(
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
|
||||
if message.user_id != user.id && !user.is_superuser {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
let required = if message.user_id == user.id {
|
||||
ChannelPermission::DELETE_OWN_MESSAGE
|
||||
} else {
|
||||
ChannelPermission::DELETE_OTHERS_MESSAGES
|
||||
};
|
||||
require_channel_permission(
|
||||
&state,
|
||||
message.channel_id,
|
||||
user.id,
|
||||
user.is_superuser,
|
||||
required,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let attachment_ids: Vec<_> = state
|
||||
.repositories
|
||||
|
||||
@@ -20,3 +20,327 @@ pub fn router() -> Router<AppState> {
|
||||
axum::routing::delete(handlers::remove_reaction),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod permission_tests {
|
||||
use super::*;
|
||||
use crate::config::{AppConfig, DEFAULT_CONFIG_TOML};
|
||||
use crate::core::App;
|
||||
use crate::http::context::{CurrentUser, RequestContext};
|
||||
use crate::models::{channel, channel_user_permission, emoji, server_user, user};
|
||||
use crate::permissions::ChannelPermission;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Method, Request, StatusCode},
|
||||
};
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
use std::time::Instant;
|
||||
use tower::ServiceExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(crate) struct Fixture {
|
||||
pub state: AppState,
|
||||
pub user: user::Model,
|
||||
pub channel_id: Uuid,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
pub async fn new() -> Self {
|
||||
let mut config: AppConfig = toml::from_str(DEFAULT_CONFIG_TOML).unwrap();
|
||||
config.database.url = "sqlite::memory:".into();
|
||||
config.network.stun_servers.clear();
|
||||
config.media.root =
|
||||
format!("src/routes/attachment/.permission-test-{}", Uuid::new_v4());
|
||||
let state = App::build(config).await.unwrap().state;
|
||||
let user = user::ActiveModel {
|
||||
id: Set(Uuid::new_v4()),
|
||||
username: Set(format!("test-{}", Uuid::new_v4())),
|
||||
password: Set(String::new()),
|
||||
pub_key: Set(None),
|
||||
created_at: Set(chrono::Utc::now()),
|
||||
updated_at: Set(chrono::Utc::now()),
|
||||
is_superuser: Set(false),
|
||||
}
|
||||
.insert(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
server_user::ActiveModel {
|
||||
id: Set(Uuid::new_v4()),
|
||||
server_id: Set(state.default_server.id),
|
||||
user_id: Set(user.id),
|
||||
username: Set(None),
|
||||
joined_at: Set(chrono::Utc::now()),
|
||||
updated_at: Set(chrono::Utc::now()),
|
||||
}
|
||||
.insert(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let channel = channel::ActiveModel {
|
||||
id: Set(Uuid::new_v4()),
|
||||
server_id: Set(Some(state.default_server.id)),
|
||||
category_id: Set(None),
|
||||
channel_type: Set(channel::ChannelType::Text),
|
||||
name: Set(Some("test".into())),
|
||||
created_at: Set(chrono::Utc::now()),
|
||||
updated_at: Set(chrono::Utc::now()),
|
||||
}
|
||||
.insert(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
Self {
|
||||
state,
|
||||
user,
|
||||
channel_id: channel.id,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn grant(&self, permissions: ChannelPermission) {
|
||||
channel_user_permission::ActiveModel {
|
||||
id: Set(Uuid::new_v4()),
|
||||
channel_id: Set(self.channel_id),
|
||||
user_id: Set(self.user.id),
|
||||
permissions: Set(permissions.bits() as i64),
|
||||
}
|
||||
.insert(&self.state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
pub async fn request(
|
||||
&self,
|
||||
router: Router<AppState>,
|
||||
method: Method,
|
||||
uri: &str,
|
||||
body: Body,
|
||||
content_type: Option<&str>,
|
||||
) -> StatusCode {
|
||||
let mut builder = Request::builder().method(method.clone()).uri(uri);
|
||||
if let Some(content_type) = content_type {
|
||||
builder = builder.header("content-type", content_type);
|
||||
}
|
||||
let mut request = builder.body(body).unwrap();
|
||||
request.extensions_mut().insert(RequestContext {
|
||||
request_id: Uuid::new_v4(),
|
||||
started_at: Instant::now(),
|
||||
method,
|
||||
uri: uri.parse().unwrap(),
|
||||
user: Some(CurrentUser(self.user.clone())),
|
||||
});
|
||||
router
|
||||
.with_state(self.state.clone())
|
||||
.oneshot(request)
|
||||
.await
|
||||
.unwrap()
|
||||
.status()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn message_list_requires_read_channel() {
|
||||
let fixture = Fixture::new().await;
|
||||
let uri = format!("/messages?channel_id={}", fixture.channel_id);
|
||||
// L'accès aux messages du canal demande READ_CHANNEL.
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(router(), Method::GET, &uri, Body::empty(), None)
|
||||
.await,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
fixture.grant(ChannelPermission::READ_CHANNEL).await;
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(router(), Method::GET, &uri, Body::empty(), None)
|
||||
.await,
|
||||
StatusCode::OK
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn message_create_requires_send_message() {
|
||||
let fixture = Fixture::new().await;
|
||||
let payload = format!(
|
||||
r#"{{"channel_id":"{}","content":"hello","file_ids":[]}}"#,
|
||||
fixture.channel_id
|
||||
);
|
||||
let send = || Body::from(payload.clone());
|
||||
// SEND_MESSAGE est distinct du droit de lire le canal.
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(
|
||||
router(),
|
||||
Method::POST,
|
||||
"/messages",
|
||||
send(),
|
||||
Some("application/json")
|
||||
)
|
||||
.await,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
fixture.grant(ChannelPermission::SEND_MESSAGE).await;
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(
|
||||
router(),
|
||||
Method::POST,
|
||||
"/messages",
|
||||
send(),
|
||||
Some("application/json")
|
||||
)
|
||||
.await,
|
||||
StatusCode::CREATED
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn message_edit_and_delete_require_own_permissions() {
|
||||
// L'édition et la suppression d'un message propre ont des permissions distinctes.
|
||||
for (method, permission, success) in [
|
||||
(
|
||||
Method::PUT,
|
||||
ChannelPermission::EDIT_OWN_MESSAGE,
|
||||
StatusCode::OK,
|
||||
),
|
||||
(
|
||||
Method::DELETE,
|
||||
ChannelPermission::DELETE_OWN_MESSAGE,
|
||||
StatusCode::NO_CONTENT,
|
||||
),
|
||||
] {
|
||||
let fixture = Fixture::new().await;
|
||||
let message = fixture
|
||||
.state
|
||||
.services
|
||||
.message
|
||||
.create_message_with_attachments(
|
||||
fixture.channel_id,
|
||||
fixture.user.id,
|
||||
"hello".into(),
|
||||
Vec::new(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let uri = format!("/messages/{}", message.id);
|
||||
let body = || Body::from(r#"{"content":"edited"}"#);
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(
|
||||
router(),
|
||||
method.clone(),
|
||||
&uri,
|
||||
body(),
|
||||
Some("application/json")
|
||||
)
|
||||
.await,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
fixture.grant(permission).await;
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(router(), method, &uri, body(), Some("application/json"))
|
||||
.await,
|
||||
success
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reactions_require_read_and_add_permissions() {
|
||||
let fixture = Fixture::new().await;
|
||||
// Prépare un message et un emoji afin de tester les deux opérations de réaction.
|
||||
let message = fixture
|
||||
.state
|
||||
.services
|
||||
.message
|
||||
.create_message_with_attachments(
|
||||
fixture.channel_id,
|
||||
fixture.user.id,
|
||||
"hello".into(),
|
||||
Vec::new(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let emoji = emoji::ActiveModel {
|
||||
id: Set(Uuid::new_v4()),
|
||||
server_id: Set(Some(fixture.state.default_server.id)),
|
||||
name: Set("wave".into()),
|
||||
emoji_type: Set("unicode".into()),
|
||||
unicode_sequence: Set(Some("👋".into())),
|
||||
supports_skin_tone: Set(false),
|
||||
file_path: Set(None),
|
||||
mime_type: Set(None),
|
||||
file_size: Set(None),
|
||||
is_animated: Set(false),
|
||||
sha256: Set(None),
|
||||
created_at: Set(chrono::Utc::now()),
|
||||
updated_at: Set(chrono::Utc::now()),
|
||||
}
|
||||
.insert(&fixture.state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let add_uri = format!("/messages/{}/reactions", message.id);
|
||||
let remove_uri = format!("{add_uri}/{}", emoji.id);
|
||||
let payload = || Body::from(format!(r#"{{"emoji_id":"{}"}}"#, emoji.id));
|
||||
// Sans permission, l'ajout et le retrait sont refusés.
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(
|
||||
router(),
|
||||
Method::POST,
|
||||
&add_uri,
|
||||
payload(),
|
||||
Some("application/json")
|
||||
)
|
||||
.await,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(router(), Method::DELETE, &remove_uri, Body::empty(), None)
|
||||
.await,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
fixture.grant(ChannelPermission::READ_CHANNEL).await;
|
||||
// Lire le canal seul ne suffit pas pour ajouter une réaction.
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(
|
||||
router(),
|
||||
Method::POST,
|
||||
&add_uri,
|
||||
payload(),
|
||||
Some("application/json")
|
||||
)
|
||||
.await,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
channel_user_permission::Entity::delete_many()
|
||||
.filter(channel_user_permission::Column::UserId.eq(fixture.user.id))
|
||||
.exec(&fixture.state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
fixture
|
||||
.grant(ChannelPermission::READ_CHANNEL | ChannelPermission::ADD_REACTIONS)
|
||||
.await;
|
||||
// L'ajout requiert les deux droits; le retrait est ensuite autorisé aussi.
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(
|
||||
router(),
|
||||
Method::POST,
|
||||
&add_uri,
|
||||
payload(),
|
||||
Some("application/json")
|
||||
)
|
||||
.await,
|
||||
StatusCode::CREATED
|
||||
);
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(router(), Method::DELETE, &remove_uri, Body::empty(), None)
|
||||
.await,
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+12
-30
@@ -6,6 +6,7 @@ use crate::domain::dto::user::UserResponse;
|
||||
use crate::http::context::CurrentUser;
|
||||
use crate::http::error::HTTPError;
|
||||
use crate::permissions::ServerPermission;
|
||||
use crate::routes::server::handlers::require_server_permission;
|
||||
use crate::routes::role::mapper;
|
||||
use crate::routes::user::mapper as user_mapper;
|
||||
use axum::{
|
||||
@@ -15,36 +16,15 @@ use axum::{
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
async fn require_permission(
|
||||
state: &AppState,
|
||||
user: &CurrentUser,
|
||||
server_id: Uuid,
|
||||
permission: ServerPermission,
|
||||
) -> Result<(), HTTPError> {
|
||||
if user.is_superuser {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let granted = state
|
||||
.repositories
|
||||
.server
|
||||
.get_user_permission(server_id, user.id)
|
||||
.await?
|
||||
.map(|value| ServerPermission::from_bits_truncate(value.permissions as u64))
|
||||
.unwrap_or_default();
|
||||
|
||||
if granted.contains(permission) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(HTTPError::Forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/roles", params(RoleQueryParams), responses((status = 200, body = [RoleResponse])), tag = "Roles")]
|
||||
pub async fn get_all(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Query(filters): Query<RoleQueryParams>,
|
||||
) -> Result<Json<Vec<RoleResponse>>, HTTPError> {
|
||||
let server_id = filters.server_id.ok_or(HTTPError::Forbidden)?;
|
||||
state.repositories.server.get_user(server_id, user.id).await?.ok_or(HTTPError::Forbidden)?;
|
||||
let roles = match filters.server_id {
|
||||
Some(server_id) => state.repositories.role.get_all_by_server(server_id).await?,
|
||||
None => state.repositories.role.get_all().await?,
|
||||
@@ -60,6 +40,7 @@ pub async fn get_all(
|
||||
|
||||
#[utoipa::path(get, path = "/roles/{id}", params(("id" = Uuid, Path)), responses((status = 200, body = RoleResponse), (status = 404)), tag = "Roles")]
|
||||
pub async fn get_by_id(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<RoleResponse>, HTTPError> {
|
||||
@@ -69,6 +50,7 @@ pub async fn get_by_id(
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
state.repositories.server.get_user(role.server_id, user.id).await?.ok_or(HTTPError::Forbidden)?;
|
||||
Ok(Json(mapper::role_model_to_role_response(role)))
|
||||
}
|
||||
|
||||
@@ -84,7 +66,7 @@ pub async fn create(
|
||||
.get_by_id(payload.server_id)
|
||||
.await?
|
||||
.ok_or(HTTPError::BadRequest("Server not found".to_string()))?;
|
||||
require_permission(
|
||||
require_server_permission(
|
||||
&state,
|
||||
&user,
|
||||
payload.server_id,
|
||||
@@ -116,7 +98,7 @@ pub async fn update(
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
require_permission(
|
||||
require_server_permission(
|
||||
&state,
|
||||
&user,
|
||||
role.server_id,
|
||||
@@ -148,7 +130,7 @@ pub async fn delete(
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
require_permission(
|
||||
require_server_permission(
|
||||
&state,
|
||||
&user,
|
||||
role.server_id,
|
||||
@@ -174,7 +156,7 @@ pub async fn get_members(
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
require_permission(
|
||||
require_server_permission(
|
||||
&state,
|
||||
&user,
|
||||
role.server_id,
|
||||
@@ -201,7 +183,7 @@ pub async fn add_member(
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
require_permission(
|
||||
require_server_permission(
|
||||
&state,
|
||||
&user,
|
||||
role.server_id,
|
||||
@@ -235,7 +217,7 @@ pub async fn remove_member(
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
require_permission(
|
||||
require_server_permission(
|
||||
&state,
|
||||
&user,
|
||||
role.server_id,
|
||||
|
||||
@@ -3,3 +3,6 @@ pub mod handlers;
|
||||
pub mod mapper;
|
||||
pub mod routes;
|
||||
pub mod service;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
use crate::http::test_support::{request, state, user};
|
||||
use crate::permissions::ServerPermission;
|
||||
use axum::{body::{to_bytes, Body}, http::{Method, StatusCode}, Router};
|
||||
use serde_json::{json, Value};
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn call(router: &Router, method: Method, uri: &str, body: Value, actor: crate::models::user::Model) -> axum::response::Response {
|
||||
let mut req = request(method, uri, Body::from(body.to_string()), Some(actor));
|
||||
req.headers_mut().insert("content-type", "application/json".parse().unwrap());
|
||||
router.clone().oneshot(req).await.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn role_permissions_over_http() {
|
||||
let state = state().await;
|
||||
let server = state.default_server.id;
|
||||
let actor = user(&state, false).await;
|
||||
let router = super::routes::router().with_state(state.clone());
|
||||
let list = format!("/roles?server_id={server}");
|
||||
|
||||
// La liste est inaccessible avant l'adhésion au serveur.
|
||||
assert_eq!(
|
||||
call(&router, Method::GET, &list, json!(null), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
|
||||
state.repositories.server.add_user(server, actor.id).await.unwrap();
|
||||
assert_eq!(
|
||||
call(&router, Method::GET, &list, json!(null), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
|
||||
let payload = json!({"server_id":server,"name":"test-role"});
|
||||
|
||||
// Un membre doit aussi disposer de MANAGE_ROLES pour créer un rôle.
|
||||
assert_eq!(
|
||||
call(&router, Method::POST, "/roles", payload.clone(), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
|
||||
state.repositories.server.set_user_permission(server, actor.id, ServerPermission::MANAGE_ROLES.bits()).await.unwrap();
|
||||
let created = call(&router, Method::POST, "/roles", payload, actor.clone()).await;
|
||||
|
||||
assert_eq!(created.status(), StatusCode::CREATED);
|
||||
let id: Value = serde_json::from_slice(&to_bytes(created.into_body(), 1024 * 1024).await.unwrap()).unwrap();
|
||||
let uri = format!("/roles/{}", id["id"].as_str().unwrap());
|
||||
assert_eq!(
|
||||
call(&router, Method::GET, &uri, json!(null), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
|
||||
// Sans MANAGE_ROLES, ni la modification ni la suppression ne sont permises.
|
||||
state.repositories.server.set_user_permission(server, actor.id, 0).await.unwrap();
|
||||
let update = json!({"name":"renamed","is_default":false});
|
||||
assert_eq!(
|
||||
call(&router, Method::PUT, &uri, update.clone(), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
assert_eq!(
|
||||
call(&router, Method::DELETE, &uri, json!(null), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
|
||||
state.repositories.server.set_user_permission(server, actor.id, ServerPermission::MANAGE_ROLES.bits()).await.unwrap();
|
||||
assert_eq!(
|
||||
call(&router, Method::PUT, &uri, update, actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
call(&router, Method::DELETE, &uri, json!(null), actor)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ use crate::core::AppState;
|
||||
use crate::http::context::CurrentUser;
|
||||
use crate::http::error::HTTPError;
|
||||
use crate::rtc::ws_entrypoint::ws_entrypoint_handler;
|
||||
use crate::models::channel::{self, ChannelType};
|
||||
use axum::extract::{Path, State, WebSocketUpgrade};
|
||||
use axum::response::IntoResponse;
|
||||
use uuid::Uuid;
|
||||
@@ -12,13 +13,22 @@ pub async fn ws_handler(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
) -> Result<impl IntoResponse, HTTPError> {
|
||||
let channel = checked_channel(&state, channel_id, user.id).await?;
|
||||
Ok(ws.on_upgrade(move |socket| ws_entrypoint_handler(socket, state, channel, user)))
|
||||
}
|
||||
|
||||
|
||||
async fn checked_channel(state: &AppState, channel_id: Uuid, user_id: Uuid) -> Result<channel::Model, HTTPError> {
|
||||
let channel = state
|
||||
.repositories
|
||||
.channel
|
||||
.get_by_id(channel_id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
// todo : check des permissions
|
||||
// aiinsi que vérifier qu'on est sur un chan vocal OU un chan de message privé
|
||||
Ok(ws.on_upgrade(move |socket| ws_entrypoint_handler(socket, state, channel, user)))
|
||||
if channel.channel_type != ChannelType::Voice { return Err(HTTPError::NotFound); }
|
||||
let server_id = channel.server_id.ok_or(HTTPError::Forbidden)?;
|
||||
if state.repositories.server.get_user(server_id, user_id).await?.is_none() {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
Ok(channel)
|
||||
}
|
||||
|
||||
@@ -6,3 +6,4 @@ use axum::routing::get;
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new().route("/rtc/{channel_id}", get(handlers::ws_handler))
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,10 @@ use crate::domain::dto::server::{
|
||||
ServerTreeResponse, ServerUserPermissionResponse, SetServerPermissionRequest,
|
||||
UpdateServerRequest,
|
||||
};
|
||||
use crate::http::context::{CurrentUser, Superuser};
|
||||
use crate::http::context::CurrentUser;
|
||||
use crate::http::error::HTTPError;
|
||||
use crate::permissions::ServerPermission;
|
||||
use crate::http::permissions::check_server_permission;
|
||||
use crate::routes::server::mapper;
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -15,25 +16,13 @@ use axum::{
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
async fn require_server_permission(
|
||||
pub(crate) async fn require_server_permission(
|
||||
state: &AppState,
|
||||
user: &CurrentUser,
|
||||
server_id: Uuid,
|
||||
permission: ServerPermission,
|
||||
) -> Result<(), HTTPError> {
|
||||
if user.is_superuser {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let granted = state
|
||||
.repositories
|
||||
.server
|
||||
.get_user_permission(server_id, user.id)
|
||||
.await?
|
||||
.map(|value| ServerPermission::from_bits_truncate(value.permissions as u64))
|
||||
.unwrap_or_default();
|
||||
|
||||
if granted.contains(permission) {
|
||||
if check_server_permission(state, user.id, server_id, permission).await? {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(HTTPError::Forbidden)
|
||||
@@ -90,9 +79,11 @@ pub async fn get_all(
|
||||
tag = "Servers"
|
||||
)]
|
||||
pub async fn get_by_id(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ServerResponse>, HTTPError> {
|
||||
state.repositories.server.get_user(id, user.id).await?.ok_or(HTTPError::NotFound)?;
|
||||
let server = state
|
||||
.repositories
|
||||
.server
|
||||
@@ -229,10 +220,11 @@ pub async fn update(
|
||||
)
|
||||
)]
|
||||
pub async fn delete(
|
||||
_admin: Superuser,
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<StatusCode, HTTPError> {
|
||||
require_server_permission(&state, &user, id, ServerPermission::MANAGE_SERVER).await?;
|
||||
if state.services.server.delete_server(id).await? {
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
} else {
|
||||
@@ -256,9 +248,11 @@ pub async fn delete(
|
||||
tag = "Server Permissions"
|
||||
)]
|
||||
pub async fn get_user_permission(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path((server_id, user_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<Json<ServerUserPermissionResponse>, HTTPError> {
|
||||
require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_MEMBERS).await?;
|
||||
let permission = state
|
||||
.repositories
|
||||
.server
|
||||
@@ -278,9 +272,11 @@ pub async fn get_user_permission(
|
||||
tag = "Server Permissions"
|
||||
)]
|
||||
pub async fn list_user_permissions(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path(server_id): Path<Uuid>,
|
||||
) -> Result<Json<Vec<ServerUserPermissionResponse>>, HTTPError> {
|
||||
require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_MEMBERS).await?;
|
||||
state
|
||||
.repositories
|
||||
.server
|
||||
@@ -416,9 +412,11 @@ pub async fn remove_user_permission(
|
||||
tag = "Server Permissions"
|
||||
)]
|
||||
pub async fn get_role_permission(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path((server_id, role_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<Json<ServerRolePermissionResponse>, HTTPError> {
|
||||
require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_ROLES).await?;
|
||||
let permission = state
|
||||
.repositories
|
||||
.server
|
||||
@@ -557,5 +555,6 @@ pub async fn get_tree(
|
||||
tree.channels,
|
||||
tree.categories,
|
||||
tree.unread_counts,
|
||||
&state.rtc.rooms,
|
||||
)))
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ use crate::domain::dto::server::{
|
||||
CreateServerRequest, ServerExplorerItemResponse, ServerResponse, ServerRolePermissionResponse,
|
||||
ServerTreeResponse, ServerUserPermissionResponse, UpdateServerRequest,
|
||||
};
|
||||
use crate::models::{server, server_item_order, server_role_permission, server_user_permission};
|
||||
use crate::models::{channel::ChannelType, server, server_item_order, server_role_permission, server_user_permission};
|
||||
use crate::repositories::types::{CategoryWithPermissions, ChannelWithPermissions};
|
||||
use crate::rtc::VoiceRoomManager;
|
||||
use crate::routes::category::mapper::category_model_to_category_response_with_permission;
|
||||
use crate::routes::channel::mapper::channel_model_to_channel_response_with_permission;
|
||||
use sea_orm::Set;
|
||||
@@ -77,6 +78,7 @@ pub fn build_server_tree(
|
||||
channels: Vec<ChannelWithPermissions>,
|
||||
categories: Vec<CategoryWithPermissions>,
|
||||
unread_counts: HashMap<Uuid, u64>,
|
||||
rooms: &VoiceRoomManager,
|
||||
) -> ServerTreeResponse {
|
||||
let order_map: HashMap<(Option<Uuid>, Uuid), i64> = orders
|
||||
.into_iter()
|
||||
@@ -135,6 +137,7 @@ pub fn build_server_tree(
|
||||
Some(chan_perm_bits),
|
||||
);
|
||||
response.unread_count = Some(*unread_counts.get(&response.id).unwrap_or(&0));
|
||||
if response.channel_type == ChannelType::Voice { response.voice_participants = Some(rooms.participants(response.id)); }
|
||||
response
|
||||
})
|
||||
.collect();
|
||||
@@ -156,6 +159,7 @@ pub fn build_server_tree(
|
||||
Some(chan_perm_bits),
|
||||
);
|
||||
chan_response.unread_count = Some(*unread_counts.get(&chan_response.id).unwrap_or(&0));
|
||||
if chan_response.channel_type == ChannelType::Voice { chan_response.voice_participants = Some(rooms.participants(chan_response.id)); }
|
||||
root_items.push((
|
||||
ServerExplorerItemResponse::Channel(chan_response),
|
||||
order_key,
|
||||
@@ -250,6 +254,7 @@ mod tests {
|
||||
],
|
||||
vec![category],
|
||||
HashMap::new(),
|
||||
&VoiceRoomManager::new(),
|
||||
);
|
||||
|
||||
let ServerExplorerItemResponse::Category(_, channels) = &response.items[0] else {
|
||||
@@ -258,4 +263,23 @@ mod tests {
|
||||
assert_eq!(channels[0].name.as_deref(), Some("first"));
|
||||
assert_eq!(channels[1].name.as_deref(), Some("second"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_includes_voice_participants_only_for_voice_channels() {
|
||||
let rooms = VoiceRoomManager::new();
|
||||
let server = Uuid::new_v4();
|
||||
let voice_id = Uuid::new_v4();
|
||||
let text_id = Uuid::new_v4();
|
||||
let user = crate::rtc::VoiceParticipant { id: Uuid::new_v4(), username: "alice".into() };
|
||||
rooms.join_user(server, voice_id, Uuid::new_v4(), user.clone());
|
||||
let mut voice = channel(voice_id, None, "voice");
|
||||
voice.channel.channel_type = channel::ChannelType::Voice;
|
||||
let tree = build_server_tree(vec![], vec![voice, channel(text_id, None, "text")], vec![], HashMap::new(), &rooms);
|
||||
let json = serde_json::to_value(tree).unwrap();
|
||||
let items = json["items"].as_array().unwrap();
|
||||
let voice = items.iter().find(|item| item["Channel"]["id"] == voice_id.to_string()).unwrap();
|
||||
let text = items.iter().find(|item| item["Channel"]["id"] == text_id.to_string()).unwrap();
|
||||
assert_eq!(voice["Channel"]["voice_participants"], serde_json::json!([user]));
|
||||
assert!(text["Channel"].get("voice_participants").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,3 +3,6 @@ pub mod handlers;
|
||||
pub mod mapper;
|
||||
pub mod routes;
|
||||
pub mod service;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
use crate::config::AppConfig;
|
||||
use crate::core::{App, AppState};
|
||||
use crate::http::context::CurrentUser;
|
||||
use crate::http::error::HTTPError;
|
||||
use crate::models::user;
|
||||
use crate::permissions::ServerPermission;
|
||||
use axum::extract::{Path, State};
|
||||
use sea_orm::{ActiveModelTrait, Set};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(crate) async fn fixture() -> (AppState, CurrentUser, Uuid) {
|
||||
let mut config = AppConfig::load().unwrap();
|
||||
config.database.url = "sqlite::memory:".to_string();
|
||||
let state = App::build(config).await.unwrap().state;
|
||||
let account = user::ActiveModel {
|
||||
username: Set(format!("test-{}", Uuid::new_v4())),
|
||||
password: Set("unused".to_string()),
|
||||
is_superuser: Set(false),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let server_id = state.default_server.id;
|
||||
(state, CurrentUser(account), server_id)
|
||||
}
|
||||
|
||||
pub(crate) async fn grant(state: &AppState, user: &CurrentUser, server_id: Uuid, permission: ServerPermission) {
|
||||
state.repositories.server.add_user(server_id, user.id).await.unwrap();
|
||||
state.repositories.server.set_user_permission(server_id, user.id, permission.bits()).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_read_requires_membership_and_member_can_read() {
|
||||
let (state, user, server_id) = fixture().await;
|
||||
|
||||
// Un utilisateur extérieur ne voit pas le serveur; un membre peut le consulter.
|
||||
assert!(matches!(super::handlers::get_by_id(user.clone(), State(state.clone()), Path(server_id)).await, Err(HTTPError::NotFound)));
|
||||
|
||||
grant(&state, &user, server_id, ServerPermission::empty()).await;
|
||||
assert!(super::handlers::get_by_id(user, State(state), Path(server_id)).await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_permission_list_requires_manage_members() {
|
||||
let (state, user, server_id) = fixture().await;
|
||||
grant(&state, &user, server_id, ServerPermission::empty()).await;
|
||||
|
||||
// L'appartenance seule ne permet pas de gérer les permissions des membres.
|
||||
assert!(matches!(super::handlers::list_user_permissions(user.clone(), State(state.clone()), Path(server_id)).await, Err(HTTPError::Forbidden)));
|
||||
|
||||
state.repositories.server.set_user_permission(server_id, user.id, ServerPermission::MANAGE_MEMBERS.bits()).await.unwrap();
|
||||
assert!(super::handlers::list_user_permissions(user, State(state), Path(server_id)).await.is_ok());
|
||||
}
|
||||
@@ -3,6 +3,8 @@ use crate::domain::dto::server_item_order::ReorderServerItemRequest;
|
||||
use crate::domain::events::server_tree::ServerTreeInvalidatedEvent;
|
||||
use crate::http::context::CurrentUser;
|
||||
use crate::http::error::HTTPError;
|
||||
use crate::permissions::ServerPermission;
|
||||
use crate::routes::server::handlers::require_server_permission;
|
||||
use axum::http::StatusCode;
|
||||
use axum::{Json, extract::State};
|
||||
|
||||
@@ -15,7 +17,7 @@ use axum::{Json, extract::State};
|
||||
security(("bearerAuth" = []))
|
||||
)]
|
||||
pub async fn reorder(
|
||||
_user: CurrentUser,
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<ReorderServerItemRequest>,
|
||||
) -> Result<StatusCode, HTTPError> {
|
||||
@@ -26,6 +28,7 @@ pub async fn reorder(
|
||||
.get_by_id(server_id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_CHANNELS | ServerPermission::MANAGE_CATEGORIES).await?;
|
||||
|
||||
state.services.server_order.reorder(payload).await?;
|
||||
state.event_bus.emit(ServerTreeInvalidatedEvent {
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
pub mod handlers;
|
||||
pub mod routes;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
use crate::http::test_support::{request, state, user};
|
||||
use crate::permissions::ServerPermission;
|
||||
use axum::{body::Body, http::{Method, StatusCode}};
|
||||
use serde_json::json;
|
||||
use tower::ServiceExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn reorder_requires_both_management_permissions_over_http() {
|
||||
let state = state().await;
|
||||
let server = state.default_server.id;
|
||||
let actor = user(&state, false).await;
|
||||
state.repositories.server.add_user(server, actor.id).await.unwrap();
|
||||
let router = super::routes::router().with_state(state.clone());
|
||||
let category = state.services.category.create_category(server, "reorder-test".into()).await.unwrap();
|
||||
let payload = json!({"server_id":server,"resource_id":category.id,"resource_type":"category","parent_category_id":null,"reference":null,"position":"after"});
|
||||
|
||||
// Le réordonnancement exige les droits adaptés à la catégorie et aux canaux.
|
||||
for permissions in [
|
||||
ServerPermission::empty(),
|
||||
ServerPermission::MANAGE_CHANNELS,
|
||||
ServerPermission::MANAGE_CATEGORIES,
|
||||
] {
|
||||
state.repositories.server.set_user_permission(server, actor.id, permissions.bits()).await.unwrap();
|
||||
|
||||
let mut req = request(Method::PUT, "/server-item-orders/reorder", Body::from(payload.to_string()), Some(actor.clone()));
|
||||
req.headers_mut().insert("content-type", "application/json".parse().unwrap());
|
||||
assert_eq!(router.clone().oneshot(req).await.unwrap().status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
state.repositories.server.set_user_permission(server, actor.id, (ServerPermission::MANAGE_CHANNELS | ServerPermission::MANAGE_CATEGORIES).bits()).await.unwrap();
|
||||
|
||||
let mut req = request(Method::PUT, "/server-item-orders/reorder", Body::from(payload.to_string()), Some(actor));
|
||||
req.headers_mut().insert("content-type", "application/json".parse().unwrap());
|
||||
assert_eq!(router.oneshot(req).await.unwrap().status(), StatusCode::NO_CONTENT);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ use uuid::Uuid;
|
||||
)
|
||||
)]
|
||||
pub async fn get_all(
|
||||
_admin: Superuser,
|
||||
State(state): State<AppState>,
|
||||
Query(filters): Query<UserQueryParams>,
|
||||
) -> Result<Json<Vec<UserResponse>>, HTTPError> {
|
||||
|
||||
@@ -13,3 +13,38 @@ pub fn router() -> Router<AppState> {
|
||||
.delete(handlers::delete),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::http::test_support::{request, state, user};
|
||||
use axum::{body::Body, http::{Method, StatusCode}};
|
||||
use tower::ServiceExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_routes_require_superuser() {
|
||||
let state = state().await;
|
||||
let admin = user(&state, true).await;
|
||||
let member = user(&state, false).await;
|
||||
let target = user(&state, false).await;
|
||||
let routes = router().with_state(state);
|
||||
let cases = [
|
||||
(Method::GET, "/users".to_string(), ""),
|
||||
(Method::GET, format!("/users/{}", target.id), ""),
|
||||
(Method::POST, "/users".to_string(), r#"{"username":"new-user","password":"password123","pub_key":null,"is_superuser":false}"#),
|
||||
(Method::PUT, format!("/users/{}", target.id), r#"{"username":"renamed-user","pub_key":null,"is_superuser":false}"#),
|
||||
(Method::DELETE, format!("/users/{}", target.id), ""),
|
||||
];
|
||||
for (method, uri, body) in cases {
|
||||
let denied = routes.clone().oneshot(request(method.clone(), &uri, Body::from(body.to_string()), Some(member.clone()))).await.unwrap();
|
||||
assert_eq!(denied.status(), StatusCode::FORBIDDEN, "{method} {uri}");
|
||||
let missing = routes.clone().oneshot(request(method.clone(), &uri, Body::from(body.to_string()), None)).await.unwrap();
|
||||
assert_eq!(missing.status(), StatusCode::UNAUTHORIZED, "{method} {uri}");
|
||||
let mut allowed = request(method.clone(), &uri, Body::from(body.to_string()), Some(admin.clone()));
|
||||
allowed.headers_mut().insert("content-type", "application/json".parse().unwrap());
|
||||
let result = routes.clone().oneshot(allowed).await.unwrap();
|
||||
let expected = if method == Method::POST { StatusCode::CREATED } else if method == Method::DELETE { StatusCode::NO_CONTENT } else { StatusCode::OK };
|
||||
assert_eq!(result.status(), expected, "{method} {uri}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-7
@@ -1,11 +1,37 @@
|
||||
### Step 1 - handshake WebSocket
|
||||
## Parcours technique RTC
|
||||
|
||||
Le client doit effectuer une connexion HTTP (websocket) sur /rtc/{channel_id}
|
||||
Le server vérifiera avant d'accepter la connexion si :
|
||||
Ce module gère la signalisation WebSocket et le relais audio WebRTC d'un canal. Le WebSocket transporte les descriptions SDP, les candidats ICE et les événements du salon ; les échantillons audio passent par la `PeerConnection`, pas par le WebSocket. Une connexion WebSocket correspond à un `RTCClient` et à une `PeerConnection`.
|
||||
|
||||
- Le channel existe
|
||||
- Vérifiera les permissions sur le canal (check_perm ou si c'est un canal privé)
|
||||
### Entrée et création du client
|
||||
|
||||
### Step 2 - SDP (négociation du type de media)
|
||||
1. `routes/mod.rs` monte `routes/rtc/routes.rs` sous `/ws` : le navigateur ouvre `/ws/rtc/{channel_id}`.
|
||||
2. `routes/rtc/handlers.rs::ws_handler` reçoit le canal et l'utilisateur via `CurrentUser`, cherche le canal en base, puis accepte l'upgrade WebSocket avec `ws_entrypoint_handler`. L'utilisateur doit être connecté et le canal doit exister. Le type du canal et les permissions ne sont **pas encore vérifiés**.
|
||||
3. `rtc/ws_entrypoint.rs::ws_entrypoint_handler` crée une connexion via `RTCManager::new_peer_connection`, puis un `RTCClient` avec le canal, l'utilisateur, un identifiant propre à cette connexion et le gestionnaire partagé des salons (`RTCManager::rooms`). `RTCManager::new`, dans `rtc/mod.rs`, prépare aussi la configuration réseau `rustrtc`, notamment le port UDP ICE.
|
||||
4. L'entrypoint démarre `RTCClient::forward_ice_candidates` avant la négociation, ainsi qu'une tâche de lecture WebSocket (`RTCClient::ws_on_message`) et une tâche d'écriture. `RTCClient::send_response` sérialise les réponses JSON dans le canal lu par cette dernière.
|
||||
|
||||
### Step 3 - ICE (Négociation du flux réseaux)
|
||||
### Négociation initiale : SDP et ICE
|
||||
|
||||
Les actions JSON sont définies dans `rtc/messages.rs`. Le `channel_id` n'est pas répété dans les messages : il est fixé par l'URL du WebSocket.
|
||||
|
||||
1. Le navigateur envoie `sdp-offer`. `RTCClient::ws_on_message` appelle `handle_sdp_offer`, qui lit le type de charge utile Opus proposé et applique l'offre avec `PeerConnection::set_remote_description`.
|
||||
2. Si l'offre contient une piste audio, `handle_sdp_offer` récupère sa piste entrante, réserve la première section audio pour éviter sa réutilisation par un autre locuteur, inscrit le client dans le salon avec `VoiceRoomManager::join`, puis démarre `forward_audio`.
|
||||
3. `create_initial_answer` crée et applique la réponse SDP locale. `opus_sdp` reprend le type de charge utile Opus proposé par le navigateur ; `ws_on_message` renvoie ensuite `answer` et lance `run_room` pour écouter les événements du salon.
|
||||
4. Le navigateur envoie ses messages `ice-candidate` : `handle_ice_candidate` les transmet à `rustrtc`. Un candidat vide marque la fin de collecte et est ignoré. En sens inverse, `forward_ice_candidates` transmet les candidats locaux sous forme de messages `ice-candidate` sur le WebSocket. Un candidat refusé produit un message `error` ; les noms mDNS `.local` peuvent notamment être refusés par `rustrtc`.
|
||||
|
||||
Pour tester depuis Internet, `network.host = "0.0.0.0"` ne définit que l'adresse d'écoute. Sans `network.external_ip`, le serveur utilise par défaut `stun:stun.l.google.com:19302` pour découvrir son IPv4 publique au démarrage et la fournir à ICE ; `network.stun_servers` permet de changer la liste ou de désactiver STUN avec `[]`. Définir `network.external_ip` prend la priorité et évite STUN côté serveur. Rediriger `network.udp_port` en UDP vers le serveur et ouvrir ce port dans le pare-feu : STUN ne crée pas de redirection NAT. Les logs `Configuration ICE du serveur`, `Candidat ICE local envoyé` et `Candidat ICE distant accepté` sont visibles au niveau `info` (par exemple `RUST_LOG=info`). Vérifier que les candidats locaux annoncent une adresse publique **et un port** effectivement joignables ; si le NAT modifie le port ou en cas de NAT symétrique, une redirection adaptée ou un relais TURN peut être nécessaire. Ces logs exposent des adresses IP : éviter de les partager sans les masquer.
|
||||
|
||||
Au démarrage, si `external_ip` est absent, les logs `Diagnostic STUN IPv4` montrent une requête Binding indépendante vers les serveurs configurés, sa réponse ou l'erreur (résolution DNS, délai de 5 secondes, réponse invalide). La première IPv4 publique découverte est injectée dans la configuration ICE avant d'accepter les connexions ; son **port temporaire distinct d'ICE est ignoré** et ICE annonce `network.udp_port`. Si la découverte échoue, la collecte ICE native reste active. Le résultat du diagnostic ne prouve pas que le port ICE est joignable ni que l'IP reste stable après le démarrage : contrôler `Candidat ICE local envoyé` et redémarrer après un changement d'adresse. Pour les erreurs de collecte de `rustrtc`, utiliser `RUST_LOG=info,rustrtc::transports::ice=debug` pendant une connexion.
|
||||
|
||||
### Audio et changements de participants
|
||||
|
||||
`VoiceRoomManager` (`rtc/mod.rs`) maintient un salon par identifiant de canal. `join` crée une source audio propre au client, prévient les autres participants avec `RoomEvent::Joined` et informe le nouvel arrivant des sources existantes. Aucun événement n'inscrit sa propre source comme piste à recevoir : le client ne s'entend pas lui-même.
|
||||
|
||||
`RTCClient::forward_audio` lit les échantillons de la piste entrante et les confie à `VoiceRoomManager::forward`, qui les publie uniquement sur la source de ce client et dans ce canal. Pour chaque `RoomEvent::Joined`, `RTCClient::run_room` crée une piste sortante distincte et une tâche qui alimente cette piste depuis la source correspondante. Le serveur relaie les échantillons Opus sans mixer les voix ; chaque navigateur lit et mixe ses pistes reçues.
|
||||
|
||||
Une nouvelle piste nécessite une renégociation : `run_room` crée une offre SDP, stabilise les identifiants d'extensions RTP avec `stable_extmaps`, envoie `sdp-offer`, puis attend le `sdp-answer` du navigateur. `ws_on_message` applique cette réponse et débloque l'attente. Les renégociations sont traitées l'une après l'autre, avec un délai maximal de dix secondes. Lors d'un `RoomEvent::Left`, `run_room` arrête le transfert de cette source, rend sa piste inactive sans réutiliser sa section SDP, envoie `source-left` au navigateur et renégocie.
|
||||
|
||||
### Déconnexion et responsabilités
|
||||
|
||||
Un message `leave`, une fermeture WebSocket ou l'arrêt de la lecture/écriture termine le traitement dans `ws_entrypoint_handler`. Celui-ci arrête l'autre tâche WebSocket, arrête la tâche ICE et appelle `RTCClient::close`. `close` retire le participant du salon via `VoiceRoomManager::leave` (qui avertit les autres et supprime le salon devenu vide), arrête la tâche de salon et ferme la `PeerConnection`.
|
||||
|
||||
En résumé : `routes/rtc` contrôle l'entrée HTTP, `ws_entrypoint` supervise les tâches et la fermeture, `RTCClient` possède la signalisation et la connexion WebRTC individuelle, et `VoiceRoomManager` distribue les sources audio entre les connexions du même canal. L'ancien module `voice` est distinct de ce parcours RTC.
|
||||
|
||||
+7
-5
@@ -1,5 +1,5 @@
|
||||
use super::messages::{VoiceClientMessage, VoiceServerMessage};
|
||||
use super::{RoomEvent, VoiceRoomManager};
|
||||
use super::{RoomEvent, VoiceParticipant, VoiceRoomManager};
|
||||
use crate::models::{channel, user};
|
||||
use axum::extract::ws::Message;
|
||||
use rustrtc::media::{self, MediaStreamTrack};
|
||||
@@ -99,7 +99,7 @@ impl RTCClient {
|
||||
message: error.to_string(),
|
||||
});
|
||||
} else {
|
||||
info!(channel_id = %self.channel.id, "Candidat ICE distant accepté");
|
||||
info!(channel_id = %self.channel.id, client_id = %self.client_id, candidate = %candidate, "Candidat ICE distant accepté");
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -136,7 +136,9 @@ impl RTCClient {
|
||||
info!(channel_id = %self.channel.id, "Audio du salon configuré");
|
||||
let channel_id = self.channel.id;
|
||||
let client_id = self.client_id;
|
||||
let room_events = self.room_manager.join(channel_id, client_id);
|
||||
let room_events = self.room_manager.join_user(self.channel.server_id.expect("canal RTC serveur"), channel_id, client_id, VoiceParticipant {
|
||||
id: self.user.id, username: self.user.username.clone(),
|
||||
});
|
||||
let room_manager = self.room_manager.clone();
|
||||
tokio::spawn(async move { Self::forward_audio(incoming, room_manager, channel_id, client_id).await });
|
||||
let answer = self.create_initial_answer(opus_payload_type).await?;
|
||||
@@ -298,7 +300,7 @@ impl RTCClient {
|
||||
// recv attend un candidat ; une erreur termine l'écoute.
|
||||
while let Ok(candidate) = candidates.recv().await {
|
||||
// Le candidat est sérialisé pour être envoyé au navigateur via send_response.
|
||||
info!(channel_id = %client.channel.id, "Candidat ICE local envoyé");
|
||||
info!(channel_id = %client.channel.id, client_id = %client.client_id, candidate = %candidate.to_sdp(), "Candidat ICE local envoyé");
|
||||
client.send_response(VoiceServerMessage::IceCandidate {
|
||||
candidate: format!("candidate:{}", candidate.to_sdp()),
|
||||
});
|
||||
@@ -350,7 +352,7 @@ mod tests {
|
||||
},
|
||||
channel::Model {
|
||||
id: Uuid::new_v4(),
|
||||
server_id: None,
|
||||
server_id: Some(Uuid::new_v4()),
|
||||
category_id: None,
|
||||
channel_type: channel::ChannelType::Voice,
|
||||
name: None,
|
||||
|
||||
+182
-13
@@ -1,18 +1,22 @@
|
||||
mod client;
|
||||
mod messages;
|
||||
mod metrics;
|
||||
mod stun_diagnostic;
|
||||
pub mod ws_entrypoint;
|
||||
|
||||
use crate::config::NetworkConfig;
|
||||
use crate::domain::events::voice_presence::VoicePresenceEvent;
|
||||
use crate::repositories::Repositories;
|
||||
use crate::services::Services;
|
||||
use event_bus::EventBus;
|
||||
use rustrtc::{PeerConnection, RtcConfiguration, RtcConfigurationBuilder};
|
||||
use rustrtc::{IceServer, PeerConnection, RtcConfiguration, RtcConfigurationBuilder};
|
||||
use rustrtc::media::MediaSample;
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::sync::Arc;
|
||||
use serde::Serialize;
|
||||
// 1. Client crée une RTCPeerConnection
|
||||
// 2. Client crée une SDP offer
|
||||
// 3. Client envoie l'offer via WebSocket
|
||||
@@ -27,6 +31,14 @@ use std::sync::Arc;
|
||||
pub struct VoiceRoom {
|
||||
participants: HashMap<uuid::Uuid, tokio::sync::mpsc::UnboundedSender<RoomEvent>>,
|
||||
sources: HashMap<uuid::Uuid, tokio::sync::broadcast::Sender<MediaSample>>,
|
||||
users: HashMap<uuid::Uuid, VoiceParticipant>,
|
||||
server_id: Option<uuid::Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct VoiceParticipant {
|
||||
pub id: uuid::Uuid,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
pub enum RoomEvent {
|
||||
@@ -36,6 +48,7 @@ pub enum RoomEvent {
|
||||
|
||||
pub struct VoiceRoomManager {
|
||||
rooms: Mutex<HashMap<uuid::Uuid, VoiceRoom>>,
|
||||
event_bus: Option<Arc<EventBus>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -59,7 +72,7 @@ impl fmt::Debug for RTCManager {
|
||||
|
||||
impl VoiceRoom {
|
||||
pub fn new() -> Self {
|
||||
Self { participants: HashMap::new(), sources: HashMap::new() }
|
||||
Self { participants: HashMap::new(), sources: HashMap::new(), users: HashMap::new(), server_id: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,10 +80,23 @@ impl VoiceRoomManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
rooms: Mutex::new(HashMap::new()),
|
||||
event_bus: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_event_bus(event_bus: Arc<EventBus>) -> Self {
|
||||
Self { rooms: Mutex::new(HashMap::new()), event_bus: Some(event_bus) }
|
||||
}
|
||||
|
||||
pub fn join(&self, channel_id: uuid::Uuid, client_id: uuid::Uuid) -> tokio::sync::mpsc::UnboundedReceiver<RoomEvent> {
|
||||
self.join_inner(channel_id, client_id, None)
|
||||
}
|
||||
|
||||
pub fn join_user(&self, server_id: uuid::Uuid, channel_id: uuid::Uuid, client_id: uuid::Uuid, user: VoiceParticipant) -> tokio::sync::mpsc::UnboundedReceiver<RoomEvent> {
|
||||
self.join_inner(channel_id, client_id, Some((server_id, user)))
|
||||
}
|
||||
|
||||
fn join_inner(&self, channel_id: uuid::Uuid, client_id: uuid::Uuid, user: Option<(uuid::Uuid, VoiceParticipant)>) -> tokio::sync::mpsc::UnboundedReceiver<RoomEvent> {
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut rooms = self.rooms.lock();
|
||||
let room = rooms.entry(channel_id).or_insert_with(VoiceRoom::new);
|
||||
@@ -83,9 +109,25 @@ impl VoiceRoomManager {
|
||||
}
|
||||
room.sources.insert(client_id, source);
|
||||
room.participants.insert(client_id, tx);
|
||||
if let Some((server_id, user)) = user {
|
||||
room.server_id = Some(server_id);
|
||||
let first = !room.users.values().any(|existing| existing.id == user.id);
|
||||
room.users.insert(client_id, user.clone());
|
||||
if first { if let Some(bus) = &self.event_bus { bus.emit(VoicePresenceEvent { server_id, channel_id, user, joined: true }); } }
|
||||
}
|
||||
rx
|
||||
}
|
||||
|
||||
pub fn participants(&self, channel_id: uuid::Uuid) -> Vec<VoiceParticipant> {
|
||||
let rooms = self.rooms.lock();
|
||||
let mut users: Vec<_> = rooms.get(&channel_id).into_iter()
|
||||
.flat_map(|room| room.users.values()).cloned().collect();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
users.retain(|user| seen.insert(user.id));
|
||||
users.sort_by(|a, b| a.username.cmp(&b.username).then(a.id.cmp(&b.id)));
|
||||
users
|
||||
}
|
||||
|
||||
pub fn forward(&self, channel_id: uuid::Uuid, client_id: uuid::Uuid, sample: MediaSample) {
|
||||
if let Some(source) = self.rooms.lock().get(&channel_id).and_then(|room| room.sources.get(&client_id).cloned()) {
|
||||
let _ = source.send(sample);
|
||||
@@ -97,6 +139,12 @@ impl VoiceRoomManager {
|
||||
if let Some(room) = rooms.get_mut(&channel_id) {
|
||||
room.participants.remove(&client_id);
|
||||
room.sources.remove(&client_id);
|
||||
let removed = room.users.remove(&client_id);
|
||||
if let (Some(user), Some(server_id), Some(bus)) = (removed, room.server_id, &self.event_bus) {
|
||||
if !room.users.values().any(|existing| existing.id == user.id) {
|
||||
bus.emit(VoicePresenceEvent { server_id, channel_id, user, joined: false });
|
||||
}
|
||||
}
|
||||
for participant in room.participants.values() {
|
||||
let _ = participant.send(RoomEvent::Left(client_id));
|
||||
}
|
||||
@@ -108,19 +156,53 @@ impl VoiceRoomManager {
|
||||
}
|
||||
|
||||
impl RTCManager {
|
||||
pub fn new(
|
||||
fn ice_config(network: &NetworkConfig) -> RtcConfiguration {
|
||||
Self::ice_config_with_discovered_ip(network, None)
|
||||
}
|
||||
|
||||
fn ice_config_with_discovered_ip(network: &NetworkConfig, discovered_ip: Option<Ipv4Addr>) -> RtcConfiguration {
|
||||
let mut builder = RtcConfigurationBuilder::new()
|
||||
.ice_udp_mux(true)
|
||||
.ice_udp_mux_port(network.udp_port)
|
||||
.bind_ip(network.host.to_string());
|
||||
if let Some(ip) = network.external_ip.or(discovered_ip) {
|
||||
builder = builder.external_ip(ip.to_string());
|
||||
} else {
|
||||
for url in &network.stun_servers {
|
||||
builder = builder.ice_server(IceServer::new(vec![url.clone()]));
|
||||
}
|
||||
}
|
||||
builder.build()
|
||||
}
|
||||
|
||||
pub async fn new(
|
||||
network: &NetworkConfig,
|
||||
repositories: Arc<Repositories>,
|
||||
services: Arc<Services>,
|
||||
event_bus: Arc<EventBus>,
|
||||
) -> Self {
|
||||
let builder = RtcConfigurationBuilder::new()
|
||||
.ice_udp_mux(true)
|
||||
.ice_udp_mux_port(network.udp_port)
|
||||
.bind_ip(network.host.to_string());
|
||||
let config = builder.build();
|
||||
let mut discovered_ip = None;
|
||||
if network.external_ip.is_none() {
|
||||
for url in &network.stun_servers {
|
||||
match stun_diagnostic::probe(url, network.host).await {
|
||||
Ok(mapped) => {
|
||||
let std::net::IpAddr::V4(ip) = mapped.ip() else { unreachable!() };
|
||||
if ip.is_unspecified() || ip.is_loopback() || ip.is_private() || ip.is_link_local() || ip.is_multicast() || ip.is_broadcast() {
|
||||
tracing::warn!(%url, %mapped, "Adresse STUN IPv4 non publique ignorée");
|
||||
continue;
|
||||
}
|
||||
tracing::info!(%url, %mapped, udp_port = network.udp_port, "IPv4 STUN injectée dans ICE ; port temporaire ignoré");
|
||||
discovered_ip = Some(ip);
|
||||
break;
|
||||
}
|
||||
Err(error) => tracing::warn!(%url, %error, "Diagnostic STUN IPv4 échoué"),
|
||||
}
|
||||
}
|
||||
}
|
||||
let config = Self::ice_config_with_discovered_ip(network, discovered_ip);
|
||||
tracing::info!(bind_ip = %network.host, udp_port = network.udp_port, external_ip = ?config.external_ip, stun_servers = ?config.ice_servers.iter().flat_map(|server| &server.urls).collect::<Vec<_>>(), "Configuration ICE du serveur");
|
||||
|
||||
let rooms = Arc::new(VoiceRoomManager::new());
|
||||
let rooms = Arc::new(VoiceRoomManager::with_event_bus(event_bus.clone()));
|
||||
|
||||
Self {
|
||||
config,
|
||||
@@ -132,18 +214,105 @@ impl RTCManager {
|
||||
}
|
||||
|
||||
pub fn new_peer_connection(&self) -> Arc<PeerConnection> {
|
||||
let pc = Arc::new(PeerConnection::new(self.config.clone()));
|
||||
// todo : some logging ?
|
||||
pc
|
||||
Arc::new(PeerConnection::new(self.config.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ice_config_tests {
|
||||
use super::RTCManager;
|
||||
use crate::config::{AppConfig, DEFAULT_CONFIG_TOML};
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
#[test]
|
||||
fn stun_is_used_only_without_manual_external_ip() {
|
||||
let mut network = toml::from_str::<AppConfig>(DEFAULT_CONFIG_TOML).unwrap().network;
|
||||
let config = RTCManager::ice_config(&network);
|
||||
assert_eq!(config.ice_servers[0].urls, network.stun_servers);
|
||||
assert!(config.ice_udp_mux);
|
||||
assert_eq!(config.ice_udp_mux_port, Some(network.udp_port));
|
||||
|
||||
network.external_ip = Some(Ipv4Addr::new(203, 0, 113, 1));
|
||||
let config = RTCManager::ice_config(&network);
|
||||
assert!(config.ice_servers.is_empty());
|
||||
assert_eq!(config.external_ip.as_deref(), Some("203.0.113.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovered_ipv4_is_used_without_advertising_the_probe_port() {
|
||||
let network = toml::from_str::<AppConfig>(DEFAULT_CONFIG_TOML).unwrap().network;
|
||||
let config = RTCManager::ice_config_with_discovered_ip(&network, Some(Ipv4Addr::new(82, 64, 205, 121)));
|
||||
assert_eq!(config.external_ip.as_deref(), Some("82.64.205.121"));
|
||||
assert_eq!(config.ice_udp_mux_port, Some(network.udp_port));
|
||||
assert!(config.ice_servers.is_empty());
|
||||
|
||||
let fallback = RTCManager::ice_config_with_discovered_ip(&network, None);
|
||||
assert!(fallback.external_ip.is_none());
|
||||
assert!(!fallback.ice_servers.is_empty());
|
||||
|
||||
let mut manual = network;
|
||||
manual.external_ip = Some(Ipv4Addr::new(203, 0, 113, 1));
|
||||
let config = RTCManager::ice_config_with_discovered_ip(&manual, Some(Ipv4Addr::new(82, 64, 205, 121)));
|
||||
assert_eq!(config.external_ip.as_deref(), Some("203.0.113.1"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod room_tests {
|
||||
use super::{RoomEvent, VoiceRoomManager};
|
||||
use super::{RoomEvent, VoiceParticipant, VoiceRoomManager};
|
||||
use crate::domain::events::voice_presence::VoicePresenceEvent;
|
||||
use event_bus::EventBus;
|
||||
use rustrtc::media::{AudioFrame, MediaSample};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn presence_is_scoped_deduplicated_and_removed_per_connection() {
|
||||
let rooms = VoiceRoomManager::new();
|
||||
let channel = Uuid::new_v4();
|
||||
let other = Uuid::new_v4();
|
||||
let user = VoiceParticipant { id: Uuid::new_v4(), username: "alice".into() };
|
||||
let first = Uuid::new_v4();
|
||||
let second = Uuid::new_v4();
|
||||
let server = Uuid::new_v4();
|
||||
rooms.join_user(server, channel, first, user.clone());
|
||||
rooms.join_user(server, channel, second, user.clone());
|
||||
rooms.join_user(server, other, Uuid::new_v4(), user.clone());
|
||||
assert_eq!(rooms.participants(channel), vec![user.clone()]);
|
||||
rooms.leave(channel, first);
|
||||
assert_eq!(rooms.participants(channel), vec![user.clone()]);
|
||||
rooms.leave(channel, second);
|
||||
assert!(rooms.participants(channel).is_empty());
|
||||
assert_eq!(rooms.participants(other), vec![user]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_events_only_on_first_join_and_last_leave() {
|
||||
let bus = Arc::new(EventBus::new());
|
||||
let mut events = bus.subscribe::<VoicePresenceEvent>();
|
||||
let rooms = VoiceRoomManager::with_event_bus(bus);
|
||||
let server_id = Uuid::new_v4();
|
||||
let channel_id = Uuid::new_v4();
|
||||
let user = VoiceParticipant { id: Uuid::new_v4(), username: "alice".into() };
|
||||
let first = Uuid::new_v4();
|
||||
let second = Uuid::new_v4();
|
||||
rooms.join_user(server_id, channel_id, first, user.clone());
|
||||
let joined = events.try_recv().unwrap();
|
||||
assert!(joined.joined);
|
||||
assert_eq!(serde_json::to_value(&joined).unwrap(), serde_json::json!({
|
||||
"server_id": server_id, "channel_id": channel_id,
|
||||
"user": { "id": user.id, "username": "alice" }
|
||||
}));
|
||||
rooms.join_user(server_id, channel_id, second, user.clone());
|
||||
rooms.leave(channel_id, first);
|
||||
assert!(events.try_recv().is_err());
|
||||
rooms.leave(channel_id, second);
|
||||
let left = events.try_recv().unwrap();
|
||||
assert!(!left.joined);
|
||||
assert_eq!(left.user, user);
|
||||
assert!(events.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn audio_reaches_only_other_clients_in_same_room() {
|
||||
let rooms = VoiceRoomManager::new();
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
use tokio::net::{UdpSocket, lookup_host};
|
||||
use tokio::time::timeout;
|
||||
|
||||
pub(super) async fn probe(url: &str, bind_ip: Ipv4Addr) -> anyhow::Result<SocketAddr> {
|
||||
let endpoint = url.strip_prefix("stun:").ok_or_else(|| anyhow::anyhow!("URL STUN non prise en charge"))?;
|
||||
let endpoint = endpoint.split('?').next().unwrap_or(endpoint);
|
||||
let mut addresses = lookup_host(endpoint).await?;
|
||||
let server = addresses.find(SocketAddr::is_ipv4).ok_or_else(|| anyhow::anyhow!("aucune adresse IPv4 pour le serveur STUN"))?;
|
||||
let socket = UdpSocket::bind((bind_ip, 0)).await?;
|
||||
let local = socket.local_addr()?;
|
||||
let transaction = *uuid::Uuid::new_v4().as_bytes();
|
||||
let mut request = [0u8; 20];
|
||||
request[..2].copy_from_slice(&0x0001u16.to_be_bytes());
|
||||
request[4..8].copy_from_slice(&0x2112a442u32.to_be_bytes());
|
||||
request[8..20].copy_from_slice(&transaction[..12]);
|
||||
tracing::info!(%server, %local, "Diagnostic STUN IPv4 : requête Binding envoyée (socket distinct d’ICE)");
|
||||
socket.send_to(&request, server).await?;
|
||||
let mut response = [0u8; 1500];
|
||||
let (length, sender) = timeout(Duration::from_secs(5), socket.recv_from(&mut response)).await??;
|
||||
anyhow::ensure!(sender == server, "réponse STUN d'une autre adresse : {sender}");
|
||||
let mapped = mapped_address(&response[..length], &transaction[..12])?;
|
||||
tracing::info!(%server, %local, %mapped, "Diagnostic STUN IPv4 : réponse Binding reçue");
|
||||
Ok(mapped)
|
||||
}
|
||||
|
||||
fn mapped_address(response: &[u8], transaction: &[u8]) -> anyhow::Result<SocketAddr> {
|
||||
anyhow::ensure!(response.len() >= 20 && response[0..2] == [0x01, 0x01]
|
||||
&& response[4..8] == 0x2112a442u32.to_be_bytes()
|
||||
&& response[8..20] == *transaction, "réponse STUN invalide ou transaction différente");
|
||||
let size = u16::from_be_bytes([response[2], response[3]]) as usize;
|
||||
anyhow::ensure!(response.len() >= 20 + size, "réponse STUN tronquée");
|
||||
let mut offset = 20;
|
||||
while offset + 4 <= 20 + size {
|
||||
let kind = u16::from_be_bytes([response[offset], response[offset + 1]]);
|
||||
let len = u16::from_be_bytes([response[offset + 2], response[offset + 3]]) as usize;
|
||||
offset += 4;
|
||||
anyhow::ensure!(offset + len <= 20 + size, "attribut STUN tronqué");
|
||||
if kind == 0x0020 && len >= 8 && response[offset + 1] == 1 {
|
||||
let port = u16::from_be_bytes([response[offset + 2], response[offset + 3]]) ^ 0x2112;
|
||||
let cookie = 0x2112a442u32.to_be_bytes();
|
||||
let ip = Ipv4Addr::from(std::array::from_fn(|i| response[offset + 4 + i] ^ cookie[i]));
|
||||
return Ok(SocketAddr::new(IpAddr::V4(ip), port));
|
||||
}
|
||||
offset += (len + 3) & !3;
|
||||
}
|
||||
anyhow::bail!("aucune adresse IPv4 XOR-MAPPED-ADDRESS dans la réponse STUN")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::mapped_address;
|
||||
|
||||
#[test]
|
||||
fn decodes_ipv4_and_rejects_unrelated_response() {
|
||||
let transaction = [7; 12];
|
||||
let mut response = vec![0x01, 0x01, 0, 12, 0x21, 0x12, 0xa4, 0x42];
|
||||
response.extend(transaction);
|
||||
response.extend([0, 0x20, 0, 8, 0, 1, 0x3f, 0x52, 0x6a, 0x22, 0xa5, 0x43]);
|
||||
assert_eq!(mapped_address(&response, &transaction).unwrap().to_string(), "75.48.1.1:7744");
|
||||
assert!(mapped_address(&response, &[8; 12]).is_err());
|
||||
assert!(mapped_address(&response[..25], &transaction).is_err());
|
||||
}
|
||||
}
|
||||
+15
-9
@@ -11,6 +11,7 @@ use crate::models::{channel, role};
|
||||
use crate::permissions::PermissionSet;
|
||||
use crate::services::ServicesContext;
|
||||
use crate::services::permission::PermissionService;
|
||||
use anyhow::Context;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set, TransactionTrait};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
@@ -154,12 +155,7 @@ impl ChannelService {
|
||||
let db = &self.service_context.repositories.server.context.db;
|
||||
let event_bus = &self.service_context.event_bus;
|
||||
|
||||
let txn = db.begin().await?;
|
||||
|
||||
let existing = channel::Entity::find_by_id(id)
|
||||
.one(&txn)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("Channel not found"))?;
|
||||
let txn = db.begin().await.context("begin channel deletion transaction")?;
|
||||
|
||||
self.service_context
|
||||
.services
|
||||
@@ -167,13 +163,23 @@ impl ChannelService {
|
||||
.expect("services initialized")
|
||||
.server_order
|
||||
.remove(&txn, id, OrderedResourceType::Channel)
|
||||
.await?;
|
||||
.await
|
||||
.context("remove channel display order")?;
|
||||
|
||||
let res = channel::Entity::delete_by_id(id).exec(&txn).await?;
|
||||
let existing = channel::Entity::find_by_id(id)
|
||||
.one(&txn)
|
||||
.await
|
||||
.context("load channel for deletion")?
|
||||
.ok_or_else(|| anyhow::anyhow!("Channel not found"))?;
|
||||
|
||||
let res = channel::Entity::delete_by_id(id)
|
||||
.exec(&txn)
|
||||
.await
|
||||
.context("delete channel record")?;
|
||||
|
||||
let deleted = res.rows_affected > 0;
|
||||
|
||||
txn.commit().await?;
|
||||
txn.commit().await.context("commit channel deletion")?;
|
||||
|
||||
if deleted {
|
||||
event_bus.emit(ChannelDeletedEvent { channel: existing });
|
||||
|
||||
@@ -341,6 +341,9 @@ mod tests {
|
||||
hostv6: None,
|
||||
tcp_port: 8080,
|
||||
udp_port: 9000,
|
||||
external_ip: None,
|
||||
stun_servers: vec![],
|
||||
tls: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user