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:
commit
be502d2834
46 changed files with 13119 additions and 0 deletions
447
server/apps.lua
Normal file
447
server/apps.lua
Normal 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
183
server/calendar.lua
Normal 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
60
server/contacts.lua
Normal 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
419
server/database.lua
Normal 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
373
server/mail.lua
Normal 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
230
server/main.lua
Normal 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
124
server/store.lua
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue