IC-Webhosting: Domaenen, Webseiten und Postfaecher ueber Anmeldungen

Ein IC-Hostinganbieter im Spiel. Er vergibt Domaenen, richtet je Domaene einen
Zugang ein, und die Inhaber pflegen darauf ihre Webseite.

Die gesamte Verwaltung haengt an einer Anmeldung, nicht an Job oder
Serverrechten. Ein Zugang ist zugleich ein Postfach: wer sich anmeldet,
bekommt das Postfach dieser Adresse. Mehrere Personen koennen denselben Zugang
benutzen und dasselbe Postfach gleichzeitig offen haben - ein Firmenpostfach
gehoert der Firma, nicht einer Person.

Drei Ebenen:
  superadmin  vergibt Domaenen und setzt je Domaene einen Admin ein
  admin       pflegt die Seiten seiner Domaene und legt dort Zugaenge an
  editor      pflegt nur die Seiten

Seiteninhalte sind kein HTML, sondern typisierte Bloecke. Die Seiten werden im
NUI des IC-Computers angezeigt, also im selben Kontext wie dieser selbst - wer
dort Markup einschleusen kann, kann auch dessen Callbacks aufrufen. Ein
Blockmodell schliesst das konstruktiv aus, statt es filtern zu muessen.

Passwoerter liegen gesalzen und gehasht in der Datenbank, gerechnet wird in der
Datenbank. Fuenf Fehlversuche je Benutzername sperren den Namen kurzzeitig.

Enthaelt README.md mit Einrichtung und Anbindung an den IC-Computer sowie
ANLEITUNG.md fuer die Bedienung im Spiel.
This commit is contained in:
Bjoern Flessing 2026-08-09 11:50:53 +00:00
commit a2ce3a69fd
14 changed files with 3018 additions and 0 deletions

445
server/auth.lua Normal file
View file

@ -0,0 +1,445 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- ic-web Zugaenge und Anmeldung
--
-- Die gesamte Verwaltung laeuft ueber Anmeldungen, nicht ueber Job oder
-- Serverrechte. Ein Zugang ist eine Mailadresse mit Passwort:
--
-- superadmin admin@liveinvader.ls legt Domaenen an und setzt je Domaene
-- einen Admin ein
-- admin <name>@<domaene> pflegt die Seiten seiner Domaene und
-- legt dort weitere Zugaenge an
-- editor <name>@<domaene> pflegt nur die Seiten
--
-- Wer angemeldet ist, bekommt zugleich das Postfach dieser Adresse. Mehrere
-- Personen koennen sich mit demselben Zugang anmelden und dasselbe Postfach
-- offen haben das ist beabsichtigt, ein Firmenpostfach gehoert der Firma
-- und nicht einer Person.
--
-- Das Passwort ist ein IC-Passwort. Es liegt gesalzen und gehasht in der
-- Datenbank und wird nie an den Client geschickt, aber es ist keine
-- Sicherheitsgrenze gegen jemanden mit Datenbankzugriff.
-- ─────────────────────────────────────────────────────────────────────────────
ICWeb = ICWeb or {}
ICWeb.Auth = {}
local A = ICWeb.Auth
local DB = ICWeb.DB
local FW = ICWeb.FW
-- source → { username, role, domain, since, lastSeen }
local sessions = {}
-- source → { [username] = true }: zusaetzlich per Passwort geoeffnete
-- Postfaecher. Siehe A.OpenMailbox weiter unten.
local openMailboxes = {}
-- ── Hilfen ──────────────────────────────────────────────────────────────────
math.randomseed(os.time())
local function randomSalt()
local out = {}
for i = 1, 32 do
out[i] = ('%x'):format(math.random(0, 15))
end
return table.concat(out)
end
-- Benutzername ist immer eine Mailadresse. Gibt (user, domain) oder nil.
function A.SplitUsername(raw)
local name = tostring(raw or ''):lower():gsub('%s', '')
local user, domain = name:match('^([a-z0-9%.%-_]+)@([a-z0-9%-]+%.[a-z]+)$')
if not user then return nil end
return user, domain, name
end
-- ── Anmelden ────────────────────────────────────────────────────────────────
-- Gibt (session, nil) oder (nil, fehlertext) zurueck.
function A.Login(src, rawUsername, password)
local user, domain, username = A.SplitUsername(rawUsername)
if not user then return nil, 'Benutzername muss eine Adresse sein, z. B. admin@liveinvader.ls' end
if type(password) ~= 'string' or password == '' then
return nil, 'Kein Passwort angegeben.'
end
-- Erst die Bremse, dann die Pruefung: sonst laesst sich der Zaehler
-- umgehen, indem man einfach weiter probiert.
local fails = DB.CountLoginFails(username, Config.Login.windowSeconds)
if fails >= Config.Login.maxAttempts then
return nil, ('Zu viele Fehlversuche. Bitte %d Minuten warten.')
:format(math.ceil(Config.Login.windowSeconds / 60))
end
local account = DB.CheckPassword(username, password)
if not account then
DB.AddLoginFail(username, FW.GetIdentifier(src))
ICWeb.Log.Write(src, 'login.failed', { username = username }, 'warn')
return nil, 'Benutzername oder Passwort falsch.'
end
if not ICWeb.Bool(account.active) then return nil, 'Dieser Zugang ist gesperrt.' end
DB.ClearLoginFails(username)
DB.TouchLogin(account.id)
-- Alte Anmeldung dieses Spielers sauber beenden, damit das vorherige
-- Postfach nicht offen bleibt.
A.Logout(src)
sessions[src] = {
id = account.id,
username = account.username,
role = account.role,
domain = account.domain,
display = account.display_name,
mustChange = ICWeb.Bool(account.must_change),
since = os.time(),
lastSeen = os.time(),
}
A.AttachMailbox(src, account.username, account.display_name)
ICWeb.Log.Write(src, 'login.ok', { username = username, role = account.role })
return A.Public(src), nil
end
function A.Logout(src)
local session = sessions[src]
if not session then return end
-- Das Postfach der Anmeldung nur schliessen, wenn es nicht zusaetzlich
-- ueber "Postfach hinzufuegen" offen ist. Sonst nimmt das Abmelden von der
-- Verwaltung dem Mailclient ein Postfach weg, das dort eigenstaendig
-- freigeschaltet wurde.
if not (openMailboxes[src] and openMailboxes[src][session.username]) then
A.DetachMailbox(src, session.username)
end
sessions[src] = nil
end
-- ── Postfach an die Anmeldung koppeln ───────────────────────────────────────
-- Der Zugriff wird beim Anmelden erteilt und beim Abmelden wieder entzogen.
-- Dadurch haengt das Postfach am Zugang und nicht am Charakter, und mehrere
-- Angemeldete koennen es gleichzeitig offen haben.
--
-- Beide Richtungen laufen ueber pcall: das Postfach ist eine Dreingabe zur
-- Anmeldung. Wenn ic-mail zickt, soll man sich trotzdem anmelden und abmelden
-- koennen nur eben ohne Postfach.
function A.AttachMailbox(src, address, displayName)
if GetResourceState('ic-mail') ~= 'started' then return end
local identifier = FW.GetIdentifier(src)
if not identifier then return end
local ok, err = pcall(function()
exports['ic-mail']:EnsureSharedAddress(address, displayName, 'ic-web')
exports['ic-mail']:GrantAddressAccess(address, identifier, FW.GetName(src), 'send', 'ic-web')
end)
if not ok then
print(('[ic-web] ^3Postfach %s konnte nicht geöffnet werden: %s^7')
:format(tostring(address), tostring(err)))
end
end
function A.DetachMailbox(src, address)
if GetResourceState('ic-mail') ~= 'started' then return end
local identifier = FW.GetIdentifier(src)
if not identifier then return end
local ok, err = pcall(function()
exports['ic-mail']:RevokeAddressAccess(address, identifier)
end)
if not ok then
print(('[ic-web] ^3Postfach %s konnte nicht geschlossen werden: %s^7')
:format(tostring(address), tostring(err)))
end
end
-- ── Weitere Postfaecher oeffnen ─────────────────────────────────────────────
--
-- Im Mailclient soll man mehrere Postfaecher gleichzeitig haben: das eigene,
-- das der Firma, das der Behoerde. Jedes davon ist ein Zugang mit Passwort,
-- also wird es genauso freigeschaltet wie die Anmeldung selbst nur ohne
-- Verwaltungsrechte. Wer die Zugangsdaten kennt, sieht das Postfach.
--
-- Bewusst getrennt von der Anmeldung an der Verwaltung: man kann zehn
-- Postfaecher offen haben und trotzdem nur fuer eines Seiten pflegen duerfen.
--
-- source → { [username] = true } (oben deklariert)
-- Gibt (true, nil) oder (false, fehlertext) zurueck.
function A.OpenMailbox(src, rawUsername, password)
local user, _, username = A.SplitUsername(rawUsername)
if not user then return false, 'Der Postfachname ist eine Adresse, z. B. presse@weazel.ls' end
if type(password) ~= 'string' or password == '' then
return false, 'Kein Passwort angegeben.'
end
-- Dieselbe Bremse wie bei der Anmeldung: sonst waere das Freischalten
-- eines Postfachs der bequemere Weg, Passwoerter durchzuprobieren.
if DB.CountLoginFails(username, Config.Login.windowSeconds) >= Config.Login.maxAttempts then
return false, ('Zu viele Fehlversuche. Bitte %d Minuten warten.')
:format(math.ceil(Config.Login.windowSeconds / 60))
end
local account = DB.CheckPassword(username, password)
if not account then
DB.AddLoginFail(username, FW.GetIdentifier(src))
ICWeb.Log.Write(src, 'mailbox.failed', { username = username }, 'warn')
return false, 'Adresse oder Passwort falsch.'
end
if not ICWeb.Bool(account.active) then return false, 'Dieser Zugang ist gesperrt.' end
local boxes = openMailboxes[src] or {}
local count = 0
for _ in pairs(boxes) do count = count + 1 end
if not boxes[account.username] and count >= (Config.MaxOpenMailboxes or 10) then
return false, ('Höchstens %d zusätzliche Postfächer gleichzeitig.')
:format(Config.MaxOpenMailboxes or 10)
end
DB.ClearLoginFails(username)
boxes[account.username] = true
openMailboxes[src] = boxes
A.AttachMailbox(src, account.username, account.display_name)
ICWeb.Log.Write(src, 'mailbox.opened', { username = account.username })
return true, nil
end
function A.CloseMailbox(src, rawUsername)
local _, _, username = A.SplitUsername(rawUsername)
if not username then return false, 'Unbekanntes Postfach.' end
local boxes = openMailboxes[src]
if not boxes or not boxes[username] then
return false, 'Dieses Postfach ist hier nicht geöffnet.'
end
boxes[username] = nil
A.DetachMailbox(src, username)
ICWeb.Log.Write(src, 'mailbox.closed', { username = username })
return true, nil
end
-- Welche Postfaecher hat dieser Spieler ueber ein Passwort geoeffnet? Der
-- Mailclient braucht das, um genau bei diesen ein "Entfernen" anzubieten
-- das eigene und dienstlich zugewiesene Postfaecher gehoeren ihm nicht.
function A.ListOpenMailboxes(src)
local out = {}
for username in pairs(openMailboxes[src] or {}) do out[#out + 1] = username end
table.sort(out)
return out
end
-- ── Sitzung ─────────────────────────────────────────────────────────────────
function A.Get(src)
local session = sessions[src]
if not session then return nil end
local idle = Config.Login.idleSeconds or 1800
if idle > 0 and (os.time() - session.lastSeen) > idle then
A.Logout(src)
return nil
end
session.lastSeen = os.time()
return session
end
-- Was der Client ueber seine Anmeldung erfahren darf. Kein Passwort, kein Salt.
function A.Public(src)
local session = A.Get(src)
if not session then return nil end
return {
id = session.id,
username = session.username,
role = session.role,
domain = session.domain,
display = session.display,
mustChange = session.mustChange == true,
}
end
-- ── Rechte ──────────────────────────────────────────────────────────────────
function A.IsSuperadmin(src)
local session = A.Get(src)
return session ~= nil and session.role == 'superadmin'
end
-- Seiten dieser Domaene pflegen.
function A.CanEdit(src, domain)
local session = A.Get(src)
if not session then return false end
if session.role == 'superadmin' then return true end
return session.domain == tostring(domain or ''):lower()
end
-- Zugaenge dieser Domaene verwalten. Editoren duerfen das nicht sonst koennte
-- sich jeder Editor selbst zum Admin machen.
function A.CanManageAccounts(src, domain)
local session = A.Get(src)
if not session then return false end
if session.role == 'superadmin' then return true end
return session.role == 'admin' and session.domain == tostring(domain or ''):lower()
end
-- ── Zugaenge anlegen und aendern ────────────────────────────────────────────
-- Gibt (accountId, nil) oder (nil, fehlertext) zurueck.
function A.CreateAccount(data)
local user, domain, username = A.SplitUsername(data.username)
if not user then return nil, 'Benutzername muss eine Adresse sein, z. B. presse@weazel.ls' end
if data.role ~= 'admin' and data.role ~= 'editor' and data.role ~= 'superadmin' then
return nil, 'Unbekannte Rolle.'
end
-- Der superadmin haengt an keiner Domaene, alle anderen an genau ihrer.
if data.role ~= 'superadmin' and domain ~= data.domain then
return nil, 'Der Benutzername muss auf die eigene Domäne enden.'
end
local pwErr = A.CheckPassword(data.password)
if pwErr then return nil, pwErr end
if DB.GetAccountByUsername(username) then
return nil, 'Diesen Benutzernamen gibt es bereits.'
end
-- Nicht als `role == 'superadmin' and nil or domain` schreiben: in Lua
-- faellt der nil-Zweig durch und man bekaeme doch die Domaene.
local accountDomain = domain
if data.role == 'superadmin' then accountDomain = nil end
local id = DB.CreateAccount({
username = username,
domain = accountDomain,
role = data.role,
display_name = tostring(data.display_name or user):sub(1, 120),
salt = randomSalt(),
password = data.password,
must_change = data.must_change and true or false,
created_by = data.created_by,
})
if not id then return nil, 'Speichern fehlgeschlagen.' end
-- Das Postfach gibt es ab sofort, auch wenn sich noch niemand angemeldet hat.
if GetResourceState('ic-mail') == 'started' then
exports['ic-mail']:EnsureSharedAddress(username, data.display_name or user, 'ic-web')
end
return id, nil
end
-- Gibt nil zurueck, wenn das Passwort taugt, sonst den Grund.
function A.CheckPassword(password)
if type(password) ~= 'string' then return 'Kein Passwort angegeben.' end
if #password < (Config.Login.minPasswordLength or 6) then
return ('Das Passwort muss mindestens %d Zeichen haben.')
:format(Config.Login.minPasswordLength or 6)
end
if #password > 100 then return 'Das Passwort ist zu lang.' end
return nil
end
-- Nach dem Wechsel des eigenen Passworts soll die Aufforderung verschwinden,
-- ohne dass sich der Zugang neu anmelden muss.
function A.ClearMustChange(src)
local session = sessions[src]
if session then session.mustChange = false end
end
function A.SetPassword(accountId, password)
local err = A.CheckPassword(password)
if err then return false, err end
DB.SetPassword(accountId, randomSalt(), password)
return true, nil
end
-- ── Aufraeumen ──────────────────────────────────────────────────────────────
AddEventHandler('playerDropped', function()
local src = source
for username in pairs(openMailboxes[src] or {}) do
A.DetachMailbox(src, username)
end
openMailboxes[src] = nil
A.Logout(src)
end)
-- Beim Herunterfahren wird NICHT mehr aufgeraeumt.
--
-- A.Logout ruft ic-mail auf, und ic-mail wartet dort auf eine Datenbankantwort.
-- Waehrend die Resource stoppt, laesst sich die wartende Koroutine nicht mehr
-- fortsetzen: die Abfrage laeuft durch, das Ergebnis kommt aber ins Leere und
-- der Server meldet "Execution of function reference in script host failed"
-- zugeordnet zu ic-mail, obwohl der Ausloeser hier liegt.
--
-- Aufgeraeumt wird stattdessen beim Start (A.CleanupStaleMailAccess). Das
-- deckt auch den Fall ab, den ein Stop-Handler nie erwischt: den Absturz.
AddEventHandler('onResourceStop', function(res)
if res ~= GetCurrentResourceName() then return end
sessions, openMailboxes = {}, {}
end)
-- Zugriffsrechte, die frueher einmal ueber eine Anmeldung erteilt wurden.
-- Beim Start gibt es keine Anmeldung, also darf auch kein Recht mehr offen sein.
function A.CleanupStaleMailAccess()
if GetResourceState('ic-mail') ~= 'started' then return end
local ok, removed = pcall(function()
return exports['ic-mail']:RevokeAccessGrantedBy('ic-web')
end)
if ok and tonumber(removed) and tonumber(removed) > 0 then
print(('[ic-web] %d verwaiste Postfachzugriffe entfernt.'):format(tonumber(removed)))
end
end
-- ── Ersteinrichtung ─────────────────────────────────────────────────────────
-- Ohne einen ersten Zugang kaeme niemand je in die Verwaltung hinein.
function A.EnsureMainAdmin()
local cfg = Config.MainAdmin
if DB.GetAccountByUsername(cfg.username) then return end
-- Die Domaene des Anbieters muss es geben, sonst haengt das Postfach ins Leere.
if GetResourceState('ic-mail') == 'started' then
local _, realmDomain = A.SplitUsername(cfg.username)
if realmDomain and not exports['ic-mail']:GetRealmByName(realmDomain) then
exports['ic-mail']:CreateRealm({
realm = realmDomain,
type = 'system',
display_name = cfg.providerName or realmDomain,
})
end
end
local id, err = A.CreateAccount({
username = cfg.username,
role = 'superadmin',
display_name = cfg.providerName or 'Administration',
password = cfg.initialPassword,
must_change = true,
created_by = 'system',
})
if not id then
print(('[ic-web] ^1Hauptzugang konnte nicht angelegt werden: %s^7'):format(tostring(err)))
return
end
print('[ic-web] ^3Hauptzugang angelegt:^7 ' .. cfg.username)
print('[ic-web] ^3Startpasswort:^7 ' .. cfg.initialPassword
.. ' bitte nach der ersten Anmeldung ändern (/webpasswd).')
end

235
server/database.lua Normal file
View file

@ -0,0 +1,235 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- ic-web Datenzugriff
-- Saemtliches SQL steht hier. Der uebrige Code kennt keine Tabellennamen.
-- ─────────────────────────────────────────────────────────────────────────────
ICWeb = ICWeb or {}
ICWeb.DB = {}
local DB = ICWeb.DB
-- TINYINT(1) kommt je nach Treiberfassung als 1, als true oder als "1" an.
-- Ein Vergleich mit == 1 geht deshalb frueher oder spaeter schief und zwar
-- still: aus "aktiv" wird "gesperrt", ohne Fehlermeldung. Deshalb laeuft jede
-- Ja/Nein-Spalte aus der Datenbank durch diese Funktion.
function ICWeb.Bool(value)
if value == nil or value == false then return false end
if value == true then return true end
if type(value) == 'number' then return value ~= 0 end
if type(value) == 'string' then
return value ~= '0' and value ~= '' and value:lower() ~= 'false'
end
return true
end
-- ── Sites ───────────────────────────────────────────────────────────────────
function DB.GetSiteByDomain(domain)
return MySQL.single.await('SELECT * FROM ic_web_sites WHERE domain = ?', { domain })
end
function DB.GetSiteById(id)
return MySQL.single.await('SELECT * FROM ic_web_sites WHERE id = ?', { id })
end
function DB.GetSitesForOwner(ownerType, ownerId)
return MySQL.query.await(
'SELECT * FROM ic_web_sites WHERE owner_type = ? AND owner_id = ? ORDER BY domain',
{ ownerType, ownerId }) or {}
end
function DB.CountSitesForOwner(ownerType, ownerId)
local row = MySQL.single.await(
'SELECT COUNT(*) AS n FROM ic_web_sites WHERE owner_type = ? AND owner_id = ?',
{ ownerType, ownerId })
return (row and tonumber(row.n)) or 0
end
function DB.GetAllSites()
return MySQL.query.await('SELECT * FROM ic_web_sites ORDER BY domain') or {}
end
function DB.SetOwner(id, ownerType, ownerId)
return MySQL.update.await(
'UPDATE ic_web_sites SET owner_type = ?, owner_id = ? WHERE id = ?',
{ ownerType, ownerId, id })
end
function DB.CreateSite(data)
return MySQL.insert.await(
[[INSERT INTO ic_web_sites (domain, title, theme, owner_type, owner_id, published, created_by)
VALUES (?,?,?,?,?,?,?)]],
{ data.domain, data.title, data.theme, data.owner_type, data.owner_id,
data.published and 1 or 0, data.created_by })
end
function DB.UpdateSite(id, data)
return MySQL.update.await(
'UPDATE ic_web_sites SET title = ?, theme = ?, published = ? WHERE id = ?',
{ data.title, data.theme, data.published and 1 or 0, id })
end
function DB.SetBlocked(id, blocked)
return MySQL.update.await('UPDATE ic_web_sites SET blocked = ? WHERE id = ?',
{ blocked and 1 or 0, id })
end
function DB.DeleteSite(id)
return MySQL.update.await('DELETE FROM ic_web_sites WHERE id = ?', { id })
end
-- ── Seiten ──────────────────────────────────────────────────────────────────
function DB.GetPages(siteId)
return MySQL.query.await(
'SELECT * FROM ic_web_pages WHERE site_id = ? ORDER BY sort_order, id', { siteId }) or {}
end
function DB.GetPage(siteId, slug)
return MySQL.single.await(
'SELECT * FROM ic_web_pages WHERE site_id = ? AND slug = ?', { siteId, slug })
end
function DB.CountPages(siteId)
local row = MySQL.single.await(
'SELECT COUNT(*) AS n FROM ic_web_pages WHERE site_id = ?', { siteId })
return (row and tonumber(row.n)) or 0
end
-- Anlegen oder ueberschreiben. Der eindeutige Schluessel ist (site_id, slug).
function DB.SavePage(siteId, slug, title, blocksJson, sortOrder)
return MySQL.update.await(
[[INSERT INTO ic_web_pages (site_id, slug, title, blocks, sort_order)
VALUES (?,?,?,?,?)
ON DUPLICATE KEY UPDATE title = VALUES(title), blocks = VALUES(blocks),
sort_order = VALUES(sort_order)]],
{ siteId, slug, title, blocksJson, sortOrder or 0 })
end
function DB.DeletePage(siteId, slug)
return MySQL.update.await(
'DELETE FROM ic_web_pages WHERE site_id = ? AND slug = ?', { siteId, slug })
end
-- ── Zugaenge ────────────────────────────────────────────────────────────────
-- Das Passwort erscheint hier nur als Abfrageparameter. Gehasht wird in der
-- Datenbank, damit weder Klartext noch Hash in einer Lua-Variable landen.
local ACCOUNT_FIELDS =
'id, username, domain, role, display_name, active, must_change, created_by, created_at, last_login'
function DB.GetAccountByUsername(username)
return MySQL.single.await(
'SELECT ' .. ACCOUNT_FIELDS .. ' FROM ic_web_accounts WHERE username = ?', { username })
end
function DB.GetAccountById(id)
return MySQL.single.await(
'SELECT ' .. ACCOUNT_FIELDS .. ' FROM ic_web_accounts WHERE id = ?', { id })
end
function DB.GetAccountsForDomain(domain)
return MySQL.query.await(
'SELECT ' .. ACCOUNT_FIELDS .. ' FROM ic_web_accounts WHERE domain = ? ORDER BY username',
{ domain }) or {}
end
function DB.CountAccountsForDomain(domain)
local row = MySQL.single.await(
'SELECT COUNT(*) AS n FROM ic_web_accounts WHERE domain = ?', { domain })
return (row and tonumber(row.n)) or 0
end
function DB.GetAllAccounts()
return MySQL.query.await(
'SELECT ' .. ACCOUNT_FIELDS .. ' FROM ic_web_accounts ORDER BY domain, username') or {}
end
function DB.CreateAccount(data)
return MySQL.insert.await(
[[INSERT INTO ic_web_accounts
(username, domain, role, display_name, salt, password_hash, must_change, created_by)
VALUES (?,?,?,?,?, SHA2(CONCAT(?, ?), 256), ?, ?)]],
{ data.username, data.domain, data.role, data.display_name,
data.salt, data.salt, data.password,
data.must_change and 1 or 0, data.created_by })
end
-- Gibt den Zugang zurueck, wenn das Passwort stimmt, sonst nil.
function DB.CheckPassword(username, password)
return MySQL.single.await(
'SELECT ' .. ACCOUNT_FIELDS .. ' FROM ic_web_accounts'
.. ' WHERE username = ? AND password_hash = SHA2(CONCAT(salt, ?), 256)',
{ username, password })
end
function DB.SetPassword(id, salt, password)
return MySQL.update.await(
'UPDATE ic_web_accounts SET salt = ?, password_hash = SHA2(CONCAT(?, ?), 256),'
.. ' must_change = 0 WHERE id = ?',
{ salt, salt, password, id })
end
function DB.SetAccountActive(id, active)
return MySQL.update.await('UPDATE ic_web_accounts SET active = ? WHERE id = ?',
{ active and 1 or 0, id })
end
function DB.SetAccountRole(id, role)
return MySQL.update.await('UPDATE ic_web_accounts SET role = ? WHERE id = ?', { role, id })
end
function DB.DeleteAccount(id)
return MySQL.update.await('DELETE FROM ic_web_accounts WHERE id = ?', { id })
end
function DB.TouchLogin(id)
return MySQL.update.await('UPDATE ic_web_accounts SET last_login = NOW() WHERE id = ?', { id })
end
-- ── Fehlversuche ────────────────────────────────────────────────────────────
function DB.AddLoginFail(username, identifier)
return MySQL.insert.await(
'INSERT INTO ic_web_login_fails (username, identifier) VALUES (?,?)',
{ username, identifier })
end
function DB.CountLoginFails(username, withinSeconds)
local row = MySQL.single.await(
[[SELECT COUNT(*) AS n FROM ic_web_login_fails
WHERE username = ? AND created_at > DATE_SUB(NOW(), INTERVAL ? SECOND)]],
{ username, withinSeconds })
return (row and tonumber(row.n)) or 0
end
function DB.ClearLoginFails(username)
return MySQL.update.await('DELETE FROM ic_web_login_fails WHERE username = ?', { username })
end
function DB.PruneLoginFails(olderThanSeconds)
return MySQL.update.await(
'DELETE FROM ic_web_login_fails WHERE created_at < DATE_SUB(NOW(), INTERVAL ? SECOND)',
{ olderThanSeconds })
end
-- ── Meldungen ───────────────────────────────────────────────────────────────
function DB.AddReport(siteId, pageSlug, reason, reportedBy)
return MySQL.insert.await(
'INSERT INTO ic_web_reports (site_id, page_slug, reason, reported_by) VALUES (?,?,?,?)',
{ siteId, pageSlug, reason, reportedBy })
end
function DB.RecentReportBy(siteId, identifier, withinSeconds)
return MySQL.single.await(
[[SELECT id FROM ic_web_reports
WHERE site_id = ? AND reported_by = ?
AND created_at > DATE_SUB(NOW(), INTERVAL ? SECOND) LIMIT 1]],
{ siteId, identifier, withinSeconds })
end
function DB.GetOpenReports()
return MySQL.query.await(
[[SELECT r.*, s.domain FROM ic_web_reports r
JOIN ic_web_sites s ON s.id = r.site_id
WHERE r.status = 'open' ORDER BY r.created_at DESC LIMIT 100]]) or {}
end
function DB.SetReportStatus(id, status)
return MySQL.update.await('UPDATE ic_web_reports SET status = ? WHERE id = ?', { status, id })
end

220
server/domains.lua Normal file
View file

@ -0,0 +1,220 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- ic-web Domaenen: Registrierung, Besitz, Bearbeitungsrecht
--
-- Domaenen vergibt der Anbieter ueber die Verwaltung (ICWeb.Manage). Die
-- Selbstanmeldung ueber den ESX-Job weiter unten ist der aeltere Weg und
-- standardmaessig ausgeschaltet siehe Config.SelfRegistration.
--
-- Die Domaene selbst wird in ic-mail angelegt. Dadurch bekommt eine Firma mit
-- der Webseite automatisch auch die passende Mail-Domaene, und es kann nicht
-- passieren, dass zwei Systeme dieselbe Domaene verschiedenen Besitzern geben.
-- ─────────────────────────────────────────────────────────────────────────────
ICWeb = ICWeb or {}
ICWeb.Domains = {}
local D = ICWeb.Domains
local DB = ICWeb.DB
local FW = ICWeb.FW
-- ── Namenspruefung ──────────────────────────────────────────────────────────
-- Gibt (domain, nil) oder (nil, fehlertext) zurueck.
--
-- allowReserved hebt nur die Namensliste auf, nicht die Formregeln. Sie soll
-- verhindern, dass Spieler sich Behoerdennamen greifen; der Anbieter vergibt
-- diese Namen ja gerade absichtlich. Der technische Schutz Domaenen des
-- Mailsystems haengt nicht hier, sondern in D.EnsureRealm und gilt fuer alle.
function D.CheckName(raw, allowReserved)
if type(raw) ~= 'string' then return nil, 'kein Name angegeben' end
local domain = raw:lower():gsub('%s', '')
local name, tld = domain:match('^([a-z0-9%-]+)%.([a-z]+)$')
if not name then
return nil, 'Format: name.endung, nur Buchstaben, Ziffern und Bindestrich'
end
if #name < Config.Domain.minLength or #name > Config.Domain.maxLength then
return nil, ('Name muss %d bis %d Zeichen lang sein')
:format(Config.Domain.minLength, Config.Domain.maxLength)
end
if name:sub(1, 1) == '-' or name:sub(-1) == '-' then
return nil, 'Name darf nicht mit Bindestrich beginnen oder enden'
end
local tldOk = false
for _, allowed in ipairs(Config.Domain.allowedTlds) do
if tld == allowed then tldOk = true break end
end
if not tldOk then
return nil, ('Endung nicht erlaubt. Moeglich: %s')
:format(table.concat(Config.Domain.allowedTlds, ', '))
end
if not allowReserved then
for _, reserved in ipairs(Config.Domain.reserved) do
if name == reserved then return nil, 'dieser Name ist reserviert' end
end
end
return domain, nil
end
-- ── Darf dieser Spieler ohne Anmeldung fuer seinen Job registrieren? ─────────
-- Gibt (ownerType, ownerId, nil) oder (nil, nil, fehlertext) zurueck.
--
-- Der Normalfall ist ausgeschaltet: Domaenen vergibt der Anbieter ueber die
-- Verwaltung. Siehe Config.SelfRegistration.
function D.CanRegister(src)
if not Config.SelfRegistration.enabled then
return nil, nil, 'Domaenen vergibt die Administration von Liveinvader'
end
local job, grade = FW.GetJob(src)
if not job then return nil, nil, 'kein Job' end
if Config.Registration.blockedJobs[job] then
return nil, nil, 'mit diesem Job nicht moeglich'
end
local allowed = Config.Registration.allowedJobs
if allowed and next(allowed) ~= nil then
local found = false
for _, j in ipairs(allowed) do
if j == job then found = true break end
end
if not found then return nil, nil, 'dieser Job ist nicht freigeschaltet' end
end
if grade < (Config.Registration.minGrade or 0) then
return nil, nil, ('Dienstgrad zu niedrig (mindestens %d)')
:format(Config.Registration.minGrade or 0)
end
local count = DB.CountSitesForOwner('job', job)
if count >= (Config.Registration.maxDomainsPerJob or 1) then
return nil, nil, ('Grenze erreicht: %d Domaenen je Job'):format(count)
end
return 'job', job, nil
end
-- ── Bearbeitungsrecht ───────────────────────────────────────────────────────
-- Ausschliesslich ueber die Anmeldung: wer mit einem Zugang dieser Domaene
-- angemeldet ist, darf ihre Seiten pflegen. Job und Serverrechte spielen keine
-- Rolle mehr wer den Zugang kennt, verwaltet die Seite, unabhaengig davon,
-- welchen Charakter er gerade spielt.
function D.CanEdit(src, site)
if not site then return false end
return ICWeb.Auth.CanEdit(src, site.domain)
end
-- ── Mail-Domaene sicherstellen ──────────────────────────────────────────────
-- Gibt (true, nil) oder (false, fehlertext) zurueck.
--
-- Laeuft ic-mail nicht, gilt das als Erfolg: die Webseite funktioniert
-- eigenstaendig, es gibt dann nur keine Mailadressen darauf.
function D.EnsureRealm(domain, title, ownerId)
if GetResourceState('ic-mail') ~= 'started' then return true, nil end
local existing = exports['ic-mail']:GetRealmByName(domain)
if existing then
-- System- und Buergerdomaenen gehoeren niemandem und haben deshalb
-- owner_id = NULL. Ohne diese Pruefung wuerde die Besitzpruefung unten
-- durchwinken, und eine Firmenwebseite haenge plötzlich an mail.ls
-- also an der Domaene saemtlicher persoenlicher Postfaecher.
if ICWeb.Bool(existing.is_default)
or existing.type == 'system' or existing.type == 'citizen' then
return false, 'diese Domaene gehoert zum Mailsystem und ist nicht vergebbar'
end
-- Domaene gibt es schon fuer Mail. Nur uebernehmen, wenn sie demselben
-- Besitzer gehoert sonst wuerde die Webseite die Mailadressen einer
-- fremden Firma kapern.
if existing.owner_id and existing.owner_id ~= ownerId then
return false, 'diese Domaene gehoert bereits einer anderen Stelle'
end
return true, nil
end
local ok, createErr = exports['ic-mail']:CreateRealm({
realm = domain,
type = 'company',
display_name = title or domain,
owner_id = ownerId,
})
if not ok then
return false, ('Domaene konnte nicht angelegt werden: %s'):format(tostring(createErr))
end
return true, nil
end
-- ── Registrieren ────────────────────────────────────────────────────────────
-- Gibt (site, nil) oder (nil, fehlertext) zurueck.
function D.Register(src, rawDomain, title)
local ownerType, ownerId, err = D.CanRegister(src)
if not ownerType then return nil, err end
local domain, nameErr = D.CheckName(rawDomain)
if not domain then return nil, nameErr end
if DB.GetSiteByDomain(domain) then
return nil, 'diese Domaene ist bereits vergeben'
end
local realmOk, realmErr = D.EnsureRealm(domain, title, ownerId)
if not realmOk then return nil, realmErr end
local site = D.CreateSiteWithHome(domain, title, ownerType, ownerId, FW.GetIdentifier(src))
if not site then return nil, 'Speichern fehlgeschlagen' end
-- Bei der Selbstanmeldung gibt es niemanden, der einen Zugang einrichten
-- koennte. Also wird er hier erzeugt und einmalig angezeigt.
local username = 'admin@' .. domain
local password = D.RandomPassword()
local accountId = ICWeb.Auth.CreateAccount({
username = username,
domain = domain,
role = 'admin',
display_name = title or domain,
password = password,
must_change = true,
created_by = 'selfservice',
})
ICWeb.Log.Write(src, 'domain.registered', { domain = domain, owner = ownerId })
return site, nil, accountId and { username = username, password = password } or nil
end
-- Domaene, Startseite und Rueckgabe in einem. Beide Anlagewege gehen hier durch,
-- damit eine neue Domaene nie ohne Startseite dasteht.
function D.CreateSiteWithHome(domain, title, ownerType, ownerId, createdBy)
local siteId = DB.CreateSite({
domain = domain,
title = tostring(title or domain):sub(1, 120),
theme = Config.DefaultTheme,
owner_type = ownerType or 'job',
owner_id = ownerId,
published = false,
created_by = createdBy,
})
if not siteId then return nil end
DB.SavePage(siteId, 'home', title or domain, json.encode({
{ type = 'heading', text = title or domain, level = 1 },
{ type = 'text', text = 'Diese Seite wurde noch nicht bearbeitet.' },
}), 0)
return DB.GetSiteById(siteId)
end
-- Aussprechbares Startpasswort. Es muss beim ersten Anmelden geaendert werden,
-- deshalb zaehlt Merkbarkeit hier mehr als Laenge.
function D.RandomPassword()
local words = { 'anker', 'birke', 'delta', 'falke', 'gipfel', 'hafen',
'insel', 'kompass', 'lotse', 'nordwind', 'orkan', 'rebe' }
return words[math.random(#words)] .. tostring(math.random(100, 999))
end

137
server/framework.lua Normal file
View file

@ -0,0 +1,137 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- ic-web ESX-Bridge, Rechte und Logging
--
-- Alle Framework-Zugriffe zentral. ESX wird bei Bedarf geholt, nicht beim
-- Laden: beim Start ist es je nach Reihenfolge noch nicht bereit, und ein
-- einmal fehlgeschlagener Versuch haette die Bridge dauerhaft lahmgelegt.
-- ─────────────────────────────────────────────────────────────────────────────
ICWeb = ICWeb or {}
ICWeb.FW = {}
local FW = ICWeb.FW
local ESX
local function esx()
if ESX then return ESX end
local ok, obj = pcall(function() return exports['es_extended']:getSharedObject() end)
if ok and obj then ESX = obj end
return ESX
end
local function player(src)
local api = esx()
return api and api.GetPlayerFromId(src) or nil
end
function FW.GetIdentifier(src)
local xPlayer = player(src)
return xPlayer and xPlayer.identifier or nil
end
function FW.GetName(src)
local xPlayer = player(src)
if not xPlayer then return GetPlayerName(src) or 'Unbekannt' end
if xPlayer.getName then return xPlayer.getName() end
return xPlayer.name or 'Unbekannt'
end
-- Job und Dienstgrad. Beides wird fuer die Registrierung gebraucht.
function FW.GetJob(src)
local xPlayer = player(src)
if not xPlayer or not xPlayer.job then return nil, 0 end
return xPlayer.job.name, tonumber(xPlayer.job.grade) or 0
end
-- Jobliste fuer die Adminansicht. Der Job entscheidet nicht mehr ueber Rechte,
-- er steht nur noch als Besitzervermerk an einer Domaene.
function FW.GetJobs()
local api = esx()
if not api or not api.GetJobs then return {} end
local out = {}
for name, job in pairs(api.GetJobs()) do
out[#out + 1] = { name = name, label = job.label or name }
end
table.sort(out, function(a, b) return a.label < b.label end)
return out
end
function FW.Notify(src, message)
local xPlayer = player(src)
if xPlayer and xPlayer.showNotification then
xPlayer.showNotification(message)
return
end
TriggerClientEvent('esx:showNotification', src, message)
end
-- ── Adminrechte ─────────────────────────────────────────────────────────────
function FW.IsAdmin(src)
if not src or src == 0 then return true end -- Serverkonsole
local mode = Config.Admin.mode or 'either'
local okEsx = false
local xPlayer = player(src)
if xPlayer and xPlayer.getGroup then
okEsx = Config.Admin.esxGroups[xPlayer.getGroup()] == true
end
local okAce = IsPlayerAceAllowed(src, Config.Admin.acePermission) == true
if mode == 'esx' then return okEsx end
if mode == 'ace' then return okAce end
if mode == 'both' then return okEsx and okAce end
return okEsx or okAce
end
-- ── Logging ─────────────────────────────────────────────────────────────────
-- Ausschliesslich ueber fmsdk. Fehlt es, wird verworfen statt zu crashen.
ICWeb.Log = {}
function ICWeb.Log.Write(src, action, details, level)
if GetResourceState('fmsdk') ~= 'started' then
if Config.Debug then
print(('[ic-web] fmsdk fehlt, Log verworfen: %s'):format(tostring(action)))
end
return false
end
local meta = details or {}
meta.action = (Config.Logging.actionPrefix or 'ic_web.') .. tostring(action)
meta.resource = GetCurrentResourceName()
meta.playerSource = src or nil
meta.identifier = src and FW.GetIdentifier(src) or nil
local ok = pcall(function()
exports.fmsdk:Log(Config.Logging.dataset or 'ic_web', level or 'info',
('Webhosting: %s'):format(meta.action), meta)
end)
return ok
end
-- ── Drossel ─────────────────────────────────────────────────────────────────
-- Jedes RegisterNetEvent ist frei aufrufbar.
local buckets = {}
function ICWeb.ThrottleOk(src, name, maxPerWindow, windowMs)
local now = GetGameTimer()
local key = ('%s:%s'):format(src, name)
local entry = buckets[key]
if not entry or (now - entry.start) > windowMs then
buckets[key] = { start = now, count = 1 }
return true
end
entry.count = entry.count + 1
return entry.count <= maxPerWindow
end
AddEventHandler('playerDropped', function()
local prefix = tostring(source) .. ':'
for key in pairs(buckets) do
if key:sub(1, #prefix) == prefix then buckets[key] = nil end
end
end)

411
server/main.lua Normal file
View file

@ -0,0 +1,411 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- ic-web Netzwerkereignisse, Exports, Konsolenbefehle
--
-- Jedes Ereignis ist frei aufrufbar und deshalb gedrosselt. Rechte werden
-- ausnahmslos serverseitig aus der Anmeldung abgeleitet; der Client erfaehrt
-- nur das Ergebnis.
-- ─────────────────────────────────────────────────────────────────────────────
local S = ICWeb.Sites
local D = ICWeb.Domains
local M = ICWeb.Manage
local A = ICWeb.Auth
local DB = ICWeb.DB
local FW = ICWeb.FW
local function reply(src, reqId, ok, data, err)
TriggerClientEvent('ic-web:client:response', src, reqId,
{ ok = ok == true, data = data, error = err })
end
-- ── Anmeldung ────────────────────────────────────────────────────────────────
RegisterNetEvent('ic-web:server:login', function(reqId, username, password)
local src = source
-- Eng gedrosselt: die Sperre je Benutzername greift erst nach fuenf
-- Versuchen, diese hier bremst das Durchprobieren vieler Benutzernamen.
if not ICWeb.ThrottleOk(src, 'login', 8, 60000) then
return reply(src, reqId, false, nil, 'Zu viele Versuche, bitte kurz warten.')
end
local session, err = A.Login(src, username, password)
reply(src, reqId, session ~= nil, session, err)
end)
RegisterNetEvent('ic-web:server:logout', function(reqId)
local src = source
A.Logout(src)
reply(src, reqId, true)
end)
-- Wer bin ich gerade? Ohne Anmeldung: data = nil, aber ok = true.
RegisterNetEvent('ic-web:server:session', function(reqId)
local src = source
if not ICWeb.ThrottleOk(src, 'session', 20, 10000) then return end
reply(src, reqId, true, {
session = A.Public(src),
themes = Config.Themes,
tlds = Config.Domain.allowedTlds,
imageHosts = Config.ImageHosts,
provider = Config.MainAdmin.providerName,
selfService = Config.SelfRegistration.enabled,
})
end)
RegisterNetEvent('ic-web:server:changePassword', function(reqId, accountId, password)
local src = source
if not ICWeb.ThrottleOk(src, 'passwd', 5, 60000) then return end
local ok, err = M.SetAccountPassword(src, accountId, password)
if ok then FW.Notify(src, 'Passwort geändert.') end
reply(src, reqId, ok, nil, err)
end)
-- ── Lesen: jeder darf ────────────────────────────────────────────────────────
RegisterNetEvent('ic-web:server:getSite', function(reqId, domain, slug)
local src = source
if not ICWeb.ThrottleOk(src, 'get', 30, 10000) then return end
local site, err = S.GetPublic(domain, slug)
reply(src, reqId, site ~= nil, site, err)
end)
-- ── Eigene Domaenen ──────────────────────────────────────────────────────────
RegisterNetEvent('ic-web:server:listMine', function(reqId)
local src = source
if not ICWeb.ThrottleOk(src, 'listMine', 10, 10000) then return end
reply(src, reqId, true, S.ListMine(src))
end)
RegisterNetEvent('ic-web:server:getEditable', function(reqId, domain)
local src = source
if not ICWeb.ThrottleOk(src, 'getEditable', 20, 10000) then return end
local site, err = S.GetForEditor(src, domain)
reply(src, reqId, site ~= nil, site, err)
end)
-- ── Selbstanmeldung einer Domaene (nur wenn eingeschaltet) ───────────────────
RegisterNetEvent('ic-web:server:register', function(reqId, domain, title)
local src = source
if not ICWeb.ThrottleOk(src, 'register', 3, 60000) then
return reply(src, reqId, false, nil, 'Zu viele Versuche, bitte kurz warten.')
end
local site, err, credentials = D.Register(src, domain, title)
if not site then
ICWeb.Log.Write(src, 'domain.rejected', { domain = tostring(domain), reason = err }, 'warn')
return reply(src, reqId, false, nil, err)
end
FW.Notify(src, ('Domäne %s registriert.'):format(site.domain))
reply(src, reqId, true, {
domain = site.domain, title = site.title, credentials = credentials,
})
end)
-- ── Bearbeiten ───────────────────────────────────────────────────────────────
RegisterNetEvent('ic-web:server:savePage', function(reqId, domain, slug, title, blocks)
local src = source
if not ICWeb.ThrottleOk(src, 'save', 20, 60000) then
return reply(src, reqId, false, nil, 'Zu viele Speichervorgänge, bitte kurz warten.')
end
local ok, err = S.SavePage(src, domain, slug, title, blocks)
reply(src, reqId, ok, nil, err)
end)
RegisterNetEvent('ic-web:server:deletePage', function(reqId, domain, slug)
local src = source
if not ICWeb.ThrottleOk(src, 'delete', 10, 60000) then return end
local ok, err = S.DeletePage(src, domain, slug)
reply(src, reqId, ok, nil, err)
end)
RegisterNetEvent('ic-web:server:updateSite', function(reqId, domain, data)
local src = source
if not ICWeb.ThrottleOk(src, 'update', 20, 60000) then return end
if type(data) ~= 'table' then return reply(src, reqId, false, nil, 'Ungültige Daten.') end
local ok, err = S.UpdateSite(src, domain, data)
reply(src, reqId, ok, nil, err)
end)
-- ── Melden ───────────────────────────────────────────────────────────────────
-- Ohne Anmeldung: jeder Spieler soll eine Seite melden koennen.
RegisterNetEvent('ic-web:server:report', function(reqId, domain, slug, reason)
local src = source
if not ICWeb.ThrottleOk(src, 'report', 5, 60000) then return end
local ok, err = S.Report(src, domain, slug, reason)
if ok then FW.Notify(src, 'Meldung eingegangen. Danke.') end
reply(src, reqId, ok, nil, err)
end)
-- ── Postfaecher im Mailclient ───────────────────────────────────────────────
-- Ein Postfach freischalten ist eine Passwortpruefung wie die Anmeldung und
-- deshalb genauso eng gedrosselt.
RegisterNetEvent('ic-web:server:openMailbox', function(reqId, username, password)
local src = source
if not ICWeb.ThrottleOk(src, 'openMailbox', 8, 60000) then
return reply(src, reqId, false, nil, 'Zu viele Versuche, bitte kurz warten.')
end
local ok, err = A.OpenMailbox(src, username, password)
if ok then FW.Notify(src, 'Postfach hinzugefügt.') end
reply(src, reqId, ok, nil, err)
end)
RegisterNetEvent('ic-web:server:closeMailbox', function(reqId, username)
local src = source
if not ICWeb.ThrottleOk(src, 'closeMailbox', 20, 60000) then return end
local ok, err = A.CloseMailbox(src, username)
reply(src, reqId, ok, nil, err)
end)
RegisterNetEvent('ic-web:server:listMailboxes', function(reqId)
local src = source
if not ICWeb.ThrottleOk(src, 'listMailboxes', 20, 10000) then return end
reply(src, reqId, true, A.ListOpenMailboxes(src))
end)
-- ── Zugaenge je Domaene ─────────────────────────────────────────────────────
RegisterNetEvent('ic-web:server:listAccounts', function(reqId, domain)
local src = source
if not ICWeb.ThrottleOk(src, 'accounts', 20, 10000) then return end
local list, err = M.ListAccounts(src, domain)
reply(src, reqId, list ~= nil, list, err)
end)
RegisterNetEvent('ic-web:server:createAccount',
function(reqId, domain, username, password, role, displayName)
local src = source
if not ICWeb.ThrottleOk(src, 'createAccount', 10, 60000) then return end
local account, err = M.CreateAccount(src, domain, username, password, role, displayName)
reply(src, reqId, account ~= nil, account, err)
end)
RegisterNetEvent('ic-web:server:setAccountPassword', function(reqId, accountId, password)
local src = source
if not ICWeb.ThrottleOk(src, 'setPassword', 10, 60000) then return end
local ok, err = M.SetAccountPassword(src, accountId, password)
reply(src, reqId, ok, nil, err)
end)
RegisterNetEvent('ic-web:server:setAccountActive', function(reqId, accountId, active)
local src = source
if not ICWeb.ThrottleOk(src, 'setActive', 20, 60000) then return end
local ok, err = M.SetAccountActive(src, accountId, active == true)
reply(src, reqId, ok, nil, err)
end)
RegisterNetEvent('ic-web:server:setAccountRole', function(reqId, accountId, role)
local src = source
if not ICWeb.ThrottleOk(src, 'setRole', 20, 60000) then return end
local ok, err = M.SetAccountRole(src, accountId, role)
reply(src, reqId, ok, nil, err)
end)
RegisterNetEvent('ic-web:server:deleteAccount', function(reqId, accountId)
local src = source
if not ICWeb.ThrottleOk(src, 'deleteAccount', 10, 60000) then return end
local ok, err = M.DeleteAccount(src, accountId)
reply(src, reqId, ok, nil, err)
end)
-- ── Anbieterebene ───────────────────────────────────────────────────────────
RegisterNetEvent('ic-web:server:adminListSites', function(reqId)
local src = source
if not ICWeb.ThrottleOk(src, 'adminList', 20, 10000) then return end
local list, err = M.AdminListSites(src)
reply(src, reqId, list ~= nil, list, err)
end)
RegisterNetEvent('ic-web:server:adminListAccounts', function(reqId)
local src = source
if not ICWeb.ThrottleOk(src, 'adminAccounts', 20, 10000) then return end
local list, err = M.AdminListAccounts(src)
reply(src, reqId, list ~= nil, list, err)
end)
RegisterNetEvent('ic-web:server:adminListJobs', function(reqId)
local src = source
if not ICWeb.ThrottleOk(src, 'adminJobs', 10, 10000) then return end
local list, err = M.AdminListJobs(src)
reply(src, reqId, list ~= nil, list, err)
end)
RegisterNetEvent('ic-web:server:adminRegister',
function(reqId, domain, title, ownerId, adminUser, adminPassword)
local src = source
if not ICWeb.ThrottleOk(src, 'adminRegister', 10, 60000) then return end
local site, err, credentials = M.AdminRegister(src, domain, title, ownerId,
adminUser, adminPassword)
reply(src, reqId, site ~= nil,
site and { domain = site.domain, credentials = credentials } or nil, err)
end)
RegisterNetEvent('ic-web:server:adminSetBlocked', function(reqId, domain, blocked)
local src = source
if not ICWeb.ThrottleOk(src, 'adminBlock', 20, 60000) then return end
local ok, err = M.AdminSetBlocked(src, domain, blocked == true)
reply(src, reqId, ok, nil, err)
end)
RegisterNetEvent('ic-web:server:adminSetOwner', function(reqId, domain, ownerId)
local src = source
if not ICWeb.ThrottleOk(src, 'adminOwner', 10, 60000) then return end
local ok, err = M.AdminSetOwner(src, domain, ownerId)
reply(src, reqId, ok, nil, err)
end)
RegisterNetEvent('ic-web:server:adminDeleteSite', function(reqId, domain)
local src = source
if not ICWeb.ThrottleOk(src, 'adminDelete', 5, 60000) then return end
local ok, err = M.AdminDeleteSite(src, domain)
reply(src, reqId, ok, nil, err)
end)
RegisterNetEvent('ic-web:server:adminListReports', function(reqId)
local src = source
if not ICWeb.ThrottleOk(src, 'adminReports', 20, 10000) then return end
local list, err = M.AdminListReports(src)
reply(src, reqId, list ~= nil, list, err)
end)
RegisterNetEvent('ic-web:server:adminSetReportStatus', function(reqId, id, status)
local src = source
if not ICWeb.ThrottleOk(src, 'adminReportStatus', 30, 60000) then return end
local ok, err = M.AdminSetReportStatus(src, id, status)
reply(src, reqId, ok, nil, err)
end)
-- ── Konsolenbefehle ─────────────────────────────────────────────────────────
-- Kein IC-Weg, sondern die Notbremse des Serverbetreibers. Absichtlich nur in
-- der Serverkonsole und fuer Serveradmins nicht ueber die Anmeldung.
local function consoleAllowed(src)
if not src or src == 0 then return true end
return FW.IsAdmin(src)
end
RegisterCommand('webpasswd', function(src, args)
if not consoleAllowed(src) then return end
local username = tostring(args[1] or ''):lower()
local password = tostring(args[2] or '')
if username == '' or password == '' then
print('[ic-web] /webpasswd <benutzer@domaene> <neues-passwort>')
return
end
local account = DB.GetAccountByUsername(username)
if not account then print('[ic-web] Zugang nicht gefunden.') return end
local ok, err = A.SetPassword(account.id, password)
print(ok and ('[ic-web] Passwort von %s geändert.'):format(username)
or ('[ic-web] Fehlgeschlagen: %s'):format(tostring(err)))
end, true)
RegisterCommand('webaccounts', function(src)
if not consoleAllowed(src) then return end
local rows = DB.GetAllAccounts()
print(('[ic-web] %d Zugänge:'):format(#rows))
for _, a in ipairs(rows) do
print((' %-34s %-11s %-22s %s'):format(
a.username, a.role, a.domain or '',
ICWeb.Bool(a.active) and 'aktiv' or 'gesperrt'))
end
end, true)
RegisterCommand('webreports', function(src)
if not consoleAllowed(src) then return end
local reports = DB.GetOpenReports()
if #reports == 0 then print('[ic-web] Keine offenen Meldungen.') return end
print(('[ic-web] %d offene Meldungen:'):format(#reports))
for _, r in ipairs(reports) do
print((' #%d %-24s %-10s %s'):format(
r.id, r.domain, r.page_slug or '-', (r.reason or ''):sub(1, 60)))
end
print('[ic-web] Sperren mit /webblock <domain>, erledigen mit /webreport done <id>')
end, true)
RegisterCommand('webblock', function(src, args)
if not consoleAllowed(src) then return end
local domain = tostring(args[1] or ''):lower()
local site = DB.GetSiteByDomain(domain)
if not site then print('[ic-web] Domäne nicht gefunden.') return end
local blocked = not ICWeb.Bool(site.blocked)
DB.SetBlocked(site.id, blocked)
ICWeb.Log.Write(src, blocked and 'site.blocked' or 'site.unblocked', { domain = domain }, 'warn')
print(('[ic-web] %s ist jetzt %s.'):format(domain, blocked and 'gesperrt' or 'frei'))
end, true)
RegisterCommand('webreport', function(src, args)
if not consoleAllowed(src) then return end
local action = tostring(args[1] or '')
local id = tonumber(args[2])
if (action ~= 'done' and action ~= 'dismiss') or not id then
print('[ic-web] /webreport done <id> oder /webreport dismiss <id>')
return
end
DB.SetReportStatus(id, action == 'done' and 'reviewed' or 'dismissed')
print(('[ic-web] Meldung #%d als %s markiert.'):format(id, action))
end, true)
-- ── Exports fuer andere Resources ────────────────────────────────────────────
exports('GetSite', function(domain, slug)
return S.GetPublic(domain, slug)
end)
exports('DomainExists', function(domain)
return DB.GetSiteByDomain(tostring(domain or ''):lower()) ~= nil
end)
exports('GetSitesForJob', function(job)
return DB.GetSitesForOwner('job', job)
end)
-- Wer ist an diesem Bildschirm angemeldet? pc-live nutzt das, um das Postfach
-- des Zugangs zu oeffnen statt des persoenlichen.
exports('GetSession', function(src)
return A.Public(src)
end)
-- ── Start ────────────────────────────────────────────────────────────────────
AddEventHandler('onResourceStart', function(res)
if res ~= GetCurrentResourceName() then return end
if GetResourceState('ic-mail') ~= 'started' then
print('[ic-web] ^3Hinweis^7: ic-mail läuft nicht Domänen werden ohne Postfächer angelegt.')
end
A.EnsureMainAdmin()
A.CleanupStaleMailAccess()
DB.PruneLoginFails(86400)
local sites = MySQL.single.await('SELECT COUNT(*) AS n FROM ic_web_sites')
print(('[ic-web] bereit. %d Domänen registriert.'):format((sites and sites.n) or 0))
end)

329
server/manage.lua Normal file
View file

@ -0,0 +1,329 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- ic-web Verwaltung
--
-- Zwei Ebenen, beide ueber die Anmeldung:
--
-- superadmin legt Domaenen an, setzt je Domaene einen Admin ein, sperrt
-- und loescht. Sieht alles.
-- admin verwaltet die Zugaenge seiner Domaene und pflegt ihre Seiten.
--
-- Ein Zugang ist zugleich ein Postfach: wer hier einen Zugang anlegt, legt
-- damit die Mailadresse mit an.
-- ─────────────────────────────────────────────────────────────────────────────
ICWeb = ICWeb or {}
ICWeb.Manage = {}
local M = ICWeb.Manage
local DB = ICWeb.DB
local FW = ICWeb.FW
local D = ICWeb.Domains
local A = ICWeb.Auth
-- Was der Client ueber einen Zugang erfahren darf.
local function publicAccount(row)
return {
id = row.id,
username = row.username,
domain = row.domain,
role = row.role,
display_name = row.display_name,
active = ICWeb.Bool(row.active),
must_change = ICWeb.Bool(row.must_change),
last_login = row.last_login,
created_at = row.created_at,
}
end
-- ── Zugaenge einer Domaene ──────────────────────────────────────────────────
function M.ListAccounts(src, domain)
domain = tostring(domain or ''):lower()
if not A.CanManageAccounts(src, domain) then return nil, 'Keine Berechtigung.' end
local out = {}
for _, row in ipairs(DB.GetAccountsForDomain(domain)) do
out[#out + 1] = publicAccount(row)
end
return out, nil
end
-- Gibt (zugang, nil) oder (nil, fehlertext) zurueck.
--
-- Das Passwort wird vom Anleger vergeben und danach weitergegeben. Es kommt
-- deshalb einmal im Klartext zurueck an den Bildschirm des Anlegers, aber nie
-- wieder aus der Datenbank heraus.
function M.CreateAccount(src, domain, username, password, role, displayName)
domain = tostring(domain or ''):lower()
if not A.CanManageAccounts(src, domain) then return nil, 'Keine Berechtigung.' end
local site = DB.GetSiteByDomain(domain)
if not site then return nil, 'Domäne nicht gefunden.' end
-- Nur der Anbieter darf weitere Anbieterzugaenge oder Domaenenadmins
-- ausserhalb der eigenen Domaene anlegen.
if role == 'superadmin' and not A.IsSuperadmin(src) then
return nil, 'Keine Berechtigung für diese Rolle.'
end
if role ~= 'admin' and role ~= 'editor' and role ~= 'superadmin' then
return nil, 'Unbekannte Rolle.'
end
if DB.CountAccountsForDomain(domain) >= (Config.MaxAccountsPerDomain or 15) then
return nil, ('Höchstens %d Zugänge je Domäne.'):format(Config.MaxAccountsPerDomain or 15)
end
local session = A.Get(src)
local id, err = A.CreateAccount({
username = username,
domain = domain,
role = role,
display_name = displayName,
password = password,
must_change = true,
created_by = session and session.username or 'system',
})
if not id then return nil, err end
ICWeb.Log.Write(src, 'account.created',
{ domain = domain, username = tostring(username):lower(), role = role })
return publicAccount(DB.GetAccountById(id)), nil
end
function M.SetAccountPassword(src, accountId, password)
local account = DB.GetAccountById(tonumber(accountId) or -1)
if not account then return false, 'Zugang nicht gefunden.' end
-- Den eigenen Zugang darf jeder aendern, fremde nur die Verwaltung.
local session = A.Get(src)
local own = session ~= nil and session.username == account.username
if not own and not A.CanManageAccounts(src, account.domain) then
return false, 'Keine Berechtigung.'
end
-- Der Anbieterzugang laesst sich nur von ihm selbst oder einem anderen
-- Anbieterzugang aendern.
if account.role == 'superadmin' and not own and not A.IsSuperadmin(src) then
return false, 'Keine Berechtigung.'
end
local ok, err = A.SetPassword(account.id, password)
if not ok then return false, err end
if own then A.ClearMustChange(src) end
ICWeb.Log.Write(src, 'account.password.changed', { username = account.username })
return true, nil
end
function M.SetAccountActive(src, accountId, active)
local account = DB.GetAccountById(tonumber(accountId) or -1)
if not account then return false, 'Zugang nicht gefunden.' end
if not A.CanManageAccounts(src, account.domain) then return false, 'Keine Berechtigung.' end
if account.role == 'superadmin' and not A.IsSuperadmin(src) then
return false, 'Keine Berechtigung.'
end
local session = A.Get(src)
if session and session.username == account.username then
return false, 'Den eigenen Zugang kannst du nicht sperren.'
end
DB.SetAccountActive(account.id, active == true)
ICWeb.Log.Write(src, active and 'account.enabled' or 'account.disabled',
{ username = account.username })
return true, nil
end
function M.SetAccountRole(src, accountId, role)
local account = DB.GetAccountById(tonumber(accountId) or -1)
if not account then return false, 'Zugang nicht gefunden.' end
if not A.CanManageAccounts(src, account.domain) then return false, 'Keine Berechtigung.' end
if role ~= 'admin' and role ~= 'editor' then return false, 'Unbekannte Rolle.' end
if account.role == 'superadmin' then return false, 'Der Anbieterzugang bleibt, wie er ist.' end
local session = A.Get(src)
if session and session.username == account.username then
return false, 'Die eigene Rolle kannst du nicht ändern.'
end
DB.SetAccountRole(account.id, role)
ICWeb.Log.Write(src, 'account.role.changed', { username = account.username, role = role })
return true, nil
end
-- Loeschen entfernt den Zugang, nicht das Postfach: dort liegen Nachrichten.
function M.DeleteAccount(src, accountId)
local account = DB.GetAccountById(tonumber(accountId) or -1)
if not account then return false, 'Zugang nicht gefunden.' end
if not A.CanManageAccounts(src, account.domain) then return false, 'Keine Berechtigung.' end
if account.role == 'superadmin' then return false, 'Der Anbieterzugang lässt sich nicht löschen.' end
local session = A.Get(src)
if session and session.username == account.username then
return false, 'Den eigenen Zugang kannst du nicht löschen.'
end
DB.DeleteAccount(account.id)
ICWeb.Log.Write(src, 'account.deleted', { username = account.username }, 'warn')
return true, nil
end
-- ── Anbieterebene ───────────────────────────────────────────────────────────
function M.AdminListSites(src)
if not A.IsSuperadmin(src) then return nil, 'Keine Berechtigung.' end
local out = {}
for _, s in ipairs(DB.GetAllSites()) do
out[#out + 1] = {
domain = s.domain,
title = s.title,
owner_type = s.owner_type,
owner_id = s.owner_id,
published = ICWeb.Bool(s.published),
blocked = ICWeb.Bool(s.blocked),
pages = DB.CountPages(s.id),
accounts = DB.CountAccountsForDomain(s.domain),
created_at = s.created_at,
}
end
return out, nil
end
-- Domaene anlegen und gleich den ersten Zugang dafuer einrichten. Ohne diesen
-- Zugang koennte niemand die neue Seite pflegen.
--
-- Gibt (site, nil, zugangsdaten) oder (nil, fehlertext) zurueck.
function M.AdminRegister(src, domain, title, ownerId, adminUser, adminPassword)
if not A.IsSuperadmin(src) then return nil, 'Keine Berechtigung.' end
-- Der Anbieter darf auch Behoerdennamen vergeben dafuer ist er da.
local name, err = D.CheckName(domain, true)
if not name then return nil, err end
ownerId = tostring(ownerId or ''):lower()
if ownerId == '' then return nil, 'Kein Besitzer (Job) angegeben.' end
if DB.GetSiteByDomain(name) then return nil, 'Diese Domäne ist bereits vergeben.' end
-- Zugangsdaten zuerst pruefen: eine Domaene ohne Zugang waere unbrauchbar,
-- und ein Rueckbau nach dem Anlegen ist fehleranfaelliger als eine
-- Pruefung davor.
local user = tostring(adminUser or 'admin'):lower():gsub('[^a-z0-9%.%-_]', '')
if user == '' then user = 'admin' end
local username = user .. '@' .. name
local password = adminPassword
local generated = false
if type(password) ~= 'string' or password == '' then
password = D.RandomPassword()
generated = true
end
local pwErr = A.CheckPassword(password)
if pwErr then return nil, pwErr end
local realmOk, realmErr = D.EnsureRealm(name, title, ownerId)
if not realmOk then return nil, realmErr end
local site = D.CreateSiteWithHome(name, title, 'job', ownerId,
(A.Get(src) or {}).username or 'system')
if not site then return nil, 'Speichern fehlgeschlagen.' end
local accountId, accErr = A.CreateAccount({
username = username,
domain = name,
role = 'admin',
display_name = title or name,
password = password,
must_change = true,
created_by = (A.Get(src) or {}).username or 'system',
})
if not accountId then
-- Ohne Zugang ist die Domaene nicht pflegbar; lieber nichts stehen lassen.
DB.DeleteSite(site.id)
return nil, accErr
end
ICWeb.Log.Write(src, 'domain.registered', { domain = name, owner = ownerId }, 'warn')
return site, nil, { username = username, password = password, generated = generated }
end
function M.AdminSetBlocked(src, domain, blocked)
if not A.IsSuperadmin(src) then return false, 'Keine Berechtigung.' end
local site = DB.GetSiteByDomain(tostring(domain or ''):lower())
if not site then return false, 'Domäne nicht gefunden.' end
DB.SetBlocked(site.id, blocked == true)
ICWeb.Log.Write(src, blocked and 'site.blocked' or 'site.unblocked',
{ domain = site.domain }, 'warn')
return true, nil
end
-- Besitzer wechseln, etwa wenn eine Firma verkauft wird.
function M.AdminSetOwner(src, domain, ownerId)
if not A.IsSuperadmin(src) then return false, 'Keine Berechtigung.' end
local site = DB.GetSiteByDomain(tostring(domain or ''):lower())
if not site then return false, 'Domäne nicht gefunden.' end
ownerId = tostring(ownerId or ''):lower()
if ownerId == '' then return false, 'Kein Besitzer angegeben.' end
DB.SetOwner(site.id, 'job', ownerId)
ICWeb.Log.Write(src, 'site.owner.changed',
{ domain = site.domain, from = site.owner_id, to = ownerId }, 'warn')
return true, nil
end
-- Loeschen entfernt Seiten und Meldungen ueber ON DELETE CASCADE sowie die
-- Zugaenge dieser Domaene. Die Mail-Domaene und die Postfaecher bleiben
-- bestehen: dort liegen Nachrichten, die eine geloeschte Webseite nichts angehen.
function M.AdminDeleteSite(src, domain)
if not A.IsSuperadmin(src) then return false, 'Keine Berechtigung.' end
local site = DB.GetSiteByDomain(tostring(domain or ''):lower())
if not site then return false, 'Domäne nicht gefunden.' end
for _, acc in ipairs(DB.GetAccountsForDomain(site.domain)) do
DB.DeleteAccount(acc.id)
end
DB.DeleteSite(site.id)
ICWeb.Log.Write(src, 'site.deleted', { domain = site.domain }, 'warn')
return true, nil
end
function M.AdminListReports(src)
if not A.IsSuperadmin(src) then return nil, 'Keine Berechtigung.' end
return DB.GetOpenReports(), nil
end
function M.AdminSetReportStatus(src, id, status)
if not A.IsSuperadmin(src) then return false, 'Keine Berechtigung.' end
if status ~= 'reviewed' and status ~= 'dismissed' and status ~= 'open' then
return false, 'Unbekannter Status.'
end
DB.SetReportStatus(tonumber(id) or -1, status)
return true, nil
end
-- Jobliste fuer das Besitzerfeld in der Verwaltung.
function M.AdminListJobs(src)
if not A.IsSuperadmin(src) then return nil, 'Keine Berechtigung.' end
return FW.GetJobs(), nil
end
-- Alle Zugaenge quer ueber alle Domaenen.
function M.AdminListAccounts(src)
if not A.IsSuperadmin(src) then return nil, 'Keine Berechtigung.' end
local out = {}
for _, row in ipairs(DB.GetAllAccounts()) do
out[#out + 1] = publicAccount(row)
end
return out, nil
end

204
server/sites.lua Normal file
View file

@ -0,0 +1,204 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- ic-web Seiten lesen und schreiben, Meldungen
-- ─────────────────────────────────────────────────────────────────────────────
ICWeb = ICWeb or {}
ICWeb.Sites = {}
local S = ICWeb.Sites
local DB = ICWeb.DB
local FW = ICWeb.FW
local D = ICWeb.Domains
local B = ICWeb.Blocks
-- Bloecke aus der Datenbank lesen. Kaputtes JSON darf keine Seite zerlegen.
local function decodeBlocks(raw)
if type(raw) ~= 'string' or raw == '' then return {} end
local ok, decoded = pcall(json.decode, raw)
if not ok or type(decoded) ~= 'table' then return {} end
return decoded
end
-- ── Lesen ───────────────────────────────────────────────────────────────────
-- Was der Browser bekommt. Gibt (site, nil) oder (nil, fehlertext) zurueck.
function S.GetPublic(domain, slug)
local site = DB.GetSiteByDomain(tostring(domain or ''):lower())
if not site then return nil, 'Diese Adresse existiert nicht.' end
if ICWeb.Bool(site.blocked) then return nil, 'Diese Seite wurde gesperrt.' end
if not ICWeb.Bool(site.published) then
return nil, 'Diese Seite ist noch nicht veröffentlicht.'
end
local pages = DB.GetPages(site.id)
if #pages == 0 then return nil, 'Diese Seite hat noch keinen Inhalt.' end
slug = tostring(slug or 'home')
local page
for _, p in ipairs(pages) do
if p.slug == slug then page = p break end
end
page = page or pages[1]
-- Navigation: nur Titel und Slug, keine Inhalte.
local nav = {}
for _, p in ipairs(pages) do
nav[#nav + 1] = { slug = p.slug, title = p.title }
end
return {
domain = site.domain,
title = site.title,
theme = site.theme,
nav = nav,
page = { slug = page.slug, title = page.title, blocks = decodeBlocks(page.blocks) },
}, nil
end
-- Was der Bearbeiter bekommt: auch unveroeffentlichte Seiten.
function S.GetForEditor(src, domain)
local site = DB.GetSiteByDomain(tostring(domain or ''):lower())
if not site then return nil, 'Domäne nicht gefunden.' end
if not D.CanEdit(src, site) then return nil, 'Keine Berechtigung für diese Domäne.' end
local pages = {}
for _, p in ipairs(DB.GetPages(site.id)) do
pages[#pages + 1] = {
slug = p.slug, title = p.title,
sort_order = p.sort_order, blocks = decodeBlocks(p.blocks),
}
end
return {
id = site.id, domain = site.domain, title = site.title,
theme = site.theme, published = ICWeb.Bool(site.published),
blocked = ICWeb.Bool(site.blocked), pages = pages,
}, nil
end
-- Alle Domaenen, die der angemeldete Zugang bearbeiten darf.
function S.ListMine(src)
local session = ICWeb.Auth.Get(src)
if not session then return {} end
local function entry(site)
return { domain = site.domain, title = site.title,
published = ICWeb.Bool(site.published),
blocked = ICWeb.Bool(site.blocked) }
end
-- Der Anbieter sieht alles, alle anderen genau ihre Domaene.
if session.role == 'superadmin' then
local out = {}
for _, site in ipairs(DB.GetAllSites()) do out[#out + 1] = entry(site) end
return out
end
local site = session.domain and DB.GetSiteByDomain(session.domain)
return site and { entry(site) } or {}
end
-- ── Schreiben ───────────────────────────────────────────────────────────────
local function checkSlug(slug)
slug = tostring(slug or ''):lower():gsub('%s', '')
if not slug:match('^[a-z0-9%-]+$') then return nil end
if #slug < 1 or #slug > 60 then return nil end
return slug
end
-- Seite speichern. Gibt (true, nil) oder (false, fehlertext) zurueck.
function S.SavePage(src, domain, slug, title, blocks)
local site = DB.GetSiteByDomain(tostring(domain or ''):lower())
if not site then return false, 'Domäne nicht gefunden.' end
if not D.CanEdit(src, site) then return false, 'Keine Berechtigung.' end
if ICWeb.Bool(site.blocked) then return false, 'Diese Seite ist gesperrt.' end
local safeSlug = checkSlug(slug)
if not safeSlug then return false, 'Ungültiger Seitenname.' end
-- Neue Seite? Dann Obergrenze pruefen.
if not DB.GetPage(site.id, safeSlug) then
if DB.CountPages(site.id) >= (Config.MaxPagesPerSite or 8) then
return false, ('Höchstens %d Seiten je Domäne.'):format(Config.MaxPagesPerSite or 8)
end
end
-- Hier faellt alles weg, was nicht ins Blockschema passt.
local safeBlocks, err = B.ValidatePage(blocks)
if not safeBlocks then return false, err end
local safeTitle = tostring(title or safeSlug):sub(1, 120)
DB.SavePage(site.id, safeSlug, safeTitle, json.encode(safeBlocks), 0)
ICWeb.Log.Write(src, 'page.saved', {
domain = site.domain, slug = safeSlug, blocks = #safeBlocks,
})
return true, nil
end
function S.DeletePage(src, domain, slug)
local site = DB.GetSiteByDomain(tostring(domain or ''):lower())
if not site then return false, 'Domäne nicht gefunden.' end
if not D.CanEdit(src, site) then return false, 'Keine Berechtigung.' end
local safeSlug = checkSlug(slug)
if not safeSlug then return false, 'Ungültiger Seitenname.' end
if safeSlug == 'home' then return false, 'Die Startseite lässt sich nicht löschen.' end
DB.DeletePage(site.id, safeSlug)
ICWeb.Log.Write(src, 'page.deleted', { domain = site.domain, slug = safeSlug })
return true, nil
end
-- Titel, Theme und Veroeffentlichung.
function S.UpdateSite(src, domain, data)
local site = DB.GetSiteByDomain(tostring(domain or ''):lower())
if not site then return false, 'Domäne nicht gefunden.' end
if not D.CanEdit(src, site) then return false, 'Keine Berechtigung.' end
if ICWeb.Bool(site.blocked) then return false, 'Diese Seite ist gesperrt.' end
local theme = tostring(data.theme or site.theme)
local themeOk = false
for _, t in ipairs(Config.Themes) do
if t == theme then themeOk = true break end
end
if not themeOk then theme = Config.DefaultTheme end
DB.UpdateSite(site.id, {
title = tostring(data.title or site.title):sub(1, 120),
theme = theme,
published = data.published == true,
})
ICWeb.Log.Write(src, 'site.updated', {
domain = site.domain, published = data.published == true, theme = theme,
})
return true, nil
end
-- ── Meldungen ───────────────────────────────────────────────────────────────
-- Bilder werden extern verlinkt; die Hostliste faengt nur den groben Rahmen ab.
-- Deshalb muss jeder Spieler eine Seite melden koennen.
function S.Report(src, domain, slug, reason)
local site = DB.GetSiteByDomain(tostring(domain or ''):lower())
if not site then return false, 'Domäne nicht gefunden.' end
local identifier = FW.GetIdentifier(src)
if not identifier then return false, 'Spieler nicht geladen.' end
-- Eine Meldung je Spieler und Seite pro Stunde: sonst laesst sich die
-- Meldeliste durch Wiederholung unbrauchbar machen.
if DB.RecentReportBy(site.id, identifier, 3600) then
return false, 'Du hast diese Seite bereits gemeldet.'
end
local safeReason = tostring(reason or ''):gsub('[^\32-\126\128-\255]', ''):sub(1, 500)
DB.AddReport(site.id, checkSlug(slug), safeReason, identifier)
ICWeb.Log.Write(src, 'site.reported', {
domain = site.domain, slug = slug, reason = safeReason,
}, 'warn')
return true, nil
end