- 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>
219 lines
7.8 KiB
Lua
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)
|