ic-web/shared/blocks.lua

204 lines
7.4 KiB
Lua
Raw Permalink Normal View History

-- ─────────────────────────────────────────────────────────────────────────────
-- ic-web Blockdefinition und Pruefung
--
-- Der sicherheitskritische Teil der Resource.
--
-- Seiteninhalte sind NIEMALS HTML, sondern eine Liste typisierter Bloecke.
-- Der Grund: die Seiten werden im NUI von pc-live angezeigt, also im selben
-- Kontext wie der PC selbst. Wer dort Markup einschleusen kann, kann auch
-- dessen NUI-Callbacks aufrufen. Ein Blockmodell schliesst das konstruktiv
-- aus, statt es filtern zu muessen Filtern von HTML ist ein Wettlauf, den
-- man langfristig verliert.
--
-- Jeder Block wird hier gegen ein festes Schema geprueft. Unbekannte Typen und
-- unbekannte Felder fallen weg, statt durchgereicht zu werden.
-- ─────────────────────────────────────────────────────────────────────────────
ICWeb = ICWeb or {}
ICWeb.Blocks = {}
local B = ICWeb.Blocks
-- Feldtypen: text (einzeilig), longtext (mehrzeilig), url, int, enum, list
B.SCHEMA = {
heading = {
label = 'Überschrift',
fields = {
text = { kind = 'text', max = 120, required = true },
level = { kind = 'enum', values = { 1, 2, 3 }, default = 2 },
},
},
text = {
label = 'Textabsatz',
fields = {
text = { kind = 'longtext', max = 4000, required = true },
},
},
image = {
label = 'Bild',
fields = {
url = { kind = 'url', max = 500, required = true },
alt = { kind = 'text', max = 160, default = '' },
caption = { kind = 'text', max = 200, default = '' },
},
},
list = {
label = 'Aufzählung',
fields = {
items = { kind = 'list', max = 20, itemMax = 200, required = true },
},
},
button = {
label = 'Schaltfläche',
fields = {
label = { kind = 'text', max = 60, required = true },
target = { kind = 'text', max = 120, required = true },
},
},
contact = {
label = 'Kontaktbox',
fields = {
email = { kind = 'text', max = 200, default = '' },
phone = { kind = 'text', max = 40, default = '' },
address = { kind = 'text', max = 200, default = '' },
},
},
divider = {
label = 'Trennlinie',
fields = {},
},
}
-- ── Hilfsfunktionen ─────────────────────────────────────────────────────────
-- Steuerzeichen entfernen und Laenge begrenzen. Winkelklammern bleiben
-- erhalten: der Renderer setzt Text ueber textContent, dort sind sie harmlos
-- und wuerden sonst grundlos verstuemmelt.
local function cleanText(value, maxLen, allowNewlines)
if type(value) ~= 'string' then return nil end
local out = value:gsub('%z', '')
if allowNewlines then
out = out:gsub('[\r]', '')
out = out:gsub('[^\n\32-\126\128-\255]', '')
else
out = out:gsub('[^\32-\126\128-\255]', '')
end
out = out:sub(1, maxLen or 200)
return out
end
-- Bild-URLs: nur https, nur erlaubte Hosts.
--
-- Ohne Hostpruefung landet frueher oder spaeter etwas auf einer IC-Seite, das
-- dort nicht hingehoert. Die Meldefunktion faengt den Rest, aber sie greift
-- erst nachtraeglich.
function B.CheckImageUrl(url)
if type(url) ~= 'string' then return nil, 'keine Adresse' end
url = url:gsub('%s', '')
if #url == 0 or #url > 500 then return nil, 'Adresse zu lang oder leer' end
local host = url:match('^https://([^/]+)/')
if not host then return nil, 'nur https-Adressen erlaubt' end
host = host:lower()
local allowed = (Config and Config.ImageHosts) or {}
for _, pattern in ipairs(allowed) do
-- Exakter Host oder Subdomain davon.
if host == pattern or host:sub(-(#pattern + 1)) == ('.' .. pattern) then
return url
end
end
return nil, ('Bildhost nicht erlaubt: %s'):format(host)
end
-- ── Einzelnen Block pruefen ─────────────────────────────────────────────────
-- Gibt (block, nil) oder (nil, fehlertext) zurueck.
function B.Validate(block)
if type(block) ~= 'table' then return nil, 'Block ist kein Objekt' end
local def = B.SCHEMA[block.type]
if not def then
return nil, ('unbekannter Blocktyp: %s'):format(tostring(block.type))
end
-- Neu aufbauen statt kopieren: was nicht im Schema steht, faellt weg.
local out = { type = block.type }
for name, field in pairs(def.fields) do
local raw = block[name]
if field.kind == 'text' or field.kind == 'longtext' then
local value = cleanText(raw, field.max, field.kind == 'longtext')
if value == nil or value == '' then
if field.required then
return nil, ('%s: Feld "%s" fehlt'):format(def.label, name)
end
value = field.default or ''
end
out[name] = value
elseif field.kind == 'url' then
local value, err = B.CheckImageUrl(raw)
if not value then
if field.required then
return nil, ('%s: %s'):format(def.label, err or 'ungueltige Adresse')
end
value = ''
end
out[name] = value
elseif field.kind == 'enum' then
local ok = false
for _, allowed in ipairs(field.values) do
if raw == allowed then ok = true break end
end
out[name] = ok and raw or field.default
elseif field.kind == 'list' then
if type(raw) ~= 'table' then
if field.required then
return nil, ('%s: Liste fehlt'):format(def.label)
end
raw = {}
end
local items = {}
for i = 1, math.min(#raw, field.max) do
local item = cleanText(raw[i], field.itemMax)
if item and item ~= '' then items[#items + 1] = item end
end
if #items == 0 and field.required then
return nil, ('%s: Liste ist leer'):format(def.label)
end
out[name] = items
end
end
return out, nil
end
-- ── Ganze Seite pruefen ─────────────────────────────────────────────────────
-- Gibt (blockliste, nil) oder (nil, fehlertext) zurueck.
function B.ValidatePage(blocks)
if type(blocks) ~= 'table' then return nil, 'Seiteninhalt ist keine Liste' end
local maxBlocks = (Config and Config.MaxBlocksPerPage) or 60
if #blocks > maxBlocks then
return nil, ('zu viele Bloecke (%d, erlaubt sind %d)'):format(#blocks, maxBlocks)
end
local out = {}
for i = 1, #blocks do
local block, err = B.Validate(blocks[i])
if not block then
return nil, ('Block %d: %s'):format(i, err)
end
out[#out + 1] = block
end
return out, nil
end