commit 849663708abcacdcf25ff7daecaecdf5063ed5b6 Author: Bxio Date: Mon Aug 3 10:57:14 2026 +0000 Add safe stash resource with security hardening Adds distance validation on placement, PIN attempt rate limiting, proximity checks on open/pickup, and prevents item loss on pickup when the safe still has contents. diff --git a/client.lua b/client.lua new file mode 100644 index 0000000..c5fe48a --- /dev/null +++ b/client.lua @@ -0,0 +1,297 @@ +local ox_target = exports.ox_target +local ox_inventory = exports.ox_inventory + +local spawnedSafes = {} + +-- Debug helper +local function dbg(...) + if Config and Config.Debug then + print('[0ixb-stashes CLIENT]', ...) + end +end + +------------------------------------------------------- +-- CHECAR COLISÃO +------------------------------------------------------- +local function isPlaceable(coords, radius) + local ped = PlayerPedId() + + for _, obj in ipairs(GetGamePool('CObject')) do + if DoesEntityExist(obj) and #(GetEntityCoords(obj) - coords) < radius then + return false + end + end + + for _, veh in ipairs(GetGamePool('CVehicle')) do + if DoesEntityExist(veh) and #(GetEntityCoords(veh) - coords) < radius then + return false + end + end + + for _, p in ipairs(GetGamePool('CPed')) do + if p ~= ped and DoesEntityExist(p) and #(GetEntityCoords(p) - coords) < radius then + return false + end + end + + return true +end + +------------------------------------------------------- +-- SPAWN SAFE +------------------------------------------------------- +RegisterNetEvent('safe:spawn', function(safeData) + if not safeData or not safeData.type or not Config.SafeTypes[safeData.type] then + dbg('safe:spawn invalid safeData', safeData) + return + end + + local model = Config.SafeTypes[safeData.type].prop + RequestModel(model) + while not HasModelLoaded(model) do Wait(0) end + + local obj = CreateObject( + model, + safeData.coords.x, + safeData.coords.y, + safeData.coords.z, + false, false, false + ) + + -------------------------------------------------------------------- + -- APLICAR ROTAÇÃO + -- PRIORIDADE: + -- 1) rx, ry, rz (se existir) + -- 2) heading antigo (fallback) + -------------------------------------------------------------------- + if safeData.coords.rx and safeData.coords.ry and safeData.coords.rz then + -- rotação completa (verdadeira 3D) + SetEntityRotation(obj, safeData.coords.rx, safeData.coords.ry, safeData.coords.rz, 2, true) + else + -- fallback (como antes) + SetEntityHeading(obj, safeData.coords.h or 0.0) + end + + FreezeEntityPosition(obj, true) + + spawnedSafes[safeData.id] = obj + dbg('Spawning safe id', safeData.id, 'type', safeData.type, 'entity', obj) + + -------------------------------------------------------------------- + -- TARGET (mantido exatamente como estava) + -------------------------------------------------------------------- + ox_target:addLocalEntity(obj, { + { + label = "» Abrir SafeBox", + icon = "fa-solid fa-lock", + onSelect = function() + dbg('Player clicou Abrir Safe', safeData.id) + local pin = lib.inputDialog('PIN do Safe', { 'Digite o PIN' }) + if pin and pin[1] then + dbg('Player digitou PIN', pin[1]) + TriggerServerEvent('safe:open', safeData.id, tostring(pin[1])) + else + dbg('Player cancelou input do PIN') + end + end + }, + { + label = "» Eliminar SafeBox", + icon = "fa-solid fa-triangle-exclamation", + onSelect = function() + dbg('Player pediu Mostrar Alerta', safeData.id) + local result = lib.alertDialog({ + header = 'CONFIRMAR REMOÇÃO DO SAFE', + content = 'Antes de prosseguir, certifique-se de que o safe está totalmente vazio.', + centered = true, + cancel = true, + size = 'lg' + }) + + if result == 'confirm' then + lib.notify({ type = 'success', description = 'Safe recolhido com sucesso!' }) + TriggerServerEvent('safe:pickup', safeData.id) + else + lib.notify({ type = 'inform', description = 'Operação cancelada.' }) + end + end + } + }) +end) + + +------------------------------------------------------- +-- DESPAWN SAFE +------------------------------------------------------- +RegisterNetEvent('safe:despawn', function(id) + local ent = spawnedSafes[id] + if ent and DoesEntityExist(ent) then + pcall(function() ox_target:removeLocalEntity(ent) end) + DeleteObject(ent) + end + spawnedSafes[id] = nil +end) + +------------------------------------------------------- +-- ABRIR INVENTÁRIO +------------------------------------------------------- +RegisterNetEvent('safe:openInventory', function(data) + if not data or not data.id then return end + + ox_inventory:openInventory('stash', { + id = "safe_" .. tostring(data.id), + slots = data.slots, + weight = data.weight + }) +end) + +------------------------------------------------------- +-- FUNÇÕES DE CAMERA +------------------------------------------------------- +local function RotToDir(rot) + local z = math.rad(rot.z) + local x = math.rad(rot.x) + local num = math.abs(math.cos(x)) + return vector3(-math.sin(z) * num, math.cos(z) * num, math.sin(x)) +end + +local function RayCastFromCam(distance) + local camRot = GetGameplayCamRot(2) + local camCoord = GetGameplayCamCoord() + local dir = RotToDir(camRot) + local dest = camCoord + dir * distance + local ray = StartShapeTestRay(camCoord.x, camCoord.y, camCoord.z, dest.x, dest.y, dest.z, -1, -1, 1) + local _, hit, endCoords, surfaceNormal, entity = GetShapeTestResult(ray) + return hit, endCoords, surfaceNormal, entity, camCoord, dir +end + +------------------------------------------------------- +-- PLACEMENT / GIZMO +------------------------------------------------------- +RegisterNetEvent('safe:use', function(type) + if not Config.SafeTypes[type] then + exports.ox_lib:notify({ type='error', description = 'Tipo de safe inválido.' }) + return + end + + local ped = PlayerPedId() + local model = Config.SafeTypes[type].prop + + RequestModel(model) + while not HasModelLoaded(model) do Wait(0) end + + local startPos = GetOffsetFromEntityInWorldCoords(ped, 0.0, 1.5, 0.0) + local startHeading = GetEntityHeading(ped) + + local obj = CreateObject(model, startPos.x, startPos.y, startPos.z, false, false, false) + SetEntityHeading(obj, startHeading) + FreezeEntityPosition(obj, true) + + SetNuiFocus(false, false) + Wait(50) + + -- abrir gizmo + local ok, result = pcall(function() + return exports.object_gizmo:useGizmo(obj) + end) + + if not ok or not result then + exports.ox_lib:notify({ type='error', description = 'Erro ao abrir gizmo.' }) + if DoesEntityExist(obj) then DeleteObject(obj) end + return + end + + -- posição final + local pos = result.position or GetEntityCoords(obj) + + -- rotação TOTAL + local rot = result.rotation or GetEntityRotation(obj, 2) + + local heading = result.heading or rot.z + + ------------------------------------------------------- + -- PEDIR PIN + ------------------------------------------------------- + SetNuiFocus(false, false) + local pin = exports.ox_lib:inputDialog('PIN do Safe', {'Digite um PIN'}) + SetNuiFocus(false, false) + + if not pin or not pin[1] then + SetNuiFocus(false, false) + SetNuiFocusKeepInput(false) + SetCursorLocation(0.5, 0.5) + + exports.ox_lib:notify({ type='error', description = 'Você cancelou a operação.' }) + + if DoesEntityExist(obj) then DeleteObject(obj) end + return + end + + -- ENVIAR POSIÇÃO + ROTAÇÃO COMPLETA PARA O SERVIDOR + TriggerServerEvent('safe:place', type, { + x = pos.x, + y = pos.y, + z = pos.z, + h = heading, + rx = rot.x, + ry = rot.y, + rz = rot.z + }, tostring(pin[1])) + + if DoesEntityExist(obj) then DeleteObject(obj) end +end) + + +------------------------------------------------------- +-- COMANDO DEBUG +------------------------------------------------------- +RegisterCommand('colocarsafe', function(_, args) + local typ = args[1] + if not typ or not Config.SafeTypes[typ] then + exports.ox_lib:notify({ type='error', description = 'Uso: /colocarsafe [small|medium|large]' }) + return + end + TriggerEvent('safe:use', typ) +end) + +------------------------------------------------------- +-- ITEM USO +------------------------------------------------------- +for type, data in pairs(Config.SafeTypes) do + if data.item then + pcall(function() + ox_inventory:useItem(data.item, function() + dbg('Player usou item', data.item) + TriggerEvent('safe:use', type) + end) + end) + end +end + +------------------------------------------------------- +-- CLEANUP +------------------------------------------------------- +Citizen.CreateThread(function() + while true do + Wait(1000) + for id, ent in pairs(spawnedSafes) do + if ent and not DoesEntityExist(ent) then + spawnedSafes[id] = nil + end + end + end +end) + +AddEventHandler('onClientResourceStart', function(resourceName) + if resourceName ~= GetCurrentResourceName() then return end + + for id, ent in pairs(spawnedSafes) do + if ent and DoesEntityExist(ent) then + pcall(function() ox_target:removeLocalEntity(ent) end) + DeleteObject(ent) + end + spawnedSafes[id] = nil + end + + TriggerServerEvent('safe:requestSync') +end) diff --git a/config.lua b/config.lua new file mode 100644 index 0000000..baa44bb --- /dev/null +++ b/config.lua @@ -0,0 +1,61 @@ +Config = {} + +-- Habilita prints de debug +Config.Debug = true + +-- Tempo de animação ao colocar o safe (ms) +Config.PlaceTime = 2500 + +-- Segurança do PIN +Config.PinMaxAttempts = 3 -- tentativas erradas de PIN antes de bloquear o jogador +Config.PinBlockTime = 30 -- segundos de bloqueio após exceder as tentativas + +-- Distância máxima (metros) para abrir ou recolher um safe +Config.SafeInteractDistance = 3.0 + +-- Sistema de update via Gitea (ativo apenas quando Config.Debug = true) +Config.Update = { + giteaUrl = "https://gitea.zol.oixb.run", -- URL base do seu Gitea + owner = "oixb.run", -- dono/organização do repositório + repo = "0ixb-stashes", -- nome do repositório + branch = "main", -- branch a ser usada + checkCommand = "oixbupdate", -- comando para checar se há update + upgradeCommand = "oixbupgrade", -- comando para baixar e aplicar o update + files = { "server.lua", "client.lua", "config.lua", "fxmanifest.lua" } +} + +-- Tipos de safes +Config.SafeTypes = { + small = { + label = "Small Safe", + prop = "prop_ld_int_safe_01", -- modelo de prop do GTA + item = "safe_small", -- item que o jogador precisa para colocar + slots = 30, + weight = 100000, + radius = 1.2 + }, + medium = { + label = "Medium Safe", + prop = "p_v_43_safe_s", + item = "safe_medium", + slots = 50, + weight = 250000, + radius = 1.5 + }, + large = { + label = "Large Safe", + prop = "xm3_prop_xm3_safe_01a", + item = "safe_large", + slots = 100, + weight = 500000, + radius = 2.0 + }, + dev = { + label = "Dev Safe", + prop = "m24_1_prop_m24_1_carrier_cargo_02a", -- modelo de prop do GTA + item = "safe_dev", -- item que o jogador precisa para colocar + slots = 5, + weight = 1000000, + radius = 1.0 + } +} diff --git a/fxmanifest.lua b/fxmanifest.lua new file mode 100644 index 0000000..e1375a9 --- /dev/null +++ b/fxmanifest.lua @@ -0,0 +1,37 @@ +fx_version 'cerulean' +game 'gta5' + +author 'SeuNome' +description 'Sistema de safes com ox_inventory e ox_target' +version '1.1.0' + +-- Dependências +dependencies { + 'ox_inventory', + 'ox_target', + 'ox_lib', + 'oxmysql' +} + +-- Shared / Config +shared_script '@ox_lib/init.lua' +shared_script 'config.lua' + + +-- Scripts do servidor +server_scripts { + '@oxmysql/lib/MySQL.lua', -- garante que o oxmysql esteja carregado + 'server.lua' +} + +-- Scripts do cliente +client_scripts { + 'client.lua' +} + +-- Comandos do console / debug +escrow_ignore { + 'config.lua', + 'client.lua', + 'server.lua' +} diff --git a/server.lua b/server.lua new file mode 100644 index 0000000..b7e91b2 --- /dev/null +++ b/server.lua @@ -0,0 +1,351 @@ +local ox_inventory = exports.ox_inventory +local safes = {} + +-- Distância máxima (metros) entre o jogador e a posição enviada em safe:place +local PLACE_MAX_DISTANCE = 5.0 + +-- Tentativas de PIN por jogador: { [src] = { count = 0, blockedUntil = 0 } } +local pinAttempts = {} + +-- Debug helper +local function dbg(...) + if Config and Config.Debug then + print('[0ixb-stashes SERVER]', ...) + end +end + +-- Verifica se o jogador está fisicamente perto do safe +local function isNearSafe(src, safe, maxDist) + local ped = GetPlayerPed(src) + local playerCoords = GetEntityCoords(ped) + local safeCoords = vector3(safe.coords.x, safe.coords.y, safe.coords.z) + return #(playerCoords - safeCoords) <= (maxDist or Config.SafeInteractDistance) +end + +------------------------------------------------------- +-- CARREGAR SAFES DO DB AO INICIAR O RECURSO +------------------------------------------------------- +AddEventHandler('onResourceStart', function(resourceName) + if resourceName ~= GetCurrentResourceName() then return end + + dbg('Resource started, carregando safes do DB...') + exports.oxmysql:fetch('SELECT * FROM safes', {}, function(result) + + for _, v in ipairs(result) do + + -- coords agora pode conter: x,y,z,h OU x,y,z,rx,ry,rz + local decoded = json.decode(v.coords) + + local safeData = { + id = v.id, + owner = v.owner, + type = v.type, + coords = { + x = decoded.x, + y = decoded.y, + z = decoded.z, + h = decoded.h, -- fallback caso seja safe antigo + rx = decoded.rx, -- rotação completa (se existir) + ry = decoded.ry, + rz = decoded.rz + }, + pin = v.pin + } + + safes[v.id] = safeData + + -- Registrar stash no Ox + local cfg = Config.SafeTypes[safeData.type] + if cfg then + exports.ox_inventory:RegisterStash( + "safe_"..safeData.id, + cfg.label, + cfg.slots, + cfg.weight, + safeData.owner + ) + end + end + + -- Spawnar para quem está online + for _, pid in ipairs(GetPlayers()) do + for _, data in pairs(safes) do + TriggerClientEvent('safe:spawn', tonumber(pid), data) + end + end + + dbg('Safes carregados e spawnados:', #result) + end) +end) + + + +------------------------------------------------------- +-- SINCRONIZAR SAFE POR PEDIDO +------------------------------------------------------- +RegisterNetEvent('safe:requestSync', function() + local src = source + dbg('Player', src, 'pedindo sync de safes') + + for _, data in pairs(safes) do + TriggerClientEvent('safe:spawn', src, data) + end +end) + + + +------------------------------------------------------- +-- COLOCAR SAFE (AGORA COM ROTAÇÃO COMPLETA) +------------------------------------------------------- +RegisterNetEvent('safe:place', function(type, coords, pin) + local src = source + local cfg = Config.SafeTypes[type] + if not cfg then return end + + dbg('safe:place chamado por', src, 'type', type) + + -- validar distância (evita coords arbitrárias vindas de um cliente modificado) + local ped = GetPlayerPed(src) + local playerCoords = GetEntityCoords(ped) + local placeCoords = vector3(coords.x, coords.y, coords.z) + + if #(playerCoords - placeCoords) > PLACE_MAX_DISTANCE then + dbg('safe:place REJEITADO - distância suspeita', src, #(playerCoords - placeCoords)) + return TriggerClientEvent('ox_lib:notify', src, {type='error', description='Posição inválida.'}) + end + + -- remover item + local removed = exports.ox_inventory:RemoveItem(src, cfg.item, 1) + if not removed then + return TriggerClientEvent('ox_lib:notify', src, {type='error', description='Você não tem esse item.'}) + end + + -- obter identifier + local identifier = GetPlayerIdentifier(src, 1) + + ----------------------------------------------------------------- + -- PREPARA COORDENADAS PARA SALVAR NO DB + -- AGORA SEMPRE SALVA: x,y,z,h,rx,ry,rz + ----------------------------------------------------------------- + local coordsToSave = json.encode({ + x = coords.x, + y = coords.y, + z = coords.z, + h = coords.h, -- fallback + rx = coords.rx, + ry = coords.ry, + rz = coords.rz + }) + + ----------------------------------------------------------------- + -- SALVAR NO DB + ----------------------------------------------------------------- + exports.oxmysql:insert( + 'INSERT INTO safes (owner, type, coords, pin) VALUES (?, ?, ?, ?)', + { identifier, type, coordsToSave, pin }, + function(id) + + if not id then + exports.ox_inventory:AddItem(src, cfg.item, 1) + return TriggerClientEvent('ox_lib:notify', src, {type='error', description='Erro ao salvar o safe.'}) + end + + ----------------------------------------------------------------- + -- GUARDAR NA MEMÓRIA DO SERVIDOR + ----------------------------------------------------------------- + local safeData = { + id = id, + owner = identifier, + type = type, + coords = coords, + pin = pin + } + + safes[id] = safeData + + ----------------------------------------------------------------- + -- REGISTRAR STASH + ----------------------------------------------------------------- + exports.ox_inventory:RegisterStash( + "safe_"..id, + cfg.label, + cfg.slots, + cfg.weight, + identifier + ) + + ----------------------------------------------------------------- + -- ENVIAR PARA TODOS + ----------------------------------------------------------------- + TriggerClientEvent('safe:spawn', -1, safeData) + TriggerClientEvent('ox_lib:notify', src, {type='success', description='Safe colocado.'}) + + dbg('Safe colocado id', id, 'por', identifier) + end + ) +end) + + + +------------------------------------------------------- +-- ABRIR SAFE +------------------------------------------------------- +RegisterNetEvent('safe:open', function(id, pin) + local src = source + local now = os.time() + local attempt = pinAttempts[src] + + if attempt and now < attempt.blockedUntil then + return TriggerClientEvent('ox_lib:notify', src, {type='error', description='Muitas tentativas erradas. Aguarde.'}) + end + + local safe = safes[id] + + if not safe then + return TriggerClientEvent('ox_lib:notify', src, {type='error', description='Safe inexistente.'}) + end + + if not isNearSafe(src, safe) then + return TriggerClientEvent('ox_lib:notify', src, {type='error', description='Você está muito longe do safe.'}) + end + + if tostring(pin) ~= tostring(safe.pin) then + attempt = attempt or { count = 0, blockedUntil = 0 } + attempt.count = attempt.count + 1 + + if attempt.count >= Config.PinMaxAttempts then + attempt.blockedUntil = now + Config.PinBlockTime + attempt.count = 0 + dbg('Jogador', src, 'bloqueado por tentativas de PIN erradas') + end + + pinAttempts[src] = attempt + return TriggerClientEvent('ox_lib:notify', src, {type='error', description='PIN incorreto.'}) + end + + pinAttempts[src] = nil + + local cfg = Config.SafeTypes[safe.type] + TriggerClientEvent('safe:openInventory', src, {id=id, slots=cfg.slots, weight=cfg.weight}) +end) + + + +------------------------------------------------------- +-- RECOLHER SAFE +------------------------------------------------------- +RegisterNetEvent('safe:pickup', function(id) + local src = source + local safe = safes[id] + + if not safe then + return TriggerClientEvent('ox_lib:notify', src, {type='error', description='Safe inexistente.'}) + end + + local identifier = GetPlayerIdentifier(src, 1) + if safe.owner ~= identifier then + return TriggerClientEvent('ox_lib:notify', src, {type='error', description='Você não é o dono.'}) + end + + if not isNearSafe(src, safe) then + return TriggerClientEvent('ox_lib:notify', src, {type='error', description='Você está muito longe do safe.'}) + end + + local cfg = Config.SafeTypes[safe.type] + + -- Bloqueia o recolhimento se ainda houver itens dentro (evita perda de itens) + -- Nota: confirme se "GetInventoryItems" é o nome do export na sua versão do ox_inventory + local items = exports.ox_inventory:GetInventoryItems("safe_"..id) + if items and next(items) then + return TriggerClientEvent('ox_lib:notify', src, {type='error', description='O safe ainda tem itens dentro.'}) + end + + exports.oxmysql:execute('DELETE FROM ox_inventory WHERE name=?', {'safe_'..id}) + exports.oxmysql:execute('DELETE FROM safes WHERE id=?', {id}) + safes[id] = nil + + exports.ox_inventory:ClearInventory("safe_"..id) + TriggerClientEvent('safe:despawn', -1, id) + + exports.ox_inventory:AddItem(src, cfg.item, 1) + TriggerClientEvent('ox_lib:notify', src, {type='success', description='Safe recolhido e item devolvido.'}) +end) + +------------------------------------------------------- +-- UPDATE CHECKER / UPGRADE (via Gitea) +-- Só existe enquanto Config.Debug = true. Em produção, com +-- Config.Debug = false, este bloco inteiro nem registra os comandos. +------------------------------------------------------- +if Config.Debug and Config.Update then + local function giteaRawUrl(file) + return ('%s/api/v1/repos/%s/%s/raw/%s?ref=%s'):format( + Config.Update.giteaUrl, Config.Update.owner, Config.Update.repo, file, Config.Update.branch + ) + end + + local function checkForUpdate(cb) + PerformHttpRequest(giteaRawUrl('version.json'), function(status, body) + if status ~= 200 or not body then + return cb(false, ('não foi possível contatar o Gitea (status %s)'):format(tostring(status))) + end + + local ok, remote = pcall(json.decode, body) + if not ok or not remote or not remote.version then + return cb(false, 'resposta inválida em version.json') + end + + cb(true, remote.version) + end, 'GET', '', { ['Content-Type'] = 'application/json' }) + end + + RegisterCommand(Config.Update.checkCommand, function(src) + local current = GetResourceMetadata(GetCurrentResourceName(), 'version', 0) + + checkForUpdate(function(ok, result) + if not ok then + dbg('Erro ao checar update:', result) + return + end + + if result ~= current then + dbg(('Nova versão disponível: %s (atual: %s). Use /%s para atualizar.') + :format(result, current, Config.Update.upgradeCommand)) + else + dbg('Já está na versão mais recente ('..current..').') + end + end) + end, true) + + RegisterCommand(Config.Update.upgradeCommand, function(src) + local resourceName = GetCurrentResourceName() + local files = Config.Update.files + local pending = #files + local failed = false + + dbg('Iniciando upgrade, baixando', pending, 'arquivo(s)...') + + for _, file in ipairs(files) do + PerformHttpRequest(giteaRawUrl(file), function(status, body) + if status == 200 and body and #body > 0 then + SaveResourceFile(resourceName, file, body, #body) + dbg('Baixado:', file) + else + failed = true + dbg(('Falha ao baixar %s (status %s)'):format(file, tostring(status))) + end + + pending = pending - 1 + if pending == 0 then + if failed then + dbg('Upgrade incompleto — algum arquivo falhou. Resource NÃO foi reiniciado.') + else + dbg('Upgrade concluído. Reiniciando resource...') + ExecuteCommand('restart '..resourceName) + end + end + end, 'GET') + end + end, true) + + dbg('Update checker ativo (Config.Debug = true). Comandos: /'..Config.Update.checkCommand..' /'..Config.Update.upgradeCommand) +end