ADD - introduce unique phone devices

This commit is contained in:
Eichenholz
2026-08-04 18:35:57 +02:00
parent 75be4c2a69
commit 46e6c6cc25
11 changed files with 1066 additions and 191 deletions
+11 -1
View File
@@ -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.
+15
View File
@@ -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,
+19 -2
View File
@@ -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 332 character iFruit address.", invalid_password = "Password must be 664 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}",
+3
View File
@@ -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',
}
+59 -17
View File
@@ -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)
+123 -4
View File
@@ -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",
+5 -167
View File
@@ -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)
+175
View File
@@ -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)
+599
View File
@@ -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)
+47
View File
@@ -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
+10
View File
@@ -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")