mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 08:13:23 +00:00
Add the Companies app, resilient call handling, custom app registry, vendor compatibility, storage policies, frontend bridge, tests, documentation, config, locale, and SQL migrations.
1170 lines
40 KiB
Lua
1170 lines
40 KiB
Lua
Bridge.Database.AfterMigration("sky_phone", function()
|
|
Bridge.Debug("debug", "[sky_phone] Server initialization started after database migration.", { always = true })
|
|
|
|
SkyPhone = {}
|
|
|
|
local sessions = {}
|
|
local auth_attempts = {}
|
|
local operation_attempts = {}
|
|
local max_device_data_bytes = 100000
|
|
local passcode_pepper = GetConvar(Config.Security.PasscodePepperConvar, "")
|
|
if passcode_pepper == "" then
|
|
Bridge.Debug(
|
|
"warn",
|
|
"[sky_phone] Passcode pepper convar '%s' is empty; configure it before production use.",
|
|
Config.Security.PasscodePepperConvar,
|
|
{ always = true }
|
|
)
|
|
end
|
|
local allowed_device_namespaces = {
|
|
settings = true,
|
|
notifications = true,
|
|
wallpaper = true,
|
|
alarms = 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 find_device_slots(source, imei)
|
|
local matches = {}
|
|
for _, item in ipairs(Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)) 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
|
|
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 #slots == 1 then
|
|
return slots[1]
|
|
end
|
|
|
|
Bridge.Debug(
|
|
"warn",
|
|
"[sky_phone] Usable item callback did not identify an exact phone slot for source %s (%s candidates).",
|
|
tostring(source),
|
|
tostring(#slots)
|
|
)
|
|
return nil
|
|
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 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 = 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`
|
|
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
|
|
|
|
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,
|
|
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),
|
|
}
|
|
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
|
|
local device = load_device(session.imei)
|
|
if not device or not device.account_id then
|
|
return nil, { success = false, error = "not_authenticated" }
|
|
end
|
|
return {
|
|
id = tonumber(device.account_id),
|
|
email = device.email,
|
|
imei = device.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
|
|
|
|
for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do
|
|
local source = tonumber(player_source) or player_source
|
|
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
|
|
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,
|
|
}
|
|
notified_devices[imei] = true
|
|
TriggerClientEvent(event_name, source, payload)
|
|
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 = resolve_used_slot(source, used_item)
|
|
if not slot then
|
|
Bridge.Debug(
|
|
"debug",
|
|
"[sky_phone] Phone open rejected for source %s: no exact inventory slot.",
|
|
tostring(source),
|
|
{ always = true }
|
|
)
|
|
TriggerClientEvent("sky_phone:device:error", source, "phone_slot_missing")
|
|
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 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,
|
|
}
|
|
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
|
|
|
|
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)
|
|
if sessions[source] and sessions[source].imei ~= imei then
|
|
SkyPhoneCompanies.ClearCallAvailability(source)
|
|
end
|
|
sessions[source] = {
|
|
imei = imei,
|
|
slot = matches[1].slot,
|
|
token = ("%s:%s:%s"):format(imei, tostring(source), tostring(GetGameTimer())),
|
|
unlocked = security == nil,
|
|
}
|
|
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)
|
|
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:development-open", function(source)
|
|
if not Config.Phone.DevelopmentCommand then
|
|
return { success = false, error = "disabled" }
|
|
end
|
|
return { success = open_phone(source, nil) }
|
|
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" or 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
|
|
end)
|
|
|
|
AddEventHandler("onResourceStop", function(resource_name)
|
|
if resource_name == GetCurrentResourceName() then
|
|
sessions = {}
|
|
auth_attempts = {}
|
|
operation_attempts = {}
|
|
end
|
|
end)
|
|
end)
|