aether-framework/resources/[aether]/aether-admin/server/kamera.lua
Jerrit Fritzsche e4b82dd685 Aether-Framework: Fachmodule, Admin-Panel und Docker-Datenbank
- aether-medical: Stationen/Belegung, Intensivstation, Blutbank, Fuhrpark,
  Nachrichten/Aushänge, Berichte und Textbausteine (server + NUI)
- aether-admin: vollständiges Server-Admin-Panel als echtes FiveM-Resource,
  an aether-core gekoppelt (Rechte über aether_users.admin_group), mit
  WebRTC-Live-Kameras (Bildschirmfreigabe) und separatem Media-Server (SFU)
- Prototypen admin/ vervollständigt
- SQL-Schema aether_admin.sql
- Docker-Compose mit MariaDB und idempotenter Auto-Migration (schema_migrations)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 18:55:32 +02:00

219 lines
7.8 KiB
Lua

-- =====================================================================
-- 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)