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.
1627 lines
59 KiB
Lua
1627 lines
59 KiB
Lua
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,
|
||
}
|