mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 00:01:29 +00:00
ENH - overhaul Local Pages profiles
This commit is contained in:
@@ -360,6 +360,9 @@ Config.Marketplace = {
|
||||
Config.LocalPages = {
|
||||
PageSize = 20,
|
||||
MaxImages = 6,
|
||||
ProfileHandleMinLength = 3,
|
||||
ProfileHandleMaxLength = 24,
|
||||
ProfileBioMaxLength = 160,
|
||||
TitleMinLength = 5,
|
||||
TitleMaxLength = 80,
|
||||
BodyMinLength = 10,
|
||||
|
||||
@@ -1245,13 +1245,25 @@ Locales["en"] = {
|
||||
discover = "Discover", create = "Post", profile = "Profile", post = "Post", posts = "posts",
|
||||
noPosts = "Nothing here yet", noPostsBody = "Be the first to share something with the city.", noPhoto = "No photo attached",
|
||||
signInTitle = "Your Local Pages profile", signInBody = "Sign in to iFruit in Settings to publish and save posts.",
|
||||
authEyebrow = "Local Pages account", authWelcome = "Welcome to Local Pages",
|
||||
authBody = "Sign in or create an iFruit account to build your local profile.", login = "Sign in", register = "Register",
|
||||
loginTitle = "Continue with iFruit", loginBody = "Your posts, saved items and profile stay linked to your account.",
|
||||
registerTitle = "Create an iFruit account", registerBody = "Choose your new iFruit address. Your Local Pages profile comes next.",
|
||||
authEmail = "iFruit address", authEmailPlaceholder = "your.name", authPassword = "Password",
|
||||
authPasswordPlaceholder = "At least 6 characters", authConfirmPassword = "Confirm password",
|
||||
authConfirmPlaceholder = "Enter the password again", showPassword = "Show password", hidePassword = "Hide password",
|
||||
localCreator = "Local creator", myPosts = "My posts", saved = "Saved", save = "Save", likes = "likes",
|
||||
createProfile = "Create your profile", editProfile = "Edit profile", profileSetupBody = "Your iFruit email is linked automatically.",
|
||||
profileEmail = "iFruit email", profileHandle = "Username", profileHandlePlaceholder = "your.name",
|
||||
profileHandleHint = "Use 3-24 lowercase letters, numbers, dots or underscores.", profileBio = "Bio",
|
||||
profileBioPlaceholder = "Tell the city a little about yourself...", profilePhoto = "Profile photo", profilePhotoHint = "Choose a photo from your Gallery or take a new one.", removeProfilePhoto = "Remove photo", profileSave = "Save profile", profileCancel = "Cancel", profileSaved = "Your profile was saved.",
|
||||
location = "Location", sharedFrom = "Shared from CityMarkt", openCityMarkt = "Open CityMarkt listing",
|
||||
newPost = "New local post", shareWithCity = "Share with the city", publish = "Publish", published = "Your post is live.", deleted = "Post deleted.",
|
||||
title = "Title", body = "Your story", category = "Category", titlePlaceholder = "What should people know?", bodyPlaceholder = "Add details, a recommendation or directions...",
|
||||
photos = "Photos", optional = "optional", camera = "Camera", gallery = "Gallery", photoLimit = "You can add up to six photos.",
|
||||
cityMarktShare = "Share to Local Pages", cityMarktShared = "Shared to Local Pages.", cityMarktShareHint = "One CityMarkt share per day",
|
||||
errors = { invalid_post = "Add a title and a little more detail.", invalid_images = "Choose valid photos from this phone.", invalid_request = "This action is not valid.", post_not_found = "This post is no longer available.", citymarkt_not_found = "This CityMarkt listing is unavailable.", citymarkt_daily_limit = "You already shared a CityMarkt listing today.", citymarkt_already_shared = "This listing was already shared.", not_authenticated = "Sign in to iFruit first.", rate_limited = "Too many requests. Try again shortly.", request_failed = "The post could not be saved.", default = "Local Pages is temporarily unavailable." },
|
||||
authErrors = { invalid_email = "Enter a valid 3-32 character iFruit address.", invalid_password = "Use a password between 6 and 64 characters.", invalid_credentials = "The iFruit address or password is incorrect.", email_taken = "This iFruit address is already registered.", password_mismatch = "The passwords do not match.", rate_limited = "Too many attempts. Try again shortly.", default = "The iFruit account request failed." },
|
||||
errors = { profile_required = "Create your Local Pages profile first.", invalid_profile = "Check your username and bio.", invalid_profile_image = "Choose a valid photo from this phone.", profile_handle_taken = "This username is already taken.", invalid_post = "Add a title and a little more detail.", invalid_images = "Choose valid photos from this phone.", invalid_request = "This action is not valid.", post_not_found = "This post is no longer available.", citymarkt_not_found = "This CityMarkt listing is unavailable.", citymarkt_daily_limit = "You already shared a CityMarkt listing today.", citymarkt_already_shared = "This listing was already shared.", not_authenticated = "Sign in to iFruit first.", rate_limited = "Too many requests. Try again shortly.", request_failed = "The post could not be saved.", default = "Local Pages is temporarily unavailable." },
|
||||
},
|
||||
map = {
|
||||
name = "Map", controls = "Map controls", currentLocation = "Current Location",
|
||||
|
||||
@@ -59,6 +59,8 @@ local server_callbacks = {
|
||||
"marketplace:block",
|
||||
"pages:list",
|
||||
"pages:get",
|
||||
"pages:profile",
|
||||
"pages:profile-save",
|
||||
"pages:list-own",
|
||||
"pages:create",
|
||||
"pages:share-citymarkt",
|
||||
|
||||
@@ -775,6 +775,37 @@ local schema = {
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_pages_profiles",
|
||||
columns = {
|
||||
{ name = "account_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{
|
||||
name = "handle",
|
||||
type = "VARCHAR(24) NOT NULL",
|
||||
characterSet = "ascii",
|
||||
collation = "ascii_general_ci",
|
||||
},
|
||||
{ name = "bio", type = "VARCHAR(160) NOT NULL DEFAULT ''" },
|
||||
{ name = "avatar_media_id", type = "BIGINT UNSIGNED NULL" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
{
|
||||
name = "updated_at",
|
||||
type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP",
|
||||
},
|
||||
},
|
||||
primaryKey = "account_id",
|
||||
uniqueKeys = {
|
||||
{ name = "uniq_sky_phone_pages_profile_handle", columns = "(`handle`)" },
|
||||
},
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_pages_profile_avatar", columns = "(`avatar_media_id`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "avatar_media_id", references = "`sky_phone_media` (`id`) ON DELETE SET NULL" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_pages_posts",
|
||||
columns = {
|
||||
|
||||
@@ -14,6 +14,27 @@ local function valid_text(value, minimum, maximum)
|
||||
return length and length >= minimum and length <= maximum
|
||||
end
|
||||
|
||||
local function normalize_handle(value)
|
||||
local handle = trim(value)
|
||||
if not handle then return nil end
|
||||
handle = handle:lower():gsub("^@", "")
|
||||
local length = utf8.len(handle)
|
||||
if not length
|
||||
or length < Config.LocalPages.ProfileHandleMinLength
|
||||
or length > Config.LocalPages.ProfileHandleMaxLength
|
||||
or not handle:match("^[a-z0-9][a-z0-9._]*[a-z0-9]$")
|
||||
then
|
||||
return nil
|
||||
end
|
||||
return handle
|
||||
end
|
||||
|
||||
local function default_handle(account)
|
||||
local email_name = type(account.email) == "string" and account.email:match("^([^@]+)") or ""
|
||||
local candidate = email_name:lower():gsub("[^a-z0-9._]", "_"):sub(1, Config.LocalPages.ProfileHandleMaxLength)
|
||||
return normalize_handle(candidate) or ("local%s"):format(account.id)
|
||||
end
|
||||
|
||||
local function new_id()
|
||||
local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {})
|
||||
if not rows[1] or type(rows[1].id) ~= "string" then
|
||||
@@ -83,7 +104,8 @@ local function list_posts(account_id, where_clause, values, limit, offset)
|
||||
return hydrate_posts(Bridge.Database.Query(([[
|
||||
SELECT p.`id`, p.`title`, p.`body`, p.`category`, p.`district`, p.`source_type`,
|
||||
p.`citymarkt_listing_id`, UNIX_TIMESTAMP(p.`created_at`) AS `created_at_unix`,
|
||||
SUBSTRING_INDEX(a.`email`, '@', 1) AS `author_name`,
|
||||
COALESCE(profile.`handle`, SUBSTRING_INDEX(a.`email`, '@', 1)) AS `author_name`,
|
||||
avatar.`url` AS `author_avatar`,
|
||||
(p.`account_id` = ?) AS `is_owner`,
|
||||
EXISTS(SELECT 1 FROM `sky_phone_pages_reactions` r WHERE r.`post_id` = p.`id`
|
||||
AND r.`account_id` = ? AND r.`kind` = 'like') AS `is_liked`,
|
||||
@@ -96,6 +118,8 @@ local function list_posts(account_id, where_clause, values, limit, offset)
|
||||
m.`price` AS `citymarkt_price`
|
||||
FROM `sky_phone_pages_posts` p
|
||||
JOIN `sky_phone_accounts` a ON a.`id` = p.`account_id`
|
||||
LEFT JOIN `sky_phone_pages_profiles` profile ON profile.`account_id` = p.`account_id`
|
||||
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = profile.`avatar_media_id`
|
||||
LEFT JOIN `sky_phone_marketplace_listings` m ON m.`id` = p.`citymarkt_listing_id`
|
||||
WHERE %s
|
||||
ORDER BY p.`created_at` DESC
|
||||
@@ -110,6 +134,102 @@ local function optional_account(source)
|
||||
return rows[1] and tonumber(rows[1].account_id) or nil, nil
|
||||
end
|
||||
|
||||
local function profile_dto(account)
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT profile.`handle`, profile.`bio`, profile.`avatar_media_id`, avatar.`url` AS `avatar_url`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_pages_posts` post
|
||||
WHERE post.`account_id` = ?) AS `post_count`
|
||||
FROM `sky_phone_accounts` account
|
||||
LEFT JOIN `sky_phone_pages_profiles` profile ON profile.`account_id` = account.`id`
|
||||
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = profile.`avatar_media_id`
|
||||
WHERE account.`id` = ?
|
||||
LIMIT 1
|
||||
]], { account.id, account.id })
|
||||
local row = rows[1]
|
||||
if not row then
|
||||
error(("[sky_phone] Could not load Local Pages profile account %s."):format(account.id))
|
||||
end
|
||||
return {
|
||||
avatar_media_id = tonumber(row.avatar_media_id),
|
||||
avatar_url = row.avatar_url,
|
||||
bio = row.bio or "",
|
||||
email = account.email,
|
||||
exists = row.handle ~= nil,
|
||||
handle = row.handle or default_handle(account),
|
||||
post_count = tonumber(row.post_count) or 0,
|
||||
}
|
||||
end
|
||||
|
||||
local function require_profile(account_id)
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `account_id`, `handle`, `bio`
|
||||
FROM `sky_phone_pages_profiles`
|
||||
WHERE `account_id` = ?
|
||||
LIMIT 1
|
||||
]], { account_id })
|
||||
if not rows[1] then
|
||||
return nil, { success = false, error = "profile_required" }
|
||||
end
|
||||
return rows[1], nil
|
||||
end
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:pages:profile", function(source)
|
||||
local account, error_response = SkyPhone.RequireAccount(source)
|
||||
if not account then return error_response end
|
||||
return { success = true, data = profile_dto(account) }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:pages:profile-save", function(source, data)
|
||||
local account, error_response = SkyPhone.RequireAccount(source)
|
||||
if not account then return error_response end
|
||||
if not SkyPhone.AllowOperation(source, "pages:profile-save", 12, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
if type(data) ~= "table" or type(data.bio) ~= "string" then
|
||||
return { success = false, error = "invalid_profile" }
|
||||
end
|
||||
local handle = normalize_handle(data.handle)
|
||||
local bio = trim(data.bio)
|
||||
local avatar_media_id = tonumber(data.avatarMediaId)
|
||||
if not handle or not valid_text(bio, 0, Config.LocalPages.ProfileBioMaxLength)
|
||||
or not avatar_media_id or avatar_media_id < 0 or avatar_media_id ~= math.floor(avatar_media_id)
|
||||
then
|
||||
return { success = false, error = "invalid_profile" }
|
||||
end
|
||||
if avatar_media_id > 0 and not SkyPhoneMedia.ResolveOwnedMedia(source, tostring(avatar_media_id), "photo") then
|
||||
Bridge.Debug("warn", "[sky_phone] Rejected unowned Local Pages profile image from source %s.", tostring(source))
|
||||
return { success = false, error = "invalid_profile_image" }
|
||||
end
|
||||
local duplicate = Bridge.Database.Query([[
|
||||
SELECT `account_id`
|
||||
FROM `sky_phone_pages_profiles`
|
||||
WHERE `handle` = ? AND `account_id` <> ?
|
||||
LIMIT 1
|
||||
]], { handle, account.id })
|
||||
if duplicate[1] then
|
||||
return { success = false, error = "profile_handle_taken" }
|
||||
end
|
||||
local existing = Bridge.Database.Query([[
|
||||
SELECT `account_id`
|
||||
FROM `sky_phone_pages_profiles`
|
||||
WHERE `account_id` = ?
|
||||
LIMIT 1
|
||||
]], { account.id })
|
||||
if existing[1] then
|
||||
Bridge.Database.Query([[
|
||||
UPDATE `sky_phone_pages_profiles`
|
||||
SET `handle` = ?, `bio` = ?, `avatar_media_id` = NULLIF(?, 0)
|
||||
WHERE `account_id` = ?
|
||||
]], { handle, bio, avatar_media_id, account.id })
|
||||
else
|
||||
Bridge.Database.Query([[
|
||||
INSERT INTO `sky_phone_pages_profiles` (`account_id`, `handle`, `bio`, `avatar_media_id`)
|
||||
VALUES (?, ?, ?, NULLIF(?, 0))
|
||||
]], { account.id, handle, bio, avatar_media_id })
|
||||
end
|
||||
return { success = true, data = profile_dto(account) }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:pages:list", function(source, data)
|
||||
if type(data) ~= "table" then return { success = false, error = "invalid_request" } end
|
||||
local account_id, error_response = optional_account(source)
|
||||
@@ -163,6 +283,8 @@ end)
|
||||
Bridge.Callbacks.Register("sky_phone:pages:create", function(source, data)
|
||||
local account, error_response = SkyPhone.RequireAccount(source)
|
||||
if not account then return error_response end
|
||||
local _, profile_error = require_profile(account.id)
|
||||
if profile_error then return profile_error end
|
||||
if not SkyPhone.AllowOperation(source, "pages:create", 6, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
@@ -201,6 +323,8 @@ end)
|
||||
Bridge.Callbacks.Register("sky_phone:pages:share-citymarkt", function(source, data)
|
||||
local account, error_response = SkyPhone.RequireAccount(source)
|
||||
if not account then return error_response end
|
||||
local _, profile_error = require_profile(account.id)
|
||||
if profile_error then return profile_error end
|
||||
if not SkyPhone.AllowOperation(source, "pages:share-citymarkt", 3, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user