bleeter/server/main.lua

1628 lines
59 KiB
Lua
Raw Normal View History

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.
2026-08-09 12:00:48 +00:00
local activeProfileBySource = {}
-- ── Mail-Helper ───────────────────────────────────────────────────────────────
local BLEETER_SENDER = 'bleeter@lifeinvader.ls'
local BLEETER_SENDER_NAME = 'Lifeinvader Bleeter'
local function sendBleeterMail(toAddress, subject, body)
if not toAddress or toAddress == '' then return end
pcall(function()
exports['ic-mail']:SendMail({
from = BLEETER_SENDER,
fromName = BLEETER_SENDER_NAME,
to = { toAddress },
subject = subject,
body = body
})
end)
end
local function debugPrint(...)
if Config.Debug then
print('[bleeter]', ...)
end
end
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 notify(source, message, notifyType)
TriggerClientEvent('bleeter:client:notify', source, {
type = notifyType or 'info',
message = message
})
end
local function buildHandleFromMail(mail)
local localPart = tostring(mail or ''):match('^([^@]+)@')
local handle = trim(localPart):lower()
if handle == '' then return nil, 'missing_mail_local_part' end
if #handle < Config.Handles.minLength then return nil, 'handle_too_short' end
if #handle > Config.Handles.maxLength then return nil, 'handle_too_long' end
if not handle:match(Config.Handles.pattern) then return nil, 'invalid_handle' end
return handle
end
local function decodeBool(value)
return value == true or value == 1 or value == '1'
end
local function canProfileBeBlocked(profileType, isLifeinvaderStaff)
return profileType ~= 'authority'
and profileType ~= 'lifeinvader'
and not decodeBool(isLifeinvaderStaff)
end
local function mapProfile(row)
if not row then return nil end
local 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 or '',
banner_url = row.banner_url or '',
bio = row.bio or '',
location = row.location or '',
email_contact = row.email_contact or '',
phone_contact = row.phone_contact or '',
is_verified = decodeBool(row.is_verified),
is_lifeinvader_staff = decodeBool(row.is_lifeinvader_staff),
is_active = not not (row.is_active == nil or 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 ~= nil then
profile.followers_count = tonumber(row.followers_count) or 0
end
if row.following_count ~= nil then
profile.following_count = tonumber(row.following_count) or 0
end
return profile
end
-- Gibt zurück: account, reason
-- account=nil, reason='missing_char' → keine NT-ID, fataler Fehler
-- account=nil, reason='no_mail' → kein IC-Mail-Konto, Registrierung nicht möglich
-- account, reason='needs_registration' → Account OK, aber kein Privatprofil → Registrierung zeigen
-- account, reason=nil → alles gut
local function ensureAccount(source)
local identity = IfruitAdapter.GetAccountIdentity(source)
local charId = trim(identity.charId, 80)
local identifier = trim(identity.identifier, 80)
local displayName = trim(identity.displayName, 80)
local mailAddress = trim(identity.mailAddress or '', 120):lower()
local phoneNumber = trim(identity.phoneNumber or '', 20)
if charId == '' then
return nil, 'missing_char'
end
-- Kein IC-Mail-Konto → Spieler muss zuerst iFruit einrichten
if mailAddress == '' then
return nil, 'no_mail'
end
MySQL.insert.await([[
INSERT INTO bleeter_accounts (char_id, identifier, mail_address, phone_number)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
identifier = VALUES(identifier),
mail_address = VALUES(mail_address),
phone_number = VALUES(phone_number)
]], { charId, identifier, mailAddress, phoneNumber ~= '' and phoneNumber or nil })
local account = MySQL.single.await('SELECT * FROM bleeter_accounts WHERE char_id = ?', { charId })
if not account then
return nil, 'account_create_failed'
end
account.displayName = displayName
account.mailAddress = mailAddress
account.phoneNumber = phoneNumber
-- Privatprofil prüfen NICHT mehr auto-erstellen, stattdessen Registrierungsflow
local privateProfile = MySQL.single.await(
'SELECT id FROM bleeter_profiles WHERE account_id = ? AND profile_type = ? LIMIT 1',
{ account.id, 'private' }
)
if not privateProfile then
return account, 'needs_registration'
end
return account, nil
end
local function getAccessibleProfiles(account)
local profiles = {}
local seen = {}
local ownRows = MySQL.query.await([[
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 }) or {}
for _, row in ipairs(ownRows) do
local profile = mapProfile(row)
profiles[#profiles + 1] = profile
seen[profile.id] = true
end
local memberRows = MySQL.query.await([[
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 }) or {}
for _, row in ipairs(memberRows) do
if not seen[row.id] then
local profile = mapProfile(row)
profiles[#profiles + 1] = profile
seen[profile.id] = true
end
end
return profiles
end
local function findProfile(profiles, profileId)
for _, profile in ipairs(profiles or {}) do
if tonumber(profile.id) == tonumber(profileId) then
return profile
end
end
return nil
end
local function activeProfileForSource(source, account)
local profiles = getAccessibleProfiles(account)
local requestedId = activeProfileBySource[source]
local profile = findProfile(profiles, requestedId) or profiles[1]
if profile then
activeProfileBySource[source] = profile.id
end
return profile, profiles
end
local function formatDate(value)
if not value then return '' end
return tostring(value)
end
local function loadCommentsForPost(postId, viewerProfileId)
local rows = MySQL.query.await([[
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
]], { viewerProfileId or 0, postId, viewerProfileId or 0, viewerProfileId or 0 }) or {}
local comments = {}
for _, row in ipairs(rows) do
comments[#comments + 1] = {
id = row.id,
author_profile_id = row.author_profile_id,
author = row.author_handle,
body = row.body,
created_at = formatDate(row.created_at),
likes = tonumber(row.likes) or 0,
liked_by_viewer = decodeBool(row.liked_by_viewer)
}
comments[#comments].can_delete = tonumber(row.author_profile_id) == tonumber(viewerProfileId)
end
return comments
end
local function loadPosts(viewerProfileId)
local rows = MySQL.query.await([[
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 = ?)
)
ORDER BY posts.created_at DESC
LIMIT 80
]], { viewerProfileId or 0, viewerProfileId or 0, viewerProfileId or 0 }) or {}
local posts = {}
for _, row in ipairs(rows) do
local comments = loadCommentsForPost(row.id, viewerProfileId)
posts[#posts + 1] = {
id = row.id,
feed_type = row.feed_type,
body = row.body or '',
media_url = row.media_url,
created_at = formatDate(row.created_at),
likes = tonumber(row.likes) or 0,
liked_by_viewer = decodeBool(row.liked_by_viewer),
can_delete = tonumber(row.author_profile_id) == tonumber(viewerProfileId),
comments = #comments,
comments_list = comments,
author = {
id = row.author_id,
handle = row.author_handle,
display_name = row.author_name,
avatar_url = row.author_avatar or '',
is_verified = decodeBool(row.author_verified),
is_lifeinvader_staff = decodeBool(row.author_lifeinvader_staff)
}
}
end
return posts
end
-- Read-only Vorschau-Export fuer andere Resources (zc_ifruit-Telefon,
-- kompakter Feed-Ausschnitt, siehe composed-soaring-sparkle.md Phase 2).
-- Nutzt bewusst dieselbe loadPosts()-Abfrage (Sperrungen/geloeschte/
-- versteckte Posts werden identisch zur echten Bleeter-Oberflaeche
-- ausgeschlossen), ohne Kommentare/Likes/Berechtigungslogik zu duplizieren --
-- Interaktionen bleiben ausschliesslich im vollen Bleeter-Screen.
exports('GetRecentPosts', function(limit)
limit = tonumber(limit) or 8
local ok, posts = pcall(loadPosts, nil)
if not ok or type(posts) ~= 'table' then return {} end
local out = {}
for i = 1, math.min(limit, #posts) do
local p = posts[i]
out[#out + 1] = {
id = p.id,
body = p.body,
created_at = p.created_at,
author_handle = p.author and p.author.handle or 'unbekannt',
author_display_name = p.author and p.author.display_name or 'Unbekannt',
author_verified = p.author and p.author.is_verified or false,
}
end
return out
end)
local function loadUsers(viewerProfileId, includeLocked)
local rows = MySQL.query.await([[
SELECT id, handle, display_name, avatar_url, profile_type, is_verified, is_lifeinvader_staff
FROM bleeter_profiles p
WHERE p.is_active = 1
AND (? = 1 OR p.is_locked = 0)
AND p.id <> ?
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 handle
LIMIT 100
]], { includeLocked and 1 or 0, viewerProfileId or 0, viewerProfileId or 0, viewerProfileId or 0 }) or {}
local users = {}
for _, row in ipairs(rows) do
users[#users + 1] = {
id = row.id,
handle = row.handle,
display_name = row.display_name,
avatar_url = row.avatar_url or '',
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)
}
end
return users
end
local function loadProfileDirectory(viewerProfileId, includeLocked)
local rows = MySQL.query.await([[
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 and 1 or 0, viewerProfileId or 0, viewerProfileId or 0 }) or {}
local profiles = {}
for _, row in ipairs(rows) do
profiles[#profiles + 1] = mapProfile(row)
end
return profiles
end
local function mapSocialProfile(row)
return {
id = row.id,
handle = row.handle,
display_name = row.display_name,
avatar_url = row.avatar_url or '',
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)
}
end
local function loadSocial(activeProfileId)
local followingRows = MySQL.query.await([[
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
]], { activeProfileId, activeProfileId, activeProfileId }) or {}
local followerRows = MySQL.query.await([[
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
]], { activeProfileId, activeProfileId, activeProfileId }) or {}
local blockedRows = MySQL.query.await([[
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
]], { activeProfileId }) or {}
local social = { following = {}, followers = {}, blocked = {} }
for _, row in ipairs(followingRows) do social.following[#social.following + 1] = mapSocialProfile(row) end
for _, row in ipairs(followerRows) do social.followers[#social.followers + 1] = mapSocialProfile(row) end
for _, row in ipairs(blockedRows) do social.blocked[#social.blocked + 1] = mapSocialProfile(row) end
return social
end
local germanWeekdays = {
'Sonntag',
'Montag',
'Dienstag',
'Mittwoch',
'Donnerstag',
'Freitag',
'Samstag'
}
local function dateKeyFromOffset(offset)
return os.date('%Y-%m-%d', os.time() + (offset * 86400))
end
local function dateTitleFromKey(dateKey)
local year, month, day = tostring(dateKey):match('^(%d%d%d%d)%-(%d%d)%-(%d%d)$')
if not year then return tostring(dateKey) end
local timestamp = os.time({
year = tonumber(year),
month = tonumber(month),
day = tonumber(day),
hour = 12
})
local weekday = germanWeekdays[tonumber(os.date('%w', timestamp)) + 1] or ''
return ('%s %s.%s.%s'):format(weekday, day, month, year)
end
local function loadCalendarDays(activeProfileId)
local rows = MySQL.query.await([[
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
]]) or {}
local byDate = {}
for _, row in ipairs(rows) do
byDate[row.date_key] = byDate[row.date_key] or {}
byDate[row.date_key][#byDate[row.date_key] + 1] = {
id = row.id,
time = row.time_text,
title = row.title,
location = row.location or '',
author = row.author_handle,
can_delete = tonumber(row.author_profile_id) == tonumber(activeProfileId)
}
end
local days = {}
for offset = 0, 27 do
local dateKey = dateKeyFromOffset(offset)
days[#days + 1] = {
offset = offset,
date = dateKey,
title = dateTitleFromKey(dateKey),
events = byDate[dateKey] or {}
}
end
return days
end
local function loadBusinesses(viewerProfileId)
local rows = MySQL.query.await([[
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
]], { viewerProfileId or 0, viewerProfileId or 0 }) or {}
local businesses = {}
for _, row in ipairs(rows) do
businesses[#businesses + 1] = {
handle = row.handle,
display_name = row.display_name,
avatar_url = row.avatar_url or '',
banner_url = row.banner_url or '',
status = row.status or 'closed',
is_verified = decodeBool(row.is_verified),
is_lifeinvader_staff = decodeBool(row.is_lifeinvader_staff)
}
end
return businesses
end
local function loadMarketplace(activeProfileId)
local rows = MySQL.query.await([[
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
]], { activeProfileId or 0, activeProfileId or 0 }) or {}
local items = {}
for _, row in ipairs(rows) do
local priceNumber = tonumber(tostring(row.price_label or ''):match('%d+')) or 0
items[#items + 1] = {
id = row.id,
title = row.title,
body = row.description,
price = priceNumber,
price_label = row.price_label or '',
created = row.id,
created_at = formatDate(row.created_at),
media_url = row.media_url,
can_delete = tonumber(row.author_profile_id) == tonumber(activeProfileId),
author = {
id = row.author_profile_id,
handle = row.author_handle,
display_name = row.author_name,
avatar_url = row.author_avatar or '',
email = row.author_email or '',
is_verified = decodeBool(row.author_verified),
is_lifeinvader_staff = decodeBool(row.author_lifeinvader_staff)
}
}
end
return items
end
local function buildRegistrationPayload(source, account, reason)
local identity = IfruitAdapter.GetAccountIdentity(source)
local mailAddress = (account and account.mailAddress) or ''
-- Handlevorschlag aus Mail-Lokalteil ableiten
local suggested = ''
if mailAddress ~= '' then
local localPart = mailAddress:match('^([^@]+)@') or ''
suggested = localPart:lower():gsub('[^a-z0-9%._%-]', '')
if #suggested > Config.Handles.maxLength then suggested = suggested:sub(1, Config.Handles.maxLength) end
end
return {
needs_registration = true,
reason = reason,
mail = mailAddress,
suggested_handle = suggested,
display_name = trim(identity.displayName or '', 80)
}
end
local function buildBootstrap(source)
local account, reason = ensureAccount(source)
-- Fataler Fehler: kein Charakter
if not account and reason == 'missing_char' then
return nil, 'missing_char'
end
-- Kein IC-Mail → Registrierung nicht möglich, iFruit fehlt
if not account and reason == 'no_mail' then
return buildRegistrationPayload(source, nil, 'no_mail'), nil
end
-- Allgemeiner Fehler
if not account then
return nil, reason
end
-- Account ok, aber kein Privatprofil → Registrierungsmaske
if reason == 'needs_registration' then
return buildRegistrationPayload(source, account, 'needs_registration'), nil
end
local activeProfile, profiles = activeProfileForSource(source, account)
if not activeProfile then
return nil, 'missing_profile'
end
local lifeinvaderPermissions = {
can_hide_posts = BleeterPermissions.HasLifeinvaderPermission(source, 'post.hide'),
can_verify = BleeterPermissions.HasLifeinvaderPermission(source, 'profile.verify'),
can_staff = BleeterPermissions.HasLifeinvaderPermission(source, 'profile.staff'),
can_lock = BleeterPermissions.HasLifeinvaderPermission(source, 'profile.lock')
}
local includeLockedProfiles = lifeinvaderPermissions.can_lock == true
return {
account = {
charId = account.char_id,
identifier = account.identifier,
displayName = account.displayName,
mailAddress = account.mailAddress,
phoneNumber = account.phoneNumber
},
config = {
navigation = Config.Navigation,
media = Config.Media
},
profiles = profiles,
profileDirectory = loadProfileDirectory(activeProfile.id, includeLockedProfiles),
activeProfileId = activeProfile.id,
posts = loadPosts(activeProfile.id),
businesses = loadBusinesses(activeProfile.id),
marketplace = loadMarketplace(activeProfile.id),
calendarDays = loadCalendarDays(activeProfile.id),
social = loadSocial(activeProfile.id),
suggestions = {},
trends = {
{ tag = '#gruppe6', posts = 77 },
{ tag = '#lalintera', posts = 43 },
{ tag = '#JoinTheTeam', posts = 39 },
{ tag = '#LSFD', posts = 39 }
},
users = loadUsers(activeProfile.id, includeLockedProfiles),
permissions = {
lifeinvader = lifeinvaderPermissions
}
}
end
local function pushData(source)
local data, reason = buildBootstrap(source)
if not data then
notify(source, ('Bleeter konnte nicht geladen werden: %s'):format(reason or 'unbekannt'), 'error')
TriggerClientEvent('bleeter:client:data', source, { ok = false, reason = reason })
return
end
TriggerClientEvent('bleeter:client:data', source, { ok = true, data = data })
end
local function getActiveProfile(source)
local account = ensureAccount(source)
if not account then return nil end
local activeProfile = activeProfileForSource(source, account)
return activeProfile, account
end
local function writeAudit(account, actorProfile, action, targetType, targetId, reason, payload)
local payloadJson = nil
if payload and json and json.encode then
payloadJson = json.encode(payload)
end
MySQL.insert.await([[
INSERT INTO bleeter_audit_logs (actor_char_id, actor_profile_id, action, target_type, target_id, reason, payload)
VALUES (?, ?, ?, ?, ?, ?, ?)
]], {
account and account.char_id or nil,
actorProfile and actorProfile.id or nil,
action,
targetType,
targetId,
reason,
payloadJson
})
end
local function findTargetProfile(payload, includeLocked)
local targetId = tonumber(payload and payload.profileId)
local targetHandle = trim(payload and payload.handle, 40):lower():gsub('^@', '')
local lockFilter = includeLocked and '' or ' AND is_locked = 0'
if targetId then
return MySQL.single.await(
('SELECT id, handle, display_name, profile_type, is_verified, is_lifeinvader_staff, is_locked FROM bleeter_profiles WHERE id = ? AND is_active = 1%s'):format(lockFilter),
{ targetId }
)
end
if targetHandle ~= '' then
return MySQL.single.await(
('SELECT id, handle, display_name, profile_type, is_verified, is_lifeinvader_staff, is_locked FROM bleeter_profiles WHERE handle = ? AND is_active = 1%s'):format(lockFilter),
{ targetHandle }
)
end
return nil
end
--- Fehlercodes der Bildpruefung in Klartext.
--- 'missing_extension' sagt niemandem etwas gemeint ist fast immer, dass
--- jemand die Adresse der Bildseite kopiert hat statt der des Bildes.
local function mediaErrorText(reason)
local texts = {
url_must_be_https = 'Die Bildadresse muss mit https:// beginnen.',
missing_extension = 'Das ist keine direkte Bildadresse. Sie muss auf '
.. '.jpg oder .png enden bei imgur & Co. mit Rechtsklick '
.. 'aufs Bild "Bildadresse kopieren".',
unsupported_extension = 'Nur JPG und PNG sind erlaubt.',
unsupported_mime = 'Diese Bildart wird nicht unterstuetzt.',
invalid_payload = 'Ungueltige Daten.',
}
return texts[reason] or ('Bild-URL abgelehnt: ' .. tostring(reason))
end
RegisterNetEvent('bleeter:server:requestBootstrap', function()
pushData(source)
end)
-- ── Handle-Verfügbarkeit prüfen ───────────────────────────────────────────────
RegisterNetEvent('bleeter:server:checkHandle', function(payload)
local src = source
local handle = trim(payload and payload.handle, 40):lower()
if handle == '' or #handle < Config.Handles.minLength then
TriggerClientEvent('bleeter:client:data', src, { ok = true, data = { action = 'handleCheck', available = false, reason = 'too_short' } })
return
end
if #handle > Config.Handles.maxLength then
TriggerClientEvent('bleeter:client:data', src, { ok = true, data = { action = 'handleCheck', available = false, reason = 'too_long' } })
return
end
if not handle:match(Config.Handles.pattern) then
TriggerClientEvent('bleeter:client:data', src, { ok = true, data = { action = 'handleCheck', available = false, reason = 'invalid_chars' } })
return
end
-- Reservierte Handles prüfen
local reserved = MySQL.single.await('SELECT 1 FROM bleeter_reserved_handles WHERE handle = ? LIMIT 1', { handle })
if reserved then
TriggerClientEvent('bleeter:client:data', src, { ok = true, data = { action = 'handleCheck', available = false, reason = 'reserved' } })
return
end
-- Vergabe prüfen
local existing = MySQL.single.await('SELECT 1 FROM bleeter_profiles WHERE handle = ? LIMIT 1', { handle })
TriggerClientEvent('bleeter:client:data', src, { ok = true, data = { action = 'handleCheck', available = not existing, reason = existing and 'handle_taken' or nil } })
end)
-- ── Account registrieren ──────────────────────────────────────────────────────
RegisterNetEvent('bleeter:server:registerAccount', function(payload)
local src = source
local handle = trim(payload and payload.handle, 40):lower()
local displayName = trim(payload and payload.displayName, 80)
-- Handle validieren
if #handle < Config.Handles.minLength or #handle > Config.Handles.maxLength then
notify(src, 'Ungültige Handle-Länge.', 'error'); return
end
if not handle:match(Config.Handles.pattern) then
notify(src, 'Handle enthält ungültige Zeichen (nur a-z, 0-9, . _ - erlaubt).', 'error'); return
end
if displayName == '' then displayName = handle end
-- IC-Mail prüfen
local identity = IfruitAdapter.GetAccountIdentity(src)
local charId = trim(identity.charId, 80)
local mailAddress = trim(identity.mailAddress or '', 120):lower()
if charId == '' or mailAddress == '' then
notify(src, 'Kein IC-iFruit-Konto gefunden. Bitte zuerst das Telefon einrichten.', 'error'); return
end
-- Account anlegen / aktualisieren
MySQL.insert.await([[
INSERT INTO bleeter_accounts (char_id, identifier, mail_address, phone_number)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
identifier = VALUES(identifier),
mail_address = VALUES(mail_address)
]], { charId, trim(identity.identifier, 80), mailAddress, trim(identity.phoneNumber or '', 20) or nil })
local account = MySQL.single.await('SELECT * FROM bleeter_accounts WHERE char_id = ?', { charId })
if not account then
notify(src, 'Registrierung fehlgeschlagen. Bitte erneut versuchen.', 'error'); return
end
-- Reserviert?
local reserved = MySQL.single.await('SELECT 1 FROM bleeter_reserved_handles WHERE handle = ? LIMIT 1', { handle })
if reserved then
TriggerClientEvent('bleeter:client:data', src, { ok = true, data = { action = 'registerError', reason = 'reserved' } }); return
end
-- Privatprofil anlegen (UNIQUE KEY schützt gegen Race-Conditions)
local insertId = nil
local success = true
local pOk, pErr = pcall(function()
insertId = MySQL.insert.await([[
INSERT INTO bleeter_profiles
(account_id, profile_type, handle, display_name, email_contact, bio, owner_source, created_by_char_id)
VALUES (?, 'private', ?, ?, ?, '', 'personal', ?)
]], { account.id, handle, displayName, mailAddress, charId })
end)
if not pOk or not insertId then
-- UNIQUE-Verletzung = Handle bereits vergeben
local existingHandle = MySQL.single.await('SELECT 1 FROM bleeter_profiles WHERE handle = ? LIMIT 1', { handle })
if existingHandle then
TriggerClientEvent('bleeter:client:data', src, { ok = true, data = { action = 'registerError', reason = 'handle_taken' } }); return
end
notify(src, 'Registrierung fehlgeschlagen: ' .. tostring(pErr), 'error'); return
end
-- registered_at setzen
MySQL.update('UPDATE bleeter_accounts SET registered_at = NOW(), registration_mail = ? WHERE id = ?', { mailAddress, account.id })
-- Willkommensmail
sendBleeterMail(mailAddress,
'🐦 Willkommen bei Lifeinvader Bleeter!',
('Hallo %s,\n\ndein Bleeter-Account wurde erfolgreich registriert.\n\nDein Handle: @%s\n\nViel Spaß auf der Plattform!\n\n Lifeinvader Bleeter'):format(displayName, handle)
)
debugPrint(('Neuer Account: @%s (%s)'):format(handle, mailAddress))
pushData(src)
end)
RegisterNetEvent('bleeter:server:setActiveProfile', function(payload)
local source = source
local account = ensureAccount(source)
if not account then return end
local profileId = tonumber(payload and payload.profileId)
local profiles = getAccessibleProfiles(account)
if profileId and findProfile(profiles, profileId) then
activeProfileBySource[source] = profileId
debugPrint(('source %s active profile %s'):format(source, profileId))
end
pushData(source)
end)
RegisterNetEvent('bleeter:server:followProfile', function(payload)
local source = source
local profile = getActiveProfile(source)
local target = findTargetProfile(payload)
if not profile or not target then return end
if tonumber(profile.id) == tonumber(target.id) then
notify(source, 'Du kannst dir nicht selbst folgen.', 'error')
return
end
MySQL.insert.await(
'INSERT IGNORE INTO bleeter_follows (follower_profile_id, followed_profile_id) VALUES (?, ?)',
{ profile.id, target.id }
)
pushData(source)
end)
RegisterNetEvent('bleeter:server:unfollowProfile', function(payload)
local source = source
local profile = getActiveProfile(source)
local target = findTargetProfile(payload)
if not profile or not target then return end
MySQL.update.await(
'DELETE FROM bleeter_follows WHERE follower_profile_id = ? AND followed_profile_id = ?',
{ profile.id, target.id }
)
pushData(source)
end)
RegisterNetEvent('bleeter:server:blockProfile', function(payload)
local source = source
local profile = getActiveProfile(source)
local target = findTargetProfile(payload)
if not profile or not target then return end
if tonumber(profile.id) == tonumber(target.id) then
notify(source, 'Du kannst dich nicht selbst blockieren.', 'error')
return
end
if not canProfileBeBlocked(target.profile_type, target.is_lifeinvader_staff) then
notify(source, 'Dieses Profil kann nicht blockiert werden.', 'error')
return
end
MySQL.insert.await(
'INSERT IGNORE INTO bleeter_blocks (blocker_profile_id, blocked_profile_id) VALUES (?, ?)',
{ profile.id, target.id }
)
MySQL.update.await([[
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 })
pushData(source)
end)
RegisterNetEvent('bleeter:server:moderateProfile', function(payload)
local source = source
local profile, account = getActiveProfile(source)
local action = trim(payload and payload.action, 32)
local target = findTargetProfile(payload, true)
if not profile or not account or not target then return end
local permissionByAction = {
verify = 'profile.verify',
staff = 'profile.staff',
lock = 'profile.lock'
}
local permission = permissionByAction[action]
if not permission or not BleeterPermissions.HasLifeinvaderPermission(source, permission) then
notify(source, 'Du hast dafuer keine Lifeinvader-Rechte.', 'error')
return
end
if action == 'lock' and tonumber(profile.id) == tonumber(target.id) then
notify(source, 'Du kannst dein aktives Profil nicht sperren.', 'error')
return
end
if action == 'verify' then
local nextValue = not decodeBool(target.is_verified)
MySQL.update.await('UPDATE bleeter_profiles SET is_verified = ? WHERE id = ?', { nextValue and 1 or 0, target.id })
writeAudit(account, profile, nextValue and 'profile.verify' or 'profile.unverify', 'profile', target.id, nil, {
handle = target.handle,
is_verified = nextValue
})
notify(source, nextValue and 'Profil verifiziert.' or 'Verifizierung entfernt.', 'success')
elseif action == 'staff' then
local nextValue = not decodeBool(target.is_lifeinvader_staff)
MySQL.update.await('UPDATE bleeter_profiles SET is_lifeinvader_staff = ? WHERE id = ?', { nextValue and 1 or 0, target.id })
writeAudit(account, profile, nextValue and 'profile.staff.add' or 'profile.staff.remove', 'profile', target.id, nil, {
handle = target.handle,
is_lifeinvader_staff = nextValue
})
notify(source, nextValue and 'Lifeinvader-Mitarbeiter markiert.' or 'Lifeinvader-Mitarbeiter entfernt.', 'success')
elseif action == 'lock' then
local nextValue = not decodeBool(target.is_locked)
MySQL.update.await('UPDATE bleeter_profiles SET is_locked = ?, locked_reason = ? WHERE id = ?', {
nextValue and 1 or 0,
nextValue and 'Lifeinvader moderation' or nil,
target.id
})
writeAudit(account, profile, nextValue and 'profile.lock' or 'profile.unlock', 'profile', target.id, nil, {
handle = target.handle,
is_locked = nextValue
})
notify(source, nextValue and 'Profil gesperrt.' or 'Profil entsperrt.', 'success')
end
pushData(source)
end)
RegisterNetEvent('bleeter:server:unblockProfile', function(payload)
local source = source
local profile = getActiveProfile(source)
local target = findTargetProfile(payload)
if not profile or not target then return end
MySQL.update.await(
'DELETE FROM bleeter_blocks WHERE blocker_profile_id = ? AND blocked_profile_id = ?',
{ profile.id, target.id }
)
pushData(source)
end)
RegisterNetEvent('bleeter:server:updateProfile', function(payload)
local source = source
local profile = getActiveProfile(source)
if not profile then return end
if not profile.can_edit_profile then
notify(source, 'Du darfst dieses Profil nicht bearbeiten.', 'error')
return
end
local displayName = trim(payload and payload.displayName, 80)
local avatarUrl = trim(payload and payload.avatarUrl, 255)
local bannerUrl = trim(payload and payload.bannerUrl, 255)
local bioMax = profile.profile_type == 'private' and 200 or 500
local bio = trim(payload and payload.bio, bioMax)
if displayName == '' then
notify(source, 'Der Profilname darf nicht leer sein.', 'error')
return
end
MySQL.update.await([[
UPDATE bleeter_profiles
SET display_name = ?, avatar_url = ?, banner_url = ?, bio = ?
WHERE id = ?
]], {
displayName,
avatarUrl ~= '' and avatarUrl or nil,
bannerUrl ~= '' and bannerUrl or nil,
bio,
profile.id
})
pushData(source)
end)
RegisterNetEvent('bleeter:server:createPost', function(payload)
local source = source
local profile = getActiveProfile(source)
if not profile then return end
local feedType = trim(payload and payload.feedType, 24)
local body = trim(payload and payload.body, 2000)
local mediaUrl = trim(payload and payload.mediaUrl, 500)
local mediaId = nil
if body == '' and mediaUrl == '' then
notify(source, 'Dein Post ist leer.', 'error')
return
end
if feedType == 'home' then feedType = 'home' end
if feedType == 'advertising' then feedType = 'advertising' end
if feedType ~= 'home' and feedType ~= 'advertising' then
notify(source, 'Dieser Feed existiert nicht.', 'error')
return
end
if not BleeterPermissions.CanPost(profile.profile_type, feedType) then
notify(source, 'Dieses Profil darf hier nicht posten.', 'error')
return
end
if mediaUrl ~= '' then
local ok, reason = MediaAdapter.IsAllowedExternalUrl(mediaUrl)
if not ok then
notify(source, mediaErrorText(reason), 'error')
return
end
mediaId = MySQL.insert.await([[
INSERT INTO bleeter_media (owner_profile_id, source_type, url)
VALUES (?, ?, ?)
]], { profile.id, 'external_url', mediaUrl })
end
MySQL.insert.await(
'INSERT INTO bleeter_posts (author_profile_id, feed_type, body, media_id) VALUES (?, ?, ?, ?)',
{ profile.id, feedType, body, mediaId }
)
pushData(source)
end)
RegisterNetEvent('bleeter:server:uploadMedia', function(payload)
local source = source
local profile = getActiveProfile(source)
if not profile then return end
MediaAdapter.Upload(payload or {}, function(ok, result)
if not ok then
notify(source, ('Upload fehlgeschlagen: %s'):format(result or 'unbekannt'), 'error')
TriggerClientEvent('bleeter:client:mediaUploaded', source, {
ok = false,
reason = result,
target = payload and payload.target
})
return
end
MySQL.insert([[
INSERT INTO bleeter_media (owner_profile_id, source_type, url, original_name, mime_type, size_bytes)
VALUES (?, ?, ?, ?, ?, ?)
]], { profile.id, 'upload', result.url, result.name, result.mime, result.size })
TriggerClientEvent('bleeter:client:mediaUploaded', source, {
ok = true,
url = result.url,
target = payload and payload.target
})
end)
end)
RegisterNetEvent('bleeter:server:togglePostLike', function(payload)
local source = source
local profile = getActiveProfile(source)
local postId = tonumber(payload and payload.postId)
if not profile or not postId then return end
local existing = MySQL.single.await(
'SELECT id FROM bleeter_likes WHERE profile_id = ? AND target_type = ? AND target_id = ?',
{ profile.id, 'post', postId }
)
if existing then
MySQL.update.await('DELETE FROM bleeter_likes WHERE id = ?', { existing.id })
else
MySQL.insert.await(
'INSERT IGNORE INTO bleeter_likes (profile_id, target_type, target_id) VALUES (?, ?, ?)',
{ profile.id, 'post', postId }
)
end
pushData(source)
end)
RegisterNetEvent('bleeter:server:deleteOwnPost', function(payload)
local source = source
local profile = getActiveProfile(source)
local postId = tonumber(payload and payload.postId)
if not profile or not postId then return end
local post = MySQL.single.await([[
SELECT author_profile_id
FROM bleeter_posts
WHERE id = ? AND self_deleted_at IS NULL AND deleted_at IS NULL
]], { postId })
if not post then
notify(source, 'Dieser Post existiert nicht mehr.', 'error')
return
end
if tonumber(post.author_profile_id) ~= tonumber(profile.id) then
notify(source, 'Du kannst nur eigene Posts loeschen.', 'error')
return
end
MySQL.update.await('UPDATE bleeter_posts SET self_deleted_at = NOW() WHERE id = ?', { postId })
pushData(source)
end)
RegisterNetEvent('bleeter:server:createComment', function(payload)
local source = source
local profile = getActiveProfile(source)
local postId = tonumber(payload and payload.postId)
local body = trim(payload and payload.body, 1000)
if not profile or not postId then return end
if body == '' then
notify(source, 'Dein Kommentar ist leer.', 'error')
return
end
local post = MySQL.single.await([[
SELECT id FROM bleeter_posts
WHERE id = ?
AND self_deleted_at IS NULL
AND hidden_at IS NULL
AND deleted_at IS NULL
]], { postId })
if not post then
notify(source, 'Dieser Post ist nicht mehr sichtbar.', 'error')
return
end
MySQL.insert.await(
'INSERT INTO bleeter_comments (post_id, author_profile_id, body) VALUES (?, ?, ?)',
{ postId, profile.id, body }
)
pushData(source)
end)
RegisterNetEvent('bleeter:server:toggleCommentLike', function(payload)
local source = source
local profile = getActiveProfile(source)
local commentId = tonumber(payload and payload.commentId)
if not profile or not commentId then return end
local existing = MySQL.single.await(
'SELECT id FROM bleeter_likes WHERE profile_id = ? AND target_type = ? AND target_id = ?',
{ profile.id, 'comment', commentId }
)
if existing then
MySQL.update.await('DELETE FROM bleeter_likes WHERE id = ?', { existing.id })
else
MySQL.insert.await(
'INSERT IGNORE INTO bleeter_likes (profile_id, target_type, target_id) VALUES (?, ?, ?)',
{ profile.id, 'comment', commentId }
)
end
pushData(source)
end)
RegisterNetEvent('bleeter:server:deleteOwnComment', function(payload)
local source = source
local profile = getActiveProfile(source)
local commentId = tonumber(payload and payload.commentId)
if not profile or not commentId then return end
local comment = MySQL.single.await([[
SELECT author_profile_id
FROM bleeter_comments
WHERE id = ? AND self_deleted_at IS NULL AND deleted_at IS NULL
]], { commentId })
if not comment then
notify(source, 'Dieser Kommentar existiert nicht mehr.', 'error')
return
end
if tonumber(comment.author_profile_id) ~= tonumber(profile.id) then
notify(source, 'Du kannst nur eigene Kommentare loeschen.', 'error')
return
end
MySQL.update.await('UPDATE bleeter_comments SET self_deleted_at = NOW() WHERE id = ?', { commentId })
pushData(source)
end)
RegisterNetEvent('bleeter:server:createMarketplaceEntry', function(payload)
local source = source
local profile = getActiveProfile(source)
if not profile then return end
local title = trim(payload and payload.title, 120)
local description = trim(payload and payload.description, 2000)
local priceLabel = trim(payload and payload.priceLabel, 80)
local mediaUrl = trim(payload and payload.mediaUrl, 500)
local mediaId = nil
if title == '' and description == '' then
notify(source, 'Bitte gib deinem Inserat einen Titel.', 'error')
return
end
if description == '' then
description = title
end
if title == '' then
title = description:sub(1, 120)
end
if mediaUrl ~= '' then
local ok, reason = MediaAdapter.IsAllowedExternalUrl(mediaUrl)
if not ok then
notify(source, mediaErrorText(reason), 'error')
return
end
mediaId = MySQL.insert.await([[
INSERT INTO bleeter_media (owner_profile_id, source_type, url)
VALUES (?, ?, ?)
]], { profile.id, 'external_url', mediaUrl })
end
MySQL.insert.await([[
INSERT INTO bleeter_marketplace
(author_profile_id, category, title, description, price_label, media_id)
VALUES (?, ?, ?, ?, ?, ?)
]], { profile.id, 'general', title, description, priceLabel, mediaId })
pushData(source)
end)
RegisterNetEvent('bleeter:server:deleteMarketplaceEntry', function(payload)
local source = source
local profile = getActiveProfile(source)
local entryId = tonumber(payload and payload.entryId)
if not profile or not entryId then return end
local entry = MySQL.single.await(
'SELECT author_profile_id FROM bleeter_marketplace WHERE id = ? AND deleted_at IS NULL',
{ entryId }
)
if not entry then
notify(source, 'Dieses Inserat existiert nicht mehr.', 'error')
return
end
if tonumber(entry.author_profile_id) ~= tonumber(profile.id) then
notify(source, 'Du kannst nur eigene Inserate loeschen.', 'error')
return
end
MySQL.update.await('UPDATE bleeter_marketplace SET deleted_at = NOW(), status = ? WHERE id = ?', {
'deleted',
entryId
})
pushData(source)
end)
RegisterNetEvent('bleeter:server:createEvent', function(payload)
local source = source
local profile = getActiveProfile(source)
if not profile then return end
if profile.profile_type ~= 'small_business'
and profile.profile_type ~= 'company'
and profile.profile_type ~= 'authority' then
notify(source, 'Dieses Profil darf keine Kalendereintraege erstellen.', 'error')
return
end
local dateKey = trim(payload and payload.date, 10)
local timeText = trim(payload and payload.time, 5)
local title = trim(payload and payload.title, 50)
local location = trim(payload and payload.location, 50)
if not dateKey:match('^%d%d%d%d%-%d%d%-%d%d$') then
notify(source, 'Das Datum ist ungueltig.', 'error')
return
end
if not timeText:match('^%d%d:%d%d$') then
notify(source, 'Die Uhrzeit ist ungueltig.', 'error')
return
end
if title == '' then
notify(source, 'Bitte trage eine Veranstaltung ein.', 'error')
return
end
local startsAt = ('%s %s:00'):format(dateKey, timeText)
MySQL.insert.await([[
INSERT INTO bleeter_events (author_profile_id, title, location, starts_at)
VALUES (?, ?, ?, ?)
]], { profile.id, title, location ~= '' and location or nil, startsAt })
pushData(source)
end)
RegisterNetEvent('bleeter:server:deleteEvent', function(payload)
local source = source
local profile = getActiveProfile(source)
local eventId = tonumber(payload and payload.eventId)
if not profile or not eventId then return end
local event = MySQL.single.await(
'SELECT author_profile_id FROM bleeter_events WHERE id = ? AND deleted_at IS NULL',
{ eventId }
)
if not event then
notify(source, 'Dieser Termin existiert nicht mehr.', 'error')
return
end
if tonumber(event.author_profile_id) ~= tonumber(profile.id) then
notify(source, 'Nur der Ersteller kann diesen Termin loeschen.', 'error')
return
end
MySQL.update.await('UPDATE bleeter_events SET deleted_at = NOW(), status = ? WHERE id = ?', {
'deleted',
eventId
})
pushData(source)
end)
RegisterNetEvent('bleeter:server:openMailTo', function(payload)
local source = source
local targetMail = trim(payload and payload.mail)
local subject = trim(payload and payload.subject, 120)
if targetMail ~= '' then
MailAdapter.OpenCompose(source, targetMail, subject)
end
end)
RegisterCommand(Config.Command, function(source)
if source > 0 then
TriggerClientEvent('bleeter:client:open', source)
end
end, false)
AddEventHandler('playerDropped', function()
activeProfileBySource[source] = nil
end)
exports('SetBusinessStatus', function(profileHandle, status, sourceName)
local handle = trim(profileHandle, 40):lower()
local profile = MySQL.single.await('SELECT id FROM bleeter_profiles WHERE handle = ?', { handle })
if not profile then return false, 'profile_not_found' end
if status ~= 'open' and status ~= 'closed' then
return false, 'invalid_status'
end
MySQL.insert.await([[
INSERT INTO bleeter_business_status (profile_id, status, source)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE status = VALUES(status), source = VALUES(source)
]], { profile.id, status, trim(sourceName, 40) ~= '' and trim(sourceName, 40) or 'external' })
SuperPcAdapter.SetBusinessStatus(handle, status)
return true
end)
exports('SetProfileMember', function(profileHandle, charId, permissions, sourceName)
local handle = trim(profileHandle, 40):lower()
local memberCharId = trim(charId, 80)
if handle == '' or memberCharId == '' then
return false, 'invalid_input'
end
local profile = MySQL.single.await('SELECT id FROM bleeter_profiles WHERE handle = ?', { handle })
if not profile then return false, 'profile_not_found' end
local opts = permissions or {}
if opts.remove == true then
MySQL.update.await(
'DELETE FROM bleeter_profile_members WHERE profile_id = ? AND char_id = ?',
{ profile.id, memberCharId }
)
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 (?, ?, ?, ?, ?, ?, ?)
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)
]], {
profile.id,
memberCharId,
trim(opts.role, 40) ~= '' and trim(opts.role, 40) or 'member',
decodeBool(opts.can_post or opts.canPost) and 1 or 0,
decodeBool(opts.can_edit_profile or opts.canEditProfile) and 1 or 0,
decodeBool(opts.can_manage_members or opts.canManageMembers) and 1 or 0,
trim(sourceName, 40) ~= '' and trim(sourceName, 40) or 'external'
})
return true
end)
exports('SetLifeinvaderPermission', function(charId, permission, allowed, sourceName)
local targetCharId = trim(charId, 80)
local permissionName = trim(permission, 80)
if targetCharId == '' or permissionName == '' then
return false, 'invalid_input'
end
MySQL.insert.await([[
INSERT INTO bleeter_lifeinvader_permissions (char_id, permission, allowed, source)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE allowed = VALUES(allowed), source = VALUES(source)
]], {
targetCharId,
permissionName,
decodeBool(allowed) and 1 or 0,
trim(sourceName, 40) ~= '' and trim(sourceName, 40) or 'external'
})
return true
end)
-- ── Web-Zugang (bleeter.naturalbornplayers.de) ────────────────────────────────
-- Der Spieler setzt sein Web-Passwort ingame; das Passwort wird an das
-- Web-Backend (Node) geschickt und dort gehasht in bleeter_accounts abgelegt.
local function setWebPassword(src, password, done)
password = tostring(password or '')
if #password < 6 then
notify(src, 'Das Web-Passwort muss mindestens 6 Zeichen haben.', 'error')
if done then done(false) end
return
end
-- Account sicherstellen (legt bei Bedarf an, prueft IC-Mail)
local account, reason = ensureAccount(src)
if not account then
if reason == 'no_mail' then
notify(src, 'Kein IC-iFruit-Konto gefunden. Bitte zuerst das Telefon/Mail einrichten.', 'error')
else
notify(src, 'Bleeter-Account konnte nicht ermittelt werden.', 'error')
end
if done then done(false) end
return
end
local apiBase = GetConvar('bleeter_web_api', 'http://127.0.0.1:4091')
local internalKey = GetConvar('bleeter_internal_key', '')
if internalKey == '' then
notify(src, 'Web-Anbindung ist nicht konfiguriert (bleeter_internal_key fehlt).', 'error')
if done then done(false) end
return
end
PerformHttpRequest(apiBase .. '/internal/set-password', function(status, body, headers)
if status == 200 then
notify(src, 'Dein Bleeter-Web-Passwort wurde gesetzt. Login: bleeter.naturalbornplayers.de', 'success')
if done then done(true) end
else
debugPrint('setWebPassword failed', status, body)
notify(src, ('Web-Passwort konnte nicht gesetzt werden (Fehler %s).'):format(tostring(status)), 'error')
if done then done(false) end
end
end, 'POST', json.encode({ charId = account.char_id, password = password }), {
['Content-Type'] = 'application/json',
['x-internal-key'] = internalKey,
})
end
-- Command: /bleeterweb <passwort>
RegisterCommand('bleeterweb', function(source, args)
if source <= 0 then return end
local password = args and args[1]
if not password or password == '' then
notify(source, 'Nutzung: /bleeterweb <passwort> (min. 6 Zeichen)', 'error')
return
end
setWebPassword(source, password)
end, false)
-- NetEvent fuer eine spaetere NUI-Anbindung ("Web-Zugang" im Bleeter-Menue)
RegisterNetEvent('bleeter:server:setWebPassword', function(payload)
local src = source
setWebPassword(src, payload and payload.password, function(ok)
TriggerClientEvent('bleeter:client:webPasswordSet', src, { ok = ok })
end)
end)
-- ─────────────────────────────────────────────────────────────────────────────
-- Naht fuer server/management.lua
--
-- Die Hilfen oben sind absichtlich lokal. Die Verwaltung der Unternehmens-
-- profile steht in einer eigenen Datei, braucht aber dieselben Bausteine
-- statt sie dort noch einmal zu schreiben, werden sie hier gebuendelt
-- weitergereicht.
-- ─────────────────────────────────────────────────────────────────────────────
BleeterInternal = {
trim = trim,
notify = notify,
decodeBool = decodeBool,
pushData = pushData,
ensureAccount = ensureAccount,
writeAudit = writeAudit,
debugPrint = debugPrint,
}