mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 00:01:29 +00:00
edd9677f43
* 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
270 lines
8.9 KiB
Lua
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)
|