Compare commits

..
5 Commits
Author SHA1 Message Date
Nell 0cb8b068a8 add permission table 2026-09-27 18:15:55 +02:00
Nell 910eb2b169 add test and permission check 2026-09-24 21:10:11 +02:00
Nell 844eaadee0 fix webrtc and login 2026-09-24 13:55:59 +02:00
Nell 5bac3174df add event_bus_typed 2026-09-23 21:11:35 +02:00
Nell b830abe3f4 add event_bus_typed 2026-09-23 19:05:09 +02:00
63 changed files with 2534 additions and 267 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
/target
/.idea
*.db*
/media/*
/media/*
/certs
Generated
+133 -3
View File
@@ -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"
+2
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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",
+21
View File
@@ -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;
+1 -1
View File
@@ -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 {
+60 -6
View File
@@ -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));
}
+1 -1
View File
@@ -2,6 +2,6 @@ import {defineStore} from 'pinia'
export const useAppStore = defineStore('app', {
state: () => ({
baseurl: 'http://localhost:8080',
baseurl: '',
}),
});
+2
View File
@@ -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')
+1
View File
@@ -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
+125
View File
@@ -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)
}
+20 -2
View File
@@ -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,
},
},
},
+5
View File
@@ -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
View File
@@ -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.
+62
View File
@@ -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
View File
@@ -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,
+3
View File
@@ -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)]
+1
View File
@@ -8,3 +8,4 @@ pub mod server;
pub mod server_permission;
pub mod server_tree;
pub mod user;
pub mod voice_presence;
+12
View File
@@ -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
View File
@@ -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
View File
@@ -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")
}
};
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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
+48
View File
@@ -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
}
+63
View File
@@ -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();
}
}
+27 -7
View File
@@ -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)?;
+94
View File
@@ -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();
}
}
+22
View File
@@ -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);
}
}
+16 -5
View File
@@ -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
View File
@@ -3,3 +3,6 @@ pub mod handlers;
pub mod mapper;
pub mod routes;
pub mod service;
#[cfg(test)]
mod tests;
+88
View File
@@ -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
);
}
+70 -13
View File
@@ -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
+1
View File
@@ -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
View File
@@ -3,3 +3,6 @@ pub mod handlers;
pub mod mapper;
pub mod routes;
pub mod service;
#[cfg(test)]
mod tests;
+126
View File
@@ -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
);
}
+38
View File
@@ -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}");
}
}
}
+38 -1
View File
@@ -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
+161
View File
@@ -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
);
}
}
}
+10
View File
@@ -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
View File
@@ -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
+324
View File
@@ -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
View File
@@ -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
View File
@@ -3,3 +3,6 @@ pub mod handlers;
pub mod mapper;
pub mod routes;
pub mod service;
#[cfg(test)]
mod tests;
+89
View File
@@ -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
);
}
+13 -3
View File
@@ -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)
}
+1
View File
@@ -6,3 +6,4 @@ use axum::routing::get;
pub fn router() -> Router<AppState> {
Router::new().route("/rtc/{channel_id}", get(handlers::ws_handler))
}
+15 -16
View File
@@ -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,
)))
}
+25 -1
View File
@@ -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
View File
@@ -3,3 +3,6 @@ pub mod handlers;
pub mod mapper;
pub mod routes;
pub mod service;
#[cfg(test)]
pub(crate) mod tests;
+54
View File
@@ -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());
}
+4 -1
View File
@@ -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 {
+3
View File
@@ -1,2 +1,5 @@
pub mod handlers;
pub mod routes;
#[cfg(test)]
mod tests;
+35
View File
@@ -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);
}
+1
View File
@@ -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> {
+35
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+65
View File
@@ -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
View File
@@ -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 });
+3
View File
@@ -341,6 +341,9 @@ mod tests {
hostv6: None,
tcp_port: 8080,
udp_port: 9000,
external_ip: None,
stun_servers: vec![],
tls: None,
}
}