ADD - voice memo app

This commit is contained in:
Leon.Schmidt
2026-08-13 16:16:23 +02:00
parent 223cb4a600
commit f4ed86fd63
29 changed files with 3234 additions and 50 deletions
+14
View File
@@ -193,6 +193,20 @@ Config.Messages = {
DeleteBatchSize = 20,
}
Config.Memos = {
MaximumCount = 100,
MaximumDurationMs = 300000,
MaximumBytes = 2 * 1024 * 1024,
MaximumWaveformSamples = 96,
TitleMaxLength = 120,
NoteMaxLength = 2000,
UploadsPerMinute = 10,
WritesPerMinute = 60,
ListsPerMinute = 60,
MaximumPendingUploads = 1,
UploadSessionTimeoutMs = 60000,
}
Config.EasyShare = {
Enabled = true,
DefaultVisibility = "everyone", -- everyone, contacts or hidden
+36
View File
@@ -1528,6 +1528,42 @@ Locales["en"] = {
noResultsBody = "Try searching for a different word or phrase.", pin = "Pin note", unpin = "Unpin note",
deleteNote = "Delete note",
},
memos = {
name = "Memos", memo = "Voice Memo", back = "Back", actions = "Memo actions",
newMemo = "New Recording", newMemoWithDate = "New Recording · {date}",
searchPlaceholder = "Search Recordings", emptyTitle = "No Recordings",
emptyBody = "Tap the record button to capture your first voice memo.", noResults = "No Results",
noResultsBody = "Try searching for a different title or note.", recording = "Recording", paused = "Paused",
preparing = "Preparing microphone…", saving = "Saving Recording…", stop = "Stop", pause = "Pause",
resume = "Resume", cancel = "Cancel", done = "Done", play = "Play recording",
pausePlayback = "Pause recording", skipBack = "Back 15 seconds", skipForward = "Forward 15 seconds",
playbackSpeed = "Playback Speed", title = "Title", titlePlaceholder = "Recording title",
note = "Note", notePlaceholder = "Add details…", delete = "Delete memo", deleteTitle = "Delete Voice Memo?",
deleteBody = "This recording will be permanently deleted.", discardTitle = "Discard Recording?",
discardBody = "The current recording will not be saved.", discard = "Discard",
keepRecording = "Keep Recording", deleted = "Memo deleted.",
microphoneUnavailable = "The microphone is unavailable.",
recordingTooLarge = "The recording is too large to save.",
maximumDuration = "The maximum recording duration was reached.",
errors = {
conflict = "This memo changed on another device. It has been reloaded.",
invalid_memo = "The voice memo contains invalid data.", invalid_request = "The memo request is invalid.",
invalid_upload = "The uploaded recording could not be verified.",
invalid_upload_token = "The recording upload could not be verified.",
memo_limit = "The limit of 100 voice memos has been reached.",
memo_not_found = "This voice memo no longer exists.",
media_provider_failed = "The voice memo upload service is unavailable.",
media_provider_rate_limited = "The voice memo upload service is busy. Try again shortly.",
media_provider_unauthorized = "The configured FiveManage API key was rejected.",
missing_config = "Voice memo uploads are not configured.",
operation_in_progress = "Finish the current recording first.",
owner_changed = "The active phone changed while saving the memo.",
rate_limited = "Too many memo changes. Try again shortly.",
recording_failed = "The recording could not be captured.",
request_timeout = "The memo request timed out.", upload_failed = "The recording could not be uploaded.",
upload_timeout = "The recording upload timed out.", request_failed = "Memos are temporarily unavailable.",
},
},
easyShare = {
name = "EasyShare", incoming = "Incoming Share", recentChats = "Contacts and Chats",
destinations = "Share destinations", newMessage = "New Message", sentToChat = "Sent to chat.",
+2 -2
View File
@@ -1,11 +1,11 @@
Config.Media = {
GiphyApiKeyConvar = "sky_phone_giphy_api_key",
GiphyApiKey = "", -- Server-only: paste the GIPHY API key here.
GifPageSize = 24,
GifRating = "pg-13",
UrlMaxLength = 2048,
AllowedGifHosts = { "giphy.com" },
FiveManage = {
ApiKeyConvar = "sky_phone_fivemanage_api_key",
ApiKey = "", -- Server-only: paste a newly generated FiveManage V3 Media API token here.
BaseUrl = "https://api.fivemanage.com/api/v3/file",
RequestTimeoutMs = 10000,
UploadTimeoutMs = 25000,
+1
View File
@@ -77,6 +77,7 @@ server_scripts {
'source/server/media.lua',
'source/server/weazel_news.lua',
'source/server/messages.lua',
'source/server/memos.lua',
'source/server/easyshare.lua',
'source/server/darkchat.lua',
'source/server/flare.lua',
+44
View File
@@ -453,6 +453,42 @@ RegisterNUICallback("gallery:delete", function(data, cb)
cb({ success = true })
end)
RegisterNUICallback("memos:requestUpload", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
TriggerServerEvent("sky_phone:memos:request-upload", data)
cb({ success = true })
end)
RegisterNUICallback("memos:completeUpload", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
TriggerServerEvent("sky_phone:memos:complete-upload", data)
cb({ success = true })
end)
RegisterNUICallback("memos:cancelUpload", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
TriggerServerEvent("sky_phone:memos:cancel-upload", data)
cb({ success = true })
end)
RegisterNUICallback("memos:failUpload", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
TriggerServerEvent("sky_phone:memos:fail-upload", data)
cb({ success = true })
end)
RegisterNetEvent("sky_phone:media:upload-ready", function(data)
SendNUIMessage({ type = "media:uploadReady", data = data })
end)
@@ -465,6 +501,14 @@ RegisterNetEvent("sky_phone:media:delete-result", function(data)
SendNUIMessage({ type = "media:deleteResult", data = data })
end)
RegisterNetEvent("sky_phone:memos:upload-ready", function(data)
SendNUIMessage({ type = "memos:uploadReady", data = data })
end)
RegisterNetEvent("sky_phone:memos:upload-result", function(data)
SendNUIMessage({ type = "memos:uploadResult", data = data })
end)
AddEventHandler("sky_phone:nuiClosed", function()
set_flash_enabled(false)
set_camera_active(false)
+3
View File
@@ -236,6 +236,9 @@ local server_callbacks = {
"messages:media",
"messages:delete",
"messages:gifs",
"memos:list",
"memos:update",
"memos:delete",
"easyshare:bootstrap",
"easyshare:own-contact",
"easyshare:set-visibility",
+35 -1
View File
@@ -376,7 +376,7 @@ local schema = {
},
{ name = "url", type = "TEXT NOT NULL" },
{ name = "remote_id", type = "VARCHAR(128) NOT NULL" },
{ name = "media_type", type = "ENUM('photo', 'video') NOT NULL" },
{ name = "media_type", type = "ENUM('photo', 'video', 'audio') NOT NULL" },
{ name = "mime_type", type = "VARCHAR(120) NULL" },
{ name = "origin", type = "ENUM('phone_upload', 'website_import') NOT NULL DEFAULT 'phone_upload'" },
{
@@ -405,6 +405,36 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_voice_memos",
columns = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "media_id", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "title", type = "VARCHAR(120) NOT NULL" },
{ name = "note", type = "VARCHAR(2000) NOT NULL DEFAULT ''" },
{ name = "duration_ms", type = "INT UNSIGNED NOT NULL" },
{ name = "size_bytes", type = "INT UNSIGNED NOT NULL" },
{ name = "waveform", type = "TEXT 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",
uniqueKeys = {
{ name = "uniq_sky_phone_voice_memos_media", columns = "(`media_id`)" },
},
indexes = {
{ name = "idx_sky_phone_voice_memos_list", columns = "(`pinned`, `updated_at`, `id`)" },
},
foreignKeys = {
{ column = "media_id", references = "`sky_phone_media` (`id`) ON DELETE CASCADE" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_contacts",
columns = {
@@ -2568,6 +2598,10 @@ Bridge.Database.Query([[
ALTER TABLE `sky_phone_darkchat_messages`
MODIFY COLUMN `message_type` ENUM('text', 'emoji', 'gif', 'voice', 'image', 'video', 'share', 'system') NOT NULL DEFAULT 'text'
]], {})
Bridge.Database.Query([[
ALTER TABLE `sky_phone_media`
MODIFY COLUMN `media_type` ENUM('photo', 'video', 'audio') NOT NULL
]], {})
Bridge.Database.Query([[
UPDATE `sky_phone_media`
SET `mime_type` = CASE
+112 -22
View File
@@ -5,6 +5,10 @@ SkyPhoneMediaImport.Initialize()
local pending_uploads = {}
local pending_deletes = {}
local allowed_remote_mimes = {
audio = {
["audio/ogg"] = true,
["audio/webm"] = true,
},
photo = {
["image/jpeg"] = true,
["image/png"] = true,
@@ -21,11 +25,11 @@ local function media_config()
end
local function media_api_key()
local convar = media_config().ApiKeyConvar
if type(convar) ~= "string" or convar == "" then
local api_key = media_config().ApiKey
if type(api_key) ~= "string" then
return ""
end
return GetConvar(convar, "")
return api_key:match("^%s*(.-)%s*$")
end
local function api_configured()
@@ -35,13 +39,14 @@ end
local function http_request(url, method, body, headers, timeout_ms)
local request = promise.new()
local settled = false
PerformHttpRequest(url, function(status, response_body, response_headers)
PerformHttpRequest(url, function(status, response_body, response_headers, error_data)
if settled then
return
end
settled = true
request:resolve({
body = response_body,
error = error_data,
headers = response_headers,
status = status,
})
@@ -56,6 +61,23 @@ local function http_request(url, method, body, headers, timeout_ms)
return Citizen.Await(request)
end
local function response_error_message(response)
if type(response) ~= "table" then
return "invalid response"
end
if type(response.error) == "string" and response.error ~= "" then
return response.error:sub(1, 240)
end
local success, payload = pcall(json.decode, response.body or "")
if success and type(payload) == "table" then
local message = payload.error or payload.message
if type(message) == "string" and message ~= "" then
return message:sub(1, 240)
end
end
return "no provider error message"
end
local function decode_response(response)
if type(response) ~= "table" or type(response.status) ~= "number" then
return nil, "invalid_response"
@@ -75,6 +97,11 @@ end
local function request_presigned_url()
if not api_configured() then
Bridge.Debug(
"error",
"[sky_phone] FiveManage presigned upload request failed: Config.Media.FiveManage.ApiKey is empty or invalid.",
{ always = true }
)
return nil, "missing_config"
end
local config = Config.Media.FiveManage
@@ -85,13 +112,43 @@ local function request_presigned_url()
{ ["Authorization"] = media_api_key() },
tonumber(config.RequestTimeoutMs) or 10000
)
if response.status == 401 or response.status == 403 then
Bridge.Debug(
"error",
"[sky_phone] FiveManage presigned upload request was rejected with HTTP %s. Check Config.Media.FiveManage.ApiKey and its file permissions.",
tostring(response.status),
{ always = true }
)
return nil, "media_provider_unauthorized"
end
if response.status == 429 then
Bridge.Debug(
"error",
"[sky_phone] FiveManage presigned upload request was rate limited with HTTP 429.",
{ always = true }
)
return nil, "media_provider_rate_limited"
end
local data, response_error = decode_response(response)
if not data then
return nil, response_error
Bridge.Debug(
"error",
"[sky_phone] FiveManage presigned upload request failed with HTTP %s (%s): %s",
tostring(response.status),
tostring(response_error),
response_error_message(response),
{ always = true }
)
return nil, response_error == "request_timeout" and "request_timeout" or "media_provider_failed"
end
local presigned_url = data.presignedUrl or data.presigned_url
if type(presigned_url) ~= "string" or presigned_url == "" then
return nil, "missing_presigned_url"
Bridge.Debug(
"error",
"[sky_phone] FiveManage presigned upload response did not contain a presignedUrl.",
{ always = true }
)
return nil, "media_provider_failed"
end
return presigned_url
end
@@ -279,34 +336,55 @@ local function verify_remote_upload(state, remote_id, uploaded_url)
if remote.url ~= uploaded_url and remote.originalUrl ~= uploaded_url then
return nil, "invalid_upload"
end
local verified_url = remote.url or uploaded_url
if type(verified_url) ~= "string" or #verified_url > Config.Media.UrlMaxLength
or not verified_url:match("^https://")
then
return nil, "invalid_upload"
end
local metadata = parse_metadata(remote.metadata)
if not metadata or metadata.captureToken ~= state.capture_token or metadata.source ~= "sky_phone" then
return nil, "invalid_upload_token"
end
if state.purpose and metadata.purpose ~= state.purpose then
return nil, "invalid_upload", true
end
local allowed_mimes = allowed_remote_mimes[state.media_type]
if not allowed_mimes then
return nil, "invalid_media_type", true
end
local remote_mime = tostring(remote.mimeType or ""):lower():match("^%s*([^;%s]+)") or ""
local remote_type = tostring(remote.type or ""):lower():match("^%s*([^;%s]+)") or ""
if remote_mime == "" and allowed_remote_mimes[state.media_type][remote_type] then
if remote_mime == "" and allowed_mimes[remote_type] then
remote_mime = remote_type
end
if remote_type == "" then
remote_type = remote_mime
end
if state.media_type == "photo" and remote_type ~= "" and not remote_type:find("image", 1, true) then
return nil, "invalid_media_type"
return nil, "invalid_media_type", true
end
if state.media_type == "video" and remote_type ~= "" and not remote_type:find("video", 1, true) then
return nil, "invalid_media_type"
return nil, "invalid_media_type", true
end
if remote_mime ~= "" and not allowed_remote_mimes[state.media_type][remote_mime] then
return nil, "invalid_media_type"
if state.media_type == "audio" and remote_type ~= "" and not remote_type:find("audio", 1, true) then
return nil, "invalid_media_type", true
end
local metadata = parse_metadata(remote.metadata)
if not metadata or metadata.captureToken ~= state.capture_token then
return nil, "invalid_upload_token"
if remote_mime ~= "" and not allowed_mimes[remote_mime] then
return nil, "invalid_media_type", true
end
return {
mime_type = allowed_remote_mimes[state.media_type][remote_mime] and remote_mime or state.mime_type,
mime_type = allowed_mimes[remote_mime] and remote_mime or state.mime_type,
remote_id = remote_id,
url = remote.url or uploaded_url,
}
size = tonumber(remote.size),
url = verified_url,
}, nil, true
end
SkyPhoneMedia.DeleteRemoteFile = delete_remote_file
SkyPhoneMedia.RequestPresignedUrl = request_presigned_url
SkyPhoneMedia.VerifyRemoteUpload = verify_remote_upload
local function expire_upload(request_id)
local state = pending_uploads[request_id]
if not state or state.completing then
@@ -329,6 +407,7 @@ Bridge.Callbacks.Register("sky_phone:gallery:list", function(source, data)
media_type = nil
end
local condition, params = owner_condition(owner)
condition = condition .. " AND `media_type` IN ('photo', 'video')"
if media_type then
condition = condition .. " AND `media_type` = ?"
params[#params + 1] = media_type
@@ -408,7 +487,8 @@ Bridge.Callbacks.Register("sky_phone:messages:gifs", function(source, data)
if #query > 60 or offset < 0 or offset > 500 then
return { success = false, error = "invalid_request" }
end
local api_key = GetConvar(Config.Media.GiphyApiKeyConvar, "")
local api_key = type(Config.Media.GiphyApiKey) == "string"
and Config.Media.GiphyApiKey:match("^%s*(.-)%s*$") or ""
if api_key == "" then
return { success = false, error = "gif_provider_unconfigured" }
end
@@ -556,9 +636,20 @@ RegisterNetEvent("sky_phone:media:complete-upload", function(data)
upload_result(src, state.correlation_id, false, error_response and error_response.error or "owner_changed")
return
end
local verified, verify_error = verify_remote_upload(state, data.remoteId, data.url)
local verified, verify_error, trusted_remote = verify_remote_upload(state, data.remoteId, data.url)
if not verified then
pending_uploads[request_id] = nil
if trusted_remote then
local deleted, delete_error = delete_remote_file(data.remoteId)
if not deleted then
Bridge.Debug(
"warn",
"[sky_phone] Could not remove rejected media upload %s: %s.",
tostring(data.remoteId),
tostring(delete_error)
)
end
end
upload_result(src, state.correlation_id, false, verify_error)
return
end
@@ -642,7 +733,7 @@ RegisterNetEvent("sky_phone:media:delete", function(data)
end
local rows = Bridge.Database.Query(([[
SELECT `id`, `remote_id`, `origin` FROM `sky_phone_media`
WHERE `id` = ? AND %s LIMIT 1
WHERE `id` = ? AND %s AND `media_type` IN ('photo', 'video') LIMIT 1
]]):format(condition), query_params)
local row = rows[1]
if not row then
@@ -718,7 +809,6 @@ AddEventHandler("playerDropped", function()
end)
if not api_configured() then
print(("^3[sky_phone] Camera uploads and FiveManage imports are disabled until the %s server convar is set.^7")
:format(tostring(media_config().ApiKeyConvar)))
print("^3[sky_phone] Camera uploads and FiveManage imports are disabled until Config.Media.FiveManage.ApiKey is set in config/media.lua.^7")
end
end)
@@ -1,9 +1,9 @@
local function api_key(website)
local convar = website.ApiKeyConvar or Config.Media.FiveManage.ApiKeyConvar
if type(convar) ~= "string" or convar == "" then
local configured_key = website.ApiKey or Config.Media.FiveManage.ApiKey
if type(configured_key) ~= "string" then
return ""
end
return GetConvar(convar, "")
return configured_key:match("^%s*(.-)%s*$")
end
local function provider_error(response, not_found_error)
+532
View File
@@ -0,0 +1,532 @@
Bridge.Database.AfterMigration("sky_phone", function()
SkyPhoneMemos = {}
local pending_uploads = {}
local pending_deletes = {}
local allowed_audio_mimes = {
["audio/ogg"] = "audio/ogg",
["audio/webm"] = "audio/webm",
["audio/webm;codecs=opus"] = "audio/webm",
}
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 trim(value)
if type(value) ~= "string" then
return nil
end
return value:match("^%s*(.-)%s*$")
end
local function valid_uuid(value)
if type(value) ~= "string" or #value ~= 36
or value:sub(9, 9) ~= "-"
or value:sub(14, 14) ~= "-"
or value:sub(19, 19) ~= "-"
or value:sub(24, 24) ~= "-"
then
return false
end
local compact = value:gsub("-", "")
return #compact == 32 and compact:match("^%x+$") ~= nil
end
local function session_owner(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 {
account_id = device.account_id and tonumber(device.account_id) or nil,
imei = session.imei,
}
end
local function owner_condition(owner, alias)
local prefix = alias and ("`%s`."):format(alias) or ""
if owner.account_id then
return prefix .. "`account_id` = ?", { owner.account_id }
end
return prefix .. "`account_id` IS NULL AND " .. prefix .. "`device_imei` = ?", { owner.imei }
end
local function owner_key(owner)
if owner.account_id then
return "account:" .. tostring(owner.account_id)
end
return "device:" .. owner.imei
end
local function append_params(target, values)
for _, value in ipairs(values) do
target[#target + 1] = value
end
end
local function memo_dto(row)
local waveform = json.decode(row.waveform)
if type(waveform) ~= "table" then
error(("[sky_phone] Voice memo %s has an invalid waveform payload."):format(tostring(row.id)))
end
return {
id = row.id,
title = row.title,
note = row.note,
url = row.url,
mimeType = row.mime_type,
durationMs = tonumber(row.duration_ms) or 0,
waveform = waveform,
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 list_memos(owner)
local condition, params = owner_condition(owner, "media")
params[#params + 1] = Config.Memos.MaximumCount
local rows = Bridge.Database.Query(([[
SELECT memo.`id`, memo.`title`, memo.`note`, memo.`duration_ms`,
memo.`waveform`, memo.`pinned`, memo.`revision`, media.`url`, media.`mime_type`,
UNIX_TIMESTAMP(memo.`created_at`) AS `created_at_unix`,
UNIX_TIMESTAMP(memo.`updated_at`) AS `updated_at_unix`
FROM `sky_phone_voice_memos` memo
INNER JOIN `sky_phone_media` media ON media.`id` = memo.`media_id`
WHERE %s AND media.`media_type` = 'audio'
ORDER BY memo.`pinned` DESC, memo.`updated_at` DESC, memo.`id` DESC
LIMIT ?
]]):format(condition), params)
local memos = {}
for index, row in ipairs(rows) do
memos[index] = memo_dto(row)
end
return memos
end
local function find_memo(memos, id)
for _, memo in ipairs(memos) do
if memo.id == id then
return memo
end
end
end
function SkyPhoneMemos.List(account_id, imei)
return list_memos({ account_id = account_id and tonumber(account_id) or nil, imei = imei })
end
local function normalize_waveform(value)
if type(value) ~= "table" or #value < 8 or #value > Config.Memos.MaximumWaveformSamples then
return nil
end
local waveform = {}
for index = 1, #value do
local sample = tonumber(value[index])
if not sample or sample ~= sample or sample == math.huge or sample == -math.huge
or sample < 0 or sample > 1
then
return nil
end
waveform[index] = math.floor(sample * 1000 + 0.5) / 1000
end
return waveform
end
local function validate_upload(data)
if type(data) ~= "table" or type(data.correlationId) ~= "string"
or #data.correlationId < 1 or #data.correlationId > 80
then
return nil
end
local title = trim(data.title)
local note = data.note == nil and "" or trim(data.note)
local title_length = title and utf8.len(title) or nil
local note_length = note and utf8.len(note) or nil
local duration_ms = tonumber(data.durationMs)
local normalized_mime = allowed_audio_mimes[data.mimeType]
local waveform = normalize_waveform(data.waveform)
if not title_length or title_length < 1 or title_length > Config.Memos.TitleMaxLength
or not note_length or note_length > Config.Memos.NoteMaxLength
or not duration_ms or duration_ms ~= duration_ms
or duration_ms == math.huge or duration_ms == -math.huge
or duration_ms < 300 or duration_ms > Config.Memos.MaximumDurationMs
or not normalized_mime or not waveform or type(data.pinned) ~= "boolean"
then
return nil
end
duration_ms = math.floor(duration_ms)
return {
correlation_id = data.correlationId,
duration_ms = duration_ms,
mime_type = normalized_mime,
note = note,
pinned = data.pinned,
title = title,
waveform = waveform,
}
end
local function upload_result(source, correlation_id, success, error_code, memo)
TriggerClientEvent("sky_phone:memos:upload-result", source, {
correlationId = correlation_id,
success = success,
error = error_code,
memo = memo,
})
end
local function discard_verified_upload(remote_id)
local deleted, delete_error = SkyPhoneMedia.DeleteRemoteFile(remote_id)
if not deleted then
Bridge.Debug(
"warn",
"[sky_phone] Could not remove rejected voice memo upload %s: %s.",
tostring(remote_id),
tostring(delete_error)
)
end
end
local function expire_upload(request_id)
local state = pending_uploads[request_id]
if not state or state.completing then
return
end
pending_uploads[request_id] = nil
upload_result(state.source, state.memo.correlation_id, false, "upload_timeout")
end
Bridge.Callbacks.Register("sky_phone:memos:list", function(source)
if not SkyPhone.AllowOperation(source, "memos_list", Config.Memos.ListsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local owner, error_response = session_owner(source)
if not owner then
return error_response
end
return { success = true, data = list_memos(owner) }
end)
Bridge.Callbacks.Register("sky_phone:memos:update", function(source, data)
if not SkyPhone.AllowOperation(source, "memos_write", Config.Memos.WritesPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local owner, error_response = session_owner(source)
if not owner then
return error_response
end
local title = type(data) == "table" and trim(data.title) or nil
local note = type(data) == "table" and (data.note == nil and "" or trim(data.note)) or nil
local title_length = title and utf8.len(title) or nil
local note_length = note and utf8.len(note) or nil
local revision = type(data) == "table" and tonumber(data.revision) or nil
if type(data) ~= "table" or not valid_uuid(data.id)
or not title_length or title_length < 1 or title_length > Config.Memos.TitleMaxLength
or not note_length or note_length > Config.Memos.NoteMaxLength
or type(data.pinned) ~= "boolean" or not revision or revision ~= revision
or revision == math.huge or revision == -math.huge or revision ~= math.floor(revision)
or revision < 1 or revision > 4294967295
then
return { success = false, error = "invalid_memo" }
end
local condition, owner_params = owner_condition(owner, "media")
local params = { title, note, data.pinned and 1 or 0, data.id, revision }
append_params(params, owner_params)
local result = Bridge.Database.Query(([[
UPDATE `sky_phone_voice_memos` memo
INNER JOIN `sky_phone_media` media ON media.`id` = memo.`media_id`
SET memo.`title` = ?, memo.`note` = ?, memo.`pinned` = ?, memo.`revision` = memo.`revision` + 1
WHERE memo.`id` = ? AND memo.`revision` = ? AND %s AND media.`media_type` = 'audio'
]]):format(condition), params)
if affected_rows(result) ~= 1 then
return { success = false, error = "conflict", data = list_memos(owner) }
end
return { success = true, data = find_memo(list_memos(owner), data.id) }
end)
Bridge.Callbacks.Register("sky_phone:memos:delete", function(source, data)
if not SkyPhone.AllowOperation(source, "memos_write", Config.Memos.WritesPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local owner, error_response = session_owner(source)
if not owner then
return error_response
end
if type(data) ~= "table" or not valid_uuid(data.id) then
return { success = false, error = "invalid_memo" }
end
local condition, owner_params = owner_condition(owner, "media")
local params = { data.id }
append_params(params, owner_params)
local rows = Bridge.Database.Query(([[
SELECT media.`id` AS `media_id`, media.`remote_id`
FROM `sky_phone_voice_memos` memo
INNER JOIN `sky_phone_media` media ON media.`id` = memo.`media_id`
WHERE memo.`id` = ? AND %s AND media.`media_type` = 'audio'
LIMIT 1
]]):format(condition), params)
local memo = rows[1]
if not memo then
return { success = false, error = "memo_not_found" }
end
if pending_deletes[memo.remote_id] then
return { success = false, error = "operation_in_progress" }
end
pending_deletes[memo.remote_id] = source
local references = Bridge.Database.Query(
"SELECT COUNT(*) AS `count` FROM `sky_phone_media` WHERE `remote_id` = ?",
{ memo.remote_id }
)
if (tonumber(references[1] and references[1].count) or 0) <= 1 then
local deleted, delete_error = SkyPhoneMedia.DeleteRemoteFile(memo.remote_id)
if not deleted then
pending_deletes[memo.remote_id] = nil
return { success = false, error = delete_error }
end
end
local result = Bridge.Database.Query(([[
DELETE media FROM `sky_phone_media` media
INNER JOIN `sky_phone_voice_memos` memo ON memo.`media_id` = media.`id`
WHERE memo.`id` = ? AND %s AND media.`media_type` = 'audio'
]]):format(condition), params)
pending_deletes[memo.remote_id] = nil
if affected_rows(result) ~= 1 then
error(("[sky_phone] Voice memo %s disappeared during deletion."):format(data.id))
end
return { success = true, data = { id = data.id } }
end)
RegisterNetEvent("sky_phone:memos:request-upload", function(data)
local src = source
if not SkyPhone.AllowOperation(src, "memos_upload", Config.Memos.UploadsPerMinute, 60) then
upload_result(src, type(data) == "table" and data.correlationId or nil, false, "rate_limited")
return
end
local memo = validate_upload(data)
if not memo then
upload_result(src, type(data) == "table" and data.correlationId or nil, false, "invalid_memo")
return
end
local owner, error_response = session_owner(src)
if not owner then
upload_result(src, memo.correlation_id, false, error_response.error)
return
end
local condition, params = owner_condition(owner, "media")
local counts = Bridge.Database.Query(([[
SELECT COUNT(*) AS `count`
FROM `sky_phone_voice_memos` memo
INNER JOIN `sky_phone_media` media ON media.`id` = memo.`media_id`
WHERE %s AND media.`media_type` = 'audio'
]]):format(condition), params)
if (tonumber(counts[1] and counts[1].count) or 0) >= Config.Memos.MaximumCount then
upload_result(src, memo.correlation_id, false, "memo_limit")
return
end
local pending_count = 0
local pending_owner_key = owner_key(owner)
for _, state in pairs(pending_uploads) do
if state.owner_key == pending_owner_key then
pending_count = pending_count + 1
end
end
if pending_count >= Config.Memos.MaximumPendingUploads then
upload_result(src, memo.correlation_id, false, "operation_in_progress")
return
end
local presigned_url, presigned_error = SkyPhoneMedia.RequestPresignedUrl()
if not presigned_url then
Bridge.Debug(
"error",
"[sky_phone] Voice memo upload could not start for source %s: %s.",
tostring(src),
tostring(presigned_error),
{ always = true }
)
upload_result(src, memo.correlation_id, false, presigned_error)
return
end
local ids = Bridge.Database.Query("SELECT UUID() AS `request_id`, UUID() AS `capture_token`", {})
local request_id = ids[1] and ids[1].request_id
local capture_token = ids[1] and ids[1].capture_token
if type(request_id) ~= "string" or type(capture_token) ~= "string" then
error("[sky_phone] Database did not generate a voice memo upload token.")
end
pending_uploads[request_id] = {
capture_token = capture_token,
media_type = "audio",
mime_type = memo.mime_type,
memo = memo,
owner = owner,
owner_key = pending_owner_key,
purpose = "memo",
source = src,
}
SetTimeout(Config.Memos.UploadSessionTimeoutMs, function()
expire_upload(request_id)
end)
TriggerClientEvent("sky_phone:memos:upload-ready", src, {
requestId = request_id,
correlationId = memo.correlation_id,
captureToken = capture_token,
presignedUrl = presigned_url,
uploadTimeoutMs = Config.Media.FiveManage.UploadTimeoutMs,
})
end)
RegisterNetEvent("sky_phone:memos:complete-upload", function(data)
local src = source
local request_id = type(data) == "table" and data.requestId or nil
local state = type(request_id) == "string" and pending_uploads[request_id] or nil
if not state or state.source ~= src or state.completing then
return
end
state.completing = true
local owner, error_response = session_owner(src)
if not owner or owner.imei ~= state.owner.imei or owner.account_id ~= state.owner.account_id then
pending_uploads[request_id] = nil
local rejected, _, trusted_remote = SkyPhoneMedia.VerifyRemoteUpload(state, data.remoteId, data.url)
if rejected or trusted_remote then
discard_verified_upload(data.remoteId)
end
upload_result(src, state.memo.correlation_id, false, error_response and error_response.error or "owner_changed")
return
end
local verified, verify_error, trusted_remote = SkyPhoneMedia.VerifyRemoteUpload(state, data.remoteId, data.url)
if not verified then
pending_uploads[request_id] = nil
if trusted_remote then
discard_verified_upload(data.remoteId)
end
upload_result(src, state.memo.correlation_id, false, verify_error)
return
end
local size_bytes = tonumber(verified.size)
if not size_bytes or size_bytes ~= size_bytes
or size_bytes == math.huge or size_bytes == -math.huge
or size_bytes < 1 or size_bytes > Config.Memos.MaximumBytes
then
pending_uploads[request_id] = nil
discard_verified_upload(verified.remote_id)
upload_result(src, state.memo.correlation_id, false, "recording_too_large")
return
end
size_bytes = math.floor(size_bytes)
local condition, count_params = owner_condition(owner, "media")
local counts = Bridge.Database.Query(([[
SELECT COUNT(*) AS `count`
FROM `sky_phone_voice_memos` memo
INNER JOIN `sky_phone_media` media ON media.`id` = memo.`media_id`
WHERE %s AND media.`media_type` = 'audio'
]]):format(condition), count_params)
if (tonumber(counts[1] and counts[1].count) or 0) >= Config.Memos.MaximumCount then
pending_uploads[request_id] = nil
discard_verified_upload(verified.remote_id)
upload_result(src, state.memo.correlation_id, false, "memo_limit")
return
end
local ids = Bridge.Database.Query("SELECT UUID() AS `id`", {})
local memo_id = ids[1] and ids[1].id
if type(memo_id) ~= "string" then
error("[sky_phone] Database did not generate a voice memo id.")
end
local media_query
local media_params
if owner.account_id then
media_query = [[
INSERT INTO `sky_phone_media`
(`account_id`, `device_imei`, `url`, `remote_id`, `media_type`, `mime_type`, `verified_at`)
VALUES (?, NULL, ?, ?, 'audio', ?, CURRENT_TIMESTAMP)
]]
media_params = { owner.account_id, verified.url, verified.remote_id, verified.mime_type }
else
media_query = [[
INSERT INTO `sky_phone_media`
(`account_id`, `device_imei`, `url`, `remote_id`, `media_type`, `mime_type`, `verified_at`)
VALUES (NULL, ?, ?, ?, 'audio', ?, CURRENT_TIMESTAMP)
]]
media_params = { owner.imei, verified.url, verified.remote_id, verified.mime_type }
end
local transaction = {
{
query = media_query,
params = media_params,
},
{
query = [[
INSERT INTO `sky_phone_voice_memos`
(`id`, `media_id`, `title`, `note`, `duration_ms`, `size_bytes`, `waveform`, `pinned`)
SELECT ?, LAST_INSERT_ID(), ?, ?, ?, ?, ?, ?
]],
params = {
memo_id,
state.memo.title,
state.memo.note,
state.memo.duration_ms,
size_bytes,
json.encode(state.memo.waveform),
state.memo.pinned and 1 or 0,
},
},
}
if not Bridge.Database.Transaction(transaction) then
pending_uploads[request_id] = nil
discard_verified_upload(verified.remote_id)
upload_result(src, state.memo.correlation_id, false, "request_failed")
return
end
pending_uploads[request_id] = nil
local memos = list_memos(owner)
local created = find_memo(memos, memo_id)
if not created then
error(("[sky_phone] Created voice memo %s could not be hydrated."):format(memo_id))
end
upload_result(src, state.memo.correlation_id, true, nil, created)
end)
RegisterNetEvent("sky_phone:memos:cancel-upload", function(data)
local src = source
local request_id = type(data) == "table" and data.requestId or nil
local state = type(request_id) == "string" and pending_uploads[request_id] or nil
if state and state.source == src and not state.completing then
pending_uploads[request_id] = nil
upload_result(src, state.memo.correlation_id, false, "cancelled")
end
end)
RegisterNetEvent("sky_phone:memos:fail-upload", function(data)
local src = source
local request_id = type(data) == "table" and data.requestId or nil
local state = type(request_id) == "string" and pending_uploads[request_id] or nil
if not state or state.source ~= src or state.completing then
return
end
local allowed_errors = {
capture_failed = true,
unsupported = true,
upload_failed = true,
upload_timeout = true,
}
pending_uploads[request_id] = nil
upload_result(src, state.memo.correlation_id, false, allowed_errors[data.error] and data.error or "upload_failed")
end)
AddEventHandler("playerDropped", function()
local src = source
for request_id, state in pairs(pending_uploads) do
if state.source == src then
pending_uploads[request_id] = nil
end
end
end)
end)
+1
View File
@@ -517,6 +517,7 @@ local function bootstrap(source, security, security_loaded)
devices = account_devices(device.account_id, device.imei),
} or nil,
notes = SkyPhoneNotes.List(device.account_id, device.imei),
memos = SkyPhoneMemos.List(device.account_id, device.imei),
}
end
+1
View File
@@ -60,6 +60,7 @@ local RESERVED_APP_IDS = {
music = true,
["neon-drop"] = true,
notes = true,
memos = true,
["number-merge"] = true,
phone = true,
photos = true,
+19 -1
View File
@@ -152,7 +152,7 @@ CREATE TABLE IF NOT EXISTS `sky_phone_media` (
`device_imei` CHAR(15) CHARACTER SET ascii COLLATE ascii_bin NULL,
`url` TEXT NOT NULL,
`remote_id` VARCHAR(128) NOT NULL,
`media_type` ENUM('photo', 'video') NOT NULL,
`media_type` ENUM('photo', 'video', 'audio') NOT NULL,
`mime_type` VARCHAR(120) NULL,
`origin` ENUM('phone_upload', 'website_import') NOT NULL DEFAULT 'phone_upload',
`source_id` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL,
@@ -167,6 +167,24 @@ CREATE TABLE IF NOT EXISTS `sky_phone_media` (
FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_voice_memos` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`media_id` BIGINT UNSIGNED NOT NULL,
`title` VARCHAR(120) NOT NULL,
`note` VARCHAR(2000) NOT NULL DEFAULT '',
`duration_ms` INT UNSIGNED NOT NULL,
`size_bytes` INT UNSIGNED NOT NULL,
`waveform` TEXT NOT NULL,
`pinned` TINYINT(1) NOT NULL DEFAULT 0,
`revision` INT UNSIGNED NOT NULL DEFAULT 1,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_sky_phone_voice_memos_media` (`media_id`),
KEY `idx_sky_phone_voice_memos_list` (`pinned`, `updated_at`, `id`),
FOREIGN KEY (`media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_contacts` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`contact_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,