Bridge.Database.AfterMigration("sky_phone", function() local BUILTIN_APPS = { banking = true, billing = true, calculator = true, calendar = true, camera = true, citymarkt = true, citywarn = true, clock = true, companies = true, crewlink = true, crypto = true, darkchat = true, feather = true, flare = true, fliptok = true, garage = true, health = true, house = true, ["app-store"] = true, ["local-pages"] = true, mail = true, map = true, memos = true, memory = true, messages = true, minesweeper = true, music = true, ["neon-drop"] = true, notes = true, ["number-merge"] = true, phone = true, photos = true, picstagram = true, radio = true, settings = true, ["sky-flappy"] = true, skyride = true, snake = true, ["tower-stack"] = true, weather = true, ["weazel-news"] = true, } local DEFAULT_INSTALLED_APPS = { ["app-store"] = true, calculator = true, calendar = true, camera = true, citywarn = true, clock = true, health = true, mail = true, map = true, memos = true, messages = true, notes = true, phone = true, photos = true, settings = true, weather = true, } local PROTECTED_APPS = { ["app-store"] = true, camera = true, citywarn = true, health = true, mail = true, messages = true, phone = true, photos = true, settings = true, } 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 trim(value) if type(value) ~= "string" then return "" end return value:match("^%s*(.-)%s*$") end local function player_name(source) local first_name = trim(Bridge.Framework.GetFirstname(source)) local last_name = trim(Bridge.Framework.GetLastname(source)) local character_name = trim((first_name .. " " .. last_name)) return character_name ~= "" and character_name or GetPlayerName(source) or ("Player %s"):format(source) end local function require_admin(source, operation, maximum) if not Config.AdminPanel.Enabled or not Bridge.Framework.HasPermission(source, "phonepanel") then Bridge.Debug("warn", "[sky_phone] Rejected admin panel access from source %s.", tostring(source)) return nil, { success = false, error = "not_authorized" } end if not SkyPhone.AllowOperation(source, "admin_" .. operation, maximum, 60) then return nil, { success = false, error = "rate_limited" } end return true end local active_admin_command local function run_admin_command(command_source) local player_source = tonumber(command_source) if not player_source or player_source < 1 then Bridge.Debug("warn", "[sky_phone] The admin panel command can only be used by a player.") return end if not Config.AdminPanel.Enabled then TriggerClientEvent("sky_phone:admin:command-error", player_source, "disabled") return end if not Bridge.Framework.HasPermission(player_source, "phonepanel") then Bridge.Debug( "warn", "[sky_phone] Rejected admin panel command from source %s.", tostring(player_source) ) TriggerClientEvent("sky_phone:admin:command-error", player_source, "not_authorized") return end TriggerClientEvent("sky_phone:admin:launch", player_source) end local function register_admin_command() local command_name = Config.AdminPanel.Command if type(command_name) ~= "string" or command_name == "" then error("[sky_phone] Config.AdminPanel.Command must be a non-empty command name.") end if command_name == active_admin_command then return end active_admin_command = command_name RegisterCommand(command_name, function(command_source) if active_admin_command == command_name then run_admin_command(command_source) end end, false) end register_admin_command() AddEventHandler("sky_phone:configurator:serverUpdated", function() register_admin_command() end) local function normalize_source(value) local player_source = tonumber(value) if not player_source or player_source < 1 or player_source ~= math.floor(player_source) then return nil end if not Bridge.Framework.GetIdentifier(player_source) then return nil end return player_source end local function collect_device_imeis(source, identifier) local imeis = {} local seen = {} local function add_imei(imei) if SkyPhoneImei.IsValid(imei) and not seen[imei] then seen[imei] = true imeis[#imeis + 1] = imei end end for _, item in ipairs(Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)) do add_imei(item.metadata and item.metadata.imei) end local rows = Bridge.Database.Query([[ SELECT `device_imei` AS `imei` FROM `sky_phone_character_devices` WHERE `owner_identifier` = ? UNION SELECT device.`imei` FROM `sky_phone_devices` device JOIN `sky_phone_sims` sim ON sim.`id` = device.`sim_id` WHERE sim.`owner_identifier` = ? ]], { identifier, identifier }) for _, row in ipairs(rows) do add_imei(row.imei) end table.sort(imeis) return imeis end local function normalize_app_ids(value) local normalized = {} local seen = {} if type(value) ~= "table" then return normalized end for index = 1, #value do local app_id = value[index] if type(app_id) == "string" and #app_id > 0 and #app_id <= 64 and not seen[app_id] then seen[app_id] = true normalized[#normalized + 1] = app_id end end return normalized end local function load_app_payload(encoded) if type(encoded) ~= "string" or encoded == "" then return {}, {}, {} end local payload = json.decode(encoded) if type(payload) ~= "table" then error("[sky_phone] Stored admin target app payload is not a JSON object.") end return payload, normalize_app_ids(payload.claimedApps), normalize_app_ids(payload.uninstalledApps) end local function load_player_devices(source, identifier) local imeis = collect_device_imeis(source, identifier) if #imeis == 0 then return {} end local placeholders = {} for index = 1, #imeis do placeholders[index] = "?" end local rows = Bridge.Database.Query(([[ SELECT device.`imei`, device.`device_name`, device.`created_at`, device.`updated_at`, sim.`phone_number`, sim.`sim_type`, sim.`registered_at`, account.`id` AS `account_id`, account.`email` AS `account_email`, security.`passcode_length`, security.`failed_attempts`, security.`locked_until`, app_data.`payload` AS `apps_payload`, app_data.`revision` AS `apps_revision` FROM `sky_phone_devices` device LEFT JOIN `sky_phone_sims` sim ON sim.`id` = device.`sim_id` LEFT JOIN `sky_phone_accounts` account ON account.`id` = device.`account_id` LEFT JOIN `sky_phone_device_security` security ON security.`device_imei` = device.`imei` LEFT JOIN `sky_phone_device_data` app_data ON app_data.`device_imei` = device.`imei` AND app_data.`namespace` = 'apps' WHERE device.`imei` IN (%s) ORDER BY device.`updated_at` DESC, device.`imei` ASC ]]):format(table.concat(placeholders, ", ")), imeis) local devices = {} for _, row in ipairs(rows) do local _, claimed_apps, uninstalled_apps = load_app_payload(row.apps_payload) devices[#devices + 1] = { imei = row.imei, name = row.device_name, createdAt = row.created_at, updatedAt = row.updated_at, number = row.phone_number, simType = row.sim_type, simRegistered = row.registered_at ~= nil, account = row.account_id and { id = tonumber(row.account_id), email = row.account_email, passwordAvailable = true, } or nil, security = { enabled = row.passcode_length ~= nil, length = row.passcode_length and tonumber(row.passcode_length) or nil, failedAttempts = tonumber(row.failed_attempts) or 0, lockedUntil = tonumber(row.locked_until) or 0, }, apps = { claimed = claimed_apps, uninstalled = uninstalled_apps, revision = tonumber(row.apps_revision) or 0, }, } end return devices end local function build_player_summary(source) local identifier = Bridge.Framework.GetIdentifier(source) local devices = load_player_devices(source, identifier) local job = Bridge.Framework.GetJob(source) return { source = source, identifier = identifier, name = player_name(source), serverName = GetPlayerName(source) or "", job = job.label ~= "" and job.label or job.name, grade = job.grade, onDuty = job.onDuty, deviceCount = #devices, phoneNumber = devices[1] and devices[1].number or nil, } end local function list_players() local sources = Bridge.Framework.GetPlayers() table.sort(sources, function(left, right) return tonumber(left) < tonumber(right) end) local players = {} local maximum = math.max(1, math.floor(tonumber(Config.AdminPanel.MaximumPlayers) or 128)) for index = 1, math.min(#sources, maximum) do local player_source = tonumber(sources[index]) if player_source and Bridge.Framework.GetIdentifier(player_source) then players[#players + 1] = build_player_summary(player_source) end end return players end local function load_player_detail(source) local identifier = Bridge.Framework.GetIdentifier(source) local job = Bridge.Framework.GetJob(source) return { source = source, identifier = identifier, name = player_name(source), serverName = GetPlayerName(source) or "", firstName = trim(Bridge.Framework.GetFirstname(source)), lastName = trim(Bridge.Framework.GetLastname(source)), birthdate = trim(Bridge.Framework.GetBirthdate(source)), job = { name = job.name, label = job.label, grade = job.grade, gradeLabel = job.gradeLabel, onDuty = job.onDuty, }, money = { bank = tonumber(Bridge.Framework.GetMoney(source, "bank")) or 0, cash = tonumber(Bridge.Framework.GetMoney(source, "cash")) or 0, currency = Config.Banking.Currency, }, devices = load_player_devices(source, identifier), } end local function find_owned_device(source, imei) local identifier = Bridge.Framework.GetIdentifier(source) for _, device in ipairs(load_player_devices(source, identifier)) do if device.imei == imei then return device, identifier end end return nil, identifier end local function load_device_sim(imei) local rows = Bridge.Database.Query([[ SELECT sim.`id`, sim.`phone_number` FROM `sky_phone_devices` device JOIN `sky_phone_sims` sim ON sim.`id` = device.`sim_id` WHERE device.`imei` = ? LIMIT 1 ]], { imei }) return rows[1] end local function app_metadata(app_id) if BUILTIN_APPS[app_id] then return { defaultInstalled = DEFAULT_INSTALLED_APPS[app_id] == true, removable = PROTECTED_APPS[app_id] ~= true, } end if SkyPhoneApps.GetPolicy(app_id) then return { defaultInstalled = false, removable = true } end return nil end local function remove_app_id(values, app_id) local next_values = {} for index = 1, #values do if values[index] ~= app_id then next_values[#next_values + 1] = values[index] end end return next_values end local function add_app_id(values, app_id) for index = 1, #values do if values[index] == app_id then return values end end values[#values + 1] = app_id return values end local function write_audit(actor_source, target_source, target_identifier, imei, action, details) local actor_identifier = Bridge.Framework.GetIdentifier(actor_source) local result = Bridge.Database.Query([[ INSERT INTO `sky_phone_admin_audit` (`actor_identifier`, `actor_name`, `target_identifier`, `target_source`, `device_imei`, `action`, `details`) VALUES (?, ?, ?, ?, ?, ?, ?) ]], { actor_identifier, player_name(actor_source), target_identifier, target_source, imei, action, json.encode(details or {}), }) if affected_rows(result) ~= 1 then error("[sky_phone] Admin audit insert did not affect exactly one row.") end end local function load_audit() local limit = math.max(1, math.min(100, math.floor(tonumber(Config.AdminPanel.AuditLimit) or 40))) local rows = Bridge.Database.Query(([[ SELECT `id`, `actor_name`, `target_identifier`, `target_source`, `device_imei`, `action`, `details`, `created_at` FROM `sky_phone_admin_audit` ORDER BY `id` DESC LIMIT %s ]]):format(limit), {}) local audit = {} for _, row in ipairs(rows) do audit[#audit + 1] = { id = tonumber(row.id), actorName = row.actor_name, targetIdentifier = row.target_identifier, targetSource = row.target_source and tonumber(row.target_source) or nil, deviceImei = row.device_imei, action = row.action, details = json.decode(row.details), createdAt = row.created_at, } end return audit end Bridge.Callbacks.Register("sky_phone:admin:bootstrap", function(source) local authorized, error_response = require_admin( source, "bootstrap", Config.AdminPanel.ReadRequestsPerMinute ) if not authorized then return error_response end local players = list_players() local totals = Bridge.Database.Query([[ SELECT (SELECT COUNT(*) FROM `sky_phone_devices`) AS `devices`, (SELECT COUNT(*) FROM `sky_phone_accounts`) AS `accounts` ]], {}) return { success = true, data = { players = players, stats = { online = #players, devices = tonumber(totals[1] and totals[1].devices) or 0, accounts = tonumber(totals[1] and totals[1].accounts) or 0, }, audit = load_audit(), }, } end) Bridge.Callbacks.Register("sky_phone:admin:player", function(source, data) local authorized, error_response = require_admin( source, "player", Config.AdminPanel.ReadRequestsPerMinute ) if not authorized then return error_response end local target_source = normalize_source(data and data.source) if not target_source then return { success = false, error = "player_unavailable" } end return { success = true, data = load_player_detail(target_source) } end) Bridge.Callbacks.Register("sky_phone:admin:save-apps", function(source, data) local authorized, error_response = require_admin( source, "save_apps", Config.AdminPanel.ActionRequestsPerMinute ) if not authorized then return error_response end if type(data) ~= "table" or not SkyPhoneImei.IsValid(data.imei) or type(data.revision) ~= "number" or data.revision < 0 or data.revision ~= math.floor(data.revision) or type(data.changes) ~= "table" or #data.changes < 1 or #data.changes > 128 then return { success = false, error = "invalid_request" } end local target_source = normalize_source(data.source) if not target_source then return { success = false, error = "player_unavailable" } end local target_device, target_identifier = find_owned_device(target_source, data.imei) if not target_device then return { success = false, error = "device_not_owned" } end local normalized_changes = {} local seen_apps = {} for index = 1, #data.changes do local change = data.changes[index] if type(change) ~= "table" or type(change.appId) ~= "string" or #change.appId < 1 or #change.appId > 64 or type(change.installed) ~= "boolean" or seen_apps[change.appId] then return { success = false, error = "invalid_request" } end local metadata = app_metadata(change.appId) if not metadata then return { success = false, error = "invalid_app" } end if not change.installed and not metadata.removable then return { success = false, error = "app_protected" } end seen_apps[change.appId] = true normalized_changes[#normalized_changes + 1] = { appId = change.appId, installed = change.installed, metadata = metadata, } end local rows = Bridge.Database.Query([[ SELECT `payload`, `revision` FROM `sky_phone_device_data` WHERE `device_imei` = ? AND `namespace` = 'apps' LIMIT 1 ]], { data.imei }) local current_revision = tonumber(rows[1] and rows[1].revision) or 0 if current_revision ~= data.revision then return { success = false, error = "revision_conflict" } end local payload, claimed_apps, uninstalled_apps = load_app_payload(rows[1] and rows[1].payload) for index = 1, #normalized_changes do local change = normalized_changes[index] if change.installed then uninstalled_apps = remove_app_id(uninstalled_apps, change.appId) if not change.metadata.defaultInstalled then claimed_apps = add_app_id(claimed_apps, change.appId) end else claimed_apps = remove_app_id(claimed_apps, change.appId) uninstalled_apps = add_app_id(uninstalled_apps, change.appId) end end payload.claimedApps = claimed_apps payload.uninstalledApps = #uninstalled_apps > 0 and uninstalled_apps or nil local encoded = json.encode(payload) if #encoded > 100000 then return { success = false, error = "payload_too_large" } end if rows[1] then local result = Bridge.Database.Query([[ UPDATE `sky_phone_device_data` SET `payload` = ?, `revision` = `revision` + 1 WHERE `device_imei` = ? AND `namespace` = 'apps' AND `revision` = ? ]], { encoded, data.imei, data.revision }) if affected_rows(result) ~= 1 then return { success = false, error = "revision_conflict" } end else local result = Bridge.Database.Query([[ INSERT IGNORE INTO `sky_phone_device_data` (`device_imei`, `namespace`, `payload`) VALUES (?, 'apps', ?) ]], { data.imei, encoded }) if affected_rows(result) ~= 1 then return { success = false, error = "revision_conflict" } end end for index = 1, #normalized_changes do local change = normalized_changes[index] write_audit( source, target_source, target_identifier, data.imei, change.installed and "grant_app" or "revoke_app", { appId = change.appId } ) end SkyPhone.RefreshDevice(data.imei) return { success = true, data = load_player_detail(target_source) } end) Bridge.Callbacks.Register("sky_phone:admin:reveal-password", function(source, data) local authorized, error_response = require_admin( source, "reveal_password", Config.AdminPanel.CredentialRevealsPerMinute ) if not authorized then return error_response end if type(data) ~= "table" or not SkyPhoneImei.IsValid(data.imei) then return { success = false, error = "invalid_request" } end local target_source = normalize_source(data.source) if not target_source then return { success = false, error = "player_unavailable" } end local device, target_identifier = find_owned_device(target_source, data.imei) if not device then return { success = false, error = "device_not_owned" } end local accounts = Bridge.Database.Query([[ SELECT account.`email`, account.`password` FROM `sky_phone_devices` device JOIN `sky_phone_accounts` account ON account.`id` = device.`account_id` WHERE device.`imei` = ? LIMIT 1 ]], { data.imei }) if not accounts[1] then return { success = false, error = "account_not_found" } end write_audit( source, target_source, target_identifier, data.imei, "reveal_account_password", { email = accounts[1].email } ) return { success = true, data = { email = accounts[1].email, password = accounts[1].password, }, } end) Bridge.Callbacks.Register("sky_phone:admin:activity", function(source, data) local authorized, error_response = require_admin( source, "activity", Config.AdminPanel.ReadRequestsPerMinute ) if not authorized then return error_response end if type(data) ~= "table" or not SkyPhoneImei.IsValid(data.imei) or (data.kind ~= "messages" and data.kind ~= "calls") then return { success = false, error = "invalid_request" } end local target_source = normalize_source(data.source) if not target_source then return { success = false, error = "player_unavailable" } end local device, target_identifier = find_owned_device(target_source, data.imei) if not device then return { success = false, error = "device_not_owned" } end local audit_action = data.kind == "messages" and "view_messages" or "view_calls" local sim = load_device_sim(data.imei) if not sim then write_audit( source, target_source, target_identifier, data.imei, audit_action, { count = 0 } ) return { success = true, data = { kind = data.kind, entries = {} } } end local limit = math.max(1, math.min(100, math.floor(tonumber(Config.AdminPanel.ActivityLimit) or 40))) local entries = {} if data.kind == "messages" then local rows = Bridge.Database.Query(([[ SELECT `id`, `sender_sim_id`, `sender_number`, `recipient_number`, `message_type`, `body`, `read_at`, `created_at` FROM `sky_phone_sms_messages` WHERE `sender_sim_id` = ? OR `recipient_sim_id` = ? ORDER BY `created_at` DESC LIMIT %s ]]):format(limit), { sim.id, sim.id }) for _, row in ipairs(rows) do local outgoing = row.sender_sim_id == sim.id entries[#entries + 1] = { id = row.id, direction = outgoing and "outgoing" or "incoming", otherNumber = outgoing and row.recipient_number or row.sender_number, messageType = row.message_type, body = row.body, readAt = row.read_at, createdAt = row.created_at, } end else local rows = Bridge.Database.Query(([[ SELECT `id`, `caller_sim_id`, `caller_number`, `callee_number`, `status`, `started_at`, `answered_at`, `ended_at`, `duration_seconds` FROM `sky_phone_calls` WHERE `caller_sim_id` = ? OR `callee_sim_id` = ? ORDER BY `started_at` DESC LIMIT %s ]]):format(limit), { sim.id, sim.id }) for _, row in ipairs(rows) do local outgoing = row.caller_sim_id == sim.id entries[#entries + 1] = { id = row.id, direction = outgoing and "outgoing" or "incoming", otherNumber = outgoing and row.callee_number or row.caller_number, status = row.status, startedAt = row.started_at, answeredAt = row.answered_at, endedAt = row.ended_at, durationSeconds = tonumber(row.duration_seconds) or 0, } end end write_audit( source, target_source, target_identifier, data.imei, audit_action, { count = #entries } ) return { success = true, data = { kind = data.kind, entries = entries } } end) Bridge.Callbacks.Register("sky_phone:admin:reset-passcode", function(source, data) local authorized, error_response = require_admin( source, "reset_passcode", Config.AdminPanel.ActionRequestsPerMinute ) if not authorized then return error_response end if type(data) ~= "table" or not SkyPhoneImei.IsValid(data.imei) then return { success = false, error = "invalid_request" } end local target_source = normalize_source(data.source) if not target_source then return { success = false, error = "player_unavailable" } end local device, target_identifier = find_owned_device(target_source, data.imei) if not device then return { success = false, error = "device_not_owned" } end local result = Bridge.Database.Query( "DELETE FROM `sky_phone_device_security` WHERE `device_imei` = ?", { data.imei } ) if affected_rows(result) ~= 1 then return { success = false, error = "passcode_not_set" } end write_audit(source, target_source, target_identifier, data.imei, "reset_passcode", {}) SkyPhone.RefreshDevice(data.imei) return { success = true, data = load_player_detail(target_source) } end) Bridge.Callbacks.Register("sky_phone:admin:change-number", function(source, data) local authorized, error_response = require_admin( source, "change_number", Config.AdminPanel.ActionRequestsPerMinute ) if not authorized then return error_response end if type(data) ~= "table" or not SkyPhoneImei.IsValid(data.imei) or (type(data.phoneNumber) ~= "string" and type(data.phoneNumber) ~= "number") then return { success = false, error = "invalid_request" } end local target_source = normalize_source(data.source) if not target_source then return { success = false, error = "player_unavailable" } end local device, target_identifier = find_owned_device(target_source, data.imei) if not device then return { success = false, error = "device_not_owned" } end local sim = load_device_sim(data.imei) if not sim then return { success = false, error = "no_sim" } end local changed, number_or_error = SkyPhoneSim.ChangeNumber( target_source, data.imei, sim.id, data.phoneNumber ) if not changed then return { success = false, error = number_or_error } end SkyPhoneCompanies.ClearCallAvailability(target_source) SkyPhoneCalls.EndForSim(sim.id, "number_changed") write_audit(source, target_source, target_identifier, data.imei, "change_number", { previousNumber = sim.phone_number, phoneNumber = number_or_error, }) SkyPhone.RefreshDevice(data.imei) return { success = true, data = load_player_detail(target_source) } end) Bridge.Callbacks.Register("sky_phone:admin:factory-reset", function(source, data) local authorized, error_response = require_admin( source, "factory_reset", Config.AdminPanel.ActionRequestsPerMinute ) if not authorized then return error_response end if type(data) ~= "table" or not SkyPhoneImei.IsValid(data.imei) then return { success = false, error = "invalid_request" } end local target_source = normalize_source(data.source) if not target_source then return { success = false, error = "player_unavailable" } end local device, target_identifier = find_owned_device(target_source, data.imei) if not device then return { success = false, error = "device_not_owned" } end local reset, phone_number_or_error = SkyPhonePersistence.FactoryReset(data.imei) if not reset then return { success = false, error = phone_number_or_error } end write_audit(source, target_source, target_identifier, data.imei, "factory_reset", {}) if phone_number_or_error then TriggerEvent("sky_phone:server:factoryReset", target_source, phone_number_or_error) end SkyPhone.RefreshDevice(data.imei) return { success = true, data = load_player_detail(target_source) } end) Bridge.Callbacks.Register("sky_phone:admin:configurator", function(source) local authorized, error_response = require_admin( source, "configurator_read", Config.AdminPanel.ReadRequestsPerMinute ) if not authorized then return error_response end return { success = true, data = SkyPhoneConfigurator.GetAdminData() } end) Bridge.Callbacks.Register("sky_phone:admin:save-configurator", function(source, data) local authorized, error_response = require_admin( source, "configurator_save", Config.AdminPanel.ActionRequestsPerMinute ) if not authorized then return error_response end if type(data) ~= "table" or type(data.changes) ~= "table" then return { success = false, error = "invalid_request" } end local actor_identifier = Bridge.Framework.GetIdentifier(source) if not actor_identifier then return { success = false, error = "player_unavailable" } end local actor_name = player_name(source) local response = SkyPhoneConfigurator.Save( data.revision, data.changes, actor_identifier, actor_name ) if response.success then write_audit(source, source, actor_identifier, nil, "save_configuration", { changeCount = #data.changes, revision = response.data.revision, }) end return response end) end)