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:
commit
a2ce3a69fd
14 changed files with 3018 additions and 0 deletions
445
server/auth.lua
Normal file
445
server/auth.lua
Normal 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
235
server/database.lua
Normal 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
220
server/domains.lua
Normal 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
137
server/framework.lua
Normal 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
411
server/main.lua
Normal 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
329
server/manage.lua
Normal 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
204
server/sites.lua
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue