Files
sky_phone-sky-systems/sky_phone/source/server/custom_app_storage.lua
T
Leon.Schmidt edd9677f43 ADD - add in-game phone administration and configuration (#21)
* ADD - build in-game admin command center

* ADD - open admin panel by command

* ENH - rebuild admin panel as standalone editor

* ENH - compact admin workspace navigation

* ENH - expand admin device controls

* ENH - refine admin panel navigation style

* ADD - add SQL phone configurator

* FIX - expose complete phone configuration

* FIX - make company jobs freely configurable

* FIX - align configurator tables to left

* ENH - organize configurator collection controls

* ENH - organize nested configurator sections

* ENH - add configurator field descriptions

* FIX - hide fixed configurator add controls

* ENH - refine configurator editing experience

* ENH - refine admin configurator controls

* FIX - apply configurator settings instantly

* FIX - close live activity command registration
2026-08-21 17:28:30 +02:00

270 lines
8.9 KiB
Lua

Bridge.Database.AfterMigration("sky_phone", function()
local STORAGE_JSON_MAX_DEPTH = 8
local STORAGE_JSON_MAX_NODES = 512
local storage_write_locks = {}
local function affected_rows(result)
if type(result) == "number" then
return result
end
return type(result) == "table" and tonumber(result.affectedRows) or 0
end
local function validate_storage_json(value, depth, state)
state.nodes = state.nodes + 1
if state.nodes > STORAGE_JSON_MAX_NODES or depth > STORAGE_JSON_MAX_DEPTH then
return false
end
local value_type = type(value)
if value_type == "nil" or value_type == "boolean" then
return true
end
if value_type == "number" then
return value == value and value ~= math.huge and value ~= -math.huge
end
if value_type == "string" then
return #value <= math.min(Config.CustomApps.MaximumStorageValueBytes, 65536)
end
if value_type ~= "table" or state.seen[value] then
return false
end
state.seen[value] = true
local key_type = nil
local numeric_keys = 0
local value_count = 0
for key, nested_value in pairs(value) do
value_count = value_count + 1
local current_key_type = type(key)
if current_key_type == "number" then
if key < 1 or key % 1 ~= 0 then
state.seen[value] = nil
return false
end
numeric_keys = numeric_keys + 1
elseif current_key_type ~= "string"
or #key == 0
or #key > 64
or key == "__proto__"
or key == "constructor"
or key == "prototype"
then
state.seen[value] = nil
return false
end
if key_type and key_type ~= current_key_type then
state.seen[value] = nil
return false
end
key_type = current_key_type
if not validate_storage_json(nested_value, depth + 1, state) then
state.seen[value] = nil
return false
end
end
if key_type == "number" and (numeric_keys ~= value_count or #value ~= value_count) then
state.seen[value] = nil
return false
end
state.seen[value] = nil
return true
end
local function with_storage_write_lock(lock_key, callback)
local previous_lock = storage_write_locks[lock_key]
local current_lock = promise.new()
storage_write_locks[lock_key] = current_lock
if previous_lock then
Citizen.Await(previous_lock)
end
local success, result = xpcall(callback, debug.traceback)
current_lock:resolve(true)
if storage_write_locks[lock_key] == current_lock then
storage_write_locks[lock_key] = nil
end
if not success then
error(result, 0)
end
return result
end
local function validate_request(source, data, operation)
if not Config.CustomApps.Enabled then
return nil, nil, { success = false, error = "custom_apps_disabled" }
end
if not SkyPhone.AllowOperation(
source,
"custom_app_storage_" .. operation,
Config.CustomApps.StorageRequestsPerMinute,
60
) then
return nil, nil, { success = false, error = "rate_limited" }
end
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return nil, nil, error_response
end
if type(data) ~= "table" or not SkyPhoneApps.HasPermission(data.appId, "device.storage") then
return nil, nil, { success = false, error = "storage_not_allowed" }
end
if type(data.key) ~= "string"
or #data.key == 0
or #data.key > math.min(Config.CustomApps.MaximumStorageKeyLength, 64)
or not data.key:match("^[%w._-]+$")
then
return nil, nil, { success = false, error = "invalid_storage_key" }
end
return session, data, nil
end
local function read_row(imei, app_id, data_key)
local rows = Bridge.Database.Query([[
SELECT `payload`, `revision`
FROM `sky_phone_custom_app_data`
WHERE `device_imei` = ? AND `app_id` = ? AND `data_key` = ?
LIMIT 1
]], { imei, app_id, data_key })
return rows[1]
end
local function conflict_response(row)
if not row then
return {
success = false,
error = "storage_conflict",
data = { exists = false, revision = 0 },
}
end
return {
success = false,
error = "storage_conflict",
data = {
exists = true,
revision = tonumber(row.revision) or 0,
value = json.decode(row.payload),
},
}
end
Bridge.Callbacks.Register("sky_phone:custom-app:storage:get", function(source, data)
local session, request, error_response = validate_request(source, data, "get")
if not session then
return error_response
end
local row = read_row(session.imei, request.appId, request.key)
if not row then
return { success = true, data = { exists = false, revision = 0 } }
end
return {
success = true,
data = {
exists = true,
revision = tonumber(row.revision) or 0,
value = json.decode(row.payload),
},
}
end)
Bridge.Callbacks.Register("sky_phone:custom-app:storage:set", function(source, data)
local session, request, error_response = validate_request(source, data, "set")
if not session then
return error_response
end
if request.value == nil then
return { success = false, error = "invalid_storage_value" }
end
local expected_revision = tonumber(request.revision)
if not expected_revision
or expected_revision ~= math.floor(expected_revision)
or expected_revision < 0
or expected_revision > 4294967294
then
return { success = false, error = "invalid_storage_revision" }
end
if not validate_storage_json(request.value, 0, { nodes = 0, seen = {} }) then
return { success = false, error = "invalid_storage_value" }
end
local encoded, payload = pcall(json.encode, request.value)
if not encoded
or type(payload) ~= "string"
or #payload > math.min(Config.CustomApps.MaximumStorageValueBytes, 65536)
then
return { success = false, error = "storage_value_too_large" }
end
local lock_key = session.imei .. "\0" .. request.appId
return with_storage_write_lock(lock_key, function()
local current_session, session_error = SkyPhone.RequireSession(source)
if not current_session then
return session_error
end
if current_session.imei ~= session.imei
or not SkyPhoneApps.HasPermission(request.appId, "device.storage")
then
return { success = false, error = "storage_not_allowed" }
end
local current = read_row(session.imei, request.appId, request.key)
local current_revision = current and tonumber(current.revision) or 0
if current_revision ~= expected_revision then
return conflict_response(current)
end
local totals = Bridge.Database.Query([[
SELECT COALESCE(SUM(OCTET_LENGTH(`payload`)), 0) AS `total`, COUNT(*) AS `keys`
FROM `sky_phone_custom_app_data`
WHERE `device_imei` = ? AND `app_id` = ?
]], { session.imei, request.appId })
local current_size = current and #current.payload or 0
local next_total = (tonumber(totals[1] and totals[1].total) or 0) - current_size + #payload
if next_total > Config.CustomApps.MaximumStorageBytesPerApp then
return { success = false, error = "storage_quota_exceeded" }
end
if not current
and (tonumber(totals[1] and totals[1].keys) or 0) >= Config.CustomApps.MaximumStorageKeysPerApp
then
return { success = false, error = "storage_key_limit" }
end
if expected_revision == 0 then
local inserted = Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_custom_app_data`
(`device_imei`, `app_id`, `data_key`, `payload`, `revision`)
VALUES (?, ?, ?, ?, 1)
]], { session.imei, request.appId, request.key, payload })
if affected_rows(inserted) ~= 1 then
return conflict_response(read_row(session.imei, request.appId, request.key))
end
return { success = true, data = { revision = 1 } }
end
local updated = Bridge.Database.Query([[
UPDATE `sky_phone_custom_app_data`
SET `payload` = ?, `revision` = `revision` + 1
WHERE `device_imei` = ? AND `app_id` = ? AND `data_key` = ? AND `revision` = ?
]], { payload, session.imei, request.appId, request.key, expected_revision })
if affected_rows(updated) ~= 1 then
return conflict_response(read_row(session.imei, request.appId, request.key))
end
return { success = true, data = { revision = expected_revision + 1 } }
end)
end)
end)