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