Files
sky_phone-sky-systems/sky_phone/source/server/easyshare.lua
Leon.Schmidt e58f1b6c18 FIX - complete phone locale fallback
Resolve configured and regional locales through a centralized English fallback for Lua and NUI consumers. Complete the German and English app translation keys, localize remaining picker and attachment copy, and add contract tests that detect missing locale entries.
2026-08-17 15:01:57 +02:00

1134 lines
46 KiB
Lua

SkyPhoneEasyShare = {}
Bridge.Database.AfterMigration("sky_phone", function()
local active_transfers = {}
local valid_kinds = {
contact = true,
document = true,
link = true,
location = true,
media = true,
note = true,
photo = true,
playlist = true,
post = true,
profile = true,
text = true,
track = true,
video = true,
}
local valid_apps = {
calendar = true,
camera = true,
citymarkt = true,
companies = true,
crewlink = true,
darkchat = true,
feather = true,
flare = true,
fliptok = true,
house = true,
["local-pages"] = true,
mail = true,
map = true,
messages = true,
music = true,
notes = true,
phone = true,
photos = true,
picstagram = true,
}
local valid_visibilities = { contacts = true, everyone = true, hidden = true }
Bridge.Database.Query([[
UPDATE `sky_phone_easyshare_transfers`
SET `status` = 'expired', `updated_at` = CURRENT_TIMESTAMP, `completed_at` = CURRENT_TIMESTAMP
WHERE `status` IN ('pending', 'transferring')
]], {})
local function uuid()
local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {})
local id = rows[1] and rows[1].id
if type(id) ~= "string" then
error("[sky_phone] Database did not generate an EasyShare id.")
end
return id
end
local rich_note_prefix = "sky-note-html-v1:"
local function plain_note_body(body)
if body:sub(1, #rich_note_prefix) ~= rich_note_prefix then
return body
end
return body:sub(#rich_note_prefix + 1)
:gsub("<[bB][rR]%s*/?>", "\n")
:gsub("</[pP]%s*>", "\n")
:gsub("</[hH][1-6]%s*>", "\n")
:gsub("<[lL][iI][^>]*>", "- ")
:gsub("</[lL][iI]%s*>", "\n")
:gsub("</[bB][lL][oO][cC][kK][qQ][uU][oO][tT][eE]%s*>", "\n")
:gsub("<[^>]*>", "")
:gsub("&nbsp;", " ")
:gsub("&lt;", "<")
:gsub("&gt;", ">")
:gsub("&quot;", '"')
:gsub("&#39;", "'")
:gsub("&amp;", "&")
:gsub("[ \t]+\n", "\n")
:gsub("\n\n\n+", "\n\n")
:match("^%s*(.-)%s*$")
end
local function trim(value, maximum)
if type(value) ~= "string" then
return nil
end
local result = value:match("^%s*(.-)%s*$")
if result == "" or #result > maximum then
return nil
end
return result
end
local function current_device(source)
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return nil, error_response
end
local device = SkyPhone.LoadDevice(session.imei)
if not device then
return nil, { success = false, error = "device_not_found" }
end
return device
end
local function owner_condition(device, alias)
local prefix = alias and ("`" .. alias .. "`.") or ""
if device.account_id then
return prefix .. "`account_id` = ?", { tonumber(device.account_id) }
end
return prefix .. "`account_id` IS NULL AND " .. prefix .. "`device_imei` = ?", { device.imei }
end
local function append_params(target, values)
for _, value in ipairs(values) do
target[#target + 1] = value
end
end
local function first_row(query, params)
local rows = Bridge.Database.Query(query, params)
return rows[1]
end
local function canonical_profile(device, app_id, id)
if app_id == "picstagram" then
local profile = first_row([[
SELECT p.`display_name`, p.`handle`, p.`bio`, media.`url` AS `image_url`
FROM `sky_phone_picstagram_profiles` p
LEFT JOIN `sky_phone_media` media ON media.`id` = p.`avatar_media_id`
WHERE p.`id` = ? AND p.`status` = 'active'
AND (p.`private` = 0 OR EXISTS(
SELECT 1 FROM `sky_phone_picstagram_sessions` session
WHERE session.`device_imei` = ? AND session.`profile_id` = p.`id`
) OR EXISTS(
SELECT 1 FROM `sky_phone_picstagram_follows` follow
WHERE follow.`following_id` = p.`id` AND follow.`status` = 'accepted'
AND follow.`follower_id` = (
SELECT session.`profile_id` FROM `sky_phone_picstagram_sessions` session
WHERE session.`device_imei` = ? LIMIT 1
)
)) LIMIT 1
]], { id, device.imei, device.imei })
if profile then
return {
title = profile.display_name,
subtitle = "@" .. profile.handle,
copyText = "@" .. profile.handle,
imageUrl = profile.image_url,
link = "skyphone://picstagram/profile/" .. id,
}
end
elseif app_id == "feather" then
local profile = first_row([[
SELECT p.`display_name`, p.`handle`, media.`url` AS `image_url`
FROM `sky_phone_feather_profiles` p
LEFT JOIN `sky_phone_media` media ON media.`id` = p.`avatar_media_id`
WHERE p.`id` = ? LIMIT 1
]], { id })
if profile then
return {
title = profile.display_name,
subtitle = "@" .. profile.handle,
copyText = "@" .. profile.handle,
imageUrl = profile.image_url,
link = "skyphone://feather/profile/" .. id,
}
end
elseif app_id == "fliptok" then
local profile = first_row("SELECT `display_name`, `handle` FROM `sky_phone_fliptok_profiles` WHERE `id` = ? LIMIT 1", { id })
if profile then
return {
title = profile.display_name,
subtitle = "@" .. profile.handle,
copyText = "@" .. profile.handle,
link = "skyphone://fliptok/profile/" .. id,
}
end
elseif app_id == "flare" and device.account_id then
local profile = first_row([[
SELECT p.`name`, p.`age`, p.`bio`, media.`url` AS `image_url`
FROM `sky_phone_flare_profiles` p
LEFT JOIN `sky_phone_flare_profile_photos` photo ON photo.`profile_id` = p.`id` AND photo.`sort_order` = 1
LEFT JOIN `sky_phone_media` media ON media.`id` = photo.`media_id`
WHERE p.`id` = ? AND p.`account_id` = ? LIMIT 1
]], { id, tonumber(device.account_id) })
if profile then
local title = ("%s, %s"):format(profile.name, profile.age)
return {
title = title,
copyText = title .. "\n" .. profile.bio,
imageUrl = profile.image_url,
link = "skyphone://flare/profile/" .. id,
}
end
elseif app_id == "crewlink" and device.account_id then
local profile = first_row([[
SELECT p.`username`, g.`name` AS `group_name`
FROM `sky_phone_crewlink_profiles` p
LEFT JOIN `sky_phone_crewlink_groups` g ON g.`id` = p.`active_group_id`
WHERE p.`id` = ? AND p.`account_id` = ? LIMIT 1
]], { id, tonumber(device.account_id) })
if profile then
return {
title = "@" .. profile.username,
subtitle = profile.group_name,
copyText = "@" .. profile.username,
link = "skyphone://crewlink/profile/" .. id,
}
end
elseif app_id == "darkchat" and device.account_id then
local profile = first_row([[
SELECT `alias`, `dark_id`, `invite_code` FROM `sky_phone_darkchat_profiles`
WHERE `id` = ? AND `account_id` = ? LIMIT 1
]], { id, tonumber(device.account_id) })
if profile then
return {
title = profile.alias,
subtitle = profile.dark_id,
copyText = profile.alias .. "\n" .. profile.dark_id .. "\n" .. profile.invite_code,
link = "skyphone://darkchat/invite/" .. profile.invite_code,
}
end
elseif app_id == "companies" then
local definition = Config.Companies.Enabled and Config.Companies.Definitions[id] or nil
if definition and definition.Public then
local profile = first_row([[
SELECT profile.`description`, media.`url` AS `image_url`
FROM `sky_phone_company_profiles` profile
LEFT JOIN `sky_phone_media` media ON media.`id` = profile.`logo_media_id`
WHERE profile.`company_id` = ? LIMIT 1
]], { id })
return {
title = definition.Name,
subtitle = definition.ServiceLine.Number,
copyText = definition.Name .. "\n" .. (profile and profile.description or definition.Description),
imageUrl = profile and profile.image_url or nil,
link = "skyphone://companies/profile/" .. id,
}
end
end
return nil
end
local function canonical_crewlink_invite(device, invite_code)
if not device.account_id
or type(invite_code) ~= "string"
or #invite_code ~= 8
or invite_code:find("[^A-Z0-9]")
then
return nil
end
local group = first_row([[
SELECT g.`name`, g.`invite_code`
FROM `sky_phone_crewlink_profiles` p
JOIN `sky_phone_crewlink_memberships` m ON m.`profile_id` = p.`id`
JOIN `sky_phone_crewlink_groups` g ON g.`id` = m.`group_id`
WHERE p.`account_id` = ? AND g.`invite_code` = ?
AND m.`role` IN ('owner', 'coordinator')
LIMIT 1
]], { tonumber(device.account_id), invite_code })
if not group then
return nil
end
local locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale).Nui.Apps.crewlink
local title = locale.shareInviteTitle:gsub("{group}", function() return group.name end)
local copy_text = locale.shareInviteBody
:gsub("{code}", function() return group.invite_code end)
:gsub("{group}", function() return group.name end)
return {
title = title,
subtitle = group.invite_code,
copyText = copy_text,
link = "skyphone://crewlink/invite/" .. group.invite_code,
}
end
local function canonical_post(device, app_id, id)
if app_id == "picstagram" then
local post = first_row([[
SELECT post.`caption`, profile.`display_name`, profile.`handle`, media.`url` AS `image_url`
FROM `sky_phone_picstagram_posts` post
JOIN `sky_phone_picstagram_profiles` profile ON profile.`id` = post.`profile_id`
LEFT JOIN `sky_phone_picstagram_post_media` post_media
ON post_media.`post_id` = post.`id` AND post_media.`position` = 1
LEFT JOIN `sky_phone_media` media ON media.`id` = post_media.`media_id`
WHERE post.`id` = ? AND post.`status` = 'published' AND profile.`status` = 'active'
AND (profile.`private` = 0 OR EXISTS(
SELECT 1 FROM `sky_phone_picstagram_sessions` session
WHERE session.`device_imei` = ? AND session.`profile_id` = profile.`id`
) OR EXISTS(
SELECT 1 FROM `sky_phone_picstagram_follows` follow
WHERE follow.`following_id` = profile.`id` AND follow.`status` = 'accepted'
AND follow.`follower_id` = (
SELECT session.`profile_id` FROM `sky_phone_picstagram_sessions` session
WHERE session.`device_imei` = ? LIMIT 1
)
)) LIMIT 1
]], { id, device.imei, device.imei })
if post then
local title = post.caption ~= "" and post.caption or post.display_name
return {
title = title,
subtitle = "@" .. post.handle,
copyText = "@" .. post.handle .. ": " .. post.caption,
imageUrl = post.image_url,
link = "skyphone://picstagram/post/" .. id,
}
end
elseif app_id == "feather" then
local post = first_row([[
SELECT post.`body`, profile.`handle`, media.`url` AS `image_url`
FROM `sky_phone_feather_posts` post
JOIN `sky_phone_feather_profiles` profile ON profile.`id` = post.`profile_id`
LEFT JOIN `sky_phone_feather_post_media` post_media
ON post_media.`post_id` = post.`id` AND post_media.`sort_order` = 0
LEFT JOIN `sky_phone_media` media ON media.`id` = post_media.`media_id`
WHERE post.`id` = ? AND post.`status` = 'published' LIMIT 1
]], { id })
if post then
return {
title = post.body,
subtitle = "@" .. post.handle,
copyText = "@" .. post.handle .. ": " .. post.body,
imageUrl = post.image_url,
link = "skyphone://feather/post/" .. id,
}
end
elseif app_id == "fliptok" then
local post = first_row([[
SELECT video.`caption`, profile.`display_name`, profile.`handle`, media.`url` AS `image_url`
FROM `sky_phone_fliptok_videos` video
JOIN `sky_phone_fliptok_profiles` profile ON profile.`id` = video.`profile_id`
JOIN `sky_phone_media` media ON media.`id` = video.`media_id`
WHERE video.`id` = ? AND video.`status` = 'published' AND (
video.`visibility` = 'public' OR EXISTS(
SELECT 1 FROM `sky_phone_fliptok_sessions` session
WHERE session.`device_imei` = ? AND session.`profile_id` = profile.`id`
) OR (video.`visibility` = 'followers' AND EXISTS(
SELECT 1 FROM `sky_phone_fliptok_follows` follow
WHERE follow.`following_id` = profile.`id` AND follow.`follower_id` = (
SELECT session.`profile_id` FROM `sky_phone_fliptok_sessions` session
WHERE session.`device_imei` = ? LIMIT 1
)
))
) LIMIT 1
]], { id, device.imei, device.imei })
if post then
local title = post.caption ~= "" and post.caption or post.display_name
return {
title = title,
subtitle = "@" .. post.handle,
copyText = "@" .. post.handle .. ": " .. post.caption,
imageUrl = post.image_url,
link = "skyphone://fliptok/video/" .. id,
}
end
elseif app_id == "local-pages" then
local post = first_row([[
SELECT post.`title`, post.`body`, SUBSTRING_INDEX(account.`email`, '@', 1) AS `author_name`,
media.`url` AS `image_url`
FROM `sky_phone_pages_posts` post
JOIN `sky_phone_accounts` account ON account.`id` = post.`account_id`
LEFT JOIN `sky_phone_pages_images` image ON image.`post_id` = post.`id` AND image.`sort_order` = 1
LEFT JOIN `sky_phone_media` media ON media.`id` = image.`media_id`
WHERE post.`id` = ? LIMIT 1
]], { id })
if post then
return {
title = post.title,
subtitle = post.author_name,
copyText = post.title .. "\n" .. post.body,
imageUrl = post.image_url,
link = "skyphone://local-pages/post/" .. id,
}
end
elseif app_id == "citymarkt" then
local post = first_row([[
SELECT listing.`title`, listing.`description`, listing.`price`, listing.`price_type`, media.`url` AS `image_url`
FROM `sky_phone_marketplace_listings` listing
LEFT JOIN `sky_phone_marketplace_images` image
ON image.`listing_id` = listing.`id` AND image.`sort_order` = 1
LEFT JOIN `sky_phone_media` media ON media.`id` = image.`media_id`
WHERE listing.`id` = ? AND listing.`status` IN ('active', 'reserved') LIMIT 1
]], { id })
if post then
local price = post.price_type == "fixed" and tostring(post.price) or post.price_type
return {
title = post.title,
subtitle = price,
copyText = post.title .. "\n" .. post.description,
imageUrl = post.image_url,
link = "skyphone://citymarkt/listing/" .. id,
}
end
end
return nil
end
local function canonical_music(device, data)
if data.kind == "track" and type(data.meta) == "table" and data.meta.source == "server" then
for _, track in ipairs(Config.Music.Tracks) do
if track.Id == data.id then
return {
title = track.Title,
subtitle = track.Artist,
copyText = track.Title .. " — " .. track.Artist,
link = "skyphone://music/server/" .. track.Id,
meta = { source = "server" },
}
end
end
elseif data.kind == "track" and type(data.meta) == "table" and data.meta.source == "youtube" then
local condition, params = owner_condition(device, "song")
local query_params = { data.id }
append_params(query_params, params)
local track = first_row(([=[
SELECT song.`title`, song.`artist`, song.`video_id`
FROM `sky_phone_music_youtube_songs` song
WHERE song.`id` = ? AND %s LIMIT 1
]=]):format(condition), query_params)
if track then
return {
title = track.title,
subtitle = track.artist,
copyText = track.title .. " — " .. track.artist,
imageUrl = "https://i.ytimg.com/vi/" .. track.video_id .. "/hqdefault.jpg",
link = "skyphone://music/youtube/" .. data.id,
meta = { source = "youtube" },
}
end
elseif data.kind == "playlist" then
local condition, params = owner_condition(device, "playlist")
local query_params = { data.id }
append_params(query_params, params)
local playlist = first_row(([=[
SELECT playlist.`name`, COUNT(item.`id`) AS `song_count`
FROM `sky_phone_music_playlists` playlist
LEFT JOIN `sky_phone_music_playlist_items` item ON item.`playlist_id` = playlist.`id`
WHERE playlist.`id` = ? AND %s
GROUP BY playlist.`id`, playlist.`name` LIMIT 1
]=]):format(condition), query_params)
if playlist then
return {
title = playlist.name,
subtitle = tostring(playlist.song_count) .. " tracks",
copyText = playlist.name .. " · " .. tostring(playlist.song_count),
link = "skyphone://music/playlist/" .. data.id,
}
end
end
return nil
end
local function canonical_document(source, device, app_id, id)
if app_id == "calendar" and device.account_id then
local event = first_row([[
SELECT `title`, `note`, `starts_at`, `ends_at` FROM `sky_phone_calendar_events`
WHERE `id` = ? AND `account_id` = ? LIMIT 1
]], { id, tonumber(device.account_id) })
if event then
return {
title = event.title,
subtitle = tostring(event.starts_at),
copyText = event.title .. "\n" .. event.note,
link = "skyphone://calendar/event/" .. id,
meta = { startAt = tostring(event.starts_at), endAt = tostring(event.ends_at) },
}
end
elseif app_id == "mail" and device.account_id then
local message = first_row([[
SELECT message.`subject`, message.`body`, sender.`email` AS `sender_email`
FROM `sky_phone_mail_entries` entry
JOIN `sky_phone_mail_messages` message ON message.`id` = entry.`message_id`
LEFT JOIN `sky_phone_accounts` sender ON sender.`id` = message.`sender_account_id`
WHERE entry.`id` = ? AND entry.`account_id` = ? AND entry.`trashed_at` IS NULL LIMIT 1
]], { id, tonumber(device.account_id) })
if message then
return {
title = message.subject,
subtitle = message.sender_email,
copyText = message.subject .. "\n" .. message.body,
link = "skyphone://mail/message/" .. id,
}
end
elseif app_id == "house" then
return SkyPhoneHousing.ResolveShare(source, id)
end
return nil
end
local function canonical_text(device, app_id, id)
if app_id ~= "darkchat" or not device.account_id then
return nil
end
local message = first_row([[
SELECT message.`body`, message.`message_type`, sender.`alias` AS `sender_alias`
FROM `sky_phone_darkchat_messages` message
JOIN `sky_phone_darkchat_members` member ON member.`conversation_id` = message.`conversation_id`
JOIN `sky_phone_darkchat_profiles` viewer ON viewer.`id` = member.`profile_id`
LEFT JOIN `sky_phone_darkchat_profiles` sender ON sender.`id` = message.`sender_profile_id`
WHERE message.`id` = ? AND viewer.`account_id` = ? AND message.`deleted_for_everyone` = 0
AND message.`message_type` IN ('text', 'emoji') LIMIT 1
]], { id, tonumber(device.account_id) })
if not message then
return nil
end
return {
title = message.body,
subtitle = message.sender_alias,
copyText = message.body,
}
end
local function display_name(source)
local first = trim(Bridge.Framework.GetFirstname(source), 80)
local last = trim(Bridge.Framework.GetLastname(source), 80)
local name = table.concat({ first or "", last or "" }, " "):match("^%s*(.-)%s*$")
return name ~= "" and name or GetPlayerName(source) or ("Player %s"):format(source)
end
local function visibility_for(imei)
local rows = Bridge.Database.Query([[
SELECT `visibility` FROM `sky_phone_easyshare_preferences`
WHERE `device_imei` = ? LIMIT 1
]], { imei })
local visibility = rows[1] and rows[1].visibility or Config.EasyShare.DefaultVisibility
return valid_visibilities[visibility] and visibility or "hidden"
end
local function distance_between(left_source, right_source)
if GetPlayerRoutingBucket(left_source) ~= GetPlayerRoutingBucket(right_source) then
return nil
end
local left_ped = GetPlayerPed(left_source)
local right_ped = GetPlayerPed(right_source)
if left_ped == 0 or right_ped == 0 then
return nil
end
return #(GetEntityCoords(left_ped) - GetEntityCoords(right_ped))
end
local function visible_to(target_device, sender_device, visibility)
if visibility == "everyone" then
return true
end
if visibility ~= "contacts" or not sender_device.phone_number then
return false
end
local condition, owner_params = owner_condition(target_device)
local params = { sender_device.phone_number }
append_params(params, owner_params)
local rows = Bridge.Database.Query(([[
SELECT `id` FROM `sky_phone_contacts`
WHERE `phone_number` = ? AND %s LIMIT 1
]]):format(condition), params)
return rows[1] ~= nil
end
local function target_state(sender_source, target_source, sender_device)
if target_source == sender_source then
return nil
end
local target_device = current_device(target_source)
if not target_device then
return nil
end
local visibility = visibility_for(target_device.imei)
if not visible_to(target_device, sender_device, visibility) then
return nil
end
local distance = distance_between(sender_source, target_source)
if not distance or distance > Config.EasyShare.MaximumDistance then
return nil
end
return { device = target_device, distance = distance }
end
local function nearby_targets(source, device)
local targets = {}
for _, target in ipairs(Bridge.Framework.GetPlayers()) do
local target_source = tonumber(target)
if target_source then
local state = target_state(source, target_source, device)
if state then
targets[#targets + 1] = {
id = target_source,
name = display_name(target_source),
distance = math.floor(state.distance * 10 + 0.5) / 10,
}
end
end
end
table.sort(targets, function(left, right)
return left.distance < right.distance
end)
return targets
end
local function canonical_own_contact(source, device)
if not device.phone_number then
return nil
end
local name = display_name(source)
return {
appId = "phone",
kind = "contact",
id = "self",
title = name,
subtitle = device.phone_number,
copyText = ("%s\n%s"):format(name, device.phone_number),
meta = {
name = name,
phoneNumber = device.phone_number,
},
}
end
local function sanitize_payload(source, device, data)
if type(data) ~= "table" or not valid_kinds[data.kind] then
return nil, "invalid_payload"
end
local title = trim(data.title, 160)
local copy_text = trim(data.copyText, 4000)
local app_id = trim(data.appId, 48)
if not title or not copy_text or not app_id or not valid_apps[app_id] then
return nil, "invalid_payload"
end
local payload = {
appId = app_id,
kind = data.kind,
title = title,
copyText = copy_text,
}
if type(data.id) == "string" and #data.id <= 128 then
payload.id = data.id
elseif type(data.id) == "number" and data.id > 0 and data.id == math.floor(data.id) then
payload.id = data.id
end
if type(data.subtitle) == "string" and #data.subtitle <= 240 then
payload.subtitle = data.subtitle
end
if type(data.link) == "string" and #data.link <= 500 and data.link:match("^[%w_%-]+://") then
payload.link = data.link
end
local condition, owner_params = owner_condition(device)
if data.kind == "contact" and type(payload.id) == "string" then
if app_id == "phone" and payload.id == "self" then
local contact = canonical_own_contact(source, device)
if not contact then
return nil, "sim_required"
end
payload.title = contact.title
payload.subtitle = contact.subtitle
payload.copyText = contact.copyText
payload.meta = contact.meta
else
local params = { payload.id }
append_params(params, owner_params)
local rows = Bridge.Database.Query(([[
SELECT `name`, `phone_number`, `organization`, `notes`
FROM `sky_phone_contacts` WHERE `contact_id` = ? AND %s LIMIT 1
]]):format(condition), params)
local contact = rows[1]
if not contact then
return nil, "not_owned"
end
payload.title = contact.name
payload.subtitle = contact.phone_number
payload.copyText = ("%s\n%s"):format(contact.name, contact.phone_number)
payload.meta = {
name = contact.name,
notes = contact.notes,
organization = contact.organization,
phoneNumber = contact.phone_number,
}
end
elseif data.kind == "note" and type(payload.id) == "string" then
local params = { payload.id }
append_params(params, owner_params)
local rows = Bridge.Database.Query(([[
SELECT `title`, `body` FROM `sky_phone_notes`
WHERE `id` = ? AND %s LIMIT 1
]]):format(condition), params)
local note = rows[1]
if not note then
return nil, "not_owned"
end
payload.title = note.title ~= "" and note.title or title
payload.copyText = plain_note_body(note.body)
payload.meta = { body = note.body, title = note.title }
elseif data.kind == "media" and app_id == "photos" and type(data.meta) == "table" and type(data.meta.mediaIds) == "table" then
local media_ids = {}
local seen_ids = {}
for _, value in ipairs(data.meta.mediaIds) do
local media_id = tonumber(value)
if media_id and media_id > 0 and media_id == math.floor(media_id) and not seen_ids[media_id] then
seen_ids[media_id] = true
media_ids[#media_ids + 1] = media_id
end
end
if #media_ids < 1 or #media_ids > 50 then
return nil, "invalid_payload"
end
local placeholders = {}
local media_params = {}
for _, media_id in ipairs(media_ids) do
placeholders[#placeholders + 1] = "?"
media_params[#media_params + 1] = media_id
end
append_params(media_params, owner_params)
local media_rows = Bridge.Database.Query(([[
SELECT `id`, `url`, `media_type` AS `mediaType`
FROM `sky_phone_media`
WHERE `id` IN (%s) AND %s AND `media_type` IN ('photo', 'video')
ORDER BY `created_at` ASC, `id` ASC
]]):format(table.concat(placeholders, ", "), condition), media_params)
if #media_rows ~= #media_ids then
return nil, "not_owned"
end
local items = {}
for _, row in ipairs(media_rows) do
items[#items + 1] = {
id = tonumber(row.id),
mediaType = row.mediaType,
url = row.url,
}
end
payload.imageUrl = items[1].url
payload.meta = { items = items }
elseif data.kind == "photo" or data.kind == "video" then
local url, media_error = SkyPhoneMedia.ResolveOwnedMedia(source, payload.id, data.kind)
if not url then
return nil, media_error or "not_owned"
end
local media_params = { tonumber(payload.id) }
append_params(media_params, owner_params)
local media_rows = Bridge.Database.Query(([[
SELECT `remote_id` FROM `sky_phone_media`
WHERE `id` = ? AND %s LIMIT 1
]]):format(condition), media_params)
if not media_rows[1] then
return nil, "not_owned"
end
payload.imageUrl = url
payload.meta = { remoteId = media_rows[1].remote_id, url = url }
elseif data.kind == "location" then
local ped = GetPlayerPed(source)
if ped == 0 then
return nil, "location_unavailable"
end
local coords = GetEntityCoords(ped)
payload.meta = { x = coords.x, y = coords.y, z = coords.z }
else
local canonical
if data.kind == "profile" and payload.id then
canonical = canonical_profile(device, app_id, payload.id)
elseif data.kind == "post" and payload.id then
canonical = canonical_post(device, app_id, payload.id)
elseif (data.kind == "track" or data.kind == "playlist") and app_id == "music" and payload.id then
canonical = canonical_music(device, data)
elseif data.kind == "document" and payload.id then
canonical = canonical_document(source, device, app_id, payload.id)
elseif data.kind == "text" and payload.id then
canonical = canonical_text(device, app_id, payload.id)
elseif data.kind == "link" and payload.id then
if app_id == "citymarkt" or app_id == "local-pages" then
canonical = canonical_post(device, app_id, payload.id)
elseif app_id == "crewlink" then
canonical = canonical_crewlink_invite(device, payload.id)
end
end
if not canonical then
return nil, "unsupported_payload"
end
payload.title = canonical.title
payload.copyText = canonical.copyText
payload.subtitle = canonical.subtitle
payload.imageUrl = canonical.imageUrl
payload.link = canonical.link
payload.meta = canonical.meta
end
local encoded = json.encode(payload)
if #encoded > Config.EasyShare.PayloadMaxBytes then
return nil, "payload_too_large"
end
return payload, nil, encoded
end
function SkyPhoneEasyShare.SanitizeChatPayload(source, data)
if not Config.EasyShare.Enabled then
return nil, "disabled"
end
local device, error_response = current_device(source)
if not device then
return nil, error_response.error
end
return sanitize_payload(source, device, data)
end
local function transfer_for_source(transfer, source)
local incoming = transfer.recipient_source == source
return {
id = transfer.id,
direction = incoming and "incoming" or "outgoing",
otherName = incoming and transfer.sender_name or transfer.recipient_name,
payload = transfer.payload,
progress = transfer.progress,
status = transfer.status,
createdAt = transfer.created_at,
}
end
local function notify_transfer(transfer)
TriggerClientEvent("sky_phone:easyshare:changed", transfer.sender_source, {
transfer = transfer_for_source(transfer, transfer.sender_source),
})
TriggerClientEvent("sky_phone:easyshare:changed", transfer.recipient_source, {
transfer = transfer_for_source(transfer, transfer.recipient_source),
})
end
local function persist_state(transfer)
Bridge.Database.Query([[
UPDATE `sky_phone_easyshare_transfers`
SET `status` = ?, `progress` = ?, `updated_at` = CURRENT_TIMESTAMP,
`completed_at` = IF(? IN ('completed','declined','cancelled','expired','failed'), CURRENT_TIMESTAMP, `completed_at`)
WHERE `id` = ?
]], { transfer.status, transfer.progress, transfer.status, transfer.id })
end
local function finish_transfer(transfer, status)
if transfer.status ~= "pending" and transfer.status ~= "transferring" then
return
end
transfer.status = status
transfer.progress = status == "completed" and 100 or transfer.progress
persist_state(transfer)
notify_transfer(transfer)
active_transfers[transfer.id] = nil
end
local function apply_received_payload(transfer)
local device = SkyPhone.LoadDevice(transfer.recipient_imei)
if not device then
return false
end
local account_id = device.account_id and tonumber(device.account_id) or nil
local meta = transfer.payload.meta or {}
if transfer.payload.kind == "contact" then
Bridge.Database.Query([[
INSERT INTO `sky_phone_contacts`
(`id`, `contact_id`, `account_id`, `device_imei`, `name`, `notes`, `organization`, `phone_number`)
VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), ?)
]], {
uuid(), uuid(), account_id, account_id and nil or device.imei,
meta.name, meta.notes or "", meta.organization or "", meta.phoneNumber,
})
TriggerClientEvent("sky_phone:contacts:changed", transfer.recipient_source, {})
elseif transfer.payload.kind == "note" or transfer.payload.kind == "text" or transfer.payload.kind == "document" then
Bridge.Database.Query([[
INSERT INTO `sky_phone_notes`
(`id`, `account_id`, `device_imei`, `title`, `body`, `pinned`)
VALUES (?, ?, ?, ?, ?, 0)
]], {
uuid(), account_id, account_id and nil or device.imei,
meta.title or transfer.payload.title, meta.body or transfer.payload.copyText,
})
if account_id then
SkyPhone.RefreshAccount(account_id)
else
SkyPhone.RefreshDevice(device.imei)
end
elseif transfer.payload.kind == "location" then
Bridge.Database.Query([[
INSERT INTO `sky_phone_map_markers`
(`id`, `device_imei`, `label`, `color`, `position_x`, `position_y`, `position_z`)
VALUES (?, ?, ?, 'blue', ?, ?, ?)
]], { uuid(), device.imei, transfer.payload.title:sub(1, 40), meta.x, meta.y, meta.z })
elseif transfer.payload.kind == "photo" or transfer.payload.kind == "video" then
Bridge.Database.Query([[
INSERT INTO `sky_phone_media` (`account_id`, `device_imei`, `url`, `remote_id`, `media_type`)
VALUES (?, ?, ?, ?, ?)
]], {
account_id, account_id and nil or device.imei, meta.url, meta.remoteId, transfer.payload.kind,
})
TriggerClientEvent("sky_phone:gallery:changed", transfer.recipient_source, {})
elseif transfer.payload.kind == "post"
or transfer.payload.kind == "profile"
or transfer.payload.kind == "track"
or transfer.payload.kind == "playlist"
or transfer.payload.kind == "link"
then
return type(transfer.payload.link) == "string"
else
return false
end
return true
end
local function advance_transfer(id)
local transfer = active_transfers[id]
if not transfer or transfer.status ~= "transferring" then
return
end
local distance = distance_between(transfer.sender_source, transfer.recipient_source)
if not distance or distance > Config.EasyShare.MaximumDistance then
finish_transfer(transfer, "failed")
return
end
transfer.progress = math.min(100, transfer.progress + 10)
persist_state(transfer)
notify_transfer(transfer)
if transfer.progress >= 100 then
if apply_received_payload(transfer) then
finish_transfer(transfer, "completed")
else
finish_transfer(transfer, "failed")
end
return
end
SetTimeout(math.max(100, math.floor(Config.EasyShare.TransferDurationMs / 10)), function()
advance_transfer(id)
end)
end
local function row_transfer(row, current_imei)
local decoded = json.decode(row.payload)
return {
id = row.id,
direction = row.recipient_imei == current_imei and "incoming" or "outgoing",
otherName = row.recipient_imei == current_imei and row.sender_name or row.recipient_name,
payload = decoded,
progress = tonumber(row.progress) or 0,
status = row.status,
createdAt = (tonumber(row.created_at_unix) or 0) * 1000,
}
end
local function history(device)
local rows = Bridge.Database.Query([[
SELECT `id`, `sender_imei`, `recipient_imei`, `sender_name`, `recipient_name`,
`payload`, `status`, `progress`, UNIX_TIMESTAMP(`created_at`) AS `created_at_unix`
FROM `sky_phone_easyshare_transfers`
WHERE `sender_imei` = ? OR `recipient_imei` = ?
ORDER BY `created_at` DESC LIMIT ?
]], { device.imei, device.imei, Config.EasyShare.HistoryLimit })
local result = {}
for index, row in ipairs(rows) do
result[index] = row_transfer(row, device.imei)
end
return result
end
Bridge.Callbacks.Register("sky_phone:easyshare:bootstrap", function(source)
if not Config.EasyShare.Enabled then
return { success = false, error = "disabled" }
end
if not SkyPhone.AllowOperation(source, "easyshare_bootstrap", Config.EasyShare.BootstrapRequestsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local device, error_response = current_device(source)
if not device then
return error_response
end
local pending = {}
for _, transfer in pairs(active_transfers) do
if transfer.sender_source == source or transfer.recipient_source == source then
pending[#pending + 1] = transfer_for_source(transfer, source)
end
end
return {
success = true,
data = {
history = history(device),
pending = pending,
targets = nearby_targets(source, device),
visibility = visibility_for(device.imei),
},
}
end)
Bridge.Callbacks.Register("sky_phone:easyshare:own-contact", function(source)
if not Config.EasyShare.Enabled then
return { success = false, error = "disabled" }
end
if not SkyPhone.AllowOperation(source, "easyshare_own_contact", Config.EasyShare.BootstrapRequestsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local device, error_response = current_device(source)
if not device then
return error_response
end
local payload = canonical_own_contact(source, device)
if not payload then
return { success = false, error = "sim_required" }
end
return { success = true, data = payload }
end)
Bridge.Callbacks.Register("sky_phone:easyshare:set-visibility", function(source, data)
if not Config.EasyShare.Enabled then
return { success = false, error = "disabled" }
end
if not SkyPhone.AllowOperation(source, "easyshare_visibility", Config.EasyShare.VisibilityUpdatesPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local device, error_response = current_device(source)
if not device then
return error_response
end
local visibility = type(data) == "table" and data.visibility or nil
if not valid_visibilities[visibility] then
return { success = false, error = "invalid_visibility" }
end
Bridge.Database.Query([[
INSERT INTO `sky_phone_easyshare_preferences` (`device_imei`, `visibility`)
VALUES (?, ?) ON DUPLICATE KEY UPDATE `visibility` = VALUES(`visibility`)
]], { device.imei, visibility })
return { success = true, data = { visibility = visibility } }
end)
Bridge.Callbacks.Register("sky_phone:easyshare:request", function(source, data)
if not Config.EasyShare.Enabled then
return { success = false, error = "disabled" }
end
if not SkyPhone.AllowOperation(source, "easyshare_request", Config.EasyShare.RequestsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local sender_device, error_response = current_device(source)
if not sender_device then
return error_response
end
local target_source = type(data) == "table" and tonumber(data.targetId) or nil
if not target_source or target_source ~= math.floor(target_source) then
return { success = false, error = "invalid_target" }
end
local target = target_state(source, target_source, sender_device)
if not target then
return { success = false, error = "target_unavailable" }
end
local payload, payload_error, encoded = sanitize_payload(source, sender_device, data.payload)
if not payload then
return { success = false, error = payload_error }
end
local id = uuid()
local transfer = {
id = id,
sender_source = source,
recipient_source = target_source,
sender_imei = sender_device.imei,
recipient_imei = target.device.imei,
sender_name = display_name(source),
recipient_name = display_name(target_source),
payload = payload,
progress = 0,
status = "pending",
created_at = os.time() * 1000,
}
Bridge.Database.Query([[
INSERT INTO `sky_phone_easyshare_transfers`
(`id`, `sender_imei`, `recipient_imei`, `sender_name`, `recipient_name`, `content_type`, `payload`, `status`, `progress`)
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', 0)
]], { id, sender_device.imei, target.device.imei, transfer.sender_name, transfer.recipient_name, payload.kind, encoded })
active_transfers[id] = transfer
notify_transfer(transfer)
SetTimeout(Config.EasyShare.PendingSeconds * 1000, function()
local current = active_transfers[id]
if current and current.status == "pending" then
finish_transfer(current, "expired")
end
end)
return { success = true, data = transfer_for_source(transfer, source) }
end)
Bridge.Callbacks.Register("sky_phone:easyshare:respond", function(source, data)
if not Config.EasyShare.Enabled then
return { success = false, error = "disabled" }
end
if not SkyPhone.AllowOperation(source, "easyshare_action", Config.EasyShare.ActionsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local id = type(data) == "table" and data.id or nil
local transfer = type(id) == "string" and active_transfers[id] or nil
if not transfer or transfer.recipient_source ~= source or transfer.status ~= "pending" or type(data.accepted) ~= "boolean" then
return { success = false, error = "transfer_not_found" }
end
if not data.accepted then
finish_transfer(transfer, "declined")
return { success = true, data = transfer_for_source(transfer, source) }
end
local distance = distance_between(transfer.sender_source, transfer.recipient_source)
if not distance or distance > Config.EasyShare.MaximumDistance then
finish_transfer(transfer, "failed")
return { success = false, error = "too_far" }
end
transfer.status = "transferring"
persist_state(transfer)
notify_transfer(transfer)
SetTimeout(math.max(100, math.floor(Config.EasyShare.TransferDurationMs / 10)), function()
advance_transfer(id)
end)
return { success = true, data = transfer_for_source(transfer, source) }
end)
Bridge.Callbacks.Register("sky_phone:easyshare:cancel", function(source, data)
if not Config.EasyShare.Enabled then
return { success = false, error = "disabled" }
end
if not SkyPhone.AllowOperation(source, "easyshare_action", Config.EasyShare.ActionsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local id = type(data) == "table" and data.id or nil
local transfer = type(id) == "string" and active_transfers[id] or nil
if not transfer or (transfer.sender_source ~= source and transfer.recipient_source ~= source) then
return { success = false, error = "transfer_not_found" }
end
finish_transfer(transfer, "cancelled")
return { success = true, data = transfer_for_source(transfer, source) }
end)
AddEventHandler("playerDropped", function()
local dropped_source = source
for _, transfer in pairs(active_transfers) do
if transfer.sender_source == dropped_source or transfer.recipient_source == dropped_source then
finish_transfer(transfer, "failed")
end
end
end)
end)