bleeter/server/main.lua
Bjoern Flessing 84d0089207 Chatbefehl /bleeter entfernt
Bleeter gehoert in den IC-Computer: dort sitzt man an einem Geraet, statt
mitten auf der Strasse ein Fenster aufzuklappen. Ein Chatbefehl umgeht das und
macht die Oberflaeche ueberall verfuegbar.

Der Export OpenBleeter bleibt, damit andere Resources sie oeffnen koennen -
etwa ein Telefon. Config.Command faellt weg, ohne Befehl hat es keine Bedeutung.
2026-08-09 12:43:36 +00:00

1621 lines
59 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
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,
}