IC-Computer: Schreibtisch, Fenster und Apps

Ein Computer im Spiel. Man tritt an ein Terminal, drueckt E, und bekommt eine
Oberflaeche mit Mail, Browser, Adressbuch, Kalender und weiteren Anwendungen.
Andere Resources haengen sich als App ein.

Auf ESX umgestellt:

  - Fuenf harte Abhaengigkeiten zeigten auf Resources, die es hier nicht gibt.
    Eine fehlende Abhaengigkeit verhindert den Start vollstaendig - die
    Resource waere nie hochgekommen.
  - Die Bruecke zum Framework neu geschrieben; Charakterdaten, Aktenverwaltung
    und Immobilien ausgebaut, weil die zugehoerigen Systeme fehlen.
  - pc_live_devices fehlte im Schema, wird vom Code aber gelesen. Ohne die
    Tabelle ist kein fester PC ansprechbar. Ein PC braucht ausserdem zwei
    Eintraege mit derselben Kennung: Standort in der Datenbank, Kamerafahrt in
    der config.lua.

Mail ueberarbeitet:

  - Postfaecher haengen an einer Anmeldung statt an der Person. Mehrere Leute
    koennen dasselbe Firmenpostfach gleichzeitig offen haben.
  - Ordner gehoeren zum Postfach, nicht zur Person. Eine Mail an ein geteiltes
    Postfach wird je Empfaenger einmal gespeichert; damit das Einsortieren
    trotzdem fuer alle gilt, tragen alle Kopien einer Zustellung dieselbe
    Kennung. Gelesen und geloescht bleibt persoenlich - das ist keine
    Eigenschaft der Nachricht, sondern der Person.
  - Signaturen gehoeren ebenfalls zum Postfach.
  - Kalender koennen privat, geteilt oder oeffentlich sein. Ein oeffentlicher
    Termin haengt bewusst auch an einem Postfach, sonst koennte ihn spaeter
    niemand mehr aendern oder absagen.

Neue Apps: Webhosting, Bleeter und dessen Verwaltung.

Behoben:

  - prompt(), confirm() und alert() froren das NUI ein. FiveMs CEF hat keinen
    Handler fuer die eingebauten Browserdialoge - der Aufruf oeffnet nichts und
    kehrt nie zurueck. Ersetzt durch eigene Dialoge.
  - Die Fenster tragen data-wid, keine id. Wer sie mit getElementById sucht,
    schreibt ins Leere und das Fenster bleibt leer.

Enthaelt README.md mit Einrichtung und PLUGINS.md: eine Anleitung, wie man eine
eigene App baut und einhaengt, mit vollstaendigem Beispiel von der Tabelle bis
zum Schreibtischsymbol.
This commit is contained in:
Bjoern Flessing 2026-08-09 12:02:11 +00:00
commit be502d2834
46 changed files with 13119 additions and 0 deletions

447
server/apps.lua Normal file
View file

@ -0,0 +1,447 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- App Registry manages registered apps, installation & export
-- ─────────────────────────────────────────────────────────────────────────────
AppRegistry = {}
local _registry = {} -- app_id -> manifest
--- Register an app with the system.
---@param manifest table
function AppRegistry.Register(manifest)
if not manifest or not manifest.app_id then
Utils.Warn("AppRegistry.Register: invalid manifest (missing app_id)")
return false
end
if _registry[manifest.app_id] then
Utils.Warn(("AppRegistry: app '%s' already registered, overwriting"):format(manifest.app_id))
end
_registry[manifest.app_id] = manifest
Utils.Log(("Registered app: %s v%s"):format(manifest.app_id, manifest.version or "?"))
return true
end
--- Get the full registry sorted (default/system first, then alphabetically).
---@return table[]
function AppRegistry.GetAll()
local list = {}
for _, m in pairs(_registry) do
list[#list + 1] = m
end
table.sort(list, function(a, b)
if (a.default or false) ~= (b.default or false) then
return (a.default and true or false)
end
return (a.name or "") < (b.name or "")
end)
return list
end
--- Get a single app manifest by id.
---@param appId string
---@return table?
function AppRegistry.Get(appId)
return _registry[appId]
end
--- Check if an app is registered.
---@param appId string
---@return boolean
function AppRegistry.Exists(appId)
return _registry[appId] ~= nil
end
--- Combine registry with per-user install data (for store listing).
---@param installs table[] rows from pc_live_installs
---@return table[]
function AppRegistry.BuildStoreList(installs)
local installedMap = {}
for _, row in ipairs(installs) do
installedMap[row.app_id] = row.version
end
local list = AppRegistry.GetAll()
for _, app in ipairs(list) do
app.installed = installedMap[app.app_id] ~= nil
app.installed_version = installedMap[app.app_id]
app.update_available = app.installed and (app.installed_version ~= app.version)
end
return list
end
--- Build list of currently installed apps for the desktop.
---@param installs table[]
---@return table[]
function AppRegistry.BuildInstalledList(installs)
local list = {}
for _, row in ipairs(installs) do
local manifest = _registry[row.app_id]
if manifest then
local entry = {}
for k, v in pairs(manifest) do entry[k] = v end
entry.installed_version = row.version
list[#list + 1] = entry
end
end
return list
end
-- ── Export for external resources ──────────────────────────────────────────
exports('RegisterApp', function(manifest)
return AppRegistry.Register(manifest)
end)
-- ── Register Built-In Apps ─────────────────────────────────────────────────
-- NOTE: icon field uses the emoji directly so NUI can display it without a
-- separate lookup table. Add dependencies = {} on every app.
-- ──────────────────────────────────────────────────────────────────────────
CreateThread(function()
Wait(0) -- Let other resources load first
-- ┌─────────────────────────────────────────────┐
-- │ SYSTEM (pre-installed, cannot remove) │
-- └─────────────────────────────────────────────┘
AppRegistry.Register({
app_id = "pc.browser",
name = "Browser",
icon = "🌐",
version = "1.0.0",
category = "system",
permissions = {},
dependencies = {},
default = true,
description = "Browse internal IC websites.",
price = 0,
})
AppRegistry.Register({
app_id = "pc.mail",
name = "Mail",
icon = "",
version = "2.0.0",
category = "system",
permissions = {},
dependencies = {},
default = true,
description = "E-Mail, Ordner, Kalender alles in einer App.",
price = 0,
})
AppRegistry.Register({
app_id = "pc.addressbook",
name = "Adressbuch",
icon = "👤",
version = "1.0.0",
category = "system",
permissions = {},
dependencies = {},
default = true,
description = "Kontakte verwalten Name, E-Mail, Telefon. Direkt mit Mail verknüpft.",
price = 0,
})
AppRegistry.Register({
app_id = "pc.store",
name = "Software Store",
icon = "🛒",
version = "1.0.0",
category = "system",
permissions = {},
dependencies = {},
default = true,
description = "Browse and install software.",
price = 0,
})
-- Webhosting (ic-web). Vorinstalliert, weil die Anmeldung ohnehin
-- entscheidet, wer hier etwas tun kann ohne Zugang sieht man nur die
-- Anmeldemaske.
AppRegistry.Register({
app_id = "pc.webhosting",
name = "Webhosting",
icon = "🌍",
version = "1.0.0",
category = "system",
permissions = {},
dependencies = {},
default = true,
description = "IC-Domänen und Webseiten verwalten. Anmeldung erforderlich.",
price = 0,
})
-- Bleeter selbst, eingebettet als Fenster im PC.
AppRegistry.Register({
app_id = "pc.bleeter",
name = "Bleeter",
icon = "🐦",
version = "1.0.0",
category = "system",
permissions = {},
dependencies = {},
default = true,
description = "Bleeter Feed, Werbung, Gewerbe und Profil.",
price = 0,
})
-- Bleeter-Verwaltung. Ohne Anmeldung sieht man nur die Anmeldemaske,
-- deshalb vorinstalliert wie das Webhosting.
AppRegistry.Register({
app_id = "pc.bleeteradmin",
name = "Bleeter-Verwaltung",
icon = "🐦",
version = "1.0.0",
category = "system",
permissions = {},
dependencies = {},
default = true,
description = "Unternehmensprofile auf Bleeter einrichten und Mitarbeiter freigeben.",
price = 0,
})
-- ┌─────────────────────────────────────────────┐
-- │ UTILITY │
-- └─────────────────────────────────────────────┘
AppRegistry.Register({
app_id = "pc.terminal",
name = "Terminal",
icon = "",
version = "1.3.0",
category = "utility",
permissions = {},
dependencies = {},
default = false,
description = "Advanced command-line interface for power users.",
price = 0,
})
AppRegistry.Register({
app_id = "pc.notes",
name = "Notepad Pro",
icon = "📝",
version = "1.0.0",
category = "utility",
permissions = {},
dependencies = {},
default = false,
description = "Text editor with rich formatting and local storage.",
price = 299,
})
AppRegistry.Register({
app_id = "pc.calculator",
name = "Calculator",
icon = "🔢",
version = "1.0.0",
category = "utility",
permissions = {},
dependencies = {},
default = false,
description = "Scientific calculator with unit conversion.",
price = 0,
})
-- ┌─────────────────────────────────────────────┐
-- │ COMMUNICATION │
-- └─────────────────────────────────────────────┘
AppRegistry.Register({
app_id = "pc.chat",
name = "IC Chat",
icon = "💬",
version = "2.0.0",
category = "communication",
permissions = {},
dependencies = {},
default = false,
description = "In-character instant messaging platform.",
price = 0,
})
AppRegistry.Register({
app_id = "pc.voip",
name = "VoIP Client",
icon = "📞",
version = "1.1.0",
category = "communication",
permissions = {},
dependencies = {},
default = false,
description = "Encrypted voice-over-IP calls via IC network.",
price = 499,
})
-- ┌─────────────────────────────────────────────┐
-- │ SECURITY │
-- └─────────────────────────────────────────────┘
AppRegistry.Register({
app_id = "pc.vpn",
name = "SecureVPN",
icon = "🔒",
version = "2.0.0",
category = "security",
permissions = {},
dependencies = {},
default = false,
description = "Encrypt your connection and mask your IP on IC networks.",
price = 1499,
})
AppRegistry.Register({
app_id = "pc.firewall",
name = "Firewall Pro",
icon = "🛡",
version = "1.1.0",
category = "security",
permissions = {},
dependencies = {},
default = false,
description = "Block unauthorized access to your system.",
price = 999,
})
-- ┌─────────────────────────────────────────────┐
-- │ BUSINESS │
-- └─────────────────────────────────────────────┘
AppRegistry.Register({
app_id = "pc.finance",
name = "Finance Manager",
icon = "💰",
version = "1.0.0",
category = "business",
permissions = {},
dependencies = {},
default = false,
description = "Track income, expenses and bank account balances.",
price = 4999,
})
-- ┌─────────────────────────────────────────────┐
-- │ GOVERNMENT (job-locked) │
-- └─────────────────────────────────────────────┘
AppRegistry.Register({
app_id = "pc.police.mdw",
name = "Police MDW",
icon = "🔍",
version = "3.0.1",
category = "government",
permissions = { job = "police" },
dependencies = {},
default = false,
description = "Mobile Data Workstation: criminal records, warrants, BOLO alerts.",
price = 0,
})
AppRegistry.Register({
app_id = "pc.police.dispatch",
name = "Dispatch Board",
icon = "🚔",
version = "2.1.0",
category = "government",
permissions = { job = "police" },
dependencies = {},
default = false,
description = "Live dispatch board for law enforcement units.",
price = 0,
})
AppRegistry.Register({
app_id = "pc.ems",
name = "EMS Dashboard",
icon = "🏥",
version = "1.2.0",
category = "government",
permissions = { job = "ambulance" },
dependencies = {},
default = false,
description = "Patient management and ambulance dispatch system.",
price = 0,
})
-- ┌─────────────────────────────────────────────┐
-- │ ENTERTAINMENT │
-- └─────────────────────────────────────────────┘
AppRegistry.Register({
app_id = "pc.music",
name = "TuneStream",
icon = "🎵",
version = "1.0.0",
category = "entertainment",
permissions = {},
dependencies = {},
default = false,
description = "Stream in-character radio stations and playlists.",
price = 999,
})
-- ┌─────────────────────────────────────────────┐
-- │ PARKING JOB │
-- └─────────────────────────────────────────────┘
AppRegistry.Register({
app_id = "pc.parkuhr",
name = "Parkuhr Dashboard",
icon = "🅿",
version = "1.0.0",
category = "government",
permissions = { job = "parking" },
dependencies = {},
default = false,
description = "Parking management dashboard device overview, revenue tracking and tariff management.",
price = 0,
})
-- ┌─────────────────────────────────────────────┐
-- │ IC-VERWALTUNG (Behörden) │
-- └─────────────────────────────────────────────┘
AppRegistry.Register({
app_id = "pc.ic_verwaltung",
name = "IC-Verwaltung",
icon = "🏛",
version = "1.0.0",
category = "government",
permissions = { jobs = { "police", "ambulance", "dpa", "fire", "doj" } },
dependencies = {},
default = false,
description = "Zugang zum IC-Verwaltungsportal. Nur für Behörden: PD, MD, DPA, FD, DOJ.",
price = 0,
})
-- ┌─────────────────────────────────────────────┐
-- │ GESETZBUCH (öffentliche Gesetze-Übersicht) │
-- └─────────────────────────────────────────────┘
AppRegistry.Register({
app_id = "pc.gesetzbuch",
name = "Gesetzbuch",
icon = "📖",
version = "1.0.0",
category = "government",
permissions = {},
dependencies = {},
default = false,
description = "Öffentliche Übersicht aller Gesetzbücher und Paragraphen des Bundesstaates San Andreas ohne Login einsehbar.",
price = 0,
})
-- ┌─────────────────────────────────────────────┐
-- │ BLEETER │
-- └─────────────────────────────────────────────┘
-- ┌─────────────────────────────────────────────┐
-- │ DARKNET (requires VPN) │
-- └─────────────────────────────────────────────┘
AppRegistry.Register({
app_id = "pc.darkbrowser",
name = "Dark Browser",
icon = "🕵",
version = "0.9.0",
category = "darknet",
permissions = {},
dependencies = { "pc.vpn" },
default = false,
description = "Access the hidden IC darknet. Requires SecureVPN. Use at your own risk.",
price = 2499,
})
end)

183
server/calendar.lua Normal file
View file

@ -0,0 +1,183 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- Kalender
--
-- Drei Arten von Terminen:
--
-- privat gehoert einer Person, sonst sieht ihn niemand
-- geteilt gehoert einem Postfach, sichtbar fuer alle, die es bedienen
-- oeffentlich gehoert einem Postfach, sichtbar fuer jeden auf dem Server
--
-- Geteilt wird ueber dasselbe Mittel wie Postfaecher und Ordner: die Adresse.
-- Ein oeffentlicher Termin haengt bewusst auch an einem Postfach sonst gaebe
-- es niemanden, der ihn spaeter aendern oder absagen koennte.
-- ─────────────────────────────────────────────────────────────────────────────
--- Alle Postfaecher, die diese Person bedienen darf.
---@return string[]
local function MailboxesOf(identifier)
if GetResourceState('ic-mail') ~= 'started' then return {} end
local out = {}
for _, mb in ipairs(exports['ic-mail']:GetAddressesForIdentifier(identifier) or {}) do
if mb.address then out[#out + 1] = mb.address end
end
return out
end
local function CanUseMailbox(identifier, address)
if address == '' then return true end
for _, addr in ipairs(MailboxesOf(identifier)) do
if addr == address then return true end
end
return false
end
--- Darf diese Person den Termin aendern?
--- Privat: nur wer ihn angelegt hat. Postfach: wer das Postfach bedient
--- auch wenn jemand anderes ihn eingetragen hat. Genau das macht ihn geteilt.
local function CanEditEvent(identifier, event)
if not event then return false end
if event.address and event.address ~= '' then
return CanUseMailbox(identifier, event.address)
end
return event.identifier == identifier
end
--- Eingaben pruefen und in die Form bringen, die die Datenbank erwartet.
--- Gibt (daten, adresse, sichtbarkeit) oder nil zurueck.
local function ReadEvent(identifier, data)
if type(data) ~= 'table' then return nil end
local d = {
title = Utils.Sanitize(tostring(data.title or "")):sub(1, 120),
description = Utils.Sanitize(tostring(data.description or "")):sub(1, 500),
start_at = Utils.Sanitize(tostring(data.start_at or "")):sub(1, 20),
end_at = data.end_at and Utils.Sanitize(tostring(data.end_at)):sub(1, 20) or nil,
color = Utils.Sanitize(tostring(data.color or "#00aaff")):sub(1, 20),
}
if #d.title < 1 or #d.start_at < 1 then return nil end
local address = Utils.Sanitize(tostring(data.address or '')):sub(1, 200)
if not CanUseMailbox(identifier, address) then
-- Kein Zugriff auf das Postfach: der Termin wird privat, statt in einem
-- fremden Kalender zu landen.
address = ''
end
local visibility = tostring(data.visibility or 'private')
if address == '' then
-- Ohne Postfach gibt es niemanden, der einen oeffentlichen Termin
-- pflegen koennte. Also bleibt er privat.
visibility = 'private'
elseif visibility ~= 'public' then
visibility = 'shared'
end
return d, address, visibility
end
--- Termin so aufbereiten, wie ihn der Client braucht.
local function PublicEvent(identifier, row)
row.can_edit = CanEditEvent(identifier, row)
row.address = row.address or ''
return row
end
local function SendCalendar(src, identifier)
local rows = DB.GetCalendar(identifier, MailboxesOf(identifier))
for _, row in ipairs(rows) do PublicEvent(identifier, row) end
TriggerClientEvent('pc-live:client:calendarData', src, rows)
end
--- Alle, die einen Termin sehen, bekommen die Aenderung sofort.
--- Ohne das haette jeder eine andere Vorstellung vom Dienstplan, bis er den
--- PC neu oeffnet und genau dafuer ist ein geteilter Kalender da.
local function NotifyOthers(src, event)
if not event then return end
local shared = (event.address and event.address ~= '') or event.visibility == 'public'
if not shared then return end
for playerSrc, session in pairs(ActiveSessions) do
if playerSrc ~= src then
if event.visibility == 'public'
or CanUseMailbox(session.identifier, event.address or '') then
SendCalendar(playerSrc, session.identifier)
end
end
end
end
-- ── Lesen ────────────────────────────────────────────────────────────────────
RegisterNetEvent('pc-live:server:getCalendar', function()
local src = source
local user = ActiveSessions[src]
if not user then return end
SendCalendar(src, user.identifier)
end)
-- ── Anlegen ──────────────────────────────────────────────────────────────────
RegisterNetEvent('pc-live:server:addCalendarEvent', function(data)
local src = source
local user = ActiveSessions[src]
if not user then return end
local d, address, visibility = ReadEvent(user.identifier, data)
if not d then return end
local id = DB.AddCalendarEvent(user.identifier, address, visibility, d)
d.id = id
d.address = address
d.visibility = visibility
d.identifier = user.identifier
d.can_edit = true
TriggerClientEvent('pc-live:client:calendarEventAdded', src, d)
NotifyOthers(src, d)
end)
-- ── Aendern ──────────────────────────────────────────────────────────────────
RegisterNetEvent('pc-live:server:updateCalendarEvent', function(id, data)
local src = source
local user = ActiveSessions[src]
if not user then return end
if type(id) ~= "number" then return end
-- Erst der bestehende Termin: sonst koennte man einen fremden aendern,
-- indem man einfach eine Adresse mitschickt, auf die man Zugriff hat.
local existing = DB.GetCalendarEvent(id)
if not CanEditEvent(user.identifier, existing) then return end
local d, address, visibility = ReadEvent(user.identifier, data)
if not d then return end
DB.UpdateCalendarEvent(id, address, visibility, d)
d.id = id
d.address = address
d.visibility = visibility
d.can_edit = true
TriggerClientEvent('pc-live:client:calendarEventUpdated', src, d)
-- Beide Seiten benachrichtigen: der Termin kann aus einem geteilten
-- Kalender herausgenommen worden sein, dann muss er dort verschwinden.
NotifyOthers(src, existing)
NotifyOthers(src, d)
end)
-- ── Loeschen ─────────────────────────────────────────────────────────────────
RegisterNetEvent('pc-live:server:deleteCalendarEvent', function(id)
local src = source
local user = ActiveSessions[src]
if not user then return end
if type(id) ~= "number" then return end
local existing = DB.GetCalendarEvent(id)
if not CanEditEvent(user.identifier, existing) then return end
DB.DeleteCalendarEvent(id)
TriggerClientEvent('pc-live:client:calendarEventDeleted', src, id)
NotifyOthers(src, existing)
end)

60
server/contacts.lua Normal file
View file

@ -0,0 +1,60 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- Contacts / Address Book handlers
-- ─────────────────────────────────────────────────────────────────────────────
RegisterNetEvent('pc-live:server:getContacts', function()
local src = source
local user = ActiveSessions[src]
if not user then return end
local rows = DB.GetContacts(user.identifier)
TriggerClientEvent('pc-live:client:contactsData', src, rows)
end)
RegisterNetEvent('pc-live:server:addContact', function(data)
local src = source
local user = ActiveSessions[src]
if not user then return end
if type(data) ~= "table" then return end
local d = {
name = Utils.Sanitize(tostring(data.name or "")):sub(1, 100),
email = Utils.Sanitize(tostring(data.email or "")):sub(1, 120),
phone = Utils.Sanitize(tostring(data.phone or "")):sub(1, 30),
notes = Utils.Sanitize(tostring(data.notes or "")):sub(1, 500),
}
if #d.name < 1 then return end
local id = DB.AddContact(user.identifier, d)
d.id = id
TriggerClientEvent('pc-live:client:contactAdded', src, d)
end)
RegisterNetEvent('pc-live:server:updateContact', function(id, data)
local src = source
local user = ActiveSessions[src]
if not user then return end
if type(id) ~= "number" or type(data) ~= "table" then return end
local d = {
name = Utils.Sanitize(tostring(data.name or "")):sub(1, 100),
email = Utils.Sanitize(tostring(data.email or "")):sub(1, 120),
phone = Utils.Sanitize(tostring(data.phone or "")):sub(1, 30),
notes = Utils.Sanitize(tostring(data.notes or "")):sub(1, 500),
}
if #d.name < 1 then return end
DB.UpdateContact(id, user.identifier, d)
d.id = id
TriggerClientEvent('pc-live:client:contactUpdated', src, d)
end)
RegisterNetEvent('pc-live:server:deleteContact', function(id)
local src = source
local user = ActiveSessions[src]
if not user then return end
if type(id) ~= "number" then return end
DB.DeleteContact(id, user.identifier)
TriggerClientEvent('pc-live:client:contactDeleted', src, id)
end)

419
server/database.lua Normal file
View file

@ -0,0 +1,419 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- Database layer all MySQL calls live here
-- ─────────────────────────────────────────────────────────────────────────────
DB = {}
-- ── Users ──────────────────────────────────────────────────────────────────
---@param identifier string
---@param displayName string
---@return table?
function DB.GetOrCreateUser(identifier, displayName)
local row = MySQL.single.await(
'SELECT * FROM pc_live_users WHERE identifier = ?',
{ identifier }
)
if row then return row end
MySQL.insert.await(
'INSERT INTO pc_live_users (identifier, display_name, settings_json) VALUES (?, ?, ?)',
{ identifier, displayName, '{}' }
)
return MySQL.single.await(
'SELECT * FROM pc_live_users WHERE identifier = ?',
{ identifier }
)
end
---@param identifier string
---@param settings table
function DB.SaveSettings(identifier, settings)
MySQL.update.await(
'UPDATE pc_live_users SET settings_json = ? WHERE identifier = ?',
{ json.encode(settings), identifier }
)
end
-- ── Installs ───────────────────────────────────────────────────────────────
---@param userId number
---@return table[]
function DB.GetInstalls(userId)
return MySQL.query.await(
'SELECT * FROM pc_live_installs WHERE user_id = ?',
{ userId }
)
end
---@param userId number
---@param appId string
---@param version string
function DB.InstallApp(userId, appId, version)
local existing = MySQL.single.await(
'SELECT id FROM pc_live_installs WHERE user_id = ? AND app_id = ?',
{ userId, appId }
)
if existing then
MySQL.update.await(
'UPDATE pc_live_installs SET version = ?, installed_at = NOW() WHERE id = ?',
{ version, existing.id }
)
else
MySQL.insert.await(
'INSERT INTO pc_live_installs (user_id, app_id, version, installed_at) VALUES (?, ?, ?, NOW())',
{ userId, appId, version }
)
end
end
---@param userId number
---@param appId string
function DB.UninstallApp(userId, appId)
MySQL.update.await(
'DELETE FROM pc_live_installs WHERE user_id = ? AND app_id = ?',
{ userId, appId }
)
end
-- ── Mail ───────────────────────────────────────────────────────────────────
---@param toId string
---@return table[]
function DB.GetInbox(toId)
return MySQL.query.await(
'SELECT * FROM pc_live_mail WHERE to_identifier = ? ORDER BY sent_at DESC LIMIT 100',
{ toId }
)
end
---@param id number
---@param toId string
---@return table?
function DB.GetMail(id, toId)
return MySQL.single.await(
'SELECT * FROM pc_live_mail WHERE id = ? AND to_identifier = ?',
{ id, toId }
)
end
---@param toId string
---@param fromId string
---@param subject string
---@param body string
---@return number inserted id
function DB.SendMail(toId, fromId, subject, body)
return MySQL.insert.await(
'INSERT INTO pc_live_mail (to_identifier, from_identifier, subject, body, is_read, sent_at) VALUES (?, ?, ?, ?, 0, NOW())',
{ toId, fromId, subject, body }
)
end
---@param toId string Empfänger-Identifier (aufgelöst)
---@param fromDisplay string Absender-Anzeige (E-Mail oder Identifier)
---@param senderId string Roher Identifier des Absenders (für Gesendet-Abfrage)
---@param toAddress string Originale Empfängeradresse
---@param subject string
---@param body string
---@return number
--- deliveryId klammert die Kopien einer Zustellung zusammen: eine Mail an ein
--- geteiltes Postfach wird je Empfaenger einmal gespeichert, soll aber fuer
--- alle im selben Ordner landen.
function DB.SendMailExtended(toId, fromDisplay, senderId, toAddress, subject, body, deliveryId)
return MySQL.insert.await(
'INSERT INTO pc_live_mail'
.. ' (to_identifier, from_identifier, sender_id, to_address, subject, body,'
.. ' delivery_id, is_read, sent_at)'
.. ' VALUES (?, ?, ?, ?, ?, ?, ?, 0, NOW())',
{ toId, fromDisplay, senderId, toAddress, subject, body, deliveryId }
)
end
---@param id number
---@param toId string
function DB.MarkRead(id, toId)
MySQL.update.await(
'UPDATE pc_live_mail SET is_read = 1 WHERE id = ? AND to_identifier = ?',
{ id, toId }
)
end
---@param id number
---@param toId string
function DB.DeleteMail(id, toId)
MySQL.update.await(
'DELETE FROM pc_live_mail WHERE id = ? AND to_identifier = ?',
{ id, toId }
)
end
---@param toId string
---@return number
function DB.CountUnread(toId)
local row = MySQL.single.await(
'SELECT COUNT(*) as cnt FROM pc_live_mail WHERE to_identifier = ? AND is_read = 0',
{ toId }
)
return row and row.cnt or 0
end
---@param fromId string Spieler-Identifier
---@return table[]
function DB.GetSent(fromId)
return MySQL.query.await(
'SELECT * FROM pc_live_mail'
.. ' WHERE from_identifier = ? OR sender_id = ?'
.. ' ORDER BY sent_at DESC LIMIT 100',
{ fromId, fromId }
)
end
-- ── Mail Folders ───────────────────────────────────────────────────────────
--
-- Ordner gehoeren zum Postfach, nicht zur Person: ein geteiltes Postfach wird
-- von mehreren bedient und braucht dieselbe Struktur fuer alle.
--
-- address = '' → persoenliche Ordner (Spalte address IS NULL)
-- address ~= '' → Ordner dieses Postfachs
--
-- Der Aufrufer muss vorher pruefen, ob die Person das Postfach benutzen darf.
---@param identifier string
---@param address string|nil
---@return table[]
function DB.GetFolders(identifier, address)
if address and address ~= '' then
return MySQL.query.await(
'SELECT * FROM pc_live_mail_folders WHERE address = ? ORDER BY name ASC',
{ address }
) or {}
end
return MySQL.query.await(
'SELECT * FROM pc_live_mail_folders WHERE identifier = ? AND address IS NULL ORDER BY name ASC',
{ identifier }
) or {}
end
---@param identifier string
---@param address string|nil
---@param name string
---@return number
function DB.CreateFolder(identifier, address, name)
return MySQL.insert.await(
'INSERT INTO pc_live_mail_folders (identifier, address, name) VALUES (?, ?, ?)',
{ identifier, (address ~= '' and address or nil), name }
)
end
--- Gibt den Ordner zurueck, wenn er zu diesem Postfach gehoert sonst nil.
--- Damit haengt jede Ordneraktion an einer Besitzpruefung statt an der Id
--- allein, die der Client frei mitschicken kann.
---@param folderId number
---@param identifier string
---@param address string|nil
---@return table|nil
function DB.GetFolder(folderId, identifier, address)
if address and address ~= '' then
return MySQL.single.await(
'SELECT * FROM pc_live_mail_folders WHERE id = ? AND address = ?',
{ folderId, address }
)
end
return MySQL.single.await(
'SELECT * FROM pc_live_mail_folders WHERE id = ? AND identifier = ? AND address IS NULL',
{ folderId, identifier }
)
end
---@param folderId number
---@param identifier string
---@param address string|nil
function DB.DeleteFolder(folderId, identifier, address)
-- Mails zurueck in den Posteingang. Bei einem geteilten Postfach betrifft
-- das die Kopien aller Beteiligten, sonst blieben deren Mails in einem
-- Ordner haengen, den es nicht mehr gibt.
if address and address ~= '' then
MySQL.update.await(
'UPDATE pc_live_mail SET folder_id = NULL WHERE folder_id = ? AND to_address = ?',
{ folderId, address }
)
else
MySQL.update.await(
'UPDATE pc_live_mail SET folder_id = NULL WHERE folder_id = ? AND to_identifier = ?',
{ folderId, identifier }
)
end
MySQL.update.await('DELETE FROM pc_live_mail_folders WHERE id = ?', { folderId })
end
--- Mail einsortieren.
--- Im geteilten Postfach wandern alle Kopien derselben Zustellung mit, damit
--- die Ablage fuer alle gleich aussieht.
---@param mailId number
---@param folderId number|nil nil = Posteingang
---@param identifier string
---@param address string|nil
function DB.MoveMailToFolder(mailId, folderId, identifier, address)
local mail = MySQL.single.await(
'SELECT id, delivery_id, to_address FROM pc_live_mail WHERE id = ? AND to_identifier = ?',
{ mailId, identifier }
)
if not mail then return end
local shared = address and address ~= '' and mail.to_address == address
if shared and mail.delivery_id then
MySQL.update.await(
'UPDATE pc_live_mail SET folder_id = ? WHERE delivery_id = ? AND to_address = ?',
{ folderId, mail.delivery_id, address }
)
return
end
-- Aeltere Mails haben keine delivery_id dann bleibt es bei der Kopie.
MySQL.update.await(
'UPDATE pc_live_mail SET folder_id = ? WHERE id = ? AND to_identifier = ?',
{ folderId, mailId, identifier }
)
end
---@param folderId number
---@param identifier string
---@return table[]
function DB.GetFolderMails(folderId, identifier)
return MySQL.query.await(
'SELECT * FROM pc_live_mail WHERE folder_id = ? AND to_identifier = ? ORDER BY sent_at DESC',
{ folderId, identifier }
) or {}
end
---@param identifier string
---@return table[]
function DB.GetInboxOnly(identifier)
return MySQL.query.await(
'SELECT * FROM pc_live_mail WHERE to_identifier = ? AND folder_id IS NULL ORDER BY sent_at DESC LIMIT 100',
{ identifier }
)
end
-- ── Calendar ───────────────────────────────────────────────────────────────
--
-- Ein Termin ist entweder privat (address IS NULL) oder gehoert einem Postfach.
-- Postfachtermine sind entweder nur fuer dessen Leute sichtbar ('shared') oder
-- fuer alle ('public') bearbeiten darf sie in beiden Faellen nur, wer das
-- Postfach bedienen darf.
--- Alle Termine, die diese Person sehen darf.
---@param identifier string
---@param addresses string[] Postfaecher, auf die sie Zugriff hat
---@return table[]
function DB.GetCalendar(identifier, addresses)
addresses = addresses or {}
local params = { identifier }
local clauses = { '(c.identifier = ? AND c.address IS NULL)' }
if #addresses > 0 then
local marks = {}
for _, addr in ipairs(addresses) do
marks[#marks + 1] = '?'
params[#params + 1] = addr
end
clauses[#clauses + 1] = ('(c.address IN (%s))'):format(table.concat(marks, ','))
end
-- Oeffentliche Termine sieht jeder, auch ohne Zugriff auf das Postfach.
clauses[#clauses + 1] = "(c.visibility = 'public')"
return MySQL.query.await(
'SELECT c.* FROM pc_live_calendar c WHERE '
.. table.concat(clauses, ' OR ') .. ' ORDER BY c.start_at ASC',
params
) or {}
end
---@param id number
---@return table|nil
function DB.GetCalendarEvent(id)
return MySQL.single.await('SELECT * FROM pc_live_calendar WHERE id = ?', { id })
end
---@param identifier string
---@param address string|nil '' oder nil = privat
---@param visibility string 'private' | 'shared' | 'public'
---@param data table
---@return number
function DB.AddCalendarEvent(identifier, address, visibility, data)
return MySQL.insert.await(
'INSERT INTO pc_live_calendar'
.. ' (identifier, address, visibility, title, description, start_at, end_at, color)'
.. ' VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
{ identifier, (address ~= '' and address or nil), visibility,
data.title, data.description or '', data.start_at,
data.end_at or nil, data.color or '#00aaff' }
)
end
--- Aendern. Die Berechtigung prueft der Aufrufer (server/calendar.lua)
--- hier steht keine Bedingung mehr, weil ein Postfachtermin auch von jemandem
--- geaendert werden darf, der ihn nicht angelegt hat.
---@param id number
---@param address string|nil
---@param visibility string
---@param data table
function DB.UpdateCalendarEvent(id, address, visibility, data)
MySQL.update.await(
'UPDATE pc_live_calendar SET address=?, visibility=?, title=?, description=?,'
.. ' start_at=?, end_at=?, color=? WHERE id=?',
{ (address ~= '' and address or nil), visibility,
data.title, data.description or '', data.start_at,
data.end_at or nil, data.color or '#00aaff', id }
)
end
---@param id number
function DB.DeleteCalendarEvent(id)
MySQL.update.await('DELETE FROM pc_live_calendar WHERE id = ?', { id })
end
-- ── Contacts ───────────────────────────────────────────────────────────────
---@param identifier string
---@return table[]
function DB.GetContacts(identifier)
return MySQL.query.await(
'SELECT * FROM pc_live_contacts WHERE identifier = ? ORDER BY name ASC',
{ identifier }
)
end
---@param identifier string
---@param data table
---@return number
function DB.AddContact(identifier, data)
return MySQL.insert.await(
'INSERT INTO pc_live_contacts (identifier, name, email, phone, notes) VALUES (?, ?, ?, ?, ?)',
{ identifier, data.name, data.email or '', data.phone or '', data.notes or '' }
)
end
---@param id number
---@param identifier string
---@param data table
function DB.UpdateContact(id, identifier, data)
MySQL.update.await(
'UPDATE pc_live_contacts SET name=?, email=?, phone=?, notes=? WHERE id=? AND identifier=?',
{ data.name, data.email or '', data.phone or '', data.notes or '', id, identifier }
)
end
---@param id number
---@param identifier string
function DB.DeleteContact(id, identifier)
MySQL.update.await(
'DELETE FROM pc_live_contacts WHERE id = ? AND identifier = ?',
{ id, identifier }
)
end

373
server/mail.lua Normal file
View file

@ -0,0 +1,373 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- Mail handlers
-- ─────────────────────────────────────────────────────────────────────────────
Mail = {}
-- Rate limiting: identifier -> { count, resetAt }
local _rateMap = {}
--- Darf diese Person das Postfach benutzen?
--- '' bedeutet "persoenliches Postfach" und ist immer erlaubt. Ohne diese
--- Pruefung koennte der Client jede beliebige Adresse mitschicken und damit in
--- fremden Ordnern lesen und schreiben.
---@return string die gepruefte Adresse, oder '' bei fehlendem Zugriff
local function CheckMailbox(identifier, address)
address = tostring(address or '')
if address == '' then return '' end
if GetResourceState('ic-mail') ~= 'started' then return '' end
local ok = exports['ic-mail']:CanAccessAddress(identifier, address)
return ok and address or ''
end
--- Postfachliste samt Signaturen an einen Spieler schicken.
--- Steht hier als Funktion, weil sie aus mehreren Ereignissen gebraucht wird
--- ein TriggerEvent auf das Netzwerkereignis waere kein Ersatz: dort ist
--- `source` nicht der Spieler, und der Aufruf liefe ins Leere.
local function SendMailboxes(src, identifier)
local mailboxes = {}
local personalAddr = ''
if GetResourceState('ic-mail') == 'started' then
personalAddr = exports['ic-mail']:GetPrimaryAddress(identifier) or ''
local addrs = exports['ic-mail']:GetAddressesForIdentifier(identifier) or {}
table.sort(addrs, function(a, b)
if a.type == 'personal' and b.type ~= 'personal' then return true end
if a.type ~= 'personal' and b.type == 'personal' then return false end
return (a.address or '') < (b.address or '')
end)
mailboxes = addrs
end
local personalSig = ''
for _, mb in ipairs(mailboxes) do
if mb.address == personalAddr then personalSig = mb.signature or '' end
end
TriggerClientEvent('pc-live:client:sharedMailboxes', src, mailboxes, personalAddr, personalSig)
end
local function CheckRateLimit(identifier)
local now = os.time()
local entry = _rateMap[identifier]
if not entry or now >= entry.resetAt then
_rateMap[identifier] = { count = 1, resetAt = now + 60 }
return true
end
if entry.count >= Config.MailRateLimit then
return false
end
entry.count = entry.count + 1
return true
end
-- ── Fetch inbox ─────────────────────────────────────────────────────────────
RegisterNetEvent('pc-live:server:getInbox', function()
local src = source
local user = ActiveSessions[src]
if not user then return end
local rows = DB.GetInboxOnly(user.identifier)
local folders = DB.GetFolders(user.identifier, '')
TriggerClientEvent('pc-live:client:inboxData', src, rows, folders)
end)
-- ── Fetch sent ───────────────────────────────────────────────────────────────
RegisterNetEvent('pc-live:server:getSent', function()
local src = source
local user = ActiveSessions[src]
if not user then return end
local rows = DB.GetSent(user.identifier)
TriggerClientEvent('pc-live:client:sentData', src, rows)
end)
-- ── Fetch folder mails ───────────────────────────────────────────────────────
RegisterNetEvent('pc-live:server:getFolderMails', function(folderId, address)
local src = source
local user = ActiveSessions[src]
if not user then return end
if type(folderId) ~= "number" then return end
local box = CheckMailbox(user.identifier, address)
if not DB.GetFolder(folderId, user.identifier, box) then return end
local rows = DB.GetFolderMails(folderId, user.identifier)
TriggerClientEvent('pc-live:client:folderMailsData', src, folderId, rows)
end)
-- ── Ordner eines Postfachs holen ─────────────────────────────────────────────
-- Wird beim Wechsel des Postfachs gerufen: jedes Postfach hat eigene Ordner.
RegisterNetEvent('pc-live:server:getFolders', function(address)
local src = source
local user = ActiveSessions[src]
if not user then return end
local box = CheckMailbox(user.identifier, address)
TriggerClientEvent('pc-live:client:foldersData', src, box,
DB.GetFolders(user.identifier, box))
end)
-- ── Create folder ────────────────────────────────────────────────────────────
RegisterNetEvent('pc-live:server:createFolder', function(name, address)
local src = source
local user = ActiveSessions[src]
if not user then return end
name = Utils.Sanitize(tostring(name or "")):sub(1, 60)
if #name < 1 then return end
local box = CheckMailbox(user.identifier, address)
local id = DB.CreateFolder(user.identifier, box, name)
TriggerClientEvent('pc-live:client:folderCreated', src, { id = id, name = name, address = box })
-- Im geteilten Postfach sehen die anderen den Ordner sofort.
if box ~= '' then
for playerSrc, session in pairs(ActiveSessions) do
if playerSrc ~= src and CheckMailbox(session.identifier, box) ~= '' then
TriggerClientEvent('pc-live:client:foldersData', playerSrc, box,
DB.GetFolders(session.identifier, box))
end
end
end
end)
-- ── Delete folder ────────────────────────────────────────────────────────────
RegisterNetEvent('pc-live:server:deleteFolder', function(folderId, address)
local src = source
local user = ActiveSessions[src]
if not user then return end
if type(folderId) ~= "number" then return end
local box = CheckMailbox(user.identifier, address)
if not DB.GetFolder(folderId, user.identifier, box) then return end
DB.DeleteFolder(folderId, user.identifier, box)
TriggerClientEvent('pc-live:client:folderDeleted', src, folderId, box)
if box ~= '' then
for playerSrc, session in pairs(ActiveSessions) do
if playerSrc ~= src and CheckMailbox(session.identifier, box) ~= '' then
TriggerClientEvent('pc-live:client:folderDeleted', playerSrc, folderId, box)
end
end
end
end)
-- ── Move mail to folder ──────────────────────────────────────────────────────
RegisterNetEvent('pc-live:server:moveMail', function(mailId, folderId, address)
local src = source
local user = ActiveSessions[src]
if not user then return end
if type(mailId) ~= "number" then return end
-- folderId may be nil (move to inbox)
if folderId ~= nil and type(folderId) ~= "number" then return end
local box = CheckMailbox(user.identifier, address)
if folderId ~= nil and not DB.GetFolder(folderId, user.identifier, box) then return end
DB.MoveMailToFolder(mailId, folderId, user.identifier, box)
TriggerClientEvent('pc-live:client:mailMoved', src, mailId, folderId)
end)
-- ── Read mail ───────────────────────────────────────────────────────────────
RegisterNetEvent('pc-live:server:readMail', function(mailId)
local src = source
local user = ActiveSessions[src]
if not user then return end
if type(mailId) ~= "number" then return end
local mail = DB.GetMail(mailId, user.identifier)
if not mail then return end
DB.MarkRead(mailId, user.identifier)
mail.is_read = 1
TriggerClientEvent('pc-live:client:mailContent', src, mail)
end)
-- ── Send mail ────────────────────────────────────────────────────────────────
RegisterNetEvent('pc-live:server:sendMail', function(payload)
local src = source
local user = ActiveSessions[src]
if not user then return end
-- Validate payload shape
if type(payload) ~= "table" then return end
local toId = Utils.Sanitize(tostring(payload.to or "")):sub(1, 120)
local subject = Utils.Sanitize(tostring(payload.subject or "")):sub(1, Config.MailMaxSubject)
local body = Utils.Sanitize(tostring(payload.body or "")):sub(1, Config.MailMaxBody)
if #toId < 3 or #subject < 1 or #body < 1 then
Bridge.Notify(src, "Invalid mail data.", "error")
return
end
-- Rate limit
if not CheckRateLimit(user.identifier) then
Bridge.Notify(src, "You are sending mails too fast. Please wait.", "error")
return
end
-- Absender-Adresse ermitteln (ic-mail optional)
local fromDisplay = user.identifier
if GetResourceState and GetResourceState('ic-mail') == 'started' then
local addr = exports['ic-mail']:GetPrimaryAddress(user.identifier)
if addr then fromDisplay = addr end
-- Versand aus geteiltem Postfach: Zugriff prüfen
local fromMailbox = Utils.Sanitize(tostring(payload.fromMailbox or '')):sub(1, 120)
if fromMailbox ~= '' and fromMailbox ~= fromDisplay then
local addrs = exports['ic-mail']:GetAddressesForIdentifier(user.identifier) or {}
for _, a in ipairs(addrs) do
if a.address == fromMailbox then
fromDisplay = fromMailbox
break
end
end
end
end
-- Empfänger auflösen: @-Adresse → Identifier-Liste, sonst direkt
local recipients = {}
if toId:find('@') and GetResourceState and GetResourceState('ic-mail') == 'started' then
local resolved = exports['ic-mail']:ResolveAddress(toId)
if resolved and resolved.identifiers and #resolved.identifiers > 0 then
recipients = resolved.identifiers
else
Bridge.Notify(src, "Empfänger-Adresse nicht gefunden.", "error")
return
end
else
recipients = { toId }
end
-- Eine Kennung fuer diese Zustellung. Alle Kopien tragen sie, damit das
-- Einsortieren in einen Ordner im geteilten Postfach fuer alle gilt.
local deliveryId = ('%08x-%04x-%04x-%04x-%012x'):format(
os.time(), math.random(0, 0xFFFF), math.random(0, 0xFFFF),
math.random(0, 0xFFFF), math.random(0, 0xFFFFFFFFFFFF))
-- Mail an alle Empfänger senden (pc_live_mail + ic-mail Dual-Write)
for _, recipId in ipairs(recipients) do
DB.SendMailExtended(recipId, fromDisplay, user.identifier, toId, subject, body, deliveryId)
-- ic-mail parallel benachrichtigen (zentrale Mailplattform)
pcall(function()
if GetResourceState('ic-mail') == 'started' then
exports['ic-mail']:SendSystemMail(recipId, subject, body)
end
end)
end
Bridge.Notify(src, "Mail gesendet.", "success")
TriggerClientEvent('pc-live:client:mailSent', src)
-- Online-Empfänger benachrichtigen
for playerSrc, session in pairs(ActiveSessions) do
for _, recipId in ipairs(recipients) do
if session.identifier == recipId then
TriggerClientEvent('pc-live:client:newMailNotify', playerSrc)
end
end
end
end)
-- ── Delete mail ─────────────────────────────────────────────────────────────
RegisterNetEvent('pc-live:server:deleteMail', function(mailId)
local src = source
local user = ActiveSessions[src]
if not user then return end
if type(mailId) ~= "number" then return end
DB.DeleteMail(mailId, user.identifier)
TriggerClientEvent('pc-live:client:mailDeleted', src, mailId)
end)
-- ── Geteilte Postfächer (ic-mail Integration) ────────────────────────────────
RegisterNetEvent('pc-live:server:getSharedMailboxes', function()
local src = source
local user = ActiveSessions[src]
if not user then return end
SendMailboxes(src, user.identifier)
end)
-- ── Mail-Adresse prüfen (Ersteinrichtung) ────────────────────────────────────
RegisterNetEvent('pc-live:server:checkMailAddress', function()
local src = source
local user = ActiveSessions[src]
if not user then return end
if GetResourceState('ic-mail') ~= 'started' then return end
local existing = exports['ic-mail']:GetPrimaryAddress(user.identifier)
if existing then
-- Adresse vorhanden → Status senden, Postfach lädt der Client selbst nach
TriggerClientEvent('pc-live:client:mailAddressStatus', src, true, existing, nil)
return
end
local realms = exports['ic-mail']:GetRealms() or {}
local citizenRealms = {}
for _, r in ipairs(realms) do
if r.realm_type == 'citizen' and r.active then
table.insert(citizenRealms, r)
end
end
TriggerClientEvent('pc-live:client:mailAddressStatus', src, false, nil, citizenRealms)
end)
RegisterNetEvent('pc-live:server:createMailAddress', function(username)
local src = source
local user = ActiveSessions[src]
if not user then return end
if type(username) ~= 'string' or #username < 2 or #username > 40 then
TriggerClientEvent('pc-live:client:mailCreateResult', src, false, 'Benutzername muss 2-40 Zeichen haben')
return
end
username = username:lower():gsub('[^a-z0-9._%-]', ''):gsub('^%.+', ''):gsub('%.+$', '')
if #username < 2 then
TriggerClientEvent('pc-live:client:mailCreateResult', src, false, 'Ungültiger Benutzername')
return
end
if GetResourceState('ic-mail') ~= 'started' then
TriggerClientEvent('pc-live:client:mailCreateResult', src, false, 'Mail-System nicht verfügbar')
return
end
local existing = exports['ic-mail']:GetPrimaryAddress(user.identifier)
if existing then
TriggerClientEvent('pc-live:client:mailCreateResult', src, true, nil, existing)
return
end
local addr = exports['ic-mail']:EnsurePersonalAddress(user.identifier, username)
if addr then
TriggerClientEvent('pc-live:client:mailCreateResult', src, true, nil, addr)
else
TriggerClientEvent('pc-live:client:mailCreateResult', src, false, 'Benutzername bereits vergeben')
end
end)
-- ── Signaturen (ic-mail) ─────────────────────────────────────────────────────
-- Die Signatur gehoert zum Postfach: eine Firmenadresse traegt dieselbe
-- Fusszeile, egal wer sie gerade bedient. Geprueft wird in ic-mail.
RegisterNetEvent('pc-live:server:setSignature', function(address, signature)
local src = source
local user = ActiveSessions[src]
if not user then return end
if GetResourceState('ic-mail') ~= 'started' then
TriggerClientEvent('pc-live:client:signatureSaved', src, false, 'Mailsystem nicht verfügbar.')
return
end
local ok, err = exports['ic-mail']:SetSignature(
tostring(address or ''), user.identifier, tostring(signature or ''))
TriggerClientEvent('pc-live:client:signatureSaved', src, ok == true, err)
-- Postfachliste nachschieben, damit die neue Signatur sofort greift.
if ok then SendMailboxes(src, user.identifier) end
end)

230
server/main.lua Normal file
View file

@ -0,0 +1,230 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- Server Main session management, boot, init
-- ─────────────────────────────────────────────────────────────────────────────
-- Bridge namespace (must be declared before bridge file is loaded;
-- bridge file is loaded by fxmanifest before this file)
Bridge = Bridge or {}
-- Active sessions: source -> { identifier, displayName, dbId, pcId }
ActiveSessions = {}
-- ── PC Cache (loaded from DB) ─────────────────────────────────────────────────
local PCCache = {} -- keyed by id (aus DB-Tabelle pc_live_devices)
-- Virtuelle/mobile Terminals aus der Config (kein DB-Standort), z.B. Streifenwagen-PC.
local MobilePCs = {}
for _, pc in ipairs(Config.PCs or {}) do
if pc.mobile then MobilePCs[pc.id] = pc end
end
local function LoadPCsFromDB()
local rows = MySQL.query.await('SELECT * FROM pc_live_devices WHERE active = 1')
PCCache = {}
for _, row in ipairs(rows or {}) do
PCCache[row.id] = {
id = row.id,
label = row.label,
coords = vector3(row.x, row.y, row.z),
heading = row.heading,
type = row.type,
job = row.job,
start_page = row.start_page,
}
end
Utils.Log(("Loaded %d PC devices from DB"):format(#(rows or {})))
end
CreateThread(function()
Wait(500)
LoadPCsFromDB()
end)
-- Reload nach Änderungen (z.B. durch Backend oder F6)
AddEventHandler('pc-live:reloadDevices', function()
LoadPCsFromDB()
local list = {}
for _, pc in pairs(PCCache) do
list[#list + 1] = { id=pc.id, label=pc.label, heading=pc.heading, type=pc.type,
job=pc.job, start_page=pc.start_page,
coords={ x=pc.coords.x, y=pc.coords.y, z=pc.coords.z } }
end
TriggerClientEvent('pc-live:client:syncDevices', -1, list)
end)
-- Client fragt nach Device-Liste
RegisterNetEvent('pc-live:server:requestDevices', function()
local src = source
local list = {}
for _, pc in pairs(PCCache) do
list[#list + 1] = { id=pc.id, label=pc.label, heading=pc.heading, type=pc.type,
job=pc.job, start_page=pc.start_page,
coords={ x=pc.coords.x, y=pc.coords.y, z=pc.coords.z } }
end
TriggerClientEvent('pc-live:client:syncDevices', src, list)
end)
local function GetPCConfig(pcId)
return PCCache[pcId] or MobilePCs[pcId]
end
local function IsPlayerNearPC(source, pcConfig)
-- We trust client position check; server re-validates via ped coords
local ped = GetPlayerPed(source)
if not ped or ped == 0 then return false end
local px, py, pz = table.unpack(GetEntityCoords(ped))
local cx, cy, cz = table.unpack({ pcConfig.coords.x, pcConfig.coords.y, pcConfig.coords.z })
local dist = #(vector3(px, py, pz) - vector3(cx, cy, cz))
return dist <= (Config.InteractRange + 2.0) -- small server-side tolerance
end
-- ── Open PC session ──────────────────────────────────────────────────────────
RegisterNetEvent('pc-live:server:openPC', function(pcId)
local src = source
if ActiveSessions[src] then return end -- already in session
if type(pcId) ~= "string" then return end
pcId = pcId:sub(1, 64)
local pcConfig = GetPCConfig(pcId)
if not pcConfig then
Utils.Warn(("Player %d tried unknown PC id: %s"):format(src, pcId))
return
end
-- Distance check (mobile Terminals wie der Streifenwagen-PC haben keinen Standort)
if not pcConfig.mobile and not IsPlayerNearPC(src, pcConfig) then
Utils.Warn(("Player %d too far from PC %s"):format(src, pcId))
return
end
-- Job lock check pcConfig.job kann eine Liste aus Behörden-Jobs und
-- Firmen-Tokens ("company:<id>") sein, z.B. "dpa, police, company:3".
if pcConfig.type == "job" and pcConfig.job and pcConfig.job ~= "" then
local job = Bridge.GetJob(src)
local allowed = false
for token in tostring(pcConfig.job):gmatch("[^,%s]+") do
local companyId = token:match("^company:(%d+)$")
if companyId then
if Bridge.IsCompanyEmployee and Bridge.IsCompanyEmployee(src, tonumber(companyId)) then
allowed = true; break
end
elseif job == token then
allowed = true; break
end
end
if not allowed then
Bridge.Notify(src, "You don't have access to this terminal.", "error")
return
end
end
local identifier = Bridge.GetIdentifier(src)
local displayName = Bridge.GetName(src)
local dbUser = DB.GetOrCreateUser(identifier, displayName)
if not dbUser then
Utils.Error(("Failed to get/create user for %s"):format(identifier))
return
end
-- Install default apps if first time
local installs = DB.GetInstalls(dbUser.id)
local installedIds = {}
for _, row in ipairs(installs) do installedIds[row.app_id] = true end
for _, app in ipairs(AppRegistry.GetAll()) do
if app.default and not installedIds[app.app_id] then
DB.InstallApp(dbUser.id, app.app_id, app.version)
end
end
-- Apps eines (mobilen) Terminals sicher installieren, damit sie verfügbar sind.
local ensureApps = pcConfig.apps or (pcConfig.auto_open and { pcConfig.auto_open }) or {}
if #ensureApps > 0 then
local byId = {}
for _, app in ipairs(AppRegistry.GetAll()) do byId[app.app_id] = app end
for _, appId in ipairs(ensureApps) do
if not installedIds[appId] and byId[appId] then
DB.InstallApp(dbUser.id, appId, byId[appId].version)
end
end
end
-- Reload installs after defaults
installs = DB.GetInstalls(dbUser.id)
ActiveSessions[src] = {
identifier = identifier,
displayName = displayName,
dbId = dbUser.id,
pcId = pcId,
}
-- Adresse wird über den Tablet-Assistenten erstellt, nicht automatisch
-- Postfächer für diesen Spieler laden (ic-mail optional)
local sharedMailboxes = {}
local personalAddress = ''
if GetResourceState and GetResourceState('ic-mail') == 'started' then
personalAddress = exports['ic-mail']:GetPrimaryAddress(identifier) or ''
local addrs = exports['ic-mail']:GetAddressesForIdentifier(identifier) or {}
table.sort(addrs, function(a, b)
if a.type == 'personal' and b.type ~= 'personal' then return true end
if a.type ~= 'personal' and b.type == 'personal' then return false end
return (a.address or '') < (b.address or '')
end)
sharedMailboxes = addrs
end
local installed = AppRegistry.BuildInstalledList(installs)
-- Mobiles Terminal: nur die freigegebenen Apps anzeigen (Whitelist aus der PC-Config).
if pcConfig.apps then
local allow = {}
for _, a in ipairs(pcConfig.apps) do allow[a] = true end
local filtered = {}
for _, app in ipairs(installed) do
if allow[app.app_id] then filtered[#filtered + 1] = app end
end
installed = filtered
end
local unread = DB.CountUnread(identifier)
local settings = json.decode(dbUser.settings_json or "{}") or {}
TriggerClientEvent('pc-live:client:sessionStart', src, {
pcId = pcId,
pcLabel = pcConfig.label,
startPage = pcConfig.start_page,
autoOpenApp = pcConfig.auto_open,
identifier = identifier,
displayName = displayName,
installedApps = installed,
unread = unread,
settings = settings,
sharedMailboxes = sharedMailboxes,
personalAddress = personalAddress,
-- Signatur des persoenlichen Postfachs. Steckt zwar auch in
-- sharedMailboxes, aber der Client kennt es dort nur ueber die Adresse.
personalSignature = (function()
for _, mb in ipairs(sharedMailboxes) do
if mb.address == personalAddress then return mb.signature or '' end
end
return ''
end)(),
})
end)
-- ── Close PC session ──────────────────────────────────────────────────────────
RegisterNetEvent('pc-live:server:closePC', function()
local src = source
ActiveSessions[src] = nil
Utils.Log(("Session closed for player %d"):format(src))
end)
-- ── Player disconnect cleanup ─────────────────────────────────────────────────
AddEventHandler('playerDropped', function()
ActiveSessions[source] = nil
end)
Utils.Log("Server main loaded.")

124
server/store.lua Normal file
View file

@ -0,0 +1,124 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- Store handlers install / uninstall / list
-- ─────────────────────────────────────────────────────────────────────────────
-- ── List store ───────────────────────────────────────────────────────────────
RegisterNetEvent('pc-live:server:getStore', function()
local src = source
local user = ActiveSessions[src]
if not user then return end
local installs = DB.GetInstalls(user.dbId)
local storeList = AppRegistry.BuildStoreList(installs)
TriggerClientEvent('pc-live:client:storeData', src, storeList)
end)
-- ── Install app ──────────────────────────────────────────────────────────────
RegisterNetEvent('pc-live:server:installApp', function(appId)
local src = source
local user = ActiveSessions[src]
if not user then return end
if type(appId) ~= "string" then return end
appId = appId:sub(1, 64)
local manifest = AppRegistry.Get(appId)
if not manifest then
Bridge.Notify(src, "App not found.", "error")
return
end
-- Fetch installs once used for all checks below
local installs = DB.GetInstalls(user.dbId)
-- Already installed and up to date?
local installedVersion = nil
for _, row in ipairs(installs) do
if row.app_id == appId then
installedVersion = row.version
break
end
end
if installedVersion and installedVersion == manifest.version then
Bridge.Notify(src, "Already installed.", "primary")
return
end
-- Job permission check (single job OR list of jobs)
local perms = manifest.permissions or {}
if perms.job or perms.jobs then
local playerJob = Bridge.GetJob and Bridge.GetJob(src) or nil
local allowed = false
if perms.jobs and type(perms.jobs) == "table" then
for _, j in ipairs(perms.jobs) do
if j == playerJob then allowed = true; break end
end
elseif perms.job then
allowed = (playerJob == perms.job)
end
if not allowed then
local jobList = perms.jobs and table.concat(perms.jobs, ", ") or perms.job
Bridge.Notify(src, ("Zugriff verweigert. Benötigt: %s"):format(jobList), "error")
return
end
end
-- Dependency check
if manifest.dependencies and #manifest.dependencies > 0 then
local installedMap = {}
for _, row in ipairs(installs) do
installedMap[row.app_id] = true
end
for _, depId in ipairs(manifest.dependencies) do
if not installedMap[depId] then
local dep = AppRegistry.Get(depId)
Bridge.Notify(src, ("Missing dependency: %s"):format(dep and dep.name or depId), "error")
return
end
end
end
-- Price check
local price = manifest.price or 0
if price > 0 then
local balance = Bridge.GetMoney(src, "bank")
if balance < price then
Bridge.Notify(src, ("Insufficient funds. Required: $%d"):format(price), "error")
return
end
Bridge.RemoveMoney(src, "bank", price)
end
DB.InstallApp(user.dbId, appId, manifest.version)
-- Refresh desktop icons AND store view
local updated = DB.GetInstalls(user.dbId)
TriggerClientEvent('pc-live:client:installedApps', src, AppRegistry.BuildInstalledList(updated))
TriggerClientEvent('pc-live:client:storeData', src, AppRegistry.BuildStoreList(updated))
Bridge.Notify(src, ("%s installed."):format(manifest.name), "success")
end)
-- ── Uninstall app ────────────────────────────────────────────────────────────
RegisterNetEvent('pc-live:server:uninstallApp', function(appId)
local src = source
local user = ActiveSessions[src]
if not user then return end
if type(appId) ~= "string" then return end
appId = appId:sub(1, 64)
-- Cannot uninstall default system apps
local manifest = AppRegistry.Get(appId)
if manifest and manifest.default then
Bridge.Notify(src, "System apps cannot be uninstalled.", "error")
return
end
DB.UninstallApp(user.dbId, appId)
-- Refresh desktop icons AND store view
local updated = DB.GetInstalls(user.dbId)
TriggerClientEvent('pc-live:client:installedApps', src, AppRegistry.BuildInstalledList(updated))
TriggerClientEvent('pc-live:client:storeData', src, AppRegistry.BuildStoreList(updated))
Bridge.Notify(src, "App uninstalled.", "primary")
end)