Files
Leon.Schmidt 1a2189ceb8 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.
2026-08-18 15:36:08 +02:00

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)