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

978 lines
54 KiB
Lua

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 music_tracks = {}
local music_track_list = {}
local custom_music_extensions = {
aac = true,
m4a = true,
mp3 = true,
oga = true,
ogg = true,
opus = true,
wav = true,
webm = true,
}
local password_pepper = tostring(Config.Server.FlipTokPasswordPepper or "")
if password_pepper == "" then
Bridge.Debug(
"warn",
"[sky_phone] Config.Server.FlipTokPasswordPepper is empty. FlipTok passwords still work, but their hashes lack the required server-side secret. Set a stable random value in config/config.lua before production; changing it later invalidates existing FlipTok passwords.",
{ 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 valid_custom_music_url(value)
if type(value) ~= "string" or value == "" or #value > Config.Media.UrlMaxLength
or value:find("[^\33-\126]")
then
return false
end
local authority = value:match("^https://([^/%?#]+)")
if not authority or authority:find("@", 1, true) then return false end
local host = authority:match("^([^:]+)")
local port = host and authority:sub(#host + 1) or ""
if not host or (port ~= "" and port ~= ":443") then return false end
host = host:lower()
if host == "localhost" or host:match("%.localhost$") or host:match("%.local$") or host:match("%.internal$")
or host:match("^%d+%.%d+%.%d+%.%d+$") or not host:find("%.")
or host:find("[^a-z0-9%.%-]")
then
return false
end
for label in host:gmatch("[^.]+") do
if #label > 63 or label:match("^%-") or label:match("%-$") then return false end
end
local path = value:match("^https://[^/]+(/[^?#]*)") or ""
local extension = path:match("%.([%w]+)$")
return extension and custom_music_extensions[extension:lower()] == true or false
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.avatar_media_id = tonumber(profile.avatar_media_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.*, avatar.`url` AS `avatar_url`,
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
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_media_id`
WHERE p.`id` = ? LIMIT 1
]], { viewer_id, profile_id })
return rows[1] and hydrate_profile(rows[1], viewer_id) or nil
end
local function load_accessible_video(video_id, viewer_id)
if type(video_id) ~= "string" or video_id == "" or #video_id > 64 then return nil end
local rows = Bridge.Database.Query([[SELECT v.`profile_id`, v.`comments_enabled`
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` follow
WHERE follow.`follower_id` = ? AND follow.`following_id` = v.`profile_id`)))
AND NOT EXISTS(SELECT 1 FROM `sky_phone_fliptok_blocks` block WHERE
(block.`blocker_id` = ? AND block.`blocked_id` = v.`profile_id`) OR
(block.`blocked_id` = ? AND block.`blocker_id` = v.`profile_id`))
LIMIT 1]], { video_id, viewer_id, viewer_id, viewer_id, viewer_id })
return rows[1]
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 })
if not rows[1] then
Bridge.Debug("warn", "[sky_phone] FlipTok notification target or actor no longer exists.", { always = true })
return
end
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)
local by_video = {}
local video_ids = {}
for _, video in ipairs(rows) do
by_video[video.id] = video
video_ids[#video_ids + 1] = video.id
video.media_id = tonumber(video.media_id)
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]
local custom_music_url = type(video.custom_music_url) == "string" and video.custom_music_url or ""
local youtube_id = SkyPhoneYouTube.ParseId(custom_music_url)
video.music_title = track and track.title or video.custom_music_title or ""
video.music_artist = track and track.artist or video.custom_music_artist or ""
video.music_url = track and track.url or (youtube_id and "" or custom_music_url)
video.music_source = track and "audio" or (youtube_id and "youtube" or (custom_music_url ~= "" and "audio" or ""))
video.music_video_id = youtube_id or ""
video.custom_music_url = nil
video.custom_music_title = nil
video.custom_music_artist = nil
video.created_at = (tonumber(video.created_at_unix) or 0) * 1000
video.created_at_unix = nil
video.media = {}
end
if #video_ids > 0 then
local placeholders = {}
for index = 1, #video_ids do placeholders[index] = "?" end
local media_rows = Bridge.Database.Query(([=[
SELECT relation.`video_id`, media.`id`, media.`url`, media.`media_type`
FROM `sky_phone_fliptok_video_media` relation
JOIN `sky_phone_media` media ON media.`id` = relation.`media_id`
WHERE relation.`video_id` IN (%s)
ORDER BY relation.`video_id`, relation.`sort_order`
]=]):format(table.concat(placeholders, ",")), video_ids)
for _, media in ipairs(media_rows) do
local video = by_video[media.video_id]
if video then
video.media[#video.media + 1] = {
id = tonumber(media.id),
mediaType = media.media_type,
url = media.url,
}
end
end
end
for _, video in ipairs(rows) do
if #video.media == 0 then
video.media[1] = { id = video.media_id, mediaType = video.media_type, url = video.url }
end
video.media_type = video.media[1].mediaType
end
return rows
end
local function report_webhook_url()
local value = trim(GetConvar(Config.FlipTok.ReportWebhookConvar, "")) or ""
if value:match("^https://discord%.com/api/webhooks/%d+/[%w%-%_]+$")
or value:match("^https://discordapp%.com/api/webhooks/%d+/[%w%-%_]+$")
then
return value
end
return nil
end
local function truncate_discord_text(value, maximum_characters)
if type(value) ~= "string" then return "" end
local length = utf8.len(value)
if not length or length <= maximum_characters then return value end
local boundary = utf8.offset(value, maximum_characters + 1)
return boundary and value:sub(1, boundary - 1) .. "…" or value
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`,
v.`custom_music_url`, v.`custom_music_title`, v.`custom_music_artist`,
m.`id` AS `media_id`, m.`media_type`, m.`url`, UNIX_TIMESTAMP(v.`created_at`) AS `created_at_unix`, p.`handle`, p.`display_name`, p.`verified`,
avatar.`url` AS `avatar_url`,
(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`
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_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,
musicTracks = music_track_list,
} }
end)
Bridge.Callbacks.Register("sky_phone:fliptok:feed", feed)
Bridge.Callbacks.Register("sky_phone:fliptok:video", function(source, data)
local profile, error_response = require_profile(source)
if not profile then return error_response end
if not SkyPhone.AllowOperation(source, "fliptok:video", 60, 60) then
return { success = false, error = "rate_limited" }
end
local video_id = type(data) == "table" and data.id or nil
if type(video_id) ~= "string" or #video_id > 64 then
return { success = false, error = "video_not_found" }
end
local rows = list_videos(
profile.id,
[[v.`id` = ? AND (v.`visibility` = 'public' OR v.`profile_id` = ? OR
(v.`visibility` = 'followers' AND EXISTS(
SELECT 1 FROM `sky_phone_fliptok_follows` follow
WHERE follow.`follower_id` = ? AND follow.`following_id` = v.`profile_id`)))]],
{ video_id, profile.id, profile.id, profile.id, profile.id },
1,
0,
"v.`created_at` DESC"
)
return rows[1]
and { success = true, data = rows[1] }
or { success = false, error = "video_not_found" }
end)
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:music-metadata", function(source, data)
local profile, error_response = require_profile(source)
if not profile then return error_response end
if not SkyPhone.AllowOperation(source, "fliptok:music-metadata", 12, 60) then
return { success = false, error = "rate_limited" }
end
local value = trim(type(data) == "table" and data.url or nil)
local video_id = SkyPhoneYouTube.ParseId(value)
if not video_id then return { success = false, error = "invalid_music_url" } end
local metadata = SkyPhoneYouTube.FetchMetadata(video_id)
return { success = true, data = {
url = SkyPhoneYouTube.WatchUrl(video_id),
videoId = video_id,
title = metadata and metadata.title or "YouTube " .. video_id,
artist = metadata and metadata.artist or "YouTube",
} }
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 media_type = data.mediaType == "photo" and "photo" or "video"
local submitted_media_ids = type(data.mediaIds) == "table" and data.mediaIds or { media_id }
local media_ids = {}
local seen_media_ids = {}
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 ""
local custom_music_url = trim(data.customMusicUrl) or ""
local custom_music_title = ""
local custom_music_artist = ""
if not media_id or media_id < 1 or media_id ~= math.floor(media_id)
or #submitted_media_ids < 1 or #submitted_media_ids > Config.FlipTok.MaxPostMedia
or (media_type == "video" and #submitted_media_ids ~= 1)
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
for _, value in ipairs(submitted_media_ids) do
local normalized = tonumber(value)
if not normalized or normalized < 1 or normalized ~= math.floor(normalized)
or seen_media_ids[normalized]
or not SkyPhoneMedia.ResolveOwnedMedia(source, tostring(normalized), media_type)
then
return { success = false, error = "invalid_media" }
end
seen_media_ids[normalized] = true
media_ids[#media_ids + 1] = normalized
end
media_id = media_ids[1]
if media_type == "photo" then
trim_start_ms = 0
trim_end_ms = nil
cover_time_ms = 0
original_volume = 0
end
if custom_music_url ~= "" then
if music_track ~= "" then return { success = false, error = "invalid_music_url" } end
local youtube_id = SkyPhoneYouTube.ParseId(custom_music_url)
if youtube_id then
local metadata = SkyPhoneYouTube.FetchMetadata(youtube_id)
custom_music_url = SkyPhoneYouTube.WatchUrl(youtube_id)
custom_music_title = metadata and metadata.title or "YouTube " .. youtube_id
custom_music_artist = metadata and metadata.artist or "YouTube"
elseif not valid_custom_music_url(custom_music_url) then
return { success = false, error = "invalid_music_url" }
end
end
if music_track == "" and custom_music_url == "" then music_volume = 0 end
local id = new_id()
local queries = {{ 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`, `custom_music_url`, `custom_music_title`,
`custom_music_artist`, `status`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)]],
params = {
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, custom_music_url, custom_music_title, custom_music_artist,
data.draft == true and "draft" or "published",
}
}}
for index, current_media_id in ipairs(media_ids) do
queries[#queries + 1] = {
query = "INSERT INTO `sky_phone_fliptok_video_media` (`video_id`, `media_id`, `sort_order`) VALUES (?, ?, ?)",
params = { id, current_media_id, index },
}
end
if not Bridge.Database.Transaction(queries) then
return { success = false, error = "request_failed" }
end
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 video = load_accessible_video(data.id, profile.id)
if not video then return { success = false, error = "video_not_found" } end
local owner_id = tonumber(video.profile_id)
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(), video.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
if not SkyPhone.AllowOperation(source, "fliptok:follow", 30, 60) then
return { success = false, error = "rate_limited" }
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
local targets = Bridge.Database.Query("SELECT `id` FROM `sky_phone_fliptok_profiles` WHERE `id` = ? LIMIT 1", { target_id })
if not targets[1] then return { success = false, error = "profile_not_found" } 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 not load_accessible_video(data.id, profile.id) then
return { success = false, error = "video_not_found" }
end
local rows = Bridge.Database.Query([[SELECT c.`id`, c.`parent_id`, c.`body`, UNIX_TIMESTAMP(c.`created_at`) * 1000 AS `created_at`,
p.`id` AS `profile_id`, p.`handle`, p.`display_name`, p.`verified`, avatar.`url` AS `avatar_url`,
parent_author.`handle` AS `reply_to_handle`,
EXISTS(SELECT 1 FROM `sky_phone_fliptok_comment_reactions` reaction
WHERE reaction.`comment_id` = c.`id` AND reaction.`profile_id` = ?) AS `is_liked`,
(SELECT COUNT(*) FROM `sky_phone_fliptok_comment_reactions` reaction
WHERE reaction.`comment_id` = c.`id`) AS `like_count`
FROM `sky_phone_fliptok_comments` c
JOIN `sky_phone_fliptok_profiles` p ON p.`id` = c.`profile_id`
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_media_id`
LEFT JOIN `sky_phone_fliptok_comments` parent ON parent.`id` = c.`parent_id`
LEFT JOIN `sky_phone_fliptok_profiles` parent_author ON parent_author.`id` = parent.`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 COALESCE(parent.`created_at`, c.`created_at`) DESC,
c.`parent_id` IS NOT NULL, c.`created_at` ASC LIMIT 100]], {
profile.id, data.id, profile.id, profile.id,
})
for _, row in ipairs(rows) do
row.verified = tonumber(row.verified) == 1
row.is_liked = tonumber(row.is_liked) == 1
row.like_count = tonumber(row.like_count) or 0
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
local parent_id = type(data) == "table" and trim(data.parentId) 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
if parent_id and #parent_id ~= 36 then return { success = false, error = "invalid_comment" } end
local video = load_accessible_video(data.id, profile.id)
if not video then return { success = false, error = "video_not_found" } end
if tonumber(video.comments_enabled) ~= 1 then return { success = false, error = "comments_disabled" } end
local owner_id = tonumber(video.profile_id)
if parent_id then
local parents = Bridge.Database.Query([[SELECT c.`id`, c.`parent_id`, c.`profile_id` FROM `sky_phone_fliptok_comments` c
WHERE c.`id` = ? AND c.`video_id` = ? AND c.`status` = 'visible' LIMIT 1]], { parent_id, data.id })
if not parents[1] or are_profiles_blocked(profile.id, tonumber(parents[1].profile_id)) then
return { success = false, error = "invalid_comment" }
end
parent_id = parents[1].parent_id or parents[1].id
end
local comment_id = new_id()
Bridge.Database.Query("INSERT INTO `sky_phone_fliptok_comments` (`id`, `video_id`, `profile_id`, `parent_id`, `body`) VALUES (?, ?, ?, ?, ?)", {
comment_id, data.id, profile.id, parent_id, body,
})
if owner_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(), video.profile_id, profile.id, data.id })
notify_profile(owner_id, profile.id, "comment", data.id)
end
return { success = true, data = { id = comment_id } }
end)
Bridge.Callbacks.Register("sky_phone:fliptok:comment-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:comment-react", 60, 60) then
return { success = false, error = "rate_limited" }
end
local comment_id = type(data) == "table" and data.id or nil
if type(comment_id) ~= "string" or #comment_id ~= 36 or type(data.active) ~= "boolean" then
return { success = false, error = "invalid_request" }
end
local comments = Bridge.Database.Query([[SELECT c.`profile_id`, c.`video_id` FROM `sky_phone_fliptok_comments` c
JOIN `sky_phone_fliptok_videos` v ON v.`id` = c.`video_id`
WHERE c.`id` = ? AND c.`status` = 'visible' AND v.`status` = 'published' LIMIT 1]], { comment_id })
if not comments[1] then return { success = false, error = "invalid_comment" } end
if not load_accessible_video(comments[1].video_id, profile.id) then
return { success = false, error = "video_not_found" }
end
if are_profiles_blocked(profile.id, tonumber(comments[1].profile_id)) then
return { success = false, error = "blocked" }
end
if data.active then
Bridge.Database.Query([[INSERT IGNORE INTO `sky_phone_fliptok_comment_reactions`
(`comment_id`, `profile_id`) VALUES (?, ?)]], { comment_id, profile.id })
else
Bridge.Database.Query([[DELETE FROM `sky_phone_fliptok_comment_reactions`
WHERE `comment_id` = ? AND `profile_id` = ?]], { comment_id, profile.id })
end
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:fliptok:view", function(source, data)
local profile, error_response = require_profile(source)
if not profile 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 not load_accessible_video(data.id, profile.id) then
return { success = false, error = "video_not_found" }
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 profile, error_response = require_profile(source)
if not profile 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 not load_accessible_video(data.id, profile.id) then
return { success = false, error = "video_not_found" }
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` = ? OR
(v.`visibility` = 'followers' AND EXISTS(SELECT 1 FROM `sky_phone_fliptok_follows` profile_follow
WHERE profile_follow.`follower_id` = ? AND profile_follow.`following_id` = v.`profile_id`)))]],
{ target.id, viewer.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:connections", function(source, data)
local viewer, error_response = require_profile(source)
if not viewer then return error_response end
local target_id = type(data) == "table" and tonumber(data.profileId) or nil
local mode = type(data) == "table" and data.mode or nil
if not target_id or (mode ~= "followers" and mode ~= "following") then
return { success = false, error = "invalid_request" }
end
if target_id ~= viewer.id and are_profiles_blocked(viewer.id, target_id) then
return { success = false, error = "profile_not_found" }
end
local join_column = mode == "followers" and "f.`follower_id`" or "f.`following_id`"
local filter_column = mode == "followers" and "f.`following_id`" or "f.`follower_id`"
local rows = Bridge.Database.Query(([[SELECT p.*, avatar.`url` AS `avatar_url`,
EXISTS(SELECT 1 FROM `sky_phone_fliptok_follows` own_follow
WHERE own_follow.`follower_id` = ? AND own_follow.`following_id` = p.`id`) AS `is_following`,
(SELECT COUNT(*) FROM `sky_phone_fliptok_follows` followers WHERE followers.`following_id` = p.`id`) AS `followers`,
(SELECT COUNT(*) FROM `sky_phone_fliptok_follows` following WHERE following.`follower_id` = p.`id`) AS `following`,
(SELECT COUNT(*) FROM `sky_phone_fliptok_videos` video WHERE video.`profile_id` = p.`id` AND video.`status` = 'published') AS `video_count`
FROM `sky_phone_fliptok_follows` f
JOIN `sky_phone_fliptok_profiles` p ON p.`id` = %s
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_media_id`
WHERE %s = ? AND NOT EXISTS(SELECT 1 FROM `sky_phone_fliptok_blocks` block WHERE
(block.`blocker_id` = ? AND block.`blocked_id` = p.`id`) OR
(block.`blocked_id` = ? AND block.`blocker_id` = p.`id`))
ORDER BY p.`display_name`, p.`handle` LIMIT 200]]):format(join_column, filter_column), {
viewer.id, target_id, viewer.id, viewer.id,
})
for _, row in ipairs(rows) do hydrate_profile(row, viewer.id) end
return { success = true, data = rows }
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
if type(data) ~= "table" then return { success = false, error = "invalid_request" } end
local handle = normalize_handle(data.handle)
local display_name = trim(data.displayName)
local bio = trim(data.bio)
local account_type = data.accountType
if not handle
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 avatar_media_id = profile.avatar_media_id and tonumber(profile.avatar_media_id) or nil
if data.avatarMediaId ~= nil then
local requested_avatar_id = tonumber(data.avatarMediaId)
if not requested_avatar_id or requested_avatar_id < 0 or requested_avatar_id ~= math.floor(requested_avatar_id) then
return { success = false, error = "invalid_profile_image" }
end
if requested_avatar_id > 0 and not SkyPhoneMedia.ResolveOwnedMedia(source, requested_avatar_id, "photo") then
return { success = false, error = "invalid_profile_image" }
end
avatar_media_id = requested_avatar_id > 0 and requested_avatar_id or nil
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` = ?, `avatar_media_id` = ? WHERE `id` = ?", {
handle, display_name, bio, account_type, avatar_media_id, 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`, avatar.`url` AS `avatar_url`
FROM `sky_phone_fliptok_notifications` n JOIN `sky_phone_fliptok_profiles` p ON p.`id` = n.`actor_id`
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_media_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`, v.`caption`, creator.`handle`, creator.`display_name`, media.`url`
FROM `sky_phone_fliptok_videos` v
JOIN `sky_phone_fliptok_profiles` creator ON creator.`id` = v.`profile_id`
JOIN `sky_phone_media` media ON media.`id` = v.`media_id`
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
local webhook = report_webhook_url()
if not webhook then
Bridge.Debug("warn", "[sky_phone] FlipTok report webhook is not configured.", { always = true })
return { success = false, error = "report_unavailable" }
end
local video = videos[1]
local payload = {
username = "Sky Phone · FlipTok Reports",
allowed_mentions = { parse = {} },
embeds = {{
title = "New FlipTok report",
color = 16723285,
url = video.url,
description = details ~= "" and details or "No additional details supplied.",
fields = {
{ name = "Reason", value = reason, inline = true },
{ name = "Video", value = tostring(data.id), inline = true },
{ name = "Creator", value = ("@%s (%s)"):format(video.handle, video.display_name), inline = false },
{ name = "Reporter", value = ("@%s (%s)"):format(profile.handle, profile.display_name), inline = false },
{ name = "Caption", value = video.caption ~= "" and truncate_discord_text(video.caption, 240) or "—", inline = false },
},
footer = { text = "sky_phone FlipTok" },
}},
}
PerformHttpRequest(webhook, function(status)
local status_code = tonumber(status) or 0
if status_code < 200 or status_code >= 300 then
Bridge.Debug("warn", "[sky_phone] FlipTok Discord report webhook returned HTTP %s.", status_code, { always = true })
end
end, "POST", json.encode(payload), { ["Content-Type"] = "application/json" })
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
if not SkyPhone.AllowOperation(source, "fliptok:block", 20, 60) then
return { success = false, error = "rate_limited" }
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
local targets = Bridge.Database.Query("SELECT `id` FROM `sky_phone_fliptok_profiles` WHERE `id` = ? LIMIT 1", { target_id })
if not targets[1] then return { success = false, error = "profile_not_found" } end
if not 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 } },
}) then return { success = false, error = "request_failed" } end
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 = SkyPhoneLocales.Resolve(Config.Bridge.Locale).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
Bridge.Debug(notification_type == "error" and "error" or "info", 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")
Bridge.Debug(
"warn",
"[sky_phone] Player %d attempted to use the FlipTok verification command without an admin group.",
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)