Soziales Netz im Spiel: Feed, Werbung, Markt, Kalender und Gewerbe
Jeder Spieler waehlt sich ein Handle mit @, Unternehmen und Behoerden bekommen
eigene Profile, fuer die sie Mitarbeiter freigeben.
Verwaltet wird ueber dieselbe Anmeldung wie das Webhosting: wer am IC-Computer
als Anbieter angemeldet ist, richtet Unternehmensprofile ein. Damit gibt es
genau eine Stelle, an der Verwaltungsrechte haengen - ein zweites Rechtesystem
daneben waere eine zweite Stelle, an der man jemanden zu entziehen vergisst.
Bewusst getrennt: wer fuer ein Unternehmen schreiben darf, kann sich nicht
selbst zum Verwalter machen.
Behoben gegenueber dem Ausgangsstand:
- Die Resource startete nicht. Eine harte Abhaengigkeit zeigte auf eine
Resource, die es nicht gibt - das verhindert den Start vollstaendig.
- Das Schema war gegenueber dem Code stehengeblieben: eine Tabelle fehlte
ganz, sechs Spalten fehlten. Jede Registrierung eines Handles scheiterte
deshalb mit einem SQL-Fehler. Zwei Migrationen ziehen das nach.
- Die Identitaet kommt jetzt von ESX statt von einem Charaktersystem, das
hier nicht laeuft. Ohne das findet das Mailsystem die Postfaecher nicht.
- Das Fenster hatte keinen Schliessknopf, nur ESC. Im Computerfenster ist
diese Taste aber schon vergeben.
- Bilder laden mit referrerpolicy="no-referrer": Bilderdienste sperren
Hotlinks anhand der Herkunft, und die eines NUI kennen sie nicht.
- Der Schluessel fuer den Bilder-Upload steht nicht mehr im Code, sondern in
der server.cfg (set bleeter_imgbb_key). Er gehoert nicht in ein oeffentlich
einsehbares Repository.
Neu: Unternehmensprofile und Mitarbeiterfreigabe, ueber Netzwerkereignisse und
Konsolenbefehle. Dazu eine Oberflaeche im IC-Computer, die dieselbe Gestaltung
benutzt - die Stildatei wird dafuer mechanisch gekapselt, statt sie nachzubauen.
Enthaelt README.md mit Einrichtung, Rechten und den Fallstricken.
This commit is contained in:
commit
20076b0aee
41 changed files with 8652 additions and 0 deletions
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal 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
|
||||||
209
README.md
Normal file
209
README.md
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
# bleeter — soziales Netz im Spiel
|
||||||
|
|
||||||
|
Feed, Werbeanzeigen, Marktplatz, Veranstaltungskalender und Gewerbeverzeichnis.
|
||||||
|
Jeder Spieler wählt sich ein Handle mit `@`, Unternehmen und Behörden bekommen
|
||||||
|
eigene Profile, für die sie Mitarbeiter freigeben.
|
||||||
|
|
||||||
|
Zwei Oberflächen, dieselben Daten:
|
||||||
|
|
||||||
|
- **`/bleeter`** — eigenes Fenster im Tablet-Format
|
||||||
|
- **App im IC-Computer** (`pc-live`) — dieselbe Gestaltung, in ein Fenster gesetzt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### 1. Ordner
|
||||||
|
|
||||||
|
```
|
||||||
|
resources/[haleoe]/bleeter/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Datenbank
|
||||||
|
|
||||||
|
Reihenfolge einhalten:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mysql -u BENUTZER -p DATENBANK < sql/install.sql
|
||||||
|
mysql -u BENUTZER -p DATENBANK < sql/migration_reservierte_handles.sql
|
||||||
|
mysql -u BENUTZER -p DATENBANK < sql/migration_fehlende_spalten.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
| Datei | |
|
||||||
|
|---|---|
|
||||||
|
| `install.sql` | Konten, Profile, Mitglieder, Beiträge, Kommentare, Medien, Markt, Termine |
|
||||||
|
| `migration_reservierte_handles.sql` | Sperrliste für Handles – **fehlte im Grundschema**, ohne sie scheitert jede Registrierung |
|
||||||
|
| `migration_fehlende_spalten.sql` | sechs Spalten, die der Code schreibt, die aber nicht angelegt wurden |
|
||||||
|
|
||||||
|
Alle wiederholt ausführbar.
|
||||||
|
|
||||||
|
### 3. server.cfg
|
||||||
|
|
||||||
|
```
|
||||||
|
ensure oxmysql
|
||||||
|
ensure ic-mail # optional, liefert die Mailadressen
|
||||||
|
ensure bleeter
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Bilder hochladen einrichten — **wird gebraucht**
|
||||||
|
|
||||||
|
Ohne diesen Schritt lässt sich **kein Bild hochladen**. Beiträge, Profilbilder
|
||||||
|
und Marktanzeigen bleiben dann auf verlinkte Adressen angewiesen — und die
|
||||||
|
sind unzuverlässig, weil Bilderdienste das Verlinken zeitweise sperren.
|
||||||
|
|
||||||
|
Bilder gehen zu **imgbb.com**. Der Schlüssel ist kostenlos:
|
||||||
|
|
||||||
|
1. Konto anlegen auf https://imgbb.com
|
||||||
|
2. Schlüssel holen unter https://api.imgbb.com
|
||||||
|
3. In die **`server.cfg`** eintragen, **vor** dem `ensure`:
|
||||||
|
|
||||||
|
```
|
||||||
|
set bleeter_imgbb_key "DEIN_SCHLUESSEL"
|
||||||
|
|
||||||
|
ensure bleeter
|
||||||
|
```
|
||||||
|
|
||||||
|
`set` statt `setr` — so bleibt der Schlüssel auf dem Server und geht nicht an
|
||||||
|
die Clients.
|
||||||
|
|
||||||
|
> **Warum nicht in die `config.lua`?**
|
||||||
|
> Die liegt in der Versionsverwaltung. Ein Schlüssel im Code landet damit in
|
||||||
|
> jeder Kopie und in der gesamten Historie. `Config.Media.imgbbApiKey` gibt es
|
||||||
|
> weiterhin als Rückfallebene — sinnvoll für eine Testumgebung, nicht für den
|
||||||
|
> Betrieb.
|
||||||
|
|
||||||
|
Fehlt der Schlüssel, meldet der Upload `missing_imgbb_key`.
|
||||||
|
|
||||||
|
Weitere Stellschrauben in `shared/config.lua`:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
Config.Media = {
|
||||||
|
provider = 'imgbb',
|
||||||
|
maxBytes = 2 * 1024 * 1024, -- 2 MB
|
||||||
|
allowedExtensions = { 'jpg', 'jpeg', 'png' },
|
||||||
|
allowExternalUrls = true, -- Verlinken zusätzlich erlauben
|
||||||
|
requireHttps = true,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. App im IC-Computer (optional)
|
||||||
|
|
||||||
|
Diese Dateien gehören nach `pc-live`:
|
||||||
|
|
||||||
|
| Datei | Ziel |
|
||||||
|
|---|---|
|
||||||
|
| `client/bleeter.lua` | `pc-live/client/` |
|
||||||
|
| `nui/js/apps/bleeter.js` | `pc-live/nui/js/apps/` |
|
||||||
|
| `nui/js/apps/bleeteradmin.js` | `pc-live/nui/js/apps/` |
|
||||||
|
| `nui/css/bleeter-embedded.css` | `pc-live/nui/css/` |
|
||||||
|
|
||||||
|
Dazu in pc-live eintragen: beide Skripte in `fxmanifest.lua` und
|
||||||
|
`nui/index.html`, die Stildatei in `fxmanifest.lua` und im `<head>`, die
|
||||||
|
App-Einträge in `desktop.js` (`APP_META` und die `case`-Zweige
|
||||||
|
`bleeter_data`, `bleeter_notify`, `bleeter_uploaded`) sowie
|
||||||
|
`AppRegistry.Register` in `server/apps.lua`.
|
||||||
|
|
||||||
|
**Zur Stildatei:** `nui/css/bleeter-embedded.css` wird **erzeugt**, nicht von
|
||||||
|
Hand gepflegt. Sie ist die gekapselte Fassung von `html/style.css` — jeder
|
||||||
|
Selektor auf `.bl-root` begrenzt. Ohne diese Kapselung würden Bleeters Regeln
|
||||||
|
für `*`, `html` und `body` den gesamten Computer einfärben. Neu erzeugen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 pc-live/nui/css/erzeuge-bleeter-css.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Vom Wurzelelement werden dabei nur Farben, Schrift und Variablen übernommen —
|
||||||
|
**kein Layout**. Im Original ist `.bleeter-root` ein bildschirmfüllendes
|
||||||
|
Overlay; übernähme man das, läge die App über dem ganzen Computer, auch über
|
||||||
|
der Titelleiste.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Die drei Ebenen
|
||||||
|
|
||||||
|
| | wer | darf |
|
||||||
|
|---|---|---|
|
||||||
|
| **Anbieter** | am Computer als `admin@liveinvader.ls` angemeldet | Unternehmensprofile einrichten, verwalten, moderieren |
|
||||||
|
| **Unternehmen** | wer im Profil `can_manage_members` hat | eigene Leute für das Profil freigeben |
|
||||||
|
| **Spieler** | jeder | eigenes Handle mit `@` wählen |
|
||||||
|
|
||||||
|
Die Anbieterrechte kommen aus der Anmeldung in **`ic-web`** — genau eine Stelle
|
||||||
|
für alle Verwaltungsrechte. Ein zweites Rechtesystem daneben wäre eine zweite
|
||||||
|
Stelle, an der man jemanden zu entziehen vergisst.
|
||||||
|
|
||||||
|
Die Tabelle `bleeter_lifeinvader_permissions` bleibt als zweiter Weg bestehen —
|
||||||
|
der Notausgang, falls `ic-web` nicht läuft.
|
||||||
|
|
||||||
|
Bewusst getrennt: Wer für ein Unternehmen **schreiben** darf, kann sich nicht
|
||||||
|
selbst zum **Verwalter** machen.
|
||||||
|
|
||||||
|
### Ohne Computer: Konsolenbefehle
|
||||||
|
|
||||||
|
| Befehl | |
|
||||||
|
|---|---|
|
||||||
|
| `bleeterprofile` | alle Unternehmensprofile auflisten |
|
||||||
|
| `bleeterprofil <handle> <art> [server-id]` | Profil anlegen; Arten: `small_business`, `company`, `authority`, `lifeinvader` |
|
||||||
|
| `bleeterfrei <handle> <server-id> [post\|edit\|manage\|weg]` | jemanden freigeben oder entfernen |
|
||||||
|
|
||||||
|
Im Spiel nur als Anbieter, in der Serverkonsole immer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Handles
|
||||||
|
|
||||||
|
Kleinbuchstaben, Ziffern, Punkt, Unterstrich, Bindestrich; 2 bis 32 Zeichen.
|
||||||
|
|
||||||
|
`bleeter_reserved_handles` verhindert, dass Spieler sich Namen wie `lspd` oder
|
||||||
|
`weazel` greifen. **Der Anbieter darf sie sehr wohl vergeben** — dafür ist er
|
||||||
|
da. Die Sperrliste gilt deshalb nur bei der Selbstregistrierung, nicht in der
|
||||||
|
Verwaltung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `oxmysql` | Datenbankzugriff |
|
||||||
|
| `ic-mail` | Mailadresse als Ausweis bei der Registrierung |
|
||||||
|
| `es_extended` | Charaktername und Identifier |
|
||||||
|
| `ic-web` | optional – liefert die Anbieteranmeldung |
|
||||||
|
| `pc-live` | optional – für die App im Computer |
|
||||||
|
|
||||||
|
Läuft ohne Charaktersystem: fehlt eines, wird der Lizenz-Identifier als
|
||||||
|
Charakterkennung benutzt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Web-Backend
|
||||||
|
|
||||||
|
Unter `web-backend/` liegt ein Node-Dienst, der dieselben Daten über HTTP
|
||||||
|
bereitstellt — für eine Webseite außerhalb des Spiels.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web-backend
|
||||||
|
npm install
|
||||||
|
cp .env.example .env # ausfüllen
|
||||||
|
node server.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Die `.env` enthält Datenbankzugang, JWT-Geheimnis und API-Schlüssel und ist
|
||||||
|
deshalb von der Versionsverwaltung ausgenommen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bekannte Fallstricke
|
||||||
|
|
||||||
|
**Bilder aus fremden Quellen bleiben leer.** Bilderdienste sperren das
|
||||||
|
Verlinken zeitweise. Alle `<img>` laden mit `referrerpolicy="no-referrer"`,
|
||||||
|
was bei referer-basierten Sperren hilft — gegen eine IP-Sperre nicht. Der
|
||||||
|
verlässliche Weg ist der Upload.
|
||||||
|
|
||||||
|
**Nicht die Adresse der Seite kopieren, sondern die des Bildes.**
|
||||||
|
`imgur.com/a/…` ist ein Album, `imgur.com/…` eine Bildseite. Die App rechnet
|
||||||
|
Bildseiten selbst um und holt die Adresse auch aus BBCode-, HTML- und
|
||||||
|
Markdown-Schnipseln heraus.
|
||||||
|
|
||||||
|
**Der Fokus gehört dem Computer.** `SetNuiFocus` gilt global für den Client.
|
||||||
|
Als App im Computer greift Bleeter deshalb nicht danach — sonst sperren sich
|
||||||
|
zwei Resources gegenseitig die Maus aus.
|
||||||
193
client/main.lua
Normal file
193
client/main.lua
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
local isOpen = false
|
||||||
|
local _openMode = 'phone' -- 'phone' | 'desktop'
|
||||||
|
|
||||||
|
local function nui(action, payload)
|
||||||
|
payload = payload or {}
|
||||||
|
payload.action = action
|
||||||
|
SendNUIMessage(payload)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function setOpen(nextOpen, mode)
|
||||||
|
isOpen = nextOpen == true
|
||||||
|
if isOpen and mode then _openMode = mode end
|
||||||
|
SetNuiFocus(isOpen, isOpen)
|
||||||
|
if isOpen then
|
||||||
|
nui('open', { mode = _openMode })
|
||||||
|
TriggerServerEvent('bleeter:server:requestBootstrap')
|
||||||
|
else
|
||||||
|
nui('close')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Kein PC-Modus mehr.
|
||||||
|
--
|
||||||
|
-- Bleeter im PC lief zunaechst als iframe, dessen Nachrichten hierher und
|
||||||
|
-- wieder zurueck gereicht wurden. Das ging schief, weil SetNuiFocus global
|
||||||
|
-- gilt: zwei Resourcen, die den Fokus beanspruchen, sperren die Maus aus.
|
||||||
|
--
|
||||||
|
-- pc-live bringt jetzt eine eigene Oberflaeche mit und spricht direkt mit
|
||||||
|
-- bleeter:server:*. Diese Resource kuemmert sich nur noch um ihr eigenes
|
||||||
|
-- Fenster – so wie jede andere auch.
|
||||||
|
|
||||||
|
CreateThread(function()
|
||||||
|
while true do
|
||||||
|
if isOpen then
|
||||||
|
DisableAllControlActions(0)
|
||||||
|
Wait(0)
|
||||||
|
else
|
||||||
|
Wait(250)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNetEvent('bleeter:client:open', function(mode)
|
||||||
|
setOpen(true, mode or 'phone')
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNetEvent('bleeter:client:close', function()
|
||||||
|
setOpen(false)
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNetEvent('bleeter:client:data', function(payload)
|
||||||
|
nui('data', payload or {})
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNetEvent('bleeter:client:notify', function(payload)
|
||||||
|
nui('notify', payload or {})
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNetEvent('bleeter:client:mediaUploaded', function(payload)
|
||||||
|
nui('mediaUploaded', payload or {})
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('close', function(_, cb)
|
||||||
|
setOpen(false)
|
||||||
|
cb({ ok = true })
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('checkHandle', function(payload, cb)
|
||||||
|
TriggerServerEvent('bleeter:server:checkHandle', payload or {})
|
||||||
|
cb({ ok = true })
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('registerAccount', function(payload, cb)
|
||||||
|
TriggerServerEvent('bleeter:server:registerAccount', payload or {})
|
||||||
|
cb({ ok = true })
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('requestBootstrap', function(_, cb)
|
||||||
|
TriggerServerEvent('bleeter:server:requestBootstrap')
|
||||||
|
cb({ ok = true })
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('setActiveProfile', function(payload, cb)
|
||||||
|
TriggerServerEvent('bleeter:server:setActiveProfile', payload or {})
|
||||||
|
cb({ ok = true })
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('followProfile', function(payload, cb)
|
||||||
|
TriggerServerEvent('bleeter:server:followProfile', payload or {})
|
||||||
|
cb({ ok = true })
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('unfollowProfile', function(payload, cb)
|
||||||
|
TriggerServerEvent('bleeter:server:unfollowProfile', payload or {})
|
||||||
|
cb({ ok = true })
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('blockProfile', function(payload, cb)
|
||||||
|
TriggerServerEvent('bleeter:server:blockProfile', payload or {})
|
||||||
|
cb({ ok = true })
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('unblockProfile', function(payload, cb)
|
||||||
|
TriggerServerEvent('bleeter:server:unblockProfile', payload or {})
|
||||||
|
cb({ ok = true })
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('moderateProfile', function(payload, cb)
|
||||||
|
TriggerServerEvent('bleeter:server:moderateProfile', payload or {})
|
||||||
|
cb({ ok = true })
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('updateProfile', function(payload, cb)
|
||||||
|
TriggerServerEvent('bleeter:server:updateProfile', payload or {})
|
||||||
|
cb({ ok = true })
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('openMailTo', function(payload, cb)
|
||||||
|
setOpen(false)
|
||||||
|
TriggerServerEvent('bleeter:server:openMailTo', payload or {})
|
||||||
|
cb({ ok = true })
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('createPost', function(payload, cb)
|
||||||
|
TriggerServerEvent('bleeter:server:createPost', payload or {})
|
||||||
|
cb({ ok = true })
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('uploadMedia', function(payload, cb)
|
||||||
|
TriggerServerEvent('bleeter:server:uploadMedia', payload or {})
|
||||||
|
cb({ ok = true })
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('togglePostLike', function(payload, cb)
|
||||||
|
TriggerServerEvent('bleeter:server:togglePostLike', payload or {})
|
||||||
|
cb({ ok = true })
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('deleteOwnPost', function(payload, cb)
|
||||||
|
TriggerServerEvent('bleeter:server:deleteOwnPost', payload or {})
|
||||||
|
cb({ ok = true })
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('createComment', function(payload, cb)
|
||||||
|
TriggerServerEvent('bleeter:server:createComment', payload or {})
|
||||||
|
cb({ ok = true })
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('toggleCommentLike', function(payload, cb)
|
||||||
|
TriggerServerEvent('bleeter:server:toggleCommentLike', payload or {})
|
||||||
|
cb({ ok = true })
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('deleteOwnComment', function(payload, cb)
|
||||||
|
TriggerServerEvent('bleeter:server:deleteOwnComment', payload or {})
|
||||||
|
cb({ ok = true })
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('createMarketplaceEntry', function(payload, cb)
|
||||||
|
TriggerServerEvent('bleeter:server:createMarketplaceEntry', payload or {})
|
||||||
|
cb({ ok = true })
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('deleteMarketplaceEntry', function(payload, cb)
|
||||||
|
TriggerServerEvent('bleeter:server:deleteMarketplaceEntry', payload or {})
|
||||||
|
cb({ ok = true })
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('createEvent', function(payload, cb)
|
||||||
|
TriggerServerEvent('bleeter:server:createEvent', payload or {})
|
||||||
|
cb({ ok = true })
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback('deleteEvent', function(payload, cb)
|
||||||
|
TriggerServerEvent('bleeter:server:deleteEvent', payload or {})
|
||||||
|
cb({ ok = true })
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterCommand(Config.Command, function()
|
||||||
|
setOpen(true)
|
||||||
|
end, false)
|
||||||
|
|
||||||
|
exports('OpenBleeter', function(mode)
|
||||||
|
setOpen(true, mode or 'phone')
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- Beim Start sicherstellen dass die NUI geschlossen ist
|
||||||
|
AddEventHandler('onClientResourceStart', function(name)
|
||||||
|
if name == GetCurrentResourceName() then
|
||||||
|
SetNuiFocus(false, false)
|
||||||
|
nui('close')
|
||||||
|
end
|
||||||
|
end)
|
||||||
45
fxmanifest.lua
Normal file
45
fxmanifest.lua
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
fx_version 'cerulean'
|
||||||
|
game 'gta5'
|
||||||
|
lua54 'yes'
|
||||||
|
|
||||||
|
author 'Zoey/Codex'
|
||||||
|
description 'Standalone Bleeter social platform powered by Lifeinvader'
|
||||||
|
version '0.2.0'
|
||||||
|
|
||||||
|
dependency 'oxmysql'
|
||||||
|
dependency 'ic-mail'
|
||||||
|
|
||||||
|
-- 'core-characters' und 'zc_ifruit' waren hier harte dependencies. Eine
|
||||||
|
-- fehlende dependency verhindert den START der gesamten Resource – auf einem
|
||||||
|
-- ESX-Server waere Bleeter also nie hochgekommen. Beide werden ueber
|
||||||
|
-- GetResourceState abgefragt und sind optional (siehe shared/config.lua).
|
||||||
|
|
||||||
|
shared_scripts {
|
||||||
|
'shared/config.lua'
|
||||||
|
}
|
||||||
|
|
||||||
|
server_scripts {
|
||||||
|
'@oxmysql/lib/MySQL.lua',
|
||||||
|
'server/permissions.lua',
|
||||||
|
'server/adapters/ifruit.lua',
|
||||||
|
'server/adapters/mail.lua',
|
||||||
|
'server/adapters/media.lua',
|
||||||
|
'server/adapters/superpc.lua',
|
||||||
|
'server/adapters/icweb.lua',
|
||||||
|
'server/main.lua',
|
||||||
|
-- nach main.lua: nutzt die dort gebuendelten Hilfen (BleeterInternal)
|
||||||
|
'server/management.lua'
|
||||||
|
}
|
||||||
|
|
||||||
|
client_scripts {
|
||||||
|
'client/main.lua'
|
||||||
|
}
|
||||||
|
|
||||||
|
ui_page 'html/index.html'
|
||||||
|
|
||||||
|
files {
|
||||||
|
'html/index.html',
|
||||||
|
'html/style.css',
|
||||||
|
'html/script.js',
|
||||||
|
'html/assets/*'
|
||||||
|
}
|
||||||
1
html/assets/.keep
Normal file
1
html/assets/.keep
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
40
html/index.html
Normal file
40
html/index.html
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Bleeter</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="bleeter-root" class="bleeter-root hidden">
|
||||||
|
<main class="tablet">
|
||||||
|
<div class="tablet-glass"></div>
|
||||||
|
<section class="screen">
|
||||||
|
<header class="topbar">
|
||||||
|
<button class="brand" data-page="feed" aria-label="Bleeter Startseite">
|
||||||
|
<img referrerpolicy="no-referrer" src="https://i.ibb.co/GfLX529K/bleeter.png" alt="Bleeter">
|
||||||
|
</button>
|
||||||
|
<div></div>
|
||||||
|
<div class="search-wrap">
|
||||||
|
<input id="global-search" class="global-search" type="search" placeholder="@username suchen" autocomplete="off">
|
||||||
|
<div id="search-results" class="search-results hidden"></div>
|
||||||
|
</div>
|
||||||
|
<button class="top-profile" id="top-profile" data-page="profile" aria-label="Profil oeffnen">
|
||||||
|
<img id="top-profile-avatar" alt="">
|
||||||
|
</button>
|
||||||
|
<button class="top-close" id="top-close" title="Schließen (ESC)" aria-label="Bleeter schließen">✕</button>
|
||||||
|
</header>
|
||||||
|
<div class="app-grid">
|
||||||
|
<nav class="side-nav" id="side-nav"></nav>
|
||||||
|
<section class="content" id="content"></section>
|
||||||
|
<aside class="context" id="context"></aside>
|
||||||
|
</div>
|
||||||
|
<div id="toast-layer" class="toast-layer"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<input id="media-file-input" class="hidden" type="file" accept="image/jpeg,image/png">
|
||||||
|
</div>
|
||||||
|
<script src="script.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1961
html/script.js
Normal file
1961
html/script.js
Normal file
File diff suppressed because it is too large
Load diff
1255
html/style.css
Normal file
1255
html/style.css
Normal file
File diff suppressed because it is too large
Load diff
46
server/adapters/icweb.lua
Normal file
46
server/adapters/icweb.lua
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
-- Bleeter ← ic-web
|
||||||
|
--
|
||||||
|
-- Bleeter wird ueber dieselbe Anmeldung verwaltet wie das Webhosting: wer am
|
||||||
|
-- PC als admin@liveinvader.ls angemeldet ist, ist auch hier der Anbieter.
|
||||||
|
--
|
||||||
|
-- Damit gibt es genau eine Stelle, an der Anbieterrechte haengen – ein
|
||||||
|
-- zweites Rechtesystem daneben waere eine zweite Stelle, an der man jemanden
|
||||||
|
-- vergessen kann zu entziehen.
|
||||||
|
--
|
||||||
|
-- Anbieter (superadmin) richtet Unternehmensprofile ein und verwaltet sie
|
||||||
|
-- Unternehmen geben eigene Leute fuer ihr Profil frei
|
||||||
|
-- Spieler waehlen ihr eigenes Handle mit @
|
||||||
|
--
|
||||||
|
-- Die alte Tabelle bleeter_lifeinvader_permissions bleibt als zweiter Weg
|
||||||
|
-- bestehen: sie ist der Notausgang, falls ic-web einmal nicht laeuft.
|
||||||
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
IcWebAdapter = {}
|
||||||
|
|
||||||
|
--- Anmeldung am PC, falls vorhanden.
|
||||||
|
---@return table|nil { username, role, domain, ... }
|
||||||
|
function IcWebAdapter.GetSession(source)
|
||||||
|
if GetResourceState('ic-web') ~= 'started' then return nil end
|
||||||
|
|
||||||
|
local ok, session = pcall(function()
|
||||||
|
return exports['ic-web']:GetSession(source)
|
||||||
|
end)
|
||||||
|
return ok and session or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Ist diese Person gerade als Anbieter angemeldet?
|
||||||
|
function IcWebAdapter.IsProvider(source)
|
||||||
|
local session = IcWebAdapter.GetSession(source)
|
||||||
|
return session ~= nil and session.role == 'superadmin'
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Ist diese Person fuer eine Domaene angemeldet – also Sprecher einer Stelle?
|
||||||
|
--- Wird nicht fuer Rechte benutzt, sondern nur als Vorschlag beim Einrichten
|
||||||
|
--- eines Unternehmensprofils.
|
||||||
|
---@return string|nil Domaene, wenn angemeldet
|
||||||
|
function IcWebAdapter.SessionDomain(source)
|
||||||
|
local session = IcWebAdapter.GetSession(source)
|
||||||
|
if not session then return nil end
|
||||||
|
return session.domain
|
||||||
|
end
|
||||||
11
server/adapters/ifruit.lua
Normal file
11
server/adapters/ifruit.lua
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
IfruitAdapter = {}
|
||||||
|
|
||||||
|
function IfruitAdapter.GetAccountIdentity(source)
|
||||||
|
return {
|
||||||
|
identifier = Config.GetIdentifier(source),
|
||||||
|
charId = Config.GetCharacterId(source),
|
||||||
|
displayName = Config.GetCharacterName(source),
|
||||||
|
mailAddress = Config.GetMailAddress(source),
|
||||||
|
phoneNumber = Config.GetPhoneNumber(source)
|
||||||
|
}
|
||||||
|
end
|
||||||
22
server/adapters/mail.lua
Normal file
22
server/adapters/mail.lua
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
MailAdapter = {}
|
||||||
|
|
||||||
|
function MailAdapter.OpenCompose(source, targetMail, subject)
|
||||||
|
local message = ('Mail an %s vorbereitet%s.'):format(
|
||||||
|
targetMail or 'unbekannt',
|
||||||
|
subject and subject ~= '' and (': ' .. subject) or ''
|
||||||
|
)
|
||||||
|
|
||||||
|
TriggerClientEvent('zc_ifruit:client:hardOpen', source)
|
||||||
|
TriggerClientEvent('zc_ifruit:client:openMailCompose', source, {
|
||||||
|
to = targetMail,
|
||||||
|
subject = subject or ''
|
||||||
|
})
|
||||||
|
TriggerClientEvent('zc_ifruit:client:notify', source, {
|
||||||
|
type = 'info',
|
||||||
|
message = message
|
||||||
|
})
|
||||||
|
TriggerClientEvent('bleeter:client:notify', source, {
|
||||||
|
type = 'info',
|
||||||
|
message = message
|
||||||
|
})
|
||||||
|
end
|
||||||
141
server/adapters/media.lua
Normal file
141
server/adapters/media.lua
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
MediaAdapter = {}
|
||||||
|
|
||||||
|
local function trim(value, maxLength)
|
||||||
|
local text = tostring(value or ''):match('^%s*(.-)%s*$')
|
||||||
|
if maxLength and #text > maxLength then
|
||||||
|
text = text:sub(1, maxLength)
|
||||||
|
end
|
||||||
|
return text
|
||||||
|
end
|
||||||
|
|
||||||
|
local function extensionFromUrl(url)
|
||||||
|
return tostring(url or ''):match('%.([%w]+)%??[^/]*$')
|
||||||
|
end
|
||||||
|
|
||||||
|
local function extensionFromName(name)
|
||||||
|
return tostring(name or ''):match('%.([%w]+)$')
|
||||||
|
end
|
||||||
|
|
||||||
|
local function isAllowedExtension(ext)
|
||||||
|
ext = tostring(ext or ''):lower()
|
||||||
|
for _, allowed in ipairs(Config.Media.allowedExtensions or {}) do
|
||||||
|
if ext == allowed then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function mimeExtension(mime)
|
||||||
|
return ({
|
||||||
|
['image/jpeg'] = 'jpg',
|
||||||
|
['image/jpg'] = 'jpg',
|
||||||
|
['image/png'] = 'png'
|
||||||
|
})[tostring(mime or ''):lower()]
|
||||||
|
end
|
||||||
|
|
||||||
|
local function urlEncode(value)
|
||||||
|
return tostring(value or ''):gsub('\n', '\r\n'):gsub('([^%w%-_%.~])', function(char)
|
||||||
|
return string.format('%%%02X', string.byte(char))
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function decodeJson(value)
|
||||||
|
if not value or value == '' then return nil end
|
||||||
|
local ok, result = pcall(json.decode, value)
|
||||||
|
if ok then return result end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function MediaAdapter.IsAllowedExternalUrl(url)
|
||||||
|
url = tostring(url or '')
|
||||||
|
if Config.Media.requireHttps and not url:match('^https://') then
|
||||||
|
return false, 'url_must_be_https'
|
||||||
|
end
|
||||||
|
|
||||||
|
if url:match('^https://i%.ibb%.co/') or url:match('^https://ibb%.co/') then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local ext = extensionFromUrl(url)
|
||||||
|
if not ext then return false, 'missing_extension' end
|
||||||
|
ext = ext:lower()
|
||||||
|
|
||||||
|
if isAllowedExtension(ext) then return true end
|
||||||
|
return false, 'unsupported_extension'
|
||||||
|
end
|
||||||
|
|
||||||
|
function MediaAdapter.ValidateUpload(payload)
|
||||||
|
if type(payload) ~= 'table' then return false, 'invalid_payload' end
|
||||||
|
|
||||||
|
local name = trim(payload.name, 180)
|
||||||
|
local mime = trim(payload.mime, 80):lower()
|
||||||
|
local data = tostring(payload.data or '')
|
||||||
|
local size = tonumber(payload.size) or 0
|
||||||
|
|
||||||
|
if data == '' then return false, 'missing_data' end
|
||||||
|
if size <= 0 then return false, 'missing_size' end
|
||||||
|
if size > (Config.Media.maxBytes or 2097152) then return false, 'file_too_large' end
|
||||||
|
|
||||||
|
local ext = extensionFromName(name) or mimeExtension(mime)
|
||||||
|
if not isAllowedExtension(ext) then return false, 'unsupported_extension' end
|
||||||
|
if mimeExtension(mime) and not isAllowedExtension(mimeExtension(mime)) then return false, 'unsupported_mime' end
|
||||||
|
|
||||||
|
return true, {
|
||||||
|
name = name ~= '' and name or ('bleeter_%s.%s'):format(os.time(), ext),
|
||||||
|
mime = mime,
|
||||||
|
data = data,
|
||||||
|
size = size,
|
||||||
|
extension = ext
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
function MediaAdapter.Upload(payload, cb)
|
||||||
|
local ok, upload = MediaAdapter.ValidateUpload(payload)
|
||||||
|
if not ok then
|
||||||
|
cb(false, upload)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if Config.Media.provider ~= 'imgbb' then
|
||||||
|
cb(false, 'provider_not_configured')
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Erst die Konsolenvariable, dann die Config. So bleibt der Schluessel
|
||||||
|
-- aus der Versionsverwaltung heraus.
|
||||||
|
local apiKey = trim(GetConvar('bleeter_imgbb_key', Config.Media.imgbbApiKey or ''))
|
||||||
|
if apiKey == '' then
|
||||||
|
cb(false, 'missing_imgbb_key')
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local body = ('image=%s&name=%s'):format(urlEncode(upload.data), urlEncode(upload.name))
|
||||||
|
local url = ('https://api.imgbb.com/1/upload?key=%s'):format(urlEncode(apiKey))
|
||||||
|
|
||||||
|
PerformHttpRequest(url, function(statusCode, responseText)
|
||||||
|
if statusCode < 200 or statusCode >= 300 then
|
||||||
|
cb(false, 'upload_failed')
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local decoded = decodeJson(responseText)
|
||||||
|
local data = decoded and decoded.data
|
||||||
|
local imageUrl = data and (data.url or data.display_url)
|
||||||
|
|
||||||
|
if not imageUrl or imageUrl == '' then
|
||||||
|
cb(false, 'missing_upload_url')
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
cb(true, {
|
||||||
|
url = imageUrl,
|
||||||
|
deleteUrl = data.delete_url,
|
||||||
|
size = upload.size,
|
||||||
|
mime = upload.mime,
|
||||||
|
name = upload.name
|
||||||
|
})
|
||||||
|
end, 'POST', body, {
|
||||||
|
['Content-Type'] = 'application/x-www-form-urlencoded'
|
||||||
|
})
|
||||||
|
end
|
||||||
39
server/adapters/superpc.lua
Normal file
39
server/adapters/superpc.lua
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
SuperPcAdapter = {}
|
||||||
|
|
||||||
|
local businessStatus = {}
|
||||||
|
|
||||||
|
function SuperPcAdapter.GetBusinessStatus(handle)
|
||||||
|
return businessStatus[handle] or 'closed'
|
||||||
|
end
|
||||||
|
|
||||||
|
function SuperPcAdapter.SetBusinessStatus(handle, status)
|
||||||
|
if not handle or handle == '' then return false end
|
||||||
|
if status ~= 'open' and status ~= 'closed' then return false end
|
||||||
|
businessStatus[handle] = status
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function SuperPcAdapter.GetProfileMemberships(_source)
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
|
||||||
|
function SuperPcAdapter.HasLifeinvaderPermission(_source, _permission)
|
||||||
|
local identity = IfruitAdapter and IfruitAdapter.GetAccountIdentity and IfruitAdapter.GetAccountIdentity(_source)
|
||||||
|
local charId = identity and identity.charId or ''
|
||||||
|
local permission = tostring(_permission or '')
|
||||||
|
|
||||||
|
if charId == '' or permission == '' then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local row = MySQL.single.await([[
|
||||||
|
SELECT id
|
||||||
|
FROM bleeter_lifeinvader_permissions
|
||||||
|
WHERE char_id = ?
|
||||||
|
AND allowed = 1
|
||||||
|
AND permission IN (?, '*', 'admin')
|
||||||
|
LIMIT 1
|
||||||
|
]], { charId, permission })
|
||||||
|
|
||||||
|
return row ~= nil
|
||||||
|
end
|
||||||
1627
server/main.lua
Normal file
1627
server/main.lua
Normal file
File diff suppressed because it is too large
Load diff
437
server/management.lua
Normal file
437
server/management.lua
Normal file
|
|
@ -0,0 +1,437 @@
|
||||||
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
-- Bleeter – Unternehmensprofile und Mitarbeiterfreigabe
|
||||||
|
--
|
||||||
|
-- Drei Ebenen, angelehnt an ic-web:
|
||||||
|
--
|
||||||
|
-- Anbieter am PC als admin@liveinvader.ls angemeldet. Richtet
|
||||||
|
-- Unternehmensprofile ein und setzt den ersten Verantwortlichen.
|
||||||
|
-- Unternehmen wer im Profil can_manage_members hat, gibt weitere Leute frei.
|
||||||
|
-- Spieler waehlen ihr eigenes Handle mit @ (registerAccount in main.lua).
|
||||||
|
--
|
||||||
|
-- Bewusst getrennt von "darf posten": wer fuer ein Unternehmen schreiben darf,
|
||||||
|
-- soll sich nicht selbst zum Verwalter machen koennen.
|
||||||
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
local trim = BleeterInternal.trim
|
||||||
|
local notify = BleeterInternal.notify
|
||||||
|
local decodeBool = BleeterInternal.decodeBool
|
||||||
|
local pushData = BleeterInternal.pushData
|
||||||
|
local writeAudit = BleeterInternal.writeAudit
|
||||||
|
|
||||||
|
-- Profiltypen, die ein Unternehmen darstellen. 'private' gehoert einer Person
|
||||||
|
-- und wird nicht hier vergeben, sondern vom Spieler selbst gewaehlt.
|
||||||
|
local BUSINESS_TYPES = {
|
||||||
|
small_business = true,
|
||||||
|
company = true,
|
||||||
|
authority = true,
|
||||||
|
lifeinvader = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
local function reply(src, action, ok, data, err)
|
||||||
|
TriggerClientEvent('bleeter:client:data', src, {
|
||||||
|
ok = true,
|
||||||
|
data = { action = action, ok = ok == true, payload = data, error = err },
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
local function isProvider(src)
|
||||||
|
-- Serverkonsole. Netzwerkereignisse haben nie die Quelle 0, das kann also
|
||||||
|
-- niemand von aussen erreichen – es ist der Zugang des Betreibers.
|
||||||
|
if not src or src == 0 then return true end
|
||||||
|
|
||||||
|
return BleeterPermissions.HasLifeinvaderPermission(src, 'profile.staff')
|
||||||
|
end
|
||||||
|
|
||||||
|
local function charIdOf(src)
|
||||||
|
return trim(Config.GetCharacterId(src), 80)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Handle pruefen. Gibt (handle, nil) oder (nil, fehlertext) zurueck.
|
||||||
|
---
|
||||||
|
--- Die Liste der reservierten Handles wird hier bewusst NICHT geprueft.
|
||||||
|
--- Sie soll verhindern, dass sich Spieler Namen wie 'lspd' oder 'weazel'
|
||||||
|
--- greifen – geprueft wird sie deshalb bei der Selbstregistrierung
|
||||||
|
--- (registerAccount in main.lua).
|
||||||
|
---
|
||||||
|
--- Hierher kommt nur der Anbieter, und der vergibt diese Namen ja gerade:
|
||||||
|
--- @weazelnews soll bei Weazel News landen. Eine Pruefung an dieser Stelle
|
||||||
|
--- kann niemanden schuetzen, sondern nur die Stelle blockieren, die die Namen
|
||||||
|
--- verteilt.
|
||||||
|
local function checkHandle(raw)
|
||||||
|
local handle = trim(raw, 40):lower()
|
||||||
|
|
||||||
|
if #handle < Config.Handles.minLength or #handle > Config.Handles.maxLength then
|
||||||
|
return nil, ('Handle muss %d bis %d Zeichen lang sein.')
|
||||||
|
:format(Config.Handles.minLength, Config.Handles.maxLength)
|
||||||
|
end
|
||||||
|
if not handle:match(Config.Handles.pattern) then
|
||||||
|
return nil, 'Handle enthält ungültige Zeichen (nur a-z, 0-9, . _ - erlaubt).'
|
||||||
|
end
|
||||||
|
if MySQL.single.await('SELECT 1 FROM bleeter_profiles WHERE handle = ? LIMIT 1',
|
||||||
|
{ handle }) then
|
||||||
|
return nil, 'Dieses Handle ist bereits vergeben.'
|
||||||
|
end
|
||||||
|
|
||||||
|
return handle, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ── Unternehmensprofil einrichten ───────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Als Funktion, nicht nur als Ereignis: derselbe Ablauf wird auch vom
|
||||||
|
-- Chatbefehl benutzt. Zwei Wege, eine Pruefung.
|
||||||
|
local function createBusinessProfile(src, payload)
|
||||||
|
if not isProvider(src) then
|
||||||
|
return reply(src, 'createBusinessProfile', false, nil,
|
||||||
|
'Nur die Lifeinvader-Verwaltung darf Unternehmensprofile einrichten.')
|
||||||
|
end
|
||||||
|
|
||||||
|
payload = type(payload) == 'table' and payload or {}
|
||||||
|
|
||||||
|
local profileType = trim(payload.profileType, 24)
|
||||||
|
if not BUSINESS_TYPES[profileType] then
|
||||||
|
return reply(src, 'createBusinessProfile', false, nil, 'Unbekannte Profilart.')
|
||||||
|
end
|
||||||
|
|
||||||
|
local handle, err = checkHandle(payload.handle)
|
||||||
|
if not handle then
|
||||||
|
return reply(src, 'createBusinessProfile', false, nil, err)
|
||||||
|
end
|
||||||
|
|
||||||
|
local displayName = trim(payload.displayName, 80)
|
||||||
|
if displayName == '' then displayName = handle end
|
||||||
|
|
||||||
|
-- Der erste Verantwortliche wird ueber die Server-Id gewaehlt; den
|
||||||
|
-- Charakter dazu sucht der Server selbst.
|
||||||
|
local ownerSource = tonumber(payload.ownerSource)
|
||||||
|
local ownerCharId = ownerSource and charIdOf(ownerSource) or ''
|
||||||
|
|
||||||
|
local profileId = MySQL.insert.await([[
|
||||||
|
INSERT INTO bleeter_profiles
|
||||||
|
(account_id, profile_type, handle, display_name, email_contact, bio,
|
||||||
|
owner_source, created_by_char_id)
|
||||||
|
VALUES (NULL, ?, ?, ?, ?, '', 'lifeinvader', ?)
|
||||||
|
]], {
|
||||||
|
profileType, handle, displayName,
|
||||||
|
trim(payload.emailContact, 120):lower(),
|
||||||
|
charIdOf(src),
|
||||||
|
})
|
||||||
|
|
||||||
|
if not profileId then
|
||||||
|
return reply(src, 'createBusinessProfile', false, nil, 'Anlegen fehlgeschlagen.')
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Ohne Verantwortlichen waere das Profil nicht bedienbar: es gehoert
|
||||||
|
-- keinem Konto, der Zugang laeuft ausschliesslich ueber die Mitgliedschaft.
|
||||||
|
if ownerCharId ~= '' then
|
||||||
|
MySQL.insert.await([[
|
||||||
|
INSERT INTO bleeter_profile_members
|
||||||
|
(profile_id, char_id, role, can_post, can_edit_profile, can_manage_members, source)
|
||||||
|
VALUES (?, ?, 'owner', 1, 1, 1, 'lifeinvader')
|
||||||
|
]], { profileId, ownerCharId })
|
||||||
|
|
||||||
|
if ownerSource then
|
||||||
|
notify(ownerSource, ('Du verwaltest jetzt das Bleeter-Profil @%s.'):format(handle),
|
||||||
|
'success')
|
||||||
|
pushData(ownerSource)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local account = BleeterInternal.ensureAccount(src)
|
||||||
|
if account then
|
||||||
|
writeAudit(account, nil, 'profile.business.create', 'profile', profileId, nil, {
|
||||||
|
handle = handle, profile_type = profileType, owner = ownerCharId,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
reply(src, 'createBusinessProfile', true, { id = profileId, handle = handle })
|
||||||
|
notify(src, ('Profil @%s eingerichtet.'):format(handle), 'success')
|
||||||
|
pushData(src)
|
||||||
|
return true, handle
|
||||||
|
end
|
||||||
|
|
||||||
|
RegisterNetEvent('bleeter:server:createBusinessProfile', function(payload)
|
||||||
|
createBusinessProfile(source, payload)
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- ── Profile, die diese Person verwalten darf ────────────────────────────────
|
||||||
|
RegisterNetEvent('bleeter:server:listBusinessProfiles', function()
|
||||||
|
local src = source
|
||||||
|
|
||||||
|
local rows
|
||||||
|
if isProvider(src) then
|
||||||
|
rows = MySQL.query.await([[
|
||||||
|
SELECT p.id, p.handle, p.display_name, p.profile_type, p.is_active,
|
||||||
|
p.is_locked, p.is_verified,
|
||||||
|
(SELECT COUNT(*) FROM bleeter_profile_members m
|
||||||
|
WHERE m.profile_id = p.id) AS member_count
|
||||||
|
FROM bleeter_profiles p
|
||||||
|
WHERE p.profile_type <> 'private'
|
||||||
|
ORDER BY p.display_name
|
||||||
|
]]) or {}
|
||||||
|
else
|
||||||
|
rows = MySQL.query.await([[
|
||||||
|
SELECT p.id, p.handle, p.display_name, p.profile_type, p.is_active,
|
||||||
|
p.is_locked, p.is_verified,
|
||||||
|
(SELECT COUNT(*) FROM bleeter_profile_members m2
|
||||||
|
WHERE m2.profile_id = p.id) AS member_count
|
||||||
|
FROM bleeter_profile_members m
|
||||||
|
JOIN bleeter_profiles p ON p.id = m.profile_id
|
||||||
|
WHERE m.char_id = ? AND m.can_manage_members = 1
|
||||||
|
ORDER BY p.display_name
|
||||||
|
]], { charIdOf(src) }) or {}
|
||||||
|
end
|
||||||
|
|
||||||
|
reply(src, 'listBusinessProfiles', true, { profiles = rows, provider = isProvider(src) })
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- ── Mitarbeiter eines Profils ───────────────────────────────────────────────
|
||||||
|
RegisterNetEvent('bleeter:server:listProfileMembers', function(payload)
|
||||||
|
local src = source
|
||||||
|
local profileId = tonumber(payload and payload.profileId)
|
||||||
|
if not profileId then return end
|
||||||
|
|
||||||
|
if not BleeterPermissions.CanManageMembers(src, charIdOf(src), profileId) then
|
||||||
|
return reply(src, 'listProfileMembers', false, nil, 'Keine Berechtigung.')
|
||||||
|
end
|
||||||
|
|
||||||
|
local rows = MySQL.query.await([[
|
||||||
|
SELECT m.char_id, m.role, m.can_post, m.can_edit_profile, m.can_manage_members,
|
||||||
|
m.source, m.updated_at
|
||||||
|
FROM bleeter_profile_members m
|
||||||
|
WHERE m.profile_id = ?
|
||||||
|
ORDER BY m.role, m.char_id
|
||||||
|
]], { profileId }) or {}
|
||||||
|
|
||||||
|
-- Namen gibt es nur fuer Anwesende. Wer offline ist, wird ueber die
|
||||||
|
-- Charakter-Id angezeigt – ein Namensnachschlag in einem fremden Schema
|
||||||
|
-- waere ein Alleingang, der bei der naechsten Aenderung dort ausfaellt.
|
||||||
|
local online = {}
|
||||||
|
for _, playerSrc in ipairs(GetPlayers()) do
|
||||||
|
online[charIdOf(tonumber(playerSrc))] = {
|
||||||
|
source = tonumber(playerSrc),
|
||||||
|
name = Config.GetCharacterName(tonumber(playerSrc)),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, row in ipairs(rows) do
|
||||||
|
local who = online[row.char_id]
|
||||||
|
row.name = who and who.name or row.char_id
|
||||||
|
row.online = who ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
reply(src, 'listProfileMembers', true, { profileId = profileId, members = rows })
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- ── Auswahlliste: wer ist gerade da ─────────────────────────────────────────
|
||||||
|
RegisterNetEvent('bleeter:server:listCandidates', function(payload)
|
||||||
|
local src = source
|
||||||
|
local profileId = tonumber(payload and payload.profileId)
|
||||||
|
|
||||||
|
-- Auch ohne Profil-Id nutzbar: der Anbieter braucht die Liste schon beim
|
||||||
|
-- Einrichten, wenn es das Profil noch gar nicht gibt.
|
||||||
|
if profileId and not BleeterPermissions.CanManageMembers(src, charIdOf(src), profileId) then
|
||||||
|
return reply(src, 'listCandidates', false, nil, 'Keine Berechtigung.')
|
||||||
|
end
|
||||||
|
if not profileId and not isProvider(src) then
|
||||||
|
return reply(src, 'listCandidates', false, nil, 'Keine Berechtigung.')
|
||||||
|
end
|
||||||
|
|
||||||
|
local existing = {}
|
||||||
|
if profileId then
|
||||||
|
for _, row in ipairs(MySQL.query.await(
|
||||||
|
'SELECT char_id FROM bleeter_profile_members WHERE profile_id = ?',
|
||||||
|
{ profileId }) or {}) do
|
||||||
|
existing[row.char_id] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
for _, playerSrc in ipairs(GetPlayers()) do
|
||||||
|
local id = tonumber(playerSrc)
|
||||||
|
local charId = charIdOf(id)
|
||||||
|
if charId ~= '' and not existing[charId] then
|
||||||
|
out[#out + 1] = { source = id, name = Config.GetCharacterName(id) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
table.sort(out, function(a, b) return a.name < b.name end)
|
||||||
|
reply(src, 'listCandidates', true, { candidates = out })
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- ── Mitarbeiter freigeben oder Rechte aendern ───────────────────────────────
|
||||||
|
local function setProfileMember(src, payload)
|
||||||
|
payload = type(payload) == 'table' and payload or {}
|
||||||
|
|
||||||
|
local profileId = tonumber(payload.profileId)
|
||||||
|
if not profileId then return end
|
||||||
|
|
||||||
|
local ownCharId = charIdOf(src)
|
||||||
|
if not BleeterPermissions.CanManageMembers(src, ownCharId, profileId) then
|
||||||
|
return reply(src, 'setProfileMember', false, nil, 'Keine Berechtigung.')
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Ziel ueber die Server-Id: der Client soll keine Charakter-Ids kennen
|
||||||
|
-- muessen, und der Server sucht sie ohnehin selbst.
|
||||||
|
local targetSource = tonumber(payload.targetSource)
|
||||||
|
local targetCharId = targetSource and charIdOf(targetSource) or trim(payload.charId, 80)
|
||||||
|
if not targetCharId or targetCharId == '' then
|
||||||
|
return reply(src, 'setProfileMember', false, nil, 'Diese Person ist nicht (mehr) online.')
|
||||||
|
end
|
||||||
|
|
||||||
|
if targetCharId == ownCharId and not isProvider(src) then
|
||||||
|
return reply(src, 'setProfileMember', false, nil,
|
||||||
|
'Deine eigenen Rechte kannst du hier nicht ändern.')
|
||||||
|
end
|
||||||
|
|
||||||
|
local profile = MySQL.single.await(
|
||||||
|
'SELECT id, handle, profile_type FROM bleeter_profiles WHERE id = ?', { profileId })
|
||||||
|
if not profile then
|
||||||
|
return reply(src, 'setProfileMember', false, nil, 'Profil nicht gefunden.')
|
||||||
|
end
|
||||||
|
if profile.profile_type == 'private' then
|
||||||
|
return reply(src, 'setProfileMember', false, nil,
|
||||||
|
'Ein privates Profil gehört genau einer Person.')
|
||||||
|
end
|
||||||
|
|
||||||
|
if decodeBool(payload.remove) then
|
||||||
|
MySQL.update.await(
|
||||||
|
'DELETE FROM bleeter_profile_members WHERE profile_id = ? AND char_id = ?',
|
||||||
|
{ profileId, targetCharId })
|
||||||
|
|
||||||
|
if targetSource then pushData(targetSource) end
|
||||||
|
reply(src, 'setProfileMember', true)
|
||||||
|
notify(src, 'Freigabe entfernt.', 'success')
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
MySQL.insert.await([[
|
||||||
|
INSERT INTO bleeter_profile_members
|
||||||
|
(profile_id, char_id, role, can_post, can_edit_profile, can_manage_members, source)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 'ingame')
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
role = VALUES(role),
|
||||||
|
can_post = VALUES(can_post),
|
||||||
|
can_edit_profile = VALUES(can_edit_profile),
|
||||||
|
can_manage_members = VALUES(can_manage_members),
|
||||||
|
source = VALUES(source)
|
||||||
|
]], {
|
||||||
|
profileId, targetCharId,
|
||||||
|
trim(payload.role, 40) ~= '' and trim(payload.role, 40) or 'member',
|
||||||
|
decodeBool(payload.canPost) and 1 or 0,
|
||||||
|
decodeBool(payload.canEditProfile) and 1 or 0,
|
||||||
|
decodeBool(payload.canManageMembers) and 1 or 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
local account = BleeterInternal.ensureAccount(src)
|
||||||
|
if account then
|
||||||
|
writeAudit(account, nil, 'profile.member.set', 'profile', profileId, nil, {
|
||||||
|
handle = profile.handle, target = targetCharId,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
if targetSource then
|
||||||
|
notify(targetSource, ('Du bist jetzt für @%s freigegeben.'):format(profile.handle),
|
||||||
|
'success')
|
||||||
|
pushData(targetSource)
|
||||||
|
end
|
||||||
|
|
||||||
|
reply(src, 'setProfileMember', true)
|
||||||
|
notify(src, 'Freigabe gespeichert.', 'success')
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
RegisterNetEvent('bleeter:server:setProfileMember', function(payload)
|
||||||
|
setProfileMember(source, payload)
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
-- Befehle
|
||||||
|
--
|
||||||
|
-- Solange es im Bleeter-Fenster noch keine Verwaltungsseite gibt, laeuft die
|
||||||
|
-- Einrichtung ueber diese Befehle. Die Rechtepruefung ist dieselbe wie oben:
|
||||||
|
-- im Spiel entscheidet die Anmeldung am PC, in der Serverkonsole der Betreiber.
|
||||||
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
local function usage(src, ...)
|
||||||
|
for _, line in ipairs({ ... }) do
|
||||||
|
if src == 0 then print(line) else notify(src, line, 'info') end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
RegisterCommand('bleeterprofil', function(src, args)
|
||||||
|
if src ~= 0 and not isProvider(src) then
|
||||||
|
return notify(src, 'Dafür musst du als admin@liveinvader.ls angemeldet sein.', 'error')
|
||||||
|
end
|
||||||
|
|
||||||
|
local handle = args[1]
|
||||||
|
local profileType = args[2]
|
||||||
|
local ownerSource = tonumber(args[3])
|
||||||
|
|
||||||
|
if not handle or not profileType then
|
||||||
|
return usage(src,
|
||||||
|
'/bleeterprofil <handle> <art> [server-id des Verantwortlichen]',
|
||||||
|
'Arten: small_business, company, authority, lifeinvader')
|
||||||
|
end
|
||||||
|
|
||||||
|
local displayName = table.concat(args, ' ', 4)
|
||||||
|
createBusinessProfile(src, {
|
||||||
|
handle = handle, profileType = profileType,
|
||||||
|
ownerSource = ownerSource,
|
||||||
|
displayName = displayName ~= '' and displayName or nil,
|
||||||
|
})
|
||||||
|
end, false)
|
||||||
|
|
||||||
|
RegisterCommand('bleeterfrei', function(src, args)
|
||||||
|
local handle = tostring(args[1] or ''):lower()
|
||||||
|
local targetSource = tonumber(args[2])
|
||||||
|
local rights = tostring(args[3] or 'post')
|
||||||
|
|
||||||
|
if handle == '' or not targetSource then
|
||||||
|
return usage(src,
|
||||||
|
'/bleeterfrei <handle> <server-id> [post|edit|manage|weg]',
|
||||||
|
'post = darf schreiben, edit = darf das Profil ändern,',
|
||||||
|
'manage = darf zusätzlich Leute freigeben, weg = Freigabe entfernen')
|
||||||
|
end
|
||||||
|
|
||||||
|
local profile = MySQL.single.await(
|
||||||
|
'SELECT id FROM bleeter_profiles WHERE handle = ?', { handle })
|
||||||
|
if not profile then
|
||||||
|
return usage(src, ('Profil @%s gibt es nicht.'):format(handle))
|
||||||
|
end
|
||||||
|
|
||||||
|
setProfileMember(src, {
|
||||||
|
profileId = profile.id,
|
||||||
|
targetSource = targetSource,
|
||||||
|
remove = rights == 'weg',
|
||||||
|
role = rights == 'manage' and 'manager' or 'member',
|
||||||
|
canPost = rights ~= 'weg',
|
||||||
|
canEditProfile = rights == 'edit' or rights == 'manage',
|
||||||
|
canManageMembers = rights == 'manage',
|
||||||
|
})
|
||||||
|
end, false)
|
||||||
|
|
||||||
|
RegisterCommand('bleeterprofile', function(src)
|
||||||
|
if src ~= 0 and not isProvider(src) then
|
||||||
|
return notify(src, 'Dafür musst du als admin@liveinvader.ls angemeldet sein.', 'error')
|
||||||
|
end
|
||||||
|
|
||||||
|
local rows = MySQL.query.await([[
|
||||||
|
SELECT p.handle, p.display_name, p.profile_type, p.is_locked,
|
||||||
|
(SELECT COUNT(*) FROM bleeter_profile_members m WHERE m.profile_id = p.id) AS members
|
||||||
|
FROM bleeter_profiles p
|
||||||
|
WHERE p.profile_type <> 'private'
|
||||||
|
ORDER BY p.display_name
|
||||||
|
]]) or {}
|
||||||
|
|
||||||
|
if src ~= 0 then
|
||||||
|
return notify(src, ('%d Unternehmensprofile – Liste steht in der Serverkonsole.')
|
||||||
|
:format(#rows), 'info')
|
||||||
|
end
|
||||||
|
|
||||||
|
print(('[bleeter] %d Unternehmensprofile:'):format(#rows))
|
||||||
|
for _, r in ipairs(rows) do
|
||||||
|
print((' @%-24s %-16s %2d Mitarbeiter%s'):format(
|
||||||
|
r.handle, r.profile_type, r.members,
|
||||||
|
(r.is_locked == 1 or r.is_locked == true) and ' [gesperrt]' or ''))
|
||||||
|
end
|
||||||
|
end, false)
|
||||||
56
server/permissions.lua
Normal file
56
server/permissions.lua
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
BleeterPermissions = {}
|
||||||
|
|
||||||
|
local feedPostRights = {
|
||||||
|
private = { home = true },
|
||||||
|
small_business = { advertising = true },
|
||||||
|
company = { advertising = true },
|
||||||
|
authority = { home = true, advertising = true },
|
||||||
|
lifeinvader = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function BleeterPermissions.CanPost(profileType, feedType)
|
||||||
|
local rights = feedPostRights[profileType or ''] or {}
|
||||||
|
return rights[feedType or ''] == true
|
||||||
|
end
|
||||||
|
|
||||||
|
function BleeterPermissions.CanInteract(profile)
|
||||||
|
return profile and profile.is_locked ~= true and profile.is_active ~= false
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Anbieterrechte (Lifeinvader).
|
||||||
|
---
|
||||||
|
--- Erste Quelle ist die Anmeldung am PC: wer als admin@liveinvader.ls
|
||||||
|
--- angemeldet ist, hat alle Rechte. Damit haengt Bleeter an derselben Stelle
|
||||||
|
--- wie das Webhosting, statt eine zweite Rechteliste zu fuehren.
|
||||||
|
---
|
||||||
|
--- Die alte Tabelle bleibt als zweiter Weg bestehen – der Notausgang, falls
|
||||||
|
--- ic-web einmal nicht laeuft.
|
||||||
|
function BleeterPermissions.HasLifeinvaderPermission(source, permission)
|
||||||
|
if IcWebAdapter and IcWebAdapter.IsProvider(source) then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
if SuperPcAdapter and SuperPcAdapter.HasLifeinvaderPermission then
|
||||||
|
return SuperPcAdapter.HasLifeinvaderPermission(source, permission)
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Darf diese Person die Mitglieder dieses Profils verwalten?
|
||||||
|
--- Der Anbieter immer; sonst nur, wer im Profil ausdruecklich dafuer
|
||||||
|
--- eingetragen ist. Wer nur posten darf, soll sich nicht selbst weitere
|
||||||
|
--- Rechte holen koennen.
|
||||||
|
function BleeterPermissions.CanManageMembers(source, charId, profileId)
|
||||||
|
if BleeterPermissions.HasLifeinvaderPermission(source, 'profile.staff') then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if not charId or charId == '' or not profileId then return false end
|
||||||
|
|
||||||
|
local row = MySQL.single.await([[
|
||||||
|
SELECT 1 AS ok FROM bleeter_profile_members
|
||||||
|
WHERE profile_id = ? AND char_id = ? AND can_manage_members = 1
|
||||||
|
LIMIT 1
|
||||||
|
]], { profileId, charId })
|
||||||
|
|
||||||
|
return row ~= nil
|
||||||
|
end
|
||||||
160
shared/config.lua
Normal file
160
shared/config.lua
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
Config = {}
|
||||||
|
|
||||||
|
Config.Debug = false
|
||||||
|
Config.Command = 'bleeter'
|
||||||
|
Config.Locale = 'de'
|
||||||
|
|
||||||
|
Config.Ui = {
|
||||||
|
alwaysLandscape = true,
|
||||||
|
defaultPage = 'feed'
|
||||||
|
}
|
||||||
|
|
||||||
|
Config.Handles = {
|
||||||
|
forceLowercase = true,
|
||||||
|
pattern = '^[a-z0-9%._%-]+$',
|
||||||
|
minLength = 2,
|
||||||
|
maxLength = 32
|
||||||
|
}
|
||||||
|
|
||||||
|
Config.Media = {
|
||||||
|
provider = 'imgbb',
|
||||||
|
-- Kein Schluessel im Code: die Datei liegt in der Versionsverwaltung.
|
||||||
|
-- Der echte Wert gehoert in die server.cfg:
|
||||||
|
-- set bleeter_imgbb_key "dein-schluessel"
|
||||||
|
-- Ein Wert hier wirkt als Rueckfallebene, etwa fuer eine Testumgebung.
|
||||||
|
imgbbApiKey = '',
|
||||||
|
maxBytes = 2 * 1024 * 1024,
|
||||||
|
allowedExtensions = { 'jpg', 'jpeg', 'png' },
|
||||||
|
allowExternalUrls = true,
|
||||||
|
requireHttps = true,
|
||||||
|
storage = 'external'
|
||||||
|
}
|
||||||
|
|
||||||
|
Config.Moderation = {
|
||||||
|
restoreDays = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
Config.ProfileTypes = {
|
||||||
|
private = 'Privat',
|
||||||
|
small_business = 'Kleingewerbe',
|
||||||
|
company = 'Unternehmen',
|
||||||
|
authority = 'Behörde',
|
||||||
|
lifeinvader = 'Lifeinvader'
|
||||||
|
}
|
||||||
|
|
||||||
|
Config.Navigation = {
|
||||||
|
{ key = 'feed', label = 'Feed', icon = 'home' },
|
||||||
|
{ key = 'ads', label = 'Werbefeed', icon = 'megaphone' },
|
||||||
|
{ key = 'followers', label = 'Follower', icon = 'users' },
|
||||||
|
{ key = 'market', label = 'Marktplatz', icon = 'basket' },
|
||||||
|
{ key = 'calendar', label = 'Kalender', icon = 'calendar' },
|
||||||
|
{ key = 'business', label = 'Gewerbe', icon = 'briefcase' },
|
||||||
|
{ key = 'profile', label = 'Profil', icon = 'user' },
|
||||||
|
{ key = 'legal', label = 'Rechtliches',icon = 'book' }
|
||||||
|
}
|
||||||
|
|
||||||
|
-- ── Identität ─────────────────────────────────────────────────────────────────
|
||||||
|
-- Alle Funktionen sind server-only und werden nur serverseitig aufgerufen.
|
||||||
|
|
||||||
|
-- ESX, wenn vorhanden. Wird bei Bedarf geholt, nicht beim Laden: beim Start
|
||||||
|
-- ist es je nach Reihenfolge noch nicht bereit.
|
||||||
|
local _esx
|
||||||
|
local function esx()
|
||||||
|
if _esx then return _esx end
|
||||||
|
if GetResourceState('es_extended') ~= 'started' then return nil end
|
||||||
|
local ok, obj = pcall(function() return exports['es_extended']:getSharedObject() end)
|
||||||
|
if ok then _esx = obj end
|
||||||
|
return _esx
|
||||||
|
end
|
||||||
|
|
||||||
|
local function xPlayer(source)
|
||||||
|
local api = esx()
|
||||||
|
return api and api.GetPlayerFromId(source) or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function Config.GetIdentifier(source)
|
||||||
|
-- ESX fuehrt seine eigene Identifierliste. Sie muss hier dieselbe sein,
|
||||||
|
-- sonst findet ic-mail die Postfaecher dieser Person nicht.
|
||||||
|
local player = xPlayer(source)
|
||||||
|
if player and player.identifier then return player.identifier end
|
||||||
|
|
||||||
|
return GetPlayerIdentifierByType(source, 'license') or GetPlayerIdentifiers(source)[1]
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Charakter-Id. Auf einem ESX-Server ist das der ESX-Identifier; core-characters
|
||||||
|
-- gibt es hier nicht, wird aber weiter unterstuetzt.
|
||||||
|
function Config.GetCharacterId(source)
|
||||||
|
if GetResourceState('core-characters') == 'started' then
|
||||||
|
local ok, cid = pcall(function()
|
||||||
|
return exports['core-characters']:GetCitizenId(source)
|
||||||
|
end)
|
||||||
|
if ok and cid and cid ~= '' then return cid end
|
||||||
|
end
|
||||||
|
return Config.GetIdentifier(source)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Anzeigename: ESX-Charaktername, sonst core-characters, sonst Spielername.
|
||||||
|
function Config.GetCharacterName(source)
|
||||||
|
local player = xPlayer(source)
|
||||||
|
if player then
|
||||||
|
if player.getName then
|
||||||
|
local ok, name = pcall(player.getName)
|
||||||
|
if ok and name and name ~= '' then return name end
|
||||||
|
end
|
||||||
|
if player.name and player.name ~= '' then return player.name end
|
||||||
|
end
|
||||||
|
|
||||||
|
if GetResourceState('core-characters') == 'started' then
|
||||||
|
local ok, name = pcall(function()
|
||||||
|
return exports['core-characters']:GetCharacterName(source)
|
||||||
|
end)
|
||||||
|
if ok and name and name ~= '' then return name end
|
||||||
|
end
|
||||||
|
|
||||||
|
return GetPlayerName(source) or ('Spieler %s'):format(source)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Primäre IC-Mailadresse aus ic-mail (single source of truth).
|
||||||
|
-- Gibt '' zurück wenn noch keine Adresse konfiguriert wurde.
|
||||||
|
function Config.GetMailAddress(source)
|
||||||
|
local charId = Config.GetCharacterId(source)
|
||||||
|
if not charId or charId == '' then return '' end
|
||||||
|
if GetResourceState('ic-mail') == 'started' then
|
||||||
|
local ok, addr = pcall(function()
|
||||||
|
return exports['ic-mail']:GetPrimaryAddress(charId)
|
||||||
|
end)
|
||||||
|
if ok and addr and addr ~= '' then return addr end
|
||||||
|
end
|
||||||
|
-- Fallback: direkt aus DB (falls ic-mail nicht erreichbar)
|
||||||
|
local ok2, row = pcall(function()
|
||||||
|
return MySQL.single.await(
|
||||||
|
'SELECT address FROM ic_mail_accounts WHERE owner_identifier = ? AND account_type = ? AND is_active = 1 LIMIT 1',
|
||||||
|
{ charId, 'personal' }
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
if ok2 and row and row.address and row.address ~= '' then
|
||||||
|
return row.address
|
||||||
|
end
|
||||||
|
return ''
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Telefonnummer aus zc_ifruit, Fallback: zc_ifruit Export
|
||||||
|
function Config.GetPhoneNumber(source)
|
||||||
|
local charId = Config.GetCharacterId(source)
|
||||||
|
if charId and charId ~= '' then
|
||||||
|
local ok, row = pcall(function()
|
||||||
|
return MySQL.single.await(
|
||||||
|
'SELECT active_number FROM zc_ifruit_accounts WHERE char_id = ? LIMIT 1',
|
||||||
|
{ charId }
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
if ok and row and row.active_number then
|
||||||
|
return tostring(row.active_number)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if GetResourceState('zc_ifruit') == 'started' then
|
||||||
|
local ok2, num = pcall(function() return exports['zc_ifruit']:GetPhoneNumber(source) end)
|
||||||
|
if ok2 and num then return tostring(num) end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
212
sql/install.sql
Normal file
212
sql/install.sql
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS bleeter_accounts (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT,
|
||||||
|
char_id VARCHAR(80) NOT NULL,
|
||||||
|
identifier VARCHAR(80) NULL,
|
||||||
|
mail_address VARCHAR(120) NOT NULL,
|
||||||
|
phone_number VARCHAR(20) NULL,
|
||||||
|
status VARCHAR(24) NOT NULL DEFAULT 'active',
|
||||||
|
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 uniq_char_id (char_id),
|
||||||
|
UNIQUE KEY uniq_mail_address (mail_address),
|
||||||
|
KEY idx_status (status)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bleeter_profiles (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT,
|
||||||
|
account_id INT NULL,
|
||||||
|
profile_type VARCHAR(24) NOT NULL,
|
||||||
|
handle VARCHAR(40) NOT NULL,
|
||||||
|
display_name VARCHAR(80) NOT NULL,
|
||||||
|
avatar_url VARCHAR(255) NULL,
|
||||||
|
banner_url VARCHAR(255) NULL,
|
||||||
|
bio VARCHAR(500) NULL,
|
||||||
|
location VARCHAR(120) NULL,
|
||||||
|
email_contact VARCHAR(120) NULL,
|
||||||
|
phone_contact VARCHAR(20) NULL,
|
||||||
|
is_verified TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
is_lifeinvader_staff TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
is_locked TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
locked_reason VARCHAR(255) 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 uniq_handle (handle),
|
||||||
|
KEY idx_account_id (account_id),
|
||||||
|
KEY idx_profile_type (profile_type),
|
||||||
|
KEY idx_active (is_active)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bleeter_profile_members (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT,
|
||||||
|
profile_id INT NOT NULL,
|
||||||
|
char_id VARCHAR(80) NOT NULL,
|
||||||
|
role VARCHAR(40) NOT NULL DEFAULT 'member',
|
||||||
|
can_post TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
can_edit_profile TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
can_manage_members TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
source VARCHAR(40) NOT NULL DEFAULT 'manual',
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uniq_profile_member (profile_id, char_id),
|
||||||
|
KEY idx_char_id (char_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bleeter_media (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT,
|
||||||
|
owner_profile_id INT NOT NULL,
|
||||||
|
source_type VARCHAR(24) NOT NULL,
|
||||||
|
url VARCHAR(500) NOT NULL,
|
||||||
|
original_name VARCHAR(180) NULL,
|
||||||
|
mime_type VARCHAR(80) NULL,
|
||||||
|
size_bytes INT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_owner (owner_profile_id),
|
||||||
|
KEY idx_source_type (source_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bleeter_posts (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT,
|
||||||
|
author_profile_id INT NOT NULL,
|
||||||
|
feed_type VARCHAR(24) NOT NULL,
|
||||||
|
body TEXT NULL,
|
||||||
|
media_id INT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
self_deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
hidden_at TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
hidden_by_profile_id INT NULL,
|
||||||
|
hidden_reason VARCHAR(255) NULL,
|
||||||
|
restore_until TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_feed_created (feed_type, created_at),
|
||||||
|
KEY idx_author_created (author_profile_id, created_at),
|
||||||
|
KEY idx_hidden (hidden_at),
|
||||||
|
KEY idx_deleted (deleted_at)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bleeter_comments (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT,
|
||||||
|
post_id INT NOT NULL,
|
||||||
|
author_profile_id INT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
self_deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
hidden_at TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
hidden_by_profile_id INT NULL,
|
||||||
|
hidden_reason VARCHAR(255) NULL,
|
||||||
|
restore_until TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_post_created (post_id, created_at),
|
||||||
|
KEY idx_author_created (author_profile_id, created_at)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bleeter_likes (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT,
|
||||||
|
profile_id INT NOT NULL,
|
||||||
|
target_type VARCHAR(24) NOT NULL,
|
||||||
|
target_id INT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uniq_like (profile_id, target_type, target_id),
|
||||||
|
KEY idx_target (target_type, target_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bleeter_follows (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT,
|
||||||
|
follower_profile_id INT NOT NULL,
|
||||||
|
followed_profile_id INT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uniq_follow (follower_profile_id, followed_profile_id),
|
||||||
|
KEY idx_followed (followed_profile_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bleeter_blocks (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT,
|
||||||
|
blocker_profile_id INT NOT NULL,
|
||||||
|
blocked_profile_id INT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uniq_block (blocker_profile_id, blocked_profile_id),
|
||||||
|
KEY idx_blocked (blocked_profile_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bleeter_marketplace (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT,
|
||||||
|
author_profile_id INT NOT NULL,
|
||||||
|
category VARCHAR(40) NOT NULL,
|
||||||
|
title VARCHAR(120) NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
price_label VARCHAR(80) NULL,
|
||||||
|
media_id INT NULL,
|
||||||
|
status VARCHAR(24) NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_category_status (category, status),
|
||||||
|
KEY idx_author (author_profile_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bleeter_events (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT,
|
||||||
|
author_profile_id INT NOT NULL,
|
||||||
|
title VARCHAR(140) NOT NULL,
|
||||||
|
description TEXT NULL,
|
||||||
|
location VARCHAR(140) NULL,
|
||||||
|
starts_at DATETIME NOT NULL,
|
||||||
|
ends_at DATETIME NULL,
|
||||||
|
status VARCHAR(24) NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_starts_at (starts_at),
|
||||||
|
KEY idx_author (author_profile_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bleeter_business_status (
|
||||||
|
profile_id INT NOT NULL,
|
||||||
|
status VARCHAR(24) NOT NULL DEFAULT 'closed',
|
||||||
|
source VARCHAR(40) NOT NULL DEFAULT 'manual',
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (profile_id),
|
||||||
|
KEY idx_status (status)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bleeter_lifeinvader_permissions (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT,
|
||||||
|
char_id VARCHAR(80) NOT NULL,
|
||||||
|
permission VARCHAR(80) NOT NULL,
|
||||||
|
allowed TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
source VARCHAR(40) NOT NULL DEFAULT 'manual',
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uniq_char_permission (char_id, permission)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bleeter_audit_logs (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT,
|
||||||
|
actor_char_id VARCHAR(80) NULL,
|
||||||
|
actor_profile_id INT NULL,
|
||||||
|
action VARCHAR(80) NOT NULL,
|
||||||
|
target_type VARCHAR(40) NULL,
|
||||||
|
target_id INT NULL,
|
||||||
|
reason VARCHAR(255) NULL,
|
||||||
|
payload LONGTEXT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_actor_char (actor_char_id),
|
||||||
|
KEY idx_actor_profile (actor_profile_id),
|
||||||
|
KEY idx_target (target_type, target_id),
|
||||||
|
KEY idx_action (action)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE bleeter_profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS is_lifeinvader_staff TINYINT(1) NOT NULL DEFAULT 0 AFTER is_verified;
|
||||||
31
sql/migration_fehlende_spalten.sql
Normal file
31
sql/migration_fehlende_spalten.sql
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
-- Bleeter | Spalten nachziehen
|
||||||
|
--
|
||||||
|
-- server/main.lua schreibt sechs Spalten, die in install.sql fehlen. Das
|
||||||
|
-- Schema ist gegenueber dem Code stehengeblieben – die Registrierung eines
|
||||||
|
-- Handles scheitert deshalb mit einem SQL-Fehler.
|
||||||
|
--
|
||||||
|
-- bleeter_accounts.registered_at wann registriert wurde
|
||||||
|
-- bleeter_accounts.registration_mail Adresse zum Zeitpunkt der Anmeldung
|
||||||
|
-- bleeter_accounts.web_password_hash Zugang zum Web-Backend
|
||||||
|
-- bleeter_accounts.web_password_updated_at
|
||||||
|
-- bleeter_profiles.owner_source woher das Profil stammt
|
||||||
|
-- bleeter_profiles.created_by_char_id wer es angelegt hat
|
||||||
|
--
|
||||||
|
-- Wiederholbar ausfuehrbar.
|
||||||
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ALTER TABLE `bleeter_accounts`
|
||||||
|
ADD COLUMN IF NOT EXISTS `registered_at` TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS `registration_mail` VARCHAR(120) NULL DEFAULT NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS `web_password_hash` VARCHAR(255) NULL DEFAULT NULL
|
||||||
|
COMMENT 'Zugang zum Web-Backend, nie im Klartext',
|
||||||
|
ADD COLUMN IF NOT EXISTS `web_password_updated_at` TIMESTAMP NULL DEFAULT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE `bleeter_profiles`
|
||||||
|
ADD COLUMN IF NOT EXISTS `owner_source` VARCHAR(40) NOT NULL DEFAULT 'personal'
|
||||||
|
COMMENT 'personal | lifeinvader | superpc – woher das Profil stammt',
|
||||||
|
ADD COLUMN IF NOT EXISTS `created_by_char_id` VARCHAR(80) NULL DEFAULT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE `bleeter_profiles`
|
||||||
|
ADD INDEX IF NOT EXISTS `idx_owner_source` (`owner_source`);
|
||||||
38
sql/migration_reservierte_handles.sql
Normal file
38
sql/migration_reservierte_handles.sql
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
-- Bleeter | Reservierte Handles
|
||||||
|
--
|
||||||
|
-- server/main.lua fragt diese Tabelle bei jeder Registrierung ab, sie fehlt
|
||||||
|
-- aber in install.sql. Ohne sie scheitert die Abfrage – und damit jede
|
||||||
|
-- Registrierung eines Spielerhandles.
|
||||||
|
--
|
||||||
|
-- Vorbelegt sind Namen, unter denen sich sonst jemand als Behoerde, Anbieter
|
||||||
|
-- oder Systemkonto ausgeben koennte. Wer sie vergeben will, nimmt sie hier
|
||||||
|
-- heraus – oder der Anbieter legt das Profil selbst an, das umgeht die Liste
|
||||||
|
-- bewusst nicht.
|
||||||
|
--
|
||||||
|
-- Wiederholbar ausfuehrbar.
|
||||||
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `bleeter_reserved_handles` (
|
||||||
|
`handle` VARCHAR(40) NOT NULL,
|
||||||
|
`reason` VARCHAR(200) NOT NULL DEFAULT '',
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`handle`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO `bleeter_reserved_handles` (`handle`, `reason`) VALUES
|
||||||
|
('lifeinvader', 'Plattformbetreiber'),
|
||||||
|
('bleeter', 'Plattformname'),
|
||||||
|
('admin', 'Verwaltung'),
|
||||||
|
('support', 'Verwaltung'),
|
||||||
|
('system', 'Verwaltung'),
|
||||||
|
('lspd', 'Behörde'),
|
||||||
|
('police', 'Behörde'),
|
||||||
|
('lsmd', 'Behörde'),
|
||||||
|
('ambulance', 'Behörde'),
|
||||||
|
('lsfd', 'Behörde'),
|
||||||
|
('doj', 'Behörde'),
|
||||||
|
('gov', 'Behörde'),
|
||||||
|
('government', 'Behörde'),
|
||||||
|
('weazel', 'Presse'),
|
||||||
|
('weazelnews', 'Presse');
|
||||||
16
web-backend/.env.example
Normal file
16
web-backend/.env.example
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
PORT=
|
||||||
|
DB_HOST=
|
||||||
|
DB_PORT=
|
||||||
|
DB_USER=
|
||||||
|
DB_PASS=
|
||||||
|
DB_NAME=
|
||||||
|
JWT_SECRET=
|
||||||
|
INTERNAL_API_KEY=
|
||||||
|
IMGBB_KEY=
|
||||||
|
|
||||||
|
# Vorlage. Kopieren nach .env und ausfuellen:
|
||||||
|
# PORT Port des Backends
|
||||||
|
# DB_* Zugang zur Spieldatenbank
|
||||||
|
# JWT_SECRET beliebige lange Zufallszeichenkette
|
||||||
|
# INTERNAL_API_KEY gemeinsames Geheimnis zwischen Backend und Resource
|
||||||
|
# IMGBB_KEY API-Schluessel von imgbb.com fuer Bilduploads
|
||||||
38
web-backend/auth.js
Normal file
38
web-backend/auth.js
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const db = require('./db');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const SECRET = process.env.JWT_SECRET;
|
||||||
|
|
||||||
|
function sign(account) {
|
||||||
|
return jwt.sign(
|
||||||
|
{ aid: account.id, cid: account.char_id },
|
||||||
|
SECRET,
|
||||||
|
{ expiresIn: '7d' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware: prueft JWT, laedt Account frisch aus DB -> req.account
|
||||||
|
async function requireAuth(req, res, next) {
|
||||||
|
try {
|
||||||
|
const header = req.headers.authorization || '';
|
||||||
|
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
|
||||||
|
if (!token) return res.status(401).json({ error: 'no_token' });
|
||||||
|
|
||||||
|
const payload = jwt.verify(token, SECRET);
|
||||||
|
const account = await db.q1(
|
||||||
|
'SELECT * FROM bleeter_accounts WHERE id = ? AND char_id = ?',
|
||||||
|
[payload.aid, payload.cid]
|
||||||
|
);
|
||||||
|
if (!account) return res.status(401).json({ error: 'account_gone' });
|
||||||
|
if (account.status && account.status !== 'active') {
|
||||||
|
return res.status(403).json({ error: 'account_disabled' });
|
||||||
|
}
|
||||||
|
req.account = account;
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(401).json({ error: 'invalid_token' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { sign, requireAuth };
|
||||||
40
web-backend/db.js
Normal file
40
web-backend/db.js
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
host: process.env.DB_HOST || '127.0.0.1',
|
||||||
|
port: Number(process.env.DB_PORT || 3306),
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASS,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 10,
|
||||||
|
charset: 'utf8mb4_general_ci',
|
||||||
|
dateStrings: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mehrere Zeilen
|
||||||
|
async function q(sql, params = []) {
|
||||||
|
const [rows] = await pool.query(sql, params);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Genau eine Zeile (oder null)
|
||||||
|
async function q1(sql, params = []) {
|
||||||
|
const rows = await q(sql, params);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// INSERT -> insertId
|
||||||
|
async function insert(sql, params = []) {
|
||||||
|
const [res] = await pool.query(sql, params);
|
||||||
|
return res.insertId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// UPDATE/DELETE -> affectedRows
|
||||||
|
async function exec(sql, params = []) {
|
||||||
|
const [res] = await pool.query(sql, params);
|
||||||
|
return res.affectedRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { pool, q, q1, insert, exec };
|
||||||
98
web-backend/lib/account.js
Normal file
98
web-backend/lib/account.js
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
const db = require('../db');
|
||||||
|
const { decodeBool, canProfileBeBlocked } = require('./permissions');
|
||||||
|
|
||||||
|
// Portiert aus main.lua mapProfile()
|
||||||
|
function mapProfile(row) {
|
||||||
|
if (!row) return null;
|
||||||
|
const profile = {
|
||||||
|
id: row.id,
|
||||||
|
account_id: row.account_id,
|
||||||
|
profile_type: row.profile_type,
|
||||||
|
handle: row.handle,
|
||||||
|
display_name: row.display_name,
|
||||||
|
avatar_url: row.avatar_url || '',
|
||||||
|
banner_url: row.banner_url || '',
|
||||||
|
bio: row.bio || '',
|
||||||
|
location: row.location || '',
|
||||||
|
email_contact: row.email_contact || '',
|
||||||
|
phone_contact: row.phone_contact || '',
|
||||||
|
is_verified: decodeBool(row.is_verified),
|
||||||
|
is_lifeinvader_staff: decodeBool(row.is_lifeinvader_staff),
|
||||||
|
is_active: row.is_active === undefined || row.is_active === null || decodeBool(row.is_active),
|
||||||
|
is_locked: decodeBool(row.is_locked),
|
||||||
|
can_post: decodeBool(row.can_post),
|
||||||
|
can_edit_profile: decodeBool(row.can_edit_profile),
|
||||||
|
can_be_blocked: canProfileBeBlocked(row.profile_type, row.is_lifeinvader_staff),
|
||||||
|
};
|
||||||
|
if (row.followers_count !== undefined) profile.followers_count = Number(row.followers_count) || 0;
|
||||||
|
if (row.following_count !== undefined) profile.following_count = Number(row.following_count) || 0;
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alle Profile, auf die der Account zugreifen kann (eigene + Mitgliedschaften)
|
||||||
|
async function getAccessibleProfiles(account) {
|
||||||
|
const profiles = [];
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
const ownRows = await db.q(
|
||||||
|
`SELECT p.*, 1 AS can_post, 1 AS can_edit_profile
|
||||||
|
FROM bleeter_profiles p
|
||||||
|
WHERE p.account_id = ? AND p.is_active = 1 AND p.is_locked = 0
|
||||||
|
ORDER BY FIELD(p.profile_type, 'private','small_business','company','authority','lifeinvader'), p.display_name`,
|
||||||
|
[account.id]
|
||||||
|
);
|
||||||
|
for (const row of ownRows) {
|
||||||
|
const profile = mapProfile(row);
|
||||||
|
profiles.push(profile);
|
||||||
|
seen.add(profile.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberRows = await db.q(
|
||||||
|
`SELECT p.*, m.can_post, m.can_edit_profile
|
||||||
|
FROM bleeter_profile_members m
|
||||||
|
JOIN bleeter_profiles p ON p.id = m.profile_id
|
||||||
|
WHERE m.char_id = ? AND p.is_active = 1 AND p.is_locked = 0
|
||||||
|
ORDER BY p.display_name`,
|
||||||
|
[account.char_id]
|
||||||
|
);
|
||||||
|
for (const row of memberRows) {
|
||||||
|
if (!seen.has(row.id)) {
|
||||||
|
const profile = mapProfile(row);
|
||||||
|
profiles.push(profile);
|
||||||
|
seen.add(profile.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return profiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das aktuell handelnde Profil (per profileId vom Client, sonst erstes).
|
||||||
|
// Gibt { profile, profiles } zurueck. profile=null wenn kein Profil vorhanden.
|
||||||
|
async function resolveActingProfile(account, profileId) {
|
||||||
|
const profiles = await getAccessibleProfiles(account);
|
||||||
|
let profile = null;
|
||||||
|
if (profileId) {
|
||||||
|
profile = profiles.find(p => Number(p.id) === Number(profileId)) || null;
|
||||||
|
}
|
||||||
|
if (!profile) profile = profiles[0] || null;
|
||||||
|
return { profile, profiles };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zielprofil fuer Follow/Block/Moderation laden.
|
||||||
|
// includeLocked=true fuer Moderationsaktionen.
|
||||||
|
async function findTargetProfile(payload, includeLocked = false) {
|
||||||
|
const id = Number(payload && (payload.targetId || payload.profileId));
|
||||||
|
const handle = payload && payload.handle ? String(payload.handle).toLowerCase() : null;
|
||||||
|
let row = null;
|
||||||
|
if (id) {
|
||||||
|
row = await db.q1('SELECT * FROM bleeter_profiles WHERE id = ?', [id]);
|
||||||
|
} else if (handle) {
|
||||||
|
row = await db.q1('SELECT * FROM bleeter_profiles WHERE handle = ?', [handle]);
|
||||||
|
}
|
||||||
|
if (!row) return null;
|
||||||
|
if (!decodeBool(row.is_active)) return null;
|
||||||
|
if (!includeLocked && decodeBool(row.is_locked)) return null;
|
||||||
|
return mapProfile(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { mapProfile, getAccessibleProfiles, resolveActingProfile, findTargetProfile };
|
||||||
39
web-backend/lib/media.js
Normal file
39
web-backend/lib/media.js
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const ALLOWED_EXT = ['jpg', 'jpeg', 'png'];
|
||||||
|
|
||||||
|
function extFromUrl(url) {
|
||||||
|
const m = /\.([a-z0-9]+)(\?|$)/i.exec(String(url || ''));
|
||||||
|
return m ? m[1].toLowerCase() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Portiert aus adapters/media.lua IsAllowedExternalUrl
|
||||||
|
function isAllowedExternalUrl(url) {
|
||||||
|
url = String(url || '');
|
||||||
|
if (!/^https:\/\//i.test(url)) return { ok: false, reason: 'url_must_be_https' };
|
||||||
|
if (/^https:\/\/i\.ibb\.co\//i.test(url) || /^https:\/\/ibb\.co\//i.test(url)) return { ok: true };
|
||||||
|
const ext = extFromUrl(url);
|
||||||
|
if (ext && ALLOWED_EXT.includes(ext)) return { ok: true };
|
||||||
|
return { ok: false, reason: 'unsupported_image_url' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload eines Buffers zu imgbb -> { url }
|
||||||
|
async function uploadToImgbb(buffer, filename) {
|
||||||
|
const key = process.env.IMGBB_KEY;
|
||||||
|
if (!key) throw new Error('imgbb_key_missing');
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('image', buffer.toString('base64'));
|
||||||
|
if (filename) form.append('name', filename.replace(/\.[^.]+$/, ''));
|
||||||
|
|
||||||
|
const resp = await fetch(`https://api.imgbb.com/1/upload?key=${encodeURIComponent(key)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!data || !data.success || !data.data || !data.data.url) {
|
||||||
|
throw new Error('imgbb_upload_failed');
|
||||||
|
}
|
||||||
|
return { url: data.data.url };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { isAllowedExternalUrl, uploadToImgbb, ALLOWED_EXT };
|
||||||
39
web-backend/lib/permissions.js
Normal file
39
web-backend/lib/permissions.js
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
// Portiert aus bleeter/server/permissions.lua + main.lua
|
||||||
|
|
||||||
|
// Wer darf in welchem Feed posten (feed_type: 'home' | 'advertising')
|
||||||
|
const feedPostRights = {
|
||||||
|
private: { home: true },
|
||||||
|
small_business: { advertising: true },
|
||||||
|
company: { advertising: true },
|
||||||
|
authority: { home: true, advertising: true },
|
||||||
|
lifeinvader: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
function canPost(profileType, feedType) {
|
||||||
|
const rights = feedPostRights[profileType || ''] || {};
|
||||||
|
return rights[feedType || ''] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBool(v) {
|
||||||
|
return v === true || v === 1 || v === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Behoerden / Lifeinvader / Staff koennen nicht blockiert werden
|
||||||
|
function canProfileBeBlocked(profileType, isLifeinvaderStaff) {
|
||||||
|
return profileType !== 'authority'
|
||||||
|
&& profileType !== 'lifeinvader'
|
||||||
|
&& !decodeBool(isLifeinvaderStaff);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lifeinvader-Moderationsrechte eines Charakters (char_id) aus DB
|
||||||
|
async function hasLifeinvaderPermission(db, charId, permission) {
|
||||||
|
if (!charId) return false;
|
||||||
|
const row = await db.q1(
|
||||||
|
`SELECT allowed FROM bleeter_lifeinvader_permissions
|
||||||
|
WHERE char_id = ? AND permission = ? LIMIT 1`,
|
||||||
|
[charId, permission]
|
||||||
|
);
|
||||||
|
return !!(row && decodeBool(row.allowed));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { feedPostRights, canPost, decodeBool, canProfileBeBlocked, hasLifeinvaderPermission };
|
||||||
281
web-backend/lib/queries.js
Normal file
281
web-backend/lib/queries.js
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
const db = require('../db');
|
||||||
|
const { decodeBool, canProfileBeBlocked } = require('./permissions');
|
||||||
|
|
||||||
|
// ── Kommentare eines Posts ────────────────────────────────────────────────
|
||||||
|
async function loadCommentsForPost(postId, viewerProfileId) {
|
||||||
|
const v = viewerProfileId || 0;
|
||||||
|
const rows = await db.q(
|
||||||
|
`SELECT c.id, c.body, c.created_at, c.author_profile_id,
|
||||||
|
p.handle AS author_handle,
|
||||||
|
(SELECT COUNT(*) FROM bleeter_likes l WHERE l.target_type='comment' AND l.target_id=c.id) AS likes,
|
||||||
|
EXISTS(SELECT 1 FROM bleeter_likes l WHERE l.target_type='comment' AND l.target_id=c.id AND l.profile_id=?) AS liked_by_viewer
|
||||||
|
FROM bleeter_comments c
|
||||||
|
JOIN bleeter_profiles p ON p.id = c.author_profile_id
|
||||||
|
WHERE c.post_id = ? AND c.self_deleted_at IS NULL AND c.hidden_at IS NULL AND c.deleted_at IS NULL
|
||||||
|
AND p.is_active = 1 AND p.is_locked = 0
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM bleeter_blocks b
|
||||||
|
WHERE (b.blocker_profile_id=? AND b.blocked_profile_id=p.id)
|
||||||
|
OR (b.blocker_profile_id=p.id AND b.blocked_profile_id=?))
|
||||||
|
ORDER BY c.created_at ASC`,
|
||||||
|
[v, postId, v, v]
|
||||||
|
);
|
||||||
|
return rows.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
author_profile_id: row.author_profile_id,
|
||||||
|
author: row.author_handle,
|
||||||
|
body: row.body,
|
||||||
|
created_at: String(row.created_at || ''),
|
||||||
|
likes: Number(row.likes) || 0,
|
||||||
|
liked_by_viewer: decodeBool(row.liked_by_viewer),
|
||||||
|
can_delete: Number(row.author_profile_id) === Number(viewerProfileId),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Feed-Posts ────────────────────────────────────────────────────────────
|
||||||
|
async function loadPosts(viewerProfileId, feedType) {
|
||||||
|
const v = viewerProfileId || 0;
|
||||||
|
const params = [v, v, v];
|
||||||
|
let feedClause = '';
|
||||||
|
if (feedType === 'home' || feedType === 'advertising') {
|
||||||
|
feedClause = ' AND posts.feed_type = ?';
|
||||||
|
params.push(feedType);
|
||||||
|
}
|
||||||
|
const rows = await db.q(
|
||||||
|
`SELECT posts.id, posts.feed_type, posts.body, posts.created_at, posts.author_profile_id,
|
||||||
|
media.url AS media_url,
|
||||||
|
author.id AS author_id, author.handle AS author_handle, author.display_name AS author_name,
|
||||||
|
author.avatar_url AS author_avatar, author.is_verified AS author_verified,
|
||||||
|
author.is_lifeinvader_staff AS author_lifeinvader_staff,
|
||||||
|
(SELECT COUNT(*) FROM bleeter_likes l WHERE l.target_type='post' AND l.target_id=posts.id) AS likes,
|
||||||
|
EXISTS(SELECT 1 FROM bleeter_likes l WHERE l.target_type='post' AND l.target_id=posts.id AND l.profile_id=?) AS liked_by_viewer
|
||||||
|
FROM bleeter_posts posts
|
||||||
|
JOIN bleeter_profiles author ON author.id = posts.author_profile_id
|
||||||
|
LEFT JOIN bleeter_media media ON media.id = posts.media_id
|
||||||
|
WHERE posts.self_deleted_at IS NULL AND posts.hidden_at IS NULL AND posts.deleted_at IS NULL
|
||||||
|
AND author.is_active = 1 AND author.is_locked = 0
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM bleeter_blocks b
|
||||||
|
WHERE (b.blocker_profile_id=? AND b.blocked_profile_id=author.id)
|
||||||
|
OR (b.blocker_profile_id=author.id AND b.blocked_profile_id=?))
|
||||||
|
${feedClause}
|
||||||
|
ORDER BY posts.created_at DESC
|
||||||
|
LIMIT 80`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
const posts = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const comments = await loadCommentsForPost(row.id, viewerProfileId);
|
||||||
|
posts.push({
|
||||||
|
id: row.id,
|
||||||
|
feed_type: row.feed_type,
|
||||||
|
body: row.body || '',
|
||||||
|
media_url: row.media_url,
|
||||||
|
created_at: String(row.created_at || ''),
|
||||||
|
likes: Number(row.likes) || 0,
|
||||||
|
liked_by_viewer: decodeBool(row.liked_by_viewer),
|
||||||
|
can_delete: Number(row.author_profile_id) === Number(viewerProfileId),
|
||||||
|
comments: comments.length,
|
||||||
|
comments_list: comments,
|
||||||
|
author: {
|
||||||
|
id: row.author_id,
|
||||||
|
handle: row.author_handle,
|
||||||
|
display_name: row.author_name,
|
||||||
|
avatar_url: row.author_avatar || '',
|
||||||
|
is_verified: decodeBool(row.author_verified),
|
||||||
|
is_lifeinvader_staff: decodeBool(row.author_lifeinvader_staff),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return posts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Profil-Verzeichnis (mit Follower-Zahlen) ──────────────────────────────
|
||||||
|
async function loadProfileDirectory(viewerProfileId, includeLocked) {
|
||||||
|
const v = viewerProfileId || 0;
|
||||||
|
const rows = await db.q(
|
||||||
|
`SELECT p.*, 0 AS can_post, 0 AS can_edit_profile,
|
||||||
|
(SELECT COUNT(*) FROM bleeter_follows f WHERE f.followed_profile_id=p.id) AS followers_count,
|
||||||
|
(SELECT COUNT(*) FROM bleeter_follows f WHERE f.follower_profile_id=p.id) AS following_count
|
||||||
|
FROM bleeter_profiles p
|
||||||
|
WHERE p.is_active = 1 AND (? = 1 OR p.is_locked = 0)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM bleeter_blocks b
|
||||||
|
WHERE (b.blocker_profile_id=? AND b.blocked_profile_id=p.id)
|
||||||
|
OR (b.blocker_profile_id=p.id AND b.blocked_profile_id=?))
|
||||||
|
ORDER BY p.display_name
|
||||||
|
LIMIT 180`,
|
||||||
|
[includeLocked ? 1 : 0, v, v]
|
||||||
|
);
|
||||||
|
const { mapProfile } = require('./account');
|
||||||
|
return rows.map(mapProfile);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapSocialProfile(row) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
handle: row.handle,
|
||||||
|
display_name: row.display_name,
|
||||||
|
avatar_url: row.avatar_url || '',
|
||||||
|
profile_type: row.profile_type,
|
||||||
|
is_verified: decodeBool(row.is_verified),
|
||||||
|
is_lifeinvader_staff: decodeBool(row.is_lifeinvader_staff),
|
||||||
|
can_be_blocked: canProfileBeBlocked(row.profile_type, row.is_lifeinvader_staff),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Following / Followers / Blocked ───────────────────────────────────────
|
||||||
|
async function loadSocial(activeProfileId) {
|
||||||
|
const a = activeProfileId || 0;
|
||||||
|
const following = await db.q(
|
||||||
|
`SELECT p.id,p.handle,p.display_name,p.avatar_url,p.profile_type,p.is_verified,p.is_lifeinvader_staff
|
||||||
|
FROM bleeter_follows f JOIN bleeter_profiles p ON p.id=f.followed_profile_id
|
||||||
|
WHERE f.follower_profile_id=? AND p.is_active=1 AND p.is_locked=0
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM bleeter_blocks b
|
||||||
|
WHERE (b.blocker_profile_id=? AND b.blocked_profile_id=p.id)
|
||||||
|
OR (b.blocker_profile_id=p.id AND b.blocked_profile_id=?))
|
||||||
|
ORDER BY p.display_name`,
|
||||||
|
[a, a, a]
|
||||||
|
);
|
||||||
|
const followers = await db.q(
|
||||||
|
`SELECT p.id,p.handle,p.display_name,p.avatar_url,p.profile_type,p.is_verified,p.is_lifeinvader_staff
|
||||||
|
FROM bleeter_follows f JOIN bleeter_profiles p ON p.id=f.follower_profile_id
|
||||||
|
WHERE f.followed_profile_id=? AND p.is_active=1 AND p.is_locked=0
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM bleeter_blocks b
|
||||||
|
WHERE (b.blocker_profile_id=? AND b.blocked_profile_id=p.id)
|
||||||
|
OR (b.blocker_profile_id=p.id AND b.blocked_profile_id=?))
|
||||||
|
ORDER BY p.display_name`,
|
||||||
|
[a, a, a]
|
||||||
|
);
|
||||||
|
const blocked = await db.q(
|
||||||
|
`SELECT p.id,p.handle,p.display_name,p.avatar_url,p.profile_type,p.is_verified,p.is_lifeinvader_staff
|
||||||
|
FROM bleeter_blocks b JOIN bleeter_profiles p ON p.id=b.blocked_profile_id
|
||||||
|
WHERE b.blocker_profile_id=? ORDER BY p.display_name`,
|
||||||
|
[a]
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
following: following.map(mapSocialProfile),
|
||||||
|
followers: followers.map(mapSocialProfile),
|
||||||
|
blocked: blocked.map(mapSocialProfile),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Kalender (28 Tage) ────────────────────────────────────────────────────
|
||||||
|
const germanWeekdays = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag'];
|
||||||
|
|
||||||
|
function dateKeyFromOffset(offset) {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() + offset);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
function dateTitleFromKey(dateKey) {
|
||||||
|
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateKey);
|
||||||
|
if (!m) return dateKey;
|
||||||
|
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 12);
|
||||||
|
return `${germanWeekdays[d.getDay()]} ${m[3]}.${m[2]}.${m[1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCalendarDays(activeProfileId) {
|
||||||
|
const rows = await db.q(
|
||||||
|
`SELECT e.id, DATE_FORMAT(e.starts_at,'%Y-%m-%d') AS date_key,
|
||||||
|
DATE_FORMAT(e.starts_at,'%H:%i') AS time_text,
|
||||||
|
e.title, e.location, e.author_profile_id, p.handle AS author_handle
|
||||||
|
FROM bleeter_events e JOIN bleeter_profiles p ON p.id=e.author_profile_id
|
||||||
|
WHERE e.deleted_at IS NULL AND e.status='active'
|
||||||
|
AND e.starts_at >= CURDATE() AND e.starts_at < DATE_ADD(CURDATE(), INTERVAL 28 DAY)
|
||||||
|
AND p.is_active=1 AND p.is_locked=0
|
||||||
|
ORDER BY e.starts_at ASC, e.id ASC`
|
||||||
|
);
|
||||||
|
const byDate = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
(byDate[row.date_key] = byDate[row.date_key] || []).push({
|
||||||
|
id: row.id,
|
||||||
|
time: row.time_text,
|
||||||
|
title: row.title,
|
||||||
|
location: row.location || '',
|
||||||
|
author: row.author_handle,
|
||||||
|
can_delete: Number(row.author_profile_id) === Number(activeProfileId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const days = [];
|
||||||
|
for (let offset = 0; offset < 28; offset++) {
|
||||||
|
const dateKey = dateKeyFromOffset(offset);
|
||||||
|
days.push({ offset, date: dateKey, title: dateTitleFromKey(dateKey), events: byDate[dateKey] || [] });
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gewerbe (Open/Closed) ─────────────────────────────────────────────────
|
||||||
|
async function loadBusinesses(viewerProfileId) {
|
||||||
|
const v = viewerProfileId || 0;
|
||||||
|
const rows = await db.q(
|
||||||
|
`SELECT p.handle,p.display_name,p.avatar_url,p.banner_url,p.is_verified,p.is_lifeinvader_staff,
|
||||||
|
COALESCE(s.status,'closed') AS status
|
||||||
|
FROM bleeter_profiles p
|
||||||
|
LEFT JOIN bleeter_business_status s ON s.profile_id=p.id
|
||||||
|
WHERE p.profile_type IN ('small_business','company','authority')
|
||||||
|
AND p.is_active=1 AND p.is_locked=0
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM bleeter_blocks b
|
||||||
|
WHERE (b.blocker_profile_id=? AND b.blocked_profile_id=p.id)
|
||||||
|
OR (b.blocker_profile_id=p.id AND b.blocked_profile_id=?))
|
||||||
|
ORDER BY FIELD(COALESCE(s.status,'closed'),'open','closed'), p.display_name
|
||||||
|
LIMIT 80`,
|
||||||
|
[v, v]
|
||||||
|
);
|
||||||
|
return rows.map(row => ({
|
||||||
|
handle: row.handle,
|
||||||
|
display_name: row.display_name,
|
||||||
|
avatar_url: row.avatar_url || '',
|
||||||
|
banner_url: row.banner_url || '',
|
||||||
|
status: row.status || 'closed',
|
||||||
|
is_verified: decodeBool(row.is_verified),
|
||||||
|
is_lifeinvader_staff: decodeBool(row.is_lifeinvader_staff),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Marktplatz ────────────────────────────────────────────────────────────
|
||||||
|
async function loadMarketplace(activeProfileId) {
|
||||||
|
const a = activeProfileId || 0;
|
||||||
|
const rows = await db.q(
|
||||||
|
`SELECT m.id,m.title,m.description,m.price_label,m.created_at,m.author_profile_id,
|
||||||
|
media.url AS media_url,
|
||||||
|
p.handle AS author_handle,p.display_name AS author_name,p.avatar_url AS author_avatar,
|
||||||
|
p.email_contact AS author_email,p.is_verified AS author_verified,
|
||||||
|
p.is_lifeinvader_staff AS author_lifeinvader_staff
|
||||||
|
FROM bleeter_marketplace m
|
||||||
|
JOIN bleeter_profiles p ON p.id=m.author_profile_id
|
||||||
|
LEFT JOIN bleeter_media media ON media.id=m.media_id
|
||||||
|
WHERE m.deleted_at IS NULL AND m.status='active' AND p.is_active=1 AND p.is_locked=0
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM bleeter_blocks b
|
||||||
|
WHERE (b.blocker_profile_id=? AND b.blocked_profile_id=p.id)
|
||||||
|
OR (b.blocker_profile_id=p.id AND b.blocked_profile_id=?))
|
||||||
|
ORDER BY m.created_at DESC
|
||||||
|
LIMIT 100`,
|
||||||
|
[a, a]
|
||||||
|
);
|
||||||
|
return rows.map(row => {
|
||||||
|
const priceNumber = Number((String(row.price_label || '').match(/\d+/) || [0])[0]) || 0;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
body: row.description,
|
||||||
|
price: priceNumber,
|
||||||
|
price_label: row.price_label || '',
|
||||||
|
created_at: String(row.created_at || ''),
|
||||||
|
media_url: row.media_url,
|
||||||
|
can_delete: Number(row.author_profile_id) === Number(activeProfileId),
|
||||||
|
author: {
|
||||||
|
id: row.author_profile_id,
|
||||||
|
handle: row.author_handle,
|
||||||
|
display_name: row.author_name,
|
||||||
|
avatar_url: row.author_avatar || '',
|
||||||
|
email: row.author_email || '',
|
||||||
|
is_verified: decodeBool(row.author_verified),
|
||||||
|
is_lifeinvader_staff: decodeBool(row.author_lifeinvader_staff),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
loadCommentsForPost, loadPosts, loadProfileDirectory,
|
||||||
|
loadSocial, loadCalendarDays, loadBusinesses, loadMarketplace,
|
||||||
|
};
|
||||||
18
web-backend/package.json
Normal file
18
web-backend/package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"name": "zc-bleeter-web",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Bleeter Web-Anwendung (Lifeinvader) – Web-Backend fuer bleeter.naturalbornplayers.de",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"mysql2": "^3.11.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
565
web-backend/public/app.js
Normal file
565
web-backend/public/app.js
Normal file
|
|
@ -0,0 +1,565 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ── State ─────────────────────────────────────────────────────────────────
|
||||||
|
const S = {
|
||||||
|
token: localStorage.getItem('bleeter_token') || null,
|
||||||
|
account: null,
|
||||||
|
profiles: [],
|
||||||
|
activeId: Number(localStorage.getItem('bleeter_profile')) || null,
|
||||||
|
canModerate: false,
|
||||||
|
view: 'home',
|
||||||
|
openComments: new Set(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROFILE_TYPE_LABEL = {
|
||||||
|
private: 'Privat', small_business: 'Kleingewerbe', company: 'Unternehmen',
|
||||||
|
authority: 'Behörde', lifeinvader: 'Lifeinvader',
|
||||||
|
};
|
||||||
|
const BUSINESS_TYPES = ['small_business', 'company', 'authority'];
|
||||||
|
|
||||||
|
function activeProfile() { return S.profiles.find(p => p.id === S.activeId) || S.profiles[0] || null; }
|
||||||
|
function canPostHere(profile, feedType) {
|
||||||
|
if (!profile) return false;
|
||||||
|
const rights = { private: { home: 1 }, small_business: { advertising: 1 }, company: { advertising: 1 }, authority: { home: 1, advertising: 1 }, lifeinvader: {} };
|
||||||
|
return !!(rights[profile.profile_type] || {})[feedType];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API ───────────────────────────────────────────────────────────────────
|
||||||
|
async function api(method, path, body, isForm) {
|
||||||
|
const headers = {};
|
||||||
|
if (S.token) headers['Authorization'] = 'Bearer ' + S.token;
|
||||||
|
let payload;
|
||||||
|
if (isForm) { payload = body; }
|
||||||
|
else if (body !== undefined) { headers['Content-Type'] = 'application/json'; payload = JSON.stringify(body); }
|
||||||
|
const res = await fetch(path, { method, headers, body: payload });
|
||||||
|
if (res.status === 401) { logout(); throw new Error('unauthorized'); }
|
||||||
|
let data = null; try { data = await res.json(); } catch (e) {}
|
||||||
|
if (!res.ok) { const e = new Error((data && data.error) || 'error'); e.data = data; throw e; }
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
function withProfile(params) { const p = activeProfile(); return Object.assign({ profileId: p ? p.id : '' }, params || {}); }
|
||||||
|
function qs(obj) { return '?' + new URLSearchParams(obj).toString(); }
|
||||||
|
function apiGet(path, params) { return api('GET', path + qs(withProfile(params))); }
|
||||||
|
function apiSend(method, path, body) { return api(method, path, withProfile(body)); }
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
function h(html) { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild; }
|
||||||
|
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
|
||||||
|
function initials(name) { return String(name || '?').trim().split(/\s+/).slice(0, 2).map(w => w[0] || '').join('').toUpperCase() || '?'; }
|
||||||
|
function avatar(url, name, size) {
|
||||||
|
size = size || 'md';
|
||||||
|
if (url) return `<img class="avatar ${size}" src="${esc(url)}" alt="" onerror="this.replaceWith(h(\`<div class='avatar ${size}'>${esc(initials(name))}</div>\`))">`;
|
||||||
|
return `<div class="avatar ${size}">${esc(initials(name))}</div>`;
|
||||||
|
}
|
||||||
|
function badge(p) {
|
||||||
|
let out = '';
|
||||||
|
if (p.is_verified) out += ' <span class="verified" title="Verifiziert">✔</span>';
|
||||||
|
if (p.is_lifeinvader_staff) out += ' <span class="pill staff">Staff</span>';
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function timeAgo(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
const t = new Date(str.replace(' ', 'T')).getTime();
|
||||||
|
if (isNaN(t)) return str;
|
||||||
|
const s = Math.floor((Date.now() - t) / 1000);
|
||||||
|
if (s < 60) return 'gerade eben';
|
||||||
|
if (s < 3600) return Math.floor(s / 60) + ' Min.';
|
||||||
|
if (s < 86400) return Math.floor(s / 3600) + ' Std.';
|
||||||
|
if (s < 604800) return Math.floor(s / 86400) + ' T.';
|
||||||
|
return new Date(t).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' });
|
||||||
|
}
|
||||||
|
function toast(msg, type) {
|
||||||
|
const t = h(`<div class="toast ${type || ''}">${esc(msg)}</div>`);
|
||||||
|
document.getElementById('toast-layer').appendChild(t);
|
||||||
|
setTimeout(() => { t.style.opacity = '0'; t.style.transition = '0.3s'; setTimeout(() => t.remove(), 300); }, 3200);
|
||||||
|
}
|
||||||
|
const ERR_DE = {
|
||||||
|
invalid_login: 'Handle/Mail oder Passwort falsch.', empty_post: 'Dein Post ist leer.',
|
||||||
|
not_allowed_here: 'Dieses Profil darf hier nicht posten.', media_rejected: 'Bild-URL abgelehnt.',
|
||||||
|
handle_taken: 'Dieses Handle ist bereits vergeben.', reserved: 'Dieses Handle ist reserviert.',
|
||||||
|
already_registered: 'Es existiert bereits ein Privatprofil.', name_required: 'Name darf nicht leer sein.',
|
||||||
|
cannot_block: 'Dieses Profil kann nicht blockiert werden.', no_lifeinvader_rights: 'Keine Lifeinvader-Rechte.',
|
||||||
|
invalid_date: 'Ungültiges Datum.', invalid_time: 'Ungültige Uhrzeit.', title_required: 'Titel fehlt.',
|
||||||
|
not_owner: 'Nur eigene Einträge können bearbeitet werden.', upload_failed: 'Upload fehlgeschlagen.',
|
||||||
|
};
|
||||||
|
function errText(e) { return ERR_DE[e && e.message] || 'Es ist ein Fehler aufgetreten.'; }
|
||||||
|
|
||||||
|
function modal(title, bodyEl, onSubmit, submitLabel) {
|
||||||
|
const layer = document.getElementById('modal-layer');
|
||||||
|
const m = h(`<div class="modal"><h3>${esc(title)}</h3></div>`);
|
||||||
|
m.appendChild(bodyEl);
|
||||||
|
const actions = h(`<div class="modal-actions"><button class="btn btn-ghost" data-x="cancel">Abbrechen</button><button class="btn btn-primary" data-x="ok">${esc(submitLabel || 'Speichern')}</button></div>`);
|
||||||
|
m.appendChild(actions);
|
||||||
|
layer.innerHTML = ''; layer.appendChild(m); layer.classList.add('show');
|
||||||
|
const close = () => { layer.classList.remove('show'); layer.innerHTML = ''; };
|
||||||
|
actions.querySelector('[data-x=cancel]').onclick = close;
|
||||||
|
actions.querySelector('[data-x=ok]').onclick = async () => { try { const ok = await onSubmit(); if (ok !== false) close(); } catch (e) { toast(errText(e), 'error'); } };
|
||||||
|
layer.onclick = (e) => { if (e.target === layer) close(); };
|
||||||
|
return close;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auth ────────────────────────────────────────────────────────────────────
|
||||||
|
async function boot() {
|
||||||
|
if (!S.token) return renderLogin();
|
||||||
|
try {
|
||||||
|
const me = await api('GET', '/api/auth/me');
|
||||||
|
S.account = me.account; S.profiles = me.profiles || []; S.canModerate = !!me.can_moderate;
|
||||||
|
if (!S.activeId || !S.profiles.some(p => p.id === S.activeId)) S.activeId = S.profiles[0] ? S.profiles[0].id : null;
|
||||||
|
renderApp();
|
||||||
|
} catch (e) { renderLogin(); }
|
||||||
|
}
|
||||||
|
async function doLogin(login, password, errBox) {
|
||||||
|
try {
|
||||||
|
const r = await api('POST', '/api/auth/login', { login, password });
|
||||||
|
S.token = r.token; localStorage.setItem('bleeter_token', r.token);
|
||||||
|
S.account = r.account; S.profiles = r.profiles || [];
|
||||||
|
S.activeId = S.profiles[0] ? S.profiles[0].id : null;
|
||||||
|
await boot();
|
||||||
|
} catch (e) { errBox.textContent = errText(e); errBox.style.display = 'block'; }
|
||||||
|
}
|
||||||
|
function logout() {
|
||||||
|
S.token = null; S.account = null; S.profiles = [];
|
||||||
|
localStorage.removeItem('bleeter_token'); localStorage.removeItem('bleeter_profile');
|
||||||
|
renderLogin();
|
||||||
|
}
|
||||||
|
function setActive(id) { S.activeId = Number(id); localStorage.setItem('bleeter_profile', S.activeId); go(S.view); }
|
||||||
|
|
||||||
|
// ── Login view ────────────────────────────────────────────────────────────
|
||||||
|
function renderLogin() {
|
||||||
|
document.getElementById('app').innerHTML = '';
|
||||||
|
const wrap = h(`<div id="login-wrap"><div class="login-card">
|
||||||
|
<div class="brand"><div class="brand-logo">🐦</div><div class="brand-name">Bleeter<small>Lifeinvader Network</small></div></div>
|
||||||
|
<h2>Anmelden</h2>
|
||||||
|
<p class="sub">Melde dich mit deinem Handle oder deiner IC-Mail und deinem ingame gesetzten Bleeter-Passwort an.</p>
|
||||||
|
<div class="login-error" id="login-error" style="display:none"></div>
|
||||||
|
<form id="login-form">
|
||||||
|
<label class="field"><span>Handle oder Mail</span><input id="l-login" autocomplete="username" placeholder="@handle oder mail@..." /></label>
|
||||||
|
<label class="field"><span>Passwort</span><input id="l-pass" type="password" autocomplete="current-password" placeholder="••••••••" /></label>
|
||||||
|
<button class="btn btn-primary" style="width:100%;justify-content:center;margin-top:6px">Anmelden</button>
|
||||||
|
</form>
|
||||||
|
<div class="login-hint">Noch kein Passwort? Öffne Bleeter im Spiel und setze mit <b>/bleeterweb <passwort></b> dein Web-Passwort.</div>
|
||||||
|
</div></div>`);
|
||||||
|
document.getElementById('app').appendChild(wrap);
|
||||||
|
const errBox = wrap.querySelector('#login-error');
|
||||||
|
wrap.querySelector('#login-form').addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const login = wrap.querySelector('#l-login').value.trim().replace(/^@/, '');
|
||||||
|
const pass = wrap.querySelector('#l-pass').value;
|
||||||
|
if (!login || !pass) { errBox.textContent = 'Bitte alle Felder ausfüllen.'; errBox.style.display = 'block'; return; }
|
||||||
|
doLogin(login, pass, errBox);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── App shell ───────────────────────────────────────────────────────────────
|
||||||
|
const NAV = [
|
||||||
|
{ key: 'home', label: 'Feed', ico: '🏠' },
|
||||||
|
{ key: 'ads', label: 'Werbefeed', ico: '📣' },
|
||||||
|
{ key: 'people', label: 'Leute', ico: '🧭' },
|
||||||
|
{ key: 'social', label: 'Follower', ico: '👥' },
|
||||||
|
{ key: 'market', label: 'Marktplatz', ico: '🛒' },
|
||||||
|
{ key: 'calendar', label: 'Kalender', ico: '📅' },
|
||||||
|
{ key: 'business', label: 'Gewerbe', ico: '💼' },
|
||||||
|
{ key: 'profile', label: 'Mein Profil', ico: '👤' },
|
||||||
|
{ key: 'legal', label: 'Rechtliches', ico: '📘' },
|
||||||
|
];
|
||||||
|
function navItems() { const n = NAV.slice(); if (S.canModerate) n.push({ key: 'moderation', label: 'Moderation', ico: '🛡️' }); return n; }
|
||||||
|
|
||||||
|
function renderApp() {
|
||||||
|
const p = activeProfile();
|
||||||
|
const profOptions = S.profiles.map(pp => `<option value="${pp.id}" ${pp.id === S.activeId ? 'selected' : ''}>${esc(pp.display_name)} · @${esc(pp.handle)}</option>`).join('');
|
||||||
|
const shell = h(`<div id="shell">
|
||||||
|
<aside id="sidebar">
|
||||||
|
<div class="side-brand"><div class="brand-logo">🐦</div><div class="brand-name">Bleeter</div></div>
|
||||||
|
<nav id="nav">${navItems().map(n => `<button class="nav-item ${n.key === S.view ? 'active' : ''}" data-nav="${n.key}"><span class="ico">${n.ico}</span>${n.label}</button>`).join('')}</nav>
|
||||||
|
<div class="nav-spacer"></div>
|
||||||
|
<div class="profile-switch">
|
||||||
|
<div class="cur">${p ? avatar(p.avatar_url, p.display_name, 'md') : ''}<div class="meta">
|
||||||
|
<b>${p ? esc(p.display_name) : 'Kein Profil'}</b><span>${p ? '@' + esc(p.handle) + ' · ' + (PROFILE_TYPE_LABEL[p.profile_type] || '') : ''}</span></div></div>
|
||||||
|
${S.profiles.length > 1 ? `<select id="prof-select">${profOptions}</select>` : ''}
|
||||||
|
<button class="btn btn-ghost btn-sm logout" data-nav="__logout"><span>⏻</span> Abmelden</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main id="main"></main>
|
||||||
|
</div>`);
|
||||||
|
const mob = h(`<div id="mobile-nav">${navItems().slice(0, 5).map(n => `<button class="${n.key === S.view ? 'active' : ''}" data-nav="${n.key}">${n.ico}</button>`).join('')}</div>`);
|
||||||
|
const app = document.getElementById('app'); app.innerHTML = ''; app.appendChild(shell); app.appendChild(mob);
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-nav]').forEach(b => b.addEventListener('click', () => {
|
||||||
|
const k = b.getAttribute('data-nav');
|
||||||
|
if (k === '__logout') return logout();
|
||||||
|
go(k);
|
||||||
|
}));
|
||||||
|
const sel = shell.querySelector('#prof-select'); if (sel) sel.addEventListener('change', e => setActive(e.target.value));
|
||||||
|
go(S.view);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveNav() {
|
||||||
|
document.querySelectorAll('[data-nav]').forEach(b => {
|
||||||
|
const k = b.getAttribute('data-nav'); if (k && k[0] !== '_') b.classList.toggle('active', k === S.view);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function main() { return document.getElementById('main'); }
|
||||||
|
function loading() { main().innerHTML = `<div class="empty"><div class="big">🐦</div>Lädt…</div>`; }
|
||||||
|
|
||||||
|
// ── Router ────────────────────────────────────────────────────────────────
|
||||||
|
async function go(view) {
|
||||||
|
S.view = view; setActiveNav(); loading();
|
||||||
|
try {
|
||||||
|
if (view === 'home' || view === 'ads') return viewFeed(view === 'ads' ? 'advertising' : 'home');
|
||||||
|
if (view === 'people') return viewPeople();
|
||||||
|
if (view === 'social') return viewSocial();
|
||||||
|
if (view === 'market') return viewMarket();
|
||||||
|
if (view === 'calendar') return viewCalendar();
|
||||||
|
if (view === 'business') return viewBusiness();
|
||||||
|
if (view === 'profile') return viewProfile();
|
||||||
|
if (view === 'legal') return viewLegal();
|
||||||
|
if (view === 'moderation') return viewModeration();
|
||||||
|
} catch (e) { if (e.message !== 'unauthorized') main().innerHTML = `<div class="empty"><div class="big">⚠️</div>${errText(e)}</div>`; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Feed / Werbefeed ─────────────────────────────────────────────────────────
|
||||||
|
function topbar(title, sub) { return `<div class="topbar"><div><h1>${esc(title)}</h1>${sub ? `<div class="sub">${esc(sub)}</div>` : ''}</div></div>`; }
|
||||||
|
|
||||||
|
async function viewFeed(feedType) {
|
||||||
|
const { posts } = await apiGet('/api/feed', { type: feedType });
|
||||||
|
const p = activeProfile();
|
||||||
|
const canPost = canPostHere(p, feedType);
|
||||||
|
const c = h(`<div>${topbar(feedType === 'advertising' ? 'Werbefeed' : 'Feed', feedType === 'advertising' ? 'Angebote & Ankündigungen von Gewerben und Behörden' : 'Was in Los Santos gerade passiert')}</div>`);
|
||||||
|
if (canPost) c.appendChild(composer(feedType));
|
||||||
|
else if (p) c.appendChild(h(`<div class="card muted" style="font-size:.86rem">Als <b>${esc(PROFILE_TYPE_LABEL[p.profile_type])}</b>-Profil kannst du hier nicht posten. ${feedType === 'home' ? 'Wechsle auf ein Behördenprofil.' : 'Wechsle auf ein Gewerbe- oder Behördenprofil.'}</div>`));
|
||||||
|
if (!posts.length) c.appendChild(h(`<div class="empty"><div class="big">🐦</div>Noch keine Bleets hier.</div>`));
|
||||||
|
posts.forEach(post => c.appendChild(postCard(post)));
|
||||||
|
main().innerHTML = ''; main().appendChild(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
function composer(feedType) {
|
||||||
|
const p = activeProfile();
|
||||||
|
const el = h(`<div class="card composer">
|
||||||
|
<div style="display:flex;gap:12px">${avatar(p.avatar_url, p.display_name, 'md')}
|
||||||
|
<div style="flex:1"><textarea maxlength="2000" placeholder="Was gibt's Neues, @${esc(p.handle)}?"></textarea>
|
||||||
|
<input class="media-url" placeholder="Bild-URL (optional, https, .jpg/.png)" style="margin-top:8px;font-size:.85rem" />
|
||||||
|
</div></div>
|
||||||
|
<div class="row"><div class="tools"><span class="char-count">0 / 2000</span></div>
|
||||||
|
<button class="btn btn-primary" data-post>Bleeten</button></div></div>`);
|
||||||
|
const ta = el.querySelector('textarea'), cc = el.querySelector('.char-count');
|
||||||
|
ta.addEventListener('input', () => cc.textContent = `${ta.value.length} / 2000`);
|
||||||
|
el.querySelector('[data-post]').addEventListener('click', async (ev) => {
|
||||||
|
const body = ta.value.trim(), mediaUrl = el.querySelector('.media-url').value.trim();
|
||||||
|
if (!body && !mediaUrl) return toast('Dein Post ist leer.', 'error');
|
||||||
|
ev.target.disabled = true;
|
||||||
|
try { await apiSend('POST', '/api/posts', { feedType, body, mediaUrl }); toast('Gepostet.', 'success'); go(S.view); }
|
||||||
|
catch (e) { toast(errText(e), 'error'); ev.target.disabled = false; }
|
||||||
|
});
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function postCard(post) {
|
||||||
|
const a = post.author;
|
||||||
|
const el = h(`<div class="card post" data-post-id="${post.id}">
|
||||||
|
${avatar(a.avatar_url, a.display_name, 'md')}
|
||||||
|
<div class="body">
|
||||||
|
<div class="head"><span class="name">${esc(a.display_name)}</span>${badge(a)}
|
||||||
|
<span class="handle">@${esc(a.handle)}</span><span class="time">· ${timeAgo(post.created_at)}</span></div>
|
||||||
|
${post.body ? `<div class="text">${esc(post.body)}</div>` : ''}
|
||||||
|
${post.media_url ? `<div class="media"><img src="${esc(post.media_url)}" loading="lazy" /></div>` : ''}
|
||||||
|
<div class="actions">
|
||||||
|
<button class="like ${post.liked_by_viewer ? 'liked' : ''}" data-act="like-post" data-id="${post.id}">${post.liked_by_viewer ? '❤️' : '🤍'} <span>${post.likes}</span></button>
|
||||||
|
<button data-act="toggle-comments" data-id="${post.id}">💬 <span>${post.comments}</span></button>
|
||||||
|
${post.can_delete ? `<button class="del" data-act="del-post" data-id="${post.id}">🗑️</button>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="comments-wrap" data-comments="${post.id}" style="display:${S.openComments.has(post.id) ? 'block' : 'none'}"></div>
|
||||||
|
</div></div>`);
|
||||||
|
if (S.openComments.has(post.id)) renderComments(el.querySelector(`[data-comments="${post.id}"]`), post);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderComments(box, post) {
|
||||||
|
box.innerHTML = '';
|
||||||
|
const list = h(`<div class="comments"></div>`);
|
||||||
|
(post.comments_list || []).forEach(cm => {
|
||||||
|
const cmEl = h(`<div class="comment">${avatar('', cm.author, 'sm')}<div class="c-body">
|
||||||
|
<div class="chead"><b>@${esc(cm.author)}</b> · ${timeAgo(cm.created_at)}</div>
|
||||||
|
<div>${esc(cm.body)}</div>
|
||||||
|
<div class="actions" style="margin-top:6px;font-size:.8rem">
|
||||||
|
<button class="like ${cm.liked_by_viewer ? 'liked' : ''}" data-act="like-comment" data-id="${cm.id}" data-post="${post.id}">${cm.liked_by_viewer ? '❤️' : '🤍'} <span>${cm.likes}</span></button>
|
||||||
|
${cm.can_delete ? `<button class="del" data-act="del-comment" data-id="${cm.id}" data-post="${post.id}">🗑️</button>` : ''}
|
||||||
|
</div></div></div>`);
|
||||||
|
list.appendChild(cmEl);
|
||||||
|
});
|
||||||
|
box.appendChild(list);
|
||||||
|
const form = h(`<form class="comment-form"><input maxlength="500" placeholder="Kommentieren…" /><button class="btn btn-primary btn-sm">→</button></form>`);
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault(); const inp = form.querySelector('input'); const body = inp.value.trim(); if (!body) return;
|
||||||
|
try { const r = await apiSend('POST', `/api/posts/${post.id}/comments`, { body }); post.comments_list = r.comments; post.comments = r.comments.length; inp.value = ''; renderComments(box, post); refreshCount(post); }
|
||||||
|
catch (err) { toast(errText(err), 'error'); }
|
||||||
|
});
|
||||||
|
box.appendChild(form);
|
||||||
|
}
|
||||||
|
function refreshCount(post) {
|
||||||
|
const btn = document.querySelector(`[data-post-id="${post.id}"] [data-act="toggle-comments"] span`);
|
||||||
|
if (btn) btn.textContent = post.comments;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delegated actions for feed-like interactions
|
||||||
|
document.addEventListener('click', async (e) => {
|
||||||
|
const btn = e.target.closest('[data-act]'); if (!btn) return;
|
||||||
|
const act = btn.getAttribute('data-act'); const id = Number(btn.getAttribute('data-id'));
|
||||||
|
try {
|
||||||
|
if (act === 'like-post') { await apiSend('POST', `/api/posts/${id}/like`); go(S.view); }
|
||||||
|
else if (act === 'like-comment') { await apiSend('POST', `/api/comments/${id}/like`); go(S.view); }
|
||||||
|
else if (act === 'del-post') { if (confirm('Diesen Post löschen?')) { await apiSend('DELETE', `/api/posts/${id}`); toast('Gelöscht.', 'success'); go(S.view); } }
|
||||||
|
else if (act === 'del-comment') { if (confirm('Kommentar löschen?')) { await apiSend('DELETE', `/api/comments/${id}`); go(S.view); } }
|
||||||
|
else if (act === 'toggle-comments') { S.openComments.has(id) ? S.openComments.delete(id) : S.openComments.add(id); go(S.view); }
|
||||||
|
else if (act === 'follow') { await apiSend('POST', `/api/profiles/${id}/follow`); go(S.view); }
|
||||||
|
else if (act === 'unfollow') { await apiSend('POST', `/api/profiles/${id}/unfollow`); go(S.view); }
|
||||||
|
else if (act === 'block') { if (confirm('Profil blockieren?')) { await apiSend('POST', `/api/profiles/${id}/block`); toast('Blockiert.', 'success'); go(S.view); } }
|
||||||
|
else if (act === 'unblock') { await apiSend('POST', `/api/profiles/${id}/unblock`); go(S.view); }
|
||||||
|
else if (act === 'del-market') { if (confirm('Inserat löschen?')) { await apiSend('DELETE', `/api/marketplace/${id}`); go(S.view); } }
|
||||||
|
else if (act === 'del-event') { if (confirm('Termin löschen?')) { await apiSend('DELETE', `/api/events/${id}`); go(S.view); } }
|
||||||
|
else if (act === 'mod') { await modAction(btn); }
|
||||||
|
} catch (err) { if (err.message !== 'unauthorized') toast(errText(err), 'error'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Leute (Directory) ───────────────────────────────────────────────────────
|
||||||
|
async function viewPeople() {
|
||||||
|
const [{ profiles }, social] = await Promise.all([apiGet('/api/directory'), apiGet('/api/social')]);
|
||||||
|
const followingIds = new Set(social.following.map(x => x.id));
|
||||||
|
const me = activeProfile();
|
||||||
|
const c = h(`<div>${topbar('Leute', 'Profile entdecken und folgen')}</div>`);
|
||||||
|
const grid = h(`<div class="grid cols-2"></div>`);
|
||||||
|
profiles.filter(p => !me || p.id !== me.id).forEach(p => {
|
||||||
|
const following = followingIds.has(p.id);
|
||||||
|
grid.appendChild(h(`<div class="card person">
|
||||||
|
${avatar(p.avatar_url, p.display_name, 'md')}
|
||||||
|
<div class="meta"><b>${esc(p.display_name)}${badge(p)}</b><span>@${esc(p.handle)} · ${PROFILE_TYPE_LABEL[p.profile_type] || ''} · ${p.followers_count || 0} Follower</span></div>
|
||||||
|
<button class="btn btn-sm ${following ? '' : 'btn-primary'}" data-act="${following ? 'unfollow' : 'follow'}" data-id="${p.id}">${following ? 'Entfolgen' : 'Folgen'}</button>
|
||||||
|
</div>`));
|
||||||
|
});
|
||||||
|
if (!profiles.length) grid.appendChild(h(`<div class="empty">Keine Profile.</div>`));
|
||||||
|
c.appendChild(grid); main().innerHTML = ''; main().appendChild(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Follower / Social ────────────────────────────────────────────────────────
|
||||||
|
let socialTab = 'following';
|
||||||
|
async function viewSocial() {
|
||||||
|
const social = await apiGet('/api/social');
|
||||||
|
const tabs = [['following', 'Ich folge', social.following], ['followers', 'Follower', social.followers], ['blocked', 'Blockiert', social.blocked]];
|
||||||
|
const c = h(`<div>${topbar('Follower', 'Dein Netzwerk')}
|
||||||
|
<div class="tabs">${tabs.map(([k, l, arr]) => `<button class="tab ${k === socialTab ? 'active' : ''}" data-stab="${k}">${l} (${arr.length})</button>`).join('')}</div></div>`);
|
||||||
|
const listWrap = h(`<div></div>`);
|
||||||
|
const render = () => {
|
||||||
|
listWrap.innerHTML = '';
|
||||||
|
const arr = (tabs.find(t => t[0] === socialTab) || [, , []])[2];
|
||||||
|
if (!arr.length) { listWrap.appendChild(h(`<div class="empty">Niemand hier.</div>`)); return; }
|
||||||
|
const grid = h(`<div class="grid cols-2"></div>`);
|
||||||
|
arr.forEach(p => {
|
||||||
|
let action = '';
|
||||||
|
if (socialTab === 'following') action = `<button class="btn btn-sm" data-act="unfollow" data-id="${p.id}">Entfolgen</button>`;
|
||||||
|
else if (socialTab === 'blocked') action = `<button class="btn btn-sm" data-act="unblock" data-id="${p.id}">Freigeben</button>`;
|
||||||
|
else if (p.can_be_blocked) action = `<button class="btn btn-sm btn-danger" data-act="block" data-id="${p.id}">Blockieren</button>`;
|
||||||
|
grid.appendChild(h(`<div class="card person">${avatar(p.avatar_url, p.display_name, 'md')}
|
||||||
|
<div class="meta"><b>${esc(p.display_name)}${badge(p)}</b><span>@${esc(p.handle)} · ${PROFILE_TYPE_LABEL[p.profile_type] || ''}</span></div>${action}</div>`));
|
||||||
|
});
|
||||||
|
listWrap.appendChild(grid);
|
||||||
|
};
|
||||||
|
c.appendChild(listWrap); render();
|
||||||
|
c.querySelectorAll('[data-stab]').forEach(b => b.addEventListener('click', () => { socialTab = b.getAttribute('data-stab'); c.querySelectorAll('[data-stab]').forEach(x => x.classList.toggle('active', x === b)); render(); }));
|
||||||
|
main().innerHTML = ''; main().appendChild(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Marktplatz ────────────────────────────────────────────────────────────
|
||||||
|
async function viewMarket() {
|
||||||
|
const { items } = await apiGet('/api/marketplace');
|
||||||
|
const c = h(`<div><div class="topbar"><div><h1>Marktplatz</h1><div class="sub">Angebote der Community</div></div>
|
||||||
|
<button class="btn btn-primary" id="new-market">+ Inserat</button></div></div>`);
|
||||||
|
c.querySelector('#new-market').addEventListener('click', marketModal);
|
||||||
|
if (!items.length) c.appendChild(h(`<div class="empty"><div class="big">🛒</div>Noch keine Inserate.</div>`));
|
||||||
|
const grid = h(`<div class="grid cols-3"></div>`);
|
||||||
|
items.forEach(it => {
|
||||||
|
grid.appendChild(h(`<div class="card market-card">
|
||||||
|
${it.media_url ? `<div class="media"><img src="${esc(it.media_url)}" loading="lazy"></div>` : ''}
|
||||||
|
<b>${esc(it.title)}</b>
|
||||||
|
${it.price_label ? `<div class="price">${esc(it.price_label)}</div>` : ''}
|
||||||
|
<div class="muted" style="font-size:.86rem;margin:6px 0;white-space:pre-wrap">${esc(it.body || '')}</div>
|
||||||
|
<div class="muted" style="font-size:.78rem">@${esc(it.author.handle)}${it.author.email ? ' · ' + esc(it.author.email) : ''}</div>
|
||||||
|
${it.can_delete ? `<button class="btn btn-sm btn-danger" style="margin-top:10px" data-act="del-market" data-id="${it.id}">Löschen</button>` : ''}
|
||||||
|
</div>`));
|
||||||
|
});
|
||||||
|
c.appendChild(grid); main().innerHTML = ''; main().appendChild(c);
|
||||||
|
}
|
||||||
|
function marketModal() {
|
||||||
|
const body = h(`<div>
|
||||||
|
<label class="field"><span>Titel</span><input id="m-title" maxlength="120"></label>
|
||||||
|
<label class="field"><span>Beschreibung</span><textarea id="m-desc" maxlength="2000"></textarea></label>
|
||||||
|
<label class="field"><span>Preis (optional)</span><input id="m-price" maxlength="80" placeholder="z.B. 5.000 $"></label>
|
||||||
|
<label class="field"><span>Bild-URL (optional)</span><input id="m-media" placeholder="https://...jpg"></label></div>`);
|
||||||
|
modal('Neues Inserat', body, async () => {
|
||||||
|
await apiSend('POST', '/api/marketplace', {
|
||||||
|
title: body.querySelector('#m-title').value, description: body.querySelector('#m-desc').value,
|
||||||
|
priceLabel: body.querySelector('#m-price').value, mediaUrl: body.querySelector('#m-media').value,
|
||||||
|
});
|
||||||
|
toast('Inserat erstellt.', 'success'); go('market');
|
||||||
|
}, 'Erstellen');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Kalender ────────────────────────────────────────────────────────────────
|
||||||
|
async function viewCalendar() {
|
||||||
|
const { days } = await apiGet('/api/calendar');
|
||||||
|
const p = activeProfile();
|
||||||
|
const canCreate = p && BUSINESS_TYPES.includes(p.profile_type);
|
||||||
|
const c = h(`<div><div class="topbar"><div><h1>Kalender</h1><div class="sub">Veranstaltungen der nächsten 4 Wochen</div></div>
|
||||||
|
${canCreate ? '<button class="btn btn-primary" id="new-event">+ Termin</button>' : ''}</div></div>`);
|
||||||
|
if (canCreate) c.querySelector('#new-event').addEventListener('click', eventModal);
|
||||||
|
const withEvents = days.filter(d => d.events.length);
|
||||||
|
if (!withEvents.length) c.appendChild(h(`<div class="empty"><div class="big">📅</div>Keine anstehenden Termine.</div>`));
|
||||||
|
withEvents.forEach(d => {
|
||||||
|
const day = h(`<div class="card cal-day"><h4>${esc(d.title)}</h4></div>`);
|
||||||
|
d.events.forEach(ev => day.appendChild(h(`<div class="event"><span class="time">${esc(ev.time)}</span>
|
||||||
|
<div style="flex:1"><b>${esc(ev.title)}</b>${ev.location ? `<div class="muted" style="font-size:.82rem">📍 ${esc(ev.location)}</div>` : ''}<div class="dim" style="font-size:.78rem">@${esc(ev.author)}</div></div>
|
||||||
|
${ev.can_delete ? `<button class="btn btn-sm btn-danger" data-act="del-event" data-id="${ev.id}">✕</button>` : ''}</div>`)));
|
||||||
|
c.appendChild(day);
|
||||||
|
});
|
||||||
|
main().innerHTML = ''; main().appendChild(c);
|
||||||
|
}
|
||||||
|
function eventModal() {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const body = h(`<div>
|
||||||
|
<label class="field"><span>Titel</span><input id="e-title" maxlength="50"></label>
|
||||||
|
<div class="grid cols-2"><label class="field"><span>Datum</span><input id="e-date" type="date" value="${today}"></label>
|
||||||
|
<label class="field"><span>Uhrzeit</span><input id="e-time" type="time" value="20:00"></label></div>
|
||||||
|
<label class="field"><span>Ort (optional)</span><input id="e-loc" maxlength="50"></label></div>`);
|
||||||
|
modal('Neuer Termin', body, async () => {
|
||||||
|
await apiSend('POST', '/api/events', {
|
||||||
|
title: body.querySelector('#e-title').value, date: body.querySelector('#e-date').value,
|
||||||
|
time: body.querySelector('#e-time').value, location: body.querySelector('#e-loc').value,
|
||||||
|
});
|
||||||
|
toast('Termin erstellt.', 'success'); go('calendar');
|
||||||
|
}, 'Erstellen');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gewerbe ─────────────────────────────────────────────────────────────────
|
||||||
|
async function viewBusiness() {
|
||||||
|
const { businesses } = await apiGet('/api/businesses');
|
||||||
|
const p = activeProfile();
|
||||||
|
const canToggle = p && BUSINESS_TYPES.includes(p.profile_type) && p.can_edit_profile;
|
||||||
|
const mine = canToggle ? businesses.find(b => b.handle === p.handle) : null;
|
||||||
|
const c = h(`<div>${topbar('Gewerbe', 'Geöffnete und geschlossene Betriebe')}</div>`);
|
||||||
|
if (canToggle) {
|
||||||
|
const cur = mine ? mine.status : 'closed';
|
||||||
|
const toggle = h(`<div class="card" style="display:flex;align-items:center;justify-content:space-between">
|
||||||
|
<div><b>Dein Betrieb: @${esc(p.handle)}</b><div class="muted" style="font-size:.84rem">Aktueller Status: <span class="pill ${cur}">${cur === 'open' ? 'Geöffnet' : 'Geschlossen'}</span></div></div>
|
||||||
|
<div style="display:flex;gap:8px"><button class="btn btn-sm ${cur === 'open' ? 'btn-primary' : ''}" data-bstatus="open">Öffnen</button>
|
||||||
|
<button class="btn btn-sm ${cur === 'closed' ? 'btn-primary' : ''}" data-bstatus="closed">Schließen</button></div></div>`);
|
||||||
|
toggle.querySelectorAll('[data-bstatus]').forEach(b => b.addEventListener('click', async () => {
|
||||||
|
try { await apiSend('POST', '/api/business/status', { status: b.getAttribute('data-bstatus') }); toast('Status aktualisiert.', 'success'); go('business'); }
|
||||||
|
catch (e) { toast(errText(e), 'error'); }
|
||||||
|
}));
|
||||||
|
c.appendChild(toggle);
|
||||||
|
}
|
||||||
|
const grid = h(`<div class="grid cols-2"></div>`);
|
||||||
|
businesses.forEach(b => grid.appendChild(h(`<div class="card person">${avatar(b.avatar_url, b.display_name, 'md')}
|
||||||
|
<div class="meta"><b>${esc(b.display_name)}${b.is_verified ? ' <span class="verified">✔</span>' : ''}</b><span>@${esc(b.handle)}</span></div>
|
||||||
|
<span class="pill ${b.status}">${b.status === 'open' ? 'Geöffnet' : 'Geschlossen'}</span></div>`)));
|
||||||
|
if (!businesses.length) grid.appendChild(h(`<div class="empty">Keine Gewerbe gelistet.</div>`));
|
||||||
|
c.appendChild(grid); main().innerHTML = ''; main().appendChild(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mein Profil ─────────────────────────────────────────────────────────────
|
||||||
|
async function viewProfile() {
|
||||||
|
const p = activeProfile();
|
||||||
|
if (!p) { return renderRegister(); }
|
||||||
|
const data = await apiGet('/api/profiles/' + encodeURIComponent(p.handle));
|
||||||
|
const prof = data.profile;
|
||||||
|
const c = h(`<div>${topbar('Mein Profil', '')}
|
||||||
|
<div class="card profile-head pad-lg">
|
||||||
|
<div class="banner">${prof.banner_url ? `<img src="${esc(prof.banner_url)}">` : ''}</div>
|
||||||
|
<div class="ptop">${avatar(prof.avatar_url, prof.display_name, 'lg')}
|
||||||
|
<div class="pmeta"><h2>${esc(prof.display_name)}${badge(prof)}</h2><div class="muted">@${esc(prof.handle)} · ${PROFILE_TYPE_LABEL[prof.profile_type] || ''}</div></div>
|
||||||
|
${p.can_edit_profile ? '<button class="btn btn-sm" id="edit-profile">Bearbeiten</button>' : ''}</div>
|
||||||
|
${prof.bio ? `<div style="margin:14px 4px;line-height:1.6">${esc(prof.bio)}</div>` : ''}
|
||||||
|
<div class="stat-row"><div class="s"><b>${prof.followers_count || 0}</b><span>Follower</span></div>
|
||||||
|
<div class="s"><b>${prof.following_count || 0}</b><span>Folge ich</span></div>
|
||||||
|
<div class="s"><b>${data.posts.length}</b><span>Bleets</span></div></div>
|
||||||
|
</div></div>`);
|
||||||
|
if (p.can_edit_profile) c.querySelector('#edit-profile').addEventListener('click', () => editProfileModal(prof));
|
||||||
|
if (!data.posts.length) c.appendChild(h(`<div class="empty">Noch keine Bleets.</div>`));
|
||||||
|
data.posts.forEach(post => {
|
||||||
|
c.appendChild(h(`<div class="card post"><div class="body" style="margin-left:0">
|
||||||
|
<div class="head"><span class="handle">${prof.feed_type === 'advertising' ? '📣 Werbung' : ''}</span><span class="time">${timeAgo(post.created_at)}</span></div>
|
||||||
|
${post.body ? `<div class="text">${esc(post.body)}</div>` : ''}
|
||||||
|
${post.media_url ? `<div class="media"><img src="${esc(post.media_url)}" loading="lazy"></div>` : ''}
|
||||||
|
<div class="actions"><span>❤️ ${post.likes}</span><span>💬 ${post.comments}</span></div></div></div>`));
|
||||||
|
});
|
||||||
|
main().innerHTML = ''; main().appendChild(c);
|
||||||
|
}
|
||||||
|
function editProfileModal(prof) {
|
||||||
|
const bioMax = prof.profile_type === 'private' ? 200 : 500;
|
||||||
|
const body = h(`<div>
|
||||||
|
<label class="field"><span>Anzeigename</span><input id="p-name" maxlength="80" value="${esc(prof.display_name)}"></label>
|
||||||
|
<label class="field"><span>Bio (max ${bioMax})</span><textarea id="p-bio" maxlength="${bioMax}">${esc(prof.bio || '')}</textarea></label>
|
||||||
|
<label class="field"><span>Avatar-URL</span><input id="p-avatar" value="${esc(prof.avatar_url || '')}" placeholder="https://...jpg"></label>
|
||||||
|
<label class="field"><span>Banner-URL</span><input id="p-banner" value="${esc(prof.banner_url || '')}" placeholder="https://...jpg"></label></div>`);
|
||||||
|
modal('Profil bearbeiten', body, async () => {
|
||||||
|
await apiSend('PATCH', '/api/profile', {
|
||||||
|
displayName: body.querySelector('#p-name').value, bio: body.querySelector('#p-bio').value,
|
||||||
|
avatarUrl: body.querySelector('#p-avatar').value, bannerUrl: body.querySelector('#p-banner').value,
|
||||||
|
});
|
||||||
|
toast('Profil gespeichert.', 'success'); await boot(); go('profile');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function renderRegister() {
|
||||||
|
const c = h(`<div>${topbar('Willkommen bei Bleeter', '')}
|
||||||
|
<div class="card pad-lg"><p class="muted" style="margin-bottom:16px">Du hast noch kein Bleeter-Profil. Lege jetzt dein privates Profil an.</p>
|
||||||
|
<label class="field"><span>Handle (@)</span><input id="r-handle" maxlength="32" placeholder="deinname"></label>
|
||||||
|
<label class="field"><span>Anzeigename</span><input id="r-name" maxlength="80"></label>
|
||||||
|
<button class="btn btn-primary" id="r-go">Profil erstellen</button></div></div>`);
|
||||||
|
c.querySelector('#r-go').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const r = await api('POST', '/api/register', { handle: c.querySelector('#r-handle').value.trim().replace(/^@/, ''), displayName: c.querySelector('#r-name').value.trim() });
|
||||||
|
S.profiles = r.profiles; S.activeId = S.profiles[0].id; toast('Profil erstellt!', 'success'); renderApp();
|
||||||
|
} catch (e) { toast(errText(e), 'error'); }
|
||||||
|
});
|
||||||
|
main().innerHTML = ''; main().appendChild(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rechtliches ─────────────────────────────────────────────────────────────
|
||||||
|
function viewLegal() {
|
||||||
|
const c = h(`<div>${topbar('Rechtliches', '')}
|
||||||
|
<div class="card pad-lg legal">
|
||||||
|
<h3>Über Bleeter</h3>
|
||||||
|
<p>Bleeter ist das soziale Netzwerk von Lifeinvader in Los Santos. Alle Inhalte sind fiktiv und Teil des Rollenspiels auf NaturalBornPlayers.</p>
|
||||||
|
<h3>Nutzungsregeln</h3>
|
||||||
|
<p>Es gelten die Serverregeln von NaturalBornPlayers. Beleidigungen, reale Werbung, OOC-Inhalte sowie das Teilen realer personenbezogener Daten sind untersagt.</p>
|
||||||
|
<p>Behörden- und Lifeinvader-Profile können nicht blockiert werden. Verstöße können von Lifeinvader-Mitarbeitern moderiert (Verifizierung, Sperrung) werden.</p>
|
||||||
|
<h3>Inhalte & Haftung</h3>
|
||||||
|
<p>Für Inhalte ist der jeweilige Verfasser (IC) verantwortlich. Bilder werden über einen externen Dienst gehostet.</p>
|
||||||
|
<h3>Impressum (IC)</h3>
|
||||||
|
<p>Lifeinvader Bleeter · Los Santos · vertreten durch die Lifeinvader-Redaktion.</p>
|
||||||
|
</div></div>`);
|
||||||
|
main().innerHTML = ''; main().appendChild(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Moderation (Lifeinvader) ──────────────────────────────────────────────────
|
||||||
|
async function viewModeration() {
|
||||||
|
const { profiles } = await apiGet('/api/directory');
|
||||||
|
const c = h(`<div>${topbar('Moderation', 'Lifeinvader-Werkzeuge')}
|
||||||
|
<div class="card muted" style="font-size:.85rem">Verifizieren, Staff markieren oder Profile sperren. Aktionen werden protokolliert.</div></div>`);
|
||||||
|
const grid = h(`<div class="grid cols-2"></div>`);
|
||||||
|
profiles.forEach(p => grid.appendChild(h(`<div class="card person" style="flex-wrap:wrap">
|
||||||
|
${avatar(p.avatar_url, p.display_name, 'md')}
|
||||||
|
<div class="meta"><b>${esc(p.display_name)}${badge(p)}</b><span>@${esc(p.handle)} · ${PROFILE_TYPE_LABEL[p.profile_type] || ''}${p.is_locked ? ' · <span style="color:var(--danger)">gesperrt</span>' : ''}</span></div>
|
||||||
|
<div style="display:flex;gap:6px;width:100%;margin-top:6px">
|
||||||
|
<button class="btn btn-sm" data-act="mod" data-modaction="verify" data-id="${p.id}">${p.is_verified ? 'Unverif.' : 'Verifizieren'}</button>
|
||||||
|
<button class="btn btn-sm" data-act="mod" data-modaction="staff" data-id="${p.id}">${p.is_lifeinvader_staff ? 'Staff -' : 'Staff +'}</button>
|
||||||
|
<button class="btn btn-sm btn-danger" data-act="mod" data-modaction="lock" data-id="${p.id}">${p.is_locked ? 'Entsperren' : 'Sperren'}</button>
|
||||||
|
</div></div>`)));
|
||||||
|
c.appendChild(grid); main().innerHTML = ''; main().appendChild(c);
|
||||||
|
}
|
||||||
|
async function modAction(btn) {
|
||||||
|
const action = btn.getAttribute('data-modaction'); const id = Number(btn.getAttribute('data-id'));
|
||||||
|
await apiSend('POST', '/api/moderation/profile', { action, targetId: id });
|
||||||
|
toast('Aktion ausgeführt.', 'success'); go('moderation');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Start ────────────────────────────────────────────────────────────────────
|
||||||
|
boot();
|
||||||
18
web-backend/public/index.html
Normal file
18
web-backend/public/index.html
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Bleeter · Lifeinvader</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<div id="toast-layer"></div>
|
||||||
|
<div id="modal-layer"></div>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
250
web-backend/public/style.css
Normal file
250
web-backend/public/style.css
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
:root {
|
||||||
|
--bg: #0f1117;
|
||||||
|
--bg-2: #141824;
|
||||||
|
--surface: rgba(26, 30, 44, 0.72);
|
||||||
|
--surface-solid: #1a1e2c;
|
||||||
|
--border: rgba(255, 255, 255, 0.08);
|
||||||
|
--border-strong: rgba(255, 255, 255, 0.14);
|
||||||
|
--accent: #8b5cf6;
|
||||||
|
--accent-2: #a78bfa;
|
||||||
|
--accent-soft: rgba(139, 92, 246, 0.16);
|
||||||
|
--text: #e8eaf0;
|
||||||
|
--text-muted: #9aa0b0;
|
||||||
|
--text-dim: #6b7180;
|
||||||
|
--danger: #f87171;
|
||||||
|
--success: #34d399;
|
||||||
|
--radius: 16px;
|
||||||
|
--blur: blur(18px);
|
||||||
|
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html, body { height: 100%; }
|
||||||
|
body {
|
||||||
|
font-family: var(--font);
|
||||||
|
background:
|
||||||
|
radial-gradient(1100px 600px at 12% -8%, rgba(139, 92, 246, 0.14), transparent 60%),
|
||||||
|
radial-gradient(900px 500px at 100% 0%, rgba(99, 102, 241, 0.10), transparent 55%),
|
||||||
|
var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
button { font-family: inherit; cursor: pointer; }
|
||||||
|
input, textarea, select { font-family: inherit; }
|
||||||
|
img { max-width: 100%; }
|
||||||
|
|
||||||
|
/* ── Buttons ─────────────────────────────────────────── */
|
||||||
|
.btn {
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 9px 16px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: 0.15s;
|
||||||
|
display: inline-flex; align-items: center; gap: 7px;
|
||||||
|
}
|
||||||
|
.btn:hover { background: rgba(255,255,255,0.09); }
|
||||||
|
.btn-primary { background: var(--accent); border-color: transparent; color: #fff; }
|
||||||
|
.btn-primary:hover { background: var(--accent-2); }
|
||||||
|
.btn-danger { color: var(--danger); border-color: rgba(248,113,113,0.35); }
|
||||||
|
.btn-danger:hover { background: rgba(248,113,113,0.12); }
|
||||||
|
.btn-sm { padding: 6px 12px; font-size: 0.82rem; }
|
||||||
|
.btn-ghost { border-color: transparent; background: transparent; color: var(--text-muted); }
|
||||||
|
.btn-ghost:hover { color: var(--text); background: rgba(255,255,255,0.05); }
|
||||||
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
input, textarea, select {
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(0,0,0,0.28);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 11px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
outline: none;
|
||||||
|
transition: 0.15s;
|
||||||
|
}
|
||||||
|
input:focus, textarea:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||||
|
textarea { resize: vertical; min-height: 84px; line-height: 1.5; }
|
||||||
|
label.field { display: block; margin-bottom: 12px; }
|
||||||
|
label.field > span { display: block; font-size: 0.8rem; color: var(--text-muted); margin-bottom: 6px; font-weight: 500; }
|
||||||
|
|
||||||
|
.verified { color: var(--accent-2); }
|
||||||
|
.pill { display:inline-block; padding: 2px 9px; border-radius: 999px; font-size: 0.72rem; font-weight: 600; background: var(--accent-soft); color: var(--accent-2); }
|
||||||
|
.pill.staff { background: rgba(52,211,153,0.14); color: var(--success); }
|
||||||
|
.pill.open { background: rgba(52,211,153,0.15); color: var(--success); }
|
||||||
|
.pill.closed { background: rgba(248,113,113,0.14); color: var(--danger); }
|
||||||
|
.muted { color: var(--text-muted); }
|
||||||
|
.dim { color: var(--text-dim); }
|
||||||
|
|
||||||
|
/* ── Login ───────────────────────────────────────────── */
|
||||||
|
#login-wrap {
|
||||||
|
min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px;
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
width: 100%; max-width: 400px;
|
||||||
|
background: var(--surface); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
|
||||||
|
border: 1px solid var(--border); border-radius: 22px; padding: 34px 30px;
|
||||||
|
box-shadow: 0 30px 80px rgba(0,0,0,0.45);
|
||||||
|
}
|
||||||
|
.brand { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
|
||||||
|
.brand-logo { width: 46px; height: 46px; border-radius: 14px; display: grid; place-items: center; font-size: 1.5rem;
|
||||||
|
background: linear-gradient(135deg, var(--accent), #6366f1); box-shadow: 0 8px 24px rgba(139,92,246,0.4); }
|
||||||
|
.brand-name { font-size: 1.5rem; font-weight: 800; letter-spacing: -0.02em; }
|
||||||
|
.brand-name small { display:block; font-size: 0.72rem; font-weight: 500; color: var(--text-muted); letter-spacing: 0.06em; text-transform: uppercase; }
|
||||||
|
.login-card h2 { font-size: 1.05rem; margin: 22px 0 4px; }
|
||||||
|
.login-card p.sub { color: var(--text-muted); font-size: 0.86rem; margin-bottom: 20px; }
|
||||||
|
.login-error { background: rgba(248,113,113,0.12); border: 1px solid rgba(248,113,113,0.3); color: #fca5a5;
|
||||||
|
padding: 10px 14px; border-radius: 12px; font-size: 0.85rem; margin-bottom: 14px; }
|
||||||
|
.login-hint { margin-top: 20px; font-size: 0.78rem; color: var(--text-dim); line-height: 1.6; text-align: center; }
|
||||||
|
|
||||||
|
/* ── App layout ──────────────────────────────────────── */
|
||||||
|
#shell { display: grid; grid-template-columns: 264px minmax(0, 1fr); min-height: 100vh; max-width: 1180px; margin: 0 auto; }
|
||||||
|
#sidebar {
|
||||||
|
position: sticky; top: 0; align-self: start; height: 100vh;
|
||||||
|
padding: 22px 16px; display: flex; flex-direction: column; gap: 6px;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.side-brand { display: flex; align-items: center; gap: 10px; padding: 6px 10px 18px; }
|
||||||
|
.side-brand .brand-logo { width: 38px; height: 38px; font-size: 1.25rem; }
|
||||||
|
.side-brand .brand-name { font-size: 1.25rem; }
|
||||||
|
.nav-item {
|
||||||
|
display: flex; align-items: center; gap: 13px; padding: 11px 14px; border-radius: 12px;
|
||||||
|
color: var(--text-muted); font-weight: 600; font-size: 0.95rem; transition: 0.15s; border: none; background: none; width: 100%; text-align: left;
|
||||||
|
}
|
||||||
|
.nav-item .ico { width: 22px; text-align: center; font-size: 1.05rem; }
|
||||||
|
.nav-item:hover { background: rgba(255,255,255,0.05); color: var(--text); }
|
||||||
|
.nav-item.active { background: var(--accent-soft); color: var(--accent-2); }
|
||||||
|
.nav-spacer { flex: 1; }
|
||||||
|
|
||||||
|
.profile-switch {
|
||||||
|
margin-top: 10px; border: 1px solid var(--border); border-radius: 14px; padding: 10px; background: rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
.profile-switch .cur { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.avatar { border-radius: 50%; object-fit: cover; background: var(--accent-soft); flex-shrink: 0; display: grid; place-items: center; font-weight: 700; color: var(--accent-2); }
|
||||||
|
.avatar.sm { width: 34px; height: 34px; font-size: 0.85rem; }
|
||||||
|
.avatar.md { width: 46px; height: 46px; font-size: 1rem; }
|
||||||
|
.avatar.lg { width: 84px; height: 84px; font-size: 1.8rem; }
|
||||||
|
.profile-switch .meta { min-width: 0; }
|
||||||
|
.profile-switch .meta b { display: block; font-size: 0.88rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.profile-switch .meta span { font-size: 0.76rem; color: var(--text-muted); }
|
||||||
|
.profile-switch select { margin-top: 9px; padding: 8px 10px; font-size: 0.84rem; }
|
||||||
|
.logout { margin-top: 8px; width: 100%; justify-content: center; }
|
||||||
|
|
||||||
|
/* ── Main column ─────────────────────────────────────── */
|
||||||
|
#main { padding: 0 26px 80px; min-width: 0; }
|
||||||
|
.topbar {
|
||||||
|
position: sticky; top: 0; z-index: 5; padding: 20px 4px 14px; margin-bottom: 8px;
|
||||||
|
background: linear-gradient(var(--bg) 62%, transparent);
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||||
|
}
|
||||||
|
.topbar h1 { font-size: 1.35rem; font-weight: 800; letter-spacing: -0.02em; }
|
||||||
|
.topbar .sub { font-size: 0.82rem; color: var(--text-muted); margin-top: 2px; }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--surface); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius); padding: 18px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.card.pad-lg { padding: 22px; }
|
||||||
|
|
||||||
|
/* Composer */
|
||||||
|
.composer textarea { border: none; background: transparent; padding: 6px 2px; min-height: 60px; font-size: 1.02rem; }
|
||||||
|
.composer textarea:focus { box-shadow: none; }
|
||||||
|
.composer .row { display: flex; align-items: center; gap: 10px; justify-content: space-between; margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--border); }
|
||||||
|
.composer .tools { display: flex; align-items: center; gap: 8px; color: var(--text-muted); }
|
||||||
|
.char-count { font-size: 0.78rem; color: var(--text-dim); }
|
||||||
|
|
||||||
|
/* Post */
|
||||||
|
.post { display: flex; gap: 13px; }
|
||||||
|
.post .body { min-width: 0; flex: 1; }
|
||||||
|
.post .head { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
|
||||||
|
.post .head .name { font-weight: 700; }
|
||||||
|
.post .head .handle, .post .head .time { color: var(--text-muted); font-size: 0.85rem; }
|
||||||
|
.post .text { margin: 5px 0 10px; line-height: 1.55; white-space: pre-wrap; word-break: break-word; }
|
||||||
|
.post .media { border-radius: 14px; border: 1px solid var(--border); overflow: hidden; margin: 6px 0 10px; }
|
||||||
|
.post .media img { display: block; width: 100%; }
|
||||||
|
.actions { display: flex; gap: 22px; color: var(--text-muted); font-size: 0.86rem; }
|
||||||
|
.actions button { background: none; border: none; color: inherit; display: inline-flex; align-items: center; gap: 6px; padding: 4px; border-radius: 8px; transition: 0.15s; }
|
||||||
|
.actions button:hover { color: var(--text); }
|
||||||
|
.actions button.liked { color: var(--accent-2); }
|
||||||
|
.actions button.del:hover { color: var(--danger); }
|
||||||
|
|
||||||
|
.comments { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 11px; }
|
||||||
|
.comment { display: flex; gap: 10px; }
|
||||||
|
.comment .c-body { background: rgba(0,0,0,0.22); border: 1px solid var(--border); border-radius: 12px; padding: 8px 12px; flex: 1; min-width: 0; }
|
||||||
|
.comment .c-body .chead { font-size: 0.8rem; color: var(--text-muted); margin-bottom: 2px; }
|
||||||
|
.comment .c-body .chead b { color: var(--text); }
|
||||||
|
.comment-form { display: flex; gap: 8px; margin-top: 4px; }
|
||||||
|
.comment-form input { border-radius: 999px; }
|
||||||
|
|
||||||
|
/* Grids */
|
||||||
|
.grid { display: grid; gap: 14px; }
|
||||||
|
.grid.cols-2 { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.grid.cols-3 { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
|
||||||
|
.person { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.person .meta { min-width: 0; flex: 1; }
|
||||||
|
.person .meta b { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.person .meta span { font-size: 0.8rem; color: var(--text-muted); }
|
||||||
|
|
||||||
|
.market-card .media { height: 150px; border-radius: 12px; overflow: hidden; border: 1px solid var(--border); margin-bottom: 12px; background: rgba(0,0,0,0.25); }
|
||||||
|
.market-card .media img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.market-card .price { color: var(--accent-2); font-weight: 700; }
|
||||||
|
|
||||||
|
.cal-day { }
|
||||||
|
.cal-day h4 { font-size: 0.9rem; margin-bottom: 8px; color: var(--text-muted); }
|
||||||
|
.event { display: flex; gap: 12px; align-items: center; padding: 10px 12px; border: 1px solid var(--border); border-radius: 12px; margin-bottom: 8px; background: rgba(0,0,0,0.18); }
|
||||||
|
.event .time { font-weight: 700; color: var(--accent-2); min-width: 46px; }
|
||||||
|
|
||||||
|
.empty { text-align: center; color: var(--text-dim); padding: 48px 20px; }
|
||||||
|
.empty .big { font-size: 2.4rem; margin-bottom: 10px; opacity: 0.7; }
|
||||||
|
|
||||||
|
.tabs { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||||
|
.tab { padding: 8px 16px; border-radius: 999px; background: rgba(255,255,255,0.04); border: 1px solid var(--border); color: var(--text-muted); font-weight: 600; font-size: 0.86rem; }
|
||||||
|
.tab.active { background: var(--accent-soft); color: var(--accent-2); border-color: transparent; }
|
||||||
|
|
||||||
|
/* Profile header */
|
||||||
|
.profile-head .banner { height: 130px; border-radius: 14px; background: linear-gradient(135deg, rgba(139,92,246,0.35), rgba(99,102,241,0.25)); border: 1px solid var(--border); overflow: hidden; }
|
||||||
|
.profile-head .banner img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.profile-head .ptop { display: flex; align-items: flex-end; gap: 16px; margin-top: -42px; padding: 0 6px; }
|
||||||
|
.profile-head .ptop .avatar { border: 4px solid var(--surface-solid); }
|
||||||
|
.profile-head .pmeta { flex: 1; padding-bottom: 4px; }
|
||||||
|
.profile-head .pmeta h2 { font-size: 1.3rem; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.stat-row { display: flex; gap: 22px; margin: 12px 4px; }
|
||||||
|
.stat-row .s b { font-size: 1.05rem; }
|
||||||
|
.stat-row .s span { color: var(--text-muted); font-size: 0.82rem; margin-left: 5px; }
|
||||||
|
|
||||||
|
/* Modal + toast */
|
||||||
|
#modal-layer { position: fixed; inset: 0; display: none; align-items: center; justify-content: center; padding: 20px; z-index: 60; background: rgba(0,0,0,0.6); }
|
||||||
|
#modal-layer.show { display: flex; }
|
||||||
|
.modal { width: 100%; max-width: 480px; background: var(--surface-solid); border: 1px solid var(--border-strong); border-radius: 20px; padding: 24px; box-shadow: 0 30px 80px rgba(0,0,0,0.5); }
|
||||||
|
.modal h3 { font-size: 1.15rem; margin-bottom: 16px; }
|
||||||
|
.modal .modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 18px; }
|
||||||
|
|
||||||
|
#toast-layer { position: fixed; top: 20px; right: 20px; z-index: 80; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.toast { background: var(--surface-solid); border: 1px solid var(--border-strong); border-left: 3px solid var(--accent); padding: 12px 18px; border-radius: 12px; font-size: 0.88rem; box-shadow: 0 12px 40px rgba(0,0,0,0.4); animation: slidein 0.2s ease; max-width: 320px; }
|
||||||
|
.toast.error { border-left-color: var(--danger); }
|
||||||
|
.toast.success { border-left-color: var(--success); }
|
||||||
|
@keyframes slidein { from { transform: translateX(20px); opacity: 0; } to { transform: none; opacity: 1; } }
|
||||||
|
|
||||||
|
.legal { line-height: 1.7; color: var(--text-muted); }
|
||||||
|
.legal h3 { color: var(--text); margin: 18px 0 8px; font-size: 1rem; }
|
||||||
|
.legal p { margin-bottom: 10px; }
|
||||||
|
|
||||||
|
/* Mobile */
|
||||||
|
#mobile-nav { display: none; }
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
#shell { grid-template-columns: 1fr; }
|
||||||
|
#sidebar { display: none; }
|
||||||
|
#main { padding: 0 14px 90px; }
|
||||||
|
.grid.cols-2, .grid.cols-3 { grid-template-columns: 1fr; }
|
||||||
|
#mobile-nav { display: flex; position: fixed; bottom: 0; left: 0; right: 0; z-index: 40;
|
||||||
|
background: var(--surface); backdrop-filter: var(--blur); border-top: 1px solid var(--border); padding: 8px; justify-content: space-around; }
|
||||||
|
#mobile-nav button { background: none; border: none; color: var(--text-muted); font-size: 1.3rem; padding: 8px 12px; border-radius: 10px; }
|
||||||
|
#mobile-nav button.active { color: var(--accent-2); background: var(--accent-soft); }
|
||||||
|
}
|
||||||
65
web-backend/routes/auth.js
Normal file
65
web-backend/routes/auth.js
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
const express = require('express');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const db = require('../db');
|
||||||
|
const { sign, requireAuth } = require('../auth');
|
||||||
|
const { getAccessibleProfiles } = require('../lib/account');
|
||||||
|
const { hasLifeinvaderPermission } = require('../lib/permissions');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function publicAccount(account) {
|
||||||
|
return {
|
||||||
|
id: account.id,
|
||||||
|
char_id: account.char_id,
|
||||||
|
mail_address: account.mail_address,
|
||||||
|
phone_number: account.phone_number,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login mit Handle ODER Mail + ingame gesetztem Bleeter-Passwort
|
||||||
|
router.post('/login', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const login = String((req.body && req.body.login) || '').trim().toLowerCase();
|
||||||
|
const password = String((req.body && req.body.password) || '');
|
||||||
|
if (!login || !password) return res.status(400).json({ error: 'missing_credentials' });
|
||||||
|
|
||||||
|
let account = null;
|
||||||
|
if (login.includes('@')) {
|
||||||
|
account = await db.q1('SELECT * FROM bleeter_accounts WHERE mail_address = ?', [login]);
|
||||||
|
} else {
|
||||||
|
const profile = await db.q1('SELECT account_id FROM bleeter_profiles WHERE handle = ? LIMIT 1', [login]);
|
||||||
|
if (profile && profile.account_id) {
|
||||||
|
account = await db.q1('SELECT * FROM bleeter_accounts WHERE id = ?', [profile.account_id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!account || !account.web_password_hash) {
|
||||||
|
return res.status(401).json({ error: 'invalid_login' });
|
||||||
|
}
|
||||||
|
if (account.status && account.status !== 'active') {
|
||||||
|
return res.status(403).json({ error: 'account_disabled' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await bcrypt.compare(password, account.web_password_hash);
|
||||||
|
if (!ok) return res.status(401).json({ error: 'invalid_login' });
|
||||||
|
|
||||||
|
const token = sign(account);
|
||||||
|
const profiles = await getAccessibleProfiles(account);
|
||||||
|
res.json({ token, account: publicAccount(account), profiles });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[bleeter] login error', err);
|
||||||
|
res.status(500).json({ error: 'server_error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Aktueller Account + zugaengliche Profile + Moderationsflag
|
||||||
|
router.get('/me', requireAuth, async (req, res) => {
|
||||||
|
const profiles = await getAccessibleProfiles(req.account);
|
||||||
|
const canModerate =
|
||||||
|
(await hasLifeinvaderPermission(db, req.account.char_id, 'profile.verify')) ||
|
||||||
|
(await hasLifeinvaderPermission(db, req.account.char_id, 'profile.staff')) ||
|
||||||
|
(await hasLifeinvaderPermission(db, req.account.char_id, 'profile.lock'));
|
||||||
|
res.json({ account: publicAccount(req.account), profiles, can_moderate: canModerate });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
42
web-backend/routes/business.js
Normal file
42
web-backend/routes/business.js
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
const express = require('express');
|
||||||
|
const db = require('../db');
|
||||||
|
const { requireAuth } = require('../auth');
|
||||||
|
const { resolveActingProfile } = require('../lib/account');
|
||||||
|
const { loadBusinesses } = require('../lib/queries');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
const BUSINESS_TYPES = ['small_business', 'company', 'authority'];
|
||||||
|
|
||||||
|
async function acting(req) {
|
||||||
|
const profileId = (req.body && req.body.profileId) || req.query.profileId;
|
||||||
|
return resolveActingProfile(req.account, profileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/businesses?profileId=
|
||||||
|
router.get('/businesses', async (req, res) => {
|
||||||
|
const { profile } = await acting(req);
|
||||||
|
res.json({ businesses: await loadBusinesses(profile ? profile.id : 0) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/business/status {profileId, status: open|closed} -> eigenes Gewerbe schalten
|
||||||
|
router.post('/business/status', async (req, res) => {
|
||||||
|
const { profile } = await acting(req);
|
||||||
|
if (!profile) return res.status(400).json({ error: 'no_profile' });
|
||||||
|
if (!BUSINESS_TYPES.includes(profile.profile_type) || !profile.can_edit_profile) {
|
||||||
|
return res.status(403).json({ error: 'not_allowed' });
|
||||||
|
}
|
||||||
|
const status = req.body.status === 'open' ? 'open' : req.body.status === 'closed' ? 'closed' : null;
|
||||||
|
if (!status) return res.status(400).json({ error: 'invalid_status' });
|
||||||
|
|
||||||
|
await db.exec(
|
||||||
|
`INSERT INTO bleeter_business_status (profile_id, status, source)
|
||||||
|
VALUES (?, ?, 'web')
|
||||||
|
ON DUPLICATE KEY UPDATE status = VALUES(status), source = VALUES(source)`,
|
||||||
|
[profile.id, status]
|
||||||
|
);
|
||||||
|
res.json({ ok: true, status });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
57
web-backend/routes/events.js
Normal file
57
web-backend/routes/events.js
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
const express = require('express');
|
||||||
|
const db = require('../db');
|
||||||
|
const { requireAuth } = require('../auth');
|
||||||
|
const { resolveActingProfile } = require('../lib/account');
|
||||||
|
const { loadCalendarDays } = require('../lib/queries');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
const BUSINESS_TYPES = ['small_business', 'company', 'authority'];
|
||||||
|
|
||||||
|
async function acting(req) {
|
||||||
|
const profileId = (req.body && req.body.profileId) || req.query.profileId;
|
||||||
|
return resolveActingProfile(req.account, profileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/calendar?profileId=
|
||||||
|
router.get('/calendar', async (req, res) => {
|
||||||
|
const { profile } = await acting(req);
|
||||||
|
res.json({ days: await loadCalendarDays(profile ? profile.id : 0) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/events {profileId, date, time, title, location}
|
||||||
|
router.post('/events', async (req, res) => {
|
||||||
|
const { profile } = await acting(req);
|
||||||
|
if (!profile) return res.status(400).json({ error: 'no_profile' });
|
||||||
|
if (!BUSINESS_TYPES.includes(profile.profile_type)) return res.status(403).json({ error: 'not_allowed' });
|
||||||
|
|
||||||
|
const date = String(req.body.date || '').trim().slice(0, 10);
|
||||||
|
const time = String(req.body.time || '').trim().slice(0, 5);
|
||||||
|
const title = String(req.body.title || '').trim().slice(0, 50);
|
||||||
|
const location = String(req.body.location || '').trim().slice(0, 50);
|
||||||
|
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return res.status(400).json({ error: 'invalid_date' });
|
||||||
|
if (!/^\d{2}:\d{2}$/.test(time)) return res.status(400).json({ error: 'invalid_time' });
|
||||||
|
if (!title) return res.status(400).json({ error: 'title_required' });
|
||||||
|
|
||||||
|
await db.insert(
|
||||||
|
'INSERT INTO bleeter_events (author_profile_id, title, location, starts_at) VALUES (?, ?, ?, ?)',
|
||||||
|
[profile.id, title, location || null, `${date} ${time}:00`]
|
||||||
|
);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/events/:id {profileId}
|
||||||
|
router.delete('/events/:id', async (req, res) => {
|
||||||
|
const { profile } = await acting(req);
|
||||||
|
if (!profile) return res.status(400).json({ error: 'no_profile' });
|
||||||
|
const eventId = Number(req.params.id);
|
||||||
|
const ev = await db.q1('SELECT author_profile_id FROM bleeter_events WHERE id = ? AND deleted_at IS NULL', [eventId]);
|
||||||
|
if (!ev) return res.status(404).json({ error: 'not_found' });
|
||||||
|
if (Number(ev.author_profile_id) !== Number(profile.id)) return res.status(403).json({ error: 'not_owner' });
|
||||||
|
await db.exec("UPDATE bleeter_events SET deleted_at = NOW(), status = 'deleted' WHERE id = ?", [eventId]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
126
web-backend/routes/feed.js
Normal file
126
web-backend/routes/feed.js
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
const express = require('express');
|
||||||
|
const db = require('../db');
|
||||||
|
const { requireAuth } = require('../auth');
|
||||||
|
const { resolveActingProfile } = require('../lib/account');
|
||||||
|
const { loadPosts, loadCommentsForPost } = require('../lib/queries');
|
||||||
|
const { canPost } = require('../lib/permissions');
|
||||||
|
const { isAllowedExternalUrl } = require('../lib/media');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
// Helfer: aktives Profil aufloesen (profileId aus body/query)
|
||||||
|
async function acting(req) {
|
||||||
|
const profileId = (req.body && req.body.profileId) || req.query.profileId;
|
||||||
|
return resolveActingProfile(req.account, profileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/feed?type=home|advertising&profileId=
|
||||||
|
router.get('/feed', async (req, res) => {
|
||||||
|
const { profile } = await acting(req);
|
||||||
|
const type = req.query.type === 'advertising' ? 'advertising'
|
||||||
|
: req.query.type === 'home' ? 'home' : null;
|
||||||
|
const posts = await loadPosts(profile ? profile.id : 0, type);
|
||||||
|
res.json({ posts });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/posts {profileId, feedType, body, mediaUrl}
|
||||||
|
router.post('/posts', async (req, res) => {
|
||||||
|
const { profile } = await acting(req);
|
||||||
|
if (!profile) return res.status(400).json({ error: 'no_profile' });
|
||||||
|
|
||||||
|
const feedType = String(req.body.feedType || '').trim();
|
||||||
|
const body = String(req.body.body || '').trim().slice(0, 2000);
|
||||||
|
const mediaUrl = String(req.body.mediaUrl || '').trim().slice(0, 500);
|
||||||
|
|
||||||
|
if (!body && !mediaUrl) return res.status(400).json({ error: 'empty_post' });
|
||||||
|
if (feedType !== 'home' && feedType !== 'advertising') return res.status(400).json({ error: 'invalid_feed' });
|
||||||
|
if (!canPost(profile.profile_type, feedType)) return res.status(403).json({ error: 'not_allowed_here' });
|
||||||
|
|
||||||
|
let mediaId = null;
|
||||||
|
if (mediaUrl) {
|
||||||
|
const check = isAllowedExternalUrl(mediaUrl);
|
||||||
|
if (!check.ok) return res.status(400).json({ error: 'media_rejected', reason: check.reason });
|
||||||
|
mediaId = await db.insert(
|
||||||
|
'INSERT INTO bleeter_media (owner_profile_id, source_type, url) VALUES (?, ?, ?)',
|
||||||
|
[profile.id, 'external_url', mediaUrl]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(
|
||||||
|
'INSERT INTO bleeter_posts (author_profile_id, feed_type, body, media_id) VALUES (?, ?, ?, ?)',
|
||||||
|
[profile.id, feedType, body, mediaId]
|
||||||
|
);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/posts/:id {profileId}
|
||||||
|
router.delete('/posts/:id', async (req, res) => {
|
||||||
|
const { profile } = await acting(req);
|
||||||
|
if (!profile) return res.status(400).json({ error: 'no_profile' });
|
||||||
|
const postId = Number(req.params.id);
|
||||||
|
const post = await db.q1('SELECT author_profile_id FROM bleeter_posts WHERE id = ? AND deleted_at IS NULL AND self_deleted_at IS NULL', [postId]);
|
||||||
|
if (!post) return res.status(404).json({ error: 'not_found' });
|
||||||
|
if (Number(post.author_profile_id) !== Number(profile.id)) return res.status(403).json({ error: 'not_owner' });
|
||||||
|
await db.exec('UPDATE bleeter_posts SET self_deleted_at = NOW() WHERE id = ?', [postId]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/posts/:id/like {profileId} -> toggle
|
||||||
|
router.post('/posts/:id/like', async (req, res) => {
|
||||||
|
const { profile } = await acting(req);
|
||||||
|
if (!profile) return res.status(400).json({ error: 'no_profile' });
|
||||||
|
await toggleLike(profile.id, 'post', Number(req.params.id));
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/posts/:id/comments {profileId, body}
|
||||||
|
router.post('/posts/:id/comments', async (req, res) => {
|
||||||
|
const { profile } = await acting(req);
|
||||||
|
if (!profile) return res.status(400).json({ error: 'no_profile' });
|
||||||
|
const postId = Number(req.params.id);
|
||||||
|
const body = String(req.body.body || '').trim().slice(0, 500);
|
||||||
|
if (!body) return res.status(400).json({ error: 'empty_comment' });
|
||||||
|
const post = await db.q1('SELECT id FROM bleeter_posts WHERE id = ? AND deleted_at IS NULL AND self_deleted_at IS NULL AND hidden_at IS NULL', [postId]);
|
||||||
|
if (!post) return res.status(404).json({ error: 'not_found' });
|
||||||
|
await db.insert('INSERT INTO bleeter_comments (post_id, author_profile_id, body) VALUES (?, ?, ?)', [postId, profile.id, body]);
|
||||||
|
const comments = await loadCommentsForPost(postId, profile.id);
|
||||||
|
res.json({ ok: true, comments });
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/comments/:id {profileId}
|
||||||
|
router.delete('/comments/:id', async (req, res) => {
|
||||||
|
const { profile } = await acting(req);
|
||||||
|
if (!profile) return res.status(400).json({ error: 'no_profile' });
|
||||||
|
const commentId = Number(req.params.id);
|
||||||
|
const c = await db.q1('SELECT author_profile_id, post_id FROM bleeter_comments WHERE id = ? AND deleted_at IS NULL AND self_deleted_at IS NULL', [commentId]);
|
||||||
|
if (!c) return res.status(404).json({ error: 'not_found' });
|
||||||
|
if (Number(c.author_profile_id) !== Number(profile.id)) return res.status(403).json({ error: 'not_owner' });
|
||||||
|
await db.exec('UPDATE bleeter_comments SET self_deleted_at = NOW() WHERE id = ?', [commentId]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/comments/:id/like {profileId} -> toggle
|
||||||
|
router.post('/comments/:id/like', async (req, res) => {
|
||||||
|
const { profile } = await acting(req);
|
||||||
|
if (!profile) return res.status(400).json({ error: 'no_profile' });
|
||||||
|
await toggleLike(profile.id, 'comment', Number(req.params.id));
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function toggleLike(profileId, targetType, targetId) {
|
||||||
|
const existing = await db.q1(
|
||||||
|
'SELECT id FROM bleeter_likes WHERE profile_id = ? AND target_type = ? AND target_id = ?',
|
||||||
|
[profileId, targetType, targetId]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
await db.exec('DELETE FROM bleeter_likes WHERE id = ?', [existing.id]);
|
||||||
|
} else {
|
||||||
|
await db.exec(
|
||||||
|
'INSERT IGNORE INTO bleeter_likes (profile_id, target_type, target_id) VALUES (?, ?, ?)',
|
||||||
|
[profileId, targetType, targetId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
41
web-backend/routes/internal.js
Normal file
41
web-backend/routes/internal.js
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
const express = require('express');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const db = require('../db');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Nur vom FiveM-Server erreichbar (interner Key)
|
||||||
|
function requireInternal(req, res, next) {
|
||||||
|
const key = req.headers['x-internal-key'];
|
||||||
|
if (!key || key !== process.env.INTERNAL_API_KEY) {
|
||||||
|
return res.status(403).json({ error: 'forbidden' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /internal/set-password {charId, password}
|
||||||
|
// Wird ingame vom Spieler ausgeloest; Passwort wird hier gehasht gespeichert.
|
||||||
|
router.post('/set-password', requireInternal, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const charId = String((req.body && req.body.charId) || '').trim();
|
||||||
|
const password = String((req.body && req.body.password) || '');
|
||||||
|
if (!charId) return res.status(400).json({ error: 'missing_char' });
|
||||||
|
if (password.length < 6) return res.status(400).json({ error: 'password_too_short' });
|
||||||
|
|
||||||
|
const account = await db.q1('SELECT id FROM bleeter_accounts WHERE char_id = ?', [charId]);
|
||||||
|
if (!account) return res.status(404).json({ error: 'no_account' });
|
||||||
|
|
||||||
|
const hash = await bcrypt.hash(password, 10);
|
||||||
|
await db.exec(
|
||||||
|
'UPDATE bleeter_accounts SET web_password_hash = ?, web_password_updated_at = NOW() WHERE id = ?',
|
||||||
|
[hash, account.id]
|
||||||
|
);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[bleeter] set-password error', err.message);
|
||||||
|
res.status(500).json({ error: 'server_error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
63
web-backend/routes/marketplace.js
Normal file
63
web-backend/routes/marketplace.js
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
const express = require('express');
|
||||||
|
const db = require('../db');
|
||||||
|
const { requireAuth } = require('../auth');
|
||||||
|
const { resolveActingProfile } = require('../lib/account');
|
||||||
|
const { loadMarketplace } = require('../lib/queries');
|
||||||
|
const { isAllowedExternalUrl } = require('../lib/media');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
async function acting(req) {
|
||||||
|
const profileId = (req.body && req.body.profileId) || req.query.profileId;
|
||||||
|
return resolveActingProfile(req.account, profileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/marketplace?profileId=
|
||||||
|
router.get('/marketplace', async (req, res) => {
|
||||||
|
const { profile } = await acting(req);
|
||||||
|
res.json({ items: await loadMarketplace(profile ? profile.id : 0) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/marketplace {profileId, title, description, priceLabel, mediaUrl}
|
||||||
|
router.post('/marketplace', async (req, res) => {
|
||||||
|
const { profile } = await acting(req);
|
||||||
|
if (!profile) return res.status(400).json({ error: 'no_profile' });
|
||||||
|
|
||||||
|
let title = String(req.body.title || '').trim().slice(0, 120);
|
||||||
|
let description = String(req.body.description || '').trim().slice(0, 2000);
|
||||||
|
const priceLabel = String(req.body.priceLabel || '').trim().slice(0, 80);
|
||||||
|
const mediaUrl = String(req.body.mediaUrl || '').trim().slice(0, 500);
|
||||||
|
|
||||||
|
if (!title && !description) return res.status(400).json({ error: 'empty' });
|
||||||
|
if (!description) description = title;
|
||||||
|
if (!title) title = description.slice(0, 120);
|
||||||
|
|
||||||
|
let mediaId = null;
|
||||||
|
if (mediaUrl) {
|
||||||
|
const check = isAllowedExternalUrl(mediaUrl);
|
||||||
|
if (!check.ok) return res.status(400).json({ error: 'media_rejected', reason: check.reason });
|
||||||
|
mediaId = await db.insert('INSERT INTO bleeter_media (owner_profile_id, source_type, url) VALUES (?, ?, ?)', [profile.id, 'external_url', mediaUrl]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(
|
||||||
|
`INSERT INTO bleeter_marketplace (author_profile_id, category, title, description, price_label, media_id)
|
||||||
|
VALUES (?, 'general', ?, ?, ?, ?)`,
|
||||||
|
[profile.id, title, description, priceLabel, mediaId]
|
||||||
|
);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/marketplace/:id {profileId}
|
||||||
|
router.delete('/marketplace/:id', async (req, res) => {
|
||||||
|
const { profile } = await acting(req);
|
||||||
|
if (!profile) return res.status(400).json({ error: 'no_profile' });
|
||||||
|
const entryId = Number(req.params.id);
|
||||||
|
const entry = await db.q1('SELECT author_profile_id FROM bleeter_marketplace WHERE id = ? AND deleted_at IS NULL', [entryId]);
|
||||||
|
if (!entry) return res.status(404).json({ error: 'not_found' });
|
||||||
|
if (Number(entry.author_profile_id) !== Number(profile.id)) return res.status(403).json({ error: 'not_owner' });
|
||||||
|
await db.exec("UPDATE bleeter_marketplace SET deleted_at = NOW(), status = 'deleted' WHERE id = ?", [entryId]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
40
web-backend/routes/media.js
Normal file
40
web-backend/routes/media.js
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
const express = require('express');
|
||||||
|
const multer = require('multer');
|
||||||
|
const db = require('../db');
|
||||||
|
const { requireAuth } = require('../auth');
|
||||||
|
const { resolveActingProfile } = require('../lib/account');
|
||||||
|
const { uploadToImgbb, ALLOWED_EXT } = require('../lib/media');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: { fileSize: 2 * 1024 * 1024 }, // 2 MB (Config.Media.maxBytes)
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/media/upload (multipart: image) {profileId}
|
||||||
|
router.post('/media/upload', upload.single('image'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { profile } = await resolveActingProfile(req.account, req.body.profileId);
|
||||||
|
if (!profile) return res.status(400).json({ error: 'no_profile' });
|
||||||
|
if (!req.file) return res.status(400).json({ error: 'no_file' });
|
||||||
|
|
||||||
|
const ext = (req.file.originalname.split('.').pop() || '').toLowerCase();
|
||||||
|
const mimeOk = /^image\/(jpe?g|png)$/i.test(req.file.mimetype || '');
|
||||||
|
if (!ALLOWED_EXT.includes(ext) && !mimeOk) return res.status(400).json({ error: 'unsupported_type' });
|
||||||
|
|
||||||
|
const { url } = await uploadToImgbb(req.file.buffer, req.file.originalname);
|
||||||
|
await db.insert(
|
||||||
|
`INSERT INTO bleeter_media (owner_profile_id, source_type, url, original_name, mime_type, size_bytes)
|
||||||
|
VALUES (?, 'upload', ?, ?, ?, ?)`,
|
||||||
|
[profile.id, url, req.file.originalname, req.file.mimetype, req.file.size]
|
||||||
|
);
|
||||||
|
res.json({ ok: true, url });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[bleeter] media upload error', err.message);
|
||||||
|
res.status(500).json({ error: 'upload_failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
62
web-backend/routes/moderation.js
Normal file
62
web-backend/routes/moderation.js
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
const express = require('express');
|
||||||
|
const db = require('../db');
|
||||||
|
const { requireAuth } = require('../auth');
|
||||||
|
const { resolveActingProfile, findTargetProfile } = require('../lib/account');
|
||||||
|
const { hasLifeinvaderPermission, decodeBool } = require('../lib/permissions');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
const PERMISSION_BY_ACTION = {
|
||||||
|
verify: 'profile.verify',
|
||||||
|
staff: 'profile.staff',
|
||||||
|
lock: 'profile.lock',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function writeAudit(account, actorProfile, action, targetType, targetId, reason, payload) {
|
||||||
|
await db.insert(
|
||||||
|
`INSERT INTO bleeter_audit_logs (actor_char_id, actor_profile_id, action, target_type, target_id, reason, payload)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[account.char_id, actorProfile ? actorProfile.id : null, action, targetType, targetId, reason || null,
|
||||||
|
payload ? JSON.stringify(payload) : null]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/moderation/profile {profileId, action: verify|staff|lock, targetId}
|
||||||
|
router.post('/moderation/profile', async (req, res) => {
|
||||||
|
const { profile } = await resolveActingProfile(req.account, req.body.profileId);
|
||||||
|
const action = String(req.body.action || '').trim();
|
||||||
|
const target = await findTargetProfile({ targetId: req.body.targetId }, true);
|
||||||
|
if (!profile || !target) return res.status(400).json({ error: 'invalid' });
|
||||||
|
|
||||||
|
const permission = PERMISSION_BY_ACTION[action];
|
||||||
|
if (!permission || !(await hasLifeinvaderPermission(db, req.account.char_id, permission))) {
|
||||||
|
return res.status(403).json({ error: 'no_lifeinvader_rights' });
|
||||||
|
}
|
||||||
|
if (action === 'lock' && Number(profile.id) === Number(target.id)) {
|
||||||
|
return res.status(400).json({ error: 'cannot_lock_self' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'verify') {
|
||||||
|
const next = !decodeBool(target.is_verified);
|
||||||
|
await db.exec('UPDATE bleeter_profiles SET is_verified = ? WHERE id = ?', [next ? 1 : 0, target.id]);
|
||||||
|
await writeAudit(req.account, profile, next ? 'profile.verify' : 'profile.unverify', 'profile', target.id, null, { handle: target.handle, is_verified: next });
|
||||||
|
return res.json({ ok: true, is_verified: next });
|
||||||
|
}
|
||||||
|
if (action === 'staff') {
|
||||||
|
const next = !decodeBool(target.is_lifeinvader_staff);
|
||||||
|
await db.exec('UPDATE bleeter_profiles SET is_lifeinvader_staff = ? WHERE id = ?', [next ? 1 : 0, target.id]);
|
||||||
|
await writeAudit(req.account, profile, next ? 'profile.staff.add' : 'profile.staff.remove', 'profile', target.id, null, { handle: target.handle, is_lifeinvader_staff: next });
|
||||||
|
return res.json({ ok: true, is_lifeinvader_staff: next });
|
||||||
|
}
|
||||||
|
if (action === 'lock') {
|
||||||
|
const next = !decodeBool(target.is_locked);
|
||||||
|
await db.exec('UPDATE bleeter_profiles SET is_locked = ?, locked_reason = ? WHERE id = ?',
|
||||||
|
[next ? 1 : 0, next ? 'Lifeinvader moderation' : null, target.id]);
|
||||||
|
await writeAudit(req.account, profile, next ? 'profile.lock' : 'profile.unlock', 'profile', target.id, null, { handle: target.handle, is_locked: next });
|
||||||
|
return res.json({ ok: true, is_locked: next });
|
||||||
|
}
|
||||||
|
res.status(400).json({ error: 'unknown_action' });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
174
web-backend/routes/profiles.js
Normal file
174
web-backend/routes/profiles.js
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
const express = require('express');
|
||||||
|
const db = require('../db');
|
||||||
|
const { requireAuth } = require('../auth');
|
||||||
|
const { resolveActingProfile, findTargetProfile, mapProfile, getAccessibleProfiles } = require('../lib/account');
|
||||||
|
const { loadProfileDirectory, loadSocial } = require('../lib/queries');
|
||||||
|
const { canProfileBeBlocked, decodeBool } = require('../lib/permissions');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
const HANDLE_RE = /^[a-z0-9._-]+$/;
|
||||||
|
|
||||||
|
async function acting(req) {
|
||||||
|
const profileId = (req.body && req.body.profileId) || req.query.profileId;
|
||||||
|
return resolveActingProfile(req.account, profileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/directory?profileId=
|
||||||
|
router.get('/directory', async (req, res) => {
|
||||||
|
const { profile } = await acting(req);
|
||||||
|
const profiles = await loadProfileDirectory(profile ? profile.id : 0, false);
|
||||||
|
res.json({ profiles });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/social?profileId=
|
||||||
|
router.get('/social', async (req, res) => {
|
||||||
|
const { profile } = await acting(req);
|
||||||
|
if (!profile) return res.json({ following: [], followers: [], blocked: [] });
|
||||||
|
res.json(await loadSocial(profile.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/profiles/:handle?profileId= -> Profil + eigene Posts
|
||||||
|
router.get('/profiles/:handle', async (req, res) => {
|
||||||
|
const { profile: viewer } = await acting(req);
|
||||||
|
const viewerId = viewer ? viewer.id : 0;
|
||||||
|
const handle = String(req.params.handle || '').toLowerCase();
|
||||||
|
const row = await db.q1(
|
||||||
|
`SELECT p.*,
|
||||||
|
(SELECT COUNT(*) FROM bleeter_follows f WHERE f.followed_profile_id=p.id) AS followers_count,
|
||||||
|
(SELECT COUNT(*) FROM bleeter_follows f WHERE f.follower_profile_id=p.id) AS following_count
|
||||||
|
FROM bleeter_profiles p WHERE p.handle = ? AND p.is_active = 1`,
|
||||||
|
[handle]
|
||||||
|
);
|
||||||
|
if (!row) return res.status(404).json({ error: 'not_found' });
|
||||||
|
const target = mapProfile(row);
|
||||||
|
|
||||||
|
const isFollowing = viewerId ? !!(await db.q1(
|
||||||
|
'SELECT 1 FROM bleeter_follows WHERE follower_profile_id = ? AND followed_profile_id = ? LIMIT 1',
|
||||||
|
[viewerId, target.id]
|
||||||
|
)) : false;
|
||||||
|
const isBlocked = viewerId ? !!(await db.q1(
|
||||||
|
'SELECT 1 FROM bleeter_blocks WHERE blocker_profile_id = ? AND blocked_profile_id = ? LIMIT 1',
|
||||||
|
[viewerId, target.id]
|
||||||
|
)) : false;
|
||||||
|
|
||||||
|
const posts = await db.q(
|
||||||
|
`SELECT posts.id, posts.feed_type, posts.body, posts.created_at, media.url AS media_url,
|
||||||
|
(SELECT COUNT(*) FROM bleeter_likes l WHERE l.target_type='post' AND l.target_id=posts.id) AS likes,
|
||||||
|
(SELECT COUNT(*) FROM bleeter_comments c WHERE c.post_id=posts.id AND c.self_deleted_at IS NULL AND c.hidden_at IS NULL AND c.deleted_at IS NULL) AS comments
|
||||||
|
FROM bleeter_posts posts
|
||||||
|
LEFT JOIN bleeter_media media ON media.id = posts.media_id
|
||||||
|
WHERE posts.author_profile_id = ? AND posts.self_deleted_at IS NULL AND posts.hidden_at IS NULL AND posts.deleted_at IS NULL
|
||||||
|
ORDER BY posts.created_at DESC LIMIT 50`,
|
||||||
|
[target.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
profile: target,
|
||||||
|
is_following: isFollowing,
|
||||||
|
is_blocked: isBlocked,
|
||||||
|
posts: posts.map(p => ({
|
||||||
|
id: p.id, feed_type: p.feed_type, body: p.body || '', media_url: p.media_url,
|
||||||
|
created_at: String(p.created_at || ''), likes: Number(p.likes) || 0, comments: Number(p.comments) || 0,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/profiles/:id/follow {profileId}
|
||||||
|
router.post('/profiles/:id/follow', async (req, res) => {
|
||||||
|
const { profile } = await acting(req);
|
||||||
|
const target = await findTargetProfile({ targetId: req.params.id });
|
||||||
|
if (!profile || !target) return res.status(400).json({ error: 'invalid' });
|
||||||
|
if (Number(profile.id) === Number(target.id)) return res.status(400).json({ error: 'self_follow' });
|
||||||
|
await db.exec('INSERT IGNORE INTO bleeter_follows (follower_profile_id, followed_profile_id) VALUES (?, ?)', [profile.id, target.id]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/profiles/:id/unfollow {profileId}
|
||||||
|
router.post('/profiles/:id/unfollow', async (req, res) => {
|
||||||
|
const { profile } = await acting(req);
|
||||||
|
const target = await findTargetProfile({ targetId: req.params.id });
|
||||||
|
if (!profile || !target) return res.status(400).json({ error: 'invalid' });
|
||||||
|
await db.exec('DELETE FROM bleeter_follows WHERE follower_profile_id = ? AND followed_profile_id = ?', [profile.id, target.id]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/profiles/:id/block {profileId}
|
||||||
|
router.post('/profiles/:id/block', async (req, res) => {
|
||||||
|
const { profile } = await acting(req);
|
||||||
|
const target = await findTargetProfile({ targetId: req.params.id });
|
||||||
|
if (!profile || !target) return res.status(400).json({ error: 'invalid' });
|
||||||
|
if (Number(profile.id) === Number(target.id)) return res.status(400).json({ error: 'self_block' });
|
||||||
|
if (!canProfileBeBlocked(target.profile_type, target.is_lifeinvader_staff)) return res.status(403).json({ error: 'cannot_block' });
|
||||||
|
await db.exec('INSERT IGNORE INTO bleeter_blocks (blocker_profile_id, blocked_profile_id) VALUES (?, ?)', [profile.id, target.id]);
|
||||||
|
await db.exec(
|
||||||
|
`DELETE FROM bleeter_follows
|
||||||
|
WHERE (follower_profile_id=? AND followed_profile_id=?) OR (follower_profile_id=? AND followed_profile_id=?)`,
|
||||||
|
[profile.id, target.id, target.id, profile.id]
|
||||||
|
);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/profiles/:id/unblock {profileId}
|
||||||
|
router.post('/profiles/:id/unblock', async (req, res) => {
|
||||||
|
const { profile } = await acting(req);
|
||||||
|
const target = await findTargetProfile({ targetId: req.params.id });
|
||||||
|
if (!profile || !target) return res.status(400).json({ error: 'invalid' });
|
||||||
|
await db.exec('DELETE FROM bleeter_blocks WHERE blocker_profile_id = ? AND blocked_profile_id = ?', [profile.id, target.id]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/profile {profileId, displayName, avatarUrl, bannerUrl, bio} -> aktives Profil bearbeiten
|
||||||
|
router.patch('/profile', async (req, res) => {
|
||||||
|
const { profile } = await acting(req);
|
||||||
|
if (!profile) return res.status(400).json({ error: 'no_profile' });
|
||||||
|
if (!profile.can_edit_profile) return res.status(403).json({ error: 'not_allowed' });
|
||||||
|
|
||||||
|
const displayName = String(req.body.displayName || '').trim().slice(0, 80);
|
||||||
|
const avatarUrl = String(req.body.avatarUrl || '').trim().slice(0, 255);
|
||||||
|
const bannerUrl = String(req.body.bannerUrl || '').trim().slice(0, 255);
|
||||||
|
const bioMax = profile.profile_type === 'private' ? 200 : 500;
|
||||||
|
const bio = String(req.body.bio || '').trim().slice(0, bioMax);
|
||||||
|
if (!displayName) return res.status(400).json({ error: 'name_required' });
|
||||||
|
|
||||||
|
await db.exec(
|
||||||
|
'UPDATE bleeter_profiles SET display_name = ?, avatar_url = ?, banner_url = ?, bio = ? WHERE id = ?',
|
||||||
|
[displayName, avatarUrl || null, bannerUrl || null, bio, profile.id]
|
||||||
|
);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/register {handle, displayName} -> privates Profil anlegen (falls noch keins)
|
||||||
|
router.post('/register', async (req, res) => {
|
||||||
|
const account = req.account;
|
||||||
|
let handle = String(req.body.handle || '').trim().toLowerCase();
|
||||||
|
let displayName = String(req.body.displayName || '').trim().slice(0, 80);
|
||||||
|
|
||||||
|
if (handle.length < 2 || handle.length > 32) return res.status(400).json({ error: 'invalid_handle_length' });
|
||||||
|
if (!HANDLE_RE.test(handle)) return res.status(400).json({ error: 'invalid_handle_chars' });
|
||||||
|
if (!displayName) displayName = handle;
|
||||||
|
|
||||||
|
const existingPrivate = await db.q1(
|
||||||
|
"SELECT id FROM bleeter_profiles WHERE account_id = ? AND profile_type = 'private' LIMIT 1",
|
||||||
|
[account.id]
|
||||||
|
);
|
||||||
|
if (existingPrivate) return res.status(409).json({ error: 'already_registered' });
|
||||||
|
|
||||||
|
const reserved = await db.q1('SELECT 1 FROM bleeter_reserved_handles WHERE handle = ? LIMIT 1', [handle]);
|
||||||
|
if (reserved) return res.status(409).json({ error: 'reserved' });
|
||||||
|
|
||||||
|
const taken = await db.q1('SELECT 1 FROM bleeter_profiles WHERE handle = ? LIMIT 1', [handle]);
|
||||||
|
if (taken) return res.status(409).json({ error: 'handle_taken' });
|
||||||
|
|
||||||
|
await db.insert(
|
||||||
|
`INSERT INTO bleeter_profiles (account_id, profile_type, handle, display_name, email_contact, bio)
|
||||||
|
VALUES (?, 'private', ?, ?, ?, '')`,
|
||||||
|
[account.id, handle, displayName, account.mail_address]
|
||||||
|
);
|
||||||
|
|
||||||
|
const profiles = await getAccessibleProfiles(account);
|
||||||
|
res.json({ ok: true, profiles });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
38
web-backend/server.js
Normal file
38
web-backend/server.js
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
require('dotenv').config();
|
||||||
|
const path = require('path');
|
||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
|
||||||
|
// Health
|
||||||
|
app.get('/api/health', (req, res) => res.json({ ok: true, service: 'zc-bleeter-web' }));
|
||||||
|
|
||||||
|
// Interner Endpunkt (FiveM-Server)
|
||||||
|
app.use('/internal', require('./routes/internal'));
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
app.use('/api/auth', require('./routes/auth'));
|
||||||
|
|
||||||
|
// API
|
||||||
|
app.use('/api', require('./routes/feed'));
|
||||||
|
app.use('/api', require('./routes/profiles'));
|
||||||
|
app.use('/api', require('./routes/marketplace'));
|
||||||
|
app.use('/api', require('./routes/events'));
|
||||||
|
app.use('/api', require('./routes/business'));
|
||||||
|
app.use('/api', require('./routes/moderation'));
|
||||||
|
app.use('/api', require('./routes/media'));
|
||||||
|
|
||||||
|
// Statisches Frontend + SPA-Fallback
|
||||||
|
const publicDir = path.join(__dirname, 'public');
|
||||||
|
app.use(express.static(publicDir));
|
||||||
|
app.get(/^(?!\/api|\/internal).*/, (req, res) => {
|
||||||
|
res.sendFile(path.join(publicDir, 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PORT || 4091);
|
||||||
|
app.listen(PORT, '127.0.0.1', () => {
|
||||||
|
console.log(`[zc-bleeter-web] Backend laeuft auf 127.0.0.1:${PORT}`);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue