mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 16:23:22 +00:00
MERGE - resolve dev integration conflicts
This commit is contained in:
@@ -208,6 +208,39 @@ local schema = {
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_device_security",
|
||||
columns = {
|
||||
{
|
||||
name = "device_imei",
|
||||
type = "CHAR(15) NOT NULL",
|
||||
characterSet = "ascii",
|
||||
collation = "ascii_bin",
|
||||
},
|
||||
{ name = "passcode_hash", type = "BINARY(32) NOT NULL" },
|
||||
{
|
||||
name = "passcode_salt",
|
||||
type = "CHAR(32) NOT NULL",
|
||||
characterSet = "ascii",
|
||||
collation = "ascii_bin",
|
||||
},
|
||||
{ name = "passcode_length", type = "TINYINT UNSIGNED NOT NULL" },
|
||||
{ name = "failed_attempts", type = "TINYINT UNSIGNED NOT NULL DEFAULT 0" },
|
||||
{ name = "locked_until", type = "BIGINT UNSIGNED NOT NULL DEFAULT 0" },
|
||||
{
|
||||
name = "updated_at",
|
||||
type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP",
|
||||
},
|
||||
},
|
||||
primaryKey = "device_imei",
|
||||
foreignKeys = {
|
||||
{
|
||||
column = "device_imei",
|
||||
references = "`sky_phone_devices` (`imei`) ON DELETE CASCADE",
|
||||
},
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_notes",
|
||||
columns = {
|
||||
@@ -650,6 +683,33 @@ local schema = {
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_map_markers",
|
||||
columns = {
|
||||
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{
|
||||
name = "device_imei",
|
||||
type = "CHAR(15) NOT NULL",
|
||||
characterSet = "ascii",
|
||||
collation = "ascii_bin",
|
||||
},
|
||||
{ name = "label", type = "VARCHAR(40) NOT NULL" },
|
||||
{ name = "color", type = "VARCHAR(16) NOT NULL" },
|
||||
{ name = "position_x", type = "DOUBLE NOT NULL" },
|
||||
{ name = "position_y", type = "DOUBLE NOT NULL" },
|
||||
{ name = "position_z", type = "DOUBLE NOT NULL DEFAULT 0" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_map_markers_device", columns = "(`device_imei`, `created_at`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "device_imei", references = "`sky_phone_devices` (`imei`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_calendar_events",
|
||||
columns = {
|
||||
@@ -830,6 +890,195 @@ local schema = {
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_fliptok_profiles",
|
||||
columns = {
|
||||
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
|
||||
{ name = "account_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "handle", type = "VARCHAR(24) NOT NULL", characterSet = "ascii", collation = "ascii_general_ci" },
|
||||
{ name = "display_name", type = "VARCHAR(40) NOT NULL" },
|
||||
{ name = "bio", type = "VARCHAR(160) NOT NULL DEFAULT ''" },
|
||||
{ name = "account_type", type = "ENUM('person', 'business', 'organization', 'media', 'event') NOT NULL DEFAULT 'person'" },
|
||||
{ name = "verified", type = "TINYINT(1) NOT NULL DEFAULT 0" },
|
||||
{ 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_fliptok_account", columns = "(`account_id`)" },
|
||||
{ name = "uniq_sky_phone_fliptok_handle", columns = "(`handle`)" },
|
||||
},
|
||||
foreignKeys = {{ column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" }},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_fliptok_credentials",
|
||||
columns = {
|
||||
{ name = "profile_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "password_hash", type = "BINARY(32) NOT NULL" },
|
||||
{ name = "password_salt", type = "CHAR(32) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ 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 = "profile_id",
|
||||
foreignKeys = {{ column = "profile_id", references = "`sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE" }},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_fliptok_sessions",
|
||||
columns = {
|
||||
{ name = "device_imei", type = "CHAR(15) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "profile_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ 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 = "device_imei",
|
||||
indexes = {{ name = "idx_sky_phone_fliptok_sessions_profile", columns = "(`profile_id`, `updated_at`)" }},
|
||||
foreignKeys = {
|
||||
{ column = "device_imei", references = "`sky_phone_devices` (`imei`) ON DELETE CASCADE" },
|
||||
{ column = "profile_id", references = "`sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_fliptok_videos",
|
||||
columns = {
|
||||
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "profile_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "media_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "caption", type = "VARCHAR(500) NOT NULL DEFAULT ''" },
|
||||
{ name = "location", type = "VARCHAR(80) NOT NULL DEFAULT ''" },
|
||||
{ name = "visibility", type = "ENUM('public', 'followers', 'private') NOT NULL DEFAULT 'public'" },
|
||||
{ name = "comments_enabled", type = "TINYINT(1) NOT NULL DEFAULT 1" },
|
||||
{ name = "trim_start_ms", type = "INT UNSIGNED NOT NULL DEFAULT 0" },
|
||||
{ name = "trim_end_ms", type = "INT UNSIGNED NULL" },
|
||||
{ name = "cover_time_ms", type = "INT UNSIGNED NOT NULL DEFAULT 0" },
|
||||
{ name = "original_volume", type = "TINYINT UNSIGNED NOT NULL DEFAULT 100" },
|
||||
{ name = "music_volume", type = "TINYINT UNSIGNED NOT NULL DEFAULT 0" },
|
||||
{ name = "music_track", type = "VARCHAR(64) NOT NULL DEFAULT ''", characterSet = "ascii", collation = "ascii_general_ci" },
|
||||
{ name = "status", type = "ENUM('draft', 'published', 'removed') NOT NULL DEFAULT 'published'" },
|
||||
{ name = "view_count", type = "INT UNSIGNED NOT NULL DEFAULT 0" },
|
||||
{ name = "share_count", type = "INT UNSIGNED NOT NULL DEFAULT 0" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_fliptok_feed", columns = "(`status`, `visibility`, `created_at`)" },
|
||||
{ name = "idx_sky_phone_fliptok_profile", columns = "(`profile_id`, `created_at`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "profile_id", references = "`sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "media_id", references = "`sky_phone_media` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_fliptok_reactions",
|
||||
columns = {
|
||||
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
|
||||
{ name = "video_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "profile_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "kind", type = "ENUM('like', 'save') NOT NULL" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
uniqueKeys = {{ name = "uniq_sky_phone_fliptok_reaction", columns = "(`video_id`, `profile_id`, `kind`)" }},
|
||||
foreignKeys = {
|
||||
{ column = "video_id", references = "`sky_phone_fliptok_videos` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "profile_id", references = "`sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_fliptok_follows",
|
||||
columns = {
|
||||
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
|
||||
{ name = "follower_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "following_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
uniqueKeys = {{ name = "uniq_sky_phone_fliptok_follow", columns = "(`follower_id`, `following_id`)" }},
|
||||
foreignKeys = {
|
||||
{ column = "follower_id", references = "`sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "following_id", references = "`sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_fliptok_comments",
|
||||
columns = {
|
||||
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "video_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "profile_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "body", type = "VARCHAR(300) NOT NULL" },
|
||||
{ name = "status", type = "ENUM('visible', 'removed') NOT NULL DEFAULT 'visible'" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
indexes = {{ name = "idx_sky_phone_fliptok_comments", columns = "(`video_id`, `created_at`)" }},
|
||||
foreignKeys = {
|
||||
{ column = "video_id", references = "`sky_phone_fliptok_videos` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "profile_id", references = "`sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_fliptok_notifications",
|
||||
columns = {
|
||||
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "recipient_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "actor_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "video_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "kind", type = "ENUM('like', 'comment', 'follow', 'verified') NOT NULL" },
|
||||
{ name = "read_at", type = "DATETIME NULL" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
indexes = {{ name = "idx_sky_phone_fliptok_activity", columns = "(`recipient_id`, `created_at`)" }},
|
||||
foreignKeys = {
|
||||
{ column = "recipient_id", references = "`sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "actor_id", references = "`sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "video_id", references = "`sky_phone_fliptok_videos` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_fliptok_reports",
|
||||
columns = {
|
||||
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "reporter_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "video_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "reason", type = "ENUM('spam', 'harassment', 'dangerous', 'illegal', 'other') NOT NULL" },
|
||||
{ name = "details", type = "VARCHAR(500) NOT NULL DEFAULT ''" },
|
||||
{ name = "status", type = "ENUM('open', 'reviewed', 'dismissed') NOT NULL DEFAULT 'open'" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
uniqueKeys = {{ name = "uniq_sky_phone_fliptok_report", columns = "(`reporter_id`, `video_id`)" }},
|
||||
foreignKeys = {
|
||||
{ column = "reporter_id", references = "`sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "video_id", references = "`sky_phone_fliptok_videos` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_fliptok_blocks",
|
||||
columns = {
|
||||
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
|
||||
{ name = "blocker_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "blocked_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
uniqueKeys = {{ name = "uniq_sky_phone_fliptok_block", columns = "(`blocker_id`, `blocked_id`)" }},
|
||||
foreignKeys = {
|
||||
{ column = "blocker_id", references = "`sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "blocked_id", references = "`sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_flare_profiles",
|
||||
columns = {
|
||||
|
||||
@@ -0,0 +1,648 @@
|
||||
Bridge.Database.AfterMigration("sky_phone", function()
|
||||
local account_types = { person = true, business = true, organization = true, media = true, event = true }
|
||||
local visibilities = { public = true, followers = true, private = true }
|
||||
local report_reasons = { spam = true, harassment = true, dangerous = true, illegal = true, other = true }
|
||||
local report_actions = { dismiss = true, remove = true }
|
||||
local music_tracks = {}
|
||||
local music_track_list = {}
|
||||
local password_pepper = GetConvar(Config.FlipTok.PasswordPepperConvar, "")
|
||||
|
||||
if password_pepper == "" then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] FlipTok password pepper convar '%s' is empty; configure it before production use.",
|
||||
Config.FlipTok.PasswordPepperConvar,
|
||||
{ always = true }
|
||||
)
|
||||
end
|
||||
|
||||
for _, track in ipairs(Config.FlipTok.MusicTracks) do
|
||||
local id = tostring(track.Id or track.id or "")
|
||||
local title = tostring(track.Title or track.title or "")
|
||||
local artist = tostring(track.Artist or track.artist or "")
|
||||
local url = tostring(track.Url or track.url or "")
|
||||
if id == "" or title == "" or artist == "" or url == "" then
|
||||
error("[sky_phone] Every configured FlipTok music track requires Id, Title, Artist, and Url.")
|
||||
end
|
||||
local item = { id = id, title = title, artist = artist, url = url }
|
||||
music_tracks[id] = item
|
||||
music_track_list[#music_track_list + 1] = item
|
||||
end
|
||||
|
||||
local function trim(value)
|
||||
if type(value) ~= "string" then return nil end
|
||||
return value:match("^%s*(.-)%s*$")
|
||||
end
|
||||
|
||||
local function valid_text(value, minimum, maximum)
|
||||
local length = type(value) == "string" and utf8.len(value) or nil
|
||||
return length and length >= minimum and length <= maximum
|
||||
end
|
||||
|
||||
local function affected_rows(result)
|
||||
if type(result) == "number" then return result end
|
||||
if type(result) == "table" then return tonumber(result.affectedRows) or tonumber(result.affected_rows) or 0 end
|
||||
return 0
|
||||
end
|
||||
|
||||
local function are_profiles_blocked(first_id, second_id)
|
||||
return Bridge.Database.Query([[SELECT `id` FROM `sky_phone_fliptok_blocks` WHERE
|
||||
(`blocker_id` = ? AND `blocked_id` = ?) OR (`blocker_id` = ? AND `blocked_id` = ?) LIMIT 1]], {
|
||||
first_id, second_id, second_id, first_id,
|
||||
})[1] ~= nil
|
||||
end
|
||||
|
||||
local function new_id()
|
||||
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 a FlipTok id.")
|
||||
end
|
||||
return rows[1].id
|
||||
end
|
||||
|
||||
local function normalize_handle(value)
|
||||
local handle = trim(value)
|
||||
if not handle then return nil end
|
||||
handle = handle:lower():gsub("^@", "")
|
||||
if #handle < 3 or #handle > 24 or not handle:match("^[a-z0-9][a-z0-9._]*[a-z0-9]$") or handle:find("..", 1, true) then
|
||||
return nil
|
||||
end
|
||||
return handle
|
||||
end
|
||||
|
||||
local function valid_password(value)
|
||||
local length = type(value) == "string" and utf8.len(value) or nil
|
||||
return length and length >= Config.FlipTok.PasswordMinLength and length <= Config.FlipTok.PasswordMaxLength
|
||||
end
|
||||
|
||||
local function profile_for_session(source)
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
if not session then return nil, error_response end
|
||||
local rows = Bridge.Database.Query([[SELECT p.* FROM `sky_phone_fliptok_sessions` s
|
||||
JOIN `sky_phone_fliptok_profiles` p ON p.`id` = s.`profile_id`
|
||||
WHERE s.`device_imei` = ? LIMIT 1]], { session.imei })
|
||||
if not rows[1] then return nil, { success = false, error = "fliptok_not_authenticated" } end
|
||||
return rows[1], nil
|
||||
end
|
||||
|
||||
local function require_profile(source)
|
||||
return profile_for_session(source)
|
||||
end
|
||||
|
||||
local function hydrate_profile(profile, viewer_id)
|
||||
profile.id = tonumber(profile.id)
|
||||
profile.verified = tonumber(profile.verified) == 1
|
||||
profile.is_following = viewer_id and tonumber(profile.is_following) == 1 or false
|
||||
profile.is_owner = viewer_id and profile.id == viewer_id or false
|
||||
profile.followers = tonumber(profile.followers) or 0
|
||||
profile.following = tonumber(profile.following) or 0
|
||||
profile.video_count = tonumber(profile.video_count) or 0
|
||||
return profile
|
||||
end
|
||||
|
||||
local function load_profile(profile_id, viewer_id)
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT p.*,
|
||||
EXISTS(SELECT 1 FROM `sky_phone_fliptok_follows` f WHERE f.`follower_id` = ? AND f.`following_id` = p.`id`) AS `is_following`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_fliptok_follows` f WHERE f.`following_id` = p.`id`) AS `followers`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_fliptok_follows` f WHERE f.`follower_id` = p.`id`) AS `following`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_fliptok_videos` v WHERE v.`profile_id` = p.`id` AND v.`status` = 'published') AS `video_count`
|
||||
FROM `sky_phone_fliptok_profiles` p WHERE p.`id` = ? LIMIT 1
|
||||
]], { viewer_id, profile_id })
|
||||
return rows[1] and hydrate_profile(rows[1], viewer_id) or nil
|
||||
end
|
||||
|
||||
local function notify_profile(recipient_id, actor_id, kind, video_id)
|
||||
local rows = Bridge.Database.Query([[SELECT recipient.`account_id`, actor.`display_name` AS `actor_name`
|
||||
FROM `sky_phone_fliptok_profiles` recipient
|
||||
JOIN `sky_phone_fliptok_profiles` actor ON actor.`id` = ?
|
||||
WHERE recipient.`id` = ? LIMIT 1]], { actor_id, recipient_id })
|
||||
SkyPhone.NotifyAccountDevices(tonumber(rows[1].account_id), "sky_phone:fliptok:new", {
|
||||
actor = rows[1].actor_name,
|
||||
kind = kind,
|
||||
videoId = video_id,
|
||||
})
|
||||
end
|
||||
|
||||
local function hydrate_videos(rows)
|
||||
for _, video in ipairs(rows) do
|
||||
video.profile_id = tonumber(video.profile_id)
|
||||
video.verified = tonumber(video.verified) == 1
|
||||
video.comments_enabled = tonumber(video.comments_enabled) == 1
|
||||
video.is_liked = tonumber(video.is_liked) == 1
|
||||
video.is_saved = tonumber(video.is_saved) == 1
|
||||
video.is_following = tonumber(video.is_following) == 1
|
||||
video.is_owner = tonumber(video.is_owner) == 1
|
||||
video.like_count = tonumber(video.like_count) or 0
|
||||
video.comment_count = tonumber(video.comment_count) or 0
|
||||
video.view_count = tonumber(video.view_count) or 0
|
||||
video.share_count = tonumber(video.share_count) or 0
|
||||
video.trim_start_ms = tonumber(video.trim_start_ms) or 0
|
||||
video.trim_end_ms = tonumber(video.trim_end_ms)
|
||||
video.cover_time_ms = tonumber(video.cover_time_ms) or 0
|
||||
video.original_volume = tonumber(video.original_volume) or 100
|
||||
video.music_volume = tonumber(video.music_volume) or 0
|
||||
local track = music_tracks[video.music_track]
|
||||
video.music_title = track and track.title or ""
|
||||
video.music_artist = track and track.artist or ""
|
||||
video.music_url = track and track.url or ""
|
||||
video.created_at = (tonumber(video.created_at_unix) or 0) * 1000
|
||||
video.created_at_unix = nil
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
local function list_videos(viewer_id, where_clause, values, limit, offset, ranking)
|
||||
local parameters = { viewer_id, viewer_id, viewer_id, viewer_id }
|
||||
for _, value in ipairs(values) do parameters[#parameters + 1] = value end
|
||||
parameters[#parameters + 1] = limit
|
||||
parameters[#parameters + 1] = offset
|
||||
return hydrate_videos(Bridge.Database.Query(([[
|
||||
SELECT v.`id`, v.`profile_id`, v.`caption`, v.`location`, v.`comments_enabled`, v.`view_count`, v.`share_count`,
|
||||
v.`trim_start_ms`, v.`trim_end_ms`, v.`cover_time_ms`, v.`original_volume`, v.`music_volume`, v.`music_track`,
|
||||
m.`url`, UNIX_TIMESTAMP(v.`created_at`) AS `created_at_unix`, p.`handle`, p.`display_name`, p.`verified`,
|
||||
(v.`profile_id` = ?) AS `is_owner`,
|
||||
EXISTS(SELECT 1 FROM `sky_phone_fliptok_reactions` r WHERE r.`video_id` = v.`id` AND r.`profile_id` = ? AND r.`kind` = 'like') AS `is_liked`,
|
||||
EXISTS(SELECT 1 FROM `sky_phone_fliptok_reactions` r WHERE r.`video_id` = v.`id` AND r.`profile_id` = ? AND r.`kind` = 'save') AS `is_saved`,
|
||||
EXISTS(SELECT 1 FROM `sky_phone_fliptok_follows` f WHERE f.`follower_id` = ? AND f.`following_id` = v.`profile_id`) AS `is_following`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_fliptok_reactions` r WHERE r.`video_id` = v.`id` AND r.`kind` = 'like') AS `like_count`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_fliptok_comments` c WHERE c.`video_id` = v.`id` AND c.`status` = 'visible') AS `comment_count`
|
||||
FROM `sky_phone_fliptok_videos` v
|
||||
JOIN `sky_phone_fliptok_profiles` p ON p.`id` = v.`profile_id`
|
||||
JOIN `sky_phone_media` m ON m.`id` = v.`media_id`
|
||||
WHERE v.`status` = 'published' AND %s
|
||||
AND NOT EXISTS(SELECT 1 FROM `sky_phone_fliptok_blocks` b WHERE
|
||||
(b.`blocker_id` = ? AND b.`blocked_id` = v.`profile_id`) OR (b.`blocked_id` = ? AND b.`blocker_id` = v.`profile_id`))
|
||||
ORDER BY %s LIMIT ? OFFSET ?
|
||||
]]):format(where_clause, ranking), parameters))
|
||||
end
|
||||
|
||||
local function feed(source, data)
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
data = type(data) == "table" and data or {}
|
||||
local offset = math.max(0, math.floor(tonumber(data.offset) or 0))
|
||||
local limit = Config.FlipTok.PageSize
|
||||
local where = "v.`visibility` = 'public'"
|
||||
local ranking = "(v.`view_count` + v.`share_count` * 8 + (SELECT COUNT(*) FROM `sky_phone_fliptok_reactions` rr WHERE rr.`video_id` = v.`id`) * 4) DESC, v.`created_at` DESC"
|
||||
if data.mode == "following" then
|
||||
where = "v.`visibility` IN ('public', 'followers') AND EXISTS(SELECT 1 FROM `sky_phone_fliptok_follows` ff WHERE ff.`follower_id` = ? AND ff.`following_id` = v.`profile_id`)"
|
||||
ranking = "v.`created_at` DESC"
|
||||
end
|
||||
local values = data.mode == "following" and { profile.id, profile.id, profile.id } or { profile.id, profile.id }
|
||||
local rows = list_videos(profile.id, where, values, limit + 1, offset, ranking)
|
||||
local has_more = #rows > limit
|
||||
if has_more then rows[#rows] = nil end
|
||||
return { success = true, data = { items = rows, offset = offset, hasMore = has_more } }
|
||||
end
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:register", function(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "fliptok:register", 5, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
local account, error_response = SkyPhone.RequireAccount(source)
|
||||
if not account then return error_response end
|
||||
if type(data) ~= "table" then return { success = false, error = "invalid_request" } end
|
||||
|
||||
local handle = normalize_handle(data.handle)
|
||||
local display_name = trim(data.displayName)
|
||||
if not handle then return { success = false, error = "invalid_handle" } end
|
||||
if not valid_text(display_name, 1, 40) then return { success = false, error = "invalid_display_name" } end
|
||||
if not valid_password(data.password) then return { success = false, error = "invalid_password" } end
|
||||
|
||||
local profiles = Bridge.Database.Query("SELECT `id` FROM `sky_phone_fliptok_profiles` WHERE `account_id` = ? LIMIT 1", { account.id })
|
||||
local profile_id = profiles[1] and tonumber(profiles[1].id) or nil
|
||||
local duplicates = profile_id and Bridge.Database.Query(
|
||||
"SELECT `id` FROM `sky_phone_fliptok_profiles` WHERE `handle` = ? AND `id` <> ? LIMIT 1",
|
||||
{ handle, profile_id }
|
||||
) or Bridge.Database.Query(
|
||||
"SELECT `id` FROM `sky_phone_fliptok_profiles` WHERE `handle` = ? LIMIT 1",
|
||||
{ handle }
|
||||
)
|
||||
if duplicates[1] then return { success = false, error = "handle_taken" } end
|
||||
|
||||
if profile_id then
|
||||
local credentials = Bridge.Database.Query("SELECT `profile_id` FROM `sky_phone_fliptok_credentials` WHERE `profile_id` = ? LIMIT 1", { profile_id })
|
||||
if credentials[1] then return { success = false, error = "already_registered" } end
|
||||
Bridge.Database.Query("UPDATE `sky_phone_fliptok_profiles` SET `handle` = ?, `display_name` = ? WHERE `id` = ?", {
|
||||
handle, display_name, profile_id,
|
||||
})
|
||||
else
|
||||
local result = Bridge.Database.Query([[INSERT IGNORE INTO `sky_phone_fliptok_profiles`
|
||||
(`account_id`, `handle`, `display_name`) VALUES (?, ?, ?)]], { account.id, handle, display_name })
|
||||
if affected_rows(result) ~= 1 then return { success = false, error = "handle_taken" } end
|
||||
local created = Bridge.Database.Query("SELECT `id` FROM `sky_phone_fliptok_profiles` WHERE `account_id` = ? LIMIT 1", { account.id })
|
||||
if not created[1] then error("[sky_phone] FlipTok profile insert did not return the created profile.") end
|
||||
profile_id = tonumber(created[1].id)
|
||||
end
|
||||
|
||||
local salts = Bridge.Database.Query("SELECT REPLACE(UUID(), '-', '') AS `salt`", {})
|
||||
local salt = salts[1] and salts[1].salt
|
||||
if type(salt) ~= "string" or #salt ~= 32 then error("[sky_phone] Database did not generate a FlipTok password salt.") end
|
||||
local credential_result = Bridge.Database.Query([[INSERT IGNORE INTO `sky_phone_fliptok_credentials`
|
||||
(`profile_id`, `password_hash`, `password_salt`) VALUES (?, UNHEX(SHA2(CONCAT(?, ?, ?), 256)), ?)]], {
|
||||
profile_id, password_pepper, salt, data.password, salt,
|
||||
})
|
||||
if affected_rows(credential_result) ~= 1 then return { success = false, error = "already_registered" } end
|
||||
Bridge.Database.Query([[INSERT INTO `sky_phone_fliptok_sessions` (`device_imei`, `profile_id`) VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE `profile_id` = VALUES(`profile_id`), `updated_at` = CURRENT_TIMESTAMP]], {
|
||||
account.imei, profile_id,
|
||||
})
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:login", function(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "fliptok:login", 10, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
if not session then return error_response end
|
||||
if type(data) ~= "table" then return { success = false, error = "invalid_credentials" } end
|
||||
local handle = normalize_handle(data.handle)
|
||||
if not handle or not valid_password(data.password) then
|
||||
return { success = false, error = "invalid_credentials" }
|
||||
end
|
||||
local profiles = Bridge.Database.Query([[SELECT p.`id` FROM `sky_phone_fliptok_profiles` p
|
||||
JOIN `sky_phone_fliptok_credentials` c ON c.`profile_id` = p.`id`
|
||||
WHERE p.`handle` = ?
|
||||
AND c.`password_hash` = UNHEX(SHA2(CONCAT(?, c.`password_salt`, ?), 256))
|
||||
LIMIT 1]], { handle, password_pepper, data.password })
|
||||
if not profiles[1] then return { success = false, error = "invalid_credentials" } end
|
||||
Bridge.Database.Query([[INSERT INTO `sky_phone_fliptok_sessions` (`device_imei`, `profile_id`) VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE `profile_id` = VALUES(`profile_id`), `updated_at` = CURRENT_TIMESTAMP]], {
|
||||
session.imei, profiles[1].id,
|
||||
})
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:logout", function(source)
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
if not session then return error_response end
|
||||
Bridge.Database.Query("DELETE FROM `sky_phone_fliptok_sessions` WHERE `device_imei` = ?", { session.imei })
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:bootstrap", function(source)
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then
|
||||
if error_response.error == "fliptok_not_authenticated" then
|
||||
return { success = true, data = { authenticated = false, musicTracks = music_track_list } }
|
||||
end
|
||||
return error_response
|
||||
end
|
||||
local result = feed(source, { mode = "for-you", offset = 0 })
|
||||
if not result.success then return result end
|
||||
return { success = true, data = {
|
||||
authenticated = true,
|
||||
profile = load_profile(profile.id, profile.id),
|
||||
feed = result.data,
|
||||
isAdmin = Bridge.Framework.HasAdminGroup(source, Config.FlipTok.ReportAdminGroups),
|
||||
musicTracks = music_track_list,
|
||||
} }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:feed", feed)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:discover", function(source, data)
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
local search = type(data) == "table" and trim(data.search) or ""
|
||||
if search and utf8.len(search) > 50 then return { success = false, error = "invalid_request" } end
|
||||
local pattern = "%" .. (search or "") .. "%"
|
||||
local rows = list_videos(profile.id, "v.`visibility` = 'public' AND (p.`handle` LIKE ? OR p.`display_name` LIKE ? OR v.`caption` LIKE ?)", { pattern, pattern, pattern, profile.id, profile.id }, Config.FlipTok.PageSize, 0, "v.`created_at` DESC")
|
||||
return { success = true, data = rows }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:publish", function(source, data)
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
if not SkyPhone.AllowOperation(source, "fliptok:publish", 6, 60) then return { success = false, error = "rate_limited" } end
|
||||
if type(data) ~= "table" then return { success = false, error = "invalid_video" } end
|
||||
local media_id = tonumber(data.mediaId)
|
||||
local caption = trim(data.caption) or ""
|
||||
local location = trim(data.location) or ""
|
||||
local visibility = data.visibility or "public"
|
||||
local trim_start_ms = math.floor(tonumber(data.trimStartMs) or 0)
|
||||
local trim_end_ms = data.trimEndMs ~= nil and math.floor(tonumber(data.trimEndMs) or -1) or nil
|
||||
local cover_time_ms = math.floor(tonumber(data.coverTimeMs) or 0)
|
||||
local original_volume = math.floor(tonumber(data.originalVolume) or 100)
|
||||
local music_volume = math.floor(tonumber(data.musicVolume) or 0)
|
||||
local music_track = type(data.musicTrack) == "string" and data.musicTrack or ""
|
||||
if not media_id or media_id < 1 or media_id ~= math.floor(media_id)
|
||||
or not valid_text(caption, 0, Config.FlipTok.CaptionMaxLength)
|
||||
or not valid_text(location, 0, 80) or not visibilities[visibility]
|
||||
or type(data.commentsEnabled) ~= "boolean"
|
||||
or trim_start_ms < 0 or trim_start_ms > Config.FlipTok.MaxVideoDurationMs
|
||||
or (trim_end_ms and (trim_end_ms <= trim_start_ms or trim_end_ms > Config.FlipTok.MaxVideoDurationMs))
|
||||
or cover_time_ms < trim_start_ms or (trim_end_ms and cover_time_ms > trim_end_ms)
|
||||
or original_volume < 0 or original_volume > 100 or music_volume < 0 or music_volume > 100
|
||||
or (music_track ~= "" and not music_tracks[music_track])
|
||||
then return { success = false, error = "invalid_video" } end
|
||||
if music_track == "" then music_volume = 0 end
|
||||
if not SkyPhoneMedia.ResolveOwnedMedia(source, tostring(media_id), "video") then
|
||||
return { success = false, error = "invalid_media" }
|
||||
end
|
||||
local id = new_id()
|
||||
Bridge.Database.Query([[INSERT INTO `sky_phone_fliptok_videos`
|
||||
(`id`, `profile_id`, `media_id`, `caption`, `location`, `visibility`, `comments_enabled`, `trim_start_ms`, `trim_end_ms`,
|
||||
`cover_time_ms`, `original_volume`, `music_volume`, `music_track`, `status`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)]], {
|
||||
id, profile.id, media_id, caption, location, visibility, data.commentsEnabled and 1 or 0, trim_start_ms, trim_end_ms,
|
||||
cover_time_ms, original_volume, music_volume, music_track, data.draft == true and "draft" or "published",
|
||||
})
|
||||
return { success = true, data = { id = id } }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:react", function(source, data)
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
if not SkyPhone.AllowOperation(source, "fliptok:react", 60, 60) then return { success = false, error = "rate_limited" } end
|
||||
if type(data) ~= "table" or type(data.id) ~= "string" or (data.kind ~= "like" and data.kind ~= "save") or type(data.active) ~= "boolean" then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
local videos = Bridge.Database.Query("SELECT `profile_id` FROM `sky_phone_fliptok_videos` WHERE `id` = ? AND `status` = 'published' LIMIT 1", { data.id })
|
||||
if not videos[1] then return { success = false, error = "video_not_found" } end
|
||||
local owner_id = tonumber(videos[1].profile_id)
|
||||
if owner_id ~= profile.id and are_profiles_blocked(profile.id, owner_id) then
|
||||
return { success = false, error = "blocked" }
|
||||
end
|
||||
if data.active then
|
||||
local inserted = Bridge.Database.Query("INSERT IGNORE INTO `sky_phone_fliptok_reactions` (`video_id`, `profile_id`, `kind`) VALUES (?, ?, ?)", { data.id, profile.id, data.kind })
|
||||
if affected_rows(inserted) > 0 and data.kind == "like" and owner_id ~= profile.id then
|
||||
Bridge.Database.Query("INSERT INTO `sky_phone_fliptok_notifications` (`id`, `recipient_id`, `actor_id`, `video_id`, `kind`) VALUES (?, ?, ?, ?, 'like')", { new_id(), videos[1].profile_id, profile.id, data.id })
|
||||
notify_profile(owner_id, profile.id, "like", data.id)
|
||||
end
|
||||
else
|
||||
Bridge.Database.Query("DELETE FROM `sky_phone_fliptok_reactions` WHERE `video_id` = ? AND `profile_id` = ? AND `kind` = ?", { data.id, profile.id, data.kind })
|
||||
end
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:follow", function(source, data)
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
local target_id = type(data) == "table" and tonumber(data.profileId) or nil
|
||||
if not target_id or target_id == profile.id or type(data.active) ~= "boolean" then return { success = false, error = "invalid_request" } end
|
||||
if are_profiles_blocked(profile.id, target_id) then return { success = false, error = "blocked" } end
|
||||
if data.active then
|
||||
local inserted = Bridge.Database.Query("INSERT IGNORE INTO `sky_phone_fliptok_follows` (`follower_id`, `following_id`) SELECT ?, `id` FROM `sky_phone_fliptok_profiles` WHERE `id` = ?", { profile.id, target_id })
|
||||
if affected_rows(inserted) > 0 then
|
||||
Bridge.Database.Query("INSERT INTO `sky_phone_fliptok_notifications` (`id`, `recipient_id`, `actor_id`, `kind`) VALUES (?, ?, ?, 'follow')", { new_id(), target_id, profile.id })
|
||||
notify_profile(target_id, profile.id, "follow")
|
||||
end
|
||||
else
|
||||
Bridge.Database.Query("DELETE FROM `sky_phone_fliptok_follows` WHERE `follower_id` = ? AND `following_id` = ?", { profile.id, target_id })
|
||||
end
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:comments", function(source, data)
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
if type(data) ~= "table" or type(data.id) ~= "string" then return { success = false, error = "invalid_request" } end
|
||||
local rows = Bridge.Database.Query([[SELECT c.`id`, c.`body`, UNIX_TIMESTAMP(c.`created_at`) * 1000 AS `created_at`,
|
||||
p.`id` AS `profile_id`, p.`handle`, p.`display_name`, p.`verified`
|
||||
FROM `sky_phone_fliptok_comments` c JOIN `sky_phone_fliptok_profiles` p ON p.`id` = c.`profile_id`
|
||||
WHERE c.`video_id` = ? AND c.`status` = 'visible'
|
||||
AND NOT EXISTS(SELECT 1 FROM `sky_phone_fliptok_blocks` b WHERE
|
||||
(b.`blocker_id` = ? AND b.`blocked_id` = c.`profile_id`) OR (b.`blocked_id` = ? AND b.`blocker_id` = c.`profile_id`))
|
||||
ORDER BY c.`created_at` DESC LIMIT 100]], { data.id, profile.id, profile.id })
|
||||
for _, row in ipairs(rows) do row.verified = tonumber(row.verified) == 1 end
|
||||
return { success = true, data = rows }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:comment", function(source, data)
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
if not SkyPhone.AllowOperation(source, "fliptok:comment", 20, 60) then return { success = false, error = "rate_limited" } end
|
||||
local body = type(data) == "table" and trim(data.body) or nil
|
||||
if type(data) ~= "table" or type(data.id) ~= "string" or not valid_text(body, 1, Config.FlipTok.CommentMaxLength) then return { success = false, error = "invalid_comment" } end
|
||||
local videos = Bridge.Database.Query("SELECT `profile_id` FROM `sky_phone_fliptok_videos` WHERE `id` = ? AND `status` = 'published' AND `comments_enabled` = 1 LIMIT 1", { data.id })
|
||||
if not videos[1] then return { success = false, error = "comments_disabled" } end
|
||||
local owner_id = tonumber(videos[1].profile_id)
|
||||
if owner_id ~= profile.id and are_profiles_blocked(profile.id, owner_id) then
|
||||
return { success = false, error = "blocked" }
|
||||
end
|
||||
Bridge.Database.Query("INSERT INTO `sky_phone_fliptok_comments` (`id`, `video_id`, `profile_id`, `body`) VALUES (?, ?, ?, ?)", { new_id(), data.id, profile.id, body })
|
||||
if tonumber(videos[1].profile_id) ~= profile.id then
|
||||
Bridge.Database.Query("INSERT INTO `sky_phone_fliptok_notifications` (`id`, `recipient_id`, `actor_id`, `video_id`, `kind`) VALUES (?, ?, ?, ?, 'comment')", { new_id(), videos[1].profile_id, profile.id, data.id })
|
||||
notify_profile(owner_id, profile.id, "comment", data.id)
|
||||
end
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:view", function(source, data)
|
||||
local _, error_response = require_profile(source)
|
||||
if error_response then return error_response end
|
||||
if not SkyPhone.AllowOperation(source, "fliptok:view", 120, 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
|
||||
Bridge.Database.Query("UPDATE `sky_phone_fliptok_videos` SET `view_count` = `view_count` + 1 WHERE `id` = ? AND `status` = 'published'", { data.id })
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:share", function(source, data)
|
||||
local _, error_response = require_profile(source)
|
||||
if error_response then return error_response end
|
||||
if not SkyPhone.AllowOperation(source, "fliptok:share", 30, 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
|
||||
Bridge.Database.Query("UPDATE `sky_phone_fliptok_videos` SET `share_count` = `share_count` + 1 WHERE `id` = ? AND `status` = 'published'", { data.id })
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:profile", function(source, data)
|
||||
local viewer, error_response = require_profile(source)
|
||||
if not viewer then return error_response end
|
||||
local handle = type(data) == "table" and trim(data.handle) or nil
|
||||
local id = type(data) == "table" and tonumber(data.profileId) or viewer.id
|
||||
local rows = handle and Bridge.Database.Query("SELECT `id` FROM `sky_phone_fliptok_profiles` WHERE `handle` = ? LIMIT 1", { handle }) or { { id = id } }
|
||||
if not rows[1] then return { success = false, error = "profile_not_found" } end
|
||||
if tonumber(rows[1].id) ~= viewer.id and are_profiles_blocked(viewer.id, tonumber(rows[1].id)) then
|
||||
return { success = false, error = "profile_not_found" }
|
||||
end
|
||||
local target = load_profile(tonumber(rows[1].id), viewer.id)
|
||||
if not target then return { success = false, error = "profile_not_found" } end
|
||||
local videos = list_videos(viewer.id, "v.`profile_id` = ? AND (v.`visibility` = 'public' OR v.`profile_id` = ?)", { target.id, viewer.id, viewer.id, viewer.id }, 60, 0, "v.`created_at` DESC")
|
||||
return { success = true, data = { profile = target, videos = videos } }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:update-profile", function(source, data)
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
local handle = type(data) == "table" and trim(data.handle) or nil
|
||||
local display_name = type(data) == "table" and trim(data.displayName) or nil
|
||||
local bio = type(data) == "table" and trim(data.bio) or nil
|
||||
local account_type = type(data) == "table" and data.accountType or nil
|
||||
if not handle or not handle:match("^[a-z0-9._]+$") or not valid_text(handle, 3, 24)
|
||||
or not valid_text(display_name, 1, 40) or not valid_text(bio, 0, Config.FlipTok.BioMaxLength) or not account_types[account_type]
|
||||
then return { success = false, error = "invalid_profile" } end
|
||||
local duplicate = Bridge.Database.Query("SELECT `id` FROM `sky_phone_fliptok_profiles` WHERE `handle` = ? AND `id` <> ? LIMIT 1", { handle, profile.id })
|
||||
if duplicate[1] then return { success = false, error = "handle_taken" } end
|
||||
Bridge.Database.Query("UPDATE `sky_phone_fliptok_profiles` SET `handle` = ?, `display_name` = ?, `bio` = ?, `account_type` = ? WHERE `id` = ?", { handle, display_name, bio, account_type, profile.id })
|
||||
return { success = true, data = load_profile(profile.id, profile.id) }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:activities", function(source)
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
local rows = Bridge.Database.Query([[SELECT n.`id`, n.`kind`, n.`video_id`, n.`read_at`, UNIX_TIMESTAMP(n.`created_at`) * 1000 AS `created_at`,
|
||||
p.`id` AS `profile_id`, p.`handle`, p.`display_name`, p.`verified`
|
||||
FROM `sky_phone_fliptok_notifications` n JOIN `sky_phone_fliptok_profiles` p ON p.`id` = n.`actor_id`
|
||||
WHERE n.`recipient_id` = ?
|
||||
AND (n.`kind` = 'verified' OR NOT EXISTS(SELECT 1 FROM `sky_phone_fliptok_blocks` b WHERE
|
||||
(b.`blocker_id` = n.`recipient_id` AND b.`blocked_id` = n.`actor_id`) OR
|
||||
(b.`blocked_id` = n.`recipient_id` AND b.`blocker_id` = n.`actor_id`)))
|
||||
ORDER BY n.`created_at` DESC LIMIT 100]], { profile.id })
|
||||
for _, row in ipairs(rows) do row.verified = tonumber(row.verified) == 1 end
|
||||
return { success = true, data = rows }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:mark-activities", function(source)
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
Bridge.Database.Query("UPDATE `sky_phone_fliptok_notifications` SET `read_at` = NOW() WHERE `recipient_id` = ? AND `read_at` IS NULL", { profile.id })
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:report", function(source, data)
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
if not SkyPhone.AllowOperation(source, "fliptok:report", 10, 60) then return { success = false, error = "rate_limited" } end
|
||||
local reason = type(data) == "table" and data.reason or nil
|
||||
local details = type(data) == "table" and trim(data.details) or ""
|
||||
if type(data) ~= "table" or type(data.id) ~= "string" or not report_reasons[reason] or not valid_text(details, 0, 500) then return { success = false, error = "invalid_report" } end
|
||||
local videos = Bridge.Database.Query([[SELECT v.`profile_id` FROM `sky_phone_fliptok_videos` v
|
||||
WHERE v.`id` = ? AND v.`status` = 'published'
|
||||
AND (v.`profile_id` = ? OR v.`visibility` = 'public' OR
|
||||
(v.`visibility` = 'followers' AND EXISTS(SELECT 1 FROM `sky_phone_fliptok_follows` f
|
||||
WHERE f.`follower_id` = ? AND f.`following_id` = v.`profile_id`)))
|
||||
AND NOT EXISTS(SELECT 1 FROM `sky_phone_fliptok_blocks` b WHERE
|
||||
(b.`blocker_id` = ? AND b.`blocked_id` = v.`profile_id`) OR
|
||||
(b.`blocked_id` = ? AND b.`blocker_id` = v.`profile_id`))
|
||||
LIMIT 1]], { data.id, profile.id, profile.id, profile.id, profile.id })
|
||||
if not videos[1] then return { success = false, error = "video_not_found" } end
|
||||
Bridge.Database.Query("INSERT IGNORE INTO `sky_phone_fliptok_reports` (`id`, `reporter_id`, `video_id`, `reason`, `details`) VALUES (?, ?, ?, ?, ?)", { new_id(), profile.id, data.id, reason, details })
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:admin-reports", function(source)
|
||||
local _, error_response = require_profile(source)
|
||||
if error_response then return error_response end
|
||||
if not Bridge.Framework.HasAdminGroup(source, Config.FlipTok.ReportAdminGroups) then
|
||||
return { success = false, error = "not_authorized" }
|
||||
end
|
||||
local rows = Bridge.Database.Query([[SELECT r.`id`, r.`video_id`, r.`reason`, r.`details`,
|
||||
UNIX_TIMESTAMP(r.`created_at`) * 1000 AS `created_at`, v.`caption`, m.`url`,
|
||||
reporter.`handle` AS `reporter_handle`, reporter.`display_name` AS `reporter_display_name`,
|
||||
creator.`handle` AS `creator_handle`, creator.`display_name` AS `creator_display_name`
|
||||
FROM `sky_phone_fliptok_reports` r
|
||||
JOIN `sky_phone_fliptok_videos` v ON v.`id` = r.`video_id`
|
||||
JOIN `sky_phone_media` m ON m.`id` = v.`media_id`
|
||||
JOIN `sky_phone_fliptok_profiles` reporter ON reporter.`id` = r.`reporter_id`
|
||||
JOIN `sky_phone_fliptok_profiles` creator ON creator.`id` = v.`profile_id`
|
||||
WHERE r.`status` = 'open' ORDER BY r.`created_at` ASC LIMIT 200]], {})
|
||||
return { success = true, data = rows }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:admin-resolve-report", function(source, data)
|
||||
local _, error_response = require_profile(source)
|
||||
if error_response then return error_response end
|
||||
if not Bridge.Framework.HasAdminGroup(source, Config.FlipTok.ReportAdminGroups) then
|
||||
return { success = false, error = "not_authorized" }
|
||||
end
|
||||
local id = type(data) == "table" and data.id or nil
|
||||
local action = type(data) == "table" and data.action or nil
|
||||
if type(id) ~= "string" or not report_actions[action] then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
local reports = Bridge.Database.Query("SELECT `video_id` FROM `sky_phone_fliptok_reports` WHERE `id` = ? AND `status` = 'open' LIMIT 1", { id })
|
||||
if not reports[1] then return { success = false, error = "report_not_found" } end
|
||||
if action == "remove" then
|
||||
Bridge.Database.Transaction({
|
||||
{ query = "UPDATE `sky_phone_fliptok_videos` SET `status` = 'removed' WHERE `id` = ?", params = { reports[1].video_id } },
|
||||
{ query = "UPDATE `sky_phone_fliptok_reports` SET `status` = 'reviewed' WHERE `video_id` = ? AND `status` = 'open'", params = { reports[1].video_id } },
|
||||
})
|
||||
else
|
||||
Bridge.Database.Query("UPDATE `sky_phone_fliptok_reports` SET `status` = 'dismissed' WHERE `id` = ?", { id })
|
||||
end
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:block", function(source, data)
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
local target_id = type(data) == "table" and tonumber(data.profileId) or nil
|
||||
if not target_id or target_id == profile.id then return { success = false, error = "invalid_request" } end
|
||||
Bridge.Database.Transaction({
|
||||
{ query = "INSERT IGNORE INTO `sky_phone_fliptok_blocks` (`blocker_id`, `blocked_id`) VALUES (?, ?)", params = { profile.id, target_id } },
|
||||
{ query = "DELETE FROM `sky_phone_fliptok_follows` WHERE (`follower_id` = ? AND `following_id` = ?) OR (`follower_id` = ? AND `following_id` = ?)", params = { profile.id, target_id, target_id, profile.id } },
|
||||
})
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:delete", function(source, data)
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
if type(data) ~= "table" or type(data.id) ~= "string" then return { success = false, error = "video_not_found" } end
|
||||
local result = Bridge.Database.Query("UPDATE `sky_phone_fliptok_videos` SET `status` = 'removed' WHERE `id` = ? AND `profile_id` = ?", { data.id, profile.id })
|
||||
local affected = type(result) == "number" and result or type(result) == "table" and tonumber(result.affectedRows) or 0
|
||||
return affected > 0 and { success = true } or { success = false, error = "video_not_found" }
|
||||
end)
|
||||
|
||||
RegisterCommand(Config.FlipTok.VerifyCommand, function(source, arguments)
|
||||
local command_locale = (Locales[Config.Bridge.Locale] or Locales["en"]).FlipTokCommand
|
||||
local function command_message(template, values)
|
||||
return template:gsub("{(%w+)}", function(key) return values[key] or "" end)
|
||||
end
|
||||
local function send_command_feedback(message, notification_type)
|
||||
if source == 0 then
|
||||
print(message)
|
||||
return
|
||||
end
|
||||
|
||||
TriggerClientEvent("sky_phone:fliptok:command-feedback", source, {
|
||||
message = message,
|
||||
notificationType = notification_type,
|
||||
})
|
||||
end
|
||||
if source ~= 0 and not Bridge.Framework.HasAdminGroup(source, Config.FlipTok.AdminGroups) then
|
||||
send_command_feedback(command_locale.noPermission, "error")
|
||||
print(("[sky_phone] Player %d attempted to use the FlipTok verification command without an admin group."):format(source))
|
||||
return
|
||||
end
|
||||
local handle = type(arguments[1]) == "string" and arguments[1]:lower():gsub("^@", "") or ""
|
||||
local requested = type(arguments[2]) == "string" and arguments[2]:lower() or nil
|
||||
if handle == "" or (requested and requested ~= "on" and requested ~= "off") then
|
||||
local message = command_message(command_locale.usage, { command = Config.FlipTok.VerifyCommand })
|
||||
send_command_feedback(message, "error")
|
||||
return
|
||||
end
|
||||
local rows = Bridge.Database.Query("SELECT `id`, `verified` FROM `sky_phone_fliptok_profiles` WHERE `handle` = ? LIMIT 1", { handle })
|
||||
if not rows[1] then
|
||||
local message = command_message(command_locale.notFound, { handle = handle })
|
||||
send_command_feedback(message, "error")
|
||||
return
|
||||
end
|
||||
local verified
|
||||
if requested then
|
||||
verified = requested == "on"
|
||||
else
|
||||
verified = tonumber(rows[1].verified) ~= 1
|
||||
end
|
||||
Bridge.Database.Query("UPDATE `sky_phone_fliptok_profiles` SET `verified` = ? WHERE `id` = ?", { verified and 1 or 0, rows[1].id })
|
||||
if verified then
|
||||
Bridge.Database.Query("INSERT INTO `sky_phone_fliptok_notifications` (`id`, `recipient_id`, `actor_id`, `kind`) VALUES (?, ?, ?, 'verified')", {
|
||||
new_id(), rows[1].id, rows[1].id,
|
||||
})
|
||||
notify_profile(tonumber(rows[1].id), tonumber(rows[1].id), "verified")
|
||||
end
|
||||
TriggerClientEvent("sky_phone:fliptok:verification-changed", -1, {
|
||||
profileId = tonumber(rows[1].id),
|
||||
verified = verified,
|
||||
})
|
||||
local message = command_message(command_locale.updated, {
|
||||
handle = handle,
|
||||
state = verified and command_locale.verified or command_locale.unverified,
|
||||
})
|
||||
send_command_feedback(message, "success")
|
||||
end, false)
|
||||
end)
|
||||
@@ -0,0 +1,161 @@
|
||||
Bridge.Database.AfterMigration("sky_phone", function()
|
||||
local marker_colors = {
|
||||
blue = true,
|
||||
green = true,
|
||||
orange = true,
|
||||
purple = true,
|
||||
red = true,
|
||||
}
|
||||
|
||||
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 text_length(value)
|
||||
return type(value) == "string" and utf8.len(value) or nil
|
||||
end
|
||||
|
||||
local function marker_dto(row)
|
||||
return {
|
||||
id = row.id,
|
||||
label = row.label,
|
||||
color = row.color,
|
||||
coords = {
|
||||
x = tonumber(row.position_x) or 0.0,
|
||||
y = tonumber(row.position_y) or 0.0,
|
||||
z = tonumber(row.position_z) or 0.0,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
local function validate_marker(data)
|
||||
if type(data) ~= "table" or type(data.coords) ~= "table" then
|
||||
return nil
|
||||
end
|
||||
|
||||
local label = type(data.label) == "string" and data.label:match("^%s*(.-)%s*$") or nil
|
||||
local label_length = text_length(label)
|
||||
local color = data.color
|
||||
local x = tonumber(data.coords.x)
|
||||
local y = tonumber(data.coords.y)
|
||||
local z = tonumber(data.coords.z)
|
||||
if not label_length
|
||||
or label_length < 1
|
||||
or label_length > Config.MapMarkers.LabelMaxLength
|
||||
or not marker_colors[color]
|
||||
or not x
|
||||
or not y
|
||||
or (data.coords.z ~= nil and not z)
|
||||
or x ~= x
|
||||
or y ~= y
|
||||
or (z and z ~= z)
|
||||
or math.abs(x) > 10000.0
|
||||
or math.abs(y) > 10000.0
|
||||
or (z and z < -1000.0)
|
||||
or (z and z > 3000.0)
|
||||
then
|
||||
return nil
|
||||
end
|
||||
|
||||
return {
|
||||
label = label,
|
||||
color = color,
|
||||
x = x,
|
||||
y = y,
|
||||
z = z or 0.0,
|
||||
}
|
||||
end
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:map:markers", function(source)
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `id`, `label`, `color`, `position_x`, `position_y`, `position_z`
|
||||
FROM `sky_phone_map_markers`
|
||||
WHERE `device_imei` = ?
|
||||
ORDER BY `created_at`, `id`
|
||||
LIMIT ?
|
||||
]], { session.imei, Config.MapMarkers.MaximumMarkers })
|
||||
local markers = {}
|
||||
for index = 1, #rows do
|
||||
markers[index] = marker_dto(rows[index])
|
||||
end
|
||||
return { success = true, data = markers }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:map:create-marker", function(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "map_marker_write", Config.MapMarkers.ActionsPerMinute, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
|
||||
local marker = validate_marker(data)
|
||||
if not marker then
|
||||
return { success = false, error = "invalid_marker" }
|
||||
end
|
||||
|
||||
local count_rows = Bridge.Database.Query([[
|
||||
SELECT COUNT(*) AS `count`
|
||||
FROM `sky_phone_map_markers`
|
||||
WHERE `device_imei` = ?
|
||||
]], { session.imei })
|
||||
if (tonumber(count_rows[1] and count_rows[1].count) or 0) >= Config.MapMarkers.MaximumMarkers then
|
||||
return { success = false, error = "marker_limit" }
|
||||
end
|
||||
|
||||
local ids = Bridge.Database.Query("SELECT UUID() AS `id`", {})
|
||||
local id = ids[1] and ids[1].id
|
||||
if type(id) ~= "string" then
|
||||
error("[sky_phone] Database did not generate a map marker id.")
|
||||
end
|
||||
|
||||
Bridge.Database.Query([[
|
||||
INSERT INTO `sky_phone_map_markers`
|
||||
(`id`, `device_imei`, `label`, `color`, `position_x`, `position_y`, `position_z`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
]], { id, session.imei, marker.label, marker.color, marker.x, marker.y, marker.z })
|
||||
return {
|
||||
success = true,
|
||||
data = {
|
||||
id = id,
|
||||
label = marker.label,
|
||||
color = marker.color,
|
||||
coords = { x = marker.x, y = marker.y, z = marker.z },
|
||||
},
|
||||
}
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:map:delete-marker", function(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "map_marker_write", Config.MapMarkers.ActionsPerMinute, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
if type(data) ~= "table" or type(data.id) ~= "string" or #data.id ~= 36 then
|
||||
return { success = false, error = "invalid_marker" }
|
||||
end
|
||||
|
||||
local result = Bridge.Database.Query(
|
||||
"DELETE FROM `sky_phone_map_markers` WHERE `id` = ? AND `device_imei` = ?",
|
||||
{ data.id, session.imei }
|
||||
)
|
||||
if affected_rows(result) ~= 1 then
|
||||
return { success = false, error = "marker_not_found" }
|
||||
end
|
||||
return { success = true }
|
||||
end)
|
||||
end)
|
||||
@@ -7,6 +7,15 @@ local sessions = {}
|
||||
local auth_attempts = {}
|
||||
local operation_attempts = {}
|
||||
local max_device_data_bytes = 100000
|
||||
local passcode_pepper = GetConvar(Config.Security.PasscodePepperConvar, "")
|
||||
if passcode_pepper == "" then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] Passcode pepper convar '%s' is empty; configure it before production use.",
|
||||
Config.Security.PasscodePepperConvar,
|
||||
{ always = true }
|
||||
)
|
||||
end
|
||||
local allowed_device_namespaces = {
|
||||
settings = true,
|
||||
notifications = true,
|
||||
@@ -234,6 +243,98 @@ local function load_device_data(imei)
|
||||
return data
|
||||
end
|
||||
|
||||
local function load_device_security(imei)
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `passcode_length`, `failed_attempts`, `locked_until`
|
||||
FROM `sky_phone_device_security`
|
||||
WHERE `device_imei` = ?
|
||||
LIMIT 1
|
||||
]], { imei })
|
||||
return rows[1]
|
||||
end
|
||||
|
||||
local function security_status(imei)
|
||||
local security = load_device_security(imei)
|
||||
return {
|
||||
enabled = security ~= nil,
|
||||
length = security and tonumber(security.passcode_length) or nil,
|
||||
lockedUntil = security and tonumber(security.locked_until) or 0,
|
||||
}
|
||||
end
|
||||
|
||||
local function valid_passcode(value)
|
||||
return type(value) == "string"
|
||||
and (#value == 4 or #value == 6)
|
||||
and value:match("^%d+$") ~= nil
|
||||
end
|
||||
|
||||
local function passcode_matches(imei, passcode)
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT 1 AS `matches`
|
||||
FROM `sky_phone_device_security`
|
||||
WHERE `device_imei` = ?
|
||||
AND `passcode_hash` = UNHEX(SHA2(CONCAT(?, `passcode_salt`, ?), 256))
|
||||
LIMIT 1
|
||||
]], { imei, passcode_pepper, passcode })
|
||||
return rows[1] ~= nil
|
||||
end
|
||||
|
||||
local function verify_passcode(session, passcode)
|
||||
if not valid_passcode(passcode) then
|
||||
return false, { success = false, error = "invalid_passcode" }
|
||||
end
|
||||
|
||||
local security = load_device_security(session.imei)
|
||||
if not security then
|
||||
return false, { success = false, error = "passcode_not_set" }
|
||||
end
|
||||
|
||||
local now = os.time()
|
||||
local locked_until = tonumber(security.locked_until) or 0
|
||||
if locked_until > now then
|
||||
return false, {
|
||||
success = false,
|
||||
error = "passcode_locked",
|
||||
data = { retryAfter = locked_until - now },
|
||||
}
|
||||
end
|
||||
|
||||
if passcode_matches(session.imei, passcode) then
|
||||
Bridge.Database.Query([[
|
||||
UPDATE `sky_phone_device_security`
|
||||
SET `failed_attempts` = 0, `locked_until` = 0
|
||||
WHERE `device_imei` = ?
|
||||
]], { session.imei })
|
||||
return true
|
||||
end
|
||||
|
||||
local failed_attempts = (tonumber(security.failed_attempts) or 0) + 1
|
||||
if failed_attempts >= Config.Security.MaximumAttempts then
|
||||
local next_unlock = now + Config.Security.LockSeconds
|
||||
Bridge.Database.Query([[
|
||||
UPDATE `sky_phone_device_security`
|
||||
SET `failed_attempts` = 0, `locked_until` = ?
|
||||
WHERE `device_imei` = ?
|
||||
]], { next_unlock, session.imei })
|
||||
return false, {
|
||||
success = false,
|
||||
error = "passcode_locked",
|
||||
data = { retryAfter = Config.Security.LockSeconds },
|
||||
}
|
||||
end
|
||||
|
||||
Bridge.Database.Query([[
|
||||
UPDATE `sky_phone_device_security`
|
||||
SET `failed_attempts` = ?
|
||||
WHERE `device_imei` = ?
|
||||
]], { failed_attempts, session.imei })
|
||||
return false, {
|
||||
success = false,
|
||||
error = "invalid_passcode",
|
||||
data = { attemptsRemaining = Config.Security.MaximumAttempts - failed_attempts },
|
||||
}
|
||||
end
|
||||
|
||||
local function account_devices(account_id, current_imei)
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `imei`, `device_name`, `created_at`, `updated_at`
|
||||
@@ -248,7 +349,7 @@ local function account_devices(account_id, current_imei)
|
||||
end
|
||||
|
||||
local function bootstrap(source)
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
local session, error_response = SkyPhone.RequireDeviceSession(source)
|
||||
if not session then
|
||||
return nil, error_response
|
||||
end
|
||||
@@ -260,6 +361,7 @@ local function bootstrap(source)
|
||||
|
||||
return {
|
||||
token = session.token,
|
||||
security = security_status(device.imei),
|
||||
device = {
|
||||
imei = device.imei,
|
||||
name = device.device_name,
|
||||
@@ -417,7 +519,7 @@ local function authenticate(source, data, registering)
|
||||
return link_account(source, accounts[1])
|
||||
end
|
||||
|
||||
function SkyPhone.RequireSession(source)
|
||||
function SkyPhone.RequireDeviceSession(source)
|
||||
local session = sessions[source]
|
||||
if not session then
|
||||
return nil, { success = false, error = "device_not_open" }
|
||||
@@ -433,6 +535,17 @@ function SkyPhone.RequireSession(source)
|
||||
return session
|
||||
end
|
||||
|
||||
function SkyPhone.RequireSession(source)
|
||||
local session, error_response = SkyPhone.RequireDeviceSession(source)
|
||||
if not session then
|
||||
return nil, error_response
|
||||
end
|
||||
if not session.unlocked then
|
||||
return nil, { success = false, error = "device_locked" }
|
||||
end
|
||||
return session
|
||||
end
|
||||
|
||||
function SkyPhone.AllowOperation(source, operation, maximum, window_seconds)
|
||||
local now = os.time()
|
||||
operation_attempts[source] = operation_attempts[source] or {}
|
||||
@@ -565,10 +678,12 @@ local function open_phone(source, used_item)
|
||||
return false
|
||||
end
|
||||
|
||||
local security = load_device_security(imei)
|
||||
sessions[source] = {
|
||||
imei = imei,
|
||||
slot = slot.slot,
|
||||
token = ("%s:%s:%s"):format(imei, tostring(source), tostring(GetGameTimer())),
|
||||
unlocked = security == nil,
|
||||
}
|
||||
local payload = bootstrap(source)
|
||||
Bridge.Debug(
|
||||
@@ -590,10 +705,12 @@ function SkyPhone.OpenDeviceForCall(source, imei)
|
||||
Bridge.Debug("warn", "[sky_phone] Could not open ringing device %s for source %s.", tostring(imei), tostring(source))
|
||||
return false
|
||||
end
|
||||
local security = load_device_security(imei)
|
||||
sessions[source] = {
|
||||
imei = imei,
|
||||
slot = matches[1].slot,
|
||||
token = ("%s:%s:%s"):format(imei, tostring(source), tostring(GetGameTimer())),
|
||||
unlocked = security == nil,
|
||||
}
|
||||
TriggerClientEvent("sky_phone:device:open", source, bootstrap(source))
|
||||
return true
|
||||
@@ -619,6 +736,106 @@ Bridge.Callbacks.Register("sky_phone:device:close", function(source)
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:security:unlock", function(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "security_unlock", Config.Security.AttemptsPerMinute, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
local session, error_response = SkyPhone.RequireDeviceSession(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
if session.unlocked then
|
||||
return { success = true, data = { security = security_status(session.imei) } }
|
||||
end
|
||||
|
||||
local verified, verification_error = verify_passcode(session, data and data.passcode)
|
||||
if not verified then
|
||||
return verification_error
|
||||
end
|
||||
session.unlocked = true
|
||||
return { success = true, data = { security = security_status(session.imei) } }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:security:set-passcode", function(source, data)
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
local passcode = data and data.passcode
|
||||
if not valid_passcode(passcode) then
|
||||
return { success = false, error = "invalid_passcode" }
|
||||
end
|
||||
if load_device_security(session.imei) then
|
||||
return { success = false, error = "passcode_already_set" }
|
||||
end
|
||||
|
||||
local salts = Bridge.Database.Query("SELECT REPLACE(UUID(), '-', '') AS `salt`", {})
|
||||
local salt = salts[1] and salts[1].salt
|
||||
if type(salt) ~= "string" or #salt ~= 32 then
|
||||
error("[sky_phone] Database did not generate a valid passcode salt.")
|
||||
end
|
||||
local result = Bridge.Database.Query([[
|
||||
INSERT INTO `sky_phone_device_security`
|
||||
(`device_imei`, `passcode_hash`, `passcode_salt`, `passcode_length`)
|
||||
VALUES (?, UNHEX(SHA2(CONCAT(?, ?, ?), 256)), ?, ?)
|
||||
]], { session.imei, passcode_pepper, salt, passcode, salt, #passcode })
|
||||
if affected_rows(result) ~= 1 then
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
return { success = true, data = { security = security_status(session.imei) } }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:security:change-passcode", function(source, data)
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
local new_passcode = data and data.newPasscode
|
||||
if not valid_passcode(new_passcode) then
|
||||
return { success = false, error = "invalid_passcode" }
|
||||
end
|
||||
local verified, verification_error = verify_passcode(session, data and data.currentPasscode)
|
||||
if not verified then
|
||||
return verification_error
|
||||
end
|
||||
|
||||
local salts = Bridge.Database.Query("SELECT REPLACE(UUID(), '-', '') AS `salt`", {})
|
||||
local salt = salts[1] and salts[1].salt
|
||||
if type(salt) ~= "string" or #salt ~= 32 then
|
||||
error("[sky_phone] Database did not generate a valid passcode salt.")
|
||||
end
|
||||
local result = Bridge.Database.Query([[
|
||||
UPDATE `sky_phone_device_security`
|
||||
SET `passcode_hash` = UNHEX(SHA2(CONCAT(?, ?, ?), 256)),
|
||||
`passcode_salt` = ?, `passcode_length` = ?, `failed_attempts` = 0, `locked_until` = 0
|
||||
WHERE `device_imei` = ?
|
||||
]], { passcode_pepper, salt, new_passcode, salt, #new_passcode, session.imei })
|
||||
if affected_rows(result) ~= 1 then
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
return { success = true, data = { security = security_status(session.imei) } }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:security:disable-passcode", function(source, data)
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
local verified, verification_error = verify_passcode(session, data and data.passcode)
|
||||
if not verified then
|
||||
return verification_error
|
||||
end
|
||||
local result = Bridge.Database.Query(
|
||||
"DELETE FROM `sky_phone_device_security` WHERE `device_imei` = ?",
|
||||
{ session.imei }
|
||||
)
|
||||
if affected_rows(result) ~= 1 then
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
session.unlocked = true
|
||||
return { success = true, data = { security = security_status(session.imei) } }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:device:development-open", function(source)
|
||||
if not Config.Phone.DevelopmentCommand then
|
||||
return { success = false, error = "disabled" }
|
||||
@@ -764,6 +981,10 @@ Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source)
|
||||
end
|
||||
local media_remote_ids = SkyPhoneMedia.GetDeviceRemoteIds(session.imei)
|
||||
if not Bridge.Database.Transaction({
|
||||
{
|
||||
query = "DELETE FROM `sky_phone_device_security` WHERE `device_imei` = ?",
|
||||
params = { session.imei },
|
||||
},
|
||||
{
|
||||
query = "DELETE FROM `sky_phone_device_data` WHERE `device_imei` = ?",
|
||||
params = { session.imei },
|
||||
@@ -784,6 +1005,10 @@ Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source)
|
||||
query = "DELETE FROM `sky_phone_call_entries` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
||||
params = { session.imei },
|
||||
},
|
||||
{
|
||||
query = "DELETE FROM `sky_phone_fliptok_sessions` WHERE `device_imei` = ?",
|
||||
params = { session.imei },
|
||||
},
|
||||
{
|
||||
query = "UPDATE `sky_phone_devices` SET `account_id` = NULL, `device_name` = ? WHERE `imei` = ?",
|
||||
params = { Config.Phone.DeviceName, session.imei },
|
||||
@@ -792,6 +1017,7 @@ Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source)
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
SkyPhoneMedia.CleanupRemoteFiles(media_remote_ids)
|
||||
session.unlocked = true
|
||||
refresh_source(source)
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Reference in New Issue
Block a user