From 4fa4bf3c7890d75609ce8646cfe264b95fdb208a Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" <159480018+leonw21342315@users.noreply.github.com> Date: Sat, 22 Aug 2026 05:02:14 +0200 Subject: [PATCH] ENH - centralize phone defaults and fixed permissions (#26) * ENH - centralize phone configuration defaults * FIX - enforce fixed phone permissions --- README.md | 29 +- frontend/build.cjs | 45 +- .../components/AdminPanel.contract.test.ts | 58 +- frontend/src/permissions.contract.test.ts | 83 + frontend/src/phoneInventory.contract.test.ts | 15 +- frontend/src/stores/phone.ts | 1 - .../adminConfiguratorDescription.test.ts | 4 +- frontend/testserver/configurator-fixture.cjs | 1 + .../testserver/configurator-fixture.test.ts | 9 +- sky_phone/README.md | 17 +- sky_phone/config/config.lua | 27 +- sky_phone/config/locales/de.lua | 1 - sky_phone/config/locales/en.lua | 1 - sky_phone/config/locales/es.lua | 1 - sky_phone/config/media.lua | 14 +- sky_phone/fxmanifest.lua | 1 + sky_phone/source/bridge/server/framework.lua | 20 + .../source/bridge/server/frameworks/qbox.lua | 7 + sky_phone/source/bridge/server/inventory.lua | 25 +- sky_phone/source/server/admin.lua | 4 +- sky_phone/source/server/fliptok.lua | 2 +- .../source/server/phone_configurator.lua | 61 +- sky_phone/source/server/picstagram.lua | 4 +- sky_phone/source/server/testdata.lua | 2 +- sky_phone/source/shared/config_default.lua | 1557 +++++++++++++++++ tests/framework_permissions.lua | 64 + tests/inventory_bridges.lua | 23 +- tests/inventory_extended_bridges.lua | 3 + tests/server_phone_modules.lua | 3 +- 29 files changed, 2006 insertions(+), 76 deletions(-) create mode 100644 frontend/src/permissions.contract.test.ts create mode 100644 sky_phone/source/shared/config_default.lua create mode 100644 tests/framework_permissions.lua diff --git a/README.md b/README.md index b8d832f..7606ef3 100644 --- a/README.md +++ b/README.md @@ -138,10 +138,10 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting | `smx-inventory` (`smx`) | Yes | Yes | ESX only; one metadata record per configured item name through the player metadata bridge | | `lj-inventory` (`lj`) | Yes | Yes | QBCore inventory with item `info` metadata | | `qb-inventory` (`qb`) | Yes | Yes | Uses item `info` metadata | -| `hex_4_inventory` (`hex`) | **No metadata support** | **No, Unique Phones are not possible** | ESX only; set `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` | -| Native ESX inventory (`esx`) | **No metadata support** | **No, Unique Phones are not possible** | Count-based items; set `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` | +| `hex_4_inventory` (`hex`) | **No metadata support** | **No, Unique Phones are not possible** | ESX only; Sky Phone automatically disables unique phones and physical SIM cards | +| Native ESX inventory (`esx`) | **No metadata support** | **No, Unique Phones are not possible** | Count-based items; Sky Phone automatically disables unique phones and physical SIM cards | -`hex_4_inventory` and native ESX inventory cannot persist per-item metadata. Unique Phones and physical SIM cards are therefore unavailable with these adapters. +`hex_4_inventory` and native ESX inventory cannot persist per-item metadata. Sky Phone therefore forces `Config.Phone.Unique` and `Config.Sim.Enabled` to `false` at runtime whenever either adapter is active. `Config.Bridge.Inventory = "auto"` detects framework-compatible adapters in the table order. This deliberately matches the Sky inventory priority so a dedicated inventory is selected before a compatibility resource it may run beside. You may configure either the short value shown in parentheses or the exact resource name. @@ -216,6 +216,7 @@ The files contain clearly separated sections for: | Section | Purpose | | --- | --- | | `Config.Bridge` | Framework, inventory, language, callback timeout, and debug mode | +| `Config.CommandPermissions` | Fixed groups for the admin panel, test data, verification commands, and social moderation | | `Config.Phone` | Phone item, movement, unique-device mode, and development command | | `Config.Sim` | Physical or virtual SIM behavior and number formatting | | `Config.Calls` / `Config.Radio` | Voice providers, call behavior, radio limits, and permissions | @@ -241,21 +242,31 @@ Config.PhoneConfigurator = { } ``` -When enabled, `config.lua` and the server-only `media.lua` are first-run defaults. Sky Phone creates -the `sky_phone_configurator` table automatically, loads its saved values before framework and phone +When enabled, the generated `source/shared/config_default.lua` is the shipped first-run baseline. +The frontend build recreates this file from `config.lua` and the server-only `media.lua`; do not edit +the generated snapshot directly. Sky Phone creates the `sky_phone_configurator` table automatically, +loads its saved values before framework and phone modules initialize, and exposes the editor through `/phonepanel`. Nothing autosaves: stage changes in the Phone Configurator tool and press the green check. Saving verifies both SQL payloads and then applies the new server, client, media, app, item, command, provider, animation, and UI values through Sky Phone's internal runtime refresh. It does not execute a resource restart command. -Every `Config.*` value from `config.lua`, including server-only sections, and every value from -`Config.Media` is discovered automatically. The bootstrap switch above intentionally remains -file-owned because it decides whether SQL configuration is loaded. Lists, nested objects, vectors, +Every configurable `Config.*` value from `config.lua`, including server-only sections, and every +value from `Config.Media` is discovered automatically. The bootstrap switch and +`Config.CommandPermissions` intentionally remain file-owned: the switch decides whether SQL +configuration is loaded, while permissions must remain authoritative outside the panel. The fixed +permission table is never displayed or overwritten by the Phone Configurator, and its stable keys do +not change when their commands are renamed in the panel. Lists, nested objects, vectors, and numeric-keyed Lua tables use structured editors instead of raw JSON. Shipped schema rows stay editable but cannot be renamed, converted, or removed. Every list and table still accepts any number of additional rows; administrator-added rows remain removable. Company job keys are intentionally fully removable because `Config.Companies.Definitions` is a freely managed job collection. +ESX and QBCore use the groups listed in `Config.CommandPermissions`. Qbox checks the configured ACE +objects first and then its framework groups. The standard Qbox `permissions.cfg` grants the `admin` +ACE to `group.admin`, so an identifier assigned to `group.admin` can open `/phonepanel` with the +shipped `phonepanel` permission list. Restart `sky_phone` after changing fixed permissions. + Media API keys and server peppers are never returned in plaintext to the NUI. Existing secrets are shown only as configured and are replaced only when an administrator enters a new value. @@ -365,7 +376,7 @@ Do not configure an LB Phone client event or client export. Sky Phone registers The server registers `Config.Phone.Item` as usable for every supported inventory adapter: `ak47`, `codem`, `core`, `jaksam`, `jpr`, `lj`, `mf`, `one`, `origen`, `ox`, `ps`, `qb`, `qs`, `smx`, `tgiann`, `hex`, and `esx`. Resource startup fails visibly if the selected adapter or its resource is unavailable. -The `hex` and `esx` adapters use ESX's count-based item API. They require both `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` because this API cannot persist per-item phone or physical SIM metadata. `auto` selects `hex` when `hex_4_inventory` is started and otherwise falls back to `esx` on an ESX server when no metadata-capable inventory is detected. +The `hex` and `esx` adapters use ESX's count-based item API, which cannot persist per-item phone or physical SIM metadata. Sky Phone automatically forces `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` while either adapter is active. `auto` selects `hex` when `hex_4_inventory` is started and otherwise falls back to `esx` on an ESX server when no metadata-capable inventory is detected. ### QBCore-style item tables diff --git a/frontend/build.cjs b/frontend/build.cjs index c896e3c..691c9f7 100644 --- a/frontend/build.cjs +++ b/frontend/build.cjs @@ -2,15 +2,49 @@ const fs = require('node:fs') const path = require('node:path') const frontendRoot = __dirname +const resourceDirectory = path.join(frontendRoot, '..', 'sky_phone') const sourceDirectory = path.join(frontendRoot, 'dist') -const targetDirectory = path.join( - frontendRoot, - '..', - 'sky_phone', +const targetDirectory = path.join(resourceDirectory, 'source', 'html') +const configDefaultPath = path.join( + resourceDirectory, 'source', - 'html', + 'shared', + 'config_default.lua', ) +function readLuaSource(...segments) { + return fs + .readFileSync(path.join(resourceDirectory, ...segments), 'utf8') + .replace(/\r\n?/g, '\n') + .trimEnd() +} + +function generateConfigDefault() { + const configSource = readLuaSource('config', 'config.lua').replace( + /\n?-- CONFIG_DEFAULT_EXCLUDE_START[\s\S]*?-- CONFIG_DEFAULT_EXCLUDE_END\n?/g, + '\n', + ) + const mediaSource = readLuaSource('config', 'media.lua') + const generatedSource = [ + '-- GENERATED by frontend/build.cjs from config/config.lua and config/media.lua - do not edit.', + '-- Escrowed snapshot of the shipped defaults used by the Phone Configurator.', + 'local realConfig = Config', + 'Config = {}', + '', + configSource, + '', + mediaSource, + '', + 'ConfigDefaults = Config', + 'Config = realConfig', + '', + ].join('\n') + + fs.mkdirSync(path.dirname(configDefaultPath), { recursive: true }) + fs.writeFileSync(configDefaultPath, generatedSource, 'utf8') + console.log(`Generated Phone Configurator defaults at ${configDefaultPath}`) +} + if (!fs.existsSync(sourceDirectory)) { throw new Error( 'Missing frontend/dist. Run vite build before publishing the NUI.', @@ -28,4 +62,5 @@ const normalizedIndex = fs .replace(/\n[ \t]*\n([ \t]*<\/body>)/g, '\n$1') fs.writeFileSync(targetIndex, normalizedIndex) +generateConfigDefault() console.log(`Published NUI to ${targetDirectory}`) diff --git a/frontend/src/components/AdminPanel.contract.test.ts b/frontend/src/components/AdminPanel.contract.test.ts index 823c9aa..d6f1f33 100644 --- a/frontend/src/components/AdminPanel.contract.test.ts +++ b/frontend/src/components/AdminPanel.contract.test.ts @@ -54,6 +54,14 @@ const config = readFileSync( new URL('../../../sky_phone/config/config.lua', import.meta.url), 'utf8', ) +const configDefaultsSource = config.replace( + /\r?\n?-- CONFIG_DEFAULT_EXCLUDE_START[\s\S]*?-- CONFIG_DEFAULT_EXCLUDE_END\r?\n?/g, + '\n', +) +const mediaConfig = readFileSync( + new URL('../../../sky_phone/config/media.lua', import.meta.url), + 'utf8', +) const schema = readFileSync( new URL('../../../sky_phone/sql/install.sql', import.meta.url), 'utf8', @@ -69,6 +77,17 @@ const configuratorServer = readFileSync( ), 'utf8', ) +const configDefault = readFileSync( + new URL( + '../../../sky_phone/source/shared/config_default.lua', + import.meta.url, + ), + 'utf8', +) +const frontendBuild = readFileSync( + new URL('../../build.cjs', import.meta.url), + 'utf8', +) const configuratorClient = readFileSync( new URL( '../../../sky_phone/source/client/phone_configurator.lua', @@ -216,12 +235,14 @@ describe('standalone admin panel contracts', () => { it('authorizes every server request without requiring a phone session', () => { expect(server).not.toContain('SkyPhone.RequireSession(source)') expect(server).toContain( - 'Bridge.Framework.HasAdminGroup(source, Config.AdminPanel.AdminGroups)', + 'Bridge.Framework.HasPermission(source, "phonepanel")', ) expect(server).toContain('Config.AdminPanel.ReadRequestsPerMinute') expect(server).toContain('Config.AdminPanel.ActionRequestsPerMinute') expect(server).toContain('Config.AdminPanel.CredentialRevealsPerMinute') expect(config).toContain('Config.AdminPanel = {') + expect(config).toContain('Config.CommandPermissions = {') + expect(config).not.toContain('AdminGroups =') }) it('opens directly from the configurable command with dedicated focus', () => { @@ -272,6 +293,12 @@ describe('standalone admin panel contracts', () => { expect(schema).toContain( 'CREATE TABLE IF NOT EXISTS `sky_phone_configurator`', ) + expect( + manifest.indexOf("'source/shared/config_default.lua'"), + ).toBeGreaterThan(manifest.indexOf("'config/media.lua'")) + expect(manifest.indexOf("'source/shared/config_default.lua'")).toBeLessThan( + manifest.indexOf("'source/server/phone_configurator.lua'"), + ) expect( manifest.indexOf("'source/server/phone_configurator.lua'"), ).toBeLessThan(manifest.indexOf("'source/bridge/server/framework.lua'")) @@ -281,11 +308,36 @@ describe('standalone admin panel contracts', () => { expect(configuratorServer).toContain('AND `revision` = ?') expect(configuratorServer).toContain('configurator_enabled') expect(configuratorServer).toMatch( - /for key, value in pairs\(Config\)[\s\S]*?key ~= "Media"[\s\S]*?key ~= "PhoneConfigurator"/, + /for key, value in pairs\(ConfigDefaults\)[\s\S]*?key ~= "Media"[\s\S]*?key ~= "PhoneConfigurator"/, ) expect(configuratorServer).toContain( - 'default_media = serialize_value(Config.Media)', + 'default_media = serialize_value(ConfigDefaults.Media)', ) + expect(configDefault).toContain( + 'GENERATED by frontend/build.cjs from config/config.lua and config/media.lua', + ) + expect(configDefault).toContain('local realConfig = Config') + expect(configDefault).toContain('ConfigDefaults = Config') + expect(configDefault.replace(/\r\n?/g, '\n').trimEnd()).toBe( + [ + '-- GENERATED by frontend/build.cjs from config/config.lua and config/media.lua - do not edit.', + '-- Escrowed snapshot of the shipped defaults used by the Phone Configurator.', + 'local realConfig = Config', + 'Config = {}', + '', + configDefaultsSource.replace(/\r\n?/g, '\n').trimEnd(), + '', + mediaConfig.replace(/\r\n?/g, '\n').trimEnd(), + '', + 'ConfigDefaults = Config', + 'Config = realConfig', + ].join('\n'), + ) + expect(frontendBuild).toContain("readLuaSource('config', 'config.lua')") + expect(frontendBuild).toContain("readLuaSource('config', 'media.lua')") + expect(frontendBuild).toContain("'config_default.lua'") + expect(configDefault).not.toContain('Config.PhoneConfigurator') + expect(configDefault).not.toContain('Config.CommandPermissions') expect(configuratorServer).toContain( 'build_sections("config", stored_config', ) diff --git a/frontend/src/permissions.contract.test.ts b/frontend/src/permissions.contract.test.ts new file mode 100644 index 0000000..f8daf4a --- /dev/null +++ b/frontend/src/permissions.contract.test.ts @@ -0,0 +1,83 @@ +import { readFileSync } from 'node:fs' + +import { describe, expect, it } from 'vitest' + +function source(path: string): string { + return readFileSync(new URL(path, import.meta.url), 'utf8') +} + +const config = source('../../sky_phone/config/config.lua') +const configDefault = source('../../sky_phone/source/shared/config_default.lua') +const framework = source('../../sky_phone/source/bridge/server/framework.lua') +const qbox = source('../../sky_phone/source/bridge/server/frameworks/qbox.lua') +const configurator = source( + '../../sky_phone/source/server/phone_configurator.lua', +) +const configuratorFixture = source('../testserver/configurator-fixture.cjs') + +describe('fixed server permissions', () => { + it('defines every protected phone capability only in config.lua', () => { + expect(config).toContain('Config.CommandPermissions = {') + for (const permission of [ + 'phonepanel', + 'phonetestdata', + 'fliptokverify', + 'picstagramverify', + 'picstagramadmin', + ]) { + expect(config).toMatch(new RegExp(`\\s${permission} = \\{`)) + } + expect(config).not.toContain('AdminGroups =') + expect(configDefault).not.toContain('Config.PhoneConfigurator') + expect(configDefault).not.toContain('Config.CommandPermissions') + expect(configDefault).not.toContain('AdminGroups =') + }) + + it('keeps fixed permissions outside SQL and removes legacy group fields', () => { + expect(configurator).toContain('key ~= "CommandPermissions"') + expect(configurator).toContain('if key ~= "CommandPermissions" then') + expect(configuratorFixture).toContain('delete config.CommandPermissions') + for (const path of [ + 'AdminPanel.AdminGroups', + 'TestData.AdminGroups', + 'FlipTok.AdminGroups', + 'Picstagram.AdminGroups', + ]) { + expect(configurator).toContain(`["${path}"] = true`) + } + expect(configurator).toContain( + 'Phone Configurator enabled: file-based settings from config.lua', + ) + expect(configurator).toContain( + '(except Config.CommandPermissions) and media.lua are disabled', + ) + }) + + it('authorizes Qbox through ACE before retaining framework group support', () => { + expect(framework).toContain( + 'local groups = Config.CommandPermissions[permission]', + ) + expect(qbox).toContain( + 'if IsPlayerAceAllowed(tostring(source), group) then', + ) + expect(qbox.indexOf('IsPlayerAceAllowed')).toBeLessThan( + qbox.indexOf('exports.qbx_core:HasGroup'), + ) + }) + + it('uses stable permission identifiers for every protected operation', () => { + const expectations = [ + ['../../sky_phone/source/server/admin.lua', 'phonepanel'], + ['../../sky_phone/source/server/testdata.lua', 'phonetestdata'], + ['../../sky_phone/source/server/fliptok.lua', 'fliptokverify'], + ['../../sky_phone/source/server/picstagram.lua', 'picstagramverify'], + ['../../sky_phone/source/server/picstagram.lua', 'picstagramadmin'], + ] as const + + for (const [path, permission] of expectations) { + expect(source(path)).toContain( + `Bridge.Framework.HasPermission(source, "${permission}")`, + ) + } + }) +}) diff --git a/frontend/src/phoneInventory.contract.test.ts b/frontend/src/phoneInventory.contract.test.ts index 029d6ec..f084944 100644 --- a/frontend/src/phoneInventory.contract.test.ts +++ b/frontend/src/phoneInventory.contract.test.ts @@ -52,7 +52,7 @@ describe('phone inventory contracts', () => { ) }) - it('auto-detects registered inventories and limits metadata-free adapters', () => { + it('auto-detects registered inventories and forces metadata-free adapters into compatible modes', () => { const inventoryBridge = readResourceFile( 'source/bridge/server/inventory.lua', ) @@ -65,8 +65,17 @@ describe('phone inventory contracts', () => { ) expect(inventoryBridge).toContain('configured_inventory = adapter.name') expect(inventoryBridge).toContain('selected_adapter.metadata == false') - expect(inventoryBridge).toContain('Config.Phone.Unique ~= false') - expect(inventoryBridge).toContain('Config.Sim.Enabled ~= false') + expect(inventoryBridge).toContain('Config.Phone.Unique = false') + expect(inventoryBridge).toContain('Config.Sim.Enabled = false') + expect(inventoryBridge).toContain( + 'does not support item metadata; unique phones and physical SIM cards were disabled automatically', + ) + expect(inventoryBridge).toContain( + 'AddEventHandler("sky_phone:configurator:serverUpdated"', + ) + expect(inventoryBridge).not.toContain( + 'cannot store unique phone or physical SIM metadata', + ) }) it('provides the LB IsOpen export alias from the authoritative client state', () => { diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index b11094f..148e679 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -945,7 +945,6 @@ const adminPanelFallbackLocales = { entry: 'Entry', general: 'General', subtabs: { - AdminGroups: 'Admin Groups', Dictionaries: 'Dictionaries', Clips: 'Clips', Transforms: 'Transforms', diff --git a/frontend/src/utils/adminConfiguratorDescription.test.ts b/frontend/src/utils/adminConfiguratorDescription.test.ts index b2ee17c..9229d2b 100644 --- a/frontend/src/utils/adminConfiguratorDescription.test.ts +++ b/frontend/src/utils/adminConfiguratorDescription.test.ts @@ -16,9 +16,7 @@ describe('admin configurator descriptions', () => { expect(configuratorDescriptionKey('Media.RequestTimeoutMs', 10000)).toBe( 'milliseconds', ) - expect(configuratorDescriptionKey('AdminPanel.AdminGroups', [])).toBe( - 'access', - ) + expect(configuratorDescriptionKey('Radio.AllowedJobs', [])).toBe('access') expect(configuratorDescriptionKey('FiveManage.ApiKey', '')).toBe( 'credential', ) diff --git a/frontend/testserver/configurator-fixture.cjs b/frontend/testserver/configurator-fixture.cjs index a9e6012..7ccc80e 100644 --- a/frontend/testserver/configurator-fixture.cjs +++ b/frontend/testserver/configurator-fixture.cjs @@ -661,6 +661,7 @@ function loadConfiguratorSections() { ) const media = mediaRoot.Media delete config.PhoneConfigurator + delete config.CommandPermissions delete config.Media return [...buildSections('config', config), ...buildSections('media', media)] } diff --git a/frontend/testserver/configurator-fixture.test.ts b/frontend/testserver/configurator-fixture.test.ts index 8e84c8a..3a46760 100644 --- a/frontend/testserver/configurator-fixture.test.ts +++ b/frontend/testserver/configurator-fixture.test.ts @@ -61,14 +61,19 @@ function countStructure(structure: ConfiguratorStructure | undefined): number { } describe('admin configurator fixture', () => { - it('exposes every config.lua root through the full live preview', () => { + it('exposes every SQL-managed config.lua root through the full live preview', () => { const sections = loadConfiguratorSections() const fields = sections.flatMap((section) => section.fields) const roots = [ ...configSource.matchAll(/^\s{0,4}Config\.([A-Za-z0-9_]+)\s*=/gm), ] .map((match) => match[1]) - .filter((root) => root !== 'Media' && root !== 'PhoneConfigurator') + .filter( + (root) => + root !== 'Media' && + root !== 'PhoneConfigurator' && + root !== 'CommandPermissions', + ) expect(sections).toHaveLength(45) expect( diff --git a/sky_phone/README.md b/sky_phone/README.md index 866e993..9ecaef7 100644 --- a/sky_phone/README.md +++ b/sky_phone/README.md @@ -134,7 +134,7 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting - `hex_4_inventory` - Native ESX inventory -`mf-inventory` and `smx-inventory` are supported with ESX. The native ESX and HEX adapters use count-based items and therefore require unique phones and physical SIM cards to be disabled. +`mf-inventory` and `smx-inventory` are supported with ESX. The native ESX and HEX adapters use count-based items, so Sky Phone automatically disables unique phones and physical SIM cards while either adapter is active. ### Voice @@ -196,6 +196,7 @@ The files contain clearly separated sections for: | Section | Purpose | | --- | --- | | `Config.Bridge` | Framework, inventory, language, callback timeout, and debug mode | +| `Config.CommandPermissions` | Fixed groups for the admin panel, test data, verification commands, and social moderation | | `Config.Phone` | Phone item, movement, unique-device mode, and development command | | `Config.Sim` | Physical or virtual SIM behavior and number formatting | | `Config.Calls` / `Config.Radio` | Voice providers, call behavior, radio limits, and permissions | @@ -211,6 +212,18 @@ The files contain clearly separated sections for: Restart `sky_phone` after changing Lua configuration. +When `Config.PhoneConfigurator.Enabled` is enabled, the generated `source/shared/config_default.lua` +provides the shipped SQL baseline. The frontend build recreates it from `config.lua` and the +server-only `media.lua`; do not edit the generated snapshot directly. + +`Config.PhoneConfigurator` and `Config.CommandPermissions` remain file-owned and are omitted from +the generated default snapshot. Permissions are not displayed in the Phone Configurator and stay +authoritative while SQL configuration is enabled. Their stable keys do not change when a command is +renamed in the panel. ESX and QBCore use their framework permissions. Qbox checks the configured ACE +objects first and then its framework groups, so the standard `permissions.cfg` mapping from +`group.admin` to the `admin` ACE works with the shipped `phonepanel` permission list. Restart +`sky_phone` after changing fixed permissions. + ### Language Available locales: @@ -312,7 +325,7 @@ Do not configure an LB Phone client event or client export. Sky Phone registers The server registers `Config.Phone.Item` as usable for every supported inventory adapter: `ox`, `qb`, `lj`, `qs`, `codem`, `core`, `mf`, `smx`, `hex`, and `esx`. Resource startup fails visibly if the selected adapter cannot complete that registration. -The `hex` and `esx` adapters use ESX's count-based item API. They require both `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` because this API cannot persist per-item phone or physical SIM metadata. `auto` selects `hex` when `hex_4_inventory` is started and otherwise falls back to `esx` on an ESX server when no metadata-capable inventory is detected. +The `hex` and `esx` adapters use ESX's count-based item API, which cannot persist per-item phone or physical SIM metadata. Sky Phone automatically forces `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` while either adapter is active. `auto` selects `hex` when `hex_4_inventory` is started and otherwise falls back to `esx` on an ESX server when no metadata-capable inventory is detected. ### QBCore-style item tables diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index a5d168d..d6b5ea7 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -10,12 +10,27 @@ Keep option names unchanged. Restart sky_phone after editing this file. ]] --- When enabled, config.lua and media.lua only provide first-run defaults. --- The active configuration is loaded from SQL and managed through /phonepanel. +-- CONFIG_DEFAULT_EXCLUDE_START +-- When enabled, the active configuration is loaded from SQL and managed through +-- /phonepanel. Frontend builds snapshot the shipped defaults from config.lua and +-- media.lua into source/shared/config_default.lua. Config.PhoneConfigurator = { Enabled = true, } +-- Fixed server permissions. These values remain authoritative even while the +-- Phone Configurator is enabled and are intentionally not shown in its panel. +-- Group names use the active framework's permissions. On Qbox they also match +-- ACE objects such as "admin" from the standard permissions.cfg. +Config.CommandPermissions = { + phonepanel = { "god", "superadmin", "admin" }, + phonetestdata = { "god", "superadmin", "admin" }, + fliptokverify = { "god", "superadmin", "admin" }, + picstagramverify = { "god", "superadmin", "admin" }, + picstagramadmin = { "god", "superadmin", "admin" }, +} +-- CONFIG_DEFAULT_EXCLUDE_END + -- ============================================================================= -- Core, framework and device -- ============================================================================= @@ -34,7 +49,7 @@ Config.Command = "phone" Config.Phone = { Item = "phone", - Unique = true, -- true: data follows each phone item; false: one persistent phone per character; hex/esx require false + Unique = true, -- true: data follows each phone item; false: one persistent phone per character; forced false for metadata-free inventories Keybind = "F1", -- false disables the configurable phone key mapping AllowMovement = true, -- true: game input stays active while the mobile phone is open HoldToLook = { @@ -49,7 +64,6 @@ Config.TestData = { Enabled = false, -- development/test servers only; keep disabled in production Command = "phonetestdata", AdminOnly = false, -- enable only on development servers; every run is scoped to the executing player's phone - AdminGroups = { "admin", "superadmin" }, } Config.CustomApps = { @@ -76,7 +90,6 @@ Config.Security = { Config.AdminPanel = { Enabled = true, Command = "phonepanel", - AdminGroups = { "admin", "superadmin" }, MaximumPlayers = 128, ReadRequestsPerMinute = 60, ActionRequestsPerMinute = 30, @@ -86,7 +99,7 @@ Config.AdminPanel = { } Config.Sim = { - Enabled = true, -- false: devices receive a persistent random number automatically; hex/esx require false + Enabled = true, -- false: devices receive a persistent random number automatically; forced false for metadata-free inventories RegisteredItem = "sky_phone_sim_registered", AnonymousItem = "sky_phone_sim_anonymous", NumberLength = 10, -- total number of digits, including NumberPrefix @@ -485,7 +498,6 @@ Config.FlipTok = { MaxPostMedia = 10, MusicTracks = {}, VerifyCommand = "fliptokverify", - AdminGroups = { "admin" }, ReportWebhookConvar = "sky_phone_fliptok_report_webhook", } @@ -507,7 +519,6 @@ Config.Picstagram = { ReportDetailsMaxLength = 500, ReportReasons = { "spam", "harassment", "dangerous", "illegal", "other" }, VerifyCommand = "picstagramverify", - AdminGroups = { "admin" }, } Config.Feather = { diff --git a/sky_phone/config/locales/de.lua b/sky_phone/config/locales/de.lua index 565ab7b..d146386 100644 --- a/sky_phone/config/locales/de.lua +++ b/sky_phone/config/locales/de.lua @@ -2139,7 +2139,6 @@ Locales["de"] = { } Locales["de"].Nui.AdminPanel.configurator.table.subtabs = { - AdminGroups = "Admin-Gruppen", Dictionaries = "Animationsdateien", Clips = "Clips", Transforms = "Transformationen", diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 26b74fe..b64e0e9 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -2139,7 +2139,6 @@ Locales["en"] = { } Locales["en"].Nui.AdminPanel.configurator.table.subtabs = { - AdminGroups = "Admin Groups", Dictionaries = "Dictionaries", Clips = "Clips", Transforms = "Transforms", diff --git a/sky_phone/config/locales/es.lua b/sky_phone/config/locales/es.lua index 328fc45..09efafe 100644 --- a/sky_phone/config/locales/es.lua +++ b/sky_phone/config/locales/es.lua @@ -2139,7 +2139,6 @@ Locales["es"] = { } Locales["es"].Nui.AdminPanel.configurator.table.subtabs = { - AdminGroups = "Grupos de administradores", Dictionaries = "Diccionarios", Clips = "Los clips", Transforms = "Transformaciones", diff --git a/sky_phone/config/media.lua b/sky_phone/config/media.lua index 9763b21..81178ca 100644 --- a/sky_phone/config/media.lua +++ b/sky_phone/config/media.lua @@ -2,19 +2,25 @@ Sky Phone media configuration This file is loaded on the server only. Keep API keys private. When the - phone configurator is enabled in config.lua, these values are first-run - defaults and the active media configuration is loaded from SQL. + phone configurator is enabled in config.lua, the active media configuration + is loaded from SQL and frontend builds copy the shipped values from this + file into source/shared/config_default.lua. ]] Config.Media = { - GiphyApiKey = "", -- Paste the GIPHY API key here. + -- Create or sign in to an account at https://developers.giphy.com/, open + -- the Developer Dashboard, choose "Create an API Key", and paste it here. + GiphyApiKey = "", GifPageSize = 24, GifRating = "pg-13", UrlMaxLength = 2048, AllowedGifHosts = { "giphy.com" }, + -- Create or sign in at https://fivemanage.com/, create or select your team, + -- then open Dashboard > Tokens and create a token with Media access. Keep + -- this server-only token private and paste it here. FiveManage = { - ApiKey = "", -- Paste a newly generated FiveManage V3 Media API token here. + ApiKey = "", BaseUrl = "https://api.fivemanage.com/api/v3/file", RequestTimeoutMs = 10000, UploadTimeoutMs = 25000, diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua index ec1a518..fd70af1 100644 --- a/sky_phone/fxmanifest.lua +++ b/sky_phone/fxmanifest.lua @@ -79,6 +79,7 @@ server_scripts { 'config/locales/en.lua', 'config/locales/de.lua', 'config/locales/es.lua', + 'source/shared/config_default.lua', 'source/server/nui_build_check.lua', 'source/server/update_check.lua', 'source/bridge/server/database.lua', diff --git a/sky_phone/source/bridge/server/framework.lua b/sky_phone/source/bridge/server/framework.lua index 66ad9bd..446f13e 100644 --- a/sky_phone/source/bridge/server/framework.lua +++ b/sky_phone/source/bridge/server/framework.lua @@ -19,3 +19,23 @@ Bridge.Framework.Name = configured_framework function Bridge.Framework.GetName() return Bridge.Framework.Name end + +function Bridge.Framework.HasPermission(source, permission) + if type(permission) ~= "string" or permission == "" then + error("[sky_phone] Permission identifiers must be non-empty strings.") + end + + local groups = Config.CommandPermissions[permission] + if type(groups) ~= "table" or #groups == 0 then + local message = "[sky_phone] Config.CommandPermissions.%s must contain at least one group." + error(message:format(permission)) + end + for index, group in ipairs(groups) do + if type(group) ~= "string" or group == "" then + local message = "[sky_phone] Config.CommandPermissions.%s[%s] must be a non-empty string." + error(message:format(permission, index)) + end + end + + return Bridge.Framework.HasAdminGroup(source, groups) +end diff --git a/sky_phone/source/bridge/server/frameworks/qbox.lua b/sky_phone/source/bridge/server/frameworks/qbox.lua index 819b284..cf75d9e 100644 --- a/sky_phone/source/bridge/server/frameworks/qbox.lua +++ b/sky_phone/source/bridge/server/frameworks/qbox.lua @@ -24,6 +24,13 @@ function Bridge.Framework.HasAdminGroup(source, groups) if not player then return false end + + for _, group in ipairs(groups) do + if IsPlayerAceAllowed(tostring(source), group) then + return true + end + end + return exports.qbx_core:HasGroup(source, groups) end diff --git a/sky_phone/source/bridge/server/inventory.lua b/sky_phone/source/bridge/server/inventory.lua index 35d9b97..f6e1274 100644 --- a/sky_phone/source/bridge/server/inventory.lua +++ b/sky_phone/source/bridge/server/inventory.lua @@ -61,13 +61,30 @@ end Bridge.Inventory.Name = configured_inventory -if selected_adapter.metadata == false then - if Config.Phone.Unique ~= false or Config.Sim.Enabled ~= false then - Bridge.Inventory.ConfigurationError = ("[sky_phone] Inventory '%s' cannot store unique phone or physical SIM metadata. Set Config.Phone.Unique = false and Config.Sim.Enabled = false, or configure a metadata-capable inventory.") - :format(configured_inventory) +local metadata_free_inventory = selected_adapter.metadata == false + +local function enforce_metadata_compatibility() + if not metadata_free_inventory then + return + end + + local modes_changed = Config.Phone.Unique ~= false or Config.Sim.Enabled ~= false + Config.Phone.Unique = false + Config.Sim.Enabled = false + + if modes_changed then + Bridge.Debug( + "warn", + "[sky_phone] Inventory '%s' does not support item metadata; unique phones and physical SIM cards were disabled automatically.", + configured_inventory + ) end end +enforce_metadata_compatibility() + +AddEventHandler("sky_phone:configurator:serverUpdated", enforce_metadata_compatibility) + function Bridge.Inventory.NormalizeItem(item, metadata_field, fallback_slot) if type(item) ~= "table" then return nil diff --git a/sky_phone/source/server/admin.lua b/sky_phone/source/server/admin.lua index 1923276..b4053e0 100644 --- a/sky_phone/source/server/admin.lua +++ b/sky_phone/source/server/admin.lua @@ -98,7 +98,7 @@ end local function require_admin(source, operation, maximum) if not Config.AdminPanel.Enabled - or not Bridge.Framework.HasAdminGroup(source, Config.AdminPanel.AdminGroups) + or not Bridge.Framework.HasPermission(source, "phonepanel") then Bridge.Debug("warn", "[sky_phone] Rejected admin panel access from source %s.", tostring(source)) return nil, { success = false, error = "not_authorized" } @@ -121,7 +121,7 @@ local function run_admin_command(command_source) TriggerClientEvent("sky_phone:admin:command-error", player_source, "disabled") return end - if not Bridge.Framework.HasAdminGroup(player_source, Config.AdminPanel.AdminGroups) then + if not Bridge.Framework.HasPermission(player_source, "phonepanel") then Bridge.Debug( "warn", "[sky_phone] Rejected admin panel command from source %s.", diff --git a/sky_phone/source/server/fliptok.lua b/sky_phone/source/server/fliptok.lua index 7b53db7..304db6e 100644 --- a/sky_phone/source/server/fliptok.lua +++ b/sky_phone/source/server/fliptok.lua @@ -943,7 +943,7 @@ local function run_verify_command(source, arguments) notificationType = notification_type, }) end - if source ~= 0 and not Bridge.Framework.HasAdminGroup(source, Config.FlipTok.AdminGroups) then + if source ~= 0 and not Bridge.Framework.HasPermission(source, "fliptokverify") then send_command_feedback(command_locale.noPermission, "error") Bridge.Debug( "warn", diff --git a/sky_phone/source/server/phone_configurator.lua b/sky_phone/source/server/phone_configurator.lua index 4d600c5..3a1e86c 100644 --- a/sky_phone/source/server/phone_configurator.lua +++ b/sky_phone/source/server/phone_configurator.lua @@ -17,6 +17,21 @@ local updated_at local updated_by_name local is_sequence +local FIXED_CONFIG_PATHS = { + CommandPermissions = true, + ["AdminPanel.AdminGroups"] = true, + ["TestData.AdminGroups"] = true, + ["FlipTok.AdminGroups"] = true, + ["Picstagram.AdminGroups"] = true, +} + +if configurator_enabled then + print( + "[sky_phone] Phone Configurator enabled: file-based settings from config.lua " .. + "(except Config.CommandPermissions) and media.lua are disabled; SQL configuration is active." + ) +end + local CLIENT_CONFIG_KEYS = { AdminPanel = true, Animations = true, @@ -252,7 +267,7 @@ local function upgrade_legacy_map(defaults, saved) return { __skyType = "map", entries = entries } end -local function merge_values(defaults, saved, path) +local function merge_values(defaults, saved, path, excluded_paths) path = path or "" if type(defaults) ~= "table" or type(saved) ~= "table" then return copy_value(saved) @@ -278,7 +293,12 @@ local function merge_values(defaults, saved, path) key = entry.key, keyType = entry.keyType, value = saved_entry - and merge_values(entry.value, saved_entry.value, path .. "." .. tostring(entry.key)) + and merge_values( + entry.value, + saved_entry.value, + path .. "." .. tostring(entry.key), + excluded_paths + ) or copy_value(entry.value), } included[identity] = true @@ -298,7 +318,7 @@ local function merge_values(defaults, saved, path) local merged = copy_value(saved) for index, child in ipairs(defaults) do merged[index] = saved[index] ~= nil - and merge_values(child, saved[index], path .. "." .. tostring(index)) + and merge_values(child, saved[index], path .. "." .. tostring(index), excluded_paths) or copy_value(child) end return merged @@ -309,11 +329,13 @@ local function merge_values(defaults, saved, path) local merged = copy_value(defaults) for key, child in pairs(saved) do - if merged[key] ~= nil then - local child_path = path == "" and tostring(key) or (path .. "." .. tostring(key)) - merged[key] = merge_values(merged[key], child, child_path) - else - merged[key] = copy_value(child) + local child_path = path == "" and tostring(key) or (path .. "." .. tostring(key)) + if not excluded_paths or not excluded_paths[child_path] then + if merged[key] ~= nil then + merged[key] = merge_values(merged[key], child, child_path, excluded_paths) + else + merged[key] = copy_value(child) + end end end return merged @@ -356,10 +378,12 @@ local function apply_runtime_configuration() local runtime_config = deserialize_value(stored_config) for key, value in pairs(runtime_config) do - if type(Config[key]) == "table" and type(value) == "table" then - apply_runtime_table(Config[key], value) - else - Config[key] = value + if key ~= "CommandPermissions" then + if type(Config[key]) == "table" and type(value) == "table" then + apply_runtime_table(Config[key], value) + else + Config[key] = value + end end end local runtime_media = deserialize_value(stored_media) @@ -1032,7 +1056,12 @@ local function read_stored_row() end local function apply_stored_row(row) - stored_config = merge_values(default_config, decode_payload(row.config_payload, "config"), "") + stored_config = merge_values( + default_config, + decode_payload(row.config_payload, "config"), + "", + FIXED_CONFIG_PATHS + ) stored_media = merge_values(default_media, decode_payload(row.media_payload, "media"), "") revision = tonumber(row.revision) or 1 updated_at = row.updated_at @@ -1040,12 +1069,12 @@ local function apply_stored_row(row) end default_config = {} -for key, value in pairs(Config) do - if key ~= "Media" and key ~= "PhoneConfigurator" then +for key, value in pairs(ConfigDefaults) do + if key ~= "Media" and key ~= "PhoneConfigurator" and key ~= "CommandPermissions" then default_config[key] = serialize_value(value) end end -default_media = serialize_value(Config.Media) +default_media = serialize_value(ConfigDefaults.Media) Bridge.Database.Migrate("sky_phone_configurator", { SkyPhoneConfiguratorSchema }) Bridge.Database.Query(([[ diff --git a/sky_phone/source/server/picstagram.lua b/sky_phone/source/server/picstagram.lua index a496c5f..a86328c 100644 --- a/sky_phone/source/server/picstagram.lua +++ b/sky_phone/source/server/picstagram.lua @@ -319,7 +319,7 @@ local function create_activity(recipient_id, actor_id, kind, post_id) end local function is_admin(source) - return Bridge.Framework.HasAdminGroup(source, Config.Picstagram.AdminGroups) + return Bridge.Framework.HasPermission(source, "picstagramadmin") end Bridge.Callbacks.Register("sky_phone:picstagram:register", function(source, data) @@ -1464,7 +1464,7 @@ local function run_verify_command(source, args) notificationType = notification_type, }) end - if source ~= 0 and not Bridge.Framework.HasAdminGroup(source, Config.Picstagram.AdminGroups) then + if source ~= 0 and not Bridge.Framework.HasPermission(source, "picstagramverify") then send_command_feedback(command_locale.noPermission, "error") Bridge.Debug( "warn", diff --git a/sky_phone/source/server/testdata.lua b/sky_phone/source/server/testdata.lua index edd857a..c35d28a 100644 --- a/sky_phone/source/server/testdata.lua +++ b/sky_phone/source/server/testdata.lua @@ -1045,7 +1045,7 @@ local function run_test_data_command(source) Bridge.Debug("warn", "[sky_phone] The test data command must be run by an in-game player.") return end - if Config.TestData.AdminOnly and not Bridge.Framework.HasAdminGroup(source, Config.TestData.AdminGroups) then + if Config.TestData.AdminOnly and not Bridge.Framework.HasPermission(source, "phonetestdata") then Bridge.Debug("warn", "[sky_phone] Source %s attempted to run the restricted test data command.", tostring(source)) TriggerClientEvent("sky_phone:testdata:feedback", source, false) return diff --git a/sky_phone/source/shared/config_default.lua b/sky_phone/source/shared/config_default.lua new file mode 100644 index 0000000..f72ecee --- /dev/null +++ b/sky_phone/source/shared/config_default.lua @@ -0,0 +1,1557 @@ +-- GENERATED by frontend/build.cjs from config/config.lua and config/media.lua - do not edit. +-- Escrowed snapshot of the shipped defaults used by the Phone Configurator. +local realConfig = Config +Config = {} + +--[[ + Sky Phone configuration + + General settings live in this file. Media providers and upload limits live + in config/media.lua, while translations remain in config/locales/*.lua. + Sections marked "SERVER ONLY" are guarded with IsDuplicityVersion(), so + passwords, API keys, migration settings, and server-owned locations are not + applied by game clients. + + Keep option names unchanged. Restart sky_phone after editing this file. +]] + + +-- ============================================================================= +-- Core, framework and device +-- ============================================================================= + +Config.Bridge = { + Framework = "auto", -- auto, esx, qbox, qb + -- auto, ak47, codem, core, jaksam, jpr, lj, mf, one, origen, ox, ps, qb, qs, smx, tgiann, hex, esx + -- Compatibility aliases: qb-inv -> qb, qbox -> ox + Inventory = "auto", + Locale = "en", + CallbackTimeout = 15000, + Debug = false, -- true: show debug/info output; warnings and errors are always shown +} + +Config.Command = "phone" + +Config.Phone = { + Item = "phone", + Unique = true, -- true: data follows each phone item; false: one persistent phone per character; forced false for metadata-free inventories + Keybind = "F1", -- false disables the configurable phone key mapping + AllowMovement = true, -- true: game input stays active while the mobile phone is open + HoldToLook = { + Enabled = true, -- hold the configured control to hide the cursor and look around; independent of AllowMovement + Control = 19, -- INPUT_CHARACTER_WHEEL (Left Alt by default) + }, + DevelopmentCommand = true, + DeviceName = "iFruit Phone", +} + +Config.TestData = { + Enabled = false, -- development/test servers only; keep disabled in production + Command = "phonetestdata", + AdminOnly = false, -- enable only on development servers; every run is scoped to the executing player's phone +} + +Config.CustomApps = { + Enabled = true, + BundledApps = true, + ExternalApps = true, + Debug = false, -- detailed client traces for exports, registration, catalog sync and lifecycle events + ReadyTimeoutMs = 8000, + MaximumMessageBytes = 65536, + MaximumStorageBytesPerApp = 262144, + MaximumStorageValueBytes = 65536, -- Bridge v1 ceiling; lower values tighten the server policy. + MaximumStorageKeyLength = 64, -- Bridge v1 ceiling; lower values tighten the server policy. + MaximumStorageKeysPerApp = 128, + StorageRequestsPerMinute = 120, + TrustedAdapters = {}, +} + +Config.Security = { + MaximumAttempts = 5, + LockSeconds = 30, + AttemptsPerMinute = 12, +} + +Config.AdminPanel = { + Enabled = true, + Command = "phonepanel", + MaximumPlayers = 128, + ReadRequestsPerMinute = 60, + ActionRequestsPerMinute = 30, + CredentialRevealsPerMinute = 6, + AuditLimit = 40, + ActivityLimit = 40, +} + +Config.Sim = { + Enabled = true, -- false: devices receive a persistent random number automatically; forced false for metadata-free inventories + RegisteredItem = "sky_phone_sim_registered", + AnonymousItem = "sky_phone_sim_anonymous", + NumberLength = 10, -- total number of digits, including NumberPrefix + NumberPrefix = "", -- digits only; use "555", not "555-" + NumberGroups = { 3, 3, 4 }, -- display groups separated by spaces +} + +-- ============================================================================= +-- Calls, voice and radio +-- ============================================================================= + +Config.Speaker = { + Enabled = true, -- global phone and radio speaker controls +} + +Config.Calls = { + VoiceProvider = "pma", -- yaca (alias: yaca-voice), pma (alias: pma-voice), saltychat (alias: salty) + RingSeconds = 30, + ContactNameMaxLength = 80, + ContactNotesMaxLength = 500, + RecentPageSize = 100, +} + +Config.Payphones = { + Enabled = true, + -- Existing world props with these models are detected automatically. + Props = { + "prop_phonebox_01b", + "p_phonebox_01b_s", + "prop_phonebox_01a", + "prop_phonebox_04", + }, + -- Additional booths are spawned only at these custom positions. + CustomProp = "prop_phonebox_01b", + CustomLocations = { + -- vector4(x, y, z, heading), + }, + ReplacementProp = "sf_prop_sf_phonebox_01b_s", + PricePerSecond = 1, + PaymentAccount = "cash", -- cash or bank + Currency = "$", + NoAnswerTimeoutSeconds = 30, + CallerNumber = "PAYPHONE", + InteractionDistance = 1.8, + ServerValidationDistance = 3.0, + MaximumCallDistance = 4.0, + ScanDistance = 25.0, + ScanIntervalMs = 1000, + ModelLoadTimeoutMs = 5000, + Animation = { + Dictionary = "anim@scripted@payphone_hits@male@", + PedClip = "FXFR_PAV_1_INTRO_MALE", + PropClip = "FXFR_PAV_1_INTRO_PHONE", + HangupDurationMs = 2000, + }, +} + +Config.Radio = { + VoiceProvider = "auto", -- auto, yaca, pma, saltychat + DefaultVolume = 50, + HistoryLimit = 8, + FrequencyMin = 0.1, + FrequencyMax = 999.9, + FrequencyDecimals = 1, + AllowSecondary = true, + Notifications = false, + AutoRejoin = false, + DisplayName = { + Enabled = true, + MaxLength = 32, + AllowedJobs = { -- Job name = minimum grade. Unlisted jobs cannot set a radio display name. + police = 0, + sheriff = 0, + fib = 0, + army = 0, + ambulance = 0, + }, + }, + Hud = { + Enabled = true, + SpeakerPersistMilliseconds = 3000, + Position = { + Horizontal = "right", -- left or right + Vertical = "top", -- top or bottom + HorizontalOffset = 2.0, -- vh + VerticalOffset = 30.0, -- vh + }, + }, + Badge = { + Enabled = true, + MaxLength = 8, + ForbiddenPatterns = { "88", "1488", "18", "14", "28", "198" }, + }, + LockedChannels = { + { + range = { 0.1, 100.0 }, + jobs = { + police = true, + sheriff = true, + fib = true, + army = true, + ambulance = true, + }, + }, + }, +} + +-- ============================================================================= +-- Phone presentation and animations +-- ============================================================================= + +Config.Animations = { + Enabled = true, + PropModel = "prop_npc_phone_02", + PropBone = 28422, + LoadTimeoutMs = 5000, + ContextPollMs = 250, + Dictionaries = { + OnFoot = "cellphone@", + Driver = "anim@cellphone@in_car@ds", + Passenger = "anim@cellphone@in_car@ps", + Camera = "cellphone@self", + }, + Clips = { + TextIn = "cellphone_text_in", + TextRead = "cellphone_text_read_base", + TextOut = "cellphone_text_out", + TextToCall = "cellphone_text_to_call", + CallListen = "cellphone_call_listen_base", + CallToText = "cellphone_call_to_text", + CallOut = "cellphone_call_out", + Camera = "selfie", + }, + Transforms = { + Portrait = { + position = vector3(0.0, 0.0, 0.0), + rotation = vector3(0.0, 0.0, 0.0), + }, + Landscape = { + position = vector3(0.0, 0.0, 0.0), + rotation = vector3(0.0, 0.0, 90.0), + }, + }, +} + +-- ============================================================================= +-- Built-in applications +-- ============================================================================= + +Config.Messages = { + BodyMaxLength = 2000, + ConversationScanLimit = 1000, + ThreadPageSize = 200, + SendsPerMinute = 30, + MediaLoadsPerMinute = 120, + VoiceMaxDurationMs = 30000, + VoiceMaxBase64Length = 180000, + VoiceWaveformSamples = 48, + VideoMaxDurationMs = 30000, + DeleteBatchSize = 20, +} + +Config.Memos = { + MaximumCount = 100, + MaximumDurationMs = 300000, + MaximumBytes = 2 * 1024 * 1024, + MaximumWaveformSamples = 96, + TitleMaxLength = 120, + NoteMaxLength = 2000, + UploadsPerMinute = 10, + WritesPerMinute = 60, + ListsPerMinute = 60, + MaximumPendingUploads = 1, + UploadSessionTimeoutMs = 60000, +} + +Config.EasyShare = { + Enabled = true, + DefaultVisibility = "everyone", -- everyone, contacts or hidden + MaximumDistance = 15.0, + HistoryLimit = 50, + PendingSeconds = 30, + TransferDurationMs = 3000, + RequestsPerMinute = 12, + BootstrapRequestsPerMinute = 30, + VisibilityUpdatesPerMinute = 10, + ActionsPerMinute = 30, + PayloadMaxBytes = 24000, +} + +Config.DarkChat = { + AliasMaxLength = 32, + BodyMaxLength = 2000, + ThreadPageSize = 200, + SendsPerMinute = 30, + ActionsPerMinute = 60, + ReportsPerDay = 10, + VoiceMaxDurationMs = 60000, + VoiceMaxBase64Length = 360000, + VoiceWaveformSamples = 48, + CleanupIntervalSeconds = 30, + AllowedDisappearTimers = { + [0] = true, + [-1] = true, -- after reading + [60] = true, + [300] = true, + [3600] = true, + [86400] = true, + [604800] = true, + }, +} + +Config.Mail = { + Domain = "ifruit.com", + LocalPartMinLength = 3, + LocalPartMaxLength = 32, + PasswordMinLength = 6, + PasswordMaxLength = 64, + SubjectMaxLength = 120, + BodyMaxLength = 20000, + MaxRecipients = 10, + PageSize = 50, + AuthAttemptsPerMinute = 5, + MailboxNameMaxLength = 50, + MaxMailboxes = 20, + MailboxRequestsPerMinute = 12, + DeleteBatchSize = 50, + DeleteRequestsPerMinute = 12, +} + +Config.Banking = { + Currency = "$", + MinimumAmount = 1, + MaximumAmount = 1000000, + ActionsPerMinute = 12, + HistoryLimit = 50, +} + +Config.Health = { + DailyStepGoal = 8000, + SampleIntervalMs = 500, + ReportIntervalSeconds = 30, + ReportsPerMinute = 4, + MaximumSpeedMetersPerSecond = 12.0, + EmergencyNumber = "911", + ProfileTextMaxLength = 500, +} + +Config.Billing = { + Enabled = true, + Currency = "$", + PaymentAccount = "bank", -- bank or cash + MinimumAmount = 1, + MaximumAmount = 1000000, + PageSize = 30, + UrgentLimit = 5, + ActionsPerMinute = 8, + AllowDisputes = true, + DefaultDueDays = 7, + MaximumTitleLength = 160, + MaximumDescriptionLength = 1000, +} + +Config.Garage = { + System = "auto", -- auto, custom, esx, qb, qbox, ak47, bp, cd, codem, ds-servercreator, hex, jg, my, okok, op, quasar, rx, vms, ws, zyke_garages + MaximumVehicles = 250, + RequestsPerMinute = 30, + VehicleImages = { + Enabled = true, + UrlTemplate = "https://cdn.sky-systems.net/vehicles/{model}.png", + -- Optional spawn-name overrides for garages that only store model hashes. + -- Example: [970598228] = "sultan", + ModelNames = {}, + }, + Custom = { + Table = "", + OwnerColumn = "", + }, + Valet = { + Enabled = true, + Price = 750, + Account = "bank", -- bank or cash + RequestsPerMinute = 3, + CooldownSeconds = 60, + TimeoutSeconds = 180, + SpawnDistance = 110.0, + ArrivalDistance = 14.0, + DriveSpeed = 20.0, + DrivingStyle = 786603, + DriverModel = "s_m_m_autoshop_01", + -- The current valet driver route supports road vehicles. Keep non-road types disabled. + VehicleTypes = { + car = true, + bike = true, + boat = false, + plane = false, + helicopter = false, + }, + }, +} + +Config.Housing = { + System = "auto", -- auto, rtx, quasar, vms, rx, nolag, sn, esx_property, qbx_properties + AutoPriority = { "esx_property", "qbx_properties", "rtx", "quasar", "vms", "rx", "nolag", "sn" }, + MaximumProperties = 50, + OverviewRequestsPerMinute = 30, + ActionsPerMinute = 12, + Camera = { + HeightAboveEntrance = 2.5, + FieldOfView = 55.0, + MinimumFieldOfView = 20.0, + MaximumFieldOfView = 75.0, + RotateSpeed = 0.65, + ExitControl = 177, -- Backspace + NightVisionControl = 38, -- E + ZoomInControl = 241, -- Mouse wheel up + ZoomOutControl = 242, -- Mouse wheel down + LeftControl = 174, + RightControl = 175, + UpControl = 172, + DownControl = 173, + }, +} + +Config.Marketplace = { + PageSize = 20, + MessagePageSize = 50, + OfferHistorySize = 50, + MaxActiveListings = 15, + MaxImages = 6, + TitleMinLength = 5, + TitleMaxLength = 70, + DescriptionMinLength = 20, + DescriptionMaxLength = 2000, + MessageMaxLength = 1000, + MaximumPrice = 100000000, + ListingLifetimeDays = 7, + Categories = { + "vehicles", + "property", + "electronics", + "clothing", + "tools", + "leisure", + "services", + "jobs", + "wanted", + "other", + }, + Districts = { + "los_santos", + "vinewood", + "vespucci", + "south_los_santos", + "sandy_shores", + "paleto_bay", + "blaine_county", + }, + PhotoGradients = { + "linear-gradient(145deg, #ff9a62, #5f2c82 58%, #141e30)", + "linear-gradient(160deg, #67d5b5, #26648e 55%, #0b132b)", + "linear-gradient(135deg, #fbc2eb, #a6c1ee 48%, #302b63)", + "linear-gradient(150deg, #f6d365, #fda085 45%, #512b58)", + "linear-gradient(145deg, #ff6b6b, #845ec2 52%, #0f2027)", + "linear-gradient(150deg, #00c9a7, #4d8076 46%, #1f3a5f)", + "linear-gradient(135deg, #ffc75f, #f96d80 48%, #4b4453)", + }, +} + +Config.LocalPages = { + PageSize = 20, + MaxImages = 6, + ProfileHandleMinLength = 3, + ProfileHandleMaxLength = 24, + ProfileBioMaxLength = 160, + TitleMinLength = 5, + TitleMaxLength = 80, + BodyMinLength = 10, + BodyMaxLength = 1500, + Categories = { "recommendation", "wanted", "service", "event", "place", "community" }, + CityMarktSharesPerDay = 1, +} + +-- ============================================================================= +-- Social applications +-- ============================================================================= + +Config.FlipTok = { + PageSize = 12, + CaptionMaxLength = 500, + CommentMaxLength = 300, + BioMaxLength = 160, + PasswordMinLength = 8, + PasswordMaxLength = 72, + MaxVideoDurationMs = 300000, + MaxPostMedia = 10, + MusicTracks = {}, + VerifyCommand = "fliptokverify", + ReportWebhookConvar = "sky_phone_fliptok_report_webhook", +} + +Config.Picstagram = { + PageSize = 12, + CommentPageSize = 100, + MaxPostMedia = 5, + CaptionMaxLength = 800, + CommentMaxLength = 300, + BioMaxLength = 160, + LocationMaxLength = 80, + StoryTextMaxLength = 160, + StoryLifetimeSeconds = 24 * 60 * 60, + PasswordMinLength = 8, + PasswordMaxLength = 72, + PostsPerMinute = 6, + StoriesPerMinute = 6, + CommentsPerMinute = 20, + ReportDetailsMaxLength = 500, + ReportReasons = { "spam", "harassment", "dangerous", "illegal", "other" }, + VerifyCommand = "picstagramverify", +} + +Config.Feather = { + PageSize = 20, + TextMaxLength = 360, + BioMaxLength = 160, + DisplayNameMaxLength = 50, + HandleMaxLength = 30, + SearchMaxLength = 80, + HashtagMaxLength = 64, + TrendingTopicLimit = 10, + ReportDetailsMaxLength = 500, + MaxImages = 4, + PostsPerMinute = 8, + ActionsPerMinute = 90, + ReportsPerDay = 10, +} + +-- ============================================================================= +-- Utilities, groups and transport +-- ============================================================================= + +Config.MapMarkers = { + MaximumMarkers = 50, + LabelMaxLength = 40, + ActionsPerMinute = 60, +} + +Config.CrewLink = { + UsernameMinLength = 3, + UsernameMaxLength = 20, + PasswordMinLength = 8, + PasswordMaxLength = 72, + GroupNameMinLength = 3, + GroupNameMaxLength = 32, + MaximumGroupsPerProfile = 5, + MaximumMembersPerGroup = 16, + InviteCodeLength = 8, + InviteLifetimeSeconds = 30, + NearbyInviteDistance = 5.0, + NearbyScanLimit = 12, + PingLabelMaxLength = 48, + PingLifetimeSeconds = 300, + MaximumActivePings = 12, + ActionsPerMinute = 30, + LiveRequestsPerMinute = 120, + OverheadRefreshMilliseconds = 3000, + OverheadDistance = 50.0, + ExternalPingResources = { + -- ["example_resource"] = true, + }, +} + +Config.Calendar = { + TitleMaxLength = 120, + NoteMaxLength = 2000, + MaximumDurationSeconds = 7 * 24 * 60 * 60, + MaximumQuerySeconds = 370 * 24 * 60 * 60, + PastEditSeconds = 365 * 24 * 60 * 60, + FutureSeconds = 5 * 365 * 24 * 60 * 60, + ReminderPollSeconds = 15, +} + +Config.SkyRide = { + PaymentAccount = "bank", + Currency = "$", + ProfileNameMinLength = 2, + ProfileNameMaxLength = 50, + DistanceUnit = "kilometer", -- kilometer or mile + QuoteLifetimeSeconds = 120, + HistoryLimit = 50, + ReadsPerMinute = 120, + ActionsPerMinute = 30, + QuotesPerMinute = 20, + RecoveryIntervalSeconds = 15, + PaymentPendingRecoverySeconds = 30, + PickupSelectionRadius = 150.0, + ArrivalRadius = 40.0, + CompletionRadius = 80.0, + MinimumDistanceMeters = 250, + MaximumDistanceMeters = 40000, + RouteDistanceMultiplier = 1.25, + AverageSpeedMetersPerSecond = 12.0, + DriverPayoutPercent = 85, + RatingCommentMaxLength = 300, + MaximumTip = 10000, + CustomFare = { + Enabled = true, + MinimumPrice = 5, + MaximumPrice = 100000, + MinimumCalculatedMultiplier = 0.5, + MaximumCalculatedMultiplier = 3.0, + }, + DriverJobs = { + taxi = 0, + }, + Services = { + { + Id = "taxi", + Seats = 4, + EtaMinutes = 3, + BaseFare = 12, + PricePerKilometer = 18, + PricePerMile = 29, + PricePerMinute = 1, + MinimumFare = 15, + }, + { + Id = "comfort", + Seats = 4, + EtaMinutes = 5, + BaseFare = 16, + PricePerKilometer = 24, + PricePerMile = 39, + PricePerMinute = 2, + MinimumFare = 22, + }, + { + Id = "xl", + Seats = 6, + EtaMinutes = 7, + BaseFare = 20, + PricePerKilometer = 30, + PricePerMile = 48, + PricePerMinute = 2, + MinimumFare = 28, + }, + { + Id = "premium", + Seats = 4, + EtaMinutes = 8, + BaseFare = 28, + PricePerKilometer = 40, + PricePerMile = 64, + PricePerMinute = 3, + MinimumFare = 38, + }, + }, + QuickLocations = { + { + Id = "legion-square", + Label = "Legion Square", + Coords = vector3(-265.1, -960.2, 31.2), + }, + { + Id = "diamond-casino", + Label = "Diamond Casino", + Coords = vector3(925.2, 46.4, 81.1), + }, + { + Id = "airport", + Label = "Los Santos Airport", + Coords = vector3(-1037.7, -2737.8, 20.2), + }, + { + Id = "vinewood", + Label = "Vinewood", + Coords = vector3(-594.4, -929.9, 23.9), + }, + }, +} + +Config.Crypto = { + Enabled = true, + Currency = "$", + BankAccount = "bank", + AssetScale = 1000000, + PriceScale = 100, + QuoteLifetimeSeconds = 8, + PriceTickMinimumSeconds = 7, + PriceTickMaximumSeconds = 12, + MarketsPerTickMinimum = 8, + MarketsPerTickMaximum = 12, + TickMovementDivisor = 10, + RandomImpulseDivisor = 3, + MomentumDecayBasisPoints = 8000, + MomentumImpulseBasisPoints = 2000, + MeanReversionBasisPoints = 55, + CycleDurationMinimumTicks = 14, + CycleDurationMaximumTicks = 30, + CycleStrengthMinimumBasisPoints = 2800, + CycleStrengthMaximumBasisPoints = 5200, + CycleTransitionBasisPoints = 1800, + GlobalCycleMinimumTicks = 24, + GlobalCycleMaximumTicks = 54, + GlobalTrendMinimumBasisPoints = 8, + GlobalTrendMaximumBasisPoints = 24, + MarketShockChanceBasisPoints = 100, + MarketShockMinimumMultiplier = 2, + MarketShockMaximumMultiplier = 3, + MaximumMovementMultiplier = 3, + HistoryRetentionTicks = 4096, + SparklinePoints = 48, + SessionSeconds = 30 * 60, + RecentAuthenticationSeconds = 5 * 60, + PasswordMinLength = 8, + PasswordMaxLength = 72, + HandleMinLength = 3, + HandleMaxLength = 20, + FeeBasisPoints = 75, + MinimumFee = 1, + MinimumSettlement = 10, + MaximumSettlement = 250000, + MaximumTradeNotional = 100000, + MaximumPositionQuantity = 100000, + MaximumTransferQuantity = 100000, + DailyDepositLimit = 500000, + DailyWithdrawalLimit = 250000, + DailyTradeLimit = 500000, + ActionsPerMinute = 30, + LoginAttempts = 5, + LockoutSeconds = 5 * 60, + TreasuryCash = 5000000, + Markets = { + { + Id = "aurora", + Symbol = "AUR", + Name = "Aurora", + Color = "#25d9ad", + Logo = "◈", + InitialPrice = 12850, + MinimumPrice = 3000, + MaximumPrice = 80000, + VolatilityBasisPoints = 180, + IssuedSupply = 1000000, + TreasuryInventory = 850000, + }, + { + Id = "vertex", + Symbol = "VTX", + Name = "Vertex", + Color = "#4d8cff", + Logo = "◆", + InitialPrice = 4275, + MinimumPrice = 800, + MaximumPrice = 30000, + VolatilityBasisPoints = 260, + IssuedSupply = 2500000, + TreasuryInventory = 2100000, + }, + { + Id = "ember", + Symbol = "EMB", + Name = "Ember", + Color = "#ff9d54", + Logo = "✦", + InitialPrice = 980, + MinimumPrice = 100, + MaximumPrice = 12000, + VolatilityBasisPoints = 340, + IssuedSupply = 8000000, + TreasuryInventory = 6800000, + }, + { + Id = "titan", + Symbol = "TTN", + Name = "Titan", + Color = "#8f7cff", + Logo = "⬢", + InitialPrice = 425000000, + MinimumPrice = 100000000, + MaximumPrice = 900000000, + VolatilityBasisPoints = 65, + IssuedSupply = 21000, + TreasuryInventory = 17500, + }, + { + Id = "crown", + Symbol = "CRN", + Name = "Crown", + Color = "#f6c453", + Logo = "♛", + InitialPrice = 128000000, + MinimumPrice = 25000000, + MaximumPrice = 350000000, + VolatilityBasisPoints = 85, + IssuedSupply = 64000, + TreasuryInventory = 51000, + }, + { + Id = "atlas", + Symbol = "ATL", + Name = "Atlas", + Color = "#e66f9e", + Logo = "▲", + InitialPrice = 12000000, + MinimumPrice = 3000000, + MaximumPrice = 30000000, + VolatilityBasisPoints = 110, + IssuedSupply = 250000, + TreasuryInventory = 205000, + }, + { + Id = "zenith", + Symbol = "ZNT", + Name = "Zenith", + Color = "#6b8cff", + Logo = "✥", + InitialPrice = 68000000, + MinimumPrice = 18000000, + MaximumPrice = 160000000, + VolatilityBasisPoints = 95, + IssuedSupply = 125000, + TreasuryInventory = 99000, + }, + { + Id = "celestium", + Symbol = "CLS", + Name = "Celestium", + Color = "#8ad8ff", + Logo = "✧", + InitialPrice = 8450000, + MinimumPrice = 1500000, + MaximumPrice = 22000000, + VolatilityBasisPoints = 125, + IssuedSupply = 420000, + TreasuryInventory = 340000, + }, + { + Id = "orbit", + Symbol = "ORB", + Name = "Orbit", + Color = "#57c7ff", + Logo = "◎", + InitialPrice = 1244000, + MinimumPrice = 250000, + MaximumPrice = 3500000, + VolatilityBasisPoints = 145, + IssuedSupply = 900000, + TreasuryInventory = 735000, + }, + { + Id = "helix", + Symbol = "HLX", + Name = "Helix", + Color = "#c47cff", + Logo = "⌁", + InitialPrice = 582000, + MinimumPrice = 90000, + MaximumPrice = 1600000, + VolatilityBasisPoints = 175, + IssuedSupply = 1400000, + TreasuryInventory = 1140000, + }, + { + Id = "nova", + Symbol = "NVA", + Name = "Nova", + Color = "#ff6f91", + Logo = "✺", + InitialPrice = 325000, + MinimumPrice = 50000, + MaximumPrice = 900000, + VolatilityBasisPoints = 210, + IssuedSupply = 2200000, + TreasuryInventory = 1800000, + }, + { + Id = "prism", + Symbol = "PRM", + Name = "Prism", + Color = "#5ee1c4", + Logo = "◇", + InitialPrice = 74500, + MinimumPrice = 12000, + MaximumPrice = 240000, + VolatilityBasisPoints = 195, + IssuedSupply = 3800000, + TreasuryInventory = 3100000, + }, + { + Id = "quantum", + Symbol = "QTM", + Name = "Quantum", + Color = "#7a9cff", + Logo = "◉", + InitialPrice = 21400, + MinimumPrice = 3500, + MaximumPrice = 85000, + VolatilityBasisPoints = 230, + IssuedSupply = 5200000, + TreasuryInventory = 4250000, + }, + { + Id = "drift", + Symbol = "DRF", + Name = "Drift", + Color = "#4ecdc4", + Logo = "≋", + InitialPrice = 7600, + MinimumPrice = 900, + MaximumPrice = 30000, + VolatilityBasisPoints = 280, + IssuedSupply = 12000000, + TreasuryInventory = 9800000, + }, + { + Id = "pulse", + Symbol = "PLS", + Name = "Pulse", + Color = "#ff5f7f", + Logo = "◍", + InitialPrice = 1840, + MinimumPrice = 200, + MaximumPrice = 9000, + VolatilityBasisPoints = 320, + IssuedSupply = 18000000, + TreasuryInventory = 14800000, + }, + { + Id = "moss", + Symbol = "MOS", + Name = "Moss", + Color = "#64c987", + Logo = "♧", + InitialPrice = 460, + MinimumPrice = 60, + MaximumPrice = 2400, + VolatilityBasisPoints = 360, + IssuedSupply = 36000000, + TreasuryInventory = 29500000, + }, + { + Id = "flux", + Symbol = "FLX", + Name = "Flux", + Color = "#a777e3", + Logo = "ϟ", + InitialPrice = 238, + MinimumPrice = 35, + MaximumPrice = 1400, + VolatilityBasisPoints = 390, + IssuedSupply = 44000000, + TreasuryInventory = 36000000, + }, + { + Id = "tide", + Symbol = "TDE", + Name = "Tide", + Color = "#46a6ff", + Logo = "≈", + InitialPrice = 125, + MinimumPrice = 20, + MaximumPrice = 700, + VolatilityBasisPoints = 410, + IssuedSupply = 60000000, + TreasuryInventory = 49000000, + }, + { + Id = "spark", + Symbol = "SPK", + Name = "Spark", + Color = "#ffb84d", + Logo = "✹", + InitialPrice = 74, + MinimumPrice = 12, + MaximumPrice = 420, + VolatilityBasisPoints = 430, + IssuedSupply = 85000000, + TreasuryInventory = 70000000, + }, + { + Id = "pebble", + Symbol = "PBL", + Name = "Pebble", + Color = "#9aa8b8", + Logo = "●", + InitialPrice = 22, + MinimumPrice = 4, + MaximumPrice = 160, + VolatilityBasisPoints = 470, + IssuedSupply = 150000000, + TreasuryInventory = 124000000, + }, + { + Id = "nano", + Symbol = "NNO", + Name = "Nano", + Color = "#5bd6ff", + Logo = "η", + InitialPrice = 9, + MinimumPrice = 1, + MaximumPrice = 85, + VolatilityBasisPoints = 520, + IssuedSupply = 260000000, + TreasuryInventory = 214000000, + }, + { + Id = "shard", + Symbol = "SHD", + Name = "Shard", + Color = "#70e0d0", + Logo = "△", + InitialPrice = 5, + MinimumPrice = 1, + MaximumPrice = 55, + VolatilityBasisPoints = 560, + IssuedSupply = 420000000, + TreasuryInventory = 345000000, + }, + { + Id = "pixel", + Symbol = "PXL", + Name = "Pixel", + Color = "#ff78c8", + Logo = "▦", + InitialPrice = 3, + MinimumPrice = 1, + MaximumPrice = 32, + VolatilityBasisPoints = 610, + IssuedSupply = 700000000, + TreasuryInventory = 580000000, + }, + { + Id = "dust", + Symbol = "DST", + Name = "Dust", + Color = "#c4b5a5", + Logo = "·", + InitialPrice = 1, + MinimumPrice = 1, + MaximumPrice = 18, + VolatilityBasisPoints = 680, + IssuedSupply = 1200000000, + TreasuryInventory = 990000000, + }, + }, +} + +-- ============================================================================= +-- Server-only configuration +-- ============================================================================= + +if IsDuplicityVersion() then + -- ------------------------------------------------------------------------- + -- Server secrets + -- ------------------------------------------------------------------------- + + Config.Server = { + -- Keep these values private and stable. Changing a pepper invalidates existing app passwords. + PasscodePepper = "f626581802800478346266e66414d8e6f2c28050214a593c25901904c162bffe", + CrewLinkPasswordPepper = "2751e5729aee4955a529c1a87104b58fbe1d7280b18b66264236028caa06eb47", + FlipTokPasswordPepper = "a85ea307680f1205a4fda03be8af18ecf7edf16ffc83450b90cac7e41a9719a7", + PicstagramPasswordPepper = "653e41dd19aba5ef750a668ad1886273ba7d0e0420bd5c745748df80a6e22676", + } + + -- ------------------------------------------------------------------------- + -- Company directory + -- ------------------------------------------------------------------------- + + Config.Companies = { + Enabled = true, + PageSize = 20, + MaximumPageSize = 50, + MaximumOpenRequestsPerSim = 5, + MaximumServices = 25, + MaximumRequestMedia = 3, + SubjectMaxLength = 120, + RequestBodyMaxLength = 2000, + MessageMaxLength = 2000, + ProfileDescriptionMaxLength = 1000, + DistrictMaxLength = 80, + AddressMaxLength = 160, + ServiceTitleMaxLength = 80, + ServiceDescriptionMaxLength = 500, + ServicePriceMaxLength = 80, + AnnouncementTitleMaxLength = 120, + AnnouncementBodyMaxLength = 1000, + AvailabilityMaximumSeconds = 24 * 60 * 60, + AnnouncementMaximumSeconds = 30 * 24 * 60 * 60, + RetentionDays = 180, + RateLimits = { + Read = 120, + Search = 60, + CreateRequest = 5, + Message = 30, + RequestAction = 30, + Profile = 12, + CallAvailability = 30, + }, + CallRouting = { + MaxAttempts = 3, + RingSeconds = 10, + }, + Categories = { + "public_services", + "vehicles", + "transport", + }, + Statuses = { + new = true, + assigned = true, + in_progress = true, + waiting_customer = true, + completed = true, + cancelled = true, + }, + AvailabilityStatuses = { + available = true, + busy = true, + closed = true, + }, + Definitions = { + police = { + Job = "police", + Name = "Los Santos Police Department", + Category = "public_services", + Public = true, + Emergency = true, + Verified = true, + Icon = "shield", + LogoUrl = "https://picsum.photos/seed/companies-police-logo/180/180", + Description = "Public safety, emergency response, and police services.", + DefaultAvailability = "closed", + AcceptsRequests = false, + District = "Mission Row", + LocationLabel = "Mission Row Police Station", + Address = "Mission Row Police Station", + Location = vector3(425.1, -979.5, 30.7), + ServiceLine = { + Number = "911", + AutoContact = true, + CanCall = true, + CanMessage = false, + Routing = "round_robin", + MinimumGrade = 0, + }, + Permissions = { + WorkQueue = 0, + Availability = 1, + Assign = 2, + Profile = 3, + Hours = 3, + Services = 3, + Announcement = 3, + }, + Services = {}, + }, + ambulance = { + Job = "ambulance", + Name = "Emergency Medical Services", + Category = "public_services", + Public = true, + Emergency = true, + Verified = true, + Icon = "medical", + LogoUrl = "https://picsum.photos/seed/companies-ems-logo/180/180", + Description = "Emergency medical response and patient care.", + DefaultAvailability = "closed", + AcceptsRequests = false, + District = "Pillbox Hill", + LocationLabel = "Pillbox Hill Medical Center", + Address = "Pillbox Hill Medical Center", + Location = vector3(307.2, -595.3, 43.3), + ServiceLine = { + Number = "912", + AutoContact = true, + CanCall = true, + CanMessage = false, + Routing = "round_robin", + MinimumGrade = 0, + }, + Permissions = { + WorkQueue = 0, + Availability = 1, + Assign = 2, + Profile = 3, + Hours = 3, + Services = 3, + Announcement = 3, + }, + Services = {}, + }, + fire = { + Job = "fire", + Name = "Los Santos Fire Department", + Category = "public_services", + Public = true, + Emergency = true, + Verified = true, + Icon = "flame", + LogoUrl = "https://picsum.photos/seed/companies-fire-logo/180/180", + Description = "Fire response, rescue, and public safety services.", + DefaultAvailability = "closed", + AcceptsRequests = false, + District = "El Burro Heights", + LocationLabel = "Capital Boulevard Fire Station", + Address = "Capital Boulevard Fire Station", + Location = vector3(1200.6, -1473.6, 34.9), + ServiceLine = { + Number = "913", + AutoContact = true, + CanCall = true, + CanMessage = false, + Routing = "round_robin", + MinimumGrade = 0, + }, + Permissions = { + WorkQueue = 0, + Availability = 1, + Assign = 2, + Profile = 3, + Hours = 3, + Services = 3, + Announcement = 3, + }, + Services = {}, + }, + mechanic = { + Job = "mechanic", + Name = "Los Santos Customs", + Category = "vehicles", + Public = true, + Emergency = false, + Verified = true, + Icon = "wrench", + LogoUrl = "https://picsum.photos/seed/companies-mechanic-logo/180/180", + Description = "Vehicle diagnostics, repairs, and roadside assistance.", + DefaultAvailability = "closed", + AcceptsRequests = true, + District = "Burton", + LocationLabel = "Los Santos Customs", + Address = "Carcer Way", + Location = vector3(-337.3, -136.9, 39.0), + ServiceLine = { + Number = "5550101000", + AutoContact = true, + CanCall = true, + CanMessage = false, + Routing = "round_robin", + MinimumGrade = 0, + }, + Permissions = { + WorkQueue = 0, + Availability = 1, + Assign = 2, + Profile = 3, + Hours = 3, + Services = 3, + Announcement = 3, + }, + Services = { + { + Id = "mechanic-repair", + Title = "Vehicle repair", + Description = "Diagnostics and repairs for road vehicles.", + Price = "Price after inspection", + RequestsEnabled = true, + }, + { + Id = "mechanic-towing", + Title = "Roadside assistance", + Description = "Assistance for disabled vehicles.", + Price = "Price by distance", + RequestsEnabled = true, + }, + }, + }, + taxi = { + Job = "taxi", + Name = "Downtown Cab Co.", + Category = "transport", + Public = true, + Emergency = false, + Verified = true, + Icon = "car", + LogoUrl = "https://picsum.photos/seed/companies-taxi-logo/180/180", + Description = "Staffed taxi rides throughout Los Santos and Blaine County.", + DefaultAvailability = "closed", + AcceptsRequests = true, + District = "East Vinewood", + LocationLabel = "Downtown Cab Co.", + Address = "Tangerine Street", + Location = vector3(895.0, -179.2, 74.7), + ServiceLine = { + Number = "5550102000", + AutoContact = true, + CanCall = true, + CanMessage = false, + Routing = "round_robin", + MinimumGrade = 0, + }, + Permissions = { + WorkQueue = 0, + Availability = 1, + Assign = 2, + Profile = 3, + Hours = 3, + Services = 3, + Announcement = 3, + }, + Services = { + { + Id = "taxi-ride", + Title = "Taxi ride", + Description = "Request a staffed taxi service.", + Price = "Metered fare", + RequestsEnabled = true, + }, + }, + }, + }, + } + + -- ------------------------------------------------------------------------- + -- Music library + -- ------------------------------------------------------------------------- + + Config.Music = { + -- Files are found recursively in config/music by their Id. For example, + -- Id = "night-drive" finds night-drive.ogg/mp3 and optional artwork with + -- the same name (.webp/.png/.jpg/.jpeg), even inside subdirectories. + Tracks = { + -- { + -- Id = "night-drive", + -- Title = "Night Drive", + -- Artist = "Sky Records", + -- }, + }, + + MaximumPersonalSongs = 100, + MaximumPlaylists = 50, + MaximumPlaylistSongs = 250, + PlaylistNameMaxLength = 80, + ActionsPerMinute = 30, + MetadataTimeoutMs = 4500, + } + + -- ------------------------------------------------------------------------- + -- Weazel News + -- ------------------------------------------------------------------------- + + Config.WeazelNews = { + Enabled = true, + PageSize = 20, + MaximumOffset = 10000, + MaximumImages = 6, + SearchMaxLength = 80, + DraftTitleMinLength = 1, + DraftBodyMinLength = 1, + TitleMinLength = 1, + TitleMaxLength = 160, + BodyMinLength = 1, + BodyMaxLength = 12000, + ExcerptMaxLength = 240, + RateLimits = { + Read = 120, + Write = 20, + }, + Categories = { + "official", + "events", + "jobs", + "news", + "business", + }, + -- Framework job name = minimum grade. Every listed job can manage every article. + AllowedJobs = { + weazel = 0, + reporter = 0, + }, + } + + -- ------------------------------------------------------------------------- + -- CityWarn population warnings + -- ------------------------------------------------------------------------- + + Config.CityWarn = { + Enabled = true, + PageSize = 30, + MaximumActiveAlerts = 20, + TitleMaxLength = 120, + BodyMaxLength = 2000, + InstructionsMaxLength = 2000, + UpdateMaxLength = 2000, + AreaLabelMaxLength = 120, + MinimumRadius = 100, + MaximumRadius = 10000, + DefaultDurationMinutes = 60, + MaximumDurationMinutes = 1440, + RequireDuty = true, + RateLimits = { + Read = 180, + Write = 20, + }, + Publishers = { + police = { + MinimumGrade = 2, + MaximumSeverity = "extreme", + CityWide = true, + Categories = { "public_safety", "police", "infrastructure", "evacuation" }, + }, + fire = { + MinimumGrade = 2, + MaximumSeverity = "extreme", + CityWide = true, + Categories = { "public_safety", "fire", "infrastructure", "evacuation" }, + }, + ambulance = { + MinimumGrade = 2, + MaximumSeverity = "danger", + CityWide = false, + Categories = { "public_safety", "medical", "evacuation" }, + }, + government = { + MinimumGrade = 2, + MaximumSeverity = "extreme", + CityWide = true, + Categories = { "public_safety", "police", "fire", "medical", "infrastructure", "evacuation" }, + }, + }, + } + + -- ------------------------------------------------------------------------- + -- Database migrations + -- ------------------------------------------------------------------------- + + Config.Migrations = Config.Migrations or {} + + -- Manual, non-destructive import from LB Phone's `phone_*` database tables. + -- The migration never starts during a resource/server restart. Completed + -- domains are recorded in `sky_phone_migrations` for idempotent retries. + -- Import: skyphone:migrate lb-phone + -- Console preview: skyphone:migrate lb-phone dry + -- Idempotent retry: skyphone:migrate lb-phone force + -- Remove imported Sky data: skyphone:migrate lb-phone remove + Config.Migrations.LbPhone = { + SourcePrefix = "phone_", + + -- auto: direct framework identifier first, then an unambiguous license match. + -- identifier: only match phone_phones.owner_id to the framework identifier. + -- license: only resolve phone_phones.owner_id through the framework license. + IdentifierMode = "auto", + + Domains = { + devices = true, + settings = true, + alarms = true, + contacts = true, + blocked = true, + calls = true, + messages = true, + photos = true, + notes = true, + wallet = true, + voiceMemos = true, + picstagram = true, + mail = true, + mapMarkers = true, + darkChat = true, + flipTok = true, + feather = true, + flare = true, + }, + } +end + +--[[ + Sky Phone media configuration + + This file is loaded on the server only. Keep API keys private. When the + phone configurator is enabled in config.lua, the active media configuration + is loaded from SQL and frontend builds copy the shipped values from this + file into source/shared/config_default.lua. +]] + +Config.Media = { + -- Create or sign in to an account at https://developers.giphy.com/, open + -- the Developer Dashboard, choose "Create an API Key", and paste it here. + GiphyApiKey = "", + GifPageSize = 24, + GifRating = "pg-13", + UrlMaxLength = 2048, + AllowedGifHosts = { "giphy.com" }, + + -- Create or sign in at https://fivemanage.com/, create or select your team, + -- then open Dashboard > Tokens and create a token with Media access. Keep + -- this server-only token private and paste it here. + FiveManage = { + ApiKey = "", + BaseUrl = "https://api.fivemanage.com/api/v3/file", + RequestTimeoutMs = 10000, + UploadTimeoutMs = 25000, + }, + + Import = { + Enabled = true, + PageSize = 30, + MaxSelection = 10, + MaxPhotoBytes = 15 * 1024 * 1024, + MaxVideoBytes = 150 * 1024 * 1024, + RevalidateAfterSeconds = 3600, + ListActionsPerMinute = 60, + ImportActionsPerMinute = 20, + CandidateTtlSeconds = 300, + ManifestCacheSeconds = 30, + ManifestMaxBytes = 2 * 1024 * 1024, + ManifestMaxItems = 5000, + + Websites = { + { + Id = "fivemanage", + Label = "FiveManage", + Enabled = true, + Adapter = "fivemanage", + Path = "sky_phone/imports", + MediaTypes = { "photo", "video" }, + -- Direct Gallery links must use one of these hosts or a subdomain. + AllowedMediaHosts = { "fivemanage.com" }, + }, + + --[[ + { + Id = "city_media", + Label = "City Media", + Enabled = true, + Adapter = "manifest", + ManifestUrl = "https://media.example.com/sky-phone/media.json", + MediaTypes = { "photo", "video" }, + AllowedMediaHosts = { "media.example.com", "cdn.example.com" }, + Auth = { + Type = "bearer", + TokenConvar = "sky_phone_city_media_token", + }, + RequiredAce = "sky_phone.import.city_media", + }, + ]] + }, + }, + + Wallpaper = { + -- Shows the verified HTTPS media import directly in the wallpaper picker. + -- Camera and Photos wallpaper sources are not affected by this setting. + CustomUploadEnabled = true, + }, + + Photo = { + Encoding = "jpg", + Quality = 0.95, + }, + + Video = { + BitrateKbps = 1500, + }, + + UploadSessionTimeoutMs = 60000, + PageSize = 30, +} + +ConfigDefaults = Config +Config = realConfig diff --git a/tests/framework_permissions.lua b/tests/framework_permissions.lua new file mode 100644 index 0000000..0f99087 --- /dev/null +++ b/tests/framework_permissions.lua @@ -0,0 +1,64 @@ +Config = { + Bridge = { + Framework = "qbox", + }, + CommandPermissions = { + phonepanel = { "god", "superadmin", "admin" }, + }, +} + +Bridge = { + Framework = {}, +} + +function GetResourceState(resource) + assert(resource == "qbx_core") + return "started" +end + +function GetPlayers() + return { "1" } +end + +local ace_permissions = {} +local framework_permission = false + +function IsPlayerAceAllowed(source, permission) + assert(source == "1") + return ace_permissions[permission] == true +end + +exports = { + qbx_core = { + GetPlayer = function(_, source) + if tonumber(source) ~= 1 then + return nil + end + return { + PlayerData = { + citizenid = "test-citizen", + }, + } + end, + HasGroup = function(_, source, groups) + assert(source == 1) + assert(groups == Config.CommandPermissions.phonepanel) + return framework_permission + end, + }, +} + +dofile("sky_phone/source/bridge/server/framework.lua") +dofile("sky_phone/source/bridge/server/frameworks/qbox.lua") + +ace_permissions.admin = true +assert(Bridge.Framework.HasPermission(1, "phonepanel"), "Qbox ACE permissions must grant access") + +ace_permissions.admin = false +framework_permission = true +assert(Bridge.Framework.HasPermission(1, "phonepanel"), "Qbox framework groups must remain supported") + +local success = pcall(Bridge.Framework.HasPermission, 1, "missing") +assert(not success, "Missing fixed permission definitions must fail loudly") + +print("framework permission tests passed") diff --git a/tests/inventory_bridges.lua b/tests/inventory_bridges.lua index dbac13b..382ead9 100644 --- a/tests/inventory_bridges.lua +++ b/tests/inventory_bridges.lua @@ -1,3 +1,9 @@ +local event_handlers = {} + +AddEventHandler = function(event_name, callback) + event_handlers[event_name] = callback +end + local function reset_bridge(inventory_name, unique_phones, sim_cards_enabled) Config = { Bridge = { @@ -193,11 +199,18 @@ exports = { } load_inventory_contract("sky_phone/source/bridge/server/inventory/esx.lua") -local ok, configuration_error = pcall(Bridge.Inventory.RegisterUsableItem, "phone", function() -end) -assert(not ok) -assert(configuration_error:find("Config.Phone.Unique = false", 1, true)) -assert(configuration_error:find("Config.Sim.Enabled = false", 1, true)) +assert(Config.Phone.Unique == false) +assert(Config.Sim.Enabled == false) +assert(Bridge.Inventory.ConfigurationError == nil) + +Config.Phone.Unique = true +Config.Sim.Enabled = true +event_handlers["sky_phone:configurator:serverUpdated"]() +assert(Config.Phone.Unique == false) +assert(Config.Sim.Enabled == false) + +assert(Bridge.Inventory.RegisterUsableItem("phone", function() +end)) local manifest_file = assert(io.open("sky_phone/fxmanifest.lua", "rb")) local manifest = manifest_file:read("*a") diff --git a/tests/inventory_extended_bridges.lua b/tests/inventory_extended_bridges.lua index 0a3243d..dde982a 100644 --- a/tests/inventory_extended_bridges.lua +++ b/tests/inventory_extended_bridges.lua @@ -1,3 +1,6 @@ +AddEventHandler = function() +end + local function reset_bridge(inventory_name, resource_name, inventory_export, framework_name, extra_exports) Config = { Bridge = { Inventory = inventory_name }, diff --git a/tests/server_phone_modules.lua b/tests/server_phone_modules.lua index c7d98c5..067dad4 100644 --- a/tests/server_phone_modules.lua +++ b/tests/server_phone_modules.lua @@ -25,7 +25,7 @@ Bridge = { Debug = function() end, Framework = { - HasAdminGroup = function() + HasPermission = function() return true end, }, @@ -42,7 +42,6 @@ Bridge = { Config = { AdminPanel = { - AdminGroups = { "admin" }, Command = "phoneadmin", Enabled = true, },