From bfb00ec3b56199cb886a5ba0fac3c7344b7f0e5a Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Thu, 6 Aug 2026 16:57:57 +0200 Subject: [PATCH] ADD - implement server-authoritative messaging --- sky_phone/source/server/messages.lua | 434 +++++++++++++++++++++++++++ 1 file changed, 434 insertions(+) create mode 100644 sky_phone/source/server/messages.lua diff --git a/sky_phone/source/server/messages.lua b/sky_phone/source/server/messages.lua new file mode 100644 index 0000000..1e0a6d5 --- /dev/null +++ b/sky_phone/source/server/messages.lua @@ -0,0 +1,434 @@ +Bridge.Database.AfterMigration("sky_phone", function() + +local allowed_voice_mimes = { + ["audio/webm"] = true, + ["audio/webm;codecs=opus"] = true, +} + +local attachment_assets = { + gif = { + celebrate = true, + hearts = true, + party = true, + thumbs_up = true, + wow = true, + }, + image = { + ["camera-1"] = true, + ["camera-2"] = true, + ["camera-3"] = true, + ["city-lights"] = true, + ["desert-road"] = true, + ["ocean-air"] = true, + ["sunset-drive"] = true, + }, + video = { + ["city-loop"] = true, + ["ocean-loop"] = true, + ["sunset-loop"] = true, + }, +} + +local attachment_mimes = { + gif = "image/gif", + image = "image/jpeg", + video = "video/mp4", +} + +local function allowed_media_url(value) + if type(value) ~= "string" or #value == 0 or #value > Config.Media.UrlMaxLength then + return false + end + local host = value:lower():match("^https://([^/:?#]+)") + if not host then + return false + end + for _, allowed_host in ipairs(Config.Media.AllowedGifHosts) do + local suffix = "." .. allowed_host + if host == allowed_host or host:sub(-#suffix) == suffix then + return true + end + end + return false +end + +local function valid_attachment_asset(message_type, value) + return attachment_assets[message_type][value] + or message_type == "gif" and allowed_media_url(value) +end + +local function uuid() + local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {}) + if not rows[1] or type(rows[1].id) ~= "string" then + error("[sky_phone] Database did not generate an SMS UUID.") + end + return rows[1].id +end + +local function trim(value) + if type(value) ~= "string" then + return nil + end + return value:match("^%s*(.-)%s*$") +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 + if not device.sim_id then + return nil, { success = false, error = "no_sim" } + end + return device +end + +local function format_message(row) + row.media_duration_ms = tonumber(row.media_duration_ms) + row.media_asset_id = nil + if row.message_type == "voice" then + local waveform = row.media_waveform and json.decode(row.media_waveform) or nil + if type(waveform) ~= "table" then + error(("[sky_phone] Voice message %s has an invalid waveform payload."):format(tostring(row.id))) + end + row.media_waveform = waveform + row.media_payload = nil + elseif attachment_assets[row.message_type] then + if not valid_attachment_asset(row.message_type, row.media_payload) then + error(("[sky_phone] Message %s has an invalid attachment asset."):format(tostring(row.id))) + end + row.media_asset_id = row.media_payload + row.media_payload = nil + row.media_waveform = nil + if row.message_type ~= "video" then + row.media_duration_ms = nil + end + else + row.media_payload = nil + row.media_duration_ms = nil + row.media_mime = nil + row.media_waveform = nil + end + return row +end + +local function validate_attachment(message_type, data) + if type(data.mediaAssetId) ~= "string" or not valid_attachment_asset(message_type, data.mediaAssetId) then + return nil + end + local duration = nil + if message_type == "video" then + duration = tonumber(data.mediaDurationMs) + if not duration or duration < 1000 or duration > Config.Messages.VideoMaxDurationMs then + return nil + end + duration = math.floor(duration) + end + return { + duration = duration, + mime = attachment_mimes[message_type], + payload = data.mediaAssetId, + } +end + +local function validate_voice(data) + if type(data.mediaPayload) ~= "string" + or #data.mediaPayload == 0 + or #data.mediaPayload > Config.Messages.VoiceMaxBase64Length + or data.mediaPayload:find("[^A-Za-z0-9+/=]") then + return nil + end + if type(data.mediaMime) ~= "string" or not allowed_voice_mimes[data.mediaMime] then + return nil + end + local duration = tonumber(data.mediaDurationMs) + if not duration or duration < 300 or duration > Config.Messages.VoiceMaxDurationMs then + return nil + end + if type(data.mediaWaveform) ~= "table" + or #data.mediaWaveform < 8 + or #data.mediaWaveform > Config.Messages.VoiceWaveformSamples then + return nil + end + local waveform = {} + for index = 1, #data.mediaWaveform do + local sample = tonumber(data.mediaWaveform[index]) + if not sample or sample < 0 or sample > 1 then + return nil + end + waveform[index] = math.floor(sample * 1000 + 0.5) / 1000 + end + return { + duration = math.floor(duration), + mime = data.mediaMime, + payload = data.mediaPayload, + waveform = json.encode(waveform), + } +end + +local function notify_sim(sim_id, event_name, data) + local devices = Bridge.Database.Query([[ + SELECT d.`imei`, d.`device_name`, settings.`payload` AS `settings` + FROM `sky_phone_devices` d + LEFT JOIN `sky_phone_device_data` settings + ON settings.`device_imei` = d.`imei` AND settings.`namespace` = 'settings' + WHERE d.`sim_id` = ? + ]], { sim_id }) + for _, device in ipairs(devices) do + for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do + local source = tonumber(player_source) or player_source + if SkyPhone.FindDeviceSlots(source, device.imei)[1] then + local payload = {} + for key, value in pairs(data) do + payload[key] = value + end + payload.device = { + imei = device.imei, + name = device.device_name, + settings = device.settings, + } + TriggerClientEvent(event_name, source, payload) + end + end + end +end + +Bridge.Callbacks.Register("sky_phone:messages:conversations", function(source) + local device, error_response = current_device(source) + if not device then + return error_response + end + local rows = Bridge.Database.Query([[ + SELECT `id`, `sender_sim_id`, `recipient_sim_id`, `sender_number`, `recipient_number`, + `message_type`, `body`, `read_at`, `created_at` + FROM `sky_phone_sms_messages` + WHERE `sender_sim_id` = ? OR `recipient_sim_id` = ? + ORDER BY `created_at` DESC, `id` DESC LIMIT ? + ]], { device.sim_id, device.sim_id, Config.Messages.ConversationScanLimit }) + local conversations = {} + local ordered = {} + for _, row in ipairs(rows) do + local received = row.recipient_sim_id == device.sim_id + local number = received and row.sender_number or row.recipient_number + local conversation = conversations[number] + if not conversation then + conversation = { + phoneNumber = number, + lastMessage = row.body, + lastMessageAt = row.created_at, + lastMessageType = row.message_type, + unread = 0, + } + conversations[number] = conversation + ordered[#ordered + 1] = conversation + end + if received and not row.read_at then + conversation.unread = conversation.unread + 1 + end + end + return { success = true, data = ordered } +end) + +Bridge.Callbacks.Register("sky_phone:messages:thread", function(source, data) + if type(data) ~= "table" then + return { success = false, error = "invalid_request" } + end + local device, error_response = current_device(source) + if not device then + return error_response + end + local number = SkyPhoneSimNumber.Normalize(data.phoneNumber, Config.Sim.NumberLength, Config.Sim.NumberPrefix) + if not number then + return { success = false, error = "invalid_number" } + end + local rows = Bridge.Database.Query([[ + SELECT * FROM ( + SELECT `id`, `sender_number`, `recipient_number`, `message_type`, `body`, `media_payload`, `media_mime`, + `media_duration_ms`, `media_waveform`, `read_at`, `created_at`, + CASE WHEN `sender_sim_id` = ? THEN 'sent' ELSE 'received' END AS `direction` + FROM `sky_phone_sms_messages` + WHERE (`sender_sim_id` = ? AND `recipient_number` = ?) + OR (`recipient_sim_id` = ? AND `sender_number` = ?) + ORDER BY `created_at` DESC, `id` DESC LIMIT ? + ) recent_messages + ORDER BY `created_at` ASC, `id` ASC + ]], { device.sim_id, device.sim_id, number, device.sim_id, number, Config.Messages.ThreadPageSize }) + for index = 1, #rows do + rows[index] = format_message(rows[index]) + end + Bridge.Database.Query([[ + UPDATE `sky_phone_sms_messages` SET `read_at` = CURRENT_TIMESTAMP + WHERE `recipient_sim_id` = ? AND `sender_number` = ? AND `read_at` IS NULL + ]], { device.sim_id, number }) + return { success = true, data = rows } +end) + +Bridge.Callbacks.Register("sky_phone:messages:delete", function(source, data) + if not SkyPhone.AllowOperation(source, "message_delete", 10, 60) then + return { success = false, error = "rate_limited" } + end + if type(data) ~= "table" or type(data.phoneNumbers) ~= "table" + or #data.phoneNumbers == 0 or #data.phoneNumbers > Config.Messages.DeleteBatchSize then + return { success = false, error = "invalid_request" } + end + local device, error_response = current_device(source) + if not device then + return error_response + end + local numbers = {} + local seen = {} + for index = 1, #data.phoneNumbers do + local number = SkyPhoneSimNumber.Normalize( + data.phoneNumbers[index], + Config.Sim.NumberLength, + Config.Sim.NumberPrefix + ) + if not number then + return { success = false, error = "invalid_number" } + end + if not seen[number] then + seen[number] = true + numbers[#numbers + 1] = number + end + end + local placeholders = {} + for index = 1, #numbers do + placeholders[index] = "?" + end + local values = { device.sim_id } + for _, number in ipairs(numbers) do + values[#values + 1] = number + end + values[#values + 1] = device.sim_id + for _, number in ipairs(numbers) do + values[#values + 1] = number + end + local list = table.concat(placeholders, ", ") + Bridge.Database.Query(([[ + DELETE FROM `sky_phone_sms_messages` + WHERE (`sender_sim_id` = ? AND `recipient_number` IN (%s)) + OR (`recipient_sim_id` = ? AND `sender_number` IN (%s)) + ]]):format(list, list), values) + TriggerClientEvent("sky_phone:messages:changed", source, {}) + return { success = true } +end) + +Bridge.Callbacks.Register("sky_phone:messages:media", function(source, data) + if not SkyPhone.AllowOperation(source, "message_media", Config.Messages.MediaLoadsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + if type(data) ~= "table" or type(data.id) ~= "string" then + return { success = false, error = "invalid_request" } + end + local device, error_response = current_device(source) + if not device then + return error_response + end + local rows = Bridge.Database.Query([[ + SELECT `media_payload`, `media_mime` + FROM `sky_phone_sms_messages` + WHERE `id` = ? AND `message_type` = 'voice' + AND (`sender_sim_id` = ? OR `recipient_sim_id` = ?) + LIMIT 1 + ]], { data.id, device.sim_id, device.sim_id }) + if not rows[1] or type(rows[1].media_payload) ~= "string" then + return { success = false, error = "message_not_found" } + end + return { + success = true, + data = { + mime = rows[1].media_mime, + payload = rows[1].media_payload, + }, + } +end) + +Bridge.Callbacks.Register("sky_phone:messages:send", function(source, data) + if not SkyPhone.AllowOperation(source, "message_send", Config.Messages.SendsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + if type(data) ~= "table" then + return { success = false, error = "invalid_request" } + end + local device, error_response = current_device(source) + if not device then + return error_response + end + local number = SkyPhoneSimNumber.Normalize(data.phoneNumber, Config.Sim.NumberLength, Config.Sim.NumberPrefix) + if not number then + return { success = false, error = "invalid_number" } + end + if number == device.phone_number then + return { success = false, error = "self_message" } + end + local message_type = data.messageType or "text" + local body = trim(data.body) or "" + local voice = nil + local attachment = nil + if message_type == "text" then + if body == "" or #body > Config.Messages.BodyMaxLength then + return { success = false, error = "invalid_message" } + end + elseif message_type == "voice" then + voice = validate_voice(data) + if not voice then + return { success = false, error = "invalid_voice" } + end + body = "" + elseif attachment_assets[message_type] then + attachment = validate_attachment(message_type, data) + if not attachment then + return { success = false, error = "invalid_attachment" } + end + body = "" + else + return { success = false, error = "invalid_request" } + end + local recipients = Bridge.Database.Query( + "SELECT `id`, `phone_number` FROM `sky_phone_sims` WHERE `phone_number` = ? LIMIT 1", + { number } + ) + local recipient = recipients[1] + if not recipient then + return { success = false, error = "recipient_not_found" } + end + local id = uuid() + Bridge.Database.Query([[ + INSERT INTO `sky_phone_sms_messages` + (`id`, `sender_sim_id`, `recipient_sim_id`, `sender_number`, `recipient_number`, `message_type`, + `body`, `media_payload`, `media_mime`, `media_duration_ms`, `media_waveform`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ]], { + id, + device.sim_id, + recipient.id, + device.phone_number, + number, + message_type, + body, + voice and voice.payload or attachment and attachment.payload or nil, + voice and voice.mime or attachment and attachment.mime or nil, + voice and voice.duration or attachment and attachment.duration or nil, + voice and voice.waveform or nil, + }) + local rows = Bridge.Database.Query([[ + SELECT `id`, `sender_number`, `recipient_number`, `message_type`, `body`, `media_payload`, `media_mime`, + `media_duration_ms`, `media_waveform`, `read_at`, `created_at`, 'sent' AS `direction` + FROM `sky_phone_sms_messages` WHERE `id` = ? LIMIT 1 + ]], { id }) + local message = format_message(rows[1]) + TriggerClientEvent("sky_phone:messages:changed", source, { phoneNumber = number }) + notify_sim(recipient.id, "sky_phone:messages:new", { + message = message, + phoneNumber = device.phone_number, + sender = device.phone_number, + voice = message_type == "voice", + }) + return { success = true, data = message } +end) + +end)