Files
sky_phone-sky-systems/sky_phone/source/server/skypic.lua
T

2763 lines
124 KiB
Lua

Bridge.Database.AfterMigration("sky_phone", function()
local settings = Config.SkyPic or {}
local json_null = type(json) == "table" and json.null or nil
local spotlight_report_reasons = {}
for _, reason in ipairs(settings.SpotlightReportReasons or {
"spam", "harassment", "dangerous", "illegal", "other",
}) do
if type(reason) == "string" then
spotlight_report_reasons[reason] = true
end
end
local function nullable(value)
if value == nil then
return json_null
end
return value
end
local function limit(name, fallback)
local value = tonumber(settings[name])
if not value or value < 1 then
return fallback
end
return math.floor(value)
end
local function trim(value)
if type(value) ~= "string" then
return ""
end
return value:match("^%s*(.-)%s*$") or ""
end
local function text_length(value)
local ok, length = pcall(utf8.len, value)
if not ok then
return nil
end
return length
end
local function valid_text(value, minimum, maximum)
if type(value) ~= "string" then
return false
end
local length = text_length(value)
return length ~= nil 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
-- Keep the denormalized counters authoritative for strict, concurrent friend
-- limit enforcement and repair any drift left by older resource versions.
Bridge.Database.Query([[
UPDATE `sky_phone_skypic_profiles` profile
LEFT JOIN (
SELECT sides.`profile_id`, COUNT(*) AS `friend_count`
FROM (
SELECT friendship.`profile_a_id` AS `profile_id`
FROM `sky_phone_skypic_friendships` friendship
WHERE friendship.`status` = 'accepted'
UNION ALL
SELECT friendship.`profile_b_id` AS `profile_id`
FROM `sky_phone_skypic_friendships` friendship
WHERE friendship.`status` = 'accepted'
) sides
GROUP BY sides.`profile_id`
) counts ON counts.`profile_id` = profile.`id`
SET profile.`friend_count` = COALESCE(counts.`friend_count`, 0)
]], {})
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 SkyPic id.")
end
return rows[1].id
end
local function valid_id(value)
return type(value) == "string"
and value:match("^%x%x%x%x%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%x%x%x%x%x%x%x%x$") ~= nil
end
local function valid_integer(value, minimum, maximum)
if type(value) ~= "number" or value ~= math.floor(value) or value < minimum or value > maximum then
return nil
end
return value
end
local function normalize_handle(value)
local handle = trim(value):lower()
if #handle < limit("HandleMinLength", 3) or #handle > limit("HandleMaxLength", 24)
or handle:match("^[a-z0-9._]+$") == nil
then
return nil
end
return handle
end
local function normalize_color(value)
if type(value) ~= "string" or value:match("^#%x%x%x%x%x%x$") == nil then
return nil
end
return value:upper()
end
local function normalized_pair(first_id, second_id)
if first_id < second_id then
return first_id, second_id
end
return second_id, first_id
end
local function is_true(value)
return value == true or tonumber(value) == 1
end
local function summary_from_row(row, status_override, friendship_id_override)
if not row then
return nil
end
local friendship_id = friendship_id_override
if friendship_id == nil then
friendship_id = row.friendship_id
end
local summary = {
id = row.profile_id or row.id,
handle = row.handle,
displayName = row.display_name,
avatarUrl = nullable(row.avatar_url),
avatarSeed = tonumber(row.avatar_seed) or 1,
snapScore = tonumber(row.snap_score) or 0,
}
summary.friendshipId = nullable(friendship_id)
summary.friendshipStatus = status_override or row.friendship_status or "none"
return summary
end
local function profile_from_row(row)
local profile = summary_from_row(row, nil, nil)
profile.avatarMediaId = nullable(tonumber(row.avatar_media_id))
profile.bio = row.bio or ""
profile.storyPrivacy = row.story_privacy == "everyone" and "everyone" or "friends"
profile.showInQuickAdd = is_true(row.quick_add)
profile.allowStoryReplies = is_true(row.allow_story_replies)
profile.friendCount = tonumber(row.friend_count) or 0
return profile
end
local function require_profile(source)
local account, error_response = SkyPhone.RequireAccount(source)
if not account then
return nil, nil, error_response
end
local rows = Bridge.Database.Query([[
SELECT profile.`id` AS `profile_id`, profile.`handle`, profile.`display_name`, profile.`bio`,
profile.`avatar_media_id`, profile.`avatar_seed`, profile.`story_privacy`, profile.`quick_add`,
profile.`allow_story_replies`, profile.`snap_score`, avatar.`url` AS `avatar_url`,
(SELECT COUNT(*) FROM `sky_phone_skypic_friendships` friendship
WHERE friendship.`status` = 'accepted'
AND (friendship.`profile_a_id` = profile.`id` OR friendship.`profile_b_id` = profile.`id`)) AS `friend_count`
FROM `sky_phone_skypic_profiles` profile
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = profile.`avatar_media_id`
WHERE profile.`account_id` = ? AND profile.`status` = 'active'
LIMIT 1
]], { account.id })
if not rows[1] then
return nil, account, { success = false, error = "profile_required" }
end
return rows[1], account
end
local function optional_profile(source)
local account, error_response = SkyPhone.RequireAccount(source)
if not account then
return nil, nil, error_response
end
local rows = Bridge.Database.Query([[
SELECT profile.`id` AS `profile_id`, profile.`handle`, profile.`display_name`, profile.`bio`,
profile.`avatar_media_id`, profile.`avatar_seed`, profile.`story_privacy`, profile.`quick_add`,
profile.`allow_story_replies`, profile.`snap_score`, avatar.`url` AS `avatar_url`,
(SELECT COUNT(*) FROM `sky_phone_skypic_friendships` friendship
WHERE friendship.`status` = 'accepted'
AND (friendship.`profile_a_id` = profile.`id` OR friendship.`profile_b_id` = profile.`id`)) AS `friend_count`
FROM `sky_phone_skypic_profiles` profile
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = profile.`avatar_media_id`
WHERE profile.`account_id` = ? AND profile.`status` = 'active'
LIMIT 1
]], { account.id })
return rows[1], account
end
local function friendship_status(actor_id, requested_by_id, status)
if status == "accepted" then
return "friends"
end
if status == "pending" then
return requested_by_id == actor_id and "outgoing" or "incoming"
end
return "none"
end
local function active_friendship(profile_id, friendship_id)
local rows = Bridge.Database.Query([[
SELECT friendship.*,
CASE WHEN friendship.`profile_a_id` = ? THEN friendship.`profile_b_id` ELSE friendship.`profile_a_id` END AS `peer_id`
FROM `sky_phone_skypic_friendships` friendship
WHERE friendship.`id` = ? AND friendship.`status` = 'accepted'
AND (friendship.`profile_a_id` = ? OR friendship.`profile_b_id` = ?)
AND NOT EXISTS (
SELECT 1 FROM `sky_phone_skypic_blocks` block
WHERE (block.`blocker_profile_id` = friendship.`profile_a_id` AND block.`blocked_profile_id` = friendship.`profile_b_id`)
OR (block.`blocker_profile_id` = friendship.`profile_b_id` AND block.`blocked_profile_id` = friendship.`profile_a_id`)
)
LIMIT 1
]], { profile_id, friendship_id, profile_id, profile_id })
return rows[1]
end
local function friendship_with_target(profile_id, target_id, accepted_only)
local profile_a_id, profile_b_id = normalized_pair(profile_id, target_id)
local query = [[
SELECT * FROM `sky_phone_skypic_friendships`
WHERE `profile_a_id` = ? AND `profile_b_id` = ?
]]
if accepted_only then
query = query .. " AND `status` = 'accepted'"
end
query = query .. " LIMIT 1"
local rows = Bridge.Database.Query(query, { profile_a_id, profile_b_id })
return rows[1]
end
local function are_blocked(first_id, second_id)
local rows = Bridge.Database.Query([[
SELECT 1 AS `blocked` FROM `sky_phone_skypic_blocks`
WHERE (`blocker_profile_id` = ? AND `blocked_profile_id` = ?)
OR (`blocker_profile_id` = ? AND `blocked_profile_id` = ?)
LIMIT 1
]], { first_id, second_id, second_id, first_id })
return rows[1] ~= nil
end
local function load_summary(profile_id, viewer_id)
local rows = Bridge.Database.Query([[
SELECT profile.`id` AS `profile_id`, profile.`handle`, profile.`display_name`, profile.`avatar_seed`,
profile.`snap_score`, avatar.`url` AS `avatar_url`, friendship.`id` AS `friendship_id`,
friendship.`status`, friendship.`requested_by_id`
FROM `sky_phone_skypic_profiles` profile
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = profile.`avatar_media_id`
LEFT JOIN `sky_phone_skypic_friendships` friendship
ON friendship.`profile_a_id` = LEAST(?, profile.`id`)
AND friendship.`profile_b_id` = GREATEST(?, profile.`id`)
WHERE profile.`id` = ? AND profile.`status` = 'active'
LIMIT 1
]], { viewer_id, viewer_id, profile_id })
if not rows[1] then
return nil
end
return summary_from_row(
rows[1],
friendship_status(viewer_id, rows[1].requested_by_id, rows[1].status),
rows[1].friendship_id
)
end
local function notify_profile(recipient_profile_id, actor, kind, snap_id)
if recipient_profile_id == actor.profile_id then
return
end
local rows = Bridge.Database.Query([[
SELECT `account_id` FROM `sky_phone_skypic_profiles`
WHERE `id` = ? AND `status` = 'active' LIMIT 1
]], { recipient_profile_id })
local account_id = tonumber(rows[1] and rows[1].account_id)
if not account_id then
return
end
SkyPhone.NotifyAccountDevices(account_id, "sky_phone:skypic:new", {
kind = kind,
actor = actor.display_name,
profileId = actor.profile_id,
snapId = snap_id,
}, 'skypic')
end
local function safe_snap_from_row(row, viewer_id)
return {
id = row.id,
friendshipId = row.friendship_id,
type = row.message_type,
direction = row.sender_profile_id == viewer_id and "sent" or "received",
durationSeconds = tonumber(row.view_seconds) or limit("MinimumViewSeconds", 1),
allowReplay = is_true(row.allow_replay),
openedAt = nullable(row.opened_at),
replayedAt = nullable(row.replayed_at),
expiresAt = row.expires_at,
createdAt = row.created_at,
sender = summary_from_row({
profile_id = row.sender_profile_id,
handle = row.sender_handle,
display_name = row.sender_display_name,
avatar_seed = row.sender_avatar_seed,
avatar_url = row.sender_avatar_url,
snap_score = row.sender_snap_score,
friendship_status = "friends",
friendship_id = row.friendship_id,
}, row.sender_profile_id == viewer_id and "none" or "friends",
row.sender_profile_id == viewer_id and nil or row.friendship_id),
}
end
local function text_message_from_row(row, viewer_id)
return {
id = row.id,
friendshipId = row.friendship_id,
type = "text",
direction = row.sender_profile_id == viewer_id and "sent" or "received",
body = row.body or "",
readAt = nullable(row.read_at),
savedAt = nullable(row.saved_at),
createdAt = row.created_at,
}
end
local function empty_bootstrap()
return {
profile = json_null,
blockedProfiles = {},
friends = {},
requests = {},
conversations = {},
inbox = {},
stories = {},
suggestions = {},
unreadCount = 0,
}
end
local function list_friends(profile_id)
local rows = Bridge.Database.Query([[
SELECT friendship.`id` AS `friendship_id`,
CASE WHEN friendship.`streak_updated_on` IS NULL
OR friendship.`streak_updated_on` < DATE_SUB(UTC_DATE(), INTERVAL 1 DAY)
THEN 0 ELSE friendship.`streak_count` END AS `streak_count`,
friendship.`best_streak`,
friendship.`accepted_at`, friendship.`created_at`, peer.`id` AS `profile_id`, peer.`handle`,
peer.`display_name`, peer.`avatar_seed`, peer.`snap_score`, avatar.`url` AS `avatar_url`
FROM `sky_phone_skypic_friendships` friendship
JOIN `sky_phone_skypic_profiles` peer
ON peer.`id` = CASE WHEN friendship.`profile_a_id` = ?
THEN friendship.`profile_b_id` ELSE friendship.`profile_a_id` END
AND peer.`status` = 'active'
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = peer.`avatar_media_id`
WHERE friendship.`status` = 'accepted'
AND (friendship.`profile_a_id` = ? OR friendship.`profile_b_id` = ?)
AND NOT EXISTS (
SELECT 1 FROM `sky_phone_skypic_blocks` block
WHERE (block.`blocker_profile_id` = ? AND block.`blocked_profile_id` = peer.`id`)
OR (block.`blocker_profile_id` = peer.`id` AND block.`blocked_profile_id` = ?)
)
ORDER BY peer.`display_name`, peer.`handle`
LIMIT ?
]], { profile_id, profile_id, profile_id, profile_id, profile_id, limit("MaximumFriends", 500) })
local friends = {}
for _, row in ipairs(rows) do
friends[#friends + 1] = {
friendshipId = row.friendship_id,
profile = summary_from_row(row, "friends", row.friendship_id),
streakCount = tonumber(row.streak_count) or 0,
bestStreak = tonumber(row.best_streak) or 0,
createdAt = row.accepted_at or row.created_at,
}
end
return friends
end
local function list_requests(profile_id)
local rows = Bridge.Database.Query([[
SELECT friendship.`id` AS `friendship_id`, friendship.`requested_by_id`, friendship.`created_at`,
peer.`id` AS `profile_id`, peer.`handle`, peer.`display_name`, peer.`avatar_seed`,
peer.`snap_score`, avatar.`url` AS `avatar_url`
FROM `sky_phone_skypic_friendships` friendship
JOIN `sky_phone_skypic_profiles` peer
ON peer.`id` = CASE WHEN friendship.`profile_a_id` = ?
THEN friendship.`profile_b_id` ELSE friendship.`profile_a_id` END
AND peer.`status` = 'active'
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = peer.`avatar_media_id`
WHERE friendship.`status` = 'pending'
AND (friendship.`profile_a_id` = ? OR friendship.`profile_b_id` = ?)
AND NOT EXISTS (
SELECT 1 FROM `sky_phone_skypic_blocks` block
WHERE (block.`blocker_profile_id` = ? AND block.`blocked_profile_id` = peer.`id`)
OR (block.`blocker_profile_id` = peer.`id` AND block.`blocked_profile_id` = ?)
)
ORDER BY friendship.`created_at` DESC
LIMIT ?
]], { profile_id, profile_id, profile_id, profile_id, profile_id, limit("MaximumPendingRequests", 100) * 2 })
local requests = {}
for _, row in ipairs(rows) do
local direction = row.requested_by_id == profile_id and "outgoing" or "incoming"
requests[#requests + 1] = {
friendshipId = row.friendship_id,
direction = direction,
profile = summary_from_row(row, direction, row.friendship_id),
createdAt = row.created_at,
}
end
return requests
end
local function list_blocked_profiles(profile_id)
local rows = Bridge.Database.Query([[
SELECT blocked.`id` AS `profile_id`, blocked.`handle`,
blocked.`display_name`, blocked.`avatar_seed`,
blocked.`snap_score`, avatar.`url` AS `avatar_url`
FROM `sky_phone_skypic_blocks` block
JOIN `sky_phone_skypic_profiles` blocked
ON blocked.`id` = block.`blocked_profile_id`
AND blocked.`status` = 'active'
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = blocked.`avatar_media_id`
WHERE block.`blocker_profile_id` = ?
ORDER BY block.`created_at` DESC
]], { profile_id })
local profiles = {}
for _, row in ipairs(rows) do
profiles[#profiles + 1] = summary_from_row(row, "none", nil)
end
return profiles
end
local function list_profiles(profile_id, search, suggestions_only)
local params = { profile_id, profile_id, profile_id, profile_id, profile_id }
local filters = {}
if suggestions_only then
filters[#filters + 1] = "profile.`quick_add` = 1"
filters[#filters + 1] = "friendship.`id` IS NULL"
else
filters[#filters + 1] = "(profile.`handle` LIKE ? OR profile.`display_name` LIKE ?)"
local term = "%" .. search .. "%"
params[#params + 1] = term
params[#params + 1] = term
end
params[#params + 1] = suggestions_only and limit("SuggestionLimit", 20) or limit("SearchLimit", 30)
local rows = Bridge.Database.Query(([[
SELECT profile.`id` AS `profile_id`, profile.`handle`, profile.`display_name`, profile.`avatar_seed`,
profile.`snap_score`, avatar.`url` AS `avatar_url`, friendship.`id` AS `friendship_id`,
friendship.`status`, friendship.`requested_by_id`
FROM `sky_phone_skypic_profiles` profile
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = profile.`avatar_media_id`
LEFT JOIN `sky_phone_skypic_friendships` friendship
ON friendship.`profile_a_id` = LEAST(?, profile.`id`)
AND friendship.`profile_b_id` = GREATEST(?, profile.`id`)
WHERE profile.`status` = 'active' AND profile.`id` <> ?
AND NOT EXISTS (
SELECT 1 FROM `sky_phone_skypic_blocks` block
WHERE (block.`blocker_profile_id` = ? AND block.`blocked_profile_id` = profile.`id`)
OR (block.`blocker_profile_id` = profile.`id` AND block.`blocked_profile_id` = ?)
)
AND %s
ORDER BY %s
LIMIT ?
]]):format(
table.concat(filters, " AND "),
suggestions_only and "profile.`updated_at` DESC" or "(profile.`handle` = ?) DESC, profile.`handle`"
), suggestions_only and params or {
params[1], params[2], params[3], params[4], params[5], params[6], params[7], search, params[8]
})
local profiles = {}
for _, row in ipairs(rows) do
profiles[#profiles + 1] = summary_from_row(
row,
friendship_status(profile_id, row.requested_by_id, row.status),
row.friendship_id
)
end
return profiles
end
local function list_inbox(profile_id)
local rows = Bridge.Database.Query([[
SELECT message.`id`, message.`friendship_id`, message.`sender_profile_id`, message.`message_type`,
message.`view_seconds`, message.`allow_replay`, message.`opened_at`, message.`replayed_at`,
message.`expires_at`, message.`created_at`, sender.`handle` AS `sender_handle`,
sender.`display_name` AS `sender_display_name`, sender.`avatar_seed` AS `sender_avatar_seed`,
sender.`snap_score` AS `sender_snap_score`, avatar.`url` AS `sender_avatar_url`
FROM `sky_phone_skypic_messages` message
JOIN `sky_phone_skypic_friendships` friendship
ON friendship.`id` = message.`friendship_id` AND friendship.`status` = 'accepted'
JOIN `sky_phone_skypic_profiles` sender
ON sender.`id` = message.`sender_profile_id` AND sender.`status` = 'active'
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = sender.`avatar_media_id`
WHERE message.`recipient_profile_id` = ?
AND message.`message_type` IN ('snap_photo', 'snap_video')
AND message.`deleted_at` IS NULL AND message.`recipient_deleted_at` IS NULL
AND message.`expires_at` > CURRENT_TIMESTAMP(6)
AND NOT EXISTS (
SELECT 1 FROM `sky_phone_skypic_blocks` block
WHERE (block.`blocker_profile_id` = ? AND block.`blocked_profile_id` = message.`sender_profile_id`)
OR (block.`blocker_profile_id` = message.`sender_profile_id` AND block.`blocked_profile_id` = ?)
)
ORDER BY message.`created_at` DESC
LIMIT ?
]], { profile_id, profile_id, profile_id, limit("InboxPageSize", 100) })
local inbox = {}
for _, row in ipairs(rows) do
inbox[#inbox + 1] = safe_snap_from_row(row, profile_id)
end
return inbox
end
local function list_stories(profile_id, offset)
local rows = Bridge.Database.Query([[
SELECT story.`id`, story.`profile_id`, story.`view_seconds`, story.`expires_at`, story.`created_at`,
author.`handle`, author.`display_name`, author.`avatar_seed`, author.`snap_score`,
avatar.`url` AS `avatar_url`,
EXISTS(SELECT 1 FROM `sky_phone_skypic_story_views` view
WHERE view.`story_id` = story.`id` AND view.`viewer_profile_id` = ?) AS `seen`,
(SELECT COUNT(*) FROM `sky_phone_skypic_story_views` view
WHERE view.`story_id` = story.`id`) AS `view_count`,
friendship.`id` AS `friendship_id`
FROM `sky_phone_skypic_stories` story
JOIN `sky_phone_skypic_profiles` author
ON author.`id` = story.`profile_id` AND author.`status` = 'active'
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = author.`avatar_media_id`
LEFT JOIN `sky_phone_skypic_friendships` friendship
ON friendship.`profile_a_id` = LEAST(?, story.`profile_id`)
AND friendship.`profile_b_id` = GREATEST(?, story.`profile_id`)
AND friendship.`status` = 'accepted'
WHERE story.`status` = 'active' AND story.`expires_at` > CURRENT_TIMESTAMP(6)
AND NOT EXISTS (
SELECT 1 FROM `sky_phone_skypic_blocks` block
WHERE (block.`blocker_profile_id` = ? AND block.`blocked_profile_id` = story.`profile_id`)
OR (block.`blocker_profile_id` = story.`profile_id` AND block.`blocked_profile_id` = ?)
)
AND (story.`profile_id` = ? OR story.`privacy` = 'everyone' OR friendship.`id` IS NOT NULL)
ORDER BY (story.`profile_id` = ?) DESC, story.`created_at` DESC, story.`id` DESC
LIMIT ? OFFSET ?
]], {
profile_id, profile_id, profile_id, profile_id, profile_id, profile_id, profile_id,
limit("PageSize", 30), offset or 0,
})
local stories = {}
for _, row in ipairs(rows) do
stories[#stories + 1] = {
id = row.id,
author = summary_from_row(
row,
row.profile_id == profile_id and "none" or (row.friendship_id and "friends" or "none"),
row.friendship_id
),
durationSeconds = tonumber(row.view_seconds) or limit("MinimumViewSeconds", 1),
expiresAt = row.expires_at,
createdAt = row.created_at,
isOwner = row.profile_id == profile_id,
seen = is_true(row.seen),
viewCount = tonumber(row.view_count) or 0,
}
end
return stories
end
local function spotlight_from_row(row, profile_id)
if not row then
return nil
end
return {
id = row.id,
author = summary_from_row(
row,
row.profile_id == profile_id and "none" or (row.friendship_id and "friends" or "none"),
row.friendship_id
),
url = row.url,
mimeType = nullable(row.mime_type),
caption = row.caption or "",
textOverlay = row.overlay_text or "",
overlayColor = row.overlay_color,
isSponsored = row.kind == "sponsored",
adHeadline = row.ad_headline or "",
commentsEnabled = is_true(row.comments_enabled),
expiresAt = row.expires_at,
createdAt = row.created_at,
isOwner = row.profile_id == profile_id,
isLiked = is_true(row.is_liked),
isViewed = is_true(row.is_viewed),
likeCount = tonumber(row.like_count) or 0,
viewCount = tonumber(row.view_count) or 0,
commentCount = tonumber(row.comment_count) or 0,
}
end
local function list_spotlights(profile_id, offset, spotlight_id)
local exact_id = spotlight_id or ""
local rows = Bridge.Database.Query([[
SELECT spotlight.`id`, spotlight.`profile_id`, spotlight.`caption`,
spotlight.`overlay_text`, spotlight.`overlay_color`, spotlight.`kind`,
spotlight.`ad_headline`, spotlight.`comments_enabled`, spotlight.`expires_at`,
spotlight.`created_at`, media.`url`, media.`mime_type`,
author.`handle`, author.`display_name`, author.`avatar_seed`, author.`snap_score`,
avatar.`url` AS `avatar_url`, friendship.`id` AS `friendship_id`,
EXISTS(
SELECT 1 FROM `sky_phone_skypic_spotlight_likes` spotlight_like
WHERE spotlight_like.`spotlight_id` = spotlight.`id`
AND spotlight_like.`profile_id` = ?
) AS `is_liked`,
EXISTS(
SELECT 1 FROM `sky_phone_skypic_spotlight_views` spotlight_view
WHERE spotlight_view.`spotlight_id` = spotlight.`id`
AND spotlight_view.`viewer_profile_id` = ?
) AS `is_viewed`,
(SELECT COUNT(*) FROM `sky_phone_skypic_spotlight_likes` spotlight_like
WHERE spotlight_like.`spotlight_id` = spotlight.`id`) AS `like_count`,
(SELECT COUNT(*) FROM `sky_phone_skypic_spotlight_views` spotlight_view
WHERE spotlight_view.`spotlight_id` = spotlight.`id`) AS `view_count`,
(SELECT COUNT(*) FROM `sky_phone_skypic_spotlight_comments` comment
WHERE comment.`spotlight_id` = spotlight.`id`
AND comment.`status` = 'visible') AS `comment_count`
FROM `sky_phone_skypic_spotlights` spotlight
JOIN `sky_phone_skypic_profiles` author
ON author.`id` = spotlight.`profile_id` AND author.`status` = 'active'
JOIN `sky_phone_media` media
ON media.`id` = spotlight.`media_id` AND media.`media_type` = 'video'
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = author.`avatar_media_id`
LEFT JOIN `sky_phone_skypic_friendships` friendship
ON friendship.`profile_a_id` = LEAST(?, spotlight.`profile_id`)
AND friendship.`profile_b_id` = GREATEST(?, spotlight.`profile_id`)
AND friendship.`status` = 'accepted'
WHERE spotlight.`status` = 'active'
AND spotlight.`expires_at` > CURRENT_TIMESTAMP(6)
AND NOT EXISTS (
SELECT 1 FROM `sky_phone_skypic_blocks` block
WHERE (block.`blocker_profile_id` = ? AND block.`blocked_profile_id` = spotlight.`profile_id`)
OR (block.`blocker_profile_id` = spotlight.`profile_id` AND block.`blocked_profile_id` = ?)
)
AND NOT EXISTS (
SELECT 1 FROM `sky_phone_skypic_spotlight_reports` report
WHERE report.`spotlight_id` = spotlight.`id`
AND report.`reporter_profile_id` = ?
)
AND (? = '' OR spotlight.`id` = ?)
ORDER BY spotlight.`created_at` DESC, spotlight.`id` DESC
LIMIT ? OFFSET ?
]], {
profile_id, profile_id, profile_id, profile_id, profile_id, profile_id, profile_id,
exact_id, exact_id, spotlight_id and 1 or limit("SpotlightPageSize", 12), offset or 0,
})
local spotlights = {}
for _, row in ipairs(rows) do
spotlights[#spotlights + 1] = spotlight_from_row(row, profile_id)
end
return spotlights
end
local function accessible_spotlight(spotlight_id, profile_id)
local rows = Bridge.Database.Query([[
SELECT spotlight.`id`, spotlight.`profile_id`, spotlight.`comments_enabled`
FROM `sky_phone_skypic_spotlights` spotlight
JOIN `sky_phone_skypic_profiles` author
ON author.`id` = spotlight.`profile_id` AND author.`status` = 'active'
WHERE spotlight.`id` = ? AND spotlight.`status` = 'active'
AND spotlight.`expires_at` > CURRENT_TIMESTAMP(6)
AND NOT EXISTS (
SELECT 1 FROM `sky_phone_skypic_blocks` block
WHERE (block.`blocker_profile_id` = ? AND block.`blocked_profile_id` = spotlight.`profile_id`)
OR (block.`blocker_profile_id` = spotlight.`profile_id` AND block.`blocked_profile_id` = ?)
)
LIMIT 1
]], { spotlight_id, profile_id, profile_id })
return rows[1]
end
local function spotlight_comment_from_row(row, profile_id)
return {
id = row.id,
spotlightId = row.spotlight_id,
body = row.body,
createdAt = row.created_at,
isOwner = row.profile_id == profile_id,
author = summary_from_row(
row,
row.profile_id == profile_id and "none"
or friendship_status(profile_id, row.requested_by_id, row.friendship_status),
row.friendship_id
),
}
end
local function list_conversations(profile_id)
local rows = Bridge.Database.Query([[
SELECT friendship.`id` AS `friendship_id`,
CASE WHEN friendship.`streak_updated_on` IS NULL
OR friendship.`streak_updated_on` < DATE_SUB(UTC_DATE(), INTERVAL 1 DAY)
THEN 0 ELSE friendship.`streak_count` END AS `streak_count`,
friendship.`best_streak`,
peer.`id` AS `profile_id`, peer.`handle`, peer.`display_name`, peer.`avatar_seed`,
peer.`snap_score`, avatar.`url` AS `avatar_url`, message.`id` AS `last_id`,
message.`message_type` AS `last_type`,
CASE WHEN message.`message_type` = 'text' THEN message.`body` ELSE NULL END AS `last_body`,
message.`sender_profile_id` AS `last_sender_id`, message.`opened_at` AS `last_opened_at`,
message.`created_at` AS `last_created_at`,
(SELECT COUNT(*) FROM `sky_phone_skypic_messages` unread
WHERE unread.`friendship_id` = friendship.`id`
AND unread.`recipient_profile_id` = ? AND unread.`deleted_at` IS NULL
AND unread.`recipient_deleted_at` IS NULL
AND (unread.`expires_at` IS NULL OR unread.`expires_at` > CURRENT_TIMESTAMP(6))
AND ((unread.`message_type` = 'text' AND unread.`read_at` IS NULL)
OR (unread.`message_type` IN ('snap_photo','snap_video') AND unread.`opened_at` IS NULL))) AS `unread_count`
FROM `sky_phone_skypic_friendships` friendship
JOIN `sky_phone_skypic_profiles` peer
ON peer.`id` = CASE WHEN friendship.`profile_a_id` = ?
THEN friendship.`profile_b_id` ELSE friendship.`profile_a_id` END
AND peer.`status` = 'active'
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = peer.`avatar_media_id`
LEFT JOIN `sky_phone_skypic_messages` message ON message.`id` = (
SELECT candidate.`id` FROM `sky_phone_skypic_messages` candidate
WHERE candidate.`friendship_id` = friendship.`id` AND candidate.`deleted_at` IS NULL
AND ((candidate.`sender_profile_id` = ? AND candidate.`sender_deleted_at` IS NULL)
OR (candidate.`recipient_profile_id` = ? AND candidate.`recipient_deleted_at` IS NULL))
AND (candidate.`expires_at` IS NULL OR candidate.`expires_at` > CURRENT_TIMESTAMP(6))
ORDER BY candidate.`created_at` DESC, candidate.`id` DESC LIMIT 1
)
WHERE friendship.`status` = 'accepted'
AND (friendship.`profile_a_id` = ? OR friendship.`profile_b_id` = ?)
AND NOT EXISTS (
SELECT 1 FROM `sky_phone_skypic_blocks` block
WHERE (block.`blocker_profile_id` = ? AND block.`blocked_profile_id` = peer.`id`)
OR (block.`blocker_profile_id` = peer.`id` AND block.`blocked_profile_id` = ?)
)
ORDER BY message.`created_at` DESC, peer.`display_name`
LIMIT ?
]], {
profile_id, profile_id, profile_id, profile_id, profile_id, profile_id,
profile_id, profile_id, limit("MaximumFriends", 500),
})
local conversations = {}
for _, row in ipairs(rows) do
local last_item = nil
if row.last_id then
last_item = {
id = row.last_id,
type = row.last_type,
direction = row.last_sender_id == profile_id and "sent" or "received",
openedAt = nullable(row.last_opened_at),
createdAt = row.last_created_at,
}
if row.last_type == "text" then
last_item.body = row.last_body or ""
end
end
conversations[#conversations + 1] = {
friendshipId = row.friendship_id,
profile = summary_from_row(row, "friends", row.friendship_id),
streakCount = tonumber(row.streak_count) or 0,
bestStreak = tonumber(row.best_streak) or 0,
unreadCount = tonumber(row.unread_count) or 0,
lastItem = nullable(last_item),
}
end
return conversations
end
Bridge.Callbacks.Register("sky_phone:skypic:bootstrap", function(source)
if not SkyPhone.AllowOperation(source, "skypic_read", limit("ReadActionsPerMinute", 120), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = optional_profile(source)
if error_response then
return error_response
end
if not profile then
return { success = true, data = empty_bootstrap() }
end
local profile_id = profile.profile_id
local conversations = list_conversations(profile_id)
local unread_count = 0
for _, conversation in ipairs(conversations) do
unread_count = unread_count + conversation.unreadCount
end
return {
success = true,
data = {
profile = profile_from_row(profile),
blockedProfiles = list_blocked_profiles(profile_id),
friends = list_friends(profile_id),
requests = list_requests(profile_id),
conversations = conversations,
inbox = list_inbox(profile_id),
stories = list_stories(profile_id, 0),
suggestions = list_profiles(profile_id, "", true),
unreadCount = unread_count,
},
}
end)
Bridge.Callbacks.Register("sky_phone:skypic:create-profile", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_profile", limit("ProfileActionsPerMinute", 10), 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, limit("DisplayNameMaxLength", 40)) then
return { success = false, error = "invalid_display_name" }
end
local avatar_seed = data.avatarSeed == nil and math.random(1, 2147483647)
or valid_integer(data.avatarSeed, 1, 2147483647)
if not avatar_seed then
return { success = false, error = "invalid_avatar_seed" }
end
local avatar_media_id = nil
if data.avatarMediaId ~= nil then
avatar_media_id = valid_integer(data.avatarMediaId, 1, 9007199254740991)
if not avatar_media_id or not SkyPhoneMedia.ResolveOwnedMedia(source, avatar_media_id, "photo") then
return { success = false, error = "invalid_avatar" }
end
end
if Bridge.Database.Query(
"SELECT `id` FROM `sky_phone_skypic_profiles` WHERE `account_id` = ? LIMIT 1",
{ account.id }
)[1] then
return { success = false, error = "profile_exists" }
end
if Bridge.Database.Query(
"SELECT `id` FROM `sky_phone_skypic_profiles` WHERE `handle` = ? LIMIT 1",
{ handle }
)[1] then
return { success = false, error = "handle_taken" }
end
local profile_id = new_id()
local result = Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_skypic_profiles`
(`id`, `account_id`, `handle`, `display_name`, `avatar_media_id`, `avatar_seed`)
VALUES (?, ?, ?, ?, ?, ?)
]], { profile_id, account.id, handle, display_name, avatar_media_id, avatar_seed })
if affected_rows(result) ~= 1 then
return { success = false, error = "handle_taken" }
end
local created = require_profile(source)
return { success = true, data = profile_from_row(created) }
end)
Bridge.Callbacks.Register("sky_phone:skypic:delete-account", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_profile_delete", 3, 60) then
return { success = false, error = "rate_limited" }
end
local profile, account, error_response = require_profile(source)
if not profile then
return error_response
end
if type(data) ~= "table" or data.confirmed ~= true then
return { success = false, error = "confirmation_required" }
end
local affected_accounts = Bridge.Database.Query([[
SELECT DISTINCT affected.`account_id`
FROM (
SELECT peer.`account_id`
FROM `sky_phone_skypic_friendships` friendship
JOIN `sky_phone_skypic_profiles` peer
ON peer.`id` = CASE
WHEN friendship.`profile_a_id` = ? THEN friendship.`profile_b_id`
ELSE friendship.`profile_a_id`
END
AND peer.`status` = 'active'
WHERE friendship.`profile_a_id` = ? OR friendship.`profile_b_id` = ?
UNION ALL
SELECT peer.`account_id`
FROM `sky_phone_skypic_blocks` block
JOIN `sky_phone_skypic_profiles` peer
ON peer.`id` = CASE
WHEN block.`blocker_profile_id` = ? THEN block.`blocked_profile_id`
ELSE block.`blocker_profile_id`
END
AND peer.`status` = 'active'
WHERE block.`blocker_profile_id` = ? OR block.`blocked_profile_id` = ?
) affected
]], {
profile.profile_id, profile.profile_id, profile.profile_id,
profile.profile_id, profile.profile_id, profile.profile_id,
})
local ok = Bridge.Database.Transaction({
{
-- Keep the peers' denormalized limit counter correct before the
-- friendship rows disappear through the profile cascade.
query = [[
UPDATE `sky_phone_skypic_profiles` peer
JOIN `sky_phone_skypic_friendships` friendship
ON friendship.`status` = 'accepted'
AND (
(friendship.`profile_a_id` = ? AND peer.`id` = friendship.`profile_b_id`)
OR (friendship.`profile_b_id` = ? AND peer.`id` = friendship.`profile_a_id`)
)
SET peer.`friend_count` = IF(
peer.`friend_count` > 0, peer.`friend_count` - 1, 0
)
WHERE peer.`id` <> ?
]],
params = { profile.profile_id, profile.profile_id, profile.profile_id },
},
{
query = [[
DELETE FROM `sky_phone_skypic_profiles`
WHERE `id` = ? AND `account_id` = ? AND `status` = 'active'
]],
params = { profile.profile_id, account.id },
},
})
if not ok then
return { success = false, error = "request_failed" }
end
if Bridge.Database.Query(
"SELECT `id` FROM `sky_phone_skypic_profiles` WHERE `id` = ? LIMIT 1",
{ profile.profile_id }
)[1] then
return { success = false, error = "request_failed" }
end
-- The generic media library belongs to the Sky account, not SkyPic. The
-- cascading profile delete intentionally removes only SkyPic rows.
SkyPhone.NotifyAccountDevices(account.id, "sky_phone:skypic:changed", {
reason = "account_deleted",
profileId = profile.profile_id,
}, 'skypic')
for _, affected in ipairs(affected_accounts) do
local affected_account_id = tonumber(affected.account_id)
if affected_account_id and affected_account_id ~= tonumber(account.id) then
SkyPhone.NotifyAccountDevices(affected_account_id, "sky_phone:skypic:changed", {
reason = "account_deleted",
profileId = profile.profile_id,
}, 'skypic')
end
end
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:skypic:update-profile", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_profile", limit("ProfileActionsPerMinute", 10), 60) then
return { success = false, error = "rate_limited" }
end
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)
if not handle then
return { success = false, error = "invalid_handle" }
end
if not valid_text(display_name, 1, limit("DisplayNameMaxLength", 40)) then
return { success = false, error = "invalid_display_name" }
end
if not valid_text(bio, 0, limit("BioMaxLength", 160)) then
return { success = false, error = "invalid_bio" }
end
if data.storyPrivacy ~= "friends" and data.storyPrivacy ~= "everyone" then
return { success = false, error = "invalid_privacy" }
end
if type(data.showInQuickAdd) ~= "boolean" or type(data.allowStoryReplies) ~= "boolean" then
return { success = false, error = "invalid_request" }
end
local avatar_seed = data.avatarSeed == nil and tonumber(profile.avatar_seed)
or valid_integer(data.avatarSeed, 1, 2147483647)
if not avatar_seed then
return { success = false, error = "invalid_avatar_seed" }
end
local avatar_media_id = tonumber(profile.avatar_media_id)
local avatar_value = rawget(data, "avatarMediaId")
if avatar_value ~= nil then
local is_null = type(json) == "table" and json.null ~= nil and avatar_value == json.null
if is_null or avatar_value == false or avatar_value == 0 then
avatar_media_id = nil
else
avatar_media_id = valid_integer(avatar_value, 1, 9007199254740991)
if not avatar_media_id or not SkyPhoneMedia.ResolveOwnedMedia(source, avatar_media_id, "photo") then
return { success = false, error = "invalid_avatar" }
end
end
end
if Bridge.Database.Query([[
SELECT `id` FROM `sky_phone_skypic_profiles`
WHERE `handle` = ? AND `id` <> ? LIMIT 1
]], { handle, profile.profile_id })[1] then
return { success = false, error = "handle_taken" }
end
local result = Bridge.Database.Query([[
UPDATE IGNORE `sky_phone_skypic_profiles`
SET `handle` = ?, `display_name` = ?, `bio` = ?, `avatar_media_id` = ?, `avatar_seed` = ?,
`story_privacy` = ?, `quick_add` = ?, `allow_story_replies` = ?
WHERE `id` = ? AND `status` = 'active'
]], {
handle, display_name, bio, avatar_media_id, avatar_seed, data.storyPrivacy,
data.showInQuickAdd and 1 or 0, data.allowStoryReplies and 1 or 0, profile.profile_id,
})
if affected_rows(result) == 0 and handle ~= profile.handle then
return { success = false, error = "handle_taken" }
end
local updated = require_profile(source)
return { success = true, data = profile_from_row(updated) }
end)
Bridge.Callbacks.Register("sky_phone:skypic:search", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_search", limit("SearchActionsPerMinute", 30), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local query = trim(type(data) == "table" and data.query or nil)
if not valid_text(query, 1, 64) then
return { success = false, error = "invalid_request" }
end
return { success = true, data = list_profiles(profile.profile_id, query, false) }
end)
Bridge.Callbacks.Register("sky_phone:skypic:add-friend", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_friend", limit("FriendActionsPerMinute", 30), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local target_id = type(data) == "table" and data.profileId or nil
if not valid_id(target_id) or target_id == profile.profile_id then
return { success = false, error = "invalid_request" }
end
local target = load_summary(target_id, profile.profile_id)
if not target then
return { success = false, error = "profile_not_found" }
end
if are_blocked(profile.profile_id, target_id) then
return { success = false, error = "blocked" }
end
local existing = friendship_with_target(profile.profile_id, target_id, false)
if existing then
return { success = false, error = "friend_request_exists" }
end
local own_friends = Bridge.Database.Query([[
SELECT COUNT(*) AS `count` FROM `sky_phone_skypic_friendships`
WHERE `status` = 'accepted' AND (`profile_a_id` = ? OR `profile_b_id` = ?)
]], { profile.profile_id, profile.profile_id })
local target_friends = Bridge.Database.Query([[
SELECT COUNT(*) AS `count` FROM `sky_phone_skypic_friendships`
WHERE `status` = 'accepted' AND (`profile_a_id` = ? OR `profile_b_id` = ?)
]], { target_id, target_id })
if (tonumber(own_friends[1] and own_friends[1].count) or 0) >= limit("MaximumFriends", 500)
or (tonumber(target_friends[1] and target_friends[1].count) or 0) >= limit("MaximumFriends", 500)
then
return { success = false, error = "friend_limit_reached" }
end
local maximum_pending_requests = limit("MaximumPendingRequests", 100)
local own_pending = Bridge.Database.Query([[
SELECT COUNT(*) AS `count` FROM `sky_phone_skypic_friendships`
WHERE `status` = 'pending' AND (`profile_a_id` = ? OR `profile_b_id` = ?)
]], { profile.profile_id, profile.profile_id })
local target_pending = Bridge.Database.Query([[
SELECT COUNT(*) AS `count` FROM `sky_phone_skypic_friendships`
WHERE `status` = 'pending' AND (`profile_a_id` = ? OR `profile_b_id` = ?)
]], { target_id, target_id })
if (tonumber(own_pending[1] and own_pending[1].count) or 0) >= maximum_pending_requests
or (tonumber(target_pending[1] and target_pending[1].count) or 0) >= maximum_pending_requests
then
return { success = false, error = "request_limit_reached" }
end
local friendship_id = new_id()
local profile_a_id, profile_b_id = normalized_pair(profile.profile_id, target_id)
local ok = Bridge.Database.Transaction({
{
-- Serialize all cap-changing operations that involve either
-- profile. InnoDB keeps these row locks until the transaction ends.
query = [[
UPDATE `sky_phone_skypic_profiles`
SET `friend_count` = `friend_count`
WHERE `id` IN (?, ?)
ORDER BY `id`
]],
params = { profile_a_id, profile_b_id },
},
{
query = [[
INSERT IGNORE INTO `sky_phone_skypic_friendships`
(`id`, `profile_a_id`, `profile_b_id`, `requested_by_id`, `status`)
SELECT ?, ?, ?, ?, 'pending'
WHERE NOT EXISTS (
SELECT 1 FROM `sky_phone_skypic_blocks`
WHERE (`blocker_profile_id` = ? AND `blocked_profile_id` = ?)
OR (`blocker_profile_id` = ? AND `blocked_profile_id` = ?)
)
AND (
SELECT COUNT(*) FROM `sky_phone_skypic_friendships`
WHERE `status` = 'pending'
AND (`profile_a_id` = ? OR `profile_b_id` = ?)
) < ?
AND (
SELECT COUNT(*) FROM `sky_phone_skypic_friendships`
WHERE `status` = 'pending'
AND (`profile_a_id` = ? OR `profile_b_id` = ?)
) < ?
]],
params = {
friendship_id, profile_a_id, profile_b_id, profile.profile_id,
profile.profile_id, target_id, target_id, profile.profile_id,
profile.profile_id, profile.profile_id, maximum_pending_requests,
target_id, target_id, maximum_pending_requests,
},
},
})
if not ok then
return { success = false, error = "request_failed" }
end
local created_rows = Bridge.Database.Query([[
SELECT `created_at` FROM `sky_phone_skypic_friendships` WHERE `id` = ? LIMIT 1
]], { friendship_id })
if not created_rows[1] then
local pending_counts = Bridge.Database.Query([[
SELECT
(SELECT COUNT(*) FROM `sky_phone_skypic_friendships`
WHERE `status` = 'pending'
AND (`profile_a_id` = ? OR `profile_b_id` = ?)) AS `own_count`,
(SELECT COUNT(*) FROM `sky_phone_skypic_friendships`
WHERE `status` = 'pending'
AND (`profile_a_id` = ? OR `profile_b_id` = ?)) AS `target_count`
]], { profile.profile_id, profile.profile_id, target_id, target_id })
if (tonumber(pending_counts[1] and pending_counts[1].own_count) or 0) >= maximum_pending_requests
or (tonumber(pending_counts[1] and pending_counts[1].target_count) or 0) >= maximum_pending_requests
then
return { success = false, error = "request_limit_reached" }
end
if are_blocked(profile.profile_id, target_id) then
return { success = false, error = "blocked" }
end
if friendship_with_target(profile.profile_id, target_id, false) then
return { success = false, error = "friend_request_exists" }
end
return { success = false, error = "request_failed" }
end
notify_profile(target_id, profile, "friend_request", nil)
target.friendshipId = friendship_id
target.friendshipStatus = "outgoing"
return {
success = true,
data = {
friendshipId = friendship_id,
createdAt = created_rows[1].created_at,
direction = "outgoing",
profile = target,
},
}
end)
Bridge.Callbacks.Register("sky_phone:skypic:respond-friend", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_friend", limit("FriendActionsPerMinute", 30), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local friendship_id = type(data) == "table" and data.friendshipId or nil
if not valid_id(friendship_id) or type(data.accept) ~= "boolean" then
return { success = false, error = "invalid_request" }
end
if not data.accept then
local result = Bridge.Database.Query([[
DELETE FROM `sky_phone_skypic_friendships`
WHERE `id` = ? AND `status` = 'pending' AND `requested_by_id` <> ?
AND (`profile_a_id` = ? OR `profile_b_id` = ?)
]], { friendship_id, profile.profile_id, profile.profile_id, profile.profile_id })
return affected_rows(result) == 1 and { success = true }
or { success = false, error = "friendship_not_found" }
end
local maximum_friends = limit("MaximumFriends", 500)
local result = Bridge.Database.Query([[
UPDATE `sky_phone_skypic_friendships` friendship
JOIN `sky_phone_skypic_profiles` profile_a
ON profile_a.`id` = friendship.`profile_a_id`
JOIN `sky_phone_skypic_profiles` profile_b
ON profile_b.`id` = friendship.`profile_b_id`
SET friendship.`status` = 'accepted',
friendship.`accepted_at` = CURRENT_TIMESTAMP,
profile_a.`friend_count` = profile_a.`friend_count` + 1,
profile_b.`friend_count` = profile_b.`friend_count` + 1
WHERE friendship.`id` = ? AND friendship.`status` = 'pending'
AND friendship.`requested_by_id` <> ?
AND (friendship.`profile_a_id` = ? OR friendship.`profile_b_id` = ?)
AND profile_a.`friend_count` < ?
AND profile_b.`friend_count` < ?
AND NOT EXISTS (
SELECT 1 FROM `sky_phone_skypic_blocks` block
WHERE (block.`blocker_profile_id` = friendship.`profile_a_id` AND block.`blocked_profile_id` = friendship.`profile_b_id`)
OR (block.`blocker_profile_id` = friendship.`profile_b_id` AND block.`blocked_profile_id` = friendship.`profile_a_id`)
)
]], {
friendship_id, profile.profile_id, profile.profile_id, profile.profile_id,
maximum_friends, maximum_friends,
})
if affected_rows(result) == 0 then
local pending = Bridge.Database.Query([[
SELECT profile_a.`friend_count` AS `profile_a_friend_count`,
profile_b.`friend_count` AS `profile_b_friend_count`
FROM `sky_phone_skypic_friendships` friendship
JOIN `sky_phone_skypic_profiles` profile_a
ON profile_a.`id` = friendship.`profile_a_id`
JOIN `sky_phone_skypic_profiles` profile_b
ON profile_b.`id` = friendship.`profile_b_id`
WHERE friendship.`id` = ? AND friendship.`status` = 'pending'
AND friendship.`requested_by_id` <> ?
AND (friendship.`profile_a_id` = ? OR friendship.`profile_b_id` = ?)
LIMIT 1
]], { friendship_id, profile.profile_id, profile.profile_id, profile.profile_id })
if pending[1]
and ((tonumber(pending[1].profile_a_friend_count) or 0) >= maximum_friends
or (tonumber(pending[1].profile_b_friend_count) or 0) >= maximum_friends)
then
return { success = false, error = "friend_limit_reached" }
end
return { success = false, error = "friendship_not_found" }
end
local friendship = active_friendship(profile.profile_id, friendship_id)
if not friendship then
return { success = false, error = "request_failed" }
end
local peer = load_summary(friendship.peer_id, profile.profile_id)
notify_profile(friendship.peer_id, profile, "friend_accepted", nil)
return {
success = true,
data = {
friendshipId = friendship.id,
profile = peer,
streakCount = tonumber(friendship.streak_count) or 0,
bestStreak = tonumber(friendship.best_streak) or 0,
createdAt = friendship.accepted_at or friendship.created_at,
},
}
end)
Bridge.Callbacks.Register("sky_phone:skypic:remove-friend", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_friend", limit("FriendActionsPerMinute", 30), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local friendship_id = type(data) == "table" and data.friendshipId or nil
if not valid_id(friendship_id) then
return { success = false, error = "invalid_request" }
end
if not Bridge.Database.Query([[
SELECT `id` FROM `sky_phone_skypic_friendships`
WHERE `id` = ? AND (`profile_a_id` = ? OR `profile_b_id` = ?)
LIMIT 1
]], { friendship_id, profile.profile_id, profile.profile_id })[1] then
return { success = false, error = "friendship_not_found" }
end
local ok = Bridge.Database.Transaction({
{
query = [[
UPDATE `sky_phone_skypic_profiles` profile
JOIN `sky_phone_skypic_friendships` friendship
ON friendship.`id` = ? AND friendship.`status` = 'accepted'
AND (profile.`id` = friendship.`profile_a_id`
OR profile.`id` = friendship.`profile_b_id`)
SET profile.`friend_count` = IF(
profile.`friend_count` > 0,
profile.`friend_count` - 1,
0
)
WHERE friendship.`profile_a_id` = ? OR friendship.`profile_b_id` = ?
]],
params = { friendship_id, profile.profile_id, profile.profile_id },
},
{
query = [[
DELETE FROM `sky_phone_skypic_friendships`
WHERE `id` = ? AND (`profile_a_id` = ? OR `profile_b_id` = ?)
]],
params = { friendship_id, profile.profile_id, profile.profile_id },
},
})
return ok and { success = true } or { success = false, error = "request_failed" }
end)
Bridge.Callbacks.Register("sky_phone:skypic:block", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_friend", limit("FriendActionsPerMinute", 30), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local target_id = type(data) == "table" and data.profileId or nil
if not valid_id(target_id) or target_id == profile.profile_id or type(data.blocked) ~= "boolean" then
return { success = false, error = "invalid_request" }
end
if not load_summary(target_id, profile.profile_id) then
return { success = false, error = "profile_not_found" }
end
if not data.blocked then
Bridge.Database.Query([[
DELETE FROM `sky_phone_skypic_blocks`
WHERE `blocker_profile_id` = ? AND `blocked_profile_id` = ?
]], { profile.profile_id, target_id })
return { success = true }
end
local profile_a_id, profile_b_id = normalized_pair(profile.profile_id, target_id)
local ok = Bridge.Database.Transaction({
{
query = [[
INSERT IGNORE INTO `sky_phone_skypic_blocks`
(`blocker_profile_id`, `blocked_profile_id`) VALUES (?, ?)
]],
params = { profile.profile_id, target_id },
},
{
query = [[
UPDATE `sky_phone_skypic_profiles` profile
JOIN `sky_phone_skypic_friendships` friendship
ON friendship.`profile_a_id` = ? AND friendship.`profile_b_id` = ?
AND friendship.`status` = 'accepted'
AND (profile.`id` = friendship.`profile_a_id`
OR profile.`id` = friendship.`profile_b_id`)
SET profile.`friend_count` = IF(
profile.`friend_count` > 0,
profile.`friend_count` - 1,
0
)
]],
params = { profile_a_id, profile_b_id },
},
{
query = [[
DELETE FROM `sky_phone_skypic_friendships`
WHERE `profile_a_id` = ? AND `profile_b_id` = ?
]],
params = { profile_a_id, profile_b_id },
},
})
return ok and { success = true } or { success = false, error = "request_failed" }
end)
local function list_thread(profile_id, friendship_id)
local rows = Bridge.Database.Query([[
SELECT message.`id`, message.`friendship_id`, message.`sender_profile_id`, message.`message_type`,
message.`body`, message.`view_seconds`, message.`allow_replay`, message.`read_at`,
message.`opened_at`, message.`replayed_at`, message.`saved_at`, message.`expires_at`,
message.`created_at`, sender.`handle` AS `sender_handle`,
sender.`display_name` AS `sender_display_name`, sender.`avatar_seed` AS `sender_avatar_seed`,
sender.`snap_score` AS `sender_snap_score`, avatar.`url` AS `sender_avatar_url`
FROM `sky_phone_skypic_messages` message
JOIN `sky_phone_skypic_profiles` sender ON sender.`id` = message.`sender_profile_id`
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = sender.`avatar_media_id`
WHERE message.`friendship_id` = ? AND message.`deleted_at` IS NULL
AND ((message.`sender_profile_id` = ? AND message.`sender_deleted_at` IS NULL)
OR (message.`recipient_profile_id` = ? AND message.`recipient_deleted_at` IS NULL))
AND (message.`expires_at` IS NULL OR message.`expires_at` > CURRENT_TIMESTAMP(6))
ORDER BY message.`created_at` DESC, message.`id` DESC
LIMIT ?
]], { friendship_id, profile_id, profile_id, limit("ThreadPageSize", 200) })
local messages = {}
local snaps = {}
for index = #rows, 1, -1 do
local row = rows[index]
if row.message_type == "text" then
messages[#messages + 1] = text_message_from_row(row, profile_id)
else
snaps[#snaps + 1] = safe_snap_from_row(row, profile_id)
end
end
return { messages = messages, snaps = snaps }
end
local function editor_payload(source, data, allow_replay)
if type(data) ~= "table" then
return nil, "invalid_request"
end
if data.mediaType ~= "photo" and data.mediaType ~= "video" then
return nil, "invalid_media_type"
end
local media_id = valid_integer(data.mediaId, 1, 9007199254740991)
if not media_id then
return nil, "invalid_media"
end
local duration = valid_integer(
data.durationSeconds,
limit("MinimumViewSeconds", 1),
limit("MaximumViewSeconds", 10)
)
if not duration then
return nil, "invalid_duration"
end
local caption = trim(data.caption)
if not valid_text(caption, 0, limit("CaptionMaxLength", 160)) then
return nil, "invalid_caption"
end
local overlay_text = trim(data.textOverlay)
if not valid_text(overlay_text, 0, limit("OverlayTextMaxLength", 160)) then
return nil, "invalid_overlay"
end
local overlay_color = normalize_color(data.overlayColor)
if not overlay_color then
return nil, "invalid_color"
end
if allow_replay and type(data.allowReplay) ~= "boolean" then
return nil, "invalid_request"
end
local url, _, mime_type = SkyPhoneMedia.ResolveOwnedMedia(source, media_id, data.mediaType)
if not url then
return nil, "invalid_media"
end
return {
mediaId = media_id,
mediaType = data.mediaType,
messageType = data.mediaType == "photo" and "snap_photo" or "snap_video",
durationSeconds = duration,
caption = caption,
textOverlay = overlay_text,
overlayColor = overlay_color,
allowReplay = allow_replay and data.allowReplay or false,
mimeType = mime_type,
}
end
local function snap_editor_payloads(source, data)
if type(data) ~= "table" then
return nil, "invalid_request"
end
if data.mediaIds == nil then
local editor, editor_error = editor_payload(source, data, true)
return editor and { editor } or nil, editor_error
end
if data.mediaId ~= nil or (data.mediaType ~= nil and data.mediaType ~= "photo") then
return nil, "invalid_media"
end
if type(data.mediaIds) ~= "table" then
return nil, "invalid_media"
end
local count = #data.mediaIds
if count < 1 or count > limit("MaximumMediaPerSend", 10) then
return nil, "invalid_media"
end
local key_count = 0
for key in pairs(data.mediaIds) do
if type(key) ~= "number" or key ~= math.floor(key) or key < 1 or key > count then
return nil, "invalid_media"
end
key_count = key_count + 1
end
if key_count ~= count then
return nil, "invalid_media"
end
local seen = {}
local editors = {}
for index = 1, count do
local media_id = valid_integer(data.mediaIds[index], 1, 9007199254740991)
if not media_id or seen[media_id] then
return nil, "invalid_media"
end
seen[media_id] = true
local editor, editor_error = editor_payload(source, {
mediaId = media_id,
mediaType = "photo",
durationSeconds = data.durationSeconds,
caption = data.caption,
textOverlay = data.textOverlay,
overlayColor = data.overlayColor,
allowReplay = data.allowReplay,
}, true)
if not editor then
return nil, editor_error
end
editors[#editors + 1] = editor
end
return editors
end
Bridge.Callbacks.Register("sky_phone:skypic:thread", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_read", limit("ReadActionsPerMinute", 120), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local friendship_id = type(data) == "table" and data.friendshipId or nil
if not valid_id(friendship_id) then
return { success = false, error = "invalid_request" }
end
if not active_friendship(profile.profile_id, friendship_id) then
return { success = false, error = "friendship_not_found" }
end
return { success = true, data = list_thread(profile.profile_id, friendship_id) }
end)
Bridge.Callbacks.Register("sky_phone:skypic:send-message", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_message", limit("MessagesPerMinute", 30), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local friendship_id = type(data) == "table" and data.friendshipId or nil
local body = trim(type(data) == "table" and data.body or nil)
local story_id = type(data) == "table" and data.storyId or nil
if not valid_id(friendship_id) then
return { success = false, error = "invalid_request" }
end
if story_id ~= nil and not valid_id(story_id) then
return { success = false, error = "invalid_request" }
end
local body_length = text_length(body)
if not body_length or body_length < 1 then
return { success = false, error = "message_empty" }
end
if body_length > limit("MessageMaxLength", 2000) then
return { success = false, error = "message_too_long" }
end
local friendship = active_friendship(profile.profile_id, friendship_id)
if not friendship then
return { success = false, error = "friendship_not_found" }
end
local message_id = new_id()
local insert_query = [[
INSERT INTO `sky_phone_skypic_messages`
(`id`, `friendship_id`, `sender_profile_id`, `recipient_profile_id`, `message_type`, `body`)
SELECT ?, friendship.`id`, ?, ?, 'text', ?
FROM `sky_phone_skypic_friendships` friendship
WHERE friendship.`id` = ? AND friendship.`status` = 'accepted'
AND NOT EXISTS (
SELECT 1 FROM `sky_phone_skypic_blocks` block
WHERE (block.`blocker_profile_id` = ? AND block.`blocked_profile_id` = ?)
OR (block.`blocker_profile_id` = ? AND block.`blocked_profile_id` = ?)
)
]]
local insert_params = {
message_id, profile.profile_id, friendship.peer_id, body, friendship_id,
profile.profile_id, friendship.peer_id, friendship.peer_id, profile.profile_id,
}
if story_id ~= nil then
insert_query = [[
INSERT INTO `sky_phone_skypic_messages`
(`id`, `friendship_id`, `sender_profile_id`, `recipient_profile_id`, `message_type`, `body`)
SELECT ?, friendship.`id`, ?, ?, 'text', ?
FROM `sky_phone_skypic_friendships` friendship
JOIN `sky_phone_skypic_stories` story
ON story.`id` = ? AND story.`profile_id` = ?
AND story.`status` = 'active' AND story.`expires_at` > CURRENT_TIMESTAMP(6)
JOIN `sky_phone_skypic_profiles` author
ON author.`id` = story.`profile_id` AND author.`status` = 'active'
AND author.`allow_story_replies` = 1
WHERE friendship.`id` = ? AND friendship.`status` = 'accepted'
AND ((friendship.`profile_a_id` = ? AND friendship.`profile_b_id` = ?)
OR (friendship.`profile_a_id` = ? AND friendship.`profile_b_id` = ?))
AND NOT EXISTS (
SELECT 1 FROM `sky_phone_skypic_blocks` block
WHERE (block.`blocker_profile_id` = ? AND block.`blocked_profile_id` = ?)
OR (block.`blocker_profile_id` = ? AND block.`blocked_profile_id` = ?)
)
]]
insert_params = {
message_id, profile.profile_id, friendship.peer_id, body, story_id, friendship.peer_id,
friendship_id, profile.profile_id, friendship.peer_id, friendship.peer_id, profile.profile_id,
profile.profile_id, friendship.peer_id, friendship.peer_id, profile.profile_id,
}
end
local result = Bridge.Database.Query(insert_query, insert_params)
if affected_rows(result) ~= 1 then
return { success = false, error = story_id and "story_unavailable" or "blocked" }
end
local rows = Bridge.Database.Query([[
SELECT `id`, `friendship_id`, `sender_profile_id`, `body`, `read_at`, `saved_at`, `created_at`
FROM `sky_phone_skypic_messages` WHERE `id` = ? LIMIT 1
]], { message_id })
if not rows[1] then
return { success = false, error = "request_failed" }
end
notify_profile(friendship.peer_id, profile, story_id and "story_reply" or "message", nil)
return { success = true, data = text_message_from_row(rows[1], profile.profile_id) }
end)
Bridge.Callbacks.Register("sky_phone:skypic:mark-thread", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_open", limit("OpensPerMinute", 120), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local friendship_id = type(data) == "table" and data.friendshipId or nil
if not valid_id(friendship_id) then
return { success = false, error = "invalid_request" }
end
if not active_friendship(profile.profile_id, friendship_id) then
return { success = false, error = "friendship_not_found" }
end
Bridge.Database.Query([[
UPDATE `sky_phone_skypic_messages`
SET `read_at` = CURRENT_TIMESTAMP(6),
`expires_at` = CASE WHEN `saved_at` IS NULL
THEN DATE_ADD(CURRENT_TIMESTAMP(6), INTERVAL ? SECOND) ELSE NULL END
WHERE `friendship_id` = ? AND `recipient_profile_id` = ? AND `message_type` = 'text'
AND `read_at` IS NULL AND `deleted_at` IS NULL AND `recipient_deleted_at` IS NULL
]], { limit("TextAfterReadLifetimeSeconds", 86400), friendship_id, profile.profile_id })
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:skypic:save-message", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_message", limit("MessagesPerMinute", 30), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local message_id = type(data) == "table" and data.messageId or nil
if not valid_id(message_id) or type(data.saved) ~= "boolean" then
return { success = false, error = "invalid_request" }
end
local result = Bridge.Database.Query([[
UPDATE `sky_phone_skypic_messages` message
JOIN `sky_phone_skypic_friendships` friendship
ON friendship.`id` = message.`friendship_id` AND friendship.`status` = 'accepted'
SET message.`saved_at` = CASE WHEN ? = 1 THEN CURRENT_TIMESTAMP(6) ELSE NULL END,
message.`expires_at` = CASE
WHEN ? = 1 THEN NULL
WHEN message.`read_at` IS NOT NULL THEN DATE_ADD(CURRENT_TIMESTAMP(6), INTERVAL ? SECOND)
ELSE NULL
END
WHERE message.`id` = ? AND message.`message_type` = 'text' AND message.`deleted_at` IS NULL
AND ((message.`sender_profile_id` = ? AND message.`sender_deleted_at` IS NULL)
OR (message.`recipient_profile_id` = ? AND message.`recipient_deleted_at` IS NULL))
AND NOT EXISTS (
SELECT 1 FROM `sky_phone_skypic_blocks` block
WHERE (block.`blocker_profile_id` = message.`sender_profile_id` AND block.`blocked_profile_id` = message.`recipient_profile_id`)
OR (block.`blocker_profile_id` = message.`recipient_profile_id` AND block.`blocked_profile_id` = message.`sender_profile_id`)
)
]], {
data.saved and 1 or 0, data.saved and 1 or 0, limit("TextAfterReadLifetimeSeconds", 86400),
message_id, profile.profile_id, profile.profile_id,
})
if affected_rows(result) ~= 1 then
return { success = false, error = "message_not_found" }
end
local rows = Bridge.Database.Query([[
SELECT `id`, `friendship_id`, `sender_profile_id`, `body`, `read_at`, `saved_at`, `created_at`
FROM `sky_phone_skypic_messages` WHERE `id` = ? LIMIT 1
]], { message_id })
return rows[1] and { success = true, data = text_message_from_row(rows[1], profile.profile_id) }
or { success = false, error = "message_not_found" }
end)
Bridge.Callbacks.Register("sky_phone:skypic:delete-message", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_message", limit("MessagesPerMinute", 30), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local message_id = type(data) == "table" and data.messageId or nil
if not valid_id(message_id) or type(data.forEveryone) ~= "boolean" then
return { success = false, error = "invalid_request" }
end
local result
if data.forEveryone then
result = Bridge.Database.Query([[
UPDATE `sky_phone_skypic_messages`
SET `deleted_at` = CURRENT_TIMESTAMP(6)
WHERE `id` = ? AND `sender_profile_id` = ? AND `deleted_at` IS NULL
]], { message_id, profile.profile_id })
else
result = Bridge.Database.Query([[
UPDATE `sky_phone_skypic_messages`
SET `sender_deleted_at` = CASE WHEN `sender_profile_id` = ? THEN CURRENT_TIMESTAMP(6) ELSE `sender_deleted_at` END,
`recipient_deleted_at` = CASE WHEN `recipient_profile_id` = ? THEN CURRENT_TIMESTAMP(6) ELSE `recipient_deleted_at` END
WHERE `id` = ? AND `deleted_at` IS NULL
AND ((`sender_profile_id` = ? AND `sender_deleted_at` IS NULL)
OR (`recipient_profile_id` = ? AND `recipient_deleted_at` IS NULL))
]], {
profile.profile_id, profile.profile_id, message_id, profile.profile_id, profile.profile_id,
})
end
return affected_rows(result) == 1 and { success = true }
or { success = false, error = "message_not_found" }
end)
local function recipient_ids(value)
if type(value) ~= "table" then
return nil
end
local count = #value
if count < 1 or count > limit("MaximumSnapRecipients", 20) then
return nil
end
local key_count = 0
for key in pairs(value) do
if type(key) ~= "number" or key ~= math.floor(key) or key < 1 or key > count then
return nil
end
key_count = key_count + 1
end
if key_count ~= count then
return nil
end
local seen = {}
local ids = {}
for index = 1, count do
local profile_id = value[index]
if not valid_id(profile_id) or seen[profile_id] then
return nil
end
seen[profile_id] = true
ids[#ids + 1] = profile_id
end
return ids
end
local function load_snap_metadata(message_ids, viewer_id)
if #message_ids == 0 then
return {}
end
local placeholders = {}
local params = {}
for _, message_id in ipairs(message_ids) do
placeholders[#placeholders + 1] = "?"
params[#params + 1] = message_id
end
local rows = Bridge.Database.Query(([[
SELECT message.`id`, message.`friendship_id`, message.`sender_profile_id`, message.`recipient_profile_id`,
message.`message_type`, message.`view_seconds`, message.`allow_replay`, message.`opened_at`,
message.`replayed_at`, message.`expires_at`, message.`created_at`, sender.`handle` AS `sender_handle`,
sender.`display_name` AS `sender_display_name`, sender.`avatar_seed` AS `sender_avatar_seed`,
sender.`snap_score` AS `sender_snap_score`, avatar.`url` AS `sender_avatar_url`,
CASE WHEN friendship.`streak_updated_on` IS NULL
OR friendship.`streak_updated_on` < DATE_SUB(UTC_DATE(), INTERVAL 1 DAY)
THEN 0 ELSE friendship.`streak_count` END AS `streak_count`,
friendship.`best_streak`
FROM `sky_phone_skypic_messages` message
JOIN `sky_phone_skypic_friendships` friendship
ON friendship.`id` = message.`friendship_id` AND friendship.`status` = 'accepted'
JOIN `sky_phone_skypic_profiles` sender ON sender.`id` = message.`sender_profile_id`
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = sender.`avatar_media_id`
WHERE message.`id` IN (%s) AND message.`message_type` IN ('snap_photo','snap_video')
ORDER BY message.`created_at`, message.`id`
]]):format(table.concat(placeholders, ",")), params)
local snaps_by_id = {}
for _, row in ipairs(rows) do
local snap = safe_snap_from_row(row, viewer_id)
snap.recipientProfileId = row.recipient_profile_id
snap.streakCount = tonumber(row.streak_count) or 0
snap.bestStreak = tonumber(row.best_streak) or 0
snaps_by_id[row.id] = snap
end
local snaps = {}
for _, message_id in ipairs(message_ids) do
if snaps_by_id[message_id] then
snaps[#snaps + 1] = snaps_by_id[message_id]
end
end
return snaps
end
local function opened_snap_from_row(row)
return {
id = row.id,
url = row.url,
mediaType = row.message_type == "snap_photo" and "photo" or "video",
mimeType = nullable(row.mime_type),
caption = row.caption or "",
textOverlay = row.overlay_text or "",
overlayColor = row.overlay_color,
durationSeconds = tonumber(row.view_seconds) or limit("MinimumViewSeconds", 1),
allowReplay = is_true(row.allow_replay),
openedAt = row.opened_at,
replayedAt = nullable(row.replayed_at),
expiresAt = row.expires_at,
}
end
local function released_snap(message_id, recipient_profile_id)
local rows = Bridge.Database.Query([[
SELECT message.`id`, message.`sender_profile_id`, message.`message_type`, message.`caption`,
message.`overlay_text`, message.`overlay_color`, message.`view_seconds`, message.`allow_replay`,
message.`opened_at`, message.`replayed_at`, message.`expires_at`, media.`url`, media.`mime_type`
FROM `sky_phone_skypic_messages` message
JOIN `sky_phone_skypic_friendships` friendship
ON friendship.`id` = message.`friendship_id` AND friendship.`status` = 'accepted'
JOIN `sky_phone_media` media ON media.`id` = message.`media_id`
WHERE message.`id` = ? AND message.`recipient_profile_id` = ?
AND message.`message_type` IN ('snap_photo','snap_video')
AND message.`opened_at` IS NOT NULL AND message.`deleted_at` IS NULL
AND message.`recipient_deleted_at` IS NULL AND message.`expires_at` > CURRENT_TIMESTAMP(6)
AND NOT EXISTS (
SELECT 1 FROM `sky_phone_skypic_blocks` block
WHERE (block.`blocker_profile_id` = message.`sender_profile_id` AND block.`blocked_profile_id` = message.`recipient_profile_id`)
OR (block.`blocker_profile_id` = message.`recipient_profile_id` AND block.`blocked_profile_id` = message.`sender_profile_id`)
)
LIMIT 1
]], { message_id, recipient_profile_id })
return rows[1]
end
Bridge.Callbacks.Register("sky_phone:skypic:send-snap", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_snap", limit("SnapsPerMinute", 20), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local recipients = recipient_ids(type(data) == "table" and data.recipientIds or nil)
if not recipients then
return { success = false, error = "invalid_recipients" }
end
if type(data) == "table" and data.mediaIds ~= nil then
local raw_media_count = type(data.mediaIds) == "table" and #data.mediaIds or 0
if raw_media_count < 1 or raw_media_count > limit("MaximumMediaPerSend", 10) then
return { success = false, error = "invalid_media" }
end
end
local requested_media_count = type(data) == "table"
and type(data.mediaIds) == "table" and #data.mediaIds or 1
if requested_media_count * #recipients > limit("MaximumSnapMessagesPerSend", 40) then
return { success = false, error = "too_many_snaps" }
end
local editors, editor_error = snap_editor_payloads(source, data)
if not editors then
return { success = false, error = editor_error }
end
local message_count = #editors * #recipients
if message_count > limit("MaximumSnapMessagesPerSend", 40) then
return { success = false, error = "too_many_snaps" }
end
for _ = 2, #editors do
if not SkyPhone.AllowOperation(source, "skypic_snap", limit("SnapsPerMinute", 20), 60) then
return { success = false, error = "rate_limited" }
end
end
for _ = 1, message_count do
if not SkyPhone.AllowOperation(
source,
"skypic_snap_recipient",
limit("SnapRecipientsPerMinute", 120),
60
) then
return { success = false, error = "rate_limited" }
end
end
local entries = {}
for _, target_id in ipairs(recipients) do
if target_id == profile.profile_id then
return { success = false, error = "invalid_recipients" }
end
local friendship = friendship_with_target(profile.profile_id, target_id, true)
if not friendship then
return { success = false, error = "friendship_not_found" }
end
if are_blocked(profile.profile_id, target_id) then
return { success = false, error = "blocked" }
end
for _, editor in ipairs(editors) do
entries[#entries + 1] = {
id = new_id(),
targetId = target_id,
friendship = friendship,
editor = editor,
}
end
end
local statements = {}
for _, entry in ipairs(entries) do
local editor = entry.editor
statements[#statements + 1] = {
query = [[
INSERT INTO `sky_phone_skypic_messages`
(`id`, `friendship_id`, `sender_profile_id`, `recipient_profile_id`, `message_type`,
`caption`, `overlay_text`, `overlay_color`, `media_id`, `view_seconds`, `allow_replay`, `expires_at`)
SELECT ?, friendship.`id`, ?, ?, ?, ?, ?, ?, ?, ?, ?,
DATE_ADD(CURRENT_TIMESTAMP(6), INTERVAL ? SECOND)
FROM `sky_phone_skypic_friendships` friendship
WHERE friendship.`id` = ? AND friendship.`status` = 'accepted'
AND ((friendship.`profile_a_id` = ? AND friendship.`profile_b_id` = ?)
OR (friendship.`profile_a_id` = ? AND friendship.`profile_b_id` = ?))
AND NOT EXISTS (
SELECT 1 FROM `sky_phone_skypic_blocks` block
WHERE (block.`blocker_profile_id` = ? AND block.`blocked_profile_id` = ?)
OR (block.`blocker_profile_id` = ? AND block.`blocked_profile_id` = ?)
)
]],
params = {
entry.id, profile.profile_id, entry.targetId, editor.messageType, editor.caption,
editor.textOverlay, editor.overlayColor, editor.mediaId, editor.durationSeconds,
editor.allowReplay and 1 or 0, limit("UnopenedSnapLifetimeSeconds", 2592000),
entry.friendship.id, profile.profile_id, entry.targetId, entry.targetId, profile.profile_id,
profile.profile_id, entry.targetId, entry.targetId, profile.profile_id,
},
}
local directional_column = entry.friendship.profile_a_id == profile.profile_id
and "`profile_a_last_snap_on`" or "`profile_b_last_snap_on`"
statements[#statements + 1] = {
query = ([=[
UPDATE `sky_phone_skypic_friendships`
SET %s = UTC_DATE()
WHERE `id` = ? AND EXISTS (
SELECT 1 FROM `sky_phone_skypic_messages` message WHERE message.`id` = ?
)
]=]):format(directional_column),
params = { entry.friendship.id, entry.id },
}
statements[#statements + 1] = {
query = [[
UPDATE `sky_phone_skypic_friendships`
SET `best_streak` = GREATEST(`best_streak`,
CASE WHEN `streak_updated_on` = DATE_SUB(UTC_DATE(), INTERVAL 1 DAY)
THEN `streak_count` + 1 ELSE 1 END),
`streak_count` = CASE WHEN `streak_updated_on` = DATE_SUB(UTC_DATE(), INTERVAL 1 DAY)
THEN `streak_count` + 1 ELSE 1 END,
`streak_updated_on` = UTC_DATE()
WHERE `id` = ? AND `profile_a_last_snap_on` = UTC_DATE()
AND `profile_b_last_snap_on` = UTC_DATE()
AND (`streak_updated_on` IS NULL OR `streak_updated_on` < UTC_DATE())
]],
params = { entry.friendship.id },
}
end
local assertion_placeholders = {}
local assertion_params = { profile.profile_id }
for _, entry in ipairs(entries) do
assertion_placeholders[#assertion_placeholders + 1] = "?"
assertion_params[#assertion_params + 1] = entry.id
end
assertion_params[#assertion_params + 1] = #entries
statements[#statements + 1] = {
-- A mismatch deliberately attempts to duplicate the sender profile.
-- The primary-key error makes oxmysql roll the whole transaction back,
-- including earlier inserts and streak mutations.
query = (([[
INSERT INTO `sky_phone_skypic_profiles`
(`id`, `account_id`, `handle`, `display_name`, `avatar_seed`)
SELECT profile.`id`, profile.`account_id`, profile.`handle`,
profile.`display_name`, profile.`avatar_seed`
FROM `sky_phone_skypic_profiles` profile
WHERE profile.`id` = ?
AND (
SELECT COUNT(*) FROM `sky_phone_skypic_messages`
WHERE `id` IN (%s)
) <> ?
]]):format(table.concat(assertion_placeholders, ", "))),
params = assertion_params,
}
if not Bridge.Database.Transaction(statements) then
return { success = false, error = "request_failed" }
end
local message_ids = {}
for _, entry in ipairs(entries) do
message_ids[#message_ids + 1] = entry.id
end
local sent = load_snap_metadata(message_ids, profile.profile_id)
if #sent ~= #entries then
return { success = false, error = "request_failed" }
end
Bridge.Database.Query([[
UPDATE `sky_phone_skypic_profiles` SET `snap_score` = `snap_score` + ? WHERE `id` = ?
]], { #sent, profile.profile_id })
local sender_score = (tonumber(profile.snap_score) or 0) + #sent
local notified_targets = {}
for _, snap in ipairs(sent) do
local target_id = snap.recipientProfileId
snap.recipientProfileId = nil
snap.sender.snapScore = sender_score
if not notified_targets[target_id] then
notified_targets[target_id] = true
notify_profile(target_id, profile, "snap", snap.id)
end
end
return { success = true, data = sent }
end)
Bridge.Callbacks.Register("sky_phone:skypic:open-snap", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_open", limit("OpensPerMinute", 120), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local snap_id = type(data) == "table" and data.snapId or nil
if not valid_id(snap_id) then
return { success = false, error = "invalid_request" }
end
local result = Bridge.Database.Query([[
UPDATE `sky_phone_skypic_messages` message
JOIN `sky_phone_skypic_friendships` friendship
ON friendship.`id` = message.`friendship_id` AND friendship.`status` = 'accepted'
SET message.`opened_at` = CURRENT_TIMESTAMP(6), message.`read_at` = CURRENT_TIMESTAMP(6),
message.`expires_at` = TIMESTAMPADD(
SECOND,
IF(message.`allow_replay` = 1, ?, message.`view_seconds`),
CURRENT_TIMESTAMP(6)
)
WHERE message.`id` = ? AND message.`recipient_profile_id` = ?
AND message.`message_type` IN ('snap_photo','snap_video')
AND message.`opened_at` IS NULL AND message.`deleted_at` IS NULL
AND message.`recipient_deleted_at` IS NULL AND message.`expires_at` > CURRENT_TIMESTAMP(6)
AND NOT EXISTS (
SELECT 1 FROM `sky_phone_skypic_blocks` block
WHERE (block.`blocker_profile_id` = message.`sender_profile_id` AND block.`blocked_profile_id` = message.`recipient_profile_id`)
OR (block.`blocker_profile_id` = message.`recipient_profile_id` AND block.`blocked_profile_id` = message.`sender_profile_id`)
)
]], { limit("ReplayWindowSeconds", 300), snap_id, profile.profile_id })
if affected_rows(result) ~= 1 then
return { success = false, error = "snap_unavailable" }
end
local row = released_snap(snap_id, profile.profile_id)
if not row then
return { success = false, error = "snap_unavailable" }
end
Bridge.Database.Query([[
UPDATE `sky_phone_skypic_profiles` SET `snap_score` = `snap_score` + 1 WHERE `id` = ?
]], { profile.profile_id })
notify_profile(row.sender_profile_id, profile, "snap_opened", snap_id)
return { success = true, data = opened_snap_from_row(row) }
end)
Bridge.Callbacks.Register("sky_phone:skypic:replay-snap", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_open", limit("OpensPerMinute", 120), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local snap_id = type(data) == "table" and data.snapId or nil
if not valid_id(snap_id) then
return { success = false, error = "invalid_request" }
end
local result = Bridge.Database.Query([[
UPDATE `sky_phone_skypic_messages` message
JOIN `sky_phone_skypic_friendships` friendship
ON friendship.`id` = message.`friendship_id` AND friendship.`status` = 'accepted'
SET message.`replayed_at` = CURRENT_TIMESTAMP(6),
message.`expires_at` = DATE_ADD(CURRENT_TIMESTAMP(6), INTERVAL message.`view_seconds` SECOND)
WHERE message.`id` = ? AND message.`recipient_profile_id` = ?
AND message.`message_type` IN ('snap_photo','snap_video') AND message.`allow_replay` = 1
AND message.`opened_at` IS NOT NULL AND message.`replayed_at` IS NULL
AND message.`deleted_at` IS NULL AND message.`recipient_deleted_at` IS NULL
AND message.`expires_at` > CURRENT_TIMESTAMP(6)
AND NOT EXISTS (
SELECT 1 FROM `sky_phone_skypic_blocks` block
WHERE (block.`blocker_profile_id` = message.`sender_profile_id` AND block.`blocked_profile_id` = message.`recipient_profile_id`)
OR (block.`blocker_profile_id` = message.`recipient_profile_id` AND block.`blocked_profile_id` = message.`sender_profile_id`)
)
]], { snap_id, profile.profile_id })
if affected_rows(result) ~= 1 then
return { success = false, error = "replay_unavailable" }
end
local row = released_snap(snap_id, profile.profile_id)
return row and { success = true, data = opened_snap_from_row(row) }
or { success = false, error = "replay_unavailable" }
end)
local function own_story_metadata(story_id, profile_id)
local rows = Bridge.Database.Query([[
SELECT story.`id`, story.`profile_id`, story.`view_seconds`, story.`expires_at`, story.`created_at`,
author.`handle`, author.`display_name`, author.`avatar_seed`, author.`snap_score`,
avatar.`url` AS `avatar_url`,
EXISTS(SELECT 1 FROM `sky_phone_skypic_story_views` view
WHERE view.`story_id` = story.`id` AND view.`viewer_profile_id` = ?) AS `seen`,
(SELECT COUNT(*) FROM `sky_phone_skypic_story_views` view
WHERE view.`story_id` = story.`id`) AS `view_count`
FROM `sky_phone_skypic_stories` story
JOIN `sky_phone_skypic_profiles` author ON author.`id` = story.`profile_id`
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = author.`avatar_media_id`
WHERE story.`id` = ? AND story.`profile_id` = ? AND story.`status` = 'active'
AND story.`expires_at` > CURRENT_TIMESTAMP(6)
LIMIT 1
]], { profile_id, story_id, profile_id })
local row = rows[1]
if not row then
return nil
end
return {
id = row.id,
author = summary_from_row(row, "none", nil),
durationSeconds = tonumber(row.view_seconds) or limit("MinimumViewSeconds", 1),
expiresAt = row.expires_at,
createdAt = row.created_at,
isOwner = true,
seen = is_true(row.seen),
viewCount = tonumber(row.view_count) or 0,
}
end
Bridge.Callbacks.Register("sky_phone:skypic:publish-story", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_story", limit("StoriesPerMinute", 6), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local editor, editor_error = editor_payload(source, data, false)
if not editor then
return { success = false, error = editor_error }
end
local maximum_active_stories = limit("MaximumActiveStories", 50)
local counts = Bridge.Database.Query([[
SELECT COUNT(*) AS `count` FROM `sky_phone_skypic_stories`
WHERE `profile_id` = ? AND `status` = 'active' AND `expires_at` > CURRENT_TIMESTAMP(6)
]], { profile.profile_id })
if (tonumber(counts[1] and counts[1].count) or 0) >= maximum_active_stories then
return { success = false, error = "story_limit_reached" }
end
local story_id = new_id()
local ok = Bridge.Database.Transaction({
{
query = [[
UPDATE `sky_phone_skypic_profiles`
SET `friend_count` = `friend_count`
WHERE `id` = ?
]],
params = { profile.profile_id },
},
{
query = [[
INSERT INTO `sky_phone_skypic_stories`
(`id`, `profile_id`, `media_id`, `caption`, `overlay_text`, `overlay_color`,
`view_seconds`, `privacy`, `expires_at`)
SELECT ?, profile.`id`, ?, ?, ?, ?, ?, profile.`story_privacy`,
DATE_ADD(CURRENT_TIMESTAMP(6), INTERVAL ? SECOND)
FROM `sky_phone_skypic_profiles` profile
WHERE profile.`id` = ? AND profile.`status` = 'active'
AND (
SELECT COUNT(*) FROM `sky_phone_skypic_stories`
WHERE `profile_id` = profile.`id`
AND `status` = 'active'
AND `expires_at` > CURRENT_TIMESTAMP(6)
) < ?
]],
params = {
story_id, editor.mediaId, editor.caption, editor.textOverlay, editor.overlayColor,
editor.durationSeconds, limit("StoryLifetimeSeconds", 86400), profile.profile_id,
maximum_active_stories,
},
},
{
query = [[
UPDATE `sky_phone_skypic_profiles`
SET `snap_score` = `snap_score` + 1
WHERE `id` = ? AND EXISTS (
SELECT 1 FROM `sky_phone_skypic_stories` WHERE `id` = ?
)
]],
params = { profile.profile_id, story_id },
},
})
if not ok then
return { success = false, error = "request_failed" }
end
local story = own_story_metadata(story_id, profile.profile_id)
if not story then
local current_count = Bridge.Database.Query([[
SELECT COUNT(*) AS `count` FROM `sky_phone_skypic_stories`
WHERE `profile_id` = ? AND `status` = 'active'
AND `expires_at` > CURRENT_TIMESTAMP(6)
]], { profile.profile_id })
if (tonumber(current_count[1] and current_count[1].count) or 0) >= maximum_active_stories then
return { success = false, error = "story_limit_reached" }
end
return { success = false, error = "request_failed" }
end
return { success = true, data = story }
end)
Bridge.Callbacks.Register("sky_phone:skypic:stories", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_read", limit("ReadActionsPerMinute", 120), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local offset = type(data) == "table" and data.offset or 0
offset = valid_integer(offset, 0, 100000)
if not offset then
return { success = false, error = "invalid_request" }
end
return { success = true, data = list_stories(profile.profile_id, offset) }
end)
Bridge.Callbacks.Register("sky_phone:skypic:view-story", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_story_view", limit("StoryViewsPerMinute", 120), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local story_id = type(data) == "table" and data.storyId or nil
if not valid_id(story_id) then
return { success = false, error = "invalid_request" }
end
Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_skypic_story_views` (`story_id`, `viewer_profile_id`)
SELECT story.`id`, ?
FROM `sky_phone_skypic_stories` story
LEFT JOIN `sky_phone_skypic_friendships` friendship
ON friendship.`profile_a_id` = LEAST(?, story.`profile_id`)
AND friendship.`profile_b_id` = GREATEST(?, story.`profile_id`)
AND friendship.`status` = 'accepted'
WHERE story.`id` = ? AND story.`profile_id` <> ? AND story.`status` = 'active'
AND story.`expires_at` > CURRENT_TIMESTAMP(6)
AND NOT EXISTS (
SELECT 1 FROM `sky_phone_skypic_blocks` block
WHERE (block.`blocker_profile_id` = ? AND block.`blocked_profile_id` = story.`profile_id`)
OR (block.`blocker_profile_id` = story.`profile_id` AND block.`blocked_profile_id` = ?)
)
AND (story.`privacy` = 'everyone' OR friendship.`id` IS NOT NULL)
]], {
profile.profile_id, profile.profile_id, profile.profile_id, story_id, profile.profile_id,
profile.profile_id, profile.profile_id,
})
local rows = Bridge.Database.Query([[
SELECT story.`id`, story.`profile_id`, story.`caption`, story.`overlay_text`, story.`overlay_color`,
story.`view_seconds`, story.`expires_at`, media.`url`, media.`media_type`, media.`mime_type`,
author.`handle`, author.`display_name`, author.`avatar_seed`, author.`snap_score`,
author.`allow_story_replies`, friendship.`id` AS `friendship_id`,
avatar.`url` AS `avatar_url`, COALESCE(view.`viewed_at`, CURRENT_TIMESTAMP(6)) AS `viewed_at`
FROM `sky_phone_skypic_stories` story
JOIN `sky_phone_media` media ON media.`id` = story.`media_id`
JOIN `sky_phone_skypic_profiles` author
ON author.`id` = story.`profile_id` AND author.`status` = 'active'
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = author.`avatar_media_id`
LEFT JOIN `sky_phone_skypic_story_views` view
ON view.`story_id` = story.`id` AND view.`viewer_profile_id` = ?
LEFT JOIN `sky_phone_skypic_friendships` friendship
ON friendship.`profile_a_id` = LEAST(?, story.`profile_id`)
AND friendship.`profile_b_id` = GREATEST(?, story.`profile_id`)
AND friendship.`status` = 'accepted'
WHERE story.`id` = ? AND story.`status` = 'active' AND story.`expires_at` > CURRENT_TIMESTAMP(6)
AND NOT EXISTS (
SELECT 1 FROM `sky_phone_skypic_blocks` block
WHERE (block.`blocker_profile_id` = ? AND block.`blocked_profile_id` = story.`profile_id`)
OR (block.`blocker_profile_id` = story.`profile_id` AND block.`blocked_profile_id` = ?)
)
AND (story.`profile_id` = ? OR story.`privacy` = 'everyone' OR friendship.`id` IS NOT NULL)
LIMIT 1
]], {
profile.profile_id, profile.profile_id, profile.profile_id, story_id,
profile.profile_id, profile.profile_id, profile.profile_id,
})
local row = rows[1]
if not row then
return { success = false, error = "story_unavailable" }
end
return {
success = true,
data = {
id = row.id,
author = summary_from_row(
row,
row.profile_id == profile.profile_id and "none" or (row.friendship_id and "friends" or "none"),
row.friendship_id
),
url = row.url,
mediaType = row.media_type,
mimeType = nullable(row.mime_type),
caption = row.caption or "",
textOverlay = row.overlay_text or "",
overlayColor = row.overlay_color,
durationSeconds = tonumber(row.view_seconds) or limit("MinimumViewSeconds", 1),
expiresAt = row.expires_at,
viewedAt = row.viewed_at,
canReply = row.profile_id ~= profile.profile_id
and row.friendship_id ~= nil
and is_true(row.allow_story_replies),
},
}
end)
Bridge.Callbacks.Register("sky_phone:skypic:story-viewers", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_read", limit("ReadActionsPerMinute", 120), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local story_id = type(data) == "table" and data.storyId or nil
local offset = type(data) == "table" and (data.offset or 0) or 0
offset = valid_integer(offset, 0, 100000)
if not valid_id(story_id) or not offset then
return { success = false, error = "invalid_request" }
end
if not Bridge.Database.Query([[
SELECT `id` FROM `sky_phone_skypic_stories` WHERE `id` = ? AND `profile_id` = ? LIMIT 1
]], { story_id, profile.profile_id })[1] then
return { success = false, error = "not_authorized" }
end
local rows = Bridge.Database.Query([[
SELECT viewer.`id` AS `profile_id`, viewer.`handle`, viewer.`display_name`, viewer.`avatar_seed`,
viewer.`snap_score`, avatar.`url` AS `avatar_url`, story_view.`viewed_at`,
friendship.`id` AS `friendship_id`, friendship.`status`, friendship.`requested_by_id`
FROM `sky_phone_skypic_story_views` story_view
JOIN `sky_phone_skypic_profiles` viewer
ON viewer.`id` = story_view.`viewer_profile_id` AND viewer.`status` = 'active'
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = viewer.`avatar_media_id`
LEFT JOIN `sky_phone_skypic_friendships` friendship
ON friendship.`profile_a_id` = LEAST(?, viewer.`id`)
AND friendship.`profile_b_id` = GREATEST(?, viewer.`id`)
WHERE story_view.`story_id` = ?
ORDER BY story_view.`viewed_at` DESC
LIMIT ? OFFSET ?
]], {
profile.profile_id, profile.profile_id, story_id,
limit("PageSize", 30), offset,
})
local viewers = {}
for _, row in ipairs(rows) do
local viewer = summary_from_row(
row,
friendship_status(profile.profile_id, row.requested_by_id, row.status),
row.friendship_id
)
viewer.viewedAt = row.viewed_at
viewers[#viewers + 1] = viewer
end
return { success = true, data = viewers }
end)
Bridge.Callbacks.Register("sky_phone:skypic:remove-story", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_story", limit("StoriesPerMinute", 6), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local story_id = type(data) == "table" and data.storyId or nil
if not valid_id(story_id) then
return { success = false, error = "invalid_request" }
end
local result = Bridge.Database.Query([[
UPDATE `sky_phone_skypic_stories` SET `status` = 'removed'
WHERE `id` = ? AND `profile_id` = ? AND `status` = 'active'
]], { story_id, profile.profile_id })
return affected_rows(result) == 1 and { success = true }
or { success = false, error = "story_unavailable" }
end)
Bridge.Callbacks.Register("sky_phone:skypic:spotlight-feed", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_read", limit("ReadActionsPerMinute", 120), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local offset = type(data) == "table" and (data.offset or 0) or 0
offset = valid_integer(offset, 0, 100000)
if not offset then
return { success = false, error = "invalid_request" }
end
return { success = true, data = list_spotlights(profile.profile_id, offset) }
end)
Bridge.Callbacks.Register("sky_phone:skypic:publish-spotlight", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_spotlight", limit("SpotlightsPerMinute", 4), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local editor, editor_error = editor_payload(source, data, false)
if not editor then
return { success = false, error = editor_error }
end
if editor.mediaType ~= "video" then
return { success = false, error = "invalid_media_type" }
end
if type(data.isSponsored) ~= "boolean" or type(data.commentsEnabled) ~= "boolean" then
return { success = false, error = "invalid_request" }
end
local sponsored = data.isSponsored
if sponsored and settings.AllowSponsoredSpotlights ~= true then
return { success = false, error = "ads_disabled" }
end
local headline = trim(data.adHeadline)
if sponsored then
if not valid_text(headline, 3, limit("SpotlightAdHeadlineMaxLength", 80)) then
return { success = false, error = "invalid_ad_headline" }
end
elseif headline ~= "" then
return { success = false, error = "invalid_ad_headline" }
end
local maximum_active = limit("MaximumActiveSpotlights", 20)
local maximum_sponsored = limit("MaximumActiveSponsoredSpotlights", 3)
local spotlight_id = new_id()
local ok = Bridge.Database.Transaction({
{
query = [[
UPDATE `sky_phone_skypic_profiles`
SET `friend_count` = `friend_count`
WHERE `id` = ?
]],
params = { profile.profile_id },
},
{
query = [[
INSERT INTO `sky_phone_skypic_spotlights`
(`id`, `profile_id`, `media_id`, `caption`, `overlay_text`,
`overlay_color`, `kind`, `ad_headline`, `comments_enabled`, `expires_at`)
SELECT ?, profile.`id`, ?, ?, ?, ?, ?, ?, ?,
DATE_ADD(CURRENT_TIMESTAMP(6), INTERVAL ? SECOND)
FROM `sky_phone_skypic_profiles` profile
WHERE profile.`id` = ? AND profile.`status` = 'active'
AND (
SELECT COUNT(*) FROM `sky_phone_skypic_spotlights`
WHERE `profile_id` = profile.`id` AND `status` = 'active'
AND `expires_at` > CURRENT_TIMESTAMP(6)
) < ?
AND (? = 'organic' OR (
SELECT COUNT(*) FROM `sky_phone_skypic_spotlights`
WHERE `profile_id` = profile.`id` AND `kind` = 'sponsored'
AND `status` = 'active' AND `expires_at` > CURRENT_TIMESTAMP(6)
) < ?)
]],
params = {
spotlight_id, editor.mediaId, editor.caption, editor.textOverlay, editor.overlayColor,
sponsored and "sponsored" or "organic", headline, data.commentsEnabled and 1 or 0,
sponsored and limit("SponsoredSpotlightLifetimeSeconds", 604800)
or limit("SpotlightLifetimeSeconds", 2592000),
profile.profile_id, maximum_active, sponsored and "sponsored" or "organic",
maximum_sponsored,
},
},
})
if not ok then
return { success = false, error = "request_failed" }
end
local spotlight = list_spotlights(profile.profile_id, 0, spotlight_id)[1]
if not spotlight then
local counts = Bridge.Database.Query([[
SELECT COUNT(*) AS `total`,
SUM(CASE WHEN `kind` = 'sponsored' THEN 1 ELSE 0 END) AS `sponsored`
FROM `sky_phone_skypic_spotlights`
WHERE `profile_id` = ? AND `status` = 'active'
AND `expires_at` > CURRENT_TIMESTAMP(6)
]], { profile.profile_id })
local total = tonumber(counts[1] and counts[1].total) or 0
local sponsored_count = tonumber(counts[1] and counts[1].sponsored) or 0
if total >= maximum_active then
return { success = false, error = "spotlight_limit_reached" }
end
if sponsored and sponsored_count >= maximum_sponsored then
return { success = false, error = "sponsored_limit_reached" }
end
return { success = false, error = "request_failed" }
end
return { success = true, data = spotlight }
end)
Bridge.Callbacks.Register("sky_phone:skypic:view-spotlight", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_spotlight_view", limit("SpotlightViewsPerMinute", 120), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local spotlight_id = type(data) == "table" and data.spotlightId or nil
if not valid_id(spotlight_id) or not accessible_spotlight(spotlight_id, profile.profile_id) then
return { success = false, error = "spotlight_unavailable" }
end
Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_skypic_spotlight_views`
(`spotlight_id`, `viewer_profile_id`)
SELECT `id`, ? FROM `sky_phone_skypic_spotlights`
WHERE `id` = ? AND `profile_id` <> ? AND `status` = 'active'
AND `expires_at` > CURRENT_TIMESTAMP(6)
]], { profile.profile_id, spotlight_id, profile.profile_id })
local rows = Bridge.Database.Query([[
SELECT COUNT(*) AS `count` FROM `sky_phone_skypic_spotlight_views`
WHERE `spotlight_id` = ?
]], { spotlight_id })
return { success = true, data = { viewCount = tonumber(rows[1] and rows[1].count) or 0 } }
end)
Bridge.Callbacks.Register("sky_phone:skypic:like-spotlight", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_spotlight_reaction", limit("SpotlightReactionsPerMinute", 120), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local spotlight_id = type(data) == "table" and data.spotlightId or nil
local active = type(data) == "table" and data.active or nil
if not valid_id(spotlight_id) or type(active) ~= "boolean"
or not accessible_spotlight(spotlight_id, profile.profile_id)
then
return { success = false, error = "spotlight_unavailable" }
end
if active then
Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_skypic_spotlight_likes`
(`spotlight_id`, `profile_id`) VALUES (?, ?)
]], { spotlight_id, profile.profile_id })
else
Bridge.Database.Query([[
DELETE FROM `sky_phone_skypic_spotlight_likes`
WHERE `spotlight_id` = ? AND `profile_id` = ?
]], { spotlight_id, profile.profile_id })
end
local rows = Bridge.Database.Query([[
SELECT COUNT(*) AS `count` FROM `sky_phone_skypic_spotlight_likes`
WHERE `spotlight_id` = ?
]], { spotlight_id })
return {
success = true,
data = { active = active, likeCount = tonumber(rows[1] and rows[1].count) or 0 },
}
end)
Bridge.Callbacks.Register("sky_phone:skypic:spotlight-comments", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_read", limit("ReadActionsPerMinute", 120), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local spotlight_id = type(data) == "table" and data.spotlightId or nil
local offset = type(data) == "table" and (data.offset or 0) or 0
offset = valid_integer(offset, 0, 100000)
if not valid_id(spotlight_id) or not offset
or not accessible_spotlight(spotlight_id, profile.profile_id)
then
return { success = false, error = "spotlight_unavailable" }
end
local rows = Bridge.Database.Query([[
SELECT comment.`id`, comment.`spotlight_id`, comment.`profile_id`, comment.`body`,
comment.`created_at`, author.`handle`, author.`display_name`, author.`avatar_seed`,
author.`snap_score`, avatar.`url` AS `avatar_url`,
friendship.`id` AS `friendship_id`, friendship.`status` AS `friendship_status`,
friendship.`requested_by_id`
FROM `sky_phone_skypic_spotlight_comments` comment
JOIN `sky_phone_skypic_profiles` author
ON author.`id` = comment.`profile_id` AND author.`status` = 'active'
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = author.`avatar_media_id`
LEFT JOIN `sky_phone_skypic_friendships` friendship
ON friendship.`profile_a_id` = LEAST(?, comment.`profile_id`)
AND friendship.`profile_b_id` = GREATEST(?, comment.`profile_id`)
WHERE comment.`spotlight_id` = ? AND comment.`status` = 'visible'
AND NOT EXISTS (
SELECT 1 FROM `sky_phone_skypic_blocks` block
WHERE (block.`blocker_profile_id` = ? AND block.`blocked_profile_id` = comment.`profile_id`)
OR (block.`blocker_profile_id` = comment.`profile_id` AND block.`blocked_profile_id` = ?)
)
ORDER BY comment.`created_at` DESC, comment.`id` DESC
LIMIT ? OFFSET ?
]], {
profile.profile_id, profile.profile_id, spotlight_id,
profile.profile_id, profile.profile_id,
limit("SpotlightCommentPageSize", 50), offset,
})
local comments = {}
for _, row in ipairs(rows) do
comments[#comments + 1] = spotlight_comment_from_row(row, profile.profile_id)
end
return { success = true, data = comments }
end)
Bridge.Callbacks.Register("sky_phone:skypic:comment-spotlight", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_spotlight_comment", limit("SpotlightCommentsPerMinute", 20), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local spotlight_id = type(data) == "table" and data.spotlightId or nil
local body = type(data) == "table" and trim(data.body) or ""
if not valid_id(spotlight_id)
or not valid_text(body, 1, limit("SpotlightCommentMaxLength", 500))
then
return { success = false, error = "invalid_comment" }
end
local spotlight = accessible_spotlight(spotlight_id, profile.profile_id)
if not spotlight then
return { success = false, error = "spotlight_unavailable" }
end
if not is_true(spotlight.comments_enabled) then
return { success = false, error = "comments_disabled" }
end
local comment_id = new_id()
Bridge.Database.Query([[
INSERT INTO `sky_phone_skypic_spotlight_comments`
(`id`, `spotlight_id`, `profile_id`, `body`)
SELECT ?, spotlight.`id`, ?, ?
FROM `sky_phone_skypic_spotlights` spotlight
WHERE spotlight.`id` = ? AND spotlight.`status` = 'active'
AND spotlight.`comments_enabled` = 1
AND spotlight.`expires_at` > CURRENT_TIMESTAMP(6)
]], { comment_id, profile.profile_id, body, spotlight_id })
local rows = Bridge.Database.Query([[
SELECT comment.`id`, comment.`spotlight_id`, comment.`profile_id`, comment.`body`,
comment.`created_at`, author.`handle`, author.`display_name`, author.`avatar_seed`,
author.`snap_score`, avatar.`url` AS `avatar_url`
FROM `sky_phone_skypic_spotlight_comments` comment
JOIN `sky_phone_skypic_profiles` author ON author.`id` = comment.`profile_id`
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = author.`avatar_media_id`
WHERE comment.`id` = ? AND comment.`profile_id` = ?
LIMIT 1
]], { comment_id, profile.profile_id })
if not rows[1] then
return { success = false, error = "spotlight_unavailable" }
end
return { success = true, data = spotlight_comment_from_row(rows[1], profile.profile_id) }
end)
Bridge.Callbacks.Register("sky_phone:skypic:delete-spotlight-comment", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_spotlight_comment", limit("SpotlightCommentsPerMinute", 20), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local comment_id = type(data) == "table" and data.commentId or nil
if not valid_id(comment_id) then
return { success = false, error = "invalid_request" }
end
local result = Bridge.Database.Query([[
UPDATE `sky_phone_skypic_spotlight_comments` comment
JOIN `sky_phone_skypic_spotlights` spotlight
ON spotlight.`id` = comment.`spotlight_id`
SET comment.`status` = 'removed'
WHERE comment.`id` = ? AND comment.`status` = 'visible'
AND (comment.`profile_id` = ? OR spotlight.`profile_id` = ?)
]], { comment_id, profile.profile_id, profile.profile_id })
return affected_rows(result) == 1 and { success = true }
or { success = false, error = "comment_not_found" }
end)
Bridge.Callbacks.Register("sky_phone:skypic:remove-spotlight", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_spotlight", limit("SpotlightsPerMinute", 4), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local spotlight_id = type(data) == "table" and data.spotlightId or nil
if not valid_id(spotlight_id) then
return { success = false, error = "invalid_request" }
end
local result = Bridge.Database.Query([[
UPDATE `sky_phone_skypic_spotlights` SET `status` = 'removed'
WHERE `id` = ? AND `profile_id` = ? AND `status` = 'active'
]], { spotlight_id, profile.profile_id })
return affected_rows(result) == 1 and { success = true }
or { success = false, error = "spotlight_unavailable" }
end)
Bridge.Callbacks.Register("sky_phone:skypic:report-spotlight", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_spotlight_report", limit("SpotlightReportsPerMinute", 10), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local spotlight_id = type(data) == "table" and data.spotlightId or nil
local reason = type(data) == "table" and trim(data.reason):lower() or ""
local details = type(data) == "table" and trim(data.details) or ""
if not valid_id(spotlight_id) or not spotlight_report_reasons[reason]
or not valid_text(details, 0, 500)
then
return { success = false, error = "invalid_report" }
end
local spotlight = accessible_spotlight(spotlight_id, profile.profile_id)
if not spotlight then
return { success = false, error = "spotlight_unavailable" }
end
if spotlight.profile_id == profile.profile_id then
return { success = false, error = "not_authorized" }
end
Bridge.Database.Query([[
INSERT INTO `sky_phone_skypic_spotlight_reports`
(`spotlight_id`, `reporter_profile_id`, `reason`, `details`)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
`reason` = VALUES(`reason`), `details` = VALUES(`details`),
`status` = 'pending', `created_at` = CURRENT_TIMESTAMP(6)
]], { spotlight_id, profile.profile_id, reason, details })
return { success = true }
end)
CreateThread(function()
while true do
Wait(limit("CleanupIntervalSeconds", 45) * 1000)
Bridge.Database.Query([[
DELETE FROM `sky_phone_skypic_messages`
WHERE `deleted_at` IS NOT NULL
OR (`sender_deleted_at` IS NOT NULL AND `recipient_deleted_at` IS NOT NULL)
OR (`message_type` IN ('snap_photo','snap_video')
AND `expires_at` IS NOT NULL AND `expires_at` <= CURRENT_TIMESTAMP(6))
OR (`message_type` = 'text' AND `saved_at` IS NULL
AND `expires_at` IS NOT NULL AND `expires_at` <= CURRENT_TIMESTAMP(6))
]], {})
Bridge.Database.Query([[
DELETE FROM `sky_phone_skypic_stories`
WHERE `status` = 'removed' OR `expires_at` <= CURRENT_TIMESTAMP(6)
]], {})
Bridge.Database.Query([[
DELETE FROM `sky_phone_skypic_spotlights`
WHERE `status` = 'removed' OR `expires_at` <= CURRENT_TIMESTAMP(6)
]], {})
Bridge.Database.Query([[
UPDATE `sky_phone_skypic_friendships`
SET `streak_count` = 0
WHERE `status` = 'accepted' AND `streak_count` > 0
AND (`streak_updated_on` IS NULL
OR `streak_updated_on` < DATE_SUB(UTC_DATE(), INTERVAL 1 DAY))
]], {})
end
end)
end)