mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 09:18:57 +00:00
1a2189ceb8
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.
502 lines
19 KiB
Lua
502 lines
19 KiB
Lua
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)
|