Files
sky_phone-sky-systems/sky_phone/source/server/phone.lua
T
Leon.Schmidt e5a1c2bf40 ADD - open unique phones by item and hotkey
Register a configurable F1 phone toggle through a server-authoritative open request and retain the selected unique handset by IMEI. Verify usable-item support for every inventory adapter, fail visibly on registration errors, and document the behavior.
2026-08-17 15:57:56 +02:00

1490 lines
51 KiB
Lua

local phone_open_handler
local pending_phone_opens = {}
local server_started = false
local function flush_pending_phone_opens()
if not server_started or not phone_open_handler then
return
end
for player_source in pairs(pending_phone_opens) do
pending_phone_opens[player_source] = nil
phone_open_handler(player_source, nil)
end
end
Bridge.Callbacks.Register("sky_phone:device:open-request", function(source)
if not server_started or not phone_open_handler then
pending_phone_opens[source] = true
return { success = true, data = { queued = true } }
end
return { success = phone_open_handler(source, nil) }
end)
Bridge.Callbacks.Register("sky_phone:device:development-open", function(source)
if not Config.Phone.DevelopmentCommand then
return { success = false, error = "disabled" }
end
if not server_started or not phone_open_handler then
pending_phone_opens[source] = true
return { success = true, data = { queued = true } }
end
return { success = phone_open_handler(source, nil) }
end)
AddEventHandler("playerDropped", function()
pending_phone_opens[source] = nil
end)
AddEventHandler("onServerResourceStart", function(resource_name)
if resource_name ~= GetCurrentResourceName() then
return
end
server_started = true
flush_pending_phone_opens()
end)
Bridge.Database.AfterMigration("sky_phone", function()
Bridge.Debug("debug", "[sky_phone] Server initialization started after database migration.", { always = true })
SkyPhone = {}
local unique_phones = Config.Phone.Unique ~= false
local sim_cards_enabled = Config.Sim.Enabled ~= false
local sessions = {}
local preferred_device_imeis = {}
local auth_attempts = {}
local operation_attempts = {}
local character_device_cache = {}
local max_device_data_bytes = 100000
local passcode_pepper = tostring(Config.Server.PasscodePepper or "")
if passcode_pepper == "" then
Bridge.Debug(
"warn",
"[sky_phone] Config.Server.PasscodePepper is empty. Device passcodes still work, but their hashes lack the required server-side secret. Set a stable random value in config/config.lua before production; changing it later invalidates existing device passcodes.",
{ always = true }
)
end
local allowed_device_namespaces = {
settings = true,
notifications = true,
wallpaper = true,
alarms = true,
appAuth = true,
apps = true,
games = true,
widgets = true,
}
local function trim(value)
if type(value) ~= "string" then
return nil
end
return value:match("^%s*(.-)%s*$")
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 reserve_imei()
local imei = SkyPhoneImei.Reserve(function()
local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {})
local uuid = rows[1] and rows[1].id
if type(uuid) ~= "string" then
error("[sky_phone] Database did not generate entropy for an IMEI.")
end
return uuid
end, function(candidate)
local result = Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_devices` (`imei`, `device_name`)
VALUES (?, ?)
]], { candidate, Config.Phone.DeviceName })
return affected_rows(result) > 0
end)
if not imei then
error("[sky_phone] Could not generate a unique IMEI after 20 attempts.")
end
return imei
end
local function character_identifier(source)
local identifier = Bridge.Framework.GetIdentifier(source)
if identifier == nil then
return nil
end
identifier = tostring(identifier)
if identifier == "" or #identifier > 80 then
Bridge.Debug(
"warn",
"[sky_phone] Could not resolve a valid character identifier for source %s.",
tostring(source)
)
return nil
end
return identifier
end
local function migrated_device_for_character(source)
local identifier = character_identifier(source)
if not identifier then
return nil
end
local rows = Bridge.Database.Query([[
SELECT mapping.`device_imei`
FROM `sky_phone_migration_owners` mapping
JOIN `sky_phone_character_devices` character_device
ON character_device.`owner_identifier` = mapping.`owner_identifier`
AND character_device.`device_imei` = mapping.`device_imei`
JOIN `sky_phone_devices` device ON device.`imei` = mapping.`device_imei`
WHERE mapping.`source` = 'lb-phone' AND mapping.`owner_identifier` = ?
ORDER BY mapping.`created_at` DESC
LIMIT 1
]], { identifier })
local imei = rows[1] and rows[1].device_imei or nil
if imei and not SkyPhoneImei.IsValid(imei) then
Bridge.Debug(
"error",
"[sky_phone] LB Phone migration mapping contains invalid IMEI '%s' for '%s'.",
tostring(imei),
identifier
)
return nil
end
return imei
end
local function load_character_device(source, identifier)
identifier = identifier or character_identifier(source)
if not identifier then
return nil, "player_unavailable"
end
local cached = character_device_cache[source]
if cached and cached.identifier == identifier then
return cached.imei, nil, identifier
end
local rows = Bridge.Database.Query([[
SELECT `device_imei`
FROM `sky_phone_character_devices`
WHERE `owner_identifier` = ?
LIMIT 1
]], { identifier })
local imei = rows[1] and rows[1].device_imei or nil
if imei then
character_device_cache[source] = { identifier = identifier, imei = imei }
else
character_device_cache[source] = nil
end
return imei, nil, identifier
end
local function cache_character_device(source, identifier, imei)
character_device_cache[source] = { identifier = identifier, imei = imei }
return imei
end
local function map_character_device(source, slot)
local mapped_imei, error_code, identifier = load_character_device(source)
if error_code or mapped_imei then
return mapped_imei, error_code
end
-- On the first switch from unique to shared phones, adopt the currently used
-- legacy device when it has not already been claimed by another character.
local legacy_imei = slot.metadata and slot.metadata.imei or nil
if SkyPhoneImei.IsValid(legacy_imei) then
Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_devices` (`imei`, `device_name`)
VALUES (?, ?)
]], { legacy_imei, Config.Phone.DeviceName })
local result = Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_character_devices` (`owner_identifier`, `device_imei`)
VALUES (?, ?)
]], { identifier, legacy_imei })
if affected_rows(result) == 1 then
return cache_character_device(source, identifier, legacy_imei)
end
mapped_imei = load_character_device(source, identifier)
if mapped_imei then
return mapped_imei
end
end
local imei = reserve_imei()
local result = Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_character_devices` (`owner_identifier`, `device_imei`)
VALUES (?, ?)
]], { identifier, imei })
if affected_rows(result) == 1 then
return cache_character_device(source, identifier, imei)
end
-- Another simultaneous open may have created the mapping first. Keep its
-- winner and remove only the fresh, unmapped device reserved by this call.
mapped_imei = load_character_device(source, identifier)
Bridge.Database.Query([[
DELETE d FROM `sky_phone_devices` d
LEFT JOIN `sky_phone_character_devices` c ON c.`device_imei` = d.`imei`
WHERE d.`imei` = ? AND c.`device_imei` IS NULL
]], { imei })
if mapped_imei then
return mapped_imei
end
Bridge.Debug(
"error",
"[sky_phone] Could not create a shared device mapping for source %s.",
tostring(source)
)
return nil, "request_failed"
end
local function find_device_slots(source, imei)
local matches = {}
local slots = Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)
if not unique_phones then
if not slots[1] then
return matches
end
local mapped_imei = load_character_device(source)
if mapped_imei ~= imei then
return matches
end
for _, item in ipairs(slots) do
matches[#matches + 1] = item
end
return matches
end
for _, item in ipairs(slots) do
if item.metadata and item.metadata.imei == imei then
matches[#matches + 1] = item
end
end
if #matches > 1 then
Bridge.Debug(
"warn",
"[sky_phone] Source %s has %s items with duplicated IMEI %s.",
tostring(source),
tostring(#matches),
imei
)
end
return matches
end
local function resolve_used_slot(source, used_item)
Bridge.Debug(
"debug",
"[sky_phone] Resolving used item for source %s: type=%s slot=%s id=%s name=%s.",
tostring(source),
type(used_item),
tostring(used_item and used_item.slot),
tostring(used_item and used_item.id),
tostring(used_item and used_item.name),
{ always = true }
)
local slot_id = used_item and (used_item.slot or used_item.id)
if slot_id then
local slot = Bridge.Inventory.GetSlot(source, slot_id)
Bridge.Debug(
"debug",
"[sky_phone] Inventory slot lookup for source %s slot %s returned name=%s amount=%s metadata_imei=%s.",
tostring(source),
tostring(slot_id),
tostring(slot and slot.name),
tostring(slot and (slot.amount or slot.count)),
tostring(slot and slot.metadata and slot.metadata.imei),
{ always = true }
)
if slot and slot.name == Config.Phone.Item then
return slot
end
Bridge.Debug(
"warn",
"[sky_phone] Usable item callback reported invalid phone slot %s for source %s.",
tostring(slot_id),
tostring(source)
)
return nil, "phone_slot_missing"
end
local slots = Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)
Bridge.Debug(
"debug",
"[sky_phone] Inventory fallback found %s phone slot candidates for source %s.",
tostring(#slots),
tostring(source),
{ always = true }
)
for _, candidate in ipairs(slots) do
Bridge.Debug(
"debug",
"[sky_phone] Candidate slot=%s name=%s amount=%s metadata_imei=%s.",
tostring(candidate.slot),
tostring(candidate.name),
tostring(candidate.amount or candidate.count),
tostring(candidate.metadata and candidate.metadata.imei),
{ always = true }
)
end
if not unique_phones and #slots > 0 then
return slots[1]
end
local preferred_imei = preferred_device_imeis[source]
if preferred_imei then
for _, candidate in ipairs(slots) do
if candidate.metadata and candidate.metadata.imei == preferred_imei then
return candidate
end
end
end
table.sort(slots, function(left, right)
local left_slot = tonumber(left.slot)
local right_slot = tonumber(right.slot)
if left_slot and right_slot and left_slot ~= right_slot then
return left_slot < right_slot
end
return tostring(left.slot) < tostring(right.slot)
end)
if slots[1] then
return slots[1]
end
Bridge.Debug(
"warn",
"[sky_phone] No phone item slot was available for source %s (%s candidates).",
tostring(source),
tostring(#slots)
)
return nil, "phone_required"
end
local function ensure_device(source, slot)
local amount = tonumber(slot.amount or slot.count) or 0
Bridge.Debug(
"debug",
"[sky_phone] Ensuring device for source %s slot=%s amount=%s existing_imei=%s inventory=%s.",
tostring(source),
tostring(slot.slot),
tostring(amount),
tostring(slot.metadata and slot.metadata.imei),
tostring(Bridge.Inventory.GetResourceName()),
{ always = true }
)
if not unique_phones then
if amount < 1 then
return nil, "phone_slot_missing"
end
return map_character_device(source, slot)
end
if amount ~= 1 then
Bridge.Debug("warn", "[sky_phone] Phone item in slot %s is stacked for source %s.", tostring(slot.slot), tostring(source))
return nil, "phone_stacked"
end
local metadata = slot.metadata or {}
local imei = metadata.imei
if imei and not SkyPhoneImei.IsValid(imei) then
Bridge.Debug("warn", "[sky_phone] Phone item in slot %s has invalid IMEI metadata.", tostring(slot.slot))
return nil, "invalid_imei"
end
if not imei then
imei = migrated_device_for_character(source)
if imei then
metadata.imei = imei
local metadata_written = Bridge.Inventory.SetSlotMetadata(source, slot.slot, metadata)
if not metadata_written then
Bridge.Debug(
"error",
"[sky_phone] Inventory '%s' could not adopt migrated phone metadata for source %s slot %s.",
Bridge.Inventory.GetResourceName(),
tostring(source),
tostring(slot.slot)
)
return nil, "metadata_unsupported"
end
Bridge.Debug(
"info",
"[sky_phone] Adopted LB Phone migrated device %s for source %s.",
imei,
tostring(source),
{ always = true }
)
return imei
end
imei = reserve_imei()
metadata.imei = imei
Bridge.Debug(
"debug",
"[sky_phone] Reserved IMEI %s; writing metadata to source %s slot %s.",
imei,
tostring(source),
tostring(slot.slot),
{ always = true }
)
local metadata_written = Bridge.Inventory.SetSlotMetadata(source, slot.slot, metadata)
Bridge.Debug(
"debug",
"[sky_phone] Metadata write result for source %s slot %s IMEI %s: %s.",
tostring(source),
tostring(slot.slot),
imei,
tostring(metadata_written),
{ always = true }
)
if not metadata_written then
Bridge.Database.Query("DELETE FROM `sky_phone_devices` WHERE `imei` = ?", { imei })
Bridge.Debug(
"error",
"[sky_phone] Inventory '%s' could not write phone metadata for source %s slot %s.",
Bridge.Inventory.GetResourceName(),
tostring(source),
tostring(slot.slot)
)
return nil, "metadata_unsupported"
end
return imei
end
Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_devices` (`imei`, `device_name`)
VALUES (?, ?)
]], { imei, Config.Phone.DeviceName })
return imei
end
local function load_device(imei)
local rows = Bridge.Database.Query([[
SELECT d.`imei`, d.`device_name`, d.`account_id`, d.`sim_id`, d.`created_at`, d.`updated_at`,
a.`email`, s.`phone_number`, s.`sim_type`, s.`registered_at`, s.`is_virtual` AS `sim_is_virtual`
FROM `sky_phone_devices` d
LEFT JOIN `sky_phone_accounts` a ON a.`id` = d.`account_id`
LEFT JOIN `sky_phone_sims` s ON s.`id` = d.`sim_id`
WHERE d.`imei` = ?
LIMIT 1
]], { imei })
return rows[1]
end
local function load_device_data(imei)
local rows = Bridge.Database.Query([[
SELECT `namespace`, `payload`, `revision`
FROM `sky_phone_device_data`
WHERE `device_imei` = ?
]], { imei })
local data = {}
for _, row in ipairs(rows) do
data[row.namespace] = {
payload = json.decode(row.payload),
revision = tonumber(row.revision) or 0,
}
end
return data
end
local function load_device_security(imei)
local rows = Bridge.Database.Query([[
SELECT `passcode_length`, `failed_attempts`, `locked_until`
FROM `sky_phone_device_security`
WHERE `device_imei` = ?
LIMIT 1
]], { imei })
return rows[1]
end
local function security_status(imei, security, security_loaded)
if not security_loaded then
security = load_device_security(imei)
end
return {
enabled = security ~= nil,
length = security and tonumber(security.passcode_length) or nil,
lockedUntil = security and tonumber(security.locked_until) or 0,
}
end
local function valid_passcode(value)
return type(value) == "string"
and (#value == 4 or #value == 6)
and value:match("^%d+$") ~= nil
end
local function passcode_matches(imei, passcode)
local rows = Bridge.Database.Query([[
SELECT 1 AS `matches`
FROM `sky_phone_device_security`
WHERE `device_imei` = ?
AND `passcode_hash` = UNHEX(SHA2(CONCAT(?, `passcode_salt`, ?), 256))
LIMIT 1
]], { imei, passcode_pepper, passcode })
return rows[1] ~= nil
end
local function verify_passcode(session, passcode)
if not valid_passcode(passcode) then
return false, { success = false, error = "invalid_passcode" }
end
local security = load_device_security(session.imei)
if not security then
return false, { success = false, error = "passcode_not_set" }
end
local now = os.time()
local locked_until = tonumber(security.locked_until) or 0
if locked_until > now then
return false, {
success = false,
error = "passcode_locked",
data = { retryAfter = locked_until - now },
}
end
if passcode_matches(session.imei, passcode) then
Bridge.Database.Query([[
UPDATE `sky_phone_device_security`
SET `failed_attempts` = 0, `locked_until` = 0
WHERE `device_imei` = ?
]], { session.imei })
return true
end
local failed_attempts = (tonumber(security.failed_attempts) or 0) + 1
if failed_attempts >= Config.Security.MaximumAttempts then
local next_unlock = now + Config.Security.LockSeconds
Bridge.Database.Query([[
UPDATE `sky_phone_device_security`
SET `failed_attempts` = 0, `locked_until` = ?
WHERE `device_imei` = ?
]], { next_unlock, session.imei })
return false, {
success = false,
error = "passcode_locked",
data = { retryAfter = Config.Security.LockSeconds },
}
end
Bridge.Database.Query([[
UPDATE `sky_phone_device_security`
SET `failed_attempts` = ?
WHERE `device_imei` = ?
]], { failed_attempts, session.imei })
return false, {
success = false,
error = "invalid_passcode",
data = { attemptsRemaining = Config.Security.MaximumAttempts - failed_attempts },
}
end
local function account_devices(account_id, current_imei)
local rows = Bridge.Database.Query([[
SELECT `imei`, `device_name`, `created_at`, `updated_at`
FROM `sky_phone_devices`
WHERE `account_id` = ?
ORDER BY `updated_at` DESC
]], { account_id })
for _, row in ipairs(rows) do
row.current = row.imei == current_imei
end
return rows
end
local function bootstrap(source, security, security_loaded)
local session, error_response = SkyPhone.RequireDeviceSession(source)
if not session then
return nil, error_response
end
local device = load_device(session.imei)
if not device then
error(("[sky_phone] Active IMEI %s has no device row."):format(session.imei))
end
session.account_id = device.account_id and tonumber(device.account_id) or nil
session.account_email = session.account_id and device.email or nil
return {
token = session.token,
security = security_status(device.imei, security, security_loaded),
device = {
imei = device.imei,
name = device.device_name,
sim = device.sim_id and {
id = device.sim_id,
number = device.phone_number,
removable = sim_cards_enabled and tonumber(device.sim_is_virtual) ~= 1,
type = device.sim_type,
registered = device.registered_at ~= nil,
} or nil,
data = load_device_data(device.imei),
},
account = device.account_id and {
id = tonumber(device.account_id),
email = device.email,
devices = account_devices(device.account_id, device.imei),
} or nil,
notes = SkyPhoneNotes.List(device.account_id, device.imei),
memos = SkyPhoneMemos.List(device.account_id, device.imei),
player = {
firstName = trim(Bridge.Framework.GetFirstname(source)) or "",
lastName = trim(Bridge.Framework.GetLastname(source)) or "",
},
}
end
local function refresh_source(source)
local payload = bootstrap(source)
if payload then
TriggerClientEvent("sky_phone:device:updated", source, payload)
end
end
SkyPhone.EnsureDevice = ensure_device
SkyPhone.FindDeviceSlots = find_device_slots
SkyPhone.LoadDevice = load_device
SkyPhone.RefreshSource = refresh_source
local function allow_auth_attempt(source)
local now = os.time()
local attempts = auth_attempts[source]
if not attempts or now - attempts.started_at >= 60 then
auth_attempts[source] = { count = 1, started_at = now }
return true
end
if attempts.count >= Config.Mail.AuthAttemptsPerMinute then
return false
end
attempts.count = attempts.count + 1
return true
end
local function normalize_email(value)
local email = trim(value)
if not email then
return nil
end
email = email:lower()
local local_part = email
if email:find("@", 1, true) then
local_part = email:match("^([^@]+)@" .. Config.Mail.Domain:gsub("%.", "%%.") .. "$")
end
if not local_part
or #local_part < Config.Mail.LocalPartMinLength
or #local_part > Config.Mail.LocalPartMaxLength
or not local_part:match("^[a-z0-9][a-z0-9._-]*[a-z0-9]$")
or local_part:find("..", 1, true)
then
return nil
end
return local_part .. "@" .. Config.Mail.Domain
end
local function valid_password(value)
if type(value) ~= "string" then
return false
end
local length = utf8.len(value)
return length and length >= Config.Mail.PasswordMinLength and length <= Config.Mail.PasswordMaxLength
end
local function link_account(source, account)
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return error_response
end
if not SkyPhoneCalls.LinkAccountData(account.id, session.imei) then
return { success = false, error = "request_failed" }
end
if not Bridge.Database.Transaction({
{
query = "UPDATE `sky_phone_devices` SET `account_id` = ? WHERE `imei` = ?",
params = { account.id, session.imei },
},
{
query = [[
UPDATE `sky_phone_notes`
SET `account_id` = ?, `device_imei` = NULL, `revision` = `revision` + 1
WHERE `device_imei` = ? AND `account_id` IS NULL
]],
params = { account.id, session.imei },
},
{
query = [[
UPDATE `sky_phone_media`
SET `account_id` = ?, `device_imei` = NULL
WHERE `device_imei` = ? AND `account_id` IS NULL
]],
params = { account.id, session.imei },
},
{
query = [[
UPDATE `sky_phone_music_playlist_items` AS `item`
INNER JOIN `sky_phone_music_playlists` AS `playlist`
ON `playlist`.`id` = `item`.`playlist_id`
INNER JOIN `sky_phone_music_youtube_songs` AS `local_song`
ON `item`.`source` = 'youtube' AND `local_song`.`id` = `item`.`song_id`
INNER JOIN `sky_phone_music_youtube_songs` AS `cloud_song`
ON `cloud_song`.`account_id` = ? AND `cloud_song`.`video_id` = `local_song`.`video_id`
SET `item`.`song_id` = `cloud_song`.`id`
WHERE `playlist`.`device_imei` = ? AND `playlist`.`account_id` IS NULL
AND `local_song`.`device_imei` = ? AND `local_song`.`account_id` IS NULL
]],
params = { account.id, session.imei, session.imei },
},
{
query = [[
DELETE `local_song` FROM `sky_phone_music_youtube_songs` AS `local_song`
INNER JOIN `sky_phone_music_youtube_songs` AS `cloud_song`
ON `cloud_song`.`account_id` = ? AND `cloud_song`.`video_id` = `local_song`.`video_id`
WHERE `local_song`.`device_imei` = ? AND `local_song`.`account_id` IS NULL
]],
params = { account.id, session.imei },
},
{
query = [[
UPDATE `sky_phone_music_youtube_songs`
SET `account_id` = ?, `device_imei` = NULL
WHERE `device_imei` = ? AND `account_id` IS NULL
]],
params = { account.id, session.imei },
},
{
query = [[
UPDATE `sky_phone_music_playlists`
SET `account_id` = ?, `device_imei` = NULL
WHERE `device_imei` = ? AND `account_id` IS NULL
]],
params = { account.id, session.imei },
},
}) then
return { success = false, error = "request_failed" }
end
refresh_source(source)
return {
success = true,
data = {
email = account.email,
devices = account_devices(account.id, session.imei),
},
}
end
local function authenticate(source, data, registering)
if not allow_auth_attempt(source) then
return { success = false, error = "rate_limited" }
end
if type(data) ~= "table" then
return { success = false, error = "invalid_request" }
end
local email = normalize_email(data.email)
local password = data.password
if not email then
return { success = false, error = registering and "invalid_email" or "invalid_credentials" }
end
if not valid_password(password) then
return { success = false, error = registering and "invalid_password" or "invalid_credentials" }
end
if registering then
local result = Bridge.Database.Query(
"INSERT IGNORE INTO `sky_phone_accounts` (`email`, `password`) VALUES (?, ?)",
{ email, password }
)
if affected_rows(result) == 0 then
return { success = false, error = "email_taken" }
end
end
local accounts = Bridge.Database.Query(
"SELECT `id`, `email` FROM `sky_phone_accounts` WHERE `email` = ? AND `password` = ? LIMIT 1",
{ email, password }
)
if not accounts[1] then
return { success = false, error = "invalid_credentials" }
end
return link_account(source, accounts[1])
end
function SkyPhone.RequireDeviceSession(source)
local session = sessions[source]
if not session then
return nil, { success = false, error = "device_not_open" }
end
local matches = find_device_slots(source, session.imei)
if not matches[1] then
SkyPhoneCompanies.ClearCallAvailability(source)
sessions[source] = nil
TriggerClientEvent("sky_phone:device:invalidated", source)
return nil, { success = false, error = "device_not_owned" }
end
session.slot = matches[1].slot
return session
end
function SkyPhone.RequireSession(source)
local session, error_response = SkyPhone.RequireDeviceSession(source)
if not session then
return nil, error_response
end
if not session.unlocked then
return nil, { success = false, error = "device_locked" }
end
return session
end
function SkyPhone.AllowOperation(source, operation, maximum, window_seconds)
local now = os.time()
operation_attempts[source] = operation_attempts[source] or {}
local attempts = operation_attempts[source][operation]
if not attempts or now - attempts.started_at >= window_seconds then
operation_attempts[source][operation] = { count = 1, started_at = now }
return true
end
if attempts.count >= maximum then
Bridge.Debug(
"warn",
"[sky_phone] Rate limit '%s' exceeded by source %s.",
operation,
tostring(source)
)
return false
end
attempts.count = attempts.count + 1
return true
end
function SkyPhone.RequireAccount(source)
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return nil, error_response
end
if not session.account_id then
return nil, { success = false, error = "not_authenticated" }
end
return {
id = session.account_id,
email = session.account_email,
imei = session.imei,
}
end
function SkyPhone.NotifyAccount(account_id, event_name, data)
for source in pairs(sessions) do
local account = SkyPhone.RequireAccount(source)
if account and account.id == tonumber(account_id) then
TriggerClientEvent(event_name, source, data)
end
end
end
function SkyPhone.NotifyAccountDevices(account_id, event_name, data)
local rows = Bridge.Database.Query([[
SELECT d.`imei`, d.`device_name`, settings.`payload` AS `settings`
FROM `sky_phone_devices` d
LEFT JOIN `sky_phone_device_data` settings
ON settings.`device_imei` = d.`imei` AND settings.`namespace` = 'settings'
WHERE d.`account_id` = ?
]], { account_id })
local devices = {}
for _, row in ipairs(rows) do
devices[row.imei] = row
end
local function notify_device(source, device)
local payload = {}
for key, value in pairs(data) do
payload[key] = value
end
payload.device = {
imei = device.imei,
name = device.device_name,
settings = device.settings,
}
TriggerClientEvent(event_name, source, payload)
end
for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do
local source = tonumber(player_source) or player_source
if not unique_phones then
if Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)[1] then
local imei = load_character_device(source)
local device = imei and devices[imei] or nil
if device then
notify_device(source, device)
end
end
else
local notified_devices = {}
for _, item in ipairs(Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)) do
local imei = item.metadata and item.metadata.imei
local device = imei and devices[imei]
if device and not notified_devices[imei] then
notified_devices[imei] = true
notify_device(source, device)
end
end
end
end
end
function SkyPhone.RefreshAccount(account_id)
for source in pairs(sessions) do
local account = SkyPhone.RequireAccount(source)
if account and account.id == tonumber(account_id) then
refresh_source(source)
end
end
end
function SkyPhone.RefreshDevice(imei)
for source, session in pairs(sessions) do
if session.imei == imei then
refresh_source(source)
end
end
end
local function open_phone(source, used_item)
local opened_at = GetGameTimer()
Bridge.Debug(
"debug",
"[sky_phone] Usable phone callback invoked for source %s.",
tostring(source),
{ always = true }
)
local slot, slot_error = resolve_used_slot(source, used_item)
if not slot then
Bridge.Debug(
"debug",
"[sky_phone] Phone open rejected for source %s: %s.",
tostring(source),
tostring(slot_error),
{ always = true }
)
TriggerClientEvent("sky_phone:device:error", source, slot_error)
return false
end
local imei, error_code = ensure_device(source, slot)
if not imei then
Bridge.Debug(
"debug",
"[sky_phone] Phone open rejected for source %s slot %s: %s.",
tostring(source),
tostring(slot.slot),
tostring(error_code),
{ always = true }
)
TriggerClientEvent("sky_phone:device:error", source, error_code)
return false
end
local prepared, prepare_error = SkyPhoneSim.PrepareDevice(source, slot, imei)
if not prepared then
Bridge.Debug(
"error",
"[sky_phone] Phone preparation failed for source %s IMEI %s: %s.",
tostring(source),
imei,
tostring(prepare_error)
)
TriggerClientEvent("sky_phone:device:error", source, prepare_error or "request_failed")
return false
end
local security = load_device_security(imei)
if sessions[source] and sessions[source].imei ~= imei then
SkyPhoneCompanies.ClearCallAvailability(source)
end
sessions[source] = {
imei = imei,
slot = slot.slot,
token = ("%s:%s:%s"):format(imei, tostring(source), tostring(GetGameTimer())),
unlocked = security == nil,
}
preferred_device_imeis[source] = imei
local payload = bootstrap(source, security, true)
Bridge.Debug(
"debug",
"[sky_phone] Triggering client open for source %s slot %s IMEI %s account_linked=%s after %sms.",
tostring(source),
tostring(slot.slot),
imei,
tostring(payload.account ~= nil),
tostring(GetGameTimer() - opened_at),
{ always = true }
)
TriggerClientEvent("sky_phone:device:open", source, payload)
return true
end
phone_open_handler = open_phone
flush_pending_phone_opens()
function SkyPhone.OpenDeviceForCall(source, imei)
local matches = find_device_slots(source, imei)
if not matches[1] then
Bridge.Debug("warn", "[sky_phone] Could not open ringing device %s for source %s.", tostring(imei), tostring(source))
return false
end
local security = load_device_security(imei)
local existing_session = sessions[source]
if existing_session and existing_session.imei ~= imei then
SkyPhoneCompanies.ClearCallAvailability(source)
end
if existing_session and existing_session.imei == imei then
existing_session.slot = matches[1].slot
else
sessions[source] = {
imei = imei,
slot = matches[1].slot,
token = ("%s:%s:%s"):format(imei, tostring(source), tostring(GetGameTimer())),
unlocked = security == nil,
}
end
preferred_device_imeis[source] = imei
TriggerClientEvent("sky_phone:device:open", source, bootstrap(source))
return true
end
Bridge.Debug(
"debug",
"[sky_phone] Registering usable item '%s' through inventory '%s'.",
Config.Phone.Item,
tostring(Bridge.Inventory.GetResourceName()),
{ always = true }
)
local usable_registered = Bridge.Inventory.RegisterUsableItem(Config.Phone.Item, open_phone)
if not usable_registered then
error(("[sky_phone] Inventory '%s' did not register phone item '%s' as usable."):format(
tostring(Bridge.Inventory.GetResourceName()),
tostring(Config.Phone.Item)
))
end
Bridge.Debug(
"debug",
"[sky_phone] Usable item registration returned: %s.",
tostring(usable_registered),
{ always = true }
)
Bridge.Callbacks.Register("sky_phone:device:close", function(source)
SkyPhoneCompanies.ClearCallAvailability(source)
sessions[source] = nil
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:device:notification-open", function(source, data)
if not SkyPhone.AllowOperation(source, "notification_open", 10, 60)
or type(data) ~= "table" or type(data.imei) ~= "string"
then
return { success = false, error = "invalid_request" }
end
if not SkyPhone.OpenDeviceForCall(source, data.imei) then
return { success = false, error = "device_not_owned" }
end
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:security:unlock", function(source, data)
if not SkyPhone.AllowOperation(source, "security_unlock", Config.Security.AttemptsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local session, error_response = SkyPhone.RequireDeviceSession(source)
if not session then
return error_response
end
if session.unlocked then
return { success = true, data = { security = security_status(session.imei) } }
end
local verified, verification_error = verify_passcode(session, data and data.passcode)
if not verified then
return verification_error
end
session.unlocked = true
return { success = true, data = { security = security_status(session.imei) } }
end)
Bridge.Callbacks.Register("sky_phone:security:set-passcode", function(source, data)
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return error_response
end
local passcode = data and data.passcode
if not valid_passcode(passcode) then
return { success = false, error = "invalid_passcode" }
end
if load_device_security(session.imei) then
return { success = false, error = "passcode_already_set" }
end
local salts = Bridge.Database.Query("SELECT REPLACE(UUID(), '-', '') AS `salt`", {})
local salt = salts[1] and salts[1].salt
if type(salt) ~= "string" or #salt ~= 32 then
error("[sky_phone] Database did not generate a valid passcode salt.")
end
local result = Bridge.Database.Query([[
INSERT INTO `sky_phone_device_security`
(`device_imei`, `passcode_hash`, `passcode_salt`, `passcode_length`)
VALUES (?, UNHEX(SHA2(CONCAT(?, ?, ?), 256)), ?, ?)
]], { session.imei, passcode_pepper, salt, passcode, salt, #passcode })
if affected_rows(result) ~= 1 then
return { success = false, error = "request_failed" }
end
return { success = true, data = { security = security_status(session.imei) } }
end)
Bridge.Callbacks.Register("sky_phone:security:change-passcode", function(source, data)
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return error_response
end
local new_passcode = data and data.newPasscode
if not valid_passcode(new_passcode) then
return { success = false, error = "invalid_passcode" }
end
local verified, verification_error = verify_passcode(session, data and data.currentPasscode)
if not verified then
return verification_error
end
local salts = Bridge.Database.Query("SELECT REPLACE(UUID(), '-', '') AS `salt`", {})
local salt = salts[1] and salts[1].salt
if type(salt) ~= "string" or #salt ~= 32 then
error("[sky_phone] Database did not generate a valid passcode salt.")
end
local result = Bridge.Database.Query([[
UPDATE `sky_phone_device_security`
SET `passcode_hash` = UNHEX(SHA2(CONCAT(?, ?, ?), 256)),
`passcode_salt` = ?, `passcode_length` = ?, `failed_attempts` = 0, `locked_until` = 0
WHERE `device_imei` = ?
]], { passcode_pepper, salt, new_passcode, salt, #new_passcode, session.imei })
if affected_rows(result) ~= 1 then
return { success = false, error = "request_failed" }
end
return { success = true, data = { security = security_status(session.imei) } }
end)
Bridge.Callbacks.Register("sky_phone:security:disable-passcode", function(source, data)
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return error_response
end
local verified, verification_error = verify_passcode(session, data and data.passcode)
if not verified then
return verification_error
end
local result = Bridge.Database.Query(
"DELETE FROM `sky_phone_device_security` WHERE `device_imei` = ?",
{ session.imei }
)
if affected_rows(result) ~= 1 then
return { success = false, error = "request_failed" }
end
session.unlocked = true
return { success = true, data = { security = security_status(session.imei) } }
end)
Bridge.Callbacks.Register("sky_phone:device:save", function(source, data)
if not SkyPhone.AllowOperation(source, "device_save", 120, 60) then
return { success = false, error = "rate_limited" }
end
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return error_response
end
if type(data) ~= "table" then
return { success = false, error = "invalid_request" }
end
if data.imei ~= session.imei or data.sessionToken ~= session.token then
return { success = false, error = "stale_session" }
end
if not allowed_device_namespaces[data.namespace] then
return { success = false, error = "invalid_namespace" }
end
local encoded = json.encode(data.payload)
if #encoded > max_device_data_bytes then
return { success = false, error = "payload_too_large" }
end
local revision = math.max(0, math.floor(tonumber(data.revision) or 0))
local rows = Bridge.Database.Query([[
SELECT `payload`, `revision`
FROM `sky_phone_device_data`
WHERE `device_imei` = ? AND `namespace` = ?
LIMIT 1
]], { session.imei, data.namespace })
if rows[1] then
local current_revision = tonumber(rows[1].revision) or 0
if revision ~= current_revision then
refresh_source(source)
return {
success = false,
error = "conflict",
data = { payload = json.decode(rows[1].payload), revision = current_revision },
}
end
local result = Bridge.Database.Query([[
UPDATE `sky_phone_device_data`
SET `payload` = ?, `revision` = `revision` + 1
WHERE `device_imei` = ? AND `namespace` = ? AND `revision` = ?
]], { encoded, session.imei, data.namespace, revision })
if affected_rows(result) ~= 1 then
refresh_source(source)
return { success = false, error = "conflict" }
end
return { success = true, data = { revision = revision + 1 } }
end
if revision ~= 0 then
return { success = false, error = "conflict" }
end
local result = Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_device_data` (`device_imei`, `namespace`, `payload`)
VALUES (?, ?, ?)
]], { session.imei, data.namespace, encoded })
if affected_rows(result) <= 0 then
refresh_source(source)
return { success = false, error = "conflict" }
end
return { success = true, data = { revision = 1 } }
end)
Bridge.Callbacks.Register("sky_phone:notifications:save", function(source, data)
if not SkyPhone.AllowOperation(source, "notifications_save", 240, 60) then
return { success = false, error = "rate_limited" }
end
if type(data) ~= "table" or not SkyPhoneImei.IsValid(data.imei) or type(data.payload) ~= "table" then
return { success = false, error = "invalid_request" }
end
if #SkyPhone.FindDeviceSlots(source, data.imei) == 0 then
Bridge.Debug(
"warn",
"[sky_phone] Source %s attempted to save notifications for unowned device %s.",
tostring(source),
tostring(data.imei)
)
return { success = false, error = "device_not_owned" }
end
if data.payload.version ~= 1 or type(data.payload.items) ~= "table" then
return { success = false, error = "invalid_request" }
end
local item_count = #data.payload.items
local key_count = 0
for key in pairs(data.payload.items) do
if type(key) ~= "number" or key % 1 ~= 0 or key < 1 or key > item_count then
return { success = false, error = "invalid_request" }
end
key_count = key_count + 1
end
if key_count ~= item_count then
return { success = false, error = "invalid_request" }
end
for _, item in ipairs(data.payload.items) do
if type(item) ~= "table"
or type(item.appId) ~= "string" or #item.appId > 64
or type(item.id) ~= "string" or #item.id > 100
or type(item.title) ~= "string" or #item.title > 200
or type(item.text) ~= "string" or #item.text > 2000
or (item.subtitle ~= nil and (type(item.subtitle) ~= "string" or #item.subtitle > 200))
then
return { success = false, error = "invalid_request" }
end
end
local encoded = json.encode(data.payload)
if #encoded > max_device_data_bytes then
return { success = false, error = "payload_too_large" }
end
Bridge.Database.Query([[
INSERT INTO `sky_phone_device_data` (`device_imei`, `namespace`, `payload`)
VALUES (?, 'notifications', ?)
ON DUPLICATE KEY UPDATE `payload` = VALUES(`payload`), `revision` = `revision` + 1
]], { data.imei, encoded })
return { success = true }
end)
for _, endpoint in ipairs({ "account:login", "mail:login" }) do
Bridge.Callbacks.Register("sky_phone:" .. endpoint, function(source, data)
return authenticate(source, data, false)
end)
end
for _, endpoint in ipairs({ "account:register", "mail:register" }) do
Bridge.Callbacks.Register("sky_phone:" .. endpoint, function(source, data)
return authenticate(source, data, true)
end)
end
for _, endpoint in ipairs({ "account:logout", "mail:logout" }) do
Bridge.Callbacks.Register("sky_phone:" .. endpoint, function(source)
local account, error_response = SkyPhone.RequireAccount(source)
if not account then
return error_response
end
if not SkyPhoneCalls.CopyCloudToDevice(account.id, account.imei) then
return { success = false, error = "request_failed" }
end
Bridge.Database.Query("UPDATE `sky_phone_devices` SET `account_id` = NULL WHERE `imei` = ?", { account.imei })
refresh_source(source)
return { success = true }
end)
end
Bridge.Callbacks.Register("sky_phone:account:devices", function(source)
local account, error_response = SkyPhone.RequireAccount(source)
if not account then
return error_response
end
return { success = true, data = account_devices(account.id, account.imei) }
end)
Bridge.Callbacks.Register("sky_phone:account:remove-device", function(source, data)
if not SkyPhone.AllowOperation(source, "remove_device", 10, 60) then
return { success = false, error = "rate_limited" }
end
local account, error_response = SkyPhone.RequireAccount(source)
if not account then
return error_response
end
if type(data) ~= "table" or not SkyPhoneImei.IsValid(data.imei) or not valid_password(data.password) then
return { success = false, error = "invalid_request" }
end
if data.imei == account.imei then
return { success = false, error = "current_device" }
end
local passwords = Bridge.Database.Query("SELECT `id` FROM `sky_phone_accounts` WHERE `id` = ? AND `password` = ? LIMIT 1", {
account.id,
data.password,
})
if not passwords[1] then
return { success = false, error = "invalid_credentials" }
end
local result = Bridge.Database.Query(
"UPDATE `sky_phone_devices` SET `account_id` = NULL WHERE `imei` = ? AND `account_id` = ?",
{ data.imei, account.id }
)
if affected_rows(result) ~= 1 then
return { success = false, error = "device_not_found" }
end
SkyPhone.RefreshDevice(data.imei)
SkyPhone.RefreshAccount(account.id)
return { success = true, data = account_devices(account.id, account.imei) }
end)
Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source)
if not SkyPhone.AllowOperation(source, "factory_reset", 3, 60) then
return { success = false, error = "rate_limited" }
end
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return error_response
end
local media_remote_ids = SkyPhoneMedia.GetDeviceRemoteIds(session.imei)
if not Bridge.Database.Transaction({
{
query = "DELETE FROM `sky_phone_device_security` WHERE `device_imei` = ?",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_device_data` WHERE `device_imei` = ?",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_custom_app_data` WHERE `device_imei` = ?",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_notes` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_media` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_music_playlists` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_music_youtube_songs` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_contacts` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_call_entries` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_fliptok_sessions` WHERE `device_imei` = ?",
params = { session.imei },
},
{
query = "UPDATE `sky_phone_devices` SET `account_id` = NULL, `device_name` = ? WHERE `imei` = ?",
params = { Config.Phone.DeviceName, session.imei },
},
}) then
return { success = false, error = "request_failed" }
end
SkyPhoneMedia.CleanupRemoteFiles(media_remote_ids)
session.unlocked = true
refresh_source(source)
return { success = true }
end)
AddEventHandler("playerDropped", function()
SkyPhoneCompanies.ClearCallAvailability(source)
sessions[source] = nil
auth_attempts[source] = nil
operation_attempts[source] = nil
character_device_cache[source] = nil
preferred_device_imeis[source] = nil
end)
AddEventHandler("onResourceStop", function(resource_name)
if resource_name == GetCurrentResourceName() then
sessions = {}
auth_attempts = {}
operation_attempts = {}
character_device_cache = {}
preferred_device_imeis = {}
end
end)
end)