-- Leitstelle Bridge: HTTP-Schnittstelle zwischen Admin-Panel und ESX Legacy -- Erreichbar unter http://:30120/leitstelle_bridge/ ESX = ESX or exports['es_extended']:getSharedObject() local RESOURCE = GetCurrentResourceName() local TOKEN = GetConvar('leitstelle_token', '') local failed = {} -- ip -> { n = Anzahl, t = Zeitpunkt } if #TOKEN < 24 then print(('^1[%s] Kein oder zu kurzer Schlüssel. Setze in der server.cfg: set leitstelle_token ""^0'):format(RESOURCE)) end ------------------------------------------------------------------------ -- Hilfsfunktionen ------------------------------------------------------------------------ local function header(req, name) name = name:lower() for k, v in pairs(req.headers or {}) do if tostring(k):lower() == name then return v end end return nil end local function safeEquals(a, b) if type(a) ~= 'string' or type(b) ~= 'string' or #a ~= #b then return false end local diff = 0 for i = 1, #a do diff = diff | (a:byte(i) ~ b:byte(i)) end return diff == 0 end local function ipOf(req) local addr = tostring(req.address or '') local v6 = addr:match('^%[(.+)%]:%d+$') if v6 then return v6 end return addr:match('^([^:]+):%d+$') or addr end local function ipAllowed(ip) if not Config.AllowedIPs or #Config.AllowedIPs == 0 then return true end for _, a in ipairs(Config.AllowedIPs) do if a == ip or ('::ffff:' .. a) == ip then return true end end return false end local function reply(res, code, data) res.writeHead(code, { ['Content-Type'] = 'application/json; charset=utf-8' }) res.send(json.encode(data)) end local function round(n) return math.floor((n or 0) * 100 + 0.5) / 100 end local function accountMoney(xPlayer, name) local acc = xPlayer.getAccount(name) return acc and acc.money or 0 end -- Kennungen ohne IP-Adresse (für Bans) function publicIdentifiers(src) local out = {} for _, id in ipairs(GetPlayerIdentifiers(src) or {}) do if not id:find('^ip:') then out[#out + 1] = id end end return out end local function playerInfo(xPlayer) local src = xPlayer.source local ped = GetPlayerPed(src) local c = ped and ped ~= 0 and GetEntityCoords(ped) or vector3(0, 0, 0) local job = xPlayer.getJob() or {} return { id = src, identifier = xPlayer.getIdentifier(), name = xPlayer.getName(), steamName = GetPlayerName(src), group = xPlayer.getGroup(), job = job.name, jobLabel = job.label, grade = job.grade, gradeLabel = job.grade_label, money = accountMoney(xPlayer, 'money'), bank = accountMoney(xPlayer, 'bank'), black_money = accountMoney(xPlayer, 'black_money'), ping = GetPlayerPing(src), health = ped and ped ~= 0 and GetEntityHealth(ped) or 0, coords = { x = round(c.x), y = round(c.y), z = round(c.z) }, identifiers = publicIdentifiers(src), } end local function notify(src, text) TriggerClientEvent('esx:showNotification', src, text) end local function chat(target, text) TriggerClientEvent('chat:addMessage', target, { color = { 249, 168, 0 }, multiline = true, args = { Config.ChatSender, text } }) end ------------------------------------------------------------------------ -- Aktionen ------------------------------------------------------------------------ local Actions = {} -- Spieler auflösen und prüfen, dass die Server-ID noch zur selben Person gehört local function target(body) local src = tonumber(body.id) if not src then return nil, 'Spieler-ID fehlt' end local xPlayer = ESX.GetPlayerFromId(src) if not xPlayer then return nil, 'Spieler ist nicht mehr online' end if body.identifier and body.identifier ~= xPlayer.getIdentifier() then return nil, 'Unter dieser ID ist inzwischen ein anderer Spieler online' end return xPlayer end Actions.kick = function(b) local x, err = target(b); if not x then return false, err end DropPlayer(x.source, ('Vom Admin-Panel getrennt: %s'):format(b.reason or 'kein Grund angegeben')) return true end Actions.revive = function(b) local x, err = target(b); if not x then return false, err end if Config.ReviveFunction then Config.ReviveFunction(x.source) else TriggerClientEvent(Config.ReviveEvent, x.source) end return true end Actions.heal = function(b) local x, err = target(b); if not x then return false, err end TriggerClientEvent(Config.HealEvent, x.source) local ped = GetPlayerPed(x.source) if ped and ped ~= 0 then SetEntityHealth(ped, 200) end return true end Actions.freeze = function(b) local x, err = target(b); if not x then return false, err end local ped = GetPlayerPed(x.source) if not ped or ped == 0 then return false, 'Spielfigur nicht gefunden' end FreezeEntityPosition(ped, b.state == true) notify(x.source, b.state and 'Du wurdest von einem Admin eingefroren.' or 'Du kannst dich wieder bewegen.') return true end Actions.teleport = function(b) local x, err = target(b); if not x then return false, err end local tx, ty, tz = tonumber(b.x), tonumber(b.y), tonumber(b.z) if b.to then local ped2 = GetPlayerPed(tonumber(b.to) or -1) if not ped2 or ped2 == 0 then return false, 'Zielspieler nicht gefunden' end local c = GetEntityCoords(ped2); tx, ty, tz = c.x, c.y, c.z end if not (tx and ty and tz) then return false, 'Koordinaten fehlen' end local ped = GetPlayerPed(x.source) if not ped or ped == 0 then return false, 'Spielfigur nicht gefunden' end local veh = GetVehiclePedIsIn(ped, false) SetEntityCoords((veh and veh ~= 0) and veh or ped, tx + 0.0, ty + 0.0, tz + 0.0, false, false, false, false) return true end Actions.money = function(b) local x, err = target(b); if not x then return false, err end local account, amount, mode = tostring(b.account or ''), math.floor(tonumber(b.amount) or -1), tostring(b.mode or '') if account ~= 'money' and account ~= 'bank' and account ~= 'black_money' then return false, 'Unbekanntes Konto' end if amount < 0 or amount > Config.MaxMoneyAction then return false, ('Betrag muss zwischen 0 und %d liegen'):format(Config.MaxMoneyAction) end if mode == 'add' then x.addAccountMoney(account, amount, 'Admin-Panel') elseif mode == 'remove' then x.removeAccountMoney(account, amount, 'Admin-Panel') elseif mode == 'set' then x.setAccountMoney(account, amount, 'Admin-Panel') else return false, 'Unbekannte Art' end return true, { money = accountMoney(x, 'money'), bank = accountMoney(x, 'bank'), black_money = accountMoney(x, 'black_money') } end Actions.job = function(b) local x, err = target(b); if not x then return false, err end local job, grade = tostring(b.job or ''), tonumber(b.grade) or 0 if not ESX.DoesJobExist(job, grade) then return false, 'Job oder Rang existiert nicht' end x.setJob(job, grade) return true end Actions.item = function(b) local x, err = target(b); if not x then return false, err end local item, count = tostring(b.item or ''), math.floor(tonumber(b.count) or 0) if item == '' or count == 0 then return false, 'Item und Anzahl angeben' end if GetResourceState('ox_inventory') == 'started' then local ok, resp if count > 0 then ok, resp = exports.ox_inventory:AddItem(x.source, item, count) else ok, resp = exports.ox_inventory:RemoveItem(x.source, item, -count) end if not ok then return false, 'ox_inventory: ' .. tostring(resp) end else if count > 0 then x.addInventoryItem(item, count) else x.removeInventoryItem(item, -count) end end return true end Actions.message = function(b) local x, err = target(b); if not x then return false, err end local text = tostring(b.text or ''):sub(1, 500) if text == '' then return false, 'Text fehlt' end chat(x.source, text); notify(x.source, text) return true end Actions.announce = function(b) local text = tostring(b.text or ''):sub(1, 500) if text == '' then return false, 'Text fehlt' end chat(-1, text) return true end -- Spieler speichern, damit Datenbank-Änderungen nicht überschrieben werden Actions.save = function(b) if b.id then local x, err = target(b); if not x then return false, err end local p = promise.new() ESX.SavePlayer(x, function() p:resolve(true) end) Citizen.Await(p) else ESX.SavePlayers() end return true end ------------------------------------------------------------------------ -- Bans: Liste kommt vom Panel, wird lokal gespeichert und beim Verbinden geprüft ------------------------------------------------------------------------ local Bans = {} do local ok, stored = pcall(json.decode, GetResourceKvpString('leitstelle_bans') or '[]') if ok and type(stored) == 'table' then Bans = stored end end Actions.bans = function(b) if type(b.list) ~= 'table' then return false, 'Liste fehlt' end Bans = b.list SetResourceKvp('leitstelle_bans', json.encode(Bans)) -- bereits verbundene gebannte Spieler trennen local kicked = 0 for _, src in ipairs(GetPlayers()) do local ban = findBan(GetPlayerIdentifiers(src)) if ban then DropPlayer(src, banMessage(ban)); kicked = kicked + 1 end end return true, { count = #Bans, kicked = kicked } end function findBan(identifiers) local now = os.time() for _, ban in ipairs(Bans) do local exp = tonumber(ban.expires) or 0 if exp == 0 or exp > now then for _, h in ipairs(ban.hashes or {}) do for _, id in ipairs(identifiers or {}) do local value = id:match('^[%w]+:(.+)$') or id if value == h then return ban end end end end end return nil end function banMessage(ban) local exp = tonumber(ban.expires) or 0 local untilText = exp == 0 and 'dauerhaft' or os.date('%d.%m.%Y %H:%M', exp) return ('Du bist von diesem Server gebannt.\nGrund: %s\nBis: %s\nBan-ID: %s'):format(ban.reason or '-', untilText, ban.id or '-') end AddEventHandler('playerConnecting', function(_, _, deferrals) local src = source deferrals.defer() Wait(0) deferrals.update('Prüfe Bans …') local ban = findBan(GetPlayerIdentifiers(src)) if ban then print(('[%s] Gebannter Spieler abgewiesen (Ban %s)'):format(RESOURCE, tostring(ban.id))) deferrals.done(banMessage(ban)) else local wl = LeitstelleWhitelistCheck and LeitstelleWhitelistCheck(src) or nil if wl then deferrals.done(wl) else deferrals.done() end end end) ------------------------------------------------------------------------ -- Ereignisse für das Panel (Tickets, Logs). Das Panel holt sie über /events ab. ------------------------------------------------------------------------ local EventSeq, EventBuf, EventMax = 0, {}, 5000 local BootId = tostring(os.time()) .. tostring(math.random(1000, 9999)) local function pushEvent(ev) EventSeq = EventSeq + 1 ev.seq = EventSeq ev.t = os.time() EventBuf[#EventBuf + 1] = ev if #EventBuf > EventMax then table.remove(EventBuf, 1) end end local function who(src) src = tonumber(src) if not src or src <= 0 then return nil end local xPlayer = ESX.GetPlayerFromId(src) local ped = GetPlayerPed(src) local c = ped and ped ~= 0 and GetEntityCoords(ped) or nil local lic local discord for _, id in ipairs(GetPlayerIdentifiers(src) or {}) do if id:find('^license:') and not lic then lic = id end if id:find('^discord:') then discord = id end end return { id = src, identifier = xPlayer and xPlayer.getIdentifier() or nil, license = lic, discord = discord, name = xPlayer and xPlayer.getName() or GetPlayerName(src), steamName = GetPlayerName(src), job = xPlayer and (xPlayer.getJob() or {}).name or nil, coords = c and { x = round(c.x), y = round(c.y), z = round(c.z) } or nil, } end -- Tickets: /report RegisterCommand(Config.ReportCommand or 'report', function(src, args, raw) if src == 0 then return end local text = (raw or ''):gsub('^%S+%s*', ''):sub(1, 500) if text == '' then chat(src, ('Bitte so benutzen: /%s '):format(Config.ReportCommand or 'report')) return end pushEvent({ type = 'ticket', player = who(src), text = text }) chat(src, 'Deine Nachricht ist beim Team angekommen. Wir melden uns.') end, false) -- Spieler-Portal: einmaliger Anmeldecode, 10 Minuten gültig local lastPortal = {} RegisterCommand(Config.PortalCommand or 'portal', function(src) if src == 0 then return end if lastPortal[src] and os.time() - lastPortal[src] < 30 then chat(src, 'Bitte warte kurz, bevor du einen neuen Code anforderst.'); return end lastPortal[src] = os.time() local code = ('%06d'):format(math.random(0, 999999)) pushEvent({ type = 'portal_code', player = who(src), code = code, expires = os.time() + 600 }) chat(src, ('Dein Portal-Code: %s (10 Minuten gültig) – %s'):format(code, Config.PortalUrl or '')) end, false) if Config.Logs and Config.Logs.joins then AddEventHandler('esx:playerLoaded', function(src) pushEvent({ type = 'join', player = who(src) }) end) AddEventHandler('playerDropped', function(reason) local p = who(source) if p then pushEvent({ type = 'leave', player = p, reason = tostring(reason or ''):sub(1, 200) }) end end) end if Config.Logs and Config.Logs.deaths then RegisterNetEvent('esx:onPlayerDeath', function(data) local src = source data = type(data) == 'table' and data or {} local killer = (data.killedByPlayer and tonumber(data.killerServerId)) and who(data.killerServerId) or nil pushEvent({ type = 'death', player = who(src), killer = killer, cause = tostring(data.deathCause or ''):sub(1, 40) }) end) end if Config.Logs and Config.Logs.chat then AddEventHandler('chatMessage', function(src, author, text) pushEvent({ type = 'chat', player = who(src), text = tostring(text or ''):sub(1, 300) }) end) end if Config.Logs and Config.Logs.money then local last = {} CreateThread(function() while true do Wait(30000) local seen = {} for _, xPlayer in pairs(ESX.GetExtendedPlayers()) do local id = xPlayer.getIdentifier(); seen[id] = true local cur = {} for _, acc in ipairs(xPlayer.getAccounts()) do cur[acc.name] = acc.money end local prev = last[id] if prev then for name, money in pairs(cur) do local before = prev[name] or 0 if math.abs(money - before) >= (Config.MoneyLogMin or 0) then pushEvent({ type = 'money', player = who(xPlayer.source), account = name, from = before, to = money, delta = money - before }) end end end last[id] = cur end for id in pairs(last) do if not seen[id] then last[id] = nil end end end end) end if Config.Logs and Config.Logs.items then CreateThread(function() Wait(2000) if GetResourceState('ox_inventory') ~= 'started' then return end local ok = pcall(function() exports.ox_inventory:registerHook('swapItems', function(payload) if payload and payload.fromInventory ~= payload.toInventory and payload.fromSlot and type(payload.fromSlot) == 'table' then pushEvent({ type = 'item', player = who(payload.source), item = payload.fromSlot.name, count = payload.count or payload.fromSlot.count, from = tostring(payload.fromInventory), to = tostring(payload.toInventory), fromType = payload.fromType, toType = payload.toType, }) end return true end, {}) end) if not ok then print(('[%s] ox_inventory-Hook nicht verfügbar, Item-Logs sind aus'):format(RESOURCE)) end end) end ------------------------------------------------------------------------ -- Whitelist: Liste kommt vom Panel (Bewerbungen), wird lokal gespeichert ------------------------------------------------------------------------ local Whitelist = { enabled = false, hashes = {} } do local ok, stored = pcall(json.decode, GetResourceKvpString('leitstelle_whitelist') or '{}') if ok and type(stored) == 'table' then Whitelist.enabled = stored.enabled == true; Whitelist.hashes = stored.hashes or {} end end local function wlSet() local set = {} for _, h in ipairs(Whitelist.hashes) do set[h] = true end return set end local WlSet = wlSet() Actions.whitelist = function(b) Whitelist.enabled = b.enabled == true Whitelist.hashes = type(b.list) == 'table' and b.list or {} WlSet = wlSet() SetResourceKvp('leitstelle_whitelist', json.encode(Whitelist)) return true, { enabled = Whitelist.enabled, count = #Whitelist.hashes } end function LeitstelleWhitelistCheck(src) if not Whitelist.enabled then return nil end local lic, discord for _, id in ipairs(GetPlayerIdentifiers(src) or {}) do local v = id:match('^[%w]+:(.+)$') if v and WlSet[v] then return nil end if id:find('^license:') and not lic then lic = id end if id:find('^discord:') then discord = id end end if not lic then return 'Dein Rockstar-Account konnte nicht erkannt werden.' end local code = lic:sub(-8):upper() pushEvent({ type = 'wl_denied', player = { name = GetPlayerName(src), license = lic, discord = discord }, code = code }) return ('Dieser Server hat eine Whitelist und du bist noch nicht freigeschaltet.\n\nDein Bewerbungs-Code: %s\n\n%s'):format(code, Config.WhitelistMessage or '') end ------------------------------------------------------------------------ -- Resources verwalten ------------------------------------------------------------------------ local function resourceList() local out = {} for i = 0, GetNumResources() - 1 do local name = GetResourceByFindIndex(i) if name then out[#out + 1] = { name = name, state = GetResourceState(name), version = GetResourceMetadata(name, 'version', 0), description = GetResourceMetadata(name, 'description', 0), author = GetResourceMetadata(name, 'author', 0) } end end table.sort(out, function(a, b) return a.name < b.name end) return out end Actions.resource = function(b) local name, op = tostring(b.name or ''), tostring(b.op or '') if op == 'refresh' then ExecuteCommand('refresh'); return true end if name == '' or GetResourceState(name) == 'missing' then return false, 'Resource nicht gefunden' end if name == RESOURCE then return false, 'Die Bridge kann sich nicht selbst stoppen oder neu starten' end if op == 'start' then StartResource(name) elseif op == 'stop' then StopResource(name) elseif op == 'restart' then StopResource(name); Wait(200); StartResource(name) else return false, 'Unbekannte Aktion' end Wait(300) return true, { state = GetResourceState(name) } end ------------------------------------------------------------------------ -- Fahrzeuge in der Welt ------------------------------------------------------------------------ local function vehicleList() local drivers = {} for _, src in ipairs(GetPlayers()) do local ped = GetPlayerPed(src) if ped and ped ~= 0 then local v = GetVehiclePedIsIn(ped, false); if v and v ~= 0 then drivers[v] = drivers[v] or tonumber(src) end end end local out = {} for _, veh in ipairs(GetAllVehicles()) do if DoesEntityExist(veh) then local c = GetEntityCoords(veh) out[#out + 1] = { netId = NetworkGetNetworkIdFromEntity(veh), plate = (GetVehicleNumberPlateText(veh) or ''):gsub('^%s+', ''):gsub('%s+$', ''), model = GetEntityModel(veh), engine = math.floor(GetVehicleEngineHealth(veh)), body = math.floor(GetVehicleBodyHealth(veh)), driver = drivers[veh], coords = { x = round(c.x), y = round(c.y), z = round(c.z) }, } if #out >= 1000 then break end end end return out end Actions.vehicle = function(b) local veh = NetworkGetEntityFromNetworkId(tonumber(b.netId) or -1) if not veh or veh == 0 or not DoesEntityExist(veh) then return false, 'Fahrzeug nicht (mehr) vorhanden' end local op = tostring(b.op or '') if op == 'delete' then for seat = -1, 6 do local ped = GetPedInVehicleSeat(veh, seat); if ped and ped ~= 0 then TaskLeaveVehicle(ped, veh, 16) end end Wait(300); DeleteEntity(veh) elseif op == 'repair' then local owner = NetworkGetEntityOwner(veh) if not owner or owner <= 0 then return false, 'Fahrzeug gehört keinem Spieler (niemand in der Nähe)' end TriggerClientEvent('leitstelle_bridge:repair', owner, NetworkGetNetworkIdFromEntity(veh)) elseif op == 'freeze' then FreezeEntityPosition(veh, b.state == true) else return false, 'Unbekannte Aktion' end return true end -- Neustart: alle speichern, trennen, Server beenden (txAdmin startet ihn neu) Actions.restart = function(b) local reason = tostring(b.reason or 'Geplanter Serverneustart'):sub(1, 200) ESX.SavePlayers() SetTimeout(3000, function() for _, src in ipairs(GetPlayers()) do DropPlayer(src, reason) end SetTimeout(2000, function() ExecuteCommand('quit "' .. reason:gsub('"', "'") .. '"') end) end) return true end ------------------------------------------------------------------------ -- HTTP ------------------------------------------------------------------------ SetHttpHandler(function(req, res) local ip = ipOf(req) if not ipAllowed(ip) then return reply(res, 403, { error = 'IP nicht freigegeben: ' .. ip }) end local f = failed[ip] if f and f.n >= 10 and os.time() - f.t < 900 then return reply(res, 429, { error = 'Zu viele Fehlversuche' }) end local auth = header(req, 'authorization') or '' local given = auth:match('^Bearer%s+(.+)$') or '' if #TOKEN < 24 or not safeEquals(given, TOKEN) then failed[ip] = { n = ((f and os.time() - f.t < 900) and f.n or 0) + 1, t = os.time() } return reply(res, 401, { error = 'Schlüssel falsch' }) end failed[ip] = nil local path = (req.path or '/'):gsub('%?.*$', '') if req.method == 'GET' and path == '/status' then return reply(res, 200, { ok = true, resource = RESOURCE, players = #GetPlayers(), maxPlayers = GetConvarInt('sv_maxclients', 0), server = GetConvar('sv_hostname', ''), inventory = GetResourceState('ox_inventory') == 'started' and 'ox_inventory' or 'esx' }) end if req.method == 'GET' and path == '/events' then local since = tonumber((req.path or ''):match('since=(%d+)')) or 0 local out = {} for _, ev in ipairs(EventBuf) do if ev.seq > since then out[#out + 1] = ev; if #out >= 1000 then break end end end return reply(res, 200, { boot = BootId, seq = EventSeq, events = out }) end if req.method == 'GET' and path == '/resources' then return reply(res, 200, { resources = resourceList() }) end if req.method == 'GET' and path == '/vehicles' then return reply(res, 200, { vehicles = vehicleList() }) end if req.method == 'GET' and path == '/players' then local list = {} for _, xPlayer in pairs(ESX.GetExtendedPlayers()) do list[#list + 1] = playerInfo(xPlayer) end table.sort(list, function(a, b) return a.id < b.id end) return reply(res, 200, { players = list }) end if req.method == 'POST' and path == '/action' then req.setDataHandler(function(raw) Citizen.CreateThread(function() local ok, body = pcall(json.decode, raw or '') if not ok or type(body) ~= 'table' then return reply(res, 400, { error = 'Ungültige Anfrage' }) end local fn = Actions[body.action or ''] if not fn then return reply(res, 400, { error = 'Unbekannte Aktion' }) end local okCall, success, extra = pcall(fn, body) if not okCall then return reply(res, 500, { error = 'Fehler: ' .. tostring(success) }) end if not success then return reply(res, 409, { error = extra or 'Aktion fehlgeschlagen' }) end if body.action ~= 'bans' then print(('[%s] Panel-Aktion %s (Admin: %s, Ziel: %s)'):format(RESOURCE, body.action, tostring(body.by or '?'), tostring(body.id or 'alle'))) end return reply(res, 200, { ok = true, result = extra }) end) end) return end return reply(res, 404, { error = 'Unbekannter Pfad' }) end) print(('^2[%s] bereit – Panel-URL: http://:%s/%s^0'):format(RESOURCE, GetConvar('netPort', '30120'), RESOURCE))