mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 17:01:18 +00:00
ADD - introduce unique phone devices
This commit is contained in:
@@ -1,6 +1,16 @@
|
||||
# sky_phone
|
||||
|
||||
Standalone FiveM phone boilerplate built with Vue 3, TypeScript, Pinia, Vue Router, Konsta UI 5, and Tailwind CSS 4. The resource currently exposes only the localized `/phone` toggle and the private NUI `ui:ready`/`close` callbacks.
|
||||
Standalone FiveM phone built with Vue 3, TypeScript, Pinia, Vue Router, Konsta UI 5, and Tailwind CSS 4. Each non-stackable `phone` item receives a unique 15-digit IMEI and owns its server-persisted device state. The phone opens through the usable item; `/phone` is disabled unless `Config.Phone.DevelopmentCommand` is enabled explicitly.
|
||||
|
||||
An iFruit account is optional. Unlinked devices retain local settings, alarms, media, apps, and notes. Linking from Mail or Settings moves local notes into the account and exposes the same notes and mailbox on every linked device. Signing out hides cloud data without deleting it, while a factory reset removes only the current device's local data and account link.
|
||||
|
||||
## Requirements
|
||||
|
||||
- `sky_base` with a slot-aware inventory adapter implementing `GetInventorySlot`, `GetInventorySlotsWithItem`, and `SetInventorySlotMetadata`.
|
||||
- A non-stackable inventory item named `phone` (or the matching value in `Config.Phone.Item`).
|
||||
- MySQL/MariaDB through the database driver configured in `sky_base`.
|
||||
|
||||
Database migrations run automatically. Existing `sky_phone_mail_accounts` installations are renamed to `sky_phone_accounts` while preserving account IDs and mail foreign keys. iFruit passwords are intentional in-character credentials and remain plaintext `VARCHAR(64)` values; registration screens warn players never to reuse a real password.
|
||||
|
||||
The homescreen is an original implementation inspired by the interaction and layout concepts in [lukejacksonn/homescreen](https://github.com/lukejacksonn/homescreen), inspected at commit [`98a812f`](https://github.com/lukejacksonn/homescreen/tree/98a812f4f7c33594e791d65092f73b8f54b3c598). No source code or image assets from that project are included.
|
||||
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
Config.Command = "phone"
|
||||
|
||||
Config.Phone = {
|
||||
Item = "phone",
|
||||
DevelopmentCommand = false,
|
||||
DeviceName = "iFruit Phone",
|
||||
MaxDeviceDataBytes = 100000,
|
||||
AllowedDeviceNamespaces = {
|
||||
settings = true,
|
||||
notifications = true,
|
||||
wallpaper = true,
|
||||
alarms = true,
|
||||
media = true,
|
||||
apps = true,
|
||||
},
|
||||
}
|
||||
|
||||
Config.Mail = {
|
||||
Domain = "ifruit.com",
|
||||
LocalPartMinLength = 3,
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
Locales["en"] = {
|
||||
CommandDescription = "Open your phone.",
|
||||
DeviceErrors = {
|
||||
phone_slot_missing = "The used phone could not be identified. Make sure phones are not stacked.",
|
||||
phone_stacked = "Phones cannot be stacked.",
|
||||
invalid_imei = "This phone has invalid IMEI metadata.",
|
||||
metadata_unsupported = "The configured inventory cannot store unique phone metadata.",
|
||||
default = "The phone could not be opened.",
|
||||
},
|
||||
Nui = {
|
||||
Common = {
|
||||
add = "Add", back = "Back", cancel = "Cancel", close = "Close", delete = "Delete", done = "Done", edit = "Edit", home = "Home", loading = "Loading", pause = "Pause",
|
||||
@@ -110,7 +117,8 @@ Locales["en"] = {
|
||||
settings = {
|
||||
name = "Settings", searchPlaceholder = "Search", airplaneMode = "Airplane Mode", streamerMode = "Streamer Mode",
|
||||
wallpaper = "Wallpaper", on = "On", off = "Off", accountName = "iFruit Account",
|
||||
accountDetail = "Cloud, Media & Purchases", accountLocalDetail = "Local account for this phone",
|
||||
accountDetail = "Cloud, Mail & Notes", accountLocalDetail = "Not signed in",
|
||||
accountCloudDetail = "Mail and notes sync through iFruit Cloud", accountLoginBody = "Sign in is optional. This phone also works with local data only.",
|
||||
accountInformation = "Account Information", accountStatus = "Account Status", accountStatusValue = "Active",
|
||||
accountStorage = "Cloud Storage", accountStorageValue = "On Device", accountPurchases = "Media & Purchases",
|
||||
accountPurchasesValue = "Available", notifications = "Notifications", sounds = "Sounds & Haptics",
|
||||
@@ -121,7 +129,16 @@ Locales["en"] = {
|
||||
automatic = "Automatic", light = "Light", dark = "Dark", phoneScale = "Phone Scale", phoneFrame = "Phone Frame",
|
||||
about = "About", deviceName = "Device Name", deviceNameValue = "Sky Phone", softwareVersion = "Software Version",
|
||||
language = "Language", languageValue = "English", localStorage = "Local Storage", localStorageValue = "On Device",
|
||||
back = "Settings", wallpaperPicker = "Built-in Wallpapers",
|
||||
back = "Settings", wallpaperPicker = "Built-in Wallpapers", deviceInformation = "Device Information",
|
||||
imei = "IMEI", linkedDevices = "Linked Devices", thisDevice = "This Phone", removeDevice = "Remove Device",
|
||||
removeDeviceBody = "Enter your iFruit password to remove this device from the account.", signOut = "Sign Out",
|
||||
factoryReset = "Erase All Content and Settings", factoryResetBody = "This removes the account and all local data from this phone. Cloud data and the IMEI remain.",
|
||||
accountErrors = {
|
||||
invalid_email = "Choose a valid 3–32 character iFruit address.", invalid_password = "Password must be 6–64 characters.",
|
||||
invalid_credentials = "Email or password is incorrect.", email_taken = "That iFruit address is already registered.",
|
||||
rate_limited = "Too many attempts. Try again in a minute.", current_device = "Sign out instead of removing the current phone.",
|
||||
device_not_found = "That device is no longer linked.", default = "The account request failed.",
|
||||
},
|
||||
toggle = {
|
||||
airplaneMode = "Toggle Airplane Mode", streamerMode = "Toggle Streamer Mode",
|
||||
notifications = "Toggle notifications for {app}", notificationSounds = "Toggle notification sounds for {app}",
|
||||
|
||||
@@ -11,6 +11,7 @@ escrow_ignore 'config/**'
|
||||
shared_scripts {
|
||||
'@sky_base/source/import.lua',
|
||||
'config/init.lua',
|
||||
'source/shared/imei.lua',
|
||||
}
|
||||
|
||||
client_scripts {
|
||||
@@ -22,6 +23,8 @@ client_scripts {
|
||||
server_scripts {
|
||||
'config/config.lua',
|
||||
'source/server/db_migrate.lua',
|
||||
'source/server/phone.lua',
|
||||
'source/server/notes.lua',
|
||||
'source/server/mail.lua',
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
local is_open = false
|
||||
local notification_focus = false
|
||||
local device_payload = nil
|
||||
|
||||
local mail_callbacks = {
|
||||
local server_callbacks = {
|
||||
"device:save",
|
||||
"device:factory-reset",
|
||||
"account:login",
|
||||
"account:register",
|
||||
"account:logout",
|
||||
"account:devices",
|
||||
"account:remove-device",
|
||||
"notes:list",
|
||||
"notes:create",
|
||||
"notes:update",
|
||||
"notes:delete",
|
||||
"mail:register",
|
||||
"mail:login",
|
||||
"mail:logout",
|
||||
@@ -24,17 +36,21 @@ local function get_locale()
|
||||
end
|
||||
|
||||
local function send_open_message()
|
||||
if not device_payload then
|
||||
return
|
||||
end
|
||||
|
||||
local payload = device_payload
|
||||
payload.lang = Sky.Config.locale
|
||||
payload.locales = get_locale().Nui
|
||||
SendNUIMessage({
|
||||
type = "app:open",
|
||||
data = {
|
||||
lang = Sky.Config.locale,
|
||||
locales = get_locale().Nui,
|
||||
},
|
||||
data = payload,
|
||||
})
|
||||
end
|
||||
|
||||
local function open_phone()
|
||||
if is_open then
|
||||
if is_open or not device_payload then
|
||||
return
|
||||
end
|
||||
|
||||
@@ -52,16 +68,18 @@ local function close_phone()
|
||||
is_open = false
|
||||
SetNuiFocus(notification_focus, notification_focus)
|
||||
SendNUIMessage({ type = "app:close" })
|
||||
Sky.Cb.Trigger("sky_phone:device:close", {})
|
||||
end
|
||||
|
||||
RegisterCommand(Config.Command, function()
|
||||
if is_open then
|
||||
close_phone()
|
||||
return
|
||||
end
|
||||
|
||||
open_phone()
|
||||
end, false)
|
||||
if Config.Phone.DevelopmentCommand then
|
||||
RegisterCommand(Config.Command, function()
|
||||
if is_open then
|
||||
close_phone()
|
||||
return
|
||||
end
|
||||
Sky.Cb.Trigger("sky_phone:device:development-open", {})
|
||||
end, false)
|
||||
end
|
||||
|
||||
RegisterNUICallback("ui:ready", function(_, cb)
|
||||
if is_open then
|
||||
@@ -82,7 +100,7 @@ RegisterNUICallback("notification:focus", function(data, cb)
|
||||
cb({ success = true })
|
||||
end)
|
||||
|
||||
for _, callback_name in ipairs(mail_callbacks) do
|
||||
for _, callback_name in ipairs(server_callbacks) do
|
||||
RegisterNUICallback(callback_name, function(data, cb)
|
||||
local result = Sky.Cb.Trigger("sky_phone:" .. callback_name, data)
|
||||
if result then
|
||||
@@ -94,6 +112,26 @@ for _, callback_name in ipairs(mail_callbacks) do
|
||||
end)
|
||||
end
|
||||
|
||||
RegisterNetEvent("sky_phone:device:open", function(data)
|
||||
device_payload = data
|
||||
open_phone()
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:device:updated", function(data)
|
||||
device_payload = data
|
||||
SendNUIMessage({ type = "device:updated", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:device:invalidated", function()
|
||||
device_payload = nil
|
||||
close_phone()
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:device:error", function(error_code)
|
||||
local message = get_locale().DeviceErrors[error_code] or get_locale().DeviceErrors.default
|
||||
Sky.Show.Notification("iFruit", message, "error", 5000)
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:mail:changed", function(data)
|
||||
SendNUIMessage({ type = "mail:changed", data = data })
|
||||
end)
|
||||
@@ -103,7 +141,9 @@ RegisterNetEvent("sky_phone:mail:new", function(data)
|
||||
end)
|
||||
|
||||
CreateThread(function()
|
||||
TriggerEvent("chat:addSuggestion", "/" .. Config.Command, get_locale().CommandDescription)
|
||||
if Config.Phone.DevelopmentCommand then
|
||||
TriggerEvent("chat:addSuggestion", "/" .. Config.Command, get_locale().CommandDescription)
|
||||
end
|
||||
end)
|
||||
|
||||
AddEventHandler("onResourceStop", function(resource_name)
|
||||
@@ -115,5 +155,7 @@ AddEventHandler("onResourceStop", function(resource_name)
|
||||
SetNuiFocus(false, false)
|
||||
end
|
||||
|
||||
TriggerEvent("chat:removeSuggestion", "/" .. Config.Command)
|
||||
if Config.Phone.DevelopmentCommand then
|
||||
TriggerEvent("chat:removeSuggestion", "/" .. Config.Command)
|
||||
end
|
||||
end)
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
local legacy_accounts = Sky.Query([[
|
||||
SELECT TABLE_NAME AS `name`
|
||||
FROM INFORMATION_SCHEMA.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME IN ('sky_phone_mail_accounts', 'sky_phone_accounts')
|
||||
]], {})
|
||||
local account_tables = {}
|
||||
for _, row in ipairs(legacy_accounts) do
|
||||
account_tables[row.name] = true
|
||||
end
|
||||
|
||||
if account_tables.sky_phone_mail_accounts and not account_tables.sky_phone_accounts then
|
||||
Sky.Query("RENAME TABLE `sky_phone_mail_accounts` TO `sky_phone_accounts`", {})
|
||||
elseif account_tables.sky_phone_mail_accounts and account_tables.sky_phone_accounts then
|
||||
error("[sky_phone] Both legacy and current iFruit account tables exist; refusing an ambiguous migration.")
|
||||
end
|
||||
|
||||
local schema = {
|
||||
{
|
||||
name = "sky_phone_mail_accounts",
|
||||
name = "sky_phone_accounts",
|
||||
columns = {
|
||||
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
|
||||
{
|
||||
@@ -35,7 +52,7 @@ local schema = {
|
||||
foreignKeys = {
|
||||
{
|
||||
column = "sender_account_id",
|
||||
references = "`sky_phone_mail_accounts` (`id`) ON DELETE CASCADE",
|
||||
references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE",
|
||||
},
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
@@ -67,7 +84,7 @@ local schema = {
|
||||
},
|
||||
{
|
||||
column = "account_id",
|
||||
references = "`sky_phone_mail_accounts` (`id`) ON DELETE CASCADE",
|
||||
references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE",
|
||||
},
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
@@ -93,7 +110,109 @@ local schema = {
|
||||
foreignKeys = {
|
||||
{
|
||||
column = "account_id",
|
||||
references = "`sky_phone_mail_accounts` (`id`) ON DELETE CASCADE",
|
||||
references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE",
|
||||
},
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_devices",
|
||||
columns = {
|
||||
{
|
||||
name = "imei",
|
||||
type = "CHAR(15) NOT NULL",
|
||||
characterSet = "ascii",
|
||||
collation = "ascii_bin",
|
||||
},
|
||||
{ name = "account_id", type = "BIGINT UNSIGNED NULL" },
|
||||
{ name = "device_name", type = "VARCHAR(64) NOT NULL DEFAULT 'iFruit Phone'" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
{
|
||||
name = "updated_at",
|
||||
type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP",
|
||||
},
|
||||
},
|
||||
primaryKey = "imei",
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_devices_account", columns = "(`account_id`, `updated_at`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{
|
||||
column = "account_id",
|
||||
references = "`sky_phone_accounts` (`id`) ON DELETE SET NULL",
|
||||
},
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_device_data",
|
||||
columns = {
|
||||
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
|
||||
{
|
||||
name = "device_imei",
|
||||
type = "CHAR(15) NOT NULL",
|
||||
characterSet = "ascii",
|
||||
collation = "ascii_bin",
|
||||
},
|
||||
{
|
||||
name = "namespace",
|
||||
type = "VARCHAR(32) NOT NULL",
|
||||
characterSet = "ascii",
|
||||
collation = "ascii_bin",
|
||||
},
|
||||
{ name = "payload", type = "LONGTEXT NOT NULL" },
|
||||
{ name = "revision", type = "INT UNSIGNED NOT NULL DEFAULT 1" },
|
||||
{
|
||||
name = "updated_at",
|
||||
type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP",
|
||||
},
|
||||
},
|
||||
primaryKey = "id",
|
||||
uniqueKeys = {
|
||||
{ name = "uniq_sky_phone_device_namespace", columns = "(`device_imei`, `namespace`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{
|
||||
column = "device_imei",
|
||||
references = "`sky_phone_devices` (`imei`) ON DELETE CASCADE",
|
||||
},
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_notes",
|
||||
columns = {
|
||||
{ name = "id", type = "VARCHAR(64) NOT NULL" },
|
||||
{ name = "account_id", type = "BIGINT UNSIGNED NULL" },
|
||||
{
|
||||
name = "device_imei",
|
||||
type = "CHAR(15) NULL",
|
||||
characterSet = "ascii",
|
||||
collation = "ascii_bin",
|
||||
},
|
||||
{ name = "title", type = "VARCHAR(120) NOT NULL DEFAULT ''" },
|
||||
{ name = "body", type = "LONGTEXT NOT NULL" },
|
||||
{ name = "pinned", type = "TINYINT(1) NOT NULL DEFAULT 0" },
|
||||
{ name = "revision", type = "INT UNSIGNED NOT NULL DEFAULT 1" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
{
|
||||
name = "updated_at",
|
||||
type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP",
|
||||
},
|
||||
},
|
||||
primaryKey = "id",
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_notes_account", columns = "(`account_id`, `updated_at`)" },
|
||||
{ name = "idx_sky_phone_notes_device", columns = "(`device_imei`, `updated_at`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{
|
||||
column = "account_id",
|
||||
references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE",
|
||||
},
|
||||
{
|
||||
column = "device_imei",
|
||||
references = "`sky_phone_devices` (`imei`) ON DELETE CASCADE",
|
||||
},
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
@@ -2,10 +2,6 @@ if not Sky.DB.AwaitMigrations("sky_phone") then
|
||||
error("[sky_phone] Mail database migrations did not complete.")
|
||||
end
|
||||
|
||||
local sessions = {}
|
||||
local account_sessions = {}
|
||||
local auth_attempts = {}
|
||||
|
||||
local function trim(value)
|
||||
if type(value) ~= "string" then
|
||||
return nil
|
||||
@@ -60,11 +56,6 @@ local function normalize_email(value)
|
||||
return local_part .. "@" .. Config.Mail.Domain
|
||||
end
|
||||
|
||||
local function validate_password(value)
|
||||
local length = text_length(value)
|
||||
return length and length >= Config.Mail.PasswordMinLength and length <= Config.Mail.PasswordMaxLength
|
||||
end
|
||||
|
||||
local function validate_text(value, maximum)
|
||||
local length = text_length(value)
|
||||
return length and length <= maximum
|
||||
@@ -102,53 +93,8 @@ local function normalize_recipients(values, strict)
|
||||
return recipients
|
||||
end
|
||||
|
||||
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 clear_session(source)
|
||||
local session = sessions[source]
|
||||
if not session then
|
||||
return
|
||||
end
|
||||
|
||||
local key = tostring(session.id)
|
||||
sessions[source] = nil
|
||||
if account_sessions[key] then
|
||||
account_sessions[key][source] = nil
|
||||
if not next(account_sessions[key]) then
|
||||
account_sessions[key] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function set_session(source, account)
|
||||
clear_session(source)
|
||||
sessions[source] = { id = account.id, email = account.email }
|
||||
local key = tostring(account.id)
|
||||
account_sessions[key] = account_sessions[key] or {}
|
||||
account_sessions[key][source] = true
|
||||
end
|
||||
|
||||
local function require_session(source)
|
||||
local session = sessions[source]
|
||||
if not session then
|
||||
return nil, { success = false, error = "not_authenticated" }
|
||||
end
|
||||
|
||||
return session
|
||||
return SkyPhone.RequireAccount(source)
|
||||
end
|
||||
|
||||
local function new_database_id()
|
||||
@@ -161,14 +107,7 @@ local function new_database_id()
|
||||
end
|
||||
|
||||
local function notify_account(account_id, event_name, data)
|
||||
local sources = account_sessions[tostring(account_id)]
|
||||
if not sources then
|
||||
return
|
||||
end
|
||||
|
||||
for source in pairs(sources) do
|
||||
TriggerClientEvent(event_name, source, data)
|
||||
end
|
||||
SkyPhone.NotifyAccount(account_id, event_name, data)
|
||||
end
|
||||
|
||||
local function get_counts(account_id)
|
||||
@@ -202,92 +141,6 @@ local function broadcast_mailbox_changed(account_id)
|
||||
})
|
||||
end
|
||||
|
||||
Sky.Cb.Register("sky_phone:mail:register", function(source, data)
|
||||
if not allow_auth_attempt(source) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
|
||||
data = validate_payload(source, "register", data)
|
||||
if not data 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 = "invalid_email" }
|
||||
end
|
||||
if not validate_password(password) then
|
||||
return { success = false, error = "invalid_password" }
|
||||
end
|
||||
|
||||
local existing = Sky.Query(
|
||||
"SELECT `id` FROM `sky_phone_mail_accounts` WHERE `email` = ? LIMIT 1",
|
||||
{ email }
|
||||
)
|
||||
if existing[1] then
|
||||
return { success = false, error = "email_taken" }
|
||||
end
|
||||
|
||||
local result = Sky.Query(
|
||||
"INSERT INTO `sky_phone_mail_accounts` (`email`, `password`) VALUES (?, ?)",
|
||||
{ email, password }
|
||||
)
|
||||
if not result or result == 0 or (type(result) == "table" and result.affectedRows == 0) then
|
||||
return { success = false, error = "email_taken" }
|
||||
end
|
||||
|
||||
local accounts = Sky.Query(
|
||||
"SELECT `id`, `email` FROM `sky_phone_mail_accounts` WHERE `email` = ? LIMIT 1",
|
||||
{ email }
|
||||
)
|
||||
if not accounts[1] then
|
||||
error("[sky_phone] Registered mail account could not be loaded.")
|
||||
end
|
||||
|
||||
set_session(source, accounts[1])
|
||||
return {
|
||||
success = true,
|
||||
data = { email = email, counts = get_counts(accounts[1].id) },
|
||||
}
|
||||
end)
|
||||
|
||||
Sky.Cb.Register("sky_phone:mail:login", function(source, data)
|
||||
if not allow_auth_attempt(source) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
|
||||
data = validate_payload(source, "login", data)
|
||||
if not data then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
|
||||
local email = normalize_email(data.email)
|
||||
local password = data.password
|
||||
if not email or not validate_password(password) then
|
||||
return { success = false, error = "invalid_credentials" }
|
||||
end
|
||||
|
||||
local accounts = Sky.Query(
|
||||
"SELECT `id`, `email` FROM `sky_phone_mail_accounts` WHERE `email` = ? AND `password` = ? LIMIT 1",
|
||||
{ email, password }
|
||||
)
|
||||
if not accounts[1] then
|
||||
return { success = false, error = "invalid_credentials" }
|
||||
end
|
||||
|
||||
set_session(source, accounts[1])
|
||||
return {
|
||||
success = true,
|
||||
data = { email = accounts[1].email, counts = get_counts(accounts[1].id) },
|
||||
}
|
||||
end)
|
||||
|
||||
Sky.Cb.Register("sky_phone:mail:logout", function(source)
|
||||
clear_session(source)
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Sky.Cb.Register("sky_phone:mail:counts", function(source)
|
||||
local session, error_response = require_session(source)
|
||||
if not session then
|
||||
@@ -355,7 +208,7 @@ Sky.Cb.Register("sky_phone:mail:list", function(source, data)
|
||||
m.`created_at`
|
||||
FROM `sky_phone_mail_entries` e
|
||||
JOIN `sky_phone_mail_messages` m ON m.`id` = e.`message_id`
|
||||
JOIN `sky_phone_mail_accounts` sender ON sender.`id` = m.`sender_account_id`
|
||||
JOIN `sky_phone_accounts` sender ON sender.`id` = m.`sender_account_id`
|
||||
WHERE e.`account_id` = ? AND %s
|
||||
AND (? = '' OR m.`subject` LIKE ? OR m.`body` LIKE ? OR sender.`email` LIKE ? OR m.`recipients` LIKE ?)
|
||||
ORDER BY m.`created_at` DESC, e.`id` DESC
|
||||
@@ -400,7 +253,7 @@ Sky.Cb.Register("sky_phone:mail:get", function(source, data)
|
||||
sender.`email` AS `sender`, m.`recipients`, m.`subject`, m.`body`, m.`created_at`
|
||||
FROM `sky_phone_mail_entries` e
|
||||
JOIN `sky_phone_mail_messages` m ON m.`id` = e.`message_id`
|
||||
JOIN `sky_phone_mail_accounts` sender ON sender.`id` = m.`sender_account_id`
|
||||
JOIN `sky_phone_accounts` sender ON sender.`id` = m.`sender_account_id`
|
||||
WHERE e.`id` = ? AND e.`account_id` = ?
|
||||
LIMIT 1
|
||||
]], { entry_id, session.id })
|
||||
@@ -550,7 +403,7 @@ Sky.Cb.Register("sky_phone:mail:send", function(source, data)
|
||||
placeholders[index] = "?"
|
||||
end
|
||||
local recipient_accounts = Sky.Query(
|
||||
("SELECT `id`, `email` FROM `sky_phone_mail_accounts` WHERE `email` IN (%s)"):format(table.concat(placeholders, ", ")),
|
||||
("SELECT `id`, `email` FROM `sky_phone_accounts` WHERE `email` IN (%s)"):format(table.concat(placeholders, ", ")),
|
||||
recipients
|
||||
)
|
||||
if #recipient_accounts ~= #recipients then
|
||||
@@ -727,18 +580,3 @@ Sky.Cb.Register("sky_phone:mail:empty-trash", function(source)
|
||||
broadcast_mailbox_changed(session.id)
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
AddEventHandler("playerDropped", function()
|
||||
auth_attempts[source] = nil
|
||||
clear_session(source)
|
||||
end)
|
||||
|
||||
AddEventHandler("onResourceStop", function(resource_name)
|
||||
if resource_name ~= GetCurrentResourceName() then
|
||||
return
|
||||
end
|
||||
|
||||
sessions = {}
|
||||
account_sessions = {}
|
||||
auth_attempts = {}
|
||||
end)
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
SkyPhoneNotes = {}
|
||||
|
||||
local function text_length(value)
|
||||
return type(value) == "string" and utf8.len(value) or nil
|
||||
end
|
||||
|
||||
local function note_owner(account_id, imei)
|
||||
if account_id then
|
||||
return "`account_id` = ?", { account_id }
|
||||
end
|
||||
return "`account_id` IS NULL AND `device_imei` = ?", { imei }
|
||||
end
|
||||
|
||||
local function note_dto(row)
|
||||
return {
|
||||
id = row.id,
|
||||
title = row.title,
|
||||
body = row.body,
|
||||
pinned = tonumber(row.pinned) == 1,
|
||||
revision = tonumber(row.revision) or 1,
|
||||
createdAt = (tonumber(row.created_at_unix) or 0) * 1000,
|
||||
updatedAt = (tonumber(row.updated_at_unix) or 0) * 1000,
|
||||
}
|
||||
end
|
||||
|
||||
local function notify_owner(account_id, imei)
|
||||
if account_id then
|
||||
SkyPhone.RefreshAccount(account_id)
|
||||
else
|
||||
SkyPhone.RefreshDevice(imei)
|
||||
end
|
||||
end
|
||||
|
||||
function SkyPhoneNotes.List(account_id, imei)
|
||||
local condition, params = note_owner(account_id, imei)
|
||||
local rows = Sky.Query(([[
|
||||
SELECT `id`, `title`, `body`, `pinned`, `revision`,
|
||||
UNIX_TIMESTAMP(`created_at`) AS `created_at_unix`,
|
||||
UNIX_TIMESTAMP(`updated_at`) AS `updated_at_unix`
|
||||
FROM `sky_phone_notes`
|
||||
WHERE %s
|
||||
ORDER BY `pinned` DESC, `updated_at` DESC
|
||||
]]):format(condition), params)
|
||||
local notes = {}
|
||||
for _, row in ipairs(rows) do
|
||||
notes[#notes + 1] = note_dto(row)
|
||||
end
|
||||
return notes
|
||||
end
|
||||
|
||||
local function session_owner(source)
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
if not session then
|
||||
return nil, nil, error_response
|
||||
end
|
||||
local account = SkyPhone.RequireAccount(source)
|
||||
return account and account.id or nil, session.imei
|
||||
end
|
||||
|
||||
Sky.Cb.Register("sky_phone:notes:list", function(source)
|
||||
local account_id, imei, error_response = session_owner(source)
|
||||
if not imei then
|
||||
return error_response
|
||||
end
|
||||
return { success = true, data = SkyPhoneNotes.List(account_id, imei) }
|
||||
end)
|
||||
|
||||
Sky.Cb.Register("sky_phone:notes:create", function(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "notes_write", 120, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
local account_id, imei, error_response = session_owner(source)
|
||||
if not imei then
|
||||
return error_response
|
||||
end
|
||||
if type(data) ~= "table"
|
||||
or type(data.id) ~= "string"
|
||||
or #data.id < 8
|
||||
or #data.id > 64
|
||||
or not data.id:match("^[%w%-]+$")
|
||||
or not text_length(data.title)
|
||||
or text_length(data.title) > 120
|
||||
or not text_length(data.body)
|
||||
or text_length(data.body) > 20000
|
||||
or type(data.pinned) ~= "boolean"
|
||||
then
|
||||
return { success = false, error = "invalid_note" }
|
||||
end
|
||||
|
||||
local result
|
||||
if account_id then
|
||||
result = Sky.Query([[
|
||||
INSERT IGNORE INTO `sky_phone_notes`
|
||||
(`id`, `account_id`, `device_imei`, `title`, `body`, `pinned`)
|
||||
VALUES (?, ?, NULL, ?, ?, ?)
|
||||
]], { data.id, account_id, data.title, data.body, data.pinned and 1 or 0 })
|
||||
else
|
||||
result = Sky.Query([[
|
||||
INSERT IGNORE INTO `sky_phone_notes`
|
||||
(`id`, `account_id`, `device_imei`, `title`, `body`, `pinned`)
|
||||
VALUES (?, NULL, ?, ?, ?, ?)
|
||||
]], { data.id, imei, data.title, data.body, data.pinned and 1 or 0 })
|
||||
end
|
||||
local affected = type(result) == "number" and result
|
||||
or (type(result) == "table" and tonumber(result.affectedRows) or 0)
|
||||
if affected <= 0 then
|
||||
return { success = false, error = "note_exists" }
|
||||
end
|
||||
|
||||
notify_owner(account_id, imei)
|
||||
return { success = true, data = SkyPhoneNotes.List(account_id, imei) }
|
||||
end)
|
||||
|
||||
Sky.Cb.Register("sky_phone:notes:update", function(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "notes_write", 120, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
local account_id, imei, error_response = session_owner(source)
|
||||
if not imei then
|
||||
return error_response
|
||||
end
|
||||
if type(data) ~= "table"
|
||||
or type(data.id) ~= "string"
|
||||
or not text_length(data.title)
|
||||
or text_length(data.title) > 120
|
||||
or not text_length(data.body)
|
||||
or text_length(data.body) > 20000
|
||||
or type(data.pinned) ~= "boolean"
|
||||
then
|
||||
return { success = false, error = "invalid_note" }
|
||||
end
|
||||
|
||||
local revision = math.max(1, math.floor(tonumber(data.revision) or 0))
|
||||
local condition, owner_params = note_owner(account_id, imei)
|
||||
local params = { data.title, data.body, data.pinned and 1 or 0, data.id }
|
||||
for _, value in ipairs(owner_params) do
|
||||
params[#params + 1] = value
|
||||
end
|
||||
params[#params + 1] = revision
|
||||
local result = Sky.Query(([[
|
||||
UPDATE `sky_phone_notes`
|
||||
SET `title` = ?, `body` = ?, `pinned` = ?, `revision` = `revision` + 1
|
||||
WHERE `id` = ? AND %s AND `revision` = ?
|
||||
]]):format(condition), params)
|
||||
local affected = type(result) == "number" and result
|
||||
or (type(result) == "table" and tonumber(result.affectedRows) or 0)
|
||||
if affected ~= 1 then
|
||||
return { success = false, error = "conflict", data = SkyPhoneNotes.List(account_id, imei) }
|
||||
end
|
||||
|
||||
notify_owner(account_id, imei)
|
||||
return { success = true, data = SkyPhoneNotes.List(account_id, imei) }
|
||||
end)
|
||||
|
||||
Sky.Cb.Register("sky_phone:notes:delete", function(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "notes_write", 120, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
local account_id, imei, error_response = session_owner(source)
|
||||
if not imei then
|
||||
return error_response
|
||||
end
|
||||
if type(data) ~= "table" or type(data.id) ~= "string" then
|
||||
return { success = false, error = "invalid_note" }
|
||||
end
|
||||
|
||||
local condition, owner_params = note_owner(account_id, imei)
|
||||
local params = { data.id }
|
||||
for _, value in ipairs(owner_params) do
|
||||
params[#params + 1] = value
|
||||
end
|
||||
Sky.Query(("DELETE FROM `sky_phone_notes` WHERE `id` = ? AND %s"):format(condition), params)
|
||||
notify_owner(account_id, imei)
|
||||
return { success = true, data = SkyPhoneNotes.List(account_id, imei) }
|
||||
end)
|
||||
@@ -0,0 +1,599 @@
|
||||
if not Sky.DB.AwaitMigrations("sky_phone") then
|
||||
error("[sky_phone] Device database migrations did not complete.")
|
||||
end
|
||||
|
||||
SkyPhone = {}
|
||||
|
||||
local sessions = {}
|
||||
local auth_attempts = {}
|
||||
local operation_attempts = {}
|
||||
|
||||
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()
|
||||
for _ = 1, 20 do
|
||||
local uuid_rows = Sky.Query("SELECT UUID() AS `id`", {})
|
||||
local uuid = uuid_rows[1] and uuid_rows[1].id
|
||||
if type(uuid) ~= "string" then
|
||||
error("[sky_phone] Database did not generate entropy for an IMEI.")
|
||||
end
|
||||
|
||||
local imei = SkyPhoneImei.FromEntropy(uuid)
|
||||
local result = Sky.Query([[
|
||||
INSERT IGNORE INTO `sky_phone_devices` (`imei`, `device_name`)
|
||||
VALUES (?, ?)
|
||||
]], { imei, Config.Phone.DeviceName })
|
||||
if affected_rows(result) > 0 then
|
||||
return imei
|
||||
end
|
||||
end
|
||||
|
||||
error("[sky_phone] Could not generate a unique IMEI after 20 attempts.")
|
||||
end
|
||||
|
||||
local function find_device_slots(source, imei)
|
||||
local matches = {}
|
||||
for _, item in ipairs(Sky.FW.GetInventorySlotsWithItem(source, Config.Phone.Item)) do
|
||||
if item.metadata and item.metadata.imei == imei then
|
||||
matches[#matches + 1] = item
|
||||
end
|
||||
end
|
||||
|
||||
if #matches > 1 then
|
||||
Sky.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)
|
||||
local slot_id = tonumber(used_item and (used_item.slot or used_item.id))
|
||||
if slot_id then
|
||||
local slot = Sky.FW.GetInventorySlot(source, slot_id)
|
||||
if slot and slot.name == Config.Phone.Item then
|
||||
return slot
|
||||
end
|
||||
end
|
||||
|
||||
local slots = Sky.FW.GetInventorySlotsWithItem(source, Config.Phone.Item)
|
||||
if #slots == 1 then
|
||||
return slots[1]
|
||||
end
|
||||
|
||||
Sky.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)
|
||||
if (tonumber(slot.amount or slot.count) or 0) ~= 1 then
|
||||
Sky.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
|
||||
Sky.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
|
||||
if not Sky.FW.SetInventorySlotMetadata(source, slot.slot, metadata) then
|
||||
Sky.Query("DELETE FROM `sky_phone_devices` WHERE `imei` = ?", { imei })
|
||||
Sky.Debug(
|
||||
"error",
|
||||
"[sky_phone] Inventory '%s' could not write phone metadata for source %s slot %s.",
|
||||
Sky.FW.GetResourceName(),
|
||||
tostring(source),
|
||||
tostring(slot.slot)
|
||||
)
|
||||
return nil, "metadata_unsupported"
|
||||
end
|
||||
return imei
|
||||
end
|
||||
|
||||
Sky.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 = Sky.Query([[
|
||||
SELECT d.`imei`, d.`device_name`, d.`account_id`, d.`created_at`, d.`updated_at`, a.`email`
|
||||
FROM `sky_phone_devices` d
|
||||
LEFT JOIN `sky_phone_accounts` a ON a.`id` = d.`account_id`
|
||||
WHERE d.`imei` = ?
|
||||
LIMIT 1
|
||||
]], { imei })
|
||||
return rows[1]
|
||||
end
|
||||
|
||||
local function load_device_data(imei)
|
||||
local rows = Sky.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 account_devices(account_id, current_imei)
|
||||
local rows = Sky.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)
|
||||
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 then
|
||||
error(("[sky_phone] Active IMEI %s has no device row."):format(session.imei))
|
||||
end
|
||||
|
||||
return {
|
||||
token = session.token,
|
||||
device = {
|
||||
imei = device.imei,
|
||||
name = device.device_name,
|
||||
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
|
||||
|
||||
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 Sky.DB.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 },
|
||||
},
|
||||
}) 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 = Sky.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 = Sky.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.RequireSession(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
|
||||
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.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
|
||||
Sky.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.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 slot = resolve_used_slot(source, used_item)
|
||||
if not slot then
|
||||
TriggerClientEvent("sky_phone:device:error", source, "phone_slot_missing")
|
||||
return false
|
||||
end
|
||||
|
||||
local imei, error_code = ensure_device(source, slot)
|
||||
if not imei then
|
||||
TriggerClientEvent("sky_phone:device:error", source, error_code)
|
||||
return false
|
||||
end
|
||||
|
||||
sessions[source] = {
|
||||
imei = imei,
|
||||
slot = slot.slot,
|
||||
token = ("%s:%s:%s"):format(imei, tostring(source), tostring(GetGameTimer())),
|
||||
}
|
||||
local payload = bootstrap(source)
|
||||
TriggerClientEvent("sky_phone:device:open", source, payload)
|
||||
return true
|
||||
end
|
||||
|
||||
Sky.FW.RegisterUsableItem(Config.Phone.Item, open_phone)
|
||||
|
||||
Sky.Cb.Register("sky_phone:device:close", function(source)
|
||||
sessions[source] = nil
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Sky.Cb.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)
|
||||
|
||||
Sky.Cb.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 Config.Phone.AllowedDeviceNamespaces[data.namespace] then
|
||||
return { success = false, error = "invalid_namespace" }
|
||||
end
|
||||
|
||||
local encoded = json.encode(data.payload)
|
||||
if #encoded > Config.Phone.MaxDeviceDataBytes then
|
||||
return { success = false, error = "payload_too_large" }
|
||||
end
|
||||
local revision = math.max(0, math.floor(tonumber(data.revision) or 0))
|
||||
local rows = Sky.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 = Sky.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 = Sky.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)
|
||||
|
||||
for _, endpoint in ipairs({ "account:login", "mail:login" }) do
|
||||
Sky.Cb.Register("sky_phone:" .. endpoint, function(source, data)
|
||||
return authenticate(source, data, false)
|
||||
end)
|
||||
end
|
||||
|
||||
for _, endpoint in ipairs({ "account:register", "mail:register" }) do
|
||||
Sky.Cb.Register("sky_phone:" .. endpoint, function(source, data)
|
||||
return authenticate(source, data, true)
|
||||
end)
|
||||
end
|
||||
|
||||
for _, endpoint in ipairs({ "account:logout", "mail:logout" }) do
|
||||
Sky.Cb.Register("sky_phone:" .. endpoint, function(source)
|
||||
local account, error_response = SkyPhone.RequireAccount(source)
|
||||
if not account then
|
||||
return error_response
|
||||
end
|
||||
Sky.Query("UPDATE `sky_phone_devices` SET `account_id` = NULL WHERE `imei` = ?", { account.imei })
|
||||
refresh_source(source)
|
||||
return { success = true }
|
||||
end)
|
||||
end
|
||||
|
||||
Sky.Cb.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)
|
||||
|
||||
Sky.Cb.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 = Sky.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 = Sky.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)
|
||||
|
||||
Sky.Cb.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
|
||||
if not Sky.DB.Transaction({
|
||||
{
|
||||
query = "DELETE FROM `sky_phone_device_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 = "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
|
||||
refresh_source(source)
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
AddEventHandler("playerDropped", function()
|
||||
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)
|
||||
@@ -0,0 +1,47 @@
|
||||
SkyPhoneImei = {}
|
||||
|
||||
function SkyPhoneImei.CheckDigit(body)
|
||||
if type(body) ~= "string" or not body:match("^%d%d%d%d%d%d%d%d%d%d%d%d%d%d$") then
|
||||
return nil
|
||||
end
|
||||
|
||||
local sum = 0
|
||||
for index = 1, 14 do
|
||||
local digit = tonumber(body:sub(index, index))
|
||||
if index % 2 == 0 then
|
||||
digit = digit * 2
|
||||
if digit > 9 then
|
||||
digit = digit - 9
|
||||
end
|
||||
end
|
||||
sum = sum + digit
|
||||
end
|
||||
return tostring((10 - (sum % 10)) % 10)
|
||||
end
|
||||
|
||||
function SkyPhoneImei.FromEntropy(entropy)
|
||||
if type(entropy) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
|
||||
local digits = {}
|
||||
for character in entropy:lower():gmatch("[0-9a-f]") do
|
||||
digits[#digits + 1] = tostring(tonumber(character, 16) % 10)
|
||||
if #digits == 14 then
|
||||
break
|
||||
end
|
||||
end
|
||||
if #digits ~= 14 then
|
||||
return nil
|
||||
end
|
||||
|
||||
local body = table.concat(digits)
|
||||
return body .. SkyPhoneImei.CheckDigit(body)
|
||||
end
|
||||
|
||||
function SkyPhoneImei.IsValid(value)
|
||||
if type(value) ~= "string" or not value:match("^%d%d%d%d%d%d%d%d%d%d%d%d%d%d%d$") then
|
||||
return false
|
||||
end
|
||||
return value:sub(15, 15) == SkyPhoneImei.CheckDigit(value:sub(1, 14))
|
||||
end
|
||||
@@ -0,0 +1,10 @@
|
||||
dofile("sky_phone/source/shared/imei.lua")
|
||||
|
||||
local imei = SkyPhoneImei.FromEntropy("550e8400-e29b-41d4-a716-446655440000")
|
||||
assert(type(imei) == "string" and #imei == 15, "IMEI must contain 15 digits")
|
||||
assert(imei:match("^%d+$"), "IMEI must be numeric")
|
||||
assert(SkyPhoneImei.IsValid(imei), "generated IMEI must pass its check digit")
|
||||
assert(not SkyPhoneImei.IsValid(imei:sub(1, 14) .. tostring((tonumber(imei:sub(15, 15)) + 1) % 10)), "invalid check digit must fail")
|
||||
assert(not SkyPhoneImei.IsValid("123"), "short IMEI must fail")
|
||||
|
||||
print("IMEI tests passed")
|
||||
Reference in New Issue
Block a user