Compare commits

...

2 Commits

Author SHA1 Message Date
978bf68745 Merge remote README 2026-08-03 10:57:18 +00:00
849663708a 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.
2026-08-03 10:57:14 +00:00
4 changed files with 746 additions and 0 deletions

297
client.lua Normal file
View File

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

61
config.lua Normal file
View File

@ -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
}
}

37
fxmanifest.lua Normal file
View File

@ -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'
}

351
server.lua Normal file
View File

@ -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