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:
Bjoern Flessing 2026-08-09 12:00:48 +00:00
commit 20076b0aee
41 changed files with 8652 additions and 0 deletions

18
.gitignore vendored Normal file
View file

@ -0,0 +1,18 @@
# Abhaengigkeiten
node_modules/
package-lock.json
# Zugangsdaten gehoeren nie ins Repository
.env
.env.*
!.env.example
# Editor und Betriebssystem
.vscode/
.idea/
*.swp
.DS_Store
Thumbs.db
# Protokolle
*.log

209
README.md Normal file
View 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
View 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
View 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
View file

@ -0,0 +1 @@

40
html/index.html Normal file
View 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

File diff suppressed because it is too large Load diff

1255
html/style.css Normal file

File diff suppressed because it is too large Load diff

46
server/adapters/icweb.lua Normal file
View 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

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

View 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

File diff suppressed because it is too large Load diff

437
server/management.lua Normal file
View 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
View 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
View 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
View 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;

View 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`);

View 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
View 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
View 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
View 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 };

View 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
View 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 };

View 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
View 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
View 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
View 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 &lt;passwort&gt;</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();

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

View 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); }
}

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

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

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

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

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

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

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

View 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
View 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}`);
});