commit a2ce3a69fd6e1d22d70af2b2e3dbee95a16df93a Author: Bjoern Flessing Date: Sun Aug 9 11:50:53 2026 +0000 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f22c9e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Abhaengigkeiten +node_modules/ +package-lock.json + +# Zugangsdaten – gehoeren nie ins Repository +.env +.env.* +!.env.example + +# Editor und Betriebssystem +.vscode/ +.idea/ +*.swp +.DS_Store +Thumbs.db + +# Protokolle +*.log diff --git a/ANLEITUNG.md b/ANLEITUNG.md new file mode 100644 index 0000000..ed82929 --- /dev/null +++ b/ANLEITUNG.md @@ -0,0 +1,220 @@ +# Webhosting β€” Anleitung + +Am PC (pc-live) gibt es die App **🌍 Webhosting**. Alles lΓ€uft ΓΌber eine +Anmeldung: Benutzername ist eine Adresse wie `presse@weazel-news.ls`, dazu ein +Passwort. **Der Zugang ist zugleich dein Postfach.** + +--- + +## FΓΌr die Administration (Liveinvader) + +### Erste Anmeldung + +Benutzername `admin@liveinvader.ls`, Passwort steht beim Serverstart in der +Konsole (Vorgabe `liveinvader`). Beim ersten Anmelden wirst du aufgefordert, +ein eigenes Passwort zu setzen. Tu das. + +Passwort vergessen? In der Serverkonsole: + +``` +webpasswd admin@liveinvader.ls neues-passwort +``` + +### DomΓ€ne anlegen + +*Verwaltung* β†’ **DomΓ€ne anlegen** + +| Feld | | +|---|---| +| DomΓ€ne | `weazel-news.ls` β€” nur Buchstaben, Ziffern, Bindestrich. Endungen: `.ls .sa .gov .org .net` | +| Anzeigename | β€žWeazel News" β€” steht spΓ€ter als Überschrift auf der Seite | +| Besitzer | der Job, dem die DomΓ€ne gehΓΆrt. Reiner Vermerk, keine Rechte | +| Zugang | Benutzername des DomΓ€nenadmins, Vorgabe `admin` β†’ `admin@weazel-news.ls` | +| Startpasswort | leer lassen β†’ wird erzeugt | + +BehΓΆrdennamen wie `lspd.ls` darfst du als Administration vergeben β€” die +Sperrliste in der Config richtet sich nur gegen die Selbstanmeldung. Gesperrt +bleiben die DomΓ€nen des Mailsystems: `mail.ls` ist die DomΓ€ne aller +persΓΆnlichen PostfΓ€cher und lΓ€sst sich nicht als Webseite vergeben. + +Nach dem Anlegen erscheinen **Benutzername und Passwort einmalig**. Notiere sie +und gib sie weiter β€” spΓ€ter sind sie nicht mehr abrufbar, nur neu setzbar. + +Mit der DomΓ€ne entsteht automatisch die Mail-DomΓ€ne, und der Zugang ist das +erste Postfach darauf. + +### Verwalten + +In der DomΓ€nenliste je Zeile: + +- **Bearbeiten** β€” Seiten der DomΓ€ne pflegen (du darfst ΓΌberall) +- **ZugΓ€nge** β€” ZugΓ€nge dieser DomΓ€ne anlegen, sperren, lΓΆschen +- **Sperren** β€” Seite offline nehmen. Der DomΓ€nenadmin kann dann nichts mehr Γ€ndern +- **Besitzer** β€” Job Γ€ndern, etwa wenn eine Firma verkauft wird +- **LΓΆschen** β€” DomΓ€ne mit Seiten und ZugΓ€ngen entfernen. **PostfΓ€cher und + Nachrichten bleiben bestehen** + +### Meldungen + +*Meldungen* zeigt, was Spieler gemeldet haben. Je Eintrag: **Ansehen**, +**Sperren**, **Erledigt**, **Verwerfen**. Jeder darf melden, einmal je Seite +und Stunde. + +--- + +## FΓΌr DomΓ€nenadmins + +### Anmelden + +App **Webhosting** ΓΆffnen, Benutzername und Passwort eingeben. Beim ersten Mal +wirst du zum Passwortwechsel aufgefordert. + +Ab jetzt ist auch dein Postfach offen β€” in der Mail-App liegt die Adresse +deines Zugangs. + +### ZugΓ€nge fΓΌr dein Team + +*ZugΓ€nge* β†’ **Zugang anlegen** + +| Feld | | +|---|---| +| Benutzername | z. B. `presse` β†’ wird `presse@deine-domΓ€ne` | +| Anzeigename | β€žRedaktion" | +| Startpasswort | mindestens 6 Zeichen, gibst du weiter | +| Rolle | **Redakteur** pflegt Seiten Β· **DomΓ€nenadmin** darf zusΓ€tzlich ZugΓ€nge verwalten | + +Die Zugangsdaten erscheinen einmalig. Jeder Zugang bekommt sein eigenes +Postfach β€” mehrere Leute kΓΆnnen sich mit demselben Zugang anmelden und dasselbe +Postfach gleichzeitig offen haben. + +Bestehende ZugΓ€nge lassen sich mit **Passwort**, **β†’ Admin / β†’ Redakteur**, +**Sperren** und **LΓΆschen** verwalten. Deinen eigenen Zugang kannst du weder +sperren noch lΓΆschen. + +Beim LΓΆschen eines Zugangs bleibt das Postfach mit allen Nachrichten bestehen. + +--- + +## Seiten bauen + +*Meine Seite* β†’ **Bearbeiten** + +### Seiten + +Links die Seitenliste. `home` ist die Startseite und lΓ€sst sich nicht lΓΆschen. +**οΌ‹ Neue Seite** legt weitere an (bis zu 8), erreichbar unter +`ic://deine-domΓ€ne/seitenname`. + +### BlΓΆcke + +Eine Seite besteht aus BlΓΆcken. Unten **Block hinzufΓΌgen**, dann ausfΓΌllen. Mit +β–² β–Ό verschieben, mit βœ• lΓΆschen. + +| Block | | +|---|---| +| **Überschrift** | Text, Grâße Groß/Mittel/Klein | +| **Textabsatz** | Fließtext, ZeilenumbrΓΌche bleiben erhalten | +| **Bild** | Adresse, Beschreibung, Bildunterschrift | +| **AufzΓ€hlung** | ein Eintrag je Zeile, bis 20 | +| **SchaltflΓ€che** | Beschriftung und Ziel: Seitenname der eigenen DomΓ€ne oder `ic://…` | +| **Kontaktbox** | E-Mail, Telefon, Anschrift | +| **Trennlinie** | β€” | + +**Vorschau** zeigt die Seite so, wie Besucher sie sehen. **Seite speichern** +schreibt sie in die Datenbank β€” ohne Speichern ist die Arbeit weg. + +### Bilder + +Bilder werden verlinkt, nicht hochgeladen. Erlaubt sind nur `https`-Adressen +von: imgur.com, discordapp.com/net, discord.com, githubusercontent.com +(Subdomains eingeschlossen). Andere Adressen weist der Server ab. + +Unpassende Bilder kΓΆnnen gemeldet werden und fΓΌhren zur Sperre der Seite. + +### VerΓΆffentlichen + +*Einstellungen* β†’ Haken bei **VerΓΆffentlicht** β†’ **Speichern**. + +Vorher sehen nur angemeldete Bearbeiter die Seite; Besucher bekommen eine +Fehlermeldung. Dort stellst du auch Anzeigename und Gestaltung ein +(`clean`, `dark`, `gov`, `business`). + +--- + +## Mailclient: PostfΓ€cher und Signatur + +### Postfach hinzufΓΌgen + +Mail-App β†’ links unter *PostfΓ€cher* auf **οΌ‹ Postfach hinzufΓΌgen**. Adresse und +Passwort des Zugangs eingeben β€” dieselben Daten wie beim Webhosting. + +Das Postfach erscheint dann in der Liste, du kannst daraus lesen und senden. +Mehrere Leute dΓΌrfen dasselbe Postfach gleichzeitig offen haben. + +Zum Entfernen das βœ• neben dem Postfach. Nur selbst hinzugefΓΌgte PostfΓ€cher +haben eins β€” dein eigenes und dienstlich zugewiesene bleiben. Beim Verlassen +des Servers werden alle hinzugefΓΌgten PostfΓ€cher wieder geschlossen. + +### Ordner + +Jedes Postfach hat **eigene Ordner**. Legst du im Firmenpostfach einen Ordner +an, sehen ihn alle, die dieses Postfach bedienen β€” und wenn jemand eine Mail +dort einsortiert, liegt sie fΓΌr alle im selben Ordner. + +Gelesen und gelΓΆscht bleibt dagegen persΓΆnlich: das ist keine Eigenschaft der +Nachricht, sondern deine. + +### Signatur + +*Signatur* β†’ oben das **Postfach auswΓ€hlen** β†’ Text eintragen β†’ **Speichern**. + +Die Signatur gehΓΆrt zum Postfach, nicht zu dir: Wer es bedient, schreibt mit +derselben Fußzeile. Beim Schreiben steht sie unter dem Text, abgetrennt durch +eine Zeile mit `--`. Wechselst du beim Schreiben den Absender, tauscht sie sich +mit β€” was du schon geschrieben hast, bleibt stehen. + +### Kalender + +Beim Anlegen eines Termins wΓ€hlst du unter **Kalender**, wohin er gehΓΆrt: + +| | | +|---|---| +| πŸ”’ **Privat** | nur du siehst ihn | +| πŸ‘₯ **Postfach** | alle, die dieses Postfach bedienen | + +Bei einem Postfachkalender kommt die Frage **Sichtbar fΓΌr** dazu: *nur wer +dieses Postfach bedient* oder *alle* β€” Letzteres fΓΌr Dinge wie +Pressekonferenzen oder Gerichtstermine, die jeder im Netz sehen soll. + +Jeder Termin trΓ€gt in der Liste ein Schild, das zeigt, wohin er gehΓΆrt. +Γ–ffentliche Termine fremder Stellen siehst du, Γ€ndern kannst du sie nicht β€” die +BearbeitungsknΓΆpfe fehlen dort. + +Wer ein Postfach bedient, darf dessen Termine auch dann Γ€ndern, wenn ein +Kollege sie eingetragen hat. Γ„nderungen erscheinen sofort bei allen. + +--- + +## FΓΌr Besucher + +Browser ΓΆffnen, Adresse eingeben: + +``` +ic://weazel-news.ls +ic://weazel-news.ls/impressum +``` + +Unten auf jeder Seite steht **βš‘ Seite melden**. + +--- + +## HΓ€ufige Meldungen + +| Meldung | Grund | +|---|---| +| β€žBenutzername oder Passwort falsch." | Tippfehler β€” der Benutzername ist die volle Adresse mit `@` | +| β€žZu viele Fehlversuche." | FΓΌnf Fehlversuche in fΓΌnf Minuten. Kurz warten | +| β€žKeine Berechtigung." | Der angemeldete Zugang gehΓΆrt zu einer anderen DomΓ€ne, oder Redakteure dΓΌrfen keine ZugΓ€nge verwalten | +| β€žBildhost nicht erlaubt" | Bildadresse liegt nicht auf einem der erlaubten Hosts | +| β€žDiese Seite ist gesperrt." | Die Administration hat die DomΓ€ne gesperrt | +| β€žHΓΆchstens 8 Seiten je DomΓ€ne." | Obergrenze erreicht β€” eine Seite lΓΆschen | diff --git a/README.md b/README.md new file mode 100644 index 0000000..1f433b9 --- /dev/null +++ b/README.md @@ -0,0 +1,335 @@ +# ic-web β€” IC-Webhosting + +Ein IC-Hostinganbieter: er vergibt DomΓ€nen, richtet je DomΓ€ne einen Zugang +ein, und die DomΓ€neninhaber pflegen darauf ihre Webseite und ihre PostfΓ€cher. +Aufgerufen werden die Seiten im Browser von **pc-live** unter `ic://domΓ€ne`. + +## Die drei Ebenen + +| Rolle | Zugang | Darf | +|---|---|---| +| **Anbieter** (`superadmin`) | `admin@liveinvader.ls` | DomΓ€nen anlegen, ΓΌbertragen, sperren, lΓΆschen; ΓΌberall ZugΓ€nge und Seiten | +| **DomΓ€nenadmin** (`admin`) | `@` | Seiten der eigenen DomΓ€ne pflegen, dort ZugΓ€nge anlegen und verwalten | +| **Redakteur** (`editor`) | `@` | nur Seiten der eigenen DomΓ€ne pflegen | + +Job und Serverrechte spielen keine Rolle. Wer den Zugang kennt, verwaltet die +Seite β€” unabhΓ€ngig davon, welchen Charakter er gerade spielt. + +## Ein Zugang ist ein Postfach + +`presse@weazel-news.ls` ist Benutzername, Passwort **und** Mailadresse in einem. +Beim Anmelden wird der Zugriff auf das Postfach in `ic-mail` erteilt, beim +Abmelden wieder entzogen. Deshalb kΓΆnnen **mehrere Personen gleichzeitig +dasselbe Postfach offen haben** β€” ein Firmenpostfach gehΓΆrt der Firma, nicht +einer Person. + +Wird ein Zugang gelΓΆscht, bleibt das Postfach mit seinen Nachrichten bestehen. + +AufgerΓ€umt wird **beim Start**, nicht beim Herunterfahren: ic-web widerruft +dann alle Postfachzugriffe, die es je erteilt hat (`granted_by = 'ic-web'`). +Beim Stoppen ginge das nicht β€” der Aufruf nach ic-mail wartet auf eine +Datenbankantwort, und eine wartende Koroutine lΓ€sst sich wΓ€hrend des +Herunterfahrens nicht mehr fortsetzen; der Server meldet dann +β€žExecution of function reference in script host failed". Der Umweg ΓΌber den +Start deckt außerdem den Fall ab, den ein Stop-Handler nie erwischt: den +Absturz. + +## PostfΓ€cher im Mailclient + +Der Mailclient von pc-live kann weitere PostfΓ€cher aufnehmen: *PostfΓ€cher β†’ +**οΌ‹ Postfach hinzufΓΌgen*** fragt Adresse und Passwort ab β€” dieselbe PrΓΌfung +wie die Anmeldung, aber **ohne Verwaltungsrechte**. Man kann zehn PostfΓ€cher +offen haben (`Config.MaxOpenMailboxes`) und trotzdem nur fΓΌr eines Seiten +pflegen dΓΌrfen. + +Freigeschaltete PostfΓ€cher tragen ein βœ• zum Entfernen; das eigene und +dienstlich zugewiesene nicht β€” die gehΓΆren einem nicht. Beim Verlassen des +Servers werden alle wieder geschlossen, ebenso beim nΓ€chsten Start von ic-web. + +Das Abmelden von der Verwaltung nimmt ein eigenstΓ€ndig geΓΆffnetes Postfach +nicht weg. + +## Signaturen + +Die Signatur gehΓΆrt zum **Postfach**, nicht zur Person: eine Firmenadresse +trΓ€gt dieselbe Fußzeile, egal wer sie gerade bedient. Γ„ndern darf sie jeder mit +Zugriff auf das Postfach (*Signatur* im Mailclient). + +Beim Schreiben wird sie unter den Text gesetzt, getrennt durch eine Zeile mit +`--`. Beim Wechsel des Absenders tauscht sie sich aus, der geschriebene Text +bleibt stehen. + +Gespeichert wird in `ic_mail_accounts.signature` (ic-mail, Migration +`sql/migration_signaturen.sql`). + +## Ordner je Postfach + +Ordner gehΓΆren zum Postfach, nicht zur Person (`pc_live_mail_folders.address`, +Migration `pc-live/sql/migration_postfach_ordner.sql`). Ein geteiltes Postfach +hat fΓΌr alle dieselbe Struktur. + +Eine Mail an ein geteiltes Postfach wird je EmpfΓ€nger einmal gespeichert. Damit +das Einsortieren trotzdem fΓΌr alle gilt, tragen alle Kopien einer Zustellung +dieselbe `delivery_id`: wird eine Kopie verschoben, wandern die anderen mit. +Gelesen und gelΓΆscht bleibt persΓΆnlich β€” das ist keine Eigenschaft der +Nachricht, sondern der Person. + +Welches Postfach gemeint ist, schickt der Client mit; der Server prΓΌft es ΓΌber +`ic-mail:CanAccessAddress` und fΓ€llt ohne Zugriff auf das persΓΆnliche Postfach +zurΓΌck, statt fremde Ordner herauszugeben. + +## Kalender + +Dieselbe Mechanik wie bei PostfΓ€chern und Ordnern +(`pc_live_calendar.address` + `visibility`, Migration +`pc-live/sql/migration_kalender.sql`): + +| Sichtbarkeit | gehΓΆrt | sieht | bearbeitet | +|---|---|---|---| +| `private` | der Person | nur sie | nur sie | +| `shared` | einem Postfach | wer es bedient | wer es bedient | +| `public` | einem Postfach | jeder auf dem Server | wer es bedient | + +Ein ΓΆffentlicher Termin hΓ€ngt bewusst auch an einem Postfach β€” sonst gΓ€be es +niemanden, der ihn spΓ€ter Γ€ndern oder absagen kΓΆnnte. Wer ein Postfach bedient, +darf dessen Termine auch dann Γ€ndern, wenn jemand anderes sie eingetragen hat; +genau das macht sie geteilt. + +Γ„nderungen erreichen alle Beteiligten sofort, sonst hΓ€tte jeder eine andere +Vorstellung vom Dienstplan, bis er den PC neu ΓΆffnet. + +## Ablauf + +1. Der Anbieter meldet sich in der App **Webhosting** mit `admin@liveinvader.ls` + an (Startpasswort siehe unten) und Γ€ndert sein Passwort. +2. Er legt unter *Verwaltung* eine DomΓ€ne an, wΓ€hlt den Besitzer-Job und den + ersten Zugang. Benutzername und Passwort werden **einmal** angezeigt. +3. Der DomΓ€nenadmin meldet sich damit an, Γ€ndert sein Passwort und legt unter + *ZugΓ€nge* weitere ZugΓ€nge fΓΌr seine Leute an. +4. Alle ZugΓ€nge dieser DomΓ€ne pflegen die Seiten unter *Meine Seite* und + verΓΆffentlichen sie. + +## Ersteinrichtung + +Beim ersten Start legt ic-web den Anbieterzugang aus `Config.MainAdmin` an und +schreibt Benutzername und Startpasswort in die Serverkonsole. Vorgabe: + +``` +admin@liveinvader.ls / liveinvader +``` + +Beim ersten Anmelden wird zum Γ„ndern aufgefordert. + +Passwort vergessen? In der Serverkonsole: + +``` +webpasswd admin@liveinvader.ls neues-passwort +``` + +## Inhalte sind kein HTML + +Seiten bestehen aus **typisierten BlΓΆcken** (`shared/blocks.lua`), nicht aus +Markup. Der Grund: die Seiten werden im NUI von pc-live angezeigt β€” im selben +Kontext wie der PC selbst. Wer dort Markup einschleusen kann, kann auch dessen +NUI-Callbacks aufrufen. Ein Blockmodell schließt das konstruktiv aus, statt es +filtern zu mΓΌssen; HTML zu filtern ist ein Wettlauf, den man langfristig +verliert. + +`B.Validate` baut jeden Block aus dem Schema **neu auf**. Was nicht im Schema +steht, fΓ€llt weg β€” es wird nicht bereinigt, sondern verworfen. + +Blocktypen: `heading`, `text`, `image`, `list`, `button`, `contact`, `divider`. + +Der Renderer in pc-live setzt Text ausschließlich ΓΌber `textContent`, niemals +ΓΌber `innerHTML`. Deshalb bleiben `<` und `>` im Text erhalten statt +verstΓΌmmelt zu werden. + +## DomΓ€nennamen + +Form: `name.endung`, nur Kleinbuchstaben, Ziffern und Bindestriche, 3–30 +Zeichen, Endung aus `Config.Domain.allowedTlds` (`.ls .sa .gov .org .net`). + +`Config.Domain.reserved` sperrt Namen **nur gegen die Selbstanmeldung** β€” der +Anbieter darf `lspd.ls` sehr wohl vergeben, dafΓΌr ist er da. + +UnabhΓ€ngig davon gesperrt sind DomΓ€nen des Mailsystems: existiert der Realm +schon in ic-mail als `is_default`, `system` oder `citizen` (etwa `mail.ls`), +lehnt `D.EnsureRealm` ab β€” fΓΌr alle. Sonst hinge eine Firmenwebseite an der +DomΓ€ne sΓ€mtlicher persΓΆnlicher PostfΓ€cher. Eine herrenlose *Firmen*-DomΓ€ne +(`type = company`) wird dagegen ΓΌbernommen. + +## Bilder und Meldungen + +Bilder werden extern verlinkt β€” NUI kann keine Dateien hochladen. Erlaubt sind +nur `https` und die Hosts aus `Config.ImageHosts` (Subdomains eingeschlossen). +Die Hostliste ist der Rahmen, die Meldefunktion fΓ€ngt den Rest β€” sie greift +aber erst nachtrΓ€glich. + +Jeder Spieler kann eine Seite melden (Link unten auf jeder Seite), einmal je +Seite und Stunde. Der Anbieter sieht die Meldungen unter *Meldungen*. + +## PasswΓΆrter + +Gespeichert wird `SHA2(salt || passwort)`, gerechnet in der Datenbank β€” das +Passwort lΓ€uft nur als Abfrageparameter durch den Server. FΓΌnf Fehlversuche je +Benutzername innerhalb von fΓΌnf Minuten sperren den Namen kurzzeitig +(`Config.Login`). + +Das ist ein **IC-Passwort**, keine Sicherheitsgrenze gegen Serveradmins: wer +Datenbankzugriff hat, kann jeden Zugang zurΓΌcksetzen. Es verhindert, dass +Mitspieler fremde Seiten Γ€ndern β€” nicht mehr. + +## Installation + +### 1. Ordner + +Der Ordner `ic-web` gehΓΆrt in dein Resource-Verzeichnis, zum Beispiel: + +``` +resources/[haleoe]/ic-web/ +``` + +### 2. Datenbank + +```bash +mysql -u BENUTZER -p DATENBANK < sql/ic_web.sql +``` + +Legt an: `ic_web_sites`, `ic_web_pages`, `ic_web_accounts`, +`ic_web_login_fails`, `ic_web_reports`. Wiederholt ausfΓΌhrbar. + +### 3. server.cfg + +``` +ensure oxmysql +ensure es_extended +ensure ic-mail +ensure ic-web # nach ic-mail +``` + +Liegt alles in einem Ordner mit eckigen Klammern, genΓΌgt `ensure [haleoe]` β€” +dann startet ic-web automatisch mit. Die Reihenfolge stimmt dabei von selbst, +weil ic-mail alphabetisch vorher kommt. + +### 4. Anbindung an pc-live + +ic-web bringt keine eigene OberflΓ€che mit β€” sie sitzt im PC. Diese Dateien +gehΓΆren nach `pc-live`: + +| Datei | Ziel in pc-live | Zweck | +|---|---|---| +| `client/webhosting.lua` | `client/` | BrΓΌcke zwischen NUI und Server | +| `nui/js/apps/webhosting.js` | `nui/js/apps/` | die App selbst | + +Dazu in pc-live eintragen: + +**`fxmanifest.lua`** +```lua +client_scripts { …, 'client/webhosting.lua' } +files { …, 'nui/js/apps/webhosting.js' } +``` + +**`nui/index.html`** β€” vor `desktop.js`, weil andere Apps den Dialogbaukasten +daraus benutzen: +```html + +``` + +**`nui/js/desktop.js`** β€” App eintragen und den Antwortkanal anschließen: +```js +'pc.webhosting': { name: 'Webhosting', icon: '🌍', open: () => WebhostingApp.open() }, +… +case 'icweb_response': ICWebNet.onResponse(data); break; +``` + +**`server/apps.lua`** β€” damit die App auf dem Schreibtisch erscheint: +```lua +AppRegistry.Register({ + app_id = "pc.webhosting", name = "Webhosting", icon = "🌍", + version = "1.0.0", category = "system", default = true, + permissions = {}, dependencies = {}, price = 0, + description = "IC-DomΓ€nen und Webseiten verwalten. Anmeldung erforderlich.", +}) +``` + +**`nui/js/apps/browser.js`** β€” damit `ic://domΓ€ne.ls` im Browser aufgeht: +der Zweig, der Adressen der Form `name.endung` an `ICWebNet.call('getSite', …)` +gibt und mit `ICWebRender.renderPage` zeichnet. + +### 5. Erste Anmeldung + +Beim ersten Start legt ic-web den Anbieterzugang an und schreibt ihn in die +Serverkonsole. Vorgabe: `admin@liveinvader.ls` / `liveinvader`. Beim ersten +Anmelden wird ein eigenes Passwort verlangt. + +## Voraussetzungen + +| | | +|---|---| +| `oxmysql` | Datenbankzugriff | +| `es_extended` | Spielername, Jobliste, Benachrichtigungen | +| `ic-mail` | DomΓ€nen und PostfΓ€cher. Fehlt es, laufen die Webseiten trotzdem – nur ohne Mailadressen | +| `pc-live` | die OberflΓ€che | +| `fmsdk` | Protokoll. Fehlt es, wird verworfen statt zu scheitern | + +ic-mail braucht dafΓΌr sechs Exports, die dieses Projekt voraussetzt: +`GetRealmByName`, `CreateRealm`, `EnsureSharedAddress`, `GrantAddressAccess`, +`RevokeAddressAccess`, `RevokeAccessGrantedBy`. + +## Selbstanmeldung (optional, aus) + +`Config.SelfRegistration.enabled = true` erlaubt BehΓΆrden und Unternehmen, sich +ohne Anbieter selbst eine DomΓ€ne zu holen β€” Grundlage ist dann der ESX-Job ab +`minGrade`. Der Zugang wird dabei automatisch erzeugt und einmalig angezeigt. +StandardmÀßig aus, weil die Vergabe ΓΌber den Anbieter lΓ€uft. + +## Konsolenbefehle + +Notbremse des Serverbetreibers, kein IC-Weg. Erlaubt sind Serverkonsole sowie +ESX-Gruppe `admin`/`superadmin` bzw. ACE `ic_web.admin` (`Config.Console`). + +| Befehl | Wirkung | +|---|---| +| `webpasswd ` | Passwort setzen | +| `webaccounts` | alle ZugΓ€nge auflisten | +| `webreports` | offene Meldungen | +| `webblock ` | Seite sperren bzw. freigeben | +| `webreport done\|dismiss ` | Meldung abschließen | + +## Exports + +```lua +exports['ic-web']:GetSite(domain, slug) -- (seite, nil) oder (nil, fehlertext) +exports['ic-web']:DomainExists(domain) +exports['ic-web']:GetSitesForJob(job) +exports['ic-web']:GetSession(src) -- wer ist an diesem PC angemeldet +``` + +`ic-mail` wurde um vier Exports ergΓ€nzt, die ic-web benutzt: +`EnsureSharedAddress`, `GrantAddressAccess`, `RevokeAddressAccess`, +`DeleteAddress` β€” sowie `GetRealmByName` und `CreateRealm` fΓΌr die DomΓ€nen. + +## Netzwerkereignisse + +Jedes Ereignis bekommt eine Anfrage-Id und antwortet ΓΌber +`ic-web:client:response` mit `{ ok, data, error }`. Alle sind gedrosselt; +Rechte werden ausnahmslos serverseitig aus der Anmeldung abgeleitet. + +Anmeldung: `login`, `logout`, `session`, `changePassword`. +Seiten: `getSite`, `listMine`, `getEditable`, `savePage`, `deletePage`, +`updateSite`, `report`. +ZugΓ€nge: `listAccounts`, `createAccount`, `setAccountPassword`, +`setAccountActive`, `setAccountRole`, `deleteAccount`. +Anbieter: `adminListSites`, `adminListAccounts`, `adminListJobs`, +`adminRegister`, `adminSetBlocked`, `adminSetOwner`, `adminDeleteSite`, +`adminListReports`, `adminSetReportStatus`. + +## Tabellen + +`ic_web_sites`, `ic_web_pages`, `ic_web_accounts`, `ic_web_login_fails`, +`ic_web_reports`. + +Die DomΓ€ne wird ΓΌber den **Namen** referenziert, nicht ΓΌber einen +FremdschlΓΌssel auf `ic_mail_realms` β€” FremdschlΓΌssel ΓΌber Resource-Grenzen +hinweg koppeln zwei Lebenszyklen aneinander, die unabhΓ€ngig voneinander +installiert werden. diff --git a/config.lua b/config.lua new file mode 100644 index 0000000..557ab40 --- /dev/null +++ b/config.lua @@ -0,0 +1,127 @@ +-- ───────────────────────────────────────────────────────────────────────────── +-- ic-web – Konfiguration +-- +-- IC-Webhosting: der Anbieter vergibt Domaenen, jede Domaene hat eigene +-- Zugaenge, und jeder Zugang ist zugleich ein Postfach. Die Webseiten sind im +-- pc-live-Browser unter ic://domaene erreichbar. +-- +-- Die Domaene selbst wird in ic-mail angelegt (ic_mail_realms). Eine Webseite +-- ist ein zweiter Dienst darauf, keine eigene Domaenenliste. +-- ───────────────────────────────────────────────────────────────────────────── + +Config = {} + +Config.Debug = false + +-- ── Der Anbieter ───────────────────────────────────────────────────────────── +-- Der erste Zugang wird beim ersten Start angelegt. Ohne ihn kaeme niemand in +-- die Verwaltung hinein. +-- +-- Das Startpasswort steht hier im Klartext: es ist genau einmal noetig und +-- sollte nach der ersten Anmeldung geaendert werden (in der Verwaltung oder in +-- der Serverkonsole mit /webpasswd). +Config.MainAdmin = { + username = 'admin@liveinvader.ls', + providerName = 'Liveinvader Hosting', + initialPassword = 'liveinvader', +} + +-- ── Anmeldung ──────────────────────────────────────────────────────────────── +Config.Login = { + minPasswordLength = 6, + -- Bremse gegen Durchprobieren: so viele Fehlversuche je Benutzername + -- innerhalb des Zeitfensters, danach ist der Name kurz gesperrt. + maxAttempts = 5, + windowSeconds = 300, + -- Anmeldung laeuft nach dieser Zeit ohne Aktivitaet ab. 0 = nie. + idleSeconds = 3600, +} + +-- Zugaenge je Domaene (Admins und Editoren zusammen). +Config.MaxAccountsPerDomain = 15 + +-- Wie viele fremde Postfaecher jemand im Mailclient gleichzeitig offen haben +-- darf ("Postfach hinzufuegen"). Das eigene und dienstlich zugewiesene zaehlen +-- nicht mit. +Config.MaxOpenMailboxes = 10 + +-- ── Wer darf Domaenen anlegen ──────────────────────────────────────────────── +-- Im Normalfall nur der Anbieter (superadmin) ueber die Verwaltung. +-- +-- Die Selbstanmeldung ueber den ESX-Job ist der aeltere Weg und bleibt als +-- Schalter erhalten. Eingeschaltet duerfen Behoerden und Unternehmen sich ohne +-- Anmeldung selbst eine Domaene holen; der Zugang dazu wird dabei automatisch +-- erzeugt und einmalig angezeigt. +Config.SelfRegistration = { + enabled = false, + allowedJobs = {}, -- leer = alle ausser blockedJobs + minGrade = 3, + blockedJobs = { unemployed = true }, + maxDomainsPerJob = 2, +} + +-- Rueckwaertsname: der uebrige Code liest weiterhin Config.Registration. +Config.Registration = Config.SelfRegistration + +-- ── Domaenennamen ──────────────────────────────────────────────────────────── +Config.Domain = { + -- Erlaubte Endungen. Ohne Begrenzung entstehen schnell .com-Domaenen, + -- die in eine GTA-Welt nicht passen. + allowedTlds = { 'ls', 'sa', 'gov', 'org', 'net' }, + minLength = 3, + maxLength = 30, + -- Namen, die sich niemand per Selbstanmeldung greifen darf. Der Anbieter + -- vergibt sie sehr wohl – lspd.ls soll ja beim LSPD landen. + -- + -- Domaenen des Mailsystems (mail.ls und andere System-/Buergerdomaenen) + -- sind unabhaengig davon gesperrt, das prueft D.EnsureRealm fuer alle. + reserved = { + 'mail', 'www', 'admin', 'police', 'lspd', 'lsmd', 'ambulance', + 'gov', 'government', 'doj', 'system', 'ic', 'pc', + }, +} + +-- ── Inhalte ────────────────────────────────────────────────────────────────── +Config.MaxPagesPerSite = 8 +Config.MaxBlocksPerPage = 60 + +-- Erlaubte Bildhosts. +-- +-- Bilder werden extern verlinkt – FiveM-NUI kann keine Dateien hochladen. +-- Ohne diese Liste laesst sich jede beliebige Adresse einbetten. Die +-- Meldefunktion faengt den Rest, greift aber erst nachtraeglich. +-- +-- Subdomains sind eingeschlossen: 'imgur.com' erlaubt auch 'i.imgur.com'. +Config.ImageHosts = { + 'imgur.com', + 'discordapp.net', + 'discordapp.com', + 'discord.com', + 'githubusercontent.com', +} + +-- ── Themes ─────────────────────────────────────────────────────────────────── +-- Reine CSS-Vorlagen im Browser von pc-live. Der Name wird nur durchgereicht. +Config.Themes = { 'clean', 'dark', 'gov', 'business' } +Config.DefaultTheme = 'clean' + +-- ── Serverseitige Notbremse ────────────────────────────────────────────────── +-- Kein IC-Weg, sondern der Zugriff des Serverbetreibers: Konsolenbefehle zum +-- Zuruecksetzen eines Passworts und zum Sperren einer Seite, falls im Spiel +-- niemand mehr an die Verwaltung kommt. +Config.Console = { + -- 'esx' | 'ace' | 'either' + mode = 'either', + esxGroups = { admin = true, superadmin = true }, + acePermission = 'ic_web.admin', +} + +-- Alter Name, wird an einigen Stellen noch gelesen. +Config.Admin = Config.Console + +-- ── Logging ────────────────────────────────────────────────────────────────── +-- Ausschliesslich ueber fmsdk, wie im Projekt ueblich. +Config.Logging = { + dataset = 'ic_web', + actionPrefix = 'ic_web.', +} diff --git a/fxmanifest.lua b/fxmanifest.lua new file mode 100644 index 0000000..c07a531 --- /dev/null +++ b/fxmanifest.lua @@ -0,0 +1,29 @@ +fx_version 'cerulean' +game 'gta5' + +name 'ic-web' +description 'IC-Webhosting – DomΓ€nen und Webseiten fΓΌr BehΓΆrden und Unternehmen' +version '0.1.0' +lua54 'yes' + +dependency 'oxmysql' +dependency 'es_extended' + +shared_scripts { + 'config.lua', + 'shared/blocks.lua', +} + +server_scripts { + '@oxmysql/lib/MySQL.lua', + 'server/framework.lua', + 'server/database.lua', + 'server/auth.lua', + 'server/domains.lua', + 'server/sites.lua', + 'server/manage.lua', + 'server/main.lua', +} + +-- Serverexports (GetSite, DomainExists, GetSitesForJob) werden zur Laufzeit in +-- server/main.lua registriert und brauchen hier keinen Eintrag. diff --git a/server/auth.lua b/server/auth.lua new file mode 100644 index 0000000..7c202be --- /dev/null +++ b/server/auth.lua @@ -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 @ pflegt die Seiten seiner Domaene und +-- legt dort weitere Zugaenge an +-- editor @ 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 diff --git a/server/database.lua b/server/database.lua new file mode 100644 index 0000000..acf523a --- /dev/null +++ b/server/database.lua @@ -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 diff --git a/server/domains.lua b/server/domains.lua new file mode 100644 index 0000000..ec009c2 --- /dev/null +++ b/server/domains.lua @@ -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 diff --git a/server/framework.lua b/server/framework.lua new file mode 100644 index 0000000..2b0954c --- /dev/null +++ b/server/framework.lua @@ -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) diff --git a/server/main.lua b/server/main.lua new file mode 100644 index 0000000..293268e --- /dev/null +++ b/server/main.lua @@ -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 ') + 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 , erledigen mit /webreport done ') +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 oder /webreport dismiss ') + 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) diff --git a/server/manage.lua b/server/manage.lua new file mode 100644 index 0000000..e1930f2 --- /dev/null +++ b/server/manage.lua @@ -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 diff --git a/server/sites.lua b/server/sites.lua new file mode 100644 index 0000000..a3eb821 --- /dev/null +++ b/server/sites.lua @@ -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 diff --git a/shared/blocks.lua b/shared/blocks.lua new file mode 100644 index 0000000..9b933d5 --- /dev/null +++ b/shared/blocks.lua @@ -0,0 +1,203 @@ +-- ───────────────────────────────────────────────────────────────────────────── +-- ic-web – Blockdefinition und Pruefung +-- +-- Der sicherheitskritische Teil der Resource. +-- +-- Seiteninhalte sind NIEMALS HTML, sondern eine Liste typisierter Bloecke. +-- Der Grund: die Seiten werden im NUI von pc-live angezeigt, also im selben +-- Kontext wie der PC selbst. Wer dort Markup einschleusen kann, kann auch +-- dessen NUI-Callbacks aufrufen. Ein Blockmodell schliesst das konstruktiv +-- aus, statt es filtern zu muessen – Filtern von HTML ist ein Wettlauf, den +-- man langfristig verliert. +-- +-- Jeder Block wird hier gegen ein festes Schema geprueft. Unbekannte Typen und +-- unbekannte Felder fallen weg, statt durchgereicht zu werden. +-- ───────────────────────────────────────────────────────────────────────────── + +ICWeb = ICWeb or {} +ICWeb.Blocks = {} + +local B = ICWeb.Blocks + +-- Feldtypen: text (einzeilig), longtext (mehrzeilig), url, int, enum, list +B.SCHEMA = { + heading = { + label = 'Überschrift', + fields = { + text = { kind = 'text', max = 120, required = true }, + level = { kind = 'enum', values = { 1, 2, 3 }, default = 2 }, + }, + }, + text = { + label = 'Textabsatz', + fields = { + text = { kind = 'longtext', max = 4000, required = true }, + }, + }, + image = { + label = 'Bild', + fields = { + url = { kind = 'url', max = 500, required = true }, + alt = { kind = 'text', max = 160, default = '' }, + caption = { kind = 'text', max = 200, default = '' }, + }, + }, + list = { + label = 'AufzΓ€hlung', + fields = { + items = { kind = 'list', max = 20, itemMax = 200, required = true }, + }, + }, + button = { + label = 'SchaltflΓ€che', + fields = { + label = { kind = 'text', max = 60, required = true }, + target = { kind = 'text', max = 120, required = true }, + }, + }, + contact = { + label = 'Kontaktbox', + fields = { + email = { kind = 'text', max = 200, default = '' }, + phone = { kind = 'text', max = 40, default = '' }, + address = { kind = 'text', max = 200, default = '' }, + }, + }, + divider = { + label = 'Trennlinie', + fields = {}, + }, +} + +-- ── Hilfsfunktionen ───────────────────────────────────────────────────────── + +-- Steuerzeichen entfernen und Laenge begrenzen. Winkelklammern bleiben +-- erhalten: der Renderer setzt Text ueber textContent, dort sind sie harmlos +-- und wuerden sonst grundlos verstuemmelt. +local function cleanText(value, maxLen, allowNewlines) + if type(value) ~= 'string' then return nil end + + local out = value:gsub('%z', '') + if allowNewlines then + out = out:gsub('[\r]', '') + out = out:gsub('[^\n\32-\126\128-\255]', '') + else + out = out:gsub('[^\32-\126\128-\255]', '') + end + + out = out:sub(1, maxLen or 200) + return out +end + +-- Bild-URLs: nur https, nur erlaubte Hosts. +-- +-- Ohne Hostpruefung landet frueher oder spaeter etwas auf einer IC-Seite, das +-- dort nicht hingehoert. Die Meldefunktion faengt den Rest, aber sie greift +-- erst nachtraeglich. +function B.CheckImageUrl(url) + if type(url) ~= 'string' then return nil, 'keine Adresse' end + + url = url:gsub('%s', '') + if #url == 0 or #url > 500 then return nil, 'Adresse zu lang oder leer' end + + local host = url:match('^https://([^/]+)/') + if not host then return nil, 'nur https-Adressen erlaubt' end + host = host:lower() + + local allowed = (Config and Config.ImageHosts) or {} + for _, pattern in ipairs(allowed) do + -- Exakter Host oder Subdomain davon. + if host == pattern or host:sub(-(#pattern + 1)) == ('.' .. pattern) then + return url + end + end + + return nil, ('Bildhost nicht erlaubt: %s'):format(host) +end + +-- ── Einzelnen Block pruefen ───────────────────────────────────────────────── +-- Gibt (block, nil) oder (nil, fehlertext) zurueck. +function B.Validate(block) + if type(block) ~= 'table' then return nil, 'Block ist kein Objekt' end + + local def = B.SCHEMA[block.type] + if not def then + return nil, ('unbekannter Blocktyp: %s'):format(tostring(block.type)) + end + + -- Neu aufbauen statt kopieren: was nicht im Schema steht, faellt weg. + local out = { type = block.type } + + for name, field in pairs(def.fields) do + local raw = block[name] + + if field.kind == 'text' or field.kind == 'longtext' then + local value = cleanText(raw, field.max, field.kind == 'longtext') + if value == nil or value == '' then + if field.required then + return nil, ('%s: Feld "%s" fehlt'):format(def.label, name) + end + value = field.default or '' + end + out[name] = value + + elseif field.kind == 'url' then + local value, err = B.CheckImageUrl(raw) + if not value then + if field.required then + return nil, ('%s: %s'):format(def.label, err or 'ungueltige Adresse') + end + value = '' + end + out[name] = value + + elseif field.kind == 'enum' then + local ok = false + for _, allowed in ipairs(field.values) do + if raw == allowed then ok = true break end + end + out[name] = ok and raw or field.default + + elseif field.kind == 'list' then + if type(raw) ~= 'table' then + if field.required then + return nil, ('%s: Liste fehlt'):format(def.label) + end + raw = {} + end + local items = {} + for i = 1, math.min(#raw, field.max) do + local item = cleanText(raw[i], field.itemMax) + if item and item ~= '' then items[#items + 1] = item end + end + if #items == 0 and field.required then + return nil, ('%s: Liste ist leer'):format(def.label) + end + out[name] = items + end + end + + return out, nil +end + +-- ── Ganze Seite pruefen ───────────────────────────────────────────────────── +-- Gibt (blockliste, nil) oder (nil, fehlertext) zurueck. +function B.ValidatePage(blocks) + if type(blocks) ~= 'table' then return nil, 'Seiteninhalt ist keine Liste' end + + local maxBlocks = (Config and Config.MaxBlocksPerPage) or 60 + if #blocks > maxBlocks then + return nil, ('zu viele Bloecke (%d, erlaubt sind %d)'):format(#blocks, maxBlocks) + end + + local out = {} + for i = 1, #blocks do + local block, err = B.Validate(blocks[i]) + if not block then + return nil, ('Block %d: %s'):format(i, err) + end + out[#out + 1] = block + end + + return out, nil +end diff --git a/sql/ic_web.sql b/sql/ic_web.sql new file mode 100644 index 0000000..ffcb3ea --- /dev/null +++ b/sql/ic_web.sql @@ -0,0 +1,105 @@ +-- ───────────────────────────────────────────────────────────────────────────── +-- ic-web | IC-Webhosting – Datenbankschema +-- Einmalig gegen die Spieldatenbank ausfuehren. Wiederholbar. +-- +-- Die DOMAENE selbst steht nicht hier, sondern in ic_mail_realms: ic-mail ist +-- die einzige Domaenenverwaltung, eine Webseite ist nur ein zweiter Dienst +-- darauf. Referenziert wird ueber den Domaenennamen, nicht ueber einen +-- Fremdschluessel – Fremdschluessel ueber Resource-Grenzen hinweg koppeln zwei +-- Lebenszyklen aneinander, die unabhaengig voneinander installiert werden. +-- ───────────────────────────────────────────────────────────────────────────── + +-- Eine Webseite je Domaene. +CREATE TABLE IF NOT EXISTS `ic_web_sites` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `domain` VARCHAR(80) NOT NULL, + `title` VARCHAR(120) NOT NULL DEFAULT '', + `theme` VARCHAR(40) NOT NULL DEFAULT 'clean', + `owner_type` ENUM('job','faction') NOT NULL DEFAULT 'job', + `owner_id` VARCHAR(80) NOT NULL, + `published` TINYINT(1) NOT NULL DEFAULT 0, + `blocked` TINYINT(1) NOT NULL DEFAULT 0, + `created_by` VARCHAR(120) NULL DEFAULT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_domain` (`domain`), + KEY `idx_owner` (`owner_type`, `owner_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Seiten einer Webseite. slug 'home' ist die Startseite. +CREATE TABLE IF NOT EXISTS `ic_web_pages` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `site_id` INT UNSIGNED NOT NULL, + `slug` VARCHAR(60) NOT NULL, + `title` VARCHAR(120) NOT NULL DEFAULT '', + `blocks` LONGTEXT NOT NULL, -- JSON-Liste, serverseitig geprueft + `sort_order` INT NOT NULL DEFAULT 0, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_site_slug` (`site_id`, `slug`), + CONSTRAINT `fk_page_site` FOREIGN KEY (`site_id`) + REFERENCES `ic_web_sites` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Zugaenge zum Verwaltungsbereich. +-- +-- superadmin – der Anbieter selbst. Legt Domaenen an und setzt je Domaene +-- einen Admin ein. Nicht an eine Domaene gebunden. +-- admin – Domaenenadmin. Pflegt die Seiten seiner Domaene und legt dort +-- weitere Zugaenge an. +-- editor – darf nur die Seiten seiner Domaene pflegen. +-- +-- Das Passwort steht nirgends im Klartext: gespeichert wird SHA2(salt||passwort). +-- Gerechnet wird das in der Datenbank, damit das Passwort den Server nur als +-- Abfrageparameter durchlaeuft und nie in einer Lua-Variable liegen bleibt. +-- +-- Das ist ein IC-Passwort, keine Sicherheitsgrenze gegen Serveradmins – wer +-- Datenbankzugriff hat, kann jeden Zugang zuruecksetzen. Es soll verhindern, +-- dass Mitspieler fremde Seiten aendern, nicht Angriffe von aussen abwehren. +CREATE TABLE IF NOT EXISTS `ic_web_accounts` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `username` VARCHAR(200) NOT NULL, -- z. B. admin@liveinvader.ls + `domain` VARCHAR(80) NULL DEFAULT NULL, -- NULL nur beim superadmin + `role` ENUM('superadmin','admin','editor') NOT NULL DEFAULT 'editor', + `display_name` VARCHAR(120) NOT NULL DEFAULT '', + `salt` CHAR(32) NOT NULL, + `password_hash` CHAR(64) NOT NULL, + `active` TINYINT(1) NOT NULL DEFAULT 1, + `must_change` TINYINT(1) NOT NULL DEFAULT 0, + `created_by` VARCHAR(200) NULL DEFAULT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `last_login` TIMESTAMP NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_username` (`username`), + KEY `idx_domain` (`domain`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Fehlgeschlagene Anmeldungen. Ohne diese Bremse ist ein vierstelliges +-- IC-Passwort in Sekunden durchprobiert. +CREATE TABLE IF NOT EXISTS `ic_web_login_fails` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `username` VARCHAR(200) NOT NULL, + `identifier` VARCHAR(120) NULL DEFAULT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_user_time` (`username`, `created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Meldungen. Bilder werden extern verlinkt, deshalb braucht es einen Weg, +-- unpassende Inhalte zu melden, ohne dass ein Admin staendig mitliest. +CREATE TABLE IF NOT EXISTS `ic_web_reports` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `site_id` INT UNSIGNED NOT NULL, + `page_slug` VARCHAR(60) NULL DEFAULT NULL, + `reason` VARCHAR(500) NOT NULL DEFAULT '', + `reported_by` VARCHAR(120) NOT NULL, + `status` ENUM('open','reviewed','dismissed') NOT NULL DEFAULT 'open', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_status` (`status`), + CONSTRAINT `fk_report_site` FOREIGN KEY (`site_id`) + REFERENCES `ic_web_sites` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;