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