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

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)