Bridge.Database.AfterMigration("sky_phone", function() local config = Config.WeazelNews if type(config) ~= "table" then error("[sky_phone] Config.WeazelNews must be configured.") end local function require_integer_config(name, minimum, maximum) local value = config[name] if type(value) ~= "number" or value ~= math.floor(value) or value < minimum or value > maximum then error(("[sky_phone] Config.WeazelNews.%s must be an integer between %d and %d."):format(name, minimum, maximum)) end end if type(config.Enabled) ~= "boolean" then error("[sky_phone] Config.WeazelNews.Enabled must be true or false.") end require_integer_config("PageSize", 1, 100) require_integer_config("MaximumOffset", 0, 1000000) require_integer_config("MaximumImages", 1, 6) require_integer_config("SearchMaxLength", 1, 256) require_integer_config("DraftTitleMinLength", 1, 160) require_integer_config("DraftBodyMinLength", 1, 12000) require_integer_config("TitleMinLength", 1, 160) require_integer_config("TitleMaxLength", 1, 160) require_integer_config("BodyMinLength", 1, 12000) require_integer_config("BodyMaxLength", 1, 12000) require_integer_config("ExcerptMaxLength", 1, 240) if config.DraftTitleMinLength > config.TitleMaxLength or config.DraftBodyMinLength > config.BodyMaxLength or config.TitleMinLength > config.TitleMaxLength or config.BodyMinLength > config.BodyMaxLength then error("[sky_phone] Weazel News minimum text lengths cannot exceed their maximums.") end if type(config.RateLimits) ~= "table" then error("[sky_phone] Config.WeazelNews.RateLimits must be a table.") end for _, name in ipairs({ "Read", "Write" }) do local value = config.RateLimits[name] if type(value) ~= "number" or value ~= math.floor(value) or value < 1 or value > 10000 then error(("[sky_phone] Config.WeazelNews.RateLimits.%s must be an integer between 1 and 10000."):format(name)) end end local supported_categories = { official = true, events = true, jobs = true, news = true, business = true, } if type(config.Categories) ~= "table" then error("[sky_phone] Config.WeazelNews.Categories must be a table.") end local categories = {} for _, category in ipairs(config.Categories or {}) do if type(category) ~= "string" or #category < 1 or #category > 32 or not category:match("^[%l_]+$") or categories[category] then error(("[sky_phone] Invalid Weazel News category '%s'."):format(tostring(category))) end categories[category] = true end for category in pairs(supported_categories) do if not categories[category] then error(("[sky_phone] Config.WeazelNews.Categories is missing supported category '%s'."):format(category)) end end for category in pairs(categories) do if not supported_categories[category] then error(("[sky_phone] Config.WeazelNews.Categories contains unsupported category '%s'."):format(category)) end end if type(config.AllowedJobs) ~= "table" then error("[sky_phone] Config.WeazelNews.AllowedJobs must be a table.") end for job_name, minimum_grade in pairs(config.AllowedJobs or {}) do if type(job_name) ~= "string" or #job_name < 1 or #job_name > 64 or not job_name:match("^[%w_-]+$") or type(minimum_grade) ~= "number" or minimum_grade < 0 or minimum_grade ~= math.floor(minimum_grade) then error(("[sky_phone] Invalid Weazel News AllowedJobs entry '%s'."):format(tostring(job_name))) end end local article_summary_columns = [[ article.`id`, article.`title`, article.`excerpt`, article.`category`, article.`image_media_id`, media.`url` AS `image_url`, article.`author_name`, article.`status`, article.`revision`, UNIX_TIMESTAMP(article.`created_at`) AS `created_at_unix`, UNIX_TIMESTAMP(article.`updated_at`) AS `updated_at_unix`, UNIX_TIMESTAMP(article.`published_at`) AS `published_at_unix` ]] local article_detail_columns = article_summary_columns .. ", article.`body`" local function trim(value) if type(value) ~= "string" then return nil end return value:match("^%s*(.-)%s*$") end local function text_length(value) if type(value) ~= "string" then return nil end local success, length = pcall(utf8.len, value) return success and length or nil end local function truncate_text(value, maximum) local length = text_length(value) if not length or length <= maximum then return value end local next_character = utf8.offset(value, maximum + 1) return next_character and value:sub(1, next_character - 1) or value end local function make_excerpt(body) return truncate_text(body:gsub("%s+", " "), config.ExcerptMaxLength) end local function valid_integer(value, minimum, maximum) local number = type(value) == "number" and value or nil if not number or number ~= math.floor(number) or number < minimum or number > maximum then return nil end return number end local function valid_uuid(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 affected_rows(result) if type(result) == "number" then return result end return type(result) == "table" and tonumber(result.affectedRows) or 0 end local function require_phone(source, operation, maximum) if not config.Enabled then return nil, { success = false, error = "feature_disabled" } end local session, error_response = SkyPhone.RequireSession(source) if not session then return nil, error_response end if not SkyPhone.AllowOperation(source, "weazel_" .. operation, maximum, 60) then return nil, { success = false, error = "rate_limited" } end return session end local function management_access(source) local job = Bridge.Framework.GetJob(source) local minimum_grade = (config.AllowedJobs or {})[job.name] local grade = tonumber(job.grade) or 0 if minimum_grade == nil or grade < minimum_grade then return nil end return { job_label = type(job.label) == "string" and job.label or "", grade_label = type(job.gradeLabel) == "string" and job.gradeLabel or "", } end local function require_manager(source) local access = management_access(source) if not access then return nil, { success = false, error = "not_authorized" } end return access end local function actor_identity(source) local identifier = Bridge.Framework.GetIdentifier(source) if type(identifier) ~= "string" or #identifier < 1 or #identifier > 80 then Bridge.Debug("error", "[sky_phone] Could not resolve a valid Weazel News actor for source %s.", tostring(source)) return nil end local first_name = trim(Bridge.Framework.GetFirstname(source)) or "" local last_name = trim(Bridge.Framework.GetLastname(source)) or "" local name = trim((first_name .. " " .. last_name)) if not name or name == "" then name = trim(GetPlayerName(source)) or "Weazel News" end if not text_length(name) then name = "Weazel News" end return { identifier = identifier, name = truncate_text(name, 120), } end local function article_dto(row) if not row then return nil end local created_at = tonumber(row.created_at_unix) local updated_at = tonumber(row.updated_at_unix) if not created_at or not updated_at then error(("[sky_phone] Weazel News article '%s' has invalid timestamps."):format(tostring(row.id))) end return { id = row.id, title = row.title, body = row.body, excerpt = row.excerpt, category = row.category, imageUrl = row.image_url, imageMediaId = row.image_media_id and tonumber(row.image_media_id) or nil, images = {}, authorName = row.author_name, createdAt = created_at * 1000, updatedAt = updated_at * 1000, publishedAt = row.published_at_unix and tonumber(row.published_at_unix) * 1000 or nil, status = row.status, revision = tonumber(row.revision) or 1, } end local function attach_article_images(articles) if #articles < 1 then return articles end local placeholders = {} local article_ids = {} local articles_by_id = {} for index, article in ipairs(articles) do placeholders[index] = "?" article_ids[index] = article.id article.images = {} articles_by_id[article.id] = article end local rows = Bridge.Database.Query(([[ SELECT relation.`article_id`, relation.`media_id`, relation.`position`, media.`url` FROM `sky_phone_weazel_article_media` relation JOIN `sky_phone_media` media ON media.`id` = relation.`media_id` AND media.`media_type` = 'photo' WHERE relation.`article_id` IN (%s) ORDER BY relation.`article_id`, relation.`position`, relation.`id` ]]):format(table.concat(placeholders, ", ")), article_ids) for _, row in ipairs(rows) do local article = articles_by_id[row.article_id] local media_id = tonumber(row.media_id) if article and media_id and type(row.url) == "string" then article.images[#article.images + 1] = { mediaId = media_id, url = row.url, } end end for _, article in ipairs(articles) do if #article.images < 1 and article.imageMediaId and article.imageUrl then article.images[1] = { mediaId = article.imageMediaId, url = article.imageUrl, } end local cover = article.images[1] article.imageMediaId = cover and cover.mediaId or nil article.imageUrl = cover and cover.url or nil end return articles end local function load_article(id, include_drafts) local visibility = include_drafts and "" or " AND article.`status` = 'published'" local rows = Bridge.Database.Query(([[ SELECT %s FROM `sky_phone_weazel_articles` article LEFT JOIN `sky_phone_media` media ON media.`id` = article.`image_media_id` WHERE article.`id` = ? AND article.`deleted_at` IS NULL%s LIMIT 1 ]]):format(article_detail_columns, visibility), { id }) local article = article_dto(rows[1]) return article and attach_article_images({ article })[1] or nil end local function query_articles(where_clause, parameters, order_clause, offset) local query_parameters = {} for _, value in ipairs(parameters) do query_parameters[#query_parameters + 1] = value end query_parameters[#query_parameters + 1] = config.PageSize + 1 query_parameters[#query_parameters + 1] = offset local rows = Bridge.Database.Query(([[ SELECT %s FROM `sky_phone_weazel_articles` article LEFT JOIN `sky_phone_media` media ON media.`id` = article.`image_media_id` WHERE %s ORDER BY %s LIMIT ? OFFSET ? ]]):format(article_summary_columns, where_clause, order_clause), query_parameters) local has_more = #rows > config.PageSize if has_more then rows[#rows] = nil end local articles = {} for _, row in ipairs(rows) do articles[#articles + 1] = article_dto(row) end return attach_article_images(articles), has_more end local function validate_article_media(source, data, retained_media_ids) local values = data.imageMediaIds if values == nil then values = data.imageMediaId ~= nil and { data.imageMediaId } or {} end if type(values) ~= "table" or #values > config.MaximumImages then return nil end for key in pairs(values) do if type(key) ~= "number" or key < 1 or key > #values or key ~= math.floor(key) then return nil end end local media_ids = {} local seen = {} for index, value in ipairs(values) do local media_id = valid_integer(value, 1, 9007199254740991) if not media_id or seen[media_id] then return nil end if not retained_media_ids[media_id] and not SkyPhoneMedia.ResolveOwnedMedia(source, tostring(media_id), "photo") then return nil end seen[media_id] = true media_ids[index] = media_id end return media_ids end local function validate_article(source, data, retained_media_ids) if type(data) ~= "table" then return nil, "invalid_article" end local title = trim(data.title) local body = trim(data.body) local title_length = text_length(title) local body_length = text_length(body) local status = data.status local minimum_title_length = status == "draft" and config.DraftTitleMinLength or config.TitleMinLength local minimum_body_length = status == "draft" and config.DraftBodyMinLength or config.BodyMinLength if not title_length or title:find("%z") or title_length < minimum_title_length or title_length > config.TitleMaxLength or not body_length or body:find("%z") or body_length < minimum_body_length or body_length > config.BodyMaxLength or not categories[data.category] or (status ~= "draft" and status ~= "published") then return nil, status == "draft" and "invalid_draft" or "invalid_publish" end local media_ids = validate_article_media(source, data, retained_media_ids or {}) if not media_ids then return nil, "invalid_attachment" end return { title = title, body = body, excerpt = make_excerpt(body), category = data.category, image_media_id = media_ids[1], image_media_ids = media_ids, status = status, } end local function article_matches(article, expected, revision) if not article or article.revision ~= revision or article.title ~= expected.title or article.body ~= expected.body or article.excerpt ~= expected.excerpt or article.category ~= expected.category or article.status ~= expected.status or #article.images ~= #expected.image_media_ids then return false end for index, media_id in ipairs(expected.image_media_ids) do if article.images[index].mediaId ~= media_id then return false end end return true end Bridge.Callbacks.Register("sky_phone:weazel-news:context", function(source) local _, error_response = require_phone(source, "read", config.RateLimits.Read) if error_response then return error_response end local access = management_access(source) local counts = Bridge.Database.Query([[ SELECT `category`, COUNT(*) AS `count` FROM `sky_phone_weazel_articles` WHERE `status` = 'published' AND `deleted_at` IS NULL GROUP BY `category` ]], {}) local counts_by_category = {} for _, row in ipairs(counts) do counts_by_category[row.category] = tonumber(row.count) or 0 end local category_context = {} for _, category in ipairs(config.Categories) do category_context[#category_context + 1] = { id = category, count = counts_by_category[category] or 0, } end return { success = true, data = { canManage = access ~= nil, jobLabel = access and access.job_label or nil, jobGradeLabel = access and access.grade_label or nil, categories = category_context, maximumImages = config.MaximumImages, }, } end) Bridge.Callbacks.Register("sky_phone:weazel-news:list", function(source, data) local _, error_response = require_phone(source, "read", config.RateLimits.Read) if error_response then return error_response end if type(data) ~= "table" then return { success = false, error = "invalid_request" } end local offset = valid_integer(data.offset or 0, 0, config.MaximumOffset) local search = trim(data.search or "") local search_length = text_length(search) if not offset or not search_length or search_length > config.SearchMaxLength or (data.category ~= nil and not categories[data.category]) then return { success = false, error = "invalid_request" } end local where = { "article.`status` = 'published'", "article.`deleted_at` IS NULL" } local parameters = {} if data.category then where[#where + 1] = "article.`category` = ?" parameters[#parameters + 1] = data.category end if search ~= "" then where[#where + 1] = "(article.`title` LIKE CONCAT('%', ?, '%') OR article.`body` LIKE CONCAT('%', ?, '%'))" parameters[#parameters + 1] = search parameters[#parameters + 1] = search end local items, has_more = query_articles( table.concat(where, " AND "), parameters, "article.`published_at` DESC, article.`id` DESC", offset ) return { success = true, data = { items = items, hasMore = has_more } } end) Bridge.Callbacks.Register("sky_phone:weazel-news:get", function(source, data) local _, error_response = require_phone(source, "read", config.RateLimits.Read) if error_response then return error_response end if type(data) ~= "table" or not valid_uuid(data.id) then return { success = false, error = "invalid_request" } end local include_drafts = data.manage == true if include_drafts then local _, manager_error = require_manager(source) if manager_error then return manager_error end end local article = load_article(data.id, include_drafts) if not article then return { success = false, error = "not_found" } end return { success = true, data = { article = article } } end) Bridge.Callbacks.Register("sky_phone:weazel-news:manage-list", function(source, data) local _, error_response = require_phone(source, "read", config.RateLimits.Read) if error_response then return error_response end local _, manager_error = require_manager(source) if manager_error then return manager_error end if type(data) ~= "table" then return { success = false, error = "invalid_request" } end local status = data.status or "all" local offset = valid_integer(data.offset or 0, 0, config.MaximumOffset) local search = trim(data.search or "") local search_length = text_length(search) if (status ~= "all" and status ~= "published" and status ~= "draft") or not offset or not search_length or search_length > config.SearchMaxLength then return { success = false, error = "invalid_request" } end local where = { "article.`deleted_at` IS NULL" } local parameters = {} if status ~= "all" then where[#where + 1] = "article.`status` = ?" parameters[#parameters + 1] = status end if search ~= "" then where[#where + 1] = "(article.`title` LIKE CONCAT('%', ?, '%') OR article.`body` LIKE CONCAT('%', ?, '%'))" parameters[#parameters + 1] = search parameters[#parameters + 1] = search end local items, has_more = query_articles( table.concat(where, " AND "), parameters, "article.`updated_at` DESC, article.`id` DESC", offset ) return { success = true, data = { items = items, hasMore = has_more } } end) Bridge.Callbacks.Register("sky_phone:weazel-news:create", function(source, data) local _, error_response = require_phone(source, "write", config.RateLimits.Write) if error_response then return error_response end local _, manager_error = require_manager(source) if manager_error then return manager_error end local article, validation_error = validate_article(source, data) if not article then return { success = false, error = validation_error } end local actor = actor_identity(source) if not actor then return { success = false, error = "request_failed" } end local ids = Bridge.Database.Query("SELECT UUID() AS `id`", {}) local id = ids[1] and ids[1].id if not valid_uuid(id) then error("[sky_phone] Database did not generate a Weazel News article id.") end local statements = {{ query = [[ INSERT INTO `sky_phone_weazel_articles` (`id`, `title`, `body`, `excerpt`, `category`, `image_media_id`, `author_identifier`, `author_name`, `updated_by_identifier`, `status`, `published_at`) VALUES (?, ?, ?, ?, ?, NULLIF(?, 0), ?, ?, ?, ?, IF(? = 'published', CURRENT_TIMESTAMP, NULL)) ]], params = { id, article.title, article.body, article.excerpt, article.category, article.image_media_id or 0, actor.identifier, actor.name, actor.identifier, article.status, article.status, }, }} for position, media_id in ipairs(article.image_media_ids) do statements[#statements + 1] = { query = [[ INSERT INTO `sky_phone_weazel_article_media` (`article_id`, `media_id`, `position`) VALUES (?, ?, ?) ]], params = { id, media_id, position }, } end if not Bridge.Database.Transaction(statements) then return { success = false, error = "request_failed" } end local created = load_article(id, true) if not created then error(("[sky_phone] Could not reload created Weazel News article '%s'."):format(id)) end return { success = true, data = { article = created } } end) Bridge.Callbacks.Register("sky_phone:weazel-news:update", function(source, data) local _, error_response = require_phone(source, "write", config.RateLimits.Write) if error_response then return error_response end local _, manager_error = require_manager(source) if manager_error then return manager_error end local revision = type(data) == "table" and valid_integer(data.revision, 1, 4294967295) or nil if not revision or not valid_uuid(data.id) then return { success = false, error = "invalid_request" } end local current_rows = Bridge.Database.Query([[ SELECT `image_media_id`, `revision` FROM `sky_phone_weazel_articles` WHERE `id` = ? AND `deleted_at` IS NULL LIMIT 1 ]], { data.id }) local current = current_rows[1] if not current then return { success = false, error = "not_found" } end if tonumber(current.revision) ~= revision then return { success = false, error = "revision_conflict" } end local retained_media_ids = {} local retained_cover_id = current.image_media_id and tonumber(current.image_media_id) or nil if retained_cover_id then retained_media_ids[retained_cover_id] = true end local retained_rows = Bridge.Database.Query([[ SELECT `media_id` FROM `sky_phone_weazel_article_media` WHERE `article_id` = ? ]], { data.id }) for _, row in ipairs(retained_rows) do local media_id = tonumber(row.media_id) if media_id then retained_media_ids[media_id] = true end end local article, validation_error = validate_article(source, data, retained_media_ids) if not article then return { success = false, error = validation_error } end local actor = actor_identity(source) if not actor then return { success = false, error = "request_failed" } end local mutation_rows = Bridge.Database.Query("SELECT UUID() AS `id`", {}) local mutation_id = mutation_rows[1] and mutation_rows[1].id if not valid_uuid(mutation_id) then error("[sky_phone] Database did not generate a Weazel News mutation id.") end local mutation_token = "weazel:" .. mutation_id local next_revision = revision + 1 local statements = { { query = [[ UPDATE `sky_phone_weazel_articles` SET `title` = ?, `body` = ?, `excerpt` = ?, `category` = ?, `image_media_id` = NULLIF(?, 0), `published_at` = CASE WHEN ? = 'draft' THEN NULL WHEN `status` = 'draft' THEN CURRENT_TIMESTAMP ELSE `published_at` END, `status` = ?, `updated_by_identifier` = ?, `revision` = `revision` + 1 WHERE `id` = ? AND `revision` = ? AND `deleted_at` IS NULL ]], params = { article.title, article.body, article.excerpt, article.category, article.image_media_id or 0, article.status, article.status, mutation_token, data.id, revision, }, }, { query = [[ DELETE relation FROM `sky_phone_weazel_article_media` relation JOIN `sky_phone_weazel_articles` article ON article.`id` = relation.`article_id` WHERE article.`id` = ? AND article.`revision` = ? AND article.`updated_by_identifier` = ? ]], params = { data.id, next_revision, mutation_token }, }, } for position, media_id in ipairs(article.image_media_ids) do statements[#statements + 1] = { query = [[ INSERT INTO `sky_phone_weazel_article_media` (`article_id`, `media_id`, `position`) SELECT article.`id`, ?, ? FROM `sky_phone_weazel_articles` article WHERE article.`id` = ? AND article.`revision` = ? AND article.`updated_by_identifier` = ? AND article.`deleted_at` IS NULL ]], params = { media_id, position, data.id, next_revision, mutation_token }, } end statements[#statements + 1] = { query = [[ UPDATE `sky_phone_weazel_articles` SET `updated_by_identifier` = ? WHERE `id` = ? AND `revision` = ? AND `updated_by_identifier` = ? ]], params = { actor.identifier, data.id, next_revision, mutation_token }, } if not Bridge.Database.Transaction(statements) then return { success = false, error = "request_failed" } end local updated = load_article(data.id, true) if not updated then return { success = false, error = "not_found" } end if not article_matches(updated, article, next_revision) then return { success = false, error = "revision_conflict" } end return { success = true, data = { article = updated } } end) Bridge.Callbacks.Register("sky_phone:weazel-news:delete", function(source, data) local _, error_response = require_phone(source, "write", config.RateLimits.Write) if error_response then return error_response end local _, manager_error = require_manager(source) if manager_error then return manager_error end local revision = type(data) == "table" and valid_integer(data.revision, 1, 4294967295) or nil if not revision or not valid_uuid(data.id) then return { success = false, error = "invalid_request" } end local actor = actor_identity(source) if not actor then return { success = false, error = "request_failed" } end local result = Bridge.Database.Query([[ UPDATE `sky_phone_weazel_articles` SET `deleted_at` = CURRENT_TIMESTAMP, `deleted_by_identifier` = ?, `revision` = `revision` + 1 WHERE `id` = ? AND `revision` = ? AND `deleted_at` IS NULL ]], { actor.identifier, data.id, revision }) if affected_rows(result) ~= 1 then local rows = Bridge.Database.Query([[ SELECT `revision`, `deleted_at` FROM `sky_phone_weazel_articles` WHERE `id` = ? LIMIT 1 ]], { data.id }) if not rows[1] or rows[1].deleted_at then return { success = false, error = "not_found" } end return { success = false, error = "revision_conflict" } end return { success = true } end) end)