Files
sky_phone-sky-systems/sky_phone/source/server/phone_configurator.lua
T
Leon.Schmidt 312ae8a3a3 FIX - repair company actions and key rebindings (#32)
* FIX - repair company detail actions

Constrain compact navbar titles to their grid column so long company names cannot overlap the back action. Allow configured public emergency companies to accept normal service requests, and migrate existing police profile and Phone Configurator defaults exactly once.

* FIX - preserve phone key rebindings

Use the stable sky_phone_toggle command as the RegisterKeyMapping identifier. The previous revisioned command names detached FiveM's persisted keyboard settings from the active handler whenever the mapping identity changed.
2026-08-23 18:23:09 +02:00

1273 lines
41 KiB
Lua

SkyPhoneConfigurator = SkyPhoneConfigurator or {}
local TABLE_NAME = "sky_phone_configurator"
local CONFIG_ROW_ID = 1
local MAX_CHANGES = 1000
local MAX_PAYLOAD_BYTES = 8 * 1024 * 1024
local MAX_STRUCTURED_DEPTH = 20
local MAX_STRUCTURED_ENTRIES = 20000
local REDACTED_VALUE = "***REDACTED***"
local configurator_enabled = Config.PhoneConfigurator.Enabled == true
local default_config
local default_media
local stored_config
local stored_media
local revision = 1
local updated_at
local updated_by_name
local is_sequence
local FIXED_CONFIG_PATHS = {
CommandPermissions = true,
["AdminPanel.AdminGroups"] = true,
["TestData.AdminGroups"] = true,
["FlipTok.AdminGroups"] = true,
["Picstagram.AdminGroups"] = true,
}
if configurator_enabled then
local border = "======================================================================"
print(([[
^1%s^0
^1 SKY PHONE CONFIGURATION FILES ARE DISABLED ^0
^1%s^0
^1 The Phone Configurator is ENABLED.^0
^1 Runtime settings from config.lua and media.lua are DISABLED.^0
^1 Configure all phone and media settings IN GAME through /phonepanel.^0
^1 Only Config.PhoneConfigurator.Enabled and Config.CommandPermissions remain file-based.^0
^1%s^0]]):format(border, border, border))
end
local CLIENT_CONFIG_KEYS = {
AdminPanel = true,
Animations = true,
Apps = true,
Banking = true,
Billing = true,
Bridge = true,
Calendar = true,
Calls = true,
Command = true,
CrewLink = true,
Crypto = true,
CustomApps = true,
DarkChat = true,
EasyShare = true,
Feather = true,
FlipTok = true,
Garage = true,
Health = true,
Housing = true,
LocalPages = true,
Mail = true,
MapMarkers = true,
Marketplace = true,
Memos = true,
Messages = true,
Payphones = true,
Phone = true,
Picstagram = true,
Radio = true,
Security = true,
Sim = true,
SkyRide = true,
Speaker = true,
TestData = true,
}
local function copy_value(value, active)
local value_type = type(value)
if value_type ~= "table" then
return value
end
active = active or {}
if active[value] then
error("[sky_phone] Phone configurator cannot copy a cyclic table.")
end
active[value] = true
local copy = {}
for key, child in pairs(value) do
copy[key] = copy_value(child, active)
end
active[value] = nil
return copy
end
local function serialize_value(value, active)
local value_type = type(value)
if value_type == "nil" or value_type == "boolean" or value_type == "string" then
return value
end
if value_type == "number" then
if value ~= value or value == math.huge or value == -math.huge then
error("[sky_phone] Phone configurator cannot serialize a non-finite number.")
end
return value
end
if value_type == "vector2" then
return { __skyType = "vector2", x = value.x, y = value.y }
end
if value_type == "vector3" then
return { __skyType = "vector3", x = value.x, y = value.y, z = value.z }
end
if value_type == "vector4" then
return { __skyType = "vector4", x = value.x, y = value.y, z = value.z, w = value.w }
end
if value_type ~= "table" then
error(("[sky_phone] Phone configurator cannot serialize value type '%s'."):format(value_type))
end
active = active or {}
if active[value] then
error("[sky_phone] Phone configurator cannot serialize a cyclic table.")
end
active[value] = true
local has_numeric_map_key = false
if not is_sequence(value) then
for key in pairs(value) do
if type(key) == "number" then
has_numeric_map_key = true
break
end
end
end
if has_numeric_map_key then
local entries = {}
for key, child in pairs(value) do
if type(key) ~= "string" and type(key) ~= "number" then
error("[sky_phone] Phone configurator only supports string and numeric table keys.")
end
entries[#entries + 1] = {
key = key,
keyType = type(key),
value = serialize_value(child, active),
}
end
table.sort(entries, function(left, right)
if left.keyType ~= right.keyType then
return left.keyType < right.keyType
end
if left.keyType == "number" then
return left.key < right.key
end
return tostring(left.key) < tostring(right.key)
end)
active[value] = nil
return { __skyType = "map", entries = entries }
end
local serialized = {}
for key, child in pairs(value) do
if type(key) ~= "string" and type(key) ~= "number" then
error("[sky_phone] Phone configurator only supports string and numeric table keys.")
end
serialized[key] = serialize_value(child, active)
end
active[value] = nil
return serialized
end
local function deserialize_value(value)
if type(value) ~= "table" then
return value
end
if value.__skyType == "vector2" then
return vector2(tonumber(value.x) or 0.0, tonumber(value.y) or 0.0)
end
if value.__skyType == "vector3" then
return vector3(tonumber(value.x) or 0.0, tonumber(value.y) or 0.0, tonumber(value.z) or 0.0)
end
if value.__skyType == "vector4" then
return vector4(
tonumber(value.x) or 0.0,
tonumber(value.y) or 0.0,
tonumber(value.z) or 0.0,
tonumber(value.w) or 0.0
)
end
if value.__skyType == "map" then
local decoded = {}
for _, entry in ipairs(value.entries or {}) do
local key = entry.keyType == "number" and tonumber(entry.key) or entry.key
decoded[key] = deserialize_value(entry.value)
end
return decoded
end
local decoded = {}
for key, child in pairs(value) do
decoded[key] = deserialize_value(child)
end
return decoded
end
local function apply_runtime_table(current, replacement)
for key in pairs(current) do
if replacement[key] == nil then
current[key] = nil
end
end
for key, value in pairs(replacement) do
local current_value = current[key]
if type(current_value) == "table" and type(value) == "table" then
apply_runtime_table(current_value, value)
else
current[key] = value
end
end
end
is_sequence = function(value)
if type(value) ~= "table" then
return false
end
local count = 0
local maximum = 0
for key in pairs(value) do
if type(key) ~= "number" or key < 1 or key % 1 ~= 0 then
return false
end
count = count + 1
maximum = math.max(maximum, key)
end
return count > 0 and count == maximum
end
local function upgrade_legacy_map(defaults, saved)
local default_types = {}
local numeric_only = true
for _, entry in ipairs(defaults.entries or {}) do
default_types[tostring(entry.key)] = entry.keyType
if entry.keyType ~= "number" then
numeric_only = false
end
end
local entries = {}
for key, child in pairs(saved) do
local key_type = default_types[tostring(key)] or (numeric_only and "number" or type(key))
local normalized_key = key_type == "number" and tonumber(key) or tostring(key)
if normalized_key == nil then
error("[sky_phone] Phone configurator could not migrate a numeric configuration key.")
end
entries[#entries + 1] = {
key = normalized_key,
keyType = key_type,
value = copy_value(child),
}
end
table.sort(entries, function(left, right)
if left.keyType ~= right.keyType then
return left.keyType < right.keyType
end
if left.keyType == "number" then
return left.key < right.key
end
return tostring(left.key) < tostring(right.key)
end)
return { __skyType = "map", entries = entries }
end
local function merge_values(defaults, saved, path, excluded_paths)
path = path or ""
if type(defaults) ~= "table" or type(saved) ~= "table" then
return copy_value(saved)
end
if path == "Companies.Definitions" then
return copy_value(saved)
end
if defaults.__skyType == "map" and not saved.__skyType then
saved = upgrade_legacy_map(defaults, saved)
end
if defaults.__skyType == "map" and saved.__skyType == "map" then
local saved_entries = {}
for _, entry in ipairs(saved.entries or {}) do
saved_entries[entry.keyType .. ":" .. tostring(entry.key)] = entry
end
local entries = {}
local included = {}
for _, entry in ipairs(defaults.entries or {}) do
local identity = entry.keyType .. ":" .. tostring(entry.key)
local saved_entry = saved_entries[identity]
entries[#entries + 1] = {
key = entry.key,
keyType = entry.keyType,
value = saved_entry
and merge_values(
entry.value,
saved_entry.value,
path .. "." .. tostring(entry.key),
excluded_paths
)
or copy_value(entry.value),
}
included[identity] = true
end
for _, entry in ipairs(saved.entries or {}) do
local identity = entry.keyType .. ":" .. tostring(entry.key)
if not included[identity] then
entries[#entries + 1] = copy_value(entry)
end
end
return { __skyType = "map", entries = entries }
end
if defaults.__skyType or saved.__skyType then
return copy_value(saved)
end
if is_sequence(defaults) then
local merged = copy_value(saved)
for index, child in ipairs(defaults) do
merged[index] = saved[index] ~= nil
and merge_values(child, saved[index], path .. "." .. tostring(index), excluded_paths)
or copy_value(child)
end
return merged
end
if is_sequence(saved) then
return copy_value(saved)
end
local merged = copy_value(defaults)
for key, child in pairs(saved) do
local child_path = path == "" and tostring(key) or (path .. "." .. tostring(key))
if not excluded_paths or not excluded_paths[child_path] then
if merged[key] ~= nil then
merged[key] = merge_values(merged[key], child, child_path, excluded_paths)
else
merged[key] = copy_value(child)
end
end
end
return merged
end
local function decode_payload(encoded, scope)
if type(encoded) ~= "string" or encoded == "" then
error(("[sky_phone] Phone configurator has an empty %s SQL payload."):format(scope))
end
local success, decoded = pcall(json.decode, encoded)
if not success or type(decoded) ~= "table" then
error(("[sky_phone] Phone configurator failed to decode the %s SQL payload."):format(scope))
end
return decoded
end
local function encode_payload(payload, scope)
local success, encoded = pcall(json.encode, payload)
if not success or type(encoded) ~= "string" then
error(("[sky_phone] Phone configurator failed to encode the %s SQL payload."):format(scope))
end
if #encoded > MAX_PAYLOAD_BYTES then
error(("[sky_phone] Phone configurator %s payload exceeds %s bytes."):format(scope, MAX_PAYLOAD_BYTES))
end
return encoded
end
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 apply_runtime_configuration()
if not configurator_enabled then
return
end
local runtime_config = deserialize_value(stored_config)
for key, value in pairs(runtime_config) do
if key ~= "CommandPermissions" then
if type(Config[key]) == "table" and type(value) == "table" then
apply_runtime_table(Config[key], value)
else
Config[key] = value
end
end
end
local runtime_media = deserialize_value(stored_media)
if type(Config.Media) == "table" and type(runtime_media) == "table" then
apply_runtime_table(Config.Media, runtime_media)
else
Config.Media = runtime_media
end
end
local function humanize(value)
local text = tostring(value or "")
:gsub("[_%-]+", " ")
:gsub("(%l)(%u)", "%1 %2")
:gsub("(%a)(%d)", "%1 %2")
return text:gsub("^%l", string.upper)
end
local function sensitive_path(path)
local leaf = tostring(path):match("([^.]+)$") or path
local normalized = leaf:lower():gsub("[^%w]", "")
if normalized:find("apikey", 1, true)
or normalized:find("secret", 1, true)
or normalized:find("pepper", 1, true)
or normalized == "password"
or normalized == "token"
or normalized == "authorization"
or normalized == "credential"
or normalized == "connectionstring"
then
return true
end
return false
end
local function mask_admin_value(value, path)
if type(value) ~= "table" then
return type(value) == "string" and sensitive_path(path) and REDACTED_VALUE or value
end
if value.__skyType == "map" then
local entries = {}
for index, entry in ipairs(value.entries or {}) do
entries[index] = {
key = entry.key,
keyType = entry.keyType,
value = mask_admin_value(entry.value, path .. "." .. tostring(entry.key)),
}
end
return { __skyType = "map", entries = entries }
end
local masked = {}
for key, child in pairs(value) do
masked[key] = mask_admin_value(child, path .. "." .. tostring(key))
end
return masked
end
local function restore_redacted_values(value, current, path)
if type(value) ~= "table" then
if value == REDACTED_VALUE and sensitive_path(path) then
return current
end
return value
end
if value.__skyType == "map" then
local current_entries = {}
if type(current) == "table" and current.__skyType == "map" then
for _, entry in ipairs(current.entries or {}) do
current_entries[entry.keyType .. ":" .. tostring(entry.key)] = entry.value
end
end
local entries = {}
for index, entry in ipairs(value.entries or {}) do
local identity = entry.keyType .. ":" .. tostring(entry.key)
entries[index] = {
key = entry.key,
keyType = entry.keyType,
value = restore_redacted_values(
entry.value,
current_entries[identity],
path .. "." .. tostring(entry.key)
),
}
end
return { __skyType = "map", entries = entries }
end
local restored = {}
for key, child in pairs(value) do
local current_child = type(current) == "table" and current[key] or nil
restored[key] = restore_redacted_values(child, current_child, path .. "." .. tostring(key))
end
return restored
end
local function empty_structure(scope, path)
if scope ~= "config" then
return nil
end
if path == "Garage.VehicleImages.ModelNames" then
return {
entries = {},
keyType = "number",
kind = "map",
template = { kind = "value", valueType = "string" },
}
end
if path == "CrewLink.ExternalPingResources" or path == "CustomApps.TrustedAdapters" then
return {
fields = {},
kind = "table",
mutableKeys = true,
template = { kind = "value", valueType = "boolean" },
}
end
if path == "FlipTok.MusicTracks" then
return {
items = {},
kind = "list",
template = {
fields = {
Artist = { kind = "value", valueType = "string" },
Id = { kind = "value", valueType = "string" },
Title = { kind = "value", valueType = "string" },
Url = { kind = "value", valueType = "string" },
},
kind = "table",
},
}
end
if path == "Music.Tracks" then
return {
items = {},
kind = "list",
template = {
fields = {
Artist = { kind = "value", valueType = "string" },
Id = { kind = "value", valueType = "string" },
Title = { kind = "value", valueType = "string" },
},
kind = "table",
},
}
end
if path == "Payphones.CustomLocations" then
return {
items = {},
kind = "list",
template = { kind = "vector", vectorType = "vector4" },
}
end
if path:match("^Companies%.Definitions%.[^.]+%.Services$") then
return {
items = {},
kind = "list",
template = {
fields = {
Description = { kind = "value", valueType = "string" },
Id = { kind = "value", valueType = "string" },
Price = { kind = "value", valueType = "string" },
RequestsEnabled = { kind = "value", valueType = "boolean" },
Title = { kind = "value", valueType = "string" },
},
kind = "table",
},
}
end
return nil
end
local function build_structure(value, scope, path)
local value_type = type(value)
if scope == "config" and path == "Phone.Keybind" then
return { kind = "optionalString" }
end
if scope == "config" and path == "Companies.Definitions" and value_type == "table" then
local keys = {}
for key in pairs(value) do
keys[#keys + 1] = key
end
table.sort(keys, function(left, right)
return tostring(left) < tostring(right)
end)
local fields = {}
for _, key in ipairs(keys) do
fields[key] = build_structure(value[key], scope, path .. "." .. tostring(key))
end
return {
fields = fields,
kind = "table",
mutableKeys = true,
template = keys[1] and fields[keys[1]] or nil,
}
end
if value_type ~= "table" then
return {
kind = "value",
valueType = value_type,
}
end
if value.__skyType == "vector2" or value.__skyType == "vector3" or value.__skyType == "vector4" then
return {
kind = "vector",
vectorType = value.__skyType,
}
end
if value.__skyType == "map" then
local entries = {}
local key_type
for index, entry in ipairs(value.entries or {}) do
entries[index] = {
key = entry.key,
keyType = entry.keyType,
structure = build_structure(entry.value, scope, path .. "." .. tostring(entry.key)),
}
if index == 1 then
key_type = entry.keyType
elseif key_type ~= entry.keyType then
key_type = nil
end
end
return {
entries = entries,
keyType = key_type,
kind = "map",
template = entries[1] and entries[1].structure or nil,
}
end
local configured_empty_structure = next(value) == nil and empty_structure(scope, path) or nil
if configured_empty_structure then
return configured_empty_structure
end
if is_sequence(value) then
local items = {}
for index, child in ipairs(value) do
items[index] = build_structure(child, scope, path .. "." .. tostring(index))
end
return {
items = items,
kind = "list",
template = items[1],
}
end
local fields = {}
for key, child in pairs(value) do
fields[key] = build_structure(child, scope, path .. "." .. tostring(key))
end
return {
fields = fields,
kind = "table",
}
end
local function add_field(fields, field_index, scope, path, value, default_value)
local value_type = type(value)
local field_type = value_type
if value_type == "table" then
field_type = "json"
elseif value_type ~= "boolean" and value_type ~= "number" and value_type ~= "string" then
return
end
if scope == "config" and path == "Phone.Keybind" then
field_type = "stringOrFalse"
end
local sensitive = type(value) == "string" and sensitive_path(path)
local field = {
configured = sensitive and value ~= "" or nil,
label = scope == "config" and path == "Companies.Definitions"
and "Jobs"
or humanize(path:match("([^.]+)$") or path),
path = path,
scope = scope,
sensitive = sensitive,
structure = field_type == "json" and build_structure(default_value, scope, path) or nil,
type = field_type,
value = sensitive and "" or mask_admin_value(value, path),
}
fields[#fields + 1] = field
field_index[scope .. ":" .. path] = field
end
local function flatten_company_fields(fields, field_index, path, value, default_value)
if path == "Companies.Definitions"
or type(value) ~= "table"
or value.__skyType
or is_sequence(value)
or next(value) == nil
then
add_field(fields, field_index, "config", path, value, default_value)
return
end
local keys = {}
for key in pairs(value) do
keys[#keys + 1] = key
end
table.sort(keys, function(left, right)
return tostring(left) < tostring(right)
end)
for _, key in ipairs(keys) do
flatten_company_fields(
fields,
field_index,
path .. "." .. tostring(key),
value[key],
type(default_value) == "table" and default_value[key] or nil
)
end
end
local function build_sections(scope, payload, defaults, sections, field_index)
local general_fields = {}
local keys = {}
for key in pairs(payload) do
keys[#keys + 1] = key
end
table.sort(keys, function(left, right)
return tostring(left) < tostring(right)
end)
for _, key in ipairs(keys) do
local value = payload[key]
if scope == "config" and key == "Companies" then
local fields = {}
flatten_company_fields(fields, field_index, "Companies", value, defaults[key])
table.sort(fields, function(left, right)
if left.path == "Companies.Definitions" then
return false
end
if right.path == "Companies.Definitions" then
return true
end
return left.path < right.path
end)
sections[#sections + 1] = {
fields = fields,
id = "config:Companies",
label = humanize(key),
scope = scope,
}
elseif type(value) == "table" and not value.__skyType and not is_sequence(value) and next(value) ~= nil then
local fields = {}
add_field(fields, field_index, scope, tostring(key), value, defaults[key])
sections[#sections + 1] = {
fields = fields,
id = scope .. ":" .. tostring(key),
label = humanize(key),
scope = scope,
}
else
add_field(general_fields, field_index, scope, tostring(key), value, defaults[key])
end
end
if #general_fields > 0 then
table.insert(sections, scope == "config" and 1 or (#sections + 1), {
fields = general_fields,
id = scope .. ":general",
label = "General",
scope = scope,
})
end
end
local function build_admin_data()
local sections = {}
local field_index = {}
build_sections("config", stored_config, default_config, sections, field_index)
build_sections("media", stored_media, default_media, sections, field_index)
return {
enabled = configurator_enabled,
revision = revision,
sections = sections,
updatedAt = updated_at,
updatedBy = updated_by_name,
}, field_index
end
local function split_path(path)
local parts = {}
for part in path:gmatch("[^.]+") do
parts[#parts + 1] = part
end
return parts
end
local function set_path(root, path, value)
local parts = split_path(path)
if #parts == 0 then
return false
end
local parent = root
for index = 1, #parts - 1 do
parent = parent[parts[index]]
if type(parent) ~= "table" then
return false
end
end
parent[parts[#parts]] = value
return true
end
local function get_path(root, path)
local value = root
for _, part in ipairs(split_path(path)) do
if type(value) ~= "table" then
return nil
end
value = value[part]
end
return value
end
local function validate_structured_value(value, depth, state)
local value_type = type(value)
if value_type == "string" then
return #value <= 65535
end
if value_type == "number" then
return value == value and value ~= math.huge and value ~= -math.huge
end
if value_type == "boolean" or value_type == "nil" then
return true
end
if value_type ~= "table" or depth > MAX_STRUCTURED_DEPTH then
return false
end
state.entries = state.entries + 1
if state.entries > MAX_STRUCTURED_ENTRIES then
return false
end
local sky_type = value.__skyType
if sky_type then
if sky_type == "vector2" or sky_type == "vector3" or sky_type == "vector4" then
local axes = sky_type == "vector2" and { "x", "y" }
or sky_type == "vector3" and { "x", "y", "z" }
or { "x", "y", "z", "w" }
for _, axis in ipairs(axes) do
local coordinate = value[axis]
if type(coordinate) ~= "number"
or coordinate ~= coordinate
or coordinate == math.huge
or coordinate == -math.huge
then
return false
end
end
return true
end
if sky_type ~= "map" or type(value.entries) ~= "table" then
return false
end
if next(value.entries) and not is_sequence(value.entries) then
return false
end
local seen = {}
for _, entry in ipairs(value.entries) do
if type(entry) ~= "table" or (entry.keyType ~= "string" and entry.keyType ~= "number") then
return false
end
local key = entry.key
if entry.keyType == "string" then
if type(key) ~= "string" or key == "" or #key > 255 then
return false
end
elseif type(key) ~= "number"
or key ~= key
or key == math.huge
or key == -math.huge
then
return false
end
local identity = entry.keyType .. ":" .. tostring(key)
if seen[identity] then
return false
end
seen[identity] = true
if not validate_structured_value(entry.value, depth + 1, state) then
return false
end
end
return true
end
for key, child in pairs(value) do
if type(key) ~= "string" and type(key) ~= "number" then
return false
end
if not validate_structured_value(child, depth + 1, state) then
return false
end
end
return true
end
local function validate_locked_structure(structure, value)
if type(structure) ~= "table" or type(structure.kind) ~= "string" then
return false
end
if structure.kind == "value" then
return type(value) == structure.valueType
end
if structure.kind == "optionalString" then
return value == false or type(value) == "string"
end
if structure.kind == "vector" then
return type(value) == "table" and value.__skyType == structure.vectorType
end
if structure.kind == "list" then
if type(value) ~= "table" or value.__skyType or (next(value) and not is_sequence(value)) then
return false
end
local fixed_items = structure.items or {}
for index, item_structure in ipairs(fixed_items) do
if value[index] == nil or not validate_locked_structure(item_structure, value[index]) then
return false
end
end
if structure.template then
for index = #fixed_items + 1, #value do
if not validate_locked_structure(structure.template, value[index]) then
return false
end
end
end
return true
end
if structure.kind == "table" then
if type(value) ~= "table" or value.__skyType or is_sequence(value) then
return false
end
if not structure.mutableKeys then
for key in pairs(value) do
if not structure.fields[key] then
return false
end
end
for key, field_structure in pairs(structure.fields or {}) do
if value[key] == nil or not validate_locked_structure(field_structure, value[key]) then
return false
end
end
end
if structure.mutableKeys and structure.template then
for key, child in pairs(value) do
if type(key) ~= "string"
or #key > 64
or not key:match("^[a-z0-9_-]+$")
or not validate_locked_structure(structure.template, child)
then
return false
end
end
end
return true
end
if structure.kind ~= "map" or type(value) ~= "table" then
return false
end
if value.__skyType ~= "map" then
return next(value) == nil and #(structure.entries or {}) == 0
end
local entries = {}
for _, entry in ipairs(value.entries or {}) do
entries[entry.keyType .. ":" .. tostring(entry.key)] = entry.value
end
for _, entry in ipairs(structure.entries or {}) do
local child = entries[entry.keyType .. ":" .. tostring(entry.key)]
if child == nil or not validate_locked_structure(entry.structure, child) then
return false
end
end
if structure.template or structure.keyType then
local fixed_entries = {}
for _, entry in ipairs(structure.entries or {}) do
fixed_entries[entry.keyType .. ":" .. tostring(entry.key)] = true
end
for _, entry in ipairs(value.entries or {}) do
local identity = entry.keyType .. ":" .. tostring(entry.key)
local invalid_key_type = structure.keyType and entry.keyType ~= structure.keyType
local invalid_value = structure.template
and not validate_locked_structure(structure.template, entry.value)
if not fixed_entries[identity] and (invalid_key_type or invalid_value) then
return false
end
end
end
return true
end
local function normalize_change_value(field, value)
if field.type == "boolean" then
return type(value) == "boolean" and value or nil
end
if field.type == "number" then
if type(value) ~= "number" or value ~= value or value == math.huge or value == -math.huge then
return nil
end
return value
end
if field.type == "string" then
if type(value) ~= "string" or #value > 65535 or value == REDACTED_VALUE then
return nil
end
return value
end
if field.type == "stringOrFalse" then
if value == false then
return false
end
if type(value) ~= "string" or #value > 65535 then
return nil
end
return value
end
if field.type == "json" and type(value) == "table" then
if not validate_structured_value(value, 0, { entries = 0 }) then
return nil
end
local serialized = serialize_value(value)
if not validate_locked_structure(field.structure, serialized) then
return nil
end
return serialized
end
return nil
end
local function client_payload()
local payload = {}
for key in pairs(CLIENT_CONFIG_KEYS) do
if stored_config[key] ~= nil then
payload[key] = copy_value(stored_config[key])
end
end
return payload
end
function SkyPhoneConfigurator.Broadcast(target)
TriggerClientEvent("sky_phone:configurator:sync", target or -1, {
config = client_payload(),
enabled = configurator_enabled,
revision = revision,
})
end
local function read_stored_row()
local rows = Bridge.Database.Query(([[
SELECT `config_payload`, `media_payload`, `revision`, `updated_at`, `updated_by_name`
FROM `%s`
WHERE `id` = ?
LIMIT 1
]]):format(TABLE_NAME), { CONFIG_ROW_ID })
local row = rows[1]
if not row then
error("[sky_phone] Phone configurator could not load its SQL row.")
end
return row
end
local function apply_stored_row(row)
stored_config = merge_values(
default_config,
decode_payload(row.config_payload, "config"),
"",
FIXED_CONFIG_PATHS
)
stored_media = merge_values(default_media, decode_payload(row.media_payload, "media"), "")
revision = tonumber(row.revision) or 1
updated_at = row.updated_at
updated_by_name = row.updated_by_name
end
local function migrate_police_request_defaults()
local migration_name = "sky-phone:configurator:police-requests:v1"
local completed = Bridge.Database.Query(
"SELECT 1 FROM `sky_phone_migrations` WHERE `name` = ? LIMIT 1",
{ migration_name }
)
if completed[1] then
return
end
local row = read_stored_row()
local config_payload = decode_payload(row.config_payload, "config")
local police = config_payload.Companies
and config_payload.Companies.Definitions
and config_payload.Companies.Definitions.police
local migrated = type(police) == "table"
and police.Emergency == true
and police.AcceptsRequests == false
and type(police.Services) == "table"
and next(police.Services) == nil
local statements = {}
if migrated then
local defaults = default_config.Companies.Definitions.police
police.AcceptsRequests = defaults.AcceptsRequests
police.Services = copy_value(defaults.Services)
statements[#statements + 1] = {
query = ([[
UPDATE `%s`
SET `config_payload` = ?, `revision` = `revision` + 1
WHERE `id` = ?
]]):format(TABLE_NAME),
params = { encode_payload(config_payload, "config"), CONFIG_ROW_ID },
}
end
statements[#statements + 1] = {
query = [[
INSERT IGNORE INTO `sky_phone_migrations` (`name`, `source`, `stats`)
VALUES (?, ?, ?)
]],
params = {
migration_name,
"sky-phone",
json.encode({ migrated = migrated }),
},
}
if not Bridge.Database.Transaction(statements) then
error("[sky_phone] Could not migrate Phone Configurator police request defaults.")
end
if not migrated then
return
end
apply_stored_row(read_stored_row())
apply_runtime_configuration()
TriggerEvent("sky_phone:configurator:serverUpdated", revision)
SkyPhoneConfigurator.Broadcast(-1)
Bridge.Debug(
"info",
"[sky_phone] Migrated Phone Configurator police request defaults.",
{ always = true }
)
end
default_config = {}
for key, value in pairs(ConfigDefaults) do
if key ~= "Media" and key ~= "PhoneConfigurator" and key ~= "CommandPermissions" then
default_config[key] = serialize_value(value)
end
end
default_media = serialize_value(ConfigDefaults.Media)
Bridge.Database.Migrate("sky_phone_configurator", { SkyPhoneConfiguratorSchema })
Bridge.Database.Query(([[
INSERT IGNORE INTO `%s` (`id`, `config_payload`, `media_payload`, `revision`)
VALUES (?, ?, ?, 1)
]]):format(TABLE_NAME), {
CONFIG_ROW_ID,
encode_payload(default_config, "config"),
encode_payload(default_media, "media"),
})
apply_stored_row(read_stored_row())
apply_runtime_configuration()
Bridge.Database.AfterMigration("sky_phone", migrate_police_request_defaults)
function SkyPhoneConfigurator.GetAdminData()
local data = build_admin_data()
return data
end
function SkyPhoneConfigurator.Save(expected_revision, changes, actor_identifier, actor_name)
if not configurator_enabled then
return { success = false, error = "configurator_disabled" }
end
if tonumber(expected_revision) ~= revision then
return { success = false, error = "revision_conflict", data = SkyPhoneConfigurator.GetAdminData() }
end
if type(changes) ~= "table" or #changes < 1 or #changes > MAX_CHANGES then
return { success = false, error = "invalid_request" }
end
local _, field_index = build_admin_data()
local next_config = copy_value(stored_config)
local next_media = copy_value(stored_media)
local seen = {}
for _, change in ipairs(changes) do
if type(change) ~= "table" or type(change.scope) ~= "string" or type(change.path) ~= "string" then
return { success = false, error = "invalid_request" }
end
local field_key = change.scope .. ":" .. change.path
local field = field_index[field_key]
if not field or seen[field_key] then
return { success = false, error = "invalid_field" }
end
seen[field_key] = true
local normalized = normalize_change_value(field, change.value)
if normalized == nil then
return { success = false, error = "invalid_value" }
end
local target = change.scope == "config" and next_config or next_media
if change.scope ~= "config" and change.scope ~= "media" then
return { success = false, error = "invalid_field" }
end
if field.type == "json" then
normalized = restore_redacted_values(normalized, get_path(target, change.path), change.path)
end
if not set_path(target, change.path, normalized) then
return { success = false, error = "invalid_field" }
end
end
local config_encoded = encode_payload(next_config, "config")
local media_encoded = encode_payload(next_media, "media")
local result = Bridge.Database.Query(([[
UPDATE `%s`
SET `config_payload` = ?, `media_payload` = ?, `revision` = `revision` + 1,
`updated_by_identifier` = ?, `updated_by_name` = ?
WHERE `id` = ? AND `revision` = ?
]]):format(TABLE_NAME), {
config_encoded,
media_encoded,
tostring(actor_identifier or ""):sub(1, 80),
tostring(actor_name or ""):sub(1, 120),
CONFIG_ROW_ID,
revision,
})
if affected_rows(result) ~= 1 then
local latest = Bridge.Database.Query(("SELECT `revision` FROM `%s` WHERE `id` = ? LIMIT 1"):format(TABLE_NAME), {
CONFIG_ROW_ID,
})
revision = latest[1] and tonumber(latest[1].revision) or revision
return { success = false, error = "revision_conflict", data = SkyPhoneConfigurator.GetAdminData() }
end
local expected_revision = revision + 1
local persisted_row = read_stored_row()
if persisted_row.config_payload ~= config_encoded
or persisted_row.media_payload ~= media_encoded
or tonumber(persisted_row.revision) ~= expected_revision
then
error("[sky_phone] Phone configurator SQL verification failed after saving config and media payloads.")
end
apply_stored_row(persisted_row)
apply_runtime_configuration()
TriggerEvent("sky_phone:configurator:serverUpdated", revision)
SkyPhoneConfigurator.Broadcast(-1)
Bridge.Debug(
"info",
"[sky_phone] Applied Phone Configurator revision %s through the internal runtime refresh.",
tostring(revision),
{ always = true }
)
return { success = true, data = SkyPhoneConfigurator.GetAdminData() }
end
Bridge.Callbacks.Register("sky_phone:configurator:runtime", function()
return {
success = true,
data = {
config = configurator_enabled and client_payload() or {},
enabled = configurator_enabled,
revision = revision,
},
}
end)
Bridge.Debug(
"info",
"[sky_phone] Phone configurator initialized enabled=%s revision=%s.",
tostring(configurator_enabled),
tostring(revision)
)