Files
sky_phone-sky-systems/sky_phone/source/server/phone_configurator.lua
T
Leon.Schmidt 4fa4bf3c78 ENH - centralize phone defaults and fixed permissions (#26)
* ENH - centralize phone configuration defaults

* FIX - enforce fixed phone permissions
2026-08-22 05:02:14 +02:00

1202 lines
38 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
print(
"[sky_phone] Phone Configurator enabled: file-based settings from config.lua " ..
"(except Config.CommandPermissions) and media.lua are disabled; SQL configuration is active."
)
end
local CLIENT_CONFIG_KEYS = {
AdminPanel = true,
Animations = 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
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()
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)
)