-- ===================================================================== -- aether-admin — Live-Kameras (WebRTC-Orchestrierung) -- -- Das eigentliche Video läuft NICHT über den FiveM-Server. Ein -- separater Media-Server (SFU, siehe media-server/) routet die Streams. -- Dieser Code ist nur der Türsteher und Vermittler: -- -- 1. Prüft, ob der Admin zusehen darf. -- 2. Lässt sich vom Media-Server kurzlebige Zugangstoken ausstellen -- (server-zu-server, abgesichert über ein geteiltes Geheimnis). -- 3. Gibt dem Admin ein Zuschauer-Token und weist die Ziel-Clients an, -- als Publisher in denselben Raum zu senden. -- -- Der FiveM-Server sieht kein Bild. Er entscheidet nur, WER wen sehen -- darf — und protokolliert es. -- ===================================================================== Admin = Admin or {} Admin.Kamera = {} -- Laufende Räume: [raum] = { admin = quelle, ziele = { [serverId]=true }, gestartet } local raeume = {} -- Server-Konfiguration aus Convars (Geheimnis NIE im geteilten Code!) local function Secret() return GetConvar('aether_admin_media_secret', '') end local function HttpBasis() return GetConvar('aether_admin_media_http', 'http://127.0.0.1:8788') end --- Ist die Kamera einsatzbereit (aktiv + Geheimnis gesetzt)? --- @return boolean, string|nil Grund, falls nicht local function Bereit() if not AdminConfig.Media.aktiv then return false, 'Live-Kameras sind in der Konfiguration deaktiviert.' end if Secret() == '' then return false, 'Kein Media-Geheimnis gesetzt (Convar aether_admin_media_secret).' end return true end --- Erzeugt eine zufällige, undurchsichtige Kennung --- @param laenge number --- @return string local function Zufall(laenge) local zeichen = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' local teile = {} for i = 1, laenge do local pos = math.random(#zeichen) teile[i] = zeichen:sub(pos, pos) end return table.concat(teile) end --- Ruft den Media-Server server-zu-server auf und wartet auf die Antwort --- @param pfad string z. B. '/grant' --- @param koerper table --- @return table|nil { status, daten } local function AnMediaServer(pfad, koerper) local p = promise.new() PerformHttpRequest(HttpBasis() .. pfad, function(status, text) local ok, daten = pcall(json.decode, text or '') p:resolve({ status = status, daten = ok and daten or nil }) end, 'POST', json.encode(koerper), { ['Content-Type'] = 'application/json', ['x-admin-secret'] = Secret(), }) return Citizen.Await(p) end --- Lässt ein Zugangstoken für einen Raum ausstellen --- @param raum string --- @param rolle string 'viewer' | 'publisher' --- @param identitaet table { name, lizenz } --- @return string|nil Token local function StelleTokenAus(raum, rolle, identitaet) local token = Zufall(40) local antwort = AnMediaServer('/grant', { token = token, raum = raum, rolle = rolle, name = identitaet.name, gueltigSek = AdminConfig.Media.tokenGueltig, }) if not antwort or antwort.status ~= 200 then return nil end return token end --- Weist einen Raum an, wieder abgebaut zu werden (best effort) --- @param raum string local function RaumAufloesen(raum) local eintrag = raeume[raum] if not eintrag then return end for serverId in pairs(eintrag.ziele) do if GetPlayerName(serverId) then TriggerClientEvent('aether-admin:kamera:stopp', serverId, raum) end end AnMediaServer('/revoke', { raum = raum }) raeume[raum] = nil end -- --------------------------------------------------------------------- -- Aktionen aus dem Panel -- --------------------------------------------------------------------- --- Öffnet eine Kamera-View auf eine oder mehrere Ziel-Server-IDs. --- daten = { ziele = { serverId, ... }, name? } Admin.Kern.Registriere('kamera', 'oeffnen', function(handelnder, daten) local bereit, grund = Bereit() if not bereit then return nil, grund end local ziele = type(daten.ziele) == 'table' and daten.ziele or {} if #ziele == 0 then return nil, 'Keine Ziel-Spieler gewählt.' end local raum = 'r_' .. Zufall(12) -- Zuschauer-Token für den Admin local viewerToken = StelleTokenAus(raum, 'viewer', { name = handelnder.name, lizenz = handelnder.lizenz, }) if not viewerToken then return nil, 'Media-Server nicht erreichbar. Läuft er? (siehe media-server/)' end raeume[raum] = { admin = handelnder.quelle, ziele = {}, gestartet = os.time() } -- Jeden erreichbaren Ziel-Client zum Publizieren auffordern local aufgefordert, namen = 0, {} for _, roh in ipairs(ziele) do local serverId = tonumber(roh) if serverId and GetPlayerName(serverId) then local pubToken = StelleTokenAus(raum, 'publisher', { name = GetPlayerName(serverId), }) if pubToken then raeume[raum].ziele[serverId] = true aufgefordert = aufgefordert + 1 namen[#namen + 1] = GetPlayerName(serverId) TriggerClientEvent('aether-admin:kamera:publiziere', serverId, { raum = raum, url = AdminConfig.Media.url, token = pubToken, }) end end end if aufgefordert == 0 then RaumAufloesen(raum) return nil, 'Kein Ziel ist erreichbar (offline?).' end Admin.Log.Schreibe(handelnder, 'Live-Kamera geöffnet', table.concat(namen, ', '), aufgefordert .. ' Ziel(e), Raum ' .. raum) return { raum = raum, url = AdminConfig.Media.url, token = viewerToken, ziele = aufgefordert, } end) --- Schließt eine Kamera-View Admin.Kern.Registriere('kamera', 'schliessen', function(handelnder, daten) local raum = tostring(daten.raum or '') if not raeume[raum] then return { ok = true } end if raeume[raum].admin ~= handelnder.quelle and not Admin.Auth.Hat(handelnder.quelle, 'owner') then return nil, 'Diese View gehört einem anderen Admin.' end RaumAufloesen(raum) Admin.Log.Schreibe(handelnder, 'Live-Kamera geschlossen', nil, 'Raum ' .. raum) return { ok = true } end) -- --------------------------------------------------------------------- -- Statusmeldung eines Publishers an den beobachtenden Admin -- (klappte die Bildschirmfreigabe oder nur der Platzhalter?) -- --------------------------------------------------------------------- RegisterNetEvent('aether-admin:kamera:status', function(raum, typ, fehler) local quelle = source local eintrag = raeume[tostring(raum or '')] -- Nur echte Publisher dieses Raums dürfen melden if not eintrag or not eintrag.ziele[quelle] then return end if not eintrag.admin or not GetPlayerName(eintrag.admin) then return end TriggerClientEvent('aether-admin:kamera:status', eintrag.admin, { quelle = GetPlayerName(quelle), typ = tostring(typ or ''), fehler = tostring(fehler or ''), }) end) -- --------------------------------------------------------------------- -- Aufräumen -- --------------------------------------------------------------------- -- Räume schließen, deren Admin oder alle Ziele weg sind AddEventHandler('playerDropped', function() local weg = source for raum, eintrag in pairs(raeume) do if eintrag.admin == weg then RaumAufloesen(raum) elseif eintrag.ziele[weg] then eintrag.ziele[weg] = nil local rest = 0 for _ in pairs(eintrag.ziele) do rest = rest + 1 end if rest == 0 then RaumAufloesen(raum) end end end end) AddEventHandler('onResourceStop', function(ressource) if ressource ~= GetCurrentResourceName() then return end for raum in pairs(raeume) do RaumAufloesen(raum) end end)