mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 11:01:34 +00:00
533 lines
20 KiB
Lua
533 lines
20 KiB
Lua
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)
|