mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 03:01:42 +00:00
ADD - implement authoritative CityWarn service
Add server-owned warning persistence, immutable update history, publishing limits, and CityWarn callbacks. Enforce framework job, grade, duty, category, severity, session, and rate-limit checks before every state change.
This commit is contained in:
@@ -1349,6 +1349,56 @@ if IsDuplicityVersion() then
|
||||
},
|
||||
}
|
||||
|
||||
-- -------------------------------------------------------------------------
|
||||
-- CityWarn population warnings
|
||||
-- -------------------------------------------------------------------------
|
||||
|
||||
Config.CityWarn = {
|
||||
Enabled = true,
|
||||
PageSize = 30,
|
||||
MaximumActiveAlerts = 20,
|
||||
TitleMaxLength = 120,
|
||||
BodyMaxLength = 2000,
|
||||
InstructionsMaxLength = 2000,
|
||||
UpdateMaxLength = 2000,
|
||||
AreaLabelMaxLength = 120,
|
||||
MinimumRadius = 100,
|
||||
MaximumRadius = 10000,
|
||||
DefaultDurationMinutes = 60,
|
||||
MaximumDurationMinutes = 1440,
|
||||
RequireDuty = true,
|
||||
RateLimits = {
|
||||
Read = 180,
|
||||
Write = 20,
|
||||
},
|
||||
Publishers = {
|
||||
police = {
|
||||
MinimumGrade = 2,
|
||||
MaximumSeverity = "extreme",
|
||||
CityWide = true,
|
||||
Categories = { "public_safety", "police", "infrastructure", "evacuation" },
|
||||
},
|
||||
fire = {
|
||||
MinimumGrade = 2,
|
||||
MaximumSeverity = "extreme",
|
||||
CityWide = true,
|
||||
Categories = { "public_safety", "fire", "infrastructure", "evacuation" },
|
||||
},
|
||||
ambulance = {
|
||||
MinimumGrade = 2,
|
||||
MaximumSeverity = "danger",
|
||||
CityWide = false,
|
||||
Categories = { "public_safety", "medical", "evacuation" },
|
||||
},
|
||||
government = {
|
||||
MinimumGrade = 2,
|
||||
MaximumSeverity = "extreme",
|
||||
CityWide = true,
|
||||
Categories = { "public_safety", "police", "fire", "medical", "infrastructure", "evacuation" },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
-- -------------------------------------------------------------------------
|
||||
-- Database migrations
|
||||
-- -------------------------------------------------------------------------
|
||||
|
||||
@@ -81,6 +81,7 @@ server_scripts {
|
||||
'source/server/media_import/manifest.lua',
|
||||
'source/server/media.lua',
|
||||
'source/server/weazel_news.lua',
|
||||
'source/server/citywarn.lua',
|
||||
'source/server/messages.lua',
|
||||
'source/server/memos.lua',
|
||||
'source/server/easyshare.lua',
|
||||
|
||||
@@ -127,13 +127,14 @@ function Bridge.Framework.GetJob(source)
|
||||
local player = get_player(source)
|
||||
local job = player and player.getJob()
|
||||
if not job then
|
||||
return { name = "", label = "", grade = 0, gradeLabel = "" }
|
||||
return { name = "", label = "", grade = 0, gradeLabel = "", onDuty = false }
|
||||
end
|
||||
return {
|
||||
name = job.name or "",
|
||||
label = job.label or "",
|
||||
grade = tonumber(job.grade) or 0,
|
||||
gradeLabel = job.grade_label or job.label or "",
|
||||
onDuty = true,
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ function Bridge.Framework.GetJob(source)
|
||||
label = job and job.label or "",
|
||||
grade = type(grade) == "table" and tonumber(grade.level) or tonumber(grade) or 0,
|
||||
gradeLabel = type(grade) == "table" and (grade.name or job.label) or (job and job.label or ""),
|
||||
onDuty = job ~= nil and job.onduty ~= false,
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ function Bridge.Framework.GetJob(source)
|
||||
label = job and job.label or "",
|
||||
grade = type(grade) == "table" and tonumber(grade.level) or tonumber(grade) or 0,
|
||||
gradeLabel = type(grade) == "table" and (grade.name or job.label) or (job and job.label or ""),
|
||||
onDuty = job ~= nil and job.onduty ~= false,
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
Bridge.Database.AfterMigration("sky_phone", function()
|
||||
local config = Config.CityWarn
|
||||
|
||||
if type(config) ~= "table" then
|
||||
error("[sky_phone] Config.CityWarn must be configured.")
|
||||
end
|
||||
|
||||
local categories = {
|
||||
public_safety = true,
|
||||
police = true,
|
||||
fire = true,
|
||||
medical = true,
|
||||
infrastructure = true,
|
||||
evacuation = true,
|
||||
}
|
||||
local severity_rank = {
|
||||
information = 1,
|
||||
warning = 2,
|
||||
danger = 3,
|
||||
extreme = 4,
|
||||
}
|
||||
|
||||
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.CityWarn.%s must be an integer between %d and %d."):format(name, minimum, maximum))
|
||||
end
|
||||
end
|
||||
|
||||
if type(config.Enabled) ~= "boolean" or type(config.RequireDuty) ~= "boolean" then
|
||||
error("[sky_phone] CityWarn Enabled and RequireDuty must be booleans.")
|
||||
end
|
||||
require_integer_config("PageSize", 1, 100)
|
||||
require_integer_config("MaximumActiveAlerts", 1, 1000)
|
||||
require_integer_config("TitleMaxLength", 1, 120)
|
||||
require_integer_config("BodyMaxLength", 1, 2000)
|
||||
require_integer_config("InstructionsMaxLength", 1, 2000)
|
||||
require_integer_config("UpdateMaxLength", 1, 2000)
|
||||
require_integer_config("AreaLabelMaxLength", 1, 120)
|
||||
require_integer_config("MinimumRadius", 1, 10000)
|
||||
require_integer_config("MaximumRadius", config.MinimumRadius, 50000)
|
||||
require_integer_config("DefaultDurationMinutes", 1, 1440)
|
||||
require_integer_config("MaximumDurationMinutes", config.DefaultDurationMinutes, 10080)
|
||||
|
||||
if type(config.RateLimits) ~= "table" or type(config.Publishers) ~= "table" then
|
||||
error("[sky_phone] CityWarn RateLimits and Publishers must be tables.")
|
||||
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] Invalid CityWarn rate limit '%s'."):format(name))
|
||||
end
|
||||
end
|
||||
for job_name, publisher in pairs(config.Publishers) do
|
||||
if type(job_name) ~= "string" or not job_name:match("^[%w_-]+$") or type(publisher) ~= "table"
|
||||
or type(publisher.MinimumGrade) ~= "number" or publisher.MinimumGrade < 0
|
||||
or publisher.MinimumGrade ~= math.floor(publisher.MinimumGrade)
|
||||
or not severity_rank[publisher.MaximumSeverity] or type(publisher.CityWide) ~= "boolean"
|
||||
or type(publisher.Categories) ~= "table"
|
||||
then
|
||||
error(("[sky_phone] Invalid CityWarn publisher '%s'."):format(tostring(job_name)))
|
||||
end
|
||||
local seen = {}
|
||||
for _, category in ipairs(publisher.Categories) do
|
||||
if not categories[category] or seen[category] then
|
||||
error(("[sky_phone] Invalid CityWarn category '%s' for publisher '%s'."):format(tostring(category), job_name))
|
||||
end
|
||||
seen[category] = true
|
||||
end
|
||||
end
|
||||
|
||||
local alert_columns = [[
|
||||
alert.`id`, alert.`title`, alert.`body`, alert.`instructions`, alert.`category`,
|
||||
alert.`severity`, alert.`status`, alert.`area_type`, alert.`area_label`,
|
||||
alert.`center_x`, alert.`center_y`, alert.`radius`, alert.`source_label`,
|
||||
alert.`author_name`, alert.`revision`,
|
||||
UNIX_TIMESTAMP(alert.`starts_at`) AS `starts_at_unix`,
|
||||
UNIX_TIMESTAMP(alert.`expires_at`) AS `expires_at_unix`,
|
||||
UNIX_TIMESTAMP(alert.`created_at`) AS `created_at_unix`,
|
||||
UNIX_TIMESTAMP(alert.`updated_at`) AS `updated_at_unix`
|
||||
]]
|
||||
|
||||
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 valid_text(value, maximum, allow_empty)
|
||||
local normalized = trim(value)
|
||||
local length = text_length(normalized)
|
||||
if not length or normalized:find("%z") or length > maximum or (not allow_empty and length < 1) then
|
||||
return nil
|
||||
end
|
||||
return normalized
|
||||
end
|
||||
|
||||
local function valid_number(value, minimum, maximum)
|
||||
local number = tonumber(value)
|
||||
if not number or number ~= number or number < minimum or number > maximum then
|
||||
return nil
|
||||
end
|
||||
return number
|
||||
end
|
||||
|
||||
local function valid_integer(value, minimum, maximum)
|
||||
local number = valid_number(value, minimum, maximum)
|
||||
return number and number == math.floor(number) and number or nil
|
||||
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 new_uuid()
|
||||
local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {})
|
||||
local id = rows[1] and rows[1].id
|
||||
if not valid_uuid(id) then
|
||||
error("[sky_phone] Database did not generate a valid CityWarn UUID.")
|
||||
end
|
||||
return id
|
||||
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, "citywarn_" .. operation, maximum, 60) then
|
||||
return nil, { success = false, error = "rate_limited" }
|
||||
end
|
||||
return session
|
||||
end
|
||||
|
||||
local function publisher_access(source)
|
||||
local job = Bridge.Framework.GetJob(source)
|
||||
local publisher = config.Publishers[job.name]
|
||||
local grade = tonumber(job.grade) or 0
|
||||
local on_duty = job.onDuty == true
|
||||
if not publisher or grade < publisher.MinimumGrade or (config.RequireDuty and not on_duty) then
|
||||
return nil, job, publisher
|
||||
end
|
||||
local allowed_categories = {}
|
||||
local allowed_lookup = {}
|
||||
for _, category in ipairs(publisher.Categories) do
|
||||
allowed_categories[#allowed_categories + 1] = category
|
||||
allowed_lookup[category] = true
|
||||
end
|
||||
return {
|
||||
allowed_categories = allowed_categories,
|
||||
allowed_lookup = allowed_lookup,
|
||||
can_city_wide = publisher.CityWide,
|
||||
job_name = job.name,
|
||||
job_label = type(job.label) == "string" and job.label or job.name,
|
||||
grade_label = type(job.gradeLabel) == "string" and job.gradeLabel or "",
|
||||
maximum_severity = publisher.MaximumSeverity,
|
||||
on_duty = on_duty,
|
||||
}, job, publisher
|
||||
end
|
||||
|
||||
local function context_dto(source)
|
||||
local access, job, publisher = publisher_access(source)
|
||||
local allowed_categories = {}
|
||||
if publisher and type(publisher.Categories) == "table" then
|
||||
for _, category in ipairs(publisher.Categories) do
|
||||
allowed_categories[#allowed_categories + 1] = category
|
||||
end
|
||||
end
|
||||
return {
|
||||
canPublish = access ~= nil,
|
||||
jobLabel = publisher and (job.label or job.name) or nil,
|
||||
gradeLabel = publisher and (job.gradeLabel or "") or nil,
|
||||
onDuty = job.onDuty == true,
|
||||
requiresDuty = config.RequireDuty,
|
||||
allowedCategories = allowed_categories,
|
||||
maximumSeverity = publisher and publisher.MaximumSeverity or nil,
|
||||
canCityWide = publisher and publisher.CityWide == true or false,
|
||||
}
|
||||
end
|
||||
|
||||
local function require_publisher(source)
|
||||
local access = publisher_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
|
||||
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 "CityWarn"
|
||||
end
|
||||
if #name > 120 then
|
||||
name = name:sub(1, 120)
|
||||
end
|
||||
return { identifier = identifier, name = name }
|
||||
end
|
||||
|
||||
local function alert_dto(row)
|
||||
return {
|
||||
id = row.id,
|
||||
title = row.title,
|
||||
body = row.body,
|
||||
instructions = row.instructions,
|
||||
category = row.category,
|
||||
severity = row.severity,
|
||||
status = row.status,
|
||||
area = {
|
||||
type = row.area_type,
|
||||
label = row.area_label,
|
||||
centerX = row.center_x and tonumber(row.center_x) or nil,
|
||||
centerY = row.center_y and tonumber(row.center_y) or nil,
|
||||
radius = row.radius and tonumber(row.radius) or nil,
|
||||
},
|
||||
sourceLabel = row.source_label,
|
||||
authorName = row.author_name,
|
||||
revision = tonumber(row.revision) or 1,
|
||||
startsAt = tonumber(row.starts_at_unix) * 1000,
|
||||
expiresAt = tonumber(row.expires_at_unix) * 1000,
|
||||
createdAt = tonumber(row.created_at_unix) * 1000,
|
||||
updatedAt = tonumber(row.updated_at_unix) * 1000,
|
||||
updates = {},
|
||||
}
|
||||
end
|
||||
|
||||
local function attach_updates(alerts)
|
||||
if #alerts < 1 then
|
||||
return alerts
|
||||
end
|
||||
local ids, placeholders, by_id = {}, {}, {}
|
||||
for index, alert in ipairs(alerts) do
|
||||
ids[index] = alert.id
|
||||
placeholders[index] = "?"
|
||||
by_id[alert.id] = alert
|
||||
end
|
||||
local rows = Bridge.Database.Query(([[
|
||||
SELECT `id`, `alert_id`, `kind`, `message`, `actor_name`,
|
||||
UNIX_TIMESTAMP(`created_at`) AS `created_at_unix`
|
||||
FROM `sky_phone_citywarn_updates`
|
||||
WHERE `alert_id` IN (%s)
|
||||
ORDER BY `created_at` DESC, `id` DESC
|
||||
]]):format(table.concat(placeholders, ",")), ids)
|
||||
for _, row in ipairs(rows) do
|
||||
local alert = by_id[row.alert_id]
|
||||
if alert then
|
||||
alert.updates[#alert.updates + 1] = {
|
||||
id = row.id,
|
||||
kind = row.kind,
|
||||
message = row.message,
|
||||
actorName = row.actor_name,
|
||||
createdAt = tonumber(row.created_at_unix) * 1000,
|
||||
}
|
||||
end
|
||||
end
|
||||
return alerts
|
||||
end
|
||||
|
||||
local function expire_alerts()
|
||||
Bridge.Database.Query([[
|
||||
UPDATE `sky_phone_citywarn_alerts`
|
||||
SET `status` = 'expired', `revision` = `revision` + 1
|
||||
WHERE `status` = 'active' AND `expires_at` <= NOW()
|
||||
]], {})
|
||||
end
|
||||
|
||||
local function query_alerts(status)
|
||||
local where = status == "active"
|
||||
and "alert.`status` = 'active' AND alert.`expires_at` > NOW()"
|
||||
or "(alert.`status` <> 'active' OR alert.`expires_at` <= NOW())"
|
||||
local rows = Bridge.Database.Query(([[
|
||||
SELECT %s
|
||||
FROM `sky_phone_citywarn_alerts` alert
|
||||
WHERE %s
|
||||
ORDER BY alert.`created_at` DESC, alert.`id` DESC
|
||||
LIMIT ?
|
||||
]]):format(alert_columns, where), { config.PageSize })
|
||||
local alerts = {}
|
||||
for _, row in ipairs(rows) do
|
||||
alerts[#alerts + 1] = alert_dto(row)
|
||||
end
|
||||
return attach_updates(alerts)
|
||||
end
|
||||
|
||||
local function load_alert(id)
|
||||
local rows = Bridge.Database.Query(([[
|
||||
SELECT %s FROM `sky_phone_citywarn_alerts` alert WHERE alert.`id` = ? LIMIT 1
|
||||
]]):format(alert_columns), { id })
|
||||
local alert = rows[1] and alert_dto(rows[1]) or nil
|
||||
return alert and attach_updates({ alert })[1] or nil
|
||||
end
|
||||
|
||||
local function validate_area(data, access)
|
||||
if type(data) ~= "table" or (data.type ~= "radius" and data.type ~= "district" and data.type ~= "city") then
|
||||
return nil
|
||||
end
|
||||
local label = valid_text(data.label, config.AreaLabelMaxLength, false)
|
||||
if not label or (data.type == "city" and not access.can_city_wide) then
|
||||
return nil
|
||||
end
|
||||
local center_x, center_y, radius
|
||||
if data.type == "radius" then
|
||||
center_x = valid_number(data.centerX, -10000, 10000)
|
||||
center_y = valid_number(data.centerY, -10000, 10000)
|
||||
radius = valid_integer(data.radius, config.MinimumRadius, config.MaximumRadius)
|
||||
if not center_x or not center_y or not radius then
|
||||
return nil
|
||||
end
|
||||
elseif data.centerX ~= nil or data.centerY ~= nil then
|
||||
center_x = valid_number(data.centerX, -10000, 10000)
|
||||
center_y = valid_number(data.centerY, -10000, 10000)
|
||||
if not center_x or not center_y then
|
||||
return nil
|
||||
end
|
||||
end
|
||||
return {
|
||||
type = data.type,
|
||||
label = label,
|
||||
center_x = center_x,
|
||||
center_y = center_y,
|
||||
radius = radius,
|
||||
}
|
||||
end
|
||||
|
||||
local function broadcast(kind, alert)
|
||||
TriggerClientEvent("sky_phone:citywarn:changed", -1, {
|
||||
alert = alert,
|
||||
alertId = alert.id,
|
||||
kind = kind,
|
||||
severity = alert.severity,
|
||||
sourceLabel = alert.sourceLabel,
|
||||
})
|
||||
end
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:citywarn:bootstrap", function(source)
|
||||
local _, error_response = require_phone(source, "read", config.RateLimits.Read)
|
||||
if error_response then
|
||||
return error_response
|
||||
end
|
||||
expire_alerts()
|
||||
return {
|
||||
success = true,
|
||||
data = {
|
||||
active = query_alerts("active"),
|
||||
archive = query_alerts("archive"),
|
||||
context = context_dto(source),
|
||||
onlinePlayers = #Bridge.Framework.GetPlayers(),
|
||||
},
|
||||
}
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:citywarn:publish", function(source, data)
|
||||
local _, error_response = require_phone(source, "write", config.RateLimits.Write)
|
||||
if error_response then
|
||||
return error_response
|
||||
end
|
||||
local access, access_error = require_publisher(source)
|
||||
if access_error then
|
||||
return access_error
|
||||
end
|
||||
local actor = actor_identity(source)
|
||||
local title = type(data) == "table" and valid_text(data.title, config.TitleMaxLength, false) or nil
|
||||
local body = type(data) == "table" and valid_text(data.body, config.BodyMaxLength, false) or nil
|
||||
local instructions = type(data) == "table" and valid_text(data.instructions, config.InstructionsMaxLength, true) or nil
|
||||
local duration = type(data) == "table" and valid_integer(data.durationMinutes, 1, config.MaximumDurationMinutes) or nil
|
||||
local area = type(data) == "table" and validate_area(data.area, access) or nil
|
||||
if not actor or not title or not body or instructions == nil or not duration or not area
|
||||
or not access.allowed_lookup[data.category]
|
||||
or not severity_rank[data.severity]
|
||||
or severity_rank[data.severity] > severity_rank[access.maximum_severity]
|
||||
then
|
||||
return { success = false, error = "invalid_warning" }
|
||||
end
|
||||
local counts = Bridge.Database.Query([[
|
||||
SELECT COUNT(*) AS `count` FROM `sky_phone_citywarn_alerts`
|
||||
WHERE `status` = 'active' AND `expires_at` > NOW()
|
||||
]], {})
|
||||
if tonumber(counts[1] and counts[1].count) >= config.MaximumActiveAlerts then
|
||||
return { success = false, error = "active_limit" }
|
||||
end
|
||||
|
||||
local id = new_uuid()
|
||||
local result = Bridge.Database.Query([[
|
||||
INSERT INTO `sky_phone_citywarn_alerts`
|
||||
(`id`, `title`, `body`, `instructions`, `category`, `severity`, `area_type`, `area_label`,
|
||||
`center_x`, `center_y`, `radius`, `source_job`, `source_label`, `author_identifier`,
|
||||
`author_name`, `expires_at`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL ? MINUTE))
|
||||
]], {
|
||||
id, title, body, instructions, data.category, data.severity, area.type, area.label,
|
||||
area.center_x, area.center_y, area.radius, access.job_name, access.job_label,
|
||||
actor.identifier, actor.name, duration,
|
||||
})
|
||||
if affected_rows(result) ~= 1 then
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
Bridge.Database.Query([[
|
||||
INSERT INTO `sky_phone_citywarn_updates`
|
||||
(`id`, `alert_id`, `kind`, `message`, `actor_identifier`, `actor_name`)
|
||||
VALUES (?, ?, 'published', ?, ?, ?)
|
||||
]], { new_uuid(), id, title, actor.identifier, actor.name })
|
||||
local alert = load_alert(id)
|
||||
broadcast("published", alert)
|
||||
return {
|
||||
success = true,
|
||||
data = { alert = alert, recipients = #Bridge.Framework.GetPlayers() },
|
||||
}
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:citywarn:update", function(source, data)
|
||||
local _, error_response = require_phone(source, "write", config.RateLimits.Write)
|
||||
if error_response then
|
||||
return error_response
|
||||
end
|
||||
local _, access_error = require_publisher(source)
|
||||
if access_error then
|
||||
return access_error
|
||||
end
|
||||
local actor = actor_identity(source)
|
||||
local message = type(data) == "table" and valid_text(data.message, config.UpdateMaxLength, false) or nil
|
||||
local revision = type(data) == "table" and valid_integer(data.revision, 1, 4294967295) or nil
|
||||
if not actor or not message or not revision or not valid_uuid(data.id) then
|
||||
return { success = false, error = "invalid_update" }
|
||||
end
|
||||
local result = Bridge.Database.Query([[
|
||||
UPDATE `sky_phone_citywarn_alerts`
|
||||
SET `revision` = `revision` + 1
|
||||
WHERE `id` = ? AND `status` = 'active' AND `expires_at` > NOW() AND `revision` = ?
|
||||
]], { data.id, revision })
|
||||
if affected_rows(result) ~= 1 then
|
||||
return { success = false, error = "revision_conflict" }
|
||||
end
|
||||
Bridge.Database.Query([[
|
||||
INSERT INTO `sky_phone_citywarn_updates`
|
||||
(`id`, `alert_id`, `kind`, `message`, `actor_identifier`, `actor_name`)
|
||||
VALUES (?, ?, 'update', ?, ?, ?)
|
||||
]], { new_uuid(), data.id, message, actor.identifier, actor.name })
|
||||
local alert = load_alert(data.id)
|
||||
broadcast("update", alert)
|
||||
return { success = true, data = { alert = alert } }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:citywarn:resolve", function(source, data)
|
||||
local _, error_response = require_phone(source, "write", config.RateLimits.Write)
|
||||
if error_response then
|
||||
return error_response
|
||||
end
|
||||
local _, access_error = require_publisher(source)
|
||||
if access_error then
|
||||
return access_error
|
||||
end
|
||||
local actor = actor_identity(source)
|
||||
local message = type(data) == "table" and valid_text(data.message, config.UpdateMaxLength, false) or nil
|
||||
local revision = type(data) == "table" and valid_integer(data.revision, 1, 4294967295) or nil
|
||||
if not actor or not message or not revision or not valid_uuid(data.id) then
|
||||
return { success = false, error = "invalid_update" }
|
||||
end
|
||||
local result = Bridge.Database.Query([[
|
||||
UPDATE `sky_phone_citywarn_alerts`
|
||||
SET `status` = 'resolved', `resolved_at` = NOW(), `revision` = `revision` + 1
|
||||
WHERE `id` = ? AND `status` = 'active' AND `revision` = ?
|
||||
]], { data.id, revision })
|
||||
if affected_rows(result) ~= 1 then
|
||||
return { success = false, error = "revision_conflict" }
|
||||
end
|
||||
Bridge.Database.Query([[
|
||||
INSERT INTO `sky_phone_citywarn_updates`
|
||||
(`id`, `alert_id`, `kind`, `message`, `actor_identifier`, `actor_name`)
|
||||
VALUES (?, ?, 'resolved', ?, ?, ?)
|
||||
]], { new_uuid(), data.id, message, actor.identifier, actor.name })
|
||||
local alert = load_alert(data.id)
|
||||
broadcast("resolved", alert)
|
||||
return { success = true, data = { alert = alert } }
|
||||
end)
|
||||
end)
|
||||
@@ -2853,6 +2853,60 @@ local schema = {
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_citywarn_alerts",
|
||||
columns = {
|
||||
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "title", type = "VARCHAR(120) NOT NULL" },
|
||||
{ name = "body", type = "VARCHAR(2000) NOT NULL" },
|
||||
{ name = "instructions", type = "VARCHAR(2000) NOT NULL" },
|
||||
{ name = "category", type = "ENUM('public_safety','police','fire','medical','infrastructure','evacuation') NOT NULL" },
|
||||
{ name = "severity", type = "ENUM('information','warning','danger','extreme') NOT NULL" },
|
||||
{ name = "status", type = "ENUM('active','resolved','expired') NOT NULL DEFAULT 'active'" },
|
||||
{ name = "area_type", type = "ENUM('radius','district','city') NOT NULL" },
|
||||
{ name = "area_label", type = "VARCHAR(120) NOT NULL" },
|
||||
{ name = "center_x", type = "DECIMAL(12,4) NULL" },
|
||||
{ name = "center_y", type = "DECIMAL(12,4) NULL" },
|
||||
{ name = "radius", type = "INT UNSIGNED NULL" },
|
||||
{ name = "source_job", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "source_label", type = "VARCHAR(120) NOT NULL" },
|
||||
{ name = "author_identifier", type = "VARCHAR(80) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "author_name", type = "VARCHAR(120) NOT NULL" },
|
||||
{ name = "revision", type = "INT UNSIGNED NOT NULL DEFAULT 1" },
|
||||
{ name = "starts_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
{ name = "expires_at", type = "DATETIME NOT NULL" },
|
||||
{ name = "resolved_at", type = "DATETIME 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 = "id",
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_citywarn_active", columns = "(`status`, `expires_at`, `created_at`, `id`)" },
|
||||
{ name = "idx_sky_phone_citywarn_area", columns = "(`area_type`, `status`, `created_at`, `id`)" },
|
||||
{ name = "idx_sky_phone_citywarn_source", columns = "(`source_job`, `created_at`, `id`)" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_citywarn_updates",
|
||||
columns = {
|
||||
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "alert_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "kind", type = "ENUM('published','update','resolved') NOT NULL" },
|
||||
{ name = "message", type = "VARCHAR(2000) NOT NULL" },
|
||||
{ name = "actor_identifier", type = "VARCHAR(80) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "actor_name", type = "VARCHAR(120) NOT NULL" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_citywarn_timeline", columns = "(`alert_id`, `created_at`, `id`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "alert_id", references = "`sky_phone_citywarn_alerts` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
}
|
||||
|
||||
Bridge.Database.Migrate("sky_phone", schema)
|
||||
|
||||
@@ -77,6 +77,7 @@ local allowed_device_namespaces = {
|
||||
apps = true,
|
||||
games = true,
|
||||
widgets = true,
|
||||
citywarn = true,
|
||||
}
|
||||
|
||||
local function trim(value)
|
||||
|
||||
Reference in New Issue
Block a user