ENH - overhaul Local Pages profiles

This commit is contained in:
smx.pusha
2026-08-12 13:31:48 +02:00
parent 9710c1c220
commit f79aed3959
11 changed files with 1140 additions and 27 deletions
+3
View File
@@ -360,6 +360,9 @@ Config.Marketplace = {
Config.LocalPages = {
PageSize = 20,
MaxImages = 6,
ProfileHandleMinLength = 3,
ProfileHandleMaxLength = 24,
ProfileBioMaxLength = 160,
TitleMinLength = 5,
TitleMaxLength = 80,
BodyMinLength = 10,
+13 -1
View File
@@ -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",
+2
View File
@@ -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",
+31
View File
@@ -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 = {
+125 -1
View File
@@ -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