fix webrtc and login
This commit is contained in:
@@ -2,3 +2,4 @@
|
|||||||
/.idea
|
/.idea
|
||||||
*.db*
|
*.db*
|
||||||
/media/*
|
/media/*
|
||||||
|
/certs
|
||||||
+8
-4
@@ -7,12 +7,16 @@ host = "0.0.0.0"
|
|||||||
tcp_port = 8080
|
tcp_port = 8080
|
||||||
# Voice/Video port
|
# Voice/Video port
|
||||||
udp_port = 8080
|
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.
|
# Uncomment for native self-signed HTTPS/WSS. Keep both files between restarts.
|
||||||
# [network.tls]
|
[network.tls]
|
||||||
# cert_path = "certs/server.pem"
|
cert_path = "certs/server.pem"
|
||||||
# key_path = "certs/server-key.pem"
|
key_path = "certs/server-key.pem"
|
||||||
# names = ["localhost", "127.0.0.1"] # include the host clients actually use
|
names = ["localhost", "127.0.0.1", "goesseau.eu"] # include the host clients actually use
|
||||||
|
|
||||||
[database]
|
[database]
|
||||||
# DSN for database
|
# DSN for database
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"eslint-config-vuetify": "^4.3.4",
|
"eslint-config-vuetify": "^4.3.4",
|
||||||
"npm-run-all2": "^8.0.4",
|
"npm-run-all2": "^8.0.4",
|
||||||
"sass-embedded": "^1.98.0",
|
"sass-embedded": "^1.98.0",
|
||||||
|
"smol-toml": "^1.9.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"unplugin-fonts": "^1.4.0",
|
"unplugin-fonts": "^1.4.0",
|
||||||
"vite": "^8.0.0",
|
"vite": "^8.0.0",
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import ContextMenu from "@/components/ContextMenu.vue";
|
|||||||
import UserListDrawer from '@/components/UserListDrawer.vue'
|
import UserListDrawer from '@/components/UserListDrawer.vue'
|
||||||
import ServerSettingsDialog from '@/components/server/ServerSettingsDialog.vue'
|
import ServerSettingsDialog from '@/components/server/ServerSettingsDialog.vue'
|
||||||
import {useContextMenu} from '@/composables/useContextMenu'
|
import {useContextMenu} from '@/composables/useContextMenu'
|
||||||
|
import {useVoiceStore} from '@/stores/voice'
|
||||||
|
|
||||||
const serverStore = useServerStore()
|
const serverStore = useServerStore()
|
||||||
|
const voiceStore = useVoiceStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const {openContextMenu} = useContextMenu()
|
const {openContextMenu} = useContextMenu()
|
||||||
@@ -212,6 +214,14 @@ function onServerContextMenu(event: MouseEvent, server: Server) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<router-view/>
|
<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 -->
|
<!-- Menu contextuel global -->
|
||||||
<ContextMenu/>
|
<ContextMenu/>
|
||||||
<v-dialog v-model="showDialog" width="400">
|
<v-dialog v-model="showDialog" width="400">
|
||||||
@@ -284,6 +294,17 @@ function onServerContextMenu(event: MouseEvent, server: Server) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.space-y-4 {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ async function handleRegister() {
|
|||||||
throw new Error(errData.message || 'Échec de l\'inscription')
|
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) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : 'Une erreur est survenue'
|
error.value = err instanceof Error ? err.message : 'Une erreur est survenue'
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import type {Channel} from '@/stores/channel'
|
|||||||
import ChannelPermissionsDialog from '@/components/permissions/ChannelPermissionsDialog.vue'
|
import ChannelPermissionsDialog from '@/components/permissions/ChannelPermissionsDialog.vue'
|
||||||
import {useAuthStore} from '@/stores/auth'
|
import {useAuthStore} from '@/stores/auth'
|
||||||
import ServerSettingsDialog from '@/components/server/ServerSettingsDialog.vue'
|
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<{
|
const props = defineProps<{
|
||||||
serverId: string
|
serverId: string
|
||||||
@@ -30,6 +31,11 @@ const serverStore = useServerStore()
|
|||||||
const {currentTree} = storeToRefs(serverStore)
|
const {currentTree} = storeToRefs(serverStore)
|
||||||
const {openContextMenu} = useContextMenu()
|
const {openContextMenu} = useContextMenu()
|
||||||
const authStore = useAuthStore()
|
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 showPermissionsDialog = ref(false)
|
||||||
const selectedChannel = ref<any | null>(null)
|
const selectedChannel = ref<any | null>(null)
|
||||||
const channelToEdit = ref<Channel | null>(null)
|
const channelToEdit = ref<Channel | null>(null)
|
||||||
@@ -37,6 +43,19 @@ const showEditChannelDialog = ref(false)
|
|||||||
const showServerSettings = ref(false)
|
const showServerSettings = ref(false)
|
||||||
const serverName = computed(() => serverStore.servers.find(server => server.id === props.serverId)?.name || 'Serveur')
|
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) => {
|
const loadServerData = async (targetServerId: string) => {
|
||||||
if (!targetServerId) return
|
if (!targetServerId) return
|
||||||
@@ -55,13 +74,27 @@ const loadServerData = async (targetServerId: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let stopReloadAll: (() => void) | null = null
|
let stopReloadAll: (() => void) | null = null
|
||||||
|
let stopVoicePresence: (() => void) | null = null
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
stopReloadAll = onReloadAll(() => loadServerData(props.serverId))
|
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(() => {
|
onUnmounted(() => {
|
||||||
stopReloadAll?.()
|
stopReloadAll?.()
|
||||||
|
stopVoicePresence?.()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -286,17 +319,17 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template v-for="channel in item.Category[1]" :key="channel.id">
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-for="channel in item.Category[1]"
|
|
||||||
:key="channel.id"
|
|
||||||
:title="channel.name"
|
:title="channel.name"
|
||||||
:to="`/server/${serverId}/channel/${channel.id}`"
|
:active="voiceStore.channelId === channel.id"
|
||||||
:class="{
|
:class="{
|
||||||
'font-weight-bold': (channel.unread_count ?? 0) > 0,
|
'font-weight-bold': (channel.unread_count ?? 0) > 0,
|
||||||
'server-item-drop-before': isDropTarget(channel.id, 'before'),
|
'server-item-drop-before': isDropTarget(channel.id, 'before'),
|
||||||
'server-item-drop-after': isDropTarget(channel.id, 'after'),
|
'server-item-drop-after': isDropTarget(channel.id, 'after'),
|
||||||
}"
|
}"
|
||||||
link
|
link
|
||||||
|
@click="selectChannel(channel)"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
@dragstart="startDragging($event, channel.id, 'channel')"
|
@dragstart="startDragging($event, channel.id, 'channel')"
|
||||||
@dragover="dragOver($event, channel.id, 'channel', item.Category[0].id)"
|
@dragover="dragOver($event, channel.id, 'channel', item.Category[0].id)"
|
||||||
@@ -304,6 +337,7 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
|
|||||||
@dragend="stopDragging"
|
@dragend="stopDragging"
|
||||||
@contextmenu="onChannelContextMenu($event, channel)"
|
@contextmenu="onChannelContextMenu($event, channel)"
|
||||||
>
|
>
|
||||||
|
<template #prepend><v-icon :icon="channel.channel_type === 'voice' ? 'mdi-volume-high' : 'mdi-pound'" /></template>
|
||||||
<template #append>
|
<template #append>
|
||||||
<v-chip
|
<v-chip
|
||||||
v-if="(channel.unread_count ?? 0) > 0"
|
v-if="(channel.unread_count ?? 0) > 0"
|
||||||
@@ -316,20 +350,27 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
|
|||||||
</v-chip>
|
</v-chip>
|
||||||
</template>
|
</template>
|
||||||
</v-list-item>
|
</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>
|
</v-list-group>
|
||||||
|
|
||||||
<!-- Canal orphelin (racine) -->
|
<!-- Canal orphelin (racine) -->
|
||||||
|
<template v-else-if="'Channel' in item">
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-else-if="'Channel' in item"
|
|
||||||
:key="item.Channel.id"
|
:key="item.Channel.id"
|
||||||
:title="item.Channel.name"
|
:title="item.Channel.name"
|
||||||
:to="`/server/${serverId}/channel/${item.Channel.id}`"
|
:active="voiceStore.channelId === item.Channel.id"
|
||||||
:class="{
|
:class="{
|
||||||
'font-weight-bold': (item.Channel.unread_count ?? 0) > 0,
|
'font-weight-bold': (item.Channel.unread_count ?? 0) > 0,
|
||||||
'server-item-drop-before': isDropTarget(item.Channel.id, 'before'),
|
'server-item-drop-before': isDropTarget(item.Channel.id, 'before'),
|
||||||
'server-item-drop-after': isDropTarget(item.Channel.id, 'after'),
|
'server-item-drop-after': isDropTarget(item.Channel.id, 'after'),
|
||||||
}"
|
}"
|
||||||
link
|
link
|
||||||
|
@click="selectChannel(item.Channel)"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
@dragstart="startDragging($event, item.Channel.id, 'channel')"
|
@dragstart="startDragging($event, item.Channel.id, 'channel')"
|
||||||
@dragover="dragOver($event, item.Channel.id, 'channel', null)"
|
@dragover="dragOver($event, item.Channel.id, 'channel', null)"
|
||||||
@@ -337,6 +378,7 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
|
|||||||
@dragend="stopDragging"
|
@dragend="stopDragging"
|
||||||
@contextmenu="onChannelContextMenu($event, item.Channel)"
|
@contextmenu="onChannelContextMenu($event, item.Channel)"
|
||||||
>
|
>
|
||||||
|
<template #prepend><v-icon :icon="item.Channel.channel_type === 'voice' ? 'mdi-volume-high' : 'mdi-pound'" /></template>
|
||||||
<template #append>
|
<template #append>
|
||||||
<v-chip
|
<v-chip
|
||||||
v-if="(item.Channel.unread_count ?? 0) > 0"
|
v-if="(item.Channel.unread_count ?? 0) > 0"
|
||||||
@@ -349,9 +391,16 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
|
|||||||
</v-chip>
|
</v-chip>
|
||||||
</template>
|
</template>
|
||||||
</v-list-item>
|
</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>
|
</template>
|
||||||
</v-list>
|
</v-list>
|
||||||
|
|
||||||
|
|
||||||
</v-navigation-drawer>
|
</v-navigation-drawer>
|
||||||
|
|
||||||
<CreateChannelDialog
|
<CreateChannelDialog
|
||||||
@@ -391,6 +440,11 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.voice-participants {
|
||||||
|
padding-left: 48px;
|
||||||
|
color: rgb(var(--v-theme-on-surface));
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
.server-item-drop-before {
|
.server-item-drop-before {
|
||||||
border-top: 2px solid rgb(var(--v-theme-primary));
|
border-top: 2px solid rgb(var(--v-theme-primary));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ import {defineStore} from 'pinia'
|
|||||||
|
|
||||||
export const useAppStore = defineStore('app', {
|
export const useAppStore = defineStore('app', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
baseurl: 'http://goesseau.eu:8080',
|
baseurl: '',
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {defineStore} from 'pinia'
|
import {defineStore} from 'pinia'
|
||||||
import {useApi} from "@/composables/useApi";
|
import {useApi} from "@/composables/useApi";
|
||||||
|
import {useVoiceStore} from '@/stores/voice'
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string
|
id: string
|
||||||
@@ -55,6 +56,7 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async logout() {
|
async logout() {
|
||||||
|
useVoiceStore().leave()
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
try {
|
try {
|
||||||
await api.post('/auth/logout')
|
await api.post('/auth/logout')
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface Channel {
|
|||||||
id: string
|
id: string
|
||||||
name?: string
|
name?: string
|
||||||
channel_type: string
|
channel_type: string
|
||||||
|
voice_participants?: {id: string; username: string}[]
|
||||||
server_id?: string | null
|
server_id?: string | null
|
||||||
category_id?: string | null
|
category_id?: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import {defineStore} from 'pinia'
|
||||||
|
|
||||||
|
export const useVoiceStore = defineStore('voice', {
|
||||||
|
state: () => ({
|
||||||
|
channelId: null as string | null,
|
||||||
|
serverId: null as string | null,
|
||||||
|
connecting: false,
|
||||||
|
error: null as string | null,
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
async join(serverId: string, channelId: string) {
|
||||||
|
if (this.channelId === channelId) return
|
||||||
|
this.leave()
|
||||||
|
this.connecting = true
|
||||||
|
this.error = null
|
||||||
|
const generation = ++voiceGeneration
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({audio: true})
|
||||||
|
if (generation !== voiceGeneration) {
|
||||||
|
stream.getTracks().forEach(track => track.stop())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
microphone = stream
|
||||||
|
const connection = new RTCPeerConnection()
|
||||||
|
peer = connection
|
||||||
|
stream.getAudioTracks().forEach(track => connection.addTrack(track, stream))
|
||||||
|
const url = new URL(`/ws/rtc/${encodeURIComponent(channelId)}`, window.location.href)
|
||||||
|
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
const ws = new WebSocket(url)
|
||||||
|
socket = ws
|
||||||
|
this.channelId = channelId
|
||||||
|
this.serverId = serverId
|
||||||
|
const pendingCandidates: string[] = []
|
||||||
|
const tracks = new Map<string, MediaStreamTrack>()
|
||||||
|
connection.ontrack = ({track, streams}) => {
|
||||||
|
if (peer !== connection) return
|
||||||
|
const audio = new Audio()
|
||||||
|
audio.autoplay = true
|
||||||
|
audio.srcObject = new MediaStream([track])
|
||||||
|
audioElements.set(track, audio)
|
||||||
|
if (streams[0]) tracks.set(streams[0].id, track)
|
||||||
|
track.onended = () => removeAudio(track)
|
||||||
|
void audio.play().catch(() => { this.error = 'Lecture du son distant impossible.' })
|
||||||
|
}
|
||||||
|
connection.onicecandidate = ({candidate}) => {
|
||||||
|
if (candidate && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({action: 'ice-candidate', candidate: candidate.candidate}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ws.onopen = async () => {
|
||||||
|
try {
|
||||||
|
const offer = await connection.createOffer()
|
||||||
|
await connection.setLocalDescription(offer)
|
||||||
|
if (socket === ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({action: 'sdp-offer', sdp: connection.localDescription?.sdp}))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (socket === ws) { this.error = String(error); this.leave() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let queue = Promise.resolve()
|
||||||
|
ws.onmessage = ({data}) => {
|
||||||
|
queue = queue.then(async () => {
|
||||||
|
if (socket !== ws) return
|
||||||
|
const message = JSON.parse(data)
|
||||||
|
if (message.action === 'answer') {
|
||||||
|
await connection.setRemoteDescription({type: 'answer', sdp: message.sdp})
|
||||||
|
for (const candidate of pendingCandidates) await connection.addIceCandidate({candidate, sdpMLineIndex: 0})
|
||||||
|
pendingCandidates.length = 0
|
||||||
|
} else if (message.action === 'sdp-offer') {
|
||||||
|
await connection.setRemoteDescription({type: 'offer', sdp: message.sdp})
|
||||||
|
const answer = await connection.createAnswer()
|
||||||
|
await connection.setLocalDescription(answer)
|
||||||
|
ws.send(JSON.stringify({action: 'sdp-answer', sdp: connection.localDescription?.sdp}))
|
||||||
|
} else if (message.action === 'ice-candidate') {
|
||||||
|
if (connection.remoteDescription) await connection.addIceCandidate({candidate: message.candidate, sdpMLineIndex: 0})
|
||||||
|
else pendingCandidates.push(message.candidate)
|
||||||
|
} else if (message.action === 'source-left') {
|
||||||
|
const track = tracks.get(message.id)
|
||||||
|
if (track) removeAudio(track)
|
||||||
|
tracks.delete(message.id)
|
||||||
|
} else if (message.action === 'error') {
|
||||||
|
this.error = message.message
|
||||||
|
this.leave()
|
||||||
|
}
|
||||||
|
}).catch(error => { if (socket === ws) { this.error = String(error); this.leave() } })
|
||||||
|
}
|
||||||
|
ws.onerror = () => { if (socket === ws) this.error = 'Connexion vocale impossible.' }
|
||||||
|
ws.onclose = () => { if (socket === ws) this.leave() }
|
||||||
|
} catch (error) {
|
||||||
|
if (generation === voiceGeneration) this.error = `Microphone indisponible : ${String(error)}`
|
||||||
|
} finally {
|
||||||
|
if (generation === voiceGeneration) this.connecting = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
leave() {
|
||||||
|
voiceGeneration++
|
||||||
|
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({action: 'leave'}))
|
||||||
|
socket?.close()
|
||||||
|
socket = null
|
||||||
|
peer?.close()
|
||||||
|
peer = null
|
||||||
|
microphone?.getTracks().forEach(track => track.stop())
|
||||||
|
microphone = null
|
||||||
|
for (const track of audioElements.keys()) removeAudio(track)
|
||||||
|
this.channelId = null
|
||||||
|
this.serverId = null
|
||||||
|
this.connecting = false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
let voiceGeneration = 0
|
||||||
|
let socket: WebSocket | null = null
|
||||||
|
let peer: RTCPeerConnection | null = null
|
||||||
|
let microphone: MediaStream | null = null
|
||||||
|
const audioElements = new Map<MediaStreamTrack, HTMLAudioElement>()
|
||||||
|
|
||||||
|
function removeAudio(track: MediaStreamTrack) {
|
||||||
|
const audio = audioElements.get(track)
|
||||||
|
if (!audio) return
|
||||||
|
audio.pause()
|
||||||
|
audio.srcObject = null
|
||||||
|
audioElements.delete(track)
|
||||||
|
}
|
||||||
@@ -1,8 +1,21 @@
|
|||||||
|
import {readFileSync} from 'node:fs'
|
||||||
|
import {resolve} from 'node:path'
|
||||||
import {fileURLToPath, URL} from 'node:url'
|
import {fileURLToPath, URL} from 'node:url'
|
||||||
import Vue from '@vitejs/plugin-vue'
|
import Vue from '@vitejs/plugin-vue'
|
||||||
import Fonts from 'unplugin-fonts/vite'
|
import Fonts from 'unplugin-fonts/vite'
|
||||||
import {defineConfig} from 'vite'
|
import {defineConfig} from 'vite'
|
||||||
import Vuetify, {transformAssetUrls} from 'vite-plugin-vuetify'
|
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/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -47,15 +60,18 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
|
https,
|
||||||
allowedHosts: ["goesseau.eu"],
|
allowedHosts: ["goesseau.eu"],
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://goesseau.eu:8080',
|
target,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
secure: !tls,
|
||||||
},
|
},
|
||||||
'/ws': {
|
'/ws': {
|
||||||
target: 'ws://goesseau.eu:8080',
|
target,
|
||||||
ws: true,
|
ws: true,
|
||||||
|
secure: !tls,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2519,6 +2519,11 @@ sisteransi@^1.0.5:
|
|||||||
resolved "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz"
|
resolved "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz"
|
||||||
integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==
|
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:
|
"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"
|
version "1.2.1"
|
||||||
resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz"
|
resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz"
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ host = "0.0.0.0"
|
|||||||
tcp_port = 8080
|
tcp_port = 8080
|
||||||
# WebRTC ICE/Media UDP multiplexing port
|
# WebRTC ICE/Media UDP multiplexing port
|
||||||
udp_port = 8080
|
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).
|
# Optional native HTTPS (omit this section to keep plain HTTP behind a proxy).
|
||||||
# [network.tls]
|
# [network.tls]
|
||||||
@@ -110,9 +114,17 @@ pub struct NetworkConfig {
|
|||||||
pub tcp_port: u16,
|
pub tcp_port: u16,
|
||||||
pub udp_port: u16,
|
pub udp_port: u16,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub external_ip: Option<Ipv4Addr>,
|
||||||
|
#[serde(default = "default_stun_servers")]
|
||||||
|
pub stun_servers: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub tls: Option<TlsConfig>,
|
pub tls: Option<TlsConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_stun_servers() -> Vec<String> {
|
||||||
|
vec!["stun:stun.l.google.com:19302".to_string()]
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct TlsConfig {
|
pub struct TlsConfig {
|
||||||
pub cert_path: std::path::PathBuf,
|
pub cert_path: std::path::PathBuf,
|
||||||
@@ -170,3 +182,38 @@ impl AppConfig {
|
|||||||
Self::load()
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+5
-2
@@ -83,12 +83,15 @@ impl App {
|
|||||||
))
|
))
|
||||||
.start(event_bus.clone());
|
.start(event_bus.clone());
|
||||||
|
|
||||||
let rtc = Arc::new(RTCManager::new(
|
let rtc = Arc::new(
|
||||||
|
RTCManager::new(
|
||||||
&config.network,
|
&config.network,
|
||||||
repositories.clone(),
|
repositories.clone(),
|
||||||
services.clone(),
|
services.clone(),
|
||||||
event_bus.clone(),
|
event_bus.clone(),
|
||||||
));
|
)
|
||||||
|
.await,
|
||||||
|
);
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::models::channel::ChannelType;
|
use crate::models::channel::ChannelType;
|
||||||
|
use crate::rtc::VoiceParticipant;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
@@ -49,6 +50,8 @@ pub struct ChannelResponse {
|
|||||||
/// Some(value) : valeur de computed_permission (0 si absente).
|
/// Some(value) : valeur de computed_permission (0 si absente).
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub permission: Option<u64>,
|
pub permission: Option<u64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub voice_participants: Option<Vec<VoiceParticipant>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ pub mod server;
|
|||||||
pub mod server_permission;
|
pub mod server_permission;
|
||||||
pub mod server_tree;
|
pub mod server_tree;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
pub mod voice_presence;
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
use crate::rtc::VoiceParticipant;
|
||||||
|
use serde::Serialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct VoicePresenceEvent {
|
||||||
|
pub server_id: Uuid,
|
||||||
|
pub channel_id: Uuid,
|
||||||
|
pub user: VoiceParticipant,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub joined: bool,
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ pub fn channel_model_to_channel_response_with_permission(
|
|||||||
updated_at: model.updated_at,
|
updated_at: model.updated_at,
|
||||||
unread_count: None,
|
unread_count: None,
|
||||||
permission,
|
permission,
|
||||||
|
voice_participants: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use crate::domain::events::message::{
|
|||||||
};
|
};
|
||||||
use crate::domain::events::server::{ServerCreatedEvent, ServerDeletedEvent, ServerUpdatedEvent};
|
use crate::domain::events::server::{ServerCreatedEvent, ServerDeletedEvent, ServerUpdatedEvent};
|
||||||
use crate::domain::events::server_tree::ServerTreeInvalidatedEvent;
|
use crate::domain::events::server_tree::ServerTreeInvalidatedEvent;
|
||||||
|
use crate::domain::events::voice_presence::VoicePresenceEvent;
|
||||||
use crate::models::{server_user, user};
|
use crate::models::{server_user, user};
|
||||||
use crate::repositories::Repositories;
|
use crate::repositories::Repositories;
|
||||||
use crate::routes::category::mapper::category_model_to_category_response;
|
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>) {
|
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);
|
let router = Arc::clone(self);
|
||||||
event_bus.on_async::<MessageCreatedEvent>(move |event| {
|
event_bus.on_async::<MessageCreatedEvent>(move |event| {
|
||||||
let router = Arc::clone(&router);
|
let router = Arc::clone(&router);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use crate::core::AppState;
|
|||||||
use crate::http::context::CurrentUser;
|
use crate::http::context::CurrentUser;
|
||||||
use crate::http::error::HTTPError;
|
use crate::http::error::HTTPError;
|
||||||
use crate::rtc::ws_entrypoint::ws_entrypoint_handler;
|
use crate::rtc::ws_entrypoint::ws_entrypoint_handler;
|
||||||
|
use crate::models::channel::{self, ChannelType};
|
||||||
use axum::extract::{Path, State, WebSocketUpgrade};
|
use axum::extract::{Path, State, WebSocketUpgrade};
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -12,13 +13,22 @@ pub async fn ws_handler(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
CurrentUser(user): CurrentUser,
|
CurrentUser(user): CurrentUser,
|
||||||
) -> Result<impl IntoResponse, HTTPError> {
|
) -> 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
|
let channel = state
|
||||||
.repositories
|
.repositories
|
||||||
.channel
|
.channel
|
||||||
.get_by_id(channel_id)
|
.get_by_id(channel_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or(HTTPError::NotFound)?;
|
.ok_or(HTTPError::NotFound)?;
|
||||||
// todo : check des permissions
|
if channel.channel_type != ChannelType::Voice { return Err(HTTPError::NotFound); }
|
||||||
// aiinsi que vérifier qu'on est sur un chan vocal OU un chan de message privé
|
let server_id = channel.server_id.ok_or(HTTPError::Forbidden)?;
|
||||||
Ok(ws.on_upgrade(move |socket| ws_entrypoint_handler(socket, state, channel, user)))
|
if state.repositories.server.get_user(server_id, user_id).await?.is_none() {
|
||||||
|
return Err(HTTPError::Forbidden);
|
||||||
|
}
|
||||||
|
Ok(channel)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ use axum::routing::get;
|
|||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new().route("/rtc/{channel_id}", get(handlers::ws_handler))
|
Router::new().route("/rtc/{channel_id}", get(handlers::ws_handler))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -557,5 +557,6 @@ pub async fn get_tree(
|
|||||||
tree.channels,
|
tree.channels,
|
||||||
tree.categories,
|
tree.categories,
|
||||||
tree.unread_counts,
|
tree.unread_counts,
|
||||||
|
&state.rtc.rooms,
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ use crate::domain::dto::server::{
|
|||||||
CreateServerRequest, ServerExplorerItemResponse, ServerResponse, ServerRolePermissionResponse,
|
CreateServerRequest, ServerExplorerItemResponse, ServerResponse, ServerRolePermissionResponse,
|
||||||
ServerTreeResponse, ServerUserPermissionResponse, UpdateServerRequest,
|
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::repositories::types::{CategoryWithPermissions, ChannelWithPermissions};
|
||||||
|
use crate::rtc::VoiceRoomManager;
|
||||||
use crate::routes::category::mapper::category_model_to_category_response_with_permission;
|
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 crate::routes::channel::mapper::channel_model_to_channel_response_with_permission;
|
||||||
use sea_orm::Set;
|
use sea_orm::Set;
|
||||||
@@ -77,6 +78,7 @@ pub fn build_server_tree(
|
|||||||
channels: Vec<ChannelWithPermissions>,
|
channels: Vec<ChannelWithPermissions>,
|
||||||
categories: Vec<CategoryWithPermissions>,
|
categories: Vec<CategoryWithPermissions>,
|
||||||
unread_counts: HashMap<Uuid, u64>,
|
unread_counts: HashMap<Uuid, u64>,
|
||||||
|
rooms: &VoiceRoomManager,
|
||||||
) -> ServerTreeResponse {
|
) -> ServerTreeResponse {
|
||||||
let order_map: HashMap<(Option<Uuid>, Uuid), i64> = orders
|
let order_map: HashMap<(Option<Uuid>, Uuid), i64> = orders
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -135,6 +137,7 @@ pub fn build_server_tree(
|
|||||||
Some(chan_perm_bits),
|
Some(chan_perm_bits),
|
||||||
);
|
);
|
||||||
response.unread_count = Some(*unread_counts.get(&response.id).unwrap_or(&0));
|
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
|
response
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -156,6 +159,7 @@ pub fn build_server_tree(
|
|||||||
Some(chan_perm_bits),
|
Some(chan_perm_bits),
|
||||||
);
|
);
|
||||||
chan_response.unread_count = Some(*unread_counts.get(&chan_response.id).unwrap_or(&0));
|
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((
|
root_items.push((
|
||||||
ServerExplorerItemResponse::Channel(chan_response),
|
ServerExplorerItemResponse::Channel(chan_response),
|
||||||
order_key,
|
order_key,
|
||||||
@@ -250,6 +254,7 @@ mod tests {
|
|||||||
],
|
],
|
||||||
vec![category],
|
vec![category],
|
||||||
HashMap::new(),
|
HashMap::new(),
|
||||||
|
&VoiceRoomManager::new(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let ServerExplorerItemResponse::Category(_, channels) = &response.items[0] else {
|
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[0].name.as_deref(), Some("first"));
|
||||||
assert_eq!(channels[1].name.as_deref(), Some("second"));
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ Les actions JSON sont définies dans `rtc/messages.rs`. Le `channel_id` n'est pa
|
|||||||
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.
|
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`.
|
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
|
### 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.
|
`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.
|
||||||
|
|||||||
+7
-5
@@ -1,5 +1,5 @@
|
|||||||
use super::messages::{VoiceClientMessage, VoiceServerMessage};
|
use super::messages::{VoiceClientMessage, VoiceServerMessage};
|
||||||
use super::{RoomEvent, VoiceRoomManager};
|
use super::{RoomEvent, VoiceParticipant, VoiceRoomManager};
|
||||||
use crate::models::{channel, user};
|
use crate::models::{channel, user};
|
||||||
use axum::extract::ws::Message;
|
use axum::extract::ws::Message;
|
||||||
use rustrtc::media::{self, MediaStreamTrack};
|
use rustrtc::media::{self, MediaStreamTrack};
|
||||||
@@ -99,7 +99,7 @@ impl RTCClient {
|
|||||||
message: error.to_string(),
|
message: error.to_string(),
|
||||||
});
|
});
|
||||||
} else {
|
} 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
|
true
|
||||||
}
|
}
|
||||||
@@ -136,7 +136,9 @@ impl RTCClient {
|
|||||||
info!(channel_id = %self.channel.id, "Audio du salon configuré");
|
info!(channel_id = %self.channel.id, "Audio du salon configuré");
|
||||||
let channel_id = self.channel.id;
|
let channel_id = self.channel.id;
|
||||||
let client_id = self.client_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();
|
let room_manager = self.room_manager.clone();
|
||||||
tokio::spawn(async move { Self::forward_audio(incoming, room_manager, channel_id, client_id).await });
|
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?;
|
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.
|
// recv attend un candidat ; une erreur termine l'écoute.
|
||||||
while let Ok(candidate) = candidates.recv().await {
|
while let Ok(candidate) = candidates.recv().await {
|
||||||
// Le candidat est sérialisé pour être envoyé au navigateur via send_response.
|
// 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 {
|
client.send_response(VoiceServerMessage::IceCandidate {
|
||||||
candidate: format!("candidate:{}", candidate.to_sdp()),
|
candidate: format!("candidate:{}", candidate.to_sdp()),
|
||||||
});
|
});
|
||||||
@@ -350,7 +352,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
channel::Model {
|
channel::Model {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
server_id: None,
|
server_id: Some(Uuid::new_v4()),
|
||||||
category_id: None,
|
category_id: None,
|
||||||
channel_type: channel::ChannelType::Voice,
|
channel_type: channel::ChannelType::Voice,
|
||||||
name: None,
|
name: None,
|
||||||
|
|||||||
+182
-13
@@ -1,18 +1,22 @@
|
|||||||
mod client;
|
mod client;
|
||||||
mod messages;
|
mod messages;
|
||||||
mod metrics;
|
mod metrics;
|
||||||
|
mod stun_diagnostic;
|
||||||
pub mod ws_entrypoint;
|
pub mod ws_entrypoint;
|
||||||
|
|
||||||
use crate::config::NetworkConfig;
|
use crate::config::NetworkConfig;
|
||||||
|
use crate::domain::events::voice_presence::VoicePresenceEvent;
|
||||||
use crate::repositories::Repositories;
|
use crate::repositories::Repositories;
|
||||||
use crate::services::Services;
|
use crate::services::Services;
|
||||||
use event_bus::EventBus;
|
use event_bus::EventBus;
|
||||||
use rustrtc::{PeerConnection, RtcConfiguration, RtcConfigurationBuilder};
|
use rustrtc::{IceServer, PeerConnection, RtcConfiguration, RtcConfigurationBuilder};
|
||||||
use rustrtc::media::MediaSample;
|
use rustrtc::media::MediaSample;
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
use std::net::Ipv4Addr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use serde::Serialize;
|
||||||
// 1. Client crée une RTCPeerConnection
|
// 1. Client crée une RTCPeerConnection
|
||||||
// 2. Client crée une SDP offer
|
// 2. Client crée une SDP offer
|
||||||
// 3. Client envoie l'offer via WebSocket
|
// 3. Client envoie l'offer via WebSocket
|
||||||
@@ -27,6 +31,14 @@ use std::sync::Arc;
|
|||||||
pub struct VoiceRoom {
|
pub struct VoiceRoom {
|
||||||
participants: HashMap<uuid::Uuid, tokio::sync::mpsc::UnboundedSender<RoomEvent>>,
|
participants: HashMap<uuid::Uuid, tokio::sync::mpsc::UnboundedSender<RoomEvent>>,
|
||||||
sources: HashMap<uuid::Uuid, tokio::sync::broadcast::Sender<MediaSample>>,
|
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 {
|
pub enum RoomEvent {
|
||||||
@@ -36,6 +48,7 @@ pub enum RoomEvent {
|
|||||||
|
|
||||||
pub struct VoiceRoomManager {
|
pub struct VoiceRoomManager {
|
||||||
rooms: Mutex<HashMap<uuid::Uuid, VoiceRoom>>,
|
rooms: Mutex<HashMap<uuid::Uuid, VoiceRoom>>,
|
||||||
|
event_bus: Option<Arc<EventBus>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -59,7 +72,7 @@ impl fmt::Debug for RTCManager {
|
|||||||
|
|
||||||
impl VoiceRoom {
|
impl VoiceRoom {
|
||||||
pub fn new() -> Self {
|
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 {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
rooms: Mutex::new(HashMap::new()),
|
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> {
|
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 (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
let mut rooms = self.rooms.lock();
|
let mut rooms = self.rooms.lock();
|
||||||
let room = rooms.entry(channel_id).or_insert_with(VoiceRoom::new);
|
let room = rooms.entry(channel_id).or_insert_with(VoiceRoom::new);
|
||||||
@@ -83,9 +109,25 @@ impl VoiceRoomManager {
|
|||||||
}
|
}
|
||||||
room.sources.insert(client_id, source);
|
room.sources.insert(client_id, source);
|
||||||
room.participants.insert(client_id, tx);
|
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
|
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) {
|
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()) {
|
if let Some(source) = self.rooms.lock().get(&channel_id).and_then(|room| room.sources.get(&client_id).cloned()) {
|
||||||
let _ = source.send(sample);
|
let _ = source.send(sample);
|
||||||
@@ -97,6 +139,12 @@ impl VoiceRoomManager {
|
|||||||
if let Some(room) = rooms.get_mut(&channel_id) {
|
if let Some(room) = rooms.get_mut(&channel_id) {
|
||||||
room.participants.remove(&client_id);
|
room.participants.remove(&client_id);
|
||||||
room.sources.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() {
|
for participant in room.participants.values() {
|
||||||
let _ = participant.send(RoomEvent::Left(client_id));
|
let _ = participant.send(RoomEvent::Left(client_id));
|
||||||
}
|
}
|
||||||
@@ -108,19 +156,53 @@ impl VoiceRoomManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RTCManager {
|
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,
|
network: &NetworkConfig,
|
||||||
repositories: Arc<Repositories>,
|
repositories: Arc<Repositories>,
|
||||||
services: Arc<Services>,
|
services: Arc<Services>,
|
||||||
event_bus: Arc<EventBus>,
|
event_bus: Arc<EventBus>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let builder = RtcConfigurationBuilder::new()
|
let mut discovered_ip = None;
|
||||||
.ice_udp_mux(true)
|
if network.external_ip.is_none() {
|
||||||
.ice_udp_mux_port(network.udp_port)
|
for url in &network.stun_servers {
|
||||||
.bind_ip(network.host.to_string());
|
match stun_diagnostic::probe(url, network.host).await {
|
||||||
let config = builder.build();
|
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 {
|
Self {
|
||||||
config,
|
config,
|
||||||
@@ -132,18 +214,105 @@ impl RTCManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn new_peer_connection(&self) -> Arc<PeerConnection> {
|
pub fn new_peer_connection(&self) -> Arc<PeerConnection> {
|
||||||
let pc = Arc::new(PeerConnection::new(self.config.clone()));
|
Arc::new(PeerConnection::new(self.config.clone()))
|
||||||
// todo : some logging ?
|
}
|
||||||
pc
|
}
|
||||||
|
|
||||||
|
#[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)]
|
#[cfg(test)]
|
||||||
mod room_tests {
|
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 rustrtc::media::{AudioFrame, MediaSample};
|
||||||
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
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]
|
#[tokio::test]
|
||||||
async fn audio_reaches_only_other_clients_in_same_room() {
|
async fn audio_reaches_only_other_clients_in_same_room() {
|
||||||
let rooms = VoiceRoomManager::new();
|
let rooms = VoiceRoomManager::new();
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::net::{UdpSocket, lookup_host};
|
||||||
|
use tokio::time::timeout;
|
||||||
|
|
||||||
|
pub(super) async fn probe(url: &str, bind_ip: Ipv4Addr) -> anyhow::Result<SocketAddr> {
|
||||||
|
let endpoint = url.strip_prefix("stun:").ok_or_else(|| anyhow::anyhow!("URL STUN non prise en charge"))?;
|
||||||
|
let endpoint = endpoint.split('?').next().unwrap_or(endpoint);
|
||||||
|
let mut addresses = lookup_host(endpoint).await?;
|
||||||
|
let server = addresses.find(SocketAddr::is_ipv4).ok_or_else(|| anyhow::anyhow!("aucune adresse IPv4 pour le serveur STUN"))?;
|
||||||
|
let socket = UdpSocket::bind((bind_ip, 0)).await?;
|
||||||
|
let local = socket.local_addr()?;
|
||||||
|
let transaction = *uuid::Uuid::new_v4().as_bytes();
|
||||||
|
let mut request = [0u8; 20];
|
||||||
|
request[..2].copy_from_slice(&0x0001u16.to_be_bytes());
|
||||||
|
request[4..8].copy_from_slice(&0x2112a442u32.to_be_bytes());
|
||||||
|
request[8..20].copy_from_slice(&transaction[..12]);
|
||||||
|
tracing::info!(%server, %local, "Diagnostic STUN IPv4 : requête Binding envoyée (socket distinct d’ICE)");
|
||||||
|
socket.send_to(&request, server).await?;
|
||||||
|
let mut response = [0u8; 1500];
|
||||||
|
let (length, sender) = timeout(Duration::from_secs(5), socket.recv_from(&mut response)).await??;
|
||||||
|
anyhow::ensure!(sender == server, "réponse STUN d'une autre adresse : {sender}");
|
||||||
|
let mapped = mapped_address(&response[..length], &transaction[..12])?;
|
||||||
|
tracing::info!(%server, %local, %mapped, "Diagnostic STUN IPv4 : réponse Binding reçue");
|
||||||
|
Ok(mapped)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mapped_address(response: &[u8], transaction: &[u8]) -> anyhow::Result<SocketAddr> {
|
||||||
|
anyhow::ensure!(response.len() >= 20 && response[0..2] == [0x01, 0x01]
|
||||||
|
&& response[4..8] == 0x2112a442u32.to_be_bytes()
|
||||||
|
&& response[8..20] == *transaction, "réponse STUN invalide ou transaction différente");
|
||||||
|
let size = u16::from_be_bytes([response[2], response[3]]) as usize;
|
||||||
|
anyhow::ensure!(response.len() >= 20 + size, "réponse STUN tronquée");
|
||||||
|
let mut offset = 20;
|
||||||
|
while offset + 4 <= 20 + size {
|
||||||
|
let kind = u16::from_be_bytes([response[offset], response[offset + 1]]);
|
||||||
|
let len = u16::from_be_bytes([response[offset + 2], response[offset + 3]]) as usize;
|
||||||
|
offset += 4;
|
||||||
|
anyhow::ensure!(offset + len <= 20 + size, "attribut STUN tronqué");
|
||||||
|
if kind == 0x0020 && len >= 8 && response[offset + 1] == 1 {
|
||||||
|
let port = u16::from_be_bytes([response[offset + 2], response[offset + 3]]) ^ 0x2112;
|
||||||
|
let cookie = 0x2112a442u32.to_be_bytes();
|
||||||
|
let ip = Ipv4Addr::from(std::array::from_fn(|i| response[offset + 4 + i] ^ cookie[i]));
|
||||||
|
return Ok(SocketAddr::new(IpAddr::V4(ip), port));
|
||||||
|
}
|
||||||
|
offset += (len + 3) & !3;
|
||||||
|
}
|
||||||
|
anyhow::bail!("aucune adresse IPv4 XOR-MAPPED-ADDRESS dans la réponse STUN")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::mapped_address;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decodes_ipv4_and_rejects_unrelated_response() {
|
||||||
|
let transaction = [7; 12];
|
||||||
|
let mut response = vec![0x01, 0x01, 0, 12, 0x21, 0x12, 0xa4, 0x42];
|
||||||
|
response.extend(transaction);
|
||||||
|
response.extend([0, 0x20, 0, 8, 0, 1, 0x3f, 0x52, 0x6a, 0x22, 0xa5, 0x43]);
|
||||||
|
assert_eq!(mapped_address(&response, &transaction).unwrap().to_string(), "75.48.1.1:7744");
|
||||||
|
assert!(mapped_address(&response, &[8; 12]).is_err());
|
||||||
|
assert!(mapped_address(&response[..25], &transaction).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -341,6 +341,8 @@ mod tests {
|
|||||||
hostv6: None,
|
hostv6: None,
|
||||||
tcp_port: 8080,
|
tcp_port: 8080,
|
||||||
udp_port: 9000,
|
udp_port: 9000,
|
||||||
|
external_ip: None,
|
||||||
|
stun_servers: vec![],
|
||||||
tls: None,
|
tls: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user