IC-Webhosting: Domaenen, Webseiten und Postfaecher ueber Anmeldungen

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

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

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

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

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

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

18
.gitignore vendored Normal file
View file

@ -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

220
ANLEITUNG.md Normal file
View file

@ -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 |

335
README.md Normal file
View file

@ -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`) | `<name>@<domäne>` | Seiten der eigenen Domäne pflegen, dort Zugänge anlegen und verwalten |
| **Redakteur** (`editor`) | `<name>@<domäne>` | 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, 330
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
<script src="js/apps/webhosting.js"></script>
```
**`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 <benutzer@domäne> <passwort>` | Passwort setzen |
| `webaccounts` | alle Zugänge auflisten |
| `webreports` | offene Meldungen |
| `webblock <domäne>` | Seite sperren bzw. freigeben |
| `webreport done\|dismiss <id>` | 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.

127
config.lua Normal file
View file

@ -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.',
}

29
fxmanifest.lua Normal file
View file

@ -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.

445
server/auth.lua Normal file
View file

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

235
server/database.lua Normal file
View file

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

220
server/domains.lua Normal file
View file

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

137
server/framework.lua Normal file
View file

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

411
server/main.lua Normal file
View file

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

329
server/manage.lua Normal file
View file

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

204
server/sites.lua Normal file
View file

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

203
shared/blocks.lua Normal file
View file

@ -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

105
sql/ic_web.sql Normal file
View file

@ -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;