mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 03:01:42 +00:00
FIX - expose complete phone configuration
This commit is contained in:
@@ -227,6 +227,13 @@ modules initialize, and exposes the editor through `/phonepanel`. Nothing autosa
|
||||
in the Phone Configurator tool and press the green check. Restart `sky_phone` after changing
|
||||
startup-bound settings such as framework, inventory, command, item, or provider configuration.
|
||||
|
||||
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,
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -553,7 +560,10 @@ Select `rtx`, `quasar`, `vms`, `rx`, `nolag`, `sn`, `esx_property`, or `qbx_prop
|
||||
|
||||
### Companies
|
||||
|
||||
Company jobs, public profiles, service numbers, services, permissions, locations, and default availability are configured under `Config.Companies.Definitions`.
|
||||
Company jobs, public profiles, service numbers, services, permissions, locations, and default
|
||||
availability are configured under `Config.Companies.Definitions`. Definitions are not limited to the
|
||||
shipped jobs: add any number of company IDs in the in-game configurator, choose `Table`, and fill the
|
||||
freely configurable `Job` value in the generated full company template.
|
||||
|
||||
### Weazel News
|
||||
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
import { Plus, Rows3, TableProperties, Trash2 } from 'lucide-vue-next'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { AdminConfiguratorStructure } from '@/types/admin'
|
||||
|
||||
export type AdminConfigEditorLabels = {
|
||||
addField: string
|
||||
addRow: string
|
||||
configuredSecret: string
|
||||
convertToList: string
|
||||
convertToMap: string
|
||||
convertToTable: string
|
||||
emptyList: string
|
||||
emptyTable: string
|
||||
@@ -33,38 +36,97 @@ const props = withDefaults(
|
||||
disabled?: boolean
|
||||
labels: AdminConfigEditorLabels
|
||||
modelValue: unknown
|
||||
structure?: AdminConfiguratorStructure
|
||||
}>(),
|
||||
{ ariaLabel: '', depth: 0, disabled: false },
|
||||
)
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: unknown] }>()
|
||||
|
||||
type ValueKind = 'boolean' | 'list' | 'number' | 'string' | 'table'
|
||||
type MapKeyKind = 'number' | 'string'
|
||||
type SerializedMapEntry = {
|
||||
key: number | string
|
||||
keyType: MapKeyKind
|
||||
value: unknown
|
||||
}
|
||||
|
||||
const newArrayKind = ref<ValueKind>('string')
|
||||
const newObjectKey = ref('')
|
||||
const newObjectKind = ref<ValueKind>('string')
|
||||
const newMapKey = ref('')
|
||||
const newMapKeyKind = ref<MapKeyKind>('string')
|
||||
const newMapValueKind = ref<ValueKind>('string')
|
||||
|
||||
const isList = computed(() => Array.isArray(props.modelValue))
|
||||
const isList = computed(
|
||||
() =>
|
||||
props.structure?.kind === 'list' ||
|
||||
(props.structure?.kind !== 'map' &&
|
||||
props.structure?.kind !== 'table' &&
|
||||
Array.isArray(props.modelValue)),
|
||||
)
|
||||
const listValue = computed<unknown[]>(() =>
|
||||
Array.isArray(props.modelValue) ? props.modelValue : [],
|
||||
)
|
||||
const isTable = computed(
|
||||
() =>
|
||||
props.modelValue !== null &&
|
||||
typeof props.modelValue === 'object' &&
|
||||
!Array.isArray(props.modelValue),
|
||||
props.structure?.kind === 'map' ||
|
||||
props.structure?.kind === 'table' ||
|
||||
props.structure?.kind === 'vector' ||
|
||||
(props.modelValue !== null &&
|
||||
typeof props.modelValue === 'object' &&
|
||||
!Array.isArray(props.modelValue)),
|
||||
)
|
||||
const tableValue = computed<Record<string, unknown>>(() =>
|
||||
isTable.value ? (props.modelValue as Record<string, unknown>) : {},
|
||||
isTable.value && !Array.isArray(props.modelValue)
|
||||
? (props.modelValue as Record<string, unknown>)
|
||||
: {},
|
||||
)
|
||||
const vectorType = computed(() => {
|
||||
const value = tableValue.value.__skyType
|
||||
return typeof value === 'string' && /^vector[234]$/.test(value) ? value : ''
|
||||
})
|
||||
const mapType = computed(
|
||||
() => props.structure?.kind === 'map' || tableValue.value.__skyType === 'map',
|
||||
)
|
||||
const mapEntries = computed<SerializedMapEntry[]>(() => {
|
||||
const entries = tableValue.value.entries
|
||||
if (!mapType.value || !Array.isArray(entries)) return []
|
||||
return entries.filter(
|
||||
(entry): entry is SerializedMapEntry =>
|
||||
entry !== null &&
|
||||
typeof entry === 'object' &&
|
||||
(entry.keyType === 'number' || entry.keyType === 'string'),
|
||||
)
|
||||
})
|
||||
const listStructure = computed(() =>
|
||||
props.structure?.kind === 'list' ? props.structure : null,
|
||||
)
|
||||
const tableStructure = computed(() =>
|
||||
props.structure?.kind === 'table' ? props.structure : null,
|
||||
)
|
||||
const mapStructure = computed(() =>
|
||||
props.structure?.kind === 'map' ? props.structure : null,
|
||||
)
|
||||
const tableEntries = computed(() =>
|
||||
Object.entries(tableValue.value).filter(([key]) => key !== '__skyType'),
|
||||
Object.entries(tableValue.value).filter(
|
||||
([key]) => key !== '__skyType' && (!mapType.value || key !== 'entries'),
|
||||
),
|
||||
)
|
||||
const isMaskedSecret = computed(() => props.modelValue === '***REDACTED***')
|
||||
const parsedNewMapKey = computed(() => {
|
||||
const key = newMapKey.value.trim()
|
||||
if (!key) return null
|
||||
if (newMapKeyKind.value === 'string') return key
|
||||
const numeric = Number(key)
|
||||
return Number.isFinite(numeric) ? numeric : null
|
||||
})
|
||||
const canAddMapEntry = computed(() => {
|
||||
const key = parsedNewMapKey.value
|
||||
if (key === null) return false
|
||||
return !mapEntries.value.some(
|
||||
(entry) => entry.keyType === newMapKeyKind.value && entry.key === key,
|
||||
)
|
||||
})
|
||||
|
||||
function blankValue(kind: ValueKind): unknown {
|
||||
if (kind === 'boolean') return false
|
||||
@@ -88,6 +150,19 @@ function blankLike(value: unknown): unknown {
|
||||
return ''
|
||||
}
|
||||
|
||||
function blankCollectionValue(kind: ValueKind, siblings: unknown[]): unknown {
|
||||
const template = siblings.find((value) => {
|
||||
if (kind === 'list') return Array.isArray(value)
|
||||
if (kind === 'table') {
|
||||
return (
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
)
|
||||
}
|
||||
return typeof value === kind
|
||||
})
|
||||
return template === undefined ? blankValue(kind) : blankLike(template)
|
||||
}
|
||||
|
||||
function updateScalar(event: Event): void {
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLInputElement)) return
|
||||
@@ -106,6 +181,19 @@ function updateBoolean(event: Event): void {
|
||||
}
|
||||
}
|
||||
|
||||
function updateOptionalString(event: Event): void {
|
||||
const target = event.target
|
||||
if (target instanceof HTMLInputElement) {
|
||||
emit('update:modelValue', target.value)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleOptionalString(event: Event): void {
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLInputElement)) return
|
||||
emit('update:modelValue', target.checked ? '' : false)
|
||||
}
|
||||
|
||||
function addListRow(): void {
|
||||
const rows = Array.isArray(props.modelValue) ? [...props.modelValue] : []
|
||||
rows.push(rows.length ? blankLike(rows[0]) : blankValue(newArrayKind.value))
|
||||
@@ -119,6 +207,7 @@ function updateListRow(index: number, value: unknown): void {
|
||||
}
|
||||
|
||||
function removeListRow(index: number): void {
|
||||
if (listStructure.value?.items[index]) return
|
||||
const rows = Array.isArray(props.modelValue) ? [...props.modelValue] : []
|
||||
rows.splice(index, 1)
|
||||
emit('update:modelValue', rows)
|
||||
@@ -129,6 +218,11 @@ function updateTableField(key: string, value: unknown): void {
|
||||
}
|
||||
|
||||
function removeTableField(key: string): void {
|
||||
if (
|
||||
tableStructure.value &&
|
||||
Object.prototype.hasOwnProperty.call(tableStructure.value.fields, key)
|
||||
)
|
||||
return
|
||||
const next = { ...tableValue.value }
|
||||
delete next[key]
|
||||
emit('update:modelValue', next)
|
||||
@@ -136,14 +230,104 @@ function removeTableField(key: string): void {
|
||||
|
||||
function addTableField(): void {
|
||||
const key = newObjectKey.value.trim()
|
||||
if (!key || Object.prototype.hasOwnProperty.call(tableValue.value, key))
|
||||
if (
|
||||
!key ||
|
||||
key === '__skyType' ||
|
||||
Object.prototype.hasOwnProperty.call(tableValue.value, key)
|
||||
)
|
||||
return
|
||||
emit('update:modelValue', {
|
||||
...tableValue.value,
|
||||
[key]: blankValue(newObjectKind.value),
|
||||
[key]: blankCollectionValue(
|
||||
newObjectKind.value,
|
||||
Object.values(tableValue.value).filter((_, index) => index < 50),
|
||||
),
|
||||
})
|
||||
newObjectKey.value = ''
|
||||
}
|
||||
|
||||
function emitMap(entries: SerializedMapEntry[]): void {
|
||||
emit('update:modelValue', { __skyType: 'map', entries })
|
||||
}
|
||||
|
||||
function convertTableToMap(): void {
|
||||
emitMap(
|
||||
Object.entries(tableValue.value)
|
||||
.filter(([key]) => key !== '__skyType')
|
||||
.map(([key, value]) => ({ key, keyType: 'string', value })),
|
||||
)
|
||||
}
|
||||
|
||||
function updateMapKey(index: number, event: Event): void {
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLInputElement)) return
|
||||
const current = mapEntries.value[index]
|
||||
if (!current) return
|
||||
if (mapEntryStructure(current)) {
|
||||
target.value = String(current.key)
|
||||
return
|
||||
}
|
||||
const key =
|
||||
current.keyType === 'number' ? Number(target.value) : target.value.trim()
|
||||
if (
|
||||
key === '' ||
|
||||
(typeof key === 'number' && !Number.isFinite(key)) ||
|
||||
mapEntries.value.some(
|
||||
(entry, candidateIndex) =>
|
||||
candidateIndex !== index &&
|
||||
entry.keyType === current.keyType &&
|
||||
entry.key === key,
|
||||
)
|
||||
) {
|
||||
target.value = String(current.key)
|
||||
return
|
||||
}
|
||||
const entries = mapEntries.value.map((entry, candidateIndex) =>
|
||||
candidateIndex === index ? { ...entry, key } : entry,
|
||||
)
|
||||
emitMap(entries)
|
||||
}
|
||||
|
||||
function updateMapValue(index: number, value: unknown): void {
|
||||
emitMap(
|
||||
mapEntries.value.map((entry, candidateIndex) =>
|
||||
candidateIndex === index ? { ...entry, value } : entry,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function removeMapEntry(index: number): void {
|
||||
const entry = mapEntries.value[index]
|
||||
if (!entry || mapEntryStructure(entry)) return
|
||||
emitMap(
|
||||
mapEntries.value.filter((_, candidateIndex) => candidateIndex !== index),
|
||||
)
|
||||
}
|
||||
|
||||
function addMapEntry(): void {
|
||||
if (!canAddMapEntry.value || parsedNewMapKey.value === null) return
|
||||
emitMap([
|
||||
...mapEntries.value,
|
||||
{
|
||||
key: parsedNewMapKey.value,
|
||||
keyType: newMapKeyKind.value,
|
||||
value: blankCollectionValue(
|
||||
newMapValueKind.value,
|
||||
mapEntries.value.slice(0, 50).map((entry) => entry.value),
|
||||
),
|
||||
},
|
||||
])
|
||||
newMapKey.value = ''
|
||||
}
|
||||
|
||||
function mapEntryStructure(
|
||||
entry: SerializedMapEntry,
|
||||
): AdminConfiguratorStructure | undefined {
|
||||
return mapStructure.value?.entries.find(
|
||||
(candidate) =>
|
||||
candidate.keyType === entry.keyType && candidate.key === entry.key,
|
||||
)?.structure
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -166,7 +350,7 @@ function addTableField(): void {
|
||||
<option value="table">{{ labels.types.table }}</option>
|
||||
</select>
|
||||
<button
|
||||
v-if="!listValue.length"
|
||||
v-if="!listValue.length && !structure"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
:title="labels.convertToTable"
|
||||
@@ -189,6 +373,7 @@ function addTableField(): void {
|
||||
<span class="config-structured-editor__index">{{ index + 1 }}</span>
|
||||
<AdminConfigValueEditor
|
||||
:model-value="row"
|
||||
:structure="listStructure?.items[index]"
|
||||
:aria-label="`${ariaLabel} ${index + 1}`"
|
||||
:labels="labels"
|
||||
:disabled="disabled"
|
||||
@@ -196,6 +381,7 @@ function addTableField(): void {
|
||||
@update:model-value="updateListRow(index, $event)"
|
||||
/>
|
||||
<button
|
||||
v-if="!listStructure?.items[index]"
|
||||
type="button"
|
||||
class="config-structured-editor__remove"
|
||||
:disabled="disabled"
|
||||
@@ -224,7 +410,16 @@ function addTableField(): void {
|
||||
}}
|
||||
</span>
|
||||
<button
|
||||
v-if="!tableEntries.length && !vectorType"
|
||||
v-if="!structure && !mapType && !vectorType"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
:title="labels.convertToMap"
|
||||
@click="convertTableToMap"
|
||||
>
|
||||
<TableProperties :size="13" />{{ labels.convertToMap }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!structure && !mapType && !tableEntries.length && !vectorType"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
:title="labels.convertToList"
|
||||
@@ -234,8 +429,50 @@ function addTableField(): void {
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="mapType" class="config-structured-editor__properties is-map">
|
||||
<div
|
||||
v-for="(entry, index) in mapEntries"
|
||||
:key="`${entry.keyType}:${entry.key}`"
|
||||
class="config-structured-editor__property is-map"
|
||||
>
|
||||
<span class="config-structured-editor__index">{{ index + 1 }}</span>
|
||||
<span class="config-structured-editor__map-key">
|
||||
<small>{{ labels.types[entry.keyType] }}</small>
|
||||
<input
|
||||
:type="entry.keyType === 'number' ? 'number' : 'text'"
|
||||
:value="entry.key"
|
||||
:aria-label="`${ariaLabel} ${labels.types[entry.keyType]} ${index + 1}`"
|
||||
:disabled="disabled || Boolean(mapEntryStructure(entry))"
|
||||
@change="updateMapKey(index, $event)"
|
||||
/>
|
||||
</span>
|
||||
<AdminConfigValueEditor
|
||||
:model-value="entry.value"
|
||||
:structure="mapEntryStructure(entry)"
|
||||
:aria-label="`${ariaLabel} ${entry.key}`"
|
||||
:labels="labels"
|
||||
:disabled="disabled"
|
||||
:depth="depth + 1"
|
||||
@update:model-value="updateMapValue(index, $event)"
|
||||
/>
|
||||
<button
|
||||
v-if="!mapEntryStructure(entry)"
|
||||
type="button"
|
||||
class="config-structured-editor__remove"
|
||||
:disabled="disabled"
|
||||
:title="labels.remove"
|
||||
@click="removeMapEntry(index)"
|
||||
>
|
||||
<Trash2 :size="13" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="!mapEntries.length" class="config-structured-editor__empty">
|
||||
{{ labels.emptyTable }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="tableEntries.length"
|
||||
v-else-if="tableEntries.length"
|
||||
class="config-structured-editor__properties"
|
||||
>
|
||||
<div
|
||||
@@ -247,6 +484,7 @@ function addTableField(): void {
|
||||
<strong>{{ key }}</strong>
|
||||
<AdminConfigValueEditor
|
||||
:model-value="value"
|
||||
:structure="tableStructure?.fields[key]"
|
||||
:aria-label="`${ariaLabel} ${key}`"
|
||||
:labels="labels"
|
||||
:disabled="disabled"
|
||||
@@ -254,7 +492,13 @@ function addTableField(): void {
|
||||
@update:model-value="updateTableField(key, $event)"
|
||||
/>
|
||||
<button
|
||||
v-if="!vectorType"
|
||||
v-if="
|
||||
!vectorType &&
|
||||
!Object.prototype.hasOwnProperty.call(
|
||||
tableStructure?.fields ?? {},
|
||||
key,
|
||||
)
|
||||
"
|
||||
type="button"
|
||||
class="config-structured-editor__remove"
|
||||
:disabled="disabled"
|
||||
@@ -270,7 +514,35 @@ function addTableField(): void {
|
||||
</div>
|
||||
|
||||
<form
|
||||
v-if="!vectorType"
|
||||
v-if="mapType"
|
||||
class="config-structured-editor__add-field is-map"
|
||||
@submit.prevent="addMapEntry"
|
||||
>
|
||||
<select v-model="newMapKeyKind" :disabled="disabled">
|
||||
<option value="string">{{ labels.types.string }}</option>
|
||||
<option value="number">{{ labels.types.number }}</option>
|
||||
</select>
|
||||
<input
|
||||
v-model="newMapKey"
|
||||
:type="newMapKeyKind === 'number' ? 'number' : 'text'"
|
||||
:disabled="disabled"
|
||||
:placeholder="labels.keyPlaceholder"
|
||||
:aria-label="labels.keyPlaceholder"
|
||||
/>
|
||||
<select v-model="newMapValueKind" :disabled="disabled">
|
||||
<option value="string">{{ labels.types.string }}</option>
|
||||
<option value="number">{{ labels.types.number }}</option>
|
||||
<option value="boolean">{{ labels.types.boolean }}</option>
|
||||
<option value="list">{{ labels.types.list }}</option>
|
||||
<option value="table">{{ labels.types.table }}</option>
|
||||
</select>
|
||||
<button type="submit" :disabled="disabled || !canAddMapEntry">
|
||||
<Plus :size="13" />{{ labels.addField }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form
|
||||
v-else-if="!vectorType"
|
||||
class="config-structured-editor__add-field"
|
||||
@submit.prevent="addTableField"
|
||||
>
|
||||
@@ -279,6 +551,7 @@ function addTableField(): void {
|
||||
type="text"
|
||||
:disabled="disabled"
|
||||
:placeholder="labels.keyPlaceholder"
|
||||
:aria-label="labels.keyPlaceholder"
|
||||
/>
|
||||
<select v-model="newObjectKind" :disabled="disabled">
|
||||
<option value="string">{{ labels.types.string }}</option>
|
||||
@@ -293,6 +566,31 @@ function addTableField(): void {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-else-if="structure?.kind === 'optionalString'"
|
||||
class="config-value-optional"
|
||||
>
|
||||
<input
|
||||
class="config-value-input"
|
||||
type="text"
|
||||
:aria-label="ariaLabel"
|
||||
:value="modelValue === false ? '' : String(modelValue ?? '')"
|
||||
:disabled="disabled || modelValue === false"
|
||||
autocomplete="off"
|
||||
@input="updateOptionalString"
|
||||
/>
|
||||
<label class="config-value-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
:aria-label="ariaLabel"
|
||||
:checked="modelValue !== false"
|
||||
:disabled="disabled"
|
||||
@change="toggleOptionalString"
|
||||
/>
|
||||
<i></i>
|
||||
</label>
|
||||
</span>
|
||||
|
||||
<label
|
||||
v-else-if="typeof modelValue === 'boolean'"
|
||||
class="config-value-toggle"
|
||||
@@ -426,6 +724,30 @@ function addTableField(): void {
|
||||
grid-template-columns: 24px minmax(80px, 0.35fr) minmax(150px, 1fr) 27px;
|
||||
}
|
||||
|
||||
.config-structured-editor__property.is-map {
|
||||
grid-template-columns: 24px minmax(105px, 0.42fr) minmax(150px, 1fr) 27px;
|
||||
}
|
||||
|
||||
.config-structured-editor__map-key {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.config-structured-editor__map-key small {
|
||||
color: var(--admin-dim);
|
||||
font-size: 7px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.config-structured-editor__map-key input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 25px;
|
||||
padding: 0 7px;
|
||||
}
|
||||
|
||||
.config-structured-editor__property > strong {
|
||||
overflow: hidden;
|
||||
color: #c9cec9;
|
||||
@@ -448,6 +770,14 @@ function addTableField(): void {
|
||||
color: #b66a6a;
|
||||
}
|
||||
|
||||
.config-value-optional {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.config-structured-editor__empty {
|
||||
padding: 13px 10px;
|
||||
color: var(--admin-dim);
|
||||
@@ -463,6 +793,10 @@ function addTableField(): void {
|
||||
background: #151715;
|
||||
}
|
||||
|
||||
.config-structured-editor__add-field.is-map {
|
||||
grid-template-columns: 76px minmax(90px, 1fr) 82px auto;
|
||||
}
|
||||
|
||||
.config-structured-editor__add-field input {
|
||||
min-width: 0;
|
||||
height: 25px;
|
||||
|
||||
@@ -256,6 +256,13 @@ describe('standalone admin panel contracts', () => {
|
||||
expect(configuratorServer).toContain('build_sections("media", stored_media')
|
||||
expect(configuratorServer).toContain('sensitive_path')
|
||||
expect(configuratorServer).toContain('restore_redacted_values')
|
||||
expect(configuratorServer).toContain(
|
||||
'{ __skyType = "map", entries = entries }',
|
||||
)
|
||||
expect(configuratorServer).toContain('validate_structured_value')
|
||||
expect(configuratorServer).toContain('validate_locked_structure')
|
||||
expect(configuratorServer).toContain('build_structure(default_value')
|
||||
expect(configuratorServer).toContain('field.type == "stringOrFalse"')
|
||||
expect(configuratorServer).not.toContain('Config.Media = client_payload')
|
||||
expect(configuratorClient).toContain(
|
||||
'Bridge.Callbacks.Trigger("sky_phone:configurator:runtime"',
|
||||
@@ -269,6 +276,12 @@ describe('standalone admin panel contracts', () => {
|
||||
expect(configuratorValueEditor).toContain('function addTableField()')
|
||||
expect(configuratorValueEditor).toContain('function removeListRow(')
|
||||
expect(configuratorValueEditor).toContain('function removeTableField(')
|
||||
expect(configuratorValueEditor).toContain('function addMapEntry()')
|
||||
expect(configuratorValueEditor).toContain('function updateMapKey(')
|
||||
expect(configuratorValueEditor).toContain('mapEntryStructure(current)')
|
||||
expect(configuratorValueEditor).toContain('listStructure?.items[index]')
|
||||
expect(configuratorValueEditor).toContain('tableStructure?.fields[key]')
|
||||
expect(configuratorValueEditor).toContain('function blankCollectionValue(')
|
||||
expect(configuratorValueEditor).not.toContain('<textarea')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -48,6 +48,7 @@ import type {
|
||||
AdminAuditEntry,
|
||||
AdminConfiguratorChange,
|
||||
AdminConfiguratorField,
|
||||
AdminConfiguratorStructure,
|
||||
AdminDevice,
|
||||
} from '@/types/admin'
|
||||
import type { LaunchablePhoneAppDefinition } from '@/types/apps'
|
||||
@@ -175,6 +176,81 @@ const revealedCredential = computed(() =>
|
||||
? admin.revealedCredentials[selectedDevice.value.imei]
|
||||
: undefined,
|
||||
)
|
||||
|
||||
function configuratorStructureCount(
|
||||
structure: AdminConfiguratorStructure | undefined,
|
||||
): number {
|
||||
if (
|
||||
!structure ||
|
||||
structure.kind === 'value' ||
|
||||
structure.kind === 'optionalString'
|
||||
)
|
||||
return 1
|
||||
if (structure.kind === 'vector') return Number(structure.vectorType.slice(-1))
|
||||
if (structure.kind === 'list') {
|
||||
return Math.max(
|
||||
1,
|
||||
structure.items.reduce(
|
||||
(total, item) => total + configuratorStructureCount(item),
|
||||
0,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (structure.kind === 'map') {
|
||||
return Math.max(
|
||||
1,
|
||||
structure.entries.reduce(
|
||||
(total, entry) => total + configuratorStructureCount(entry.structure),
|
||||
0,
|
||||
),
|
||||
)
|
||||
}
|
||||
return Math.max(
|
||||
1,
|
||||
Object.values(structure.fields).reduce(
|
||||
(total, field) => total + configuratorStructureCount(field),
|
||||
0,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function configuratorSectionCount(fields: AdminConfiguratorField[]): number {
|
||||
return fields.reduce(
|
||||
(total, field) => total + configuratorStructureCount(field.structure),
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
function configuratorStructureContains(
|
||||
structure: AdminConfiguratorStructure | undefined,
|
||||
needle: string,
|
||||
): boolean {
|
||||
if (
|
||||
!structure ||
|
||||
structure.kind === 'value' ||
|
||||
structure.kind === 'optionalString'
|
||||
)
|
||||
return false
|
||||
if (structure.kind === 'vector') return structure.vectorType.includes(needle)
|
||||
if (structure.kind === 'list') {
|
||||
return structure.items.some((item) =>
|
||||
configuratorStructureContains(item, needle),
|
||||
)
|
||||
}
|
||||
if (structure.kind === 'map') {
|
||||
return structure.entries.some(
|
||||
(entry) =>
|
||||
String(entry.key).toLocaleLowerCase(phone.lang).includes(needle) ||
|
||||
configuratorStructureContains(entry.structure, needle),
|
||||
)
|
||||
}
|
||||
return Object.entries(structure.fields).some(
|
||||
([key, field]) =>
|
||||
key.toLocaleLowerCase(phone.lang).includes(needle) ||
|
||||
configuratorStructureContains(field, needle),
|
||||
)
|
||||
}
|
||||
|
||||
const selectedMessages = computed(() =>
|
||||
selectedDevice.value
|
||||
? (admin.deviceActivity[selectedDevice.value.imei]?.messages ?? [])
|
||||
@@ -196,13 +272,14 @@ const filteredConfiguratorSections = computed(() => {
|
||||
section.fields.some(
|
||||
(field) =>
|
||||
field.label.toLocaleLowerCase(phone.lang).includes(needle) ||
|
||||
field.path.toLocaleLowerCase(phone.lang).includes(needle),
|
||||
field.path.toLocaleLowerCase(phone.lang).includes(needle) ||
|
||||
configuratorStructureContains(field.structure, needle),
|
||||
),
|
||||
)
|
||||
})
|
||||
const filteredConfiguratorFieldCount = computed(() =>
|
||||
filteredConfiguratorSections.value.reduce(
|
||||
(total, section) => total + section.fields.length,
|
||||
(total, section) => total + configuratorSectionCount(section.fields),
|
||||
0,
|
||||
),
|
||||
)
|
||||
@@ -264,6 +341,7 @@ const configuratorEditorLabels = computed<AdminConfigEditorLabels>(() => ({
|
||||
addRow: t('configurator.table.addRow'),
|
||||
configuredSecret: t('configurator.secretConfigured'),
|
||||
convertToList: t('configurator.table.convertToList'),
|
||||
convertToMap: t('configurator.table.convertToMap'),
|
||||
convertToTable: t('configurator.table.convertToTable'),
|
||||
emptyList: t('configurator.table.emptyList'),
|
||||
emptyTable: t('configurator.table.emptyTable'),
|
||||
@@ -474,6 +552,19 @@ function updateConfiguratorToggle(
|
||||
}
|
||||
}
|
||||
|
||||
function updateOptionalStringToggle(
|
||||
field: AdminConfiguratorField,
|
||||
event: Event,
|
||||
): void {
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLInputElement)) return
|
||||
const current = configuratorFieldValue(field)
|
||||
updateConfiguratorField(
|
||||
field,
|
||||
target.checked ? (current === false ? '' : String(current ?? '')) : false,
|
||||
)
|
||||
}
|
||||
|
||||
function findConfiguratorField(key: string): AdminConfiguratorField | null {
|
||||
for (const section of admin.configurator?.sections ?? []) {
|
||||
const field = section.fields.find(
|
||||
@@ -499,6 +590,8 @@ function buildConfiguratorChanges(): AdminConfiguratorChange[] | null {
|
||||
if (!value || typeof value !== 'object') return null
|
||||
} else if (field.type === 'string') {
|
||||
value = String(draft)
|
||||
} else if (field.type === 'stringOrFalse') {
|
||||
if (value !== false && typeof value !== 'string') return null
|
||||
} else if (field.type === 'boolean' && typeof value !== 'boolean') {
|
||||
return null
|
||||
}
|
||||
@@ -953,7 +1046,7 @@ onBeforeUnmount(() => {
|
||||
: t('configurator.configScope')
|
||||
}}</small>
|
||||
</span>
|
||||
<em>{{ section.fields.length }}</em>
|
||||
<em>{{ configuratorSectionCount(section.fields) }}</em>
|
||||
</button>
|
||||
<div
|
||||
v-if="!filteredConfiguratorSections.length"
|
||||
@@ -1211,7 +1304,11 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<strong>{{
|
||||
t('configurator.fieldCount', {
|
||||
count: String(activeConfiguratorSection.fields.length),
|
||||
count: String(
|
||||
configuratorSectionCount(
|
||||
activeConfiguratorSection.fields,
|
||||
),
|
||||
),
|
||||
})
|
||||
}}</strong>
|
||||
</header>
|
||||
@@ -1250,6 +1347,7 @@ onBeforeUnmount(() => {
|
||||
<AdminConfigValueEditor
|
||||
v-else-if="field.type === 'json'"
|
||||
:model-value="configuratorFieldValue(field)"
|
||||
:structure="field.structure"
|
||||
:aria-label="`${field.label} ${field.path}`"
|
||||
:labels="configuratorEditorLabels"
|
||||
:disabled="!admin.configurator.enabled"
|
||||
@@ -1258,6 +1356,37 @@ onBeforeUnmount(() => {
|
||||
"
|
||||
/>
|
||||
|
||||
<span
|
||||
v-else-if="field.type === 'stringOrFalse'"
|
||||
class="admin-panel-config-optional"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
:aria-label="`${field.label} ${field.path}`"
|
||||
:value="
|
||||
configuratorFieldValue(field) === false
|
||||
? ''
|
||||
: String(configuratorFieldValue(field) ?? '')
|
||||
"
|
||||
:disabled="
|
||||
!admin.configurator.enabled ||
|
||||
configuratorFieldValue(field) === false
|
||||
"
|
||||
autocomplete="off"
|
||||
@input="updateConfiguratorInput(field, $event)"
|
||||
/>
|
||||
<span class="admin-panel-config-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
:aria-label="`${field.label} ${field.path}`"
|
||||
:checked="configuratorFieldValue(field) !== false"
|
||||
:disabled="!admin.configurator.enabled"
|
||||
@change="updateOptionalStringToggle(field, $event)"
|
||||
/>
|
||||
<i></i>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<input
|
||||
v-else
|
||||
:aria-label="`${field.label} ${field.path}`"
|
||||
@@ -3479,7 +3608,8 @@ button:disabled {
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.admin-panel-config-field > input {
|
||||
.admin-panel-config-field > input,
|
||||
.admin-panel-config-optional > input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
@@ -3491,19 +3621,30 @@ button:disabled {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.admin-panel-config-field > input {
|
||||
.admin-panel-config-field > input,
|
||||
.admin-panel-config-optional > input {
|
||||
height: 31px;
|
||||
padding: 0 9px;
|
||||
}
|
||||
|
||||
.admin-panel-config-field > input:focus {
|
||||
.admin-panel-config-field > input:focus,
|
||||
.admin-panel-config-optional > input:focus {
|
||||
outline-color: color-mix(in srgb, var(--admin-green) 45%, transparent);
|
||||
}
|
||||
|
||||
.admin-panel-config-field > input:disabled {
|
||||
.admin-panel-config-field > input:disabled,
|
||||
.admin-panel-config-optional > input:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.admin-panel-config-optional {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 32px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-panel-config-toggle {
|
||||
position: relative;
|
||||
justify-self: end;
|
||||
|
||||
@@ -890,6 +890,7 @@ const adminPanelFallbackLocales = {
|
||||
emptyTable: 'No fields yet. Add the first key below.',
|
||||
keyPlaceholder: 'New key',
|
||||
convertToList: 'Use list',
|
||||
convertToMap: 'Use typed key table',
|
||||
convertToTable: 'Use key table',
|
||||
types: {
|
||||
string: 'Text',
|
||||
|
||||
@@ -116,12 +116,42 @@ export type AdminConfiguratorField = {
|
||||
configured?: boolean
|
||||
label: string
|
||||
path: string
|
||||
structure?: AdminConfiguratorStructure
|
||||
scope: 'config' | 'media'
|
||||
sensitive: boolean
|
||||
type: 'boolean' | 'json' | 'number' | 'string'
|
||||
type: 'boolean' | 'json' | 'number' | 'string' | 'stringOrFalse'
|
||||
value: unknown
|
||||
}
|
||||
|
||||
export type AdminConfiguratorStructure =
|
||||
| {
|
||||
kind: 'list'
|
||||
items: AdminConfiguratorStructure[]
|
||||
}
|
||||
| {
|
||||
fields: Record<string, AdminConfiguratorStructure>
|
||||
kind: 'table'
|
||||
}
|
||||
| {
|
||||
entries: Array<{
|
||||
key: number | string
|
||||
keyType: 'number' | 'string'
|
||||
structure: AdminConfiguratorStructure
|
||||
}>
|
||||
kind: 'map'
|
||||
}
|
||||
| {
|
||||
kind: 'value'
|
||||
valueType: 'boolean' | 'number' | 'string'
|
||||
}
|
||||
| {
|
||||
kind: 'optionalString'
|
||||
}
|
||||
| {
|
||||
kind: 'vector'
|
||||
vectorType: 'vector2' | 'vector3' | 'vector4'
|
||||
}
|
||||
|
||||
export type AdminConfiguratorSection = {
|
||||
fields: AdminConfiguratorField[]
|
||||
id: string
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
const { readFileSync } = require('node:fs')
|
||||
const { resolve } = require('node:path')
|
||||
|
||||
class LuaTable {
|
||||
constructor() {
|
||||
this.entries = []
|
||||
this.nextArrayIndex = 1
|
||||
}
|
||||
|
||||
get(key) {
|
||||
return this.entries.find((entry) => entry.key === key)?.value
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
const entry = this.entries.find((candidate) => candidate.key === key)
|
||||
if (entry) entry.value = value
|
||||
else this.entries.push({ key, value })
|
||||
}
|
||||
}
|
||||
|
||||
function tokenize(source) {
|
||||
const tokens = []
|
||||
let index = 0
|
||||
|
||||
while (index < source.length) {
|
||||
const character = source[index]
|
||||
if (/\s/.test(character)) {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if (source.startsWith('--[[', index)) {
|
||||
const end = source.indexOf(']]', index + 4)
|
||||
index = end < 0 ? source.length : end + 2
|
||||
continue
|
||||
}
|
||||
if (source.startsWith('--', index)) {
|
||||
const end = source.indexOf('\n', index + 2)
|
||||
index = end < 0 ? source.length : end + 1
|
||||
continue
|
||||
}
|
||||
if (character === '"' || character === "'") {
|
||||
const quote = character
|
||||
let value = ''
|
||||
index += 1
|
||||
while (index < source.length && source[index] !== quote) {
|
||||
if (source[index] !== '\\') {
|
||||
value += source[index]
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
index += 1
|
||||
const escaped = source[index]
|
||||
const escapeValues = {
|
||||
a: '\u0007',
|
||||
b: '\b',
|
||||
f: '\f',
|
||||
n: '\n',
|
||||
r: '\r',
|
||||
t: '\t',
|
||||
v: '\u000b',
|
||||
}
|
||||
value += escapeValues[escaped] ?? escaped
|
||||
index += 1
|
||||
}
|
||||
if (source[index] !== quote)
|
||||
throw new Error('Unterminated Lua string in configurator fixture.')
|
||||
index += 1
|
||||
tokens.push({ type: 'string', value })
|
||||
continue
|
||||
}
|
||||
if (/[A-Za-z_]/.test(character)) {
|
||||
const match = source.slice(index).match(/^[A-Za-z_][A-Za-z0-9_]*/)
|
||||
tokens.push({ type: 'name', value: match[0] })
|
||||
index += match[0].length
|
||||
continue
|
||||
}
|
||||
if (
|
||||
/\d/.test(character) ||
|
||||
(character === '.' && /\d/.test(source[index + 1]))
|
||||
) {
|
||||
const match = source
|
||||
.slice(index)
|
||||
.match(/^(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/)
|
||||
tokens.push({ type: 'number', value: Number(match[0]) })
|
||||
index += match[0].length
|
||||
continue
|
||||
}
|
||||
|
||||
tokens.push({ type: 'symbol', value: character })
|
||||
index += 1
|
||||
}
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
class LuaConfigParser {
|
||||
constructor(source) {
|
||||
this.tokens = tokenize(source)
|
||||
this.index = 0
|
||||
this.config = new LuaTable()
|
||||
}
|
||||
|
||||
current(offset = 0) {
|
||||
return this.tokens[this.index + offset]
|
||||
}
|
||||
|
||||
matches(value, offset = 0) {
|
||||
return this.current(offset)?.value === value
|
||||
}
|
||||
|
||||
take(value) {
|
||||
const token = this.current()
|
||||
if (!token || (value !== undefined && token.value !== value)) {
|
||||
throw new Error(
|
||||
`Expected '${value}', received '${token?.value ?? 'end of file'}' in configurator fixture.`,
|
||||
)
|
||||
}
|
||||
this.index += 1
|
||||
return token
|
||||
}
|
||||
|
||||
parse() {
|
||||
while (this.current()) {
|
||||
if (this.matches('Config')) {
|
||||
const start = this.index
|
||||
const path = this.parsePath()
|
||||
if (path.length && this.matches('=')) {
|
||||
this.take('=')
|
||||
this.setPath(path, this.parseExpression())
|
||||
continue
|
||||
}
|
||||
this.index = start + 1
|
||||
continue
|
||||
}
|
||||
this.index += 1
|
||||
}
|
||||
return this.config
|
||||
}
|
||||
|
||||
parsePath() {
|
||||
this.take('Config')
|
||||
const path = []
|
||||
while (this.matches('.') && this.current(1)?.type === 'name') {
|
||||
this.take('.')
|
||||
path.push(this.take().value)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
getPath(path) {
|
||||
let value = this.config
|
||||
for (const key of path) {
|
||||
if (!(value instanceof LuaTable)) return undefined
|
||||
value = value.get(key)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
setPath(path, value) {
|
||||
let parent = this.config
|
||||
for (let index = 0; index < path.length - 1; index += 1) {
|
||||
const key = path[index]
|
||||
let child = parent.get(key)
|
||||
if (!(child instanceof LuaTable)) {
|
||||
child = new LuaTable()
|
||||
parent.set(key, child)
|
||||
}
|
||||
parent = child
|
||||
}
|
||||
parent.set(path.at(-1), value)
|
||||
}
|
||||
|
||||
parseExpression() {
|
||||
return this.parseOr()
|
||||
}
|
||||
|
||||
parseOr() {
|
||||
let value = this.parseAdditive()
|
||||
while (this.matches('or')) {
|
||||
this.take('or')
|
||||
const fallback = this.parseAdditive()
|
||||
value =
|
||||
value !== false && value !== null && value !== undefined
|
||||
? value
|
||||
: fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
parseAdditive() {
|
||||
let value = this.parseMultiplicative()
|
||||
while (this.matches('+') || this.matches('-')) {
|
||||
const operator = this.take().value
|
||||
const right = this.parseMultiplicative()
|
||||
value =
|
||||
operator === '+'
|
||||
? Number(value) + Number(right)
|
||||
: Number(value) - Number(right)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
parseMultiplicative() {
|
||||
let value = this.parseUnary()
|
||||
while (this.matches('*') || this.matches('/')) {
|
||||
const operator = this.take().value
|
||||
const right = this.parseUnary()
|
||||
value =
|
||||
operator === '*'
|
||||
? Number(value) * Number(right)
|
||||
: Number(value) / Number(right)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
parseUnary() {
|
||||
if (this.matches('-')) {
|
||||
this.take('-')
|
||||
return -Number(this.parseUnary())
|
||||
}
|
||||
return this.parsePrimary()
|
||||
}
|
||||
|
||||
parsePrimary() {
|
||||
const token = this.current()
|
||||
if (!token)
|
||||
throw new Error(
|
||||
'Unexpected end of Lua configuration in configurator fixture.',
|
||||
)
|
||||
if (token.type === 'number' || token.type === 'string') {
|
||||
this.index += 1
|
||||
return token.value
|
||||
}
|
||||
if (this.matches('true') || this.matches('false') || this.matches('nil')) {
|
||||
this.index += 1
|
||||
return token.value === 'true'
|
||||
? true
|
||||
: token.value === 'false'
|
||||
? false
|
||||
: null
|
||||
}
|
||||
if (this.matches('{')) return this.parseTable()
|
||||
if (this.matches('(')) {
|
||||
this.take('(')
|
||||
const value = this.parseExpression()
|
||||
this.take(')')
|
||||
return value
|
||||
}
|
||||
if (this.matches('Config')) return this.getPath(this.parsePath())
|
||||
if (token.type === 'name' && this.current(1)?.value === '(') {
|
||||
const name = this.take().value
|
||||
this.take('(')
|
||||
const argumentsList = []
|
||||
while (!this.matches(')')) {
|
||||
argumentsList.push(this.parseExpression())
|
||||
if (!this.matches(',')) break
|
||||
this.take(',')
|
||||
}
|
||||
this.take(')')
|
||||
if (/^vector[234]$/.test(name)) {
|
||||
const axes = ['x', 'y', 'z', 'w']
|
||||
return Object.fromEntries([
|
||||
['__skyType', name],
|
||||
...argumentsList.map((value, index) => [axes[index], value]),
|
||||
])
|
||||
}
|
||||
throw new Error(`Unsupported Lua call '${name}' in configurator fixture.`)
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Unsupported Lua token '${token.value}' in configurator fixture.`,
|
||||
)
|
||||
}
|
||||
|
||||
parseTable() {
|
||||
const table = new LuaTable()
|
||||
this.take('{')
|
||||
while (!this.matches('}')) {
|
||||
if (this.matches('[')) {
|
||||
this.take('[')
|
||||
const key = this.parseExpression()
|
||||
this.take(']')
|
||||
this.take('=')
|
||||
table.set(key, this.parseExpression())
|
||||
} else if (
|
||||
this.current()?.type === 'name' &&
|
||||
this.current(1)?.value === '='
|
||||
) {
|
||||
const key = this.take().value
|
||||
this.take('=')
|
||||
table.set(key, this.parseExpression())
|
||||
} else {
|
||||
table.set(table.nextArrayIndex, this.parseExpression())
|
||||
table.nextArrayIndex += 1
|
||||
}
|
||||
|
||||
if (this.matches(',') || this.matches(';')) this.index += 1
|
||||
else if (!this.matches('}')) {
|
||||
throw new Error(
|
||||
`Expected a Lua table separator, received '${this.current()?.value ?? 'end of file'}'.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
this.take('}')
|
||||
return table
|
||||
}
|
||||
}
|
||||
|
||||
function serializeLuaValue(value) {
|
||||
if (!(value instanceof LuaTable)) {
|
||||
if (Array.isArray(value)) return value.map(serializeLuaValue)
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, child]) => [
|
||||
key,
|
||||
serializeLuaValue(child),
|
||||
]),
|
||||
)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const numericKeys = value.entries
|
||||
.filter((entry) => typeof entry.key === 'number')
|
||||
.map((entry) => entry.key)
|
||||
.sort((left, right) => left - right)
|
||||
const isSequence =
|
||||
numericKeys.length === value.entries.length &&
|
||||
numericKeys.every((key, index) => key === index + 1)
|
||||
if (isSequence) {
|
||||
return [...value.entries]
|
||||
.sort((left, right) => left.key - right.key)
|
||||
.map((entry) => serializeLuaValue(entry.value))
|
||||
}
|
||||
if (value.entries.every((entry) => typeof entry.key === 'string')) {
|
||||
return Object.fromEntries(
|
||||
value.entries.map((entry) => [entry.key, serializeLuaValue(entry.value)]),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
__skyType: 'map',
|
||||
entries: value.entries.map((entry) => ({
|
||||
key: entry.key,
|
||||
keyType: typeof entry.key,
|
||||
value: serializeLuaValue(entry.value),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function humanize(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
.replace(/([A-Za-z])(\d)/g, '$1 $2')
|
||||
.replace(/^./, (character) => character.toUpperCase())
|
||||
}
|
||||
|
||||
function sensitivePath(path) {
|
||||
const leaf = path.split('.').at(-1) ?? path
|
||||
const normalized = leaf.toLowerCase().replace(/[^a-z0-9]/g, '')
|
||||
return (
|
||||
normalized.includes('apikey') ||
|
||||
normalized.includes('secret') ||
|
||||
normalized.includes('pepper') ||
|
||||
[
|
||||
'password',
|
||||
'token',
|
||||
'authorization',
|
||||
'credential',
|
||||
'connectionstring',
|
||||
].includes(normalized)
|
||||
)
|
||||
}
|
||||
|
||||
function maskValue(value, path) {
|
||||
if (Array.isArray(value))
|
||||
return value.map((child, index) => maskValue(child, `${path}.${index + 1}`))
|
||||
if (value?.__skyType === 'map' && Array.isArray(value.entries)) {
|
||||
return {
|
||||
__skyType: 'map',
|
||||
entries: value.entries.map((entry) => ({
|
||||
key: entry.key,
|
||||
keyType: entry.keyType,
|
||||
value: maskValue(entry.value, `${path}.${entry.key}`),
|
||||
})),
|
||||
}
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, child]) => [
|
||||
key,
|
||||
maskValue(child, `${path}.${key}`),
|
||||
]),
|
||||
)
|
||||
}
|
||||
return typeof value === 'string' && sensitivePath(path)
|
||||
? '***REDACTED***'
|
||||
: value
|
||||
}
|
||||
|
||||
function buildStructure(value, scope, path) {
|
||||
if (scope === 'config' && path === 'Phone.Keybind') {
|
||||
return { kind: 'optionalString' }
|
||||
}
|
||||
if (
|
||||
scope === 'config' &&
|
||||
path === 'Garage.VehicleImages.ModelNames' &&
|
||||
Array.isArray(value) &&
|
||||
value.length === 0
|
||||
) {
|
||||
return { kind: 'map', entries: [] }
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return {
|
||||
kind: 'list',
|
||||
items: value.map((child, index) =>
|
||||
buildStructure(child, scope, `${path}.${index + 1}`),
|
||||
),
|
||||
}
|
||||
}
|
||||
if (value?.__skyType === 'map' && Array.isArray(value.entries)) {
|
||||
return {
|
||||
kind: 'map',
|
||||
entries: value.entries.map((entry) => ({
|
||||
key: entry.key,
|
||||
keyType: entry.keyType,
|
||||
structure: buildStructure(entry.value, scope, `${path}.${entry.key}`),
|
||||
})),
|
||||
}
|
||||
}
|
||||
if (/^vector[234]$/.test(value?.__skyType ?? '')) {
|
||||
return { kind: 'vector', vectorType: value.__skyType }
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return {
|
||||
kind: 'table',
|
||||
fields: Object.fromEntries(
|
||||
Object.entries(value).map(([key, child]) => [
|
||||
key,
|
||||
buildStructure(child, scope, `${path}.${key}`),
|
||||
]),
|
||||
),
|
||||
}
|
||||
}
|
||||
return { kind: 'value', valueType: typeof value }
|
||||
}
|
||||
|
||||
function addField(fields, scope, path, value) {
|
||||
const valueType = Array.isArray(value) ? 'json' : typeof value
|
||||
const sensitive = typeof value === 'string' && sensitivePath(path)
|
||||
fields.push({
|
||||
configured: sensitive ? value !== '' : undefined,
|
||||
label: humanize(path.split('.').at(-1)),
|
||||
path,
|
||||
scope,
|
||||
sensitive,
|
||||
structure:
|
||||
value !== null && typeof value === 'object'
|
||||
? buildStructure(value, scope, path)
|
||||
: undefined,
|
||||
type:
|
||||
scope === 'config' && path === 'Phone.Keybind'
|
||||
? 'stringOrFalse'
|
||||
: value !== null && typeof value === 'object'
|
||||
? 'json'
|
||||
: valueType,
|
||||
value: sensitive ? '' : maskValue(value, path),
|
||||
})
|
||||
}
|
||||
|
||||
function buildSections(scope, payload) {
|
||||
const sections = []
|
||||
const generalFields = []
|
||||
for (const key of Object.keys(payload).sort()) {
|
||||
const value = payload[key]
|
||||
if (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
!value.__skyType &&
|
||||
Object.keys(value).length > 0
|
||||
) {
|
||||
const fields = []
|
||||
addField(fields, scope, key, value)
|
||||
sections.push({
|
||||
fields,
|
||||
id: `${scope}:${key}`,
|
||||
label: humanize(key),
|
||||
scope,
|
||||
})
|
||||
} else {
|
||||
addField(generalFields, scope, key, value)
|
||||
}
|
||||
}
|
||||
if (generalFields.length) {
|
||||
const general = {
|
||||
fields: generalFields,
|
||||
id: `${scope}:general`,
|
||||
label: 'General',
|
||||
scope,
|
||||
}
|
||||
if (scope === 'config') sections.unshift(general)
|
||||
else sections.push(general)
|
||||
}
|
||||
return sections
|
||||
}
|
||||
|
||||
function loadConfiguratorSections() {
|
||||
const resourceRoot = resolve(__dirname, '../..')
|
||||
const config = serializeLuaValue(
|
||||
new LuaConfigParser(
|
||||
readFileSync(
|
||||
resolve(resourceRoot, 'sky_phone/config/config.lua'),
|
||||
'utf8',
|
||||
),
|
||||
).parse(),
|
||||
)
|
||||
const mediaRoot = serializeLuaValue(
|
||||
new LuaConfigParser(
|
||||
readFileSync(resolve(resourceRoot, 'sky_phone/config/media.lua'), 'utf8'),
|
||||
).parse(),
|
||||
)
|
||||
const media = mediaRoot.Media
|
||||
delete config.PhoneConfigurator
|
||||
delete config.Media
|
||||
return [...buildSections('config', config), ...buildSections('media', media)]
|
||||
}
|
||||
|
||||
module.exports = { loadConfiguratorSections }
|
||||
@@ -0,0 +1,117 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
type ConfiguratorField = {
|
||||
path: string
|
||||
structure?: ConfiguratorStructure
|
||||
type: string
|
||||
value: unknown
|
||||
}
|
||||
|
||||
type ConfiguratorStructure = {
|
||||
entries?: Array<{ structure: ConfiguratorStructure }>
|
||||
fields?: Record<string, ConfiguratorStructure>
|
||||
items?: ConfiguratorStructure[]
|
||||
kind: string
|
||||
}
|
||||
|
||||
type ConfiguratorSection = {
|
||||
fields: ConfiguratorField[]
|
||||
id: string
|
||||
scope: 'config' | 'media'
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { loadConfiguratorSections } = require('./configurator-fixture.cjs') as {
|
||||
loadConfiguratorSections: () => ConfiguratorSection[]
|
||||
}
|
||||
|
||||
const configSource = readFileSync(
|
||||
new URL('../../sky_phone/config/config.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
function countStructure(structure: ConfiguratorStructure | undefined): number {
|
||||
if (!structure) return 1
|
||||
if (structure.fields) {
|
||||
return Object.values(structure.fields).reduce(
|
||||
(total, field) => total + countStructure(field),
|
||||
0,
|
||||
)
|
||||
}
|
||||
if (structure.items) {
|
||||
return structure.items.reduce(
|
||||
(total, item) => total + countStructure(item),
|
||||
0,
|
||||
)
|
||||
}
|
||||
if (structure.entries) {
|
||||
return structure.entries.reduce(
|
||||
(total, entry) => total + countStructure(entry.structure),
|
||||
0,
|
||||
)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
describe('admin configurator fixture', () => {
|
||||
it('exposes every 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')
|
||||
|
||||
expect(sections).toHaveLength(45)
|
||||
expect(
|
||||
fields.reduce(
|
||||
(total, field) => total + countStructure(field.structure),
|
||||
0,
|
||||
),
|
||||
).toBeGreaterThan(700)
|
||||
for (const root of new Set(roots)) {
|
||||
expect(
|
||||
sections.some(
|
||||
(section) =>
|
||||
section.id === `config:${root}` ||
|
||||
section.fields.some(
|
||||
(field) =>
|
||||
field.path === root || field.path.startsWith(`${root}.`),
|
||||
),
|
||||
),
|
||||
`Missing Config.${root}`,
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves numeric Lua map keys and the false keybind option', () => {
|
||||
const fields = loadConfiguratorSections().flatMap(
|
||||
(section) => section.fields,
|
||||
)
|
||||
const darkChat = fields.find((field) => field.path === 'DarkChat')
|
||||
const phone = fields.find((field) => field.path === 'Phone')
|
||||
const companies = fields.find((field) => field.path === 'Companies')
|
||||
const garage = fields.find((field) => field.path === 'Garage')
|
||||
const timers = (darkChat?.value as Record<string, unknown> | undefined)
|
||||
?.AllowedDisappearTimers
|
||||
|
||||
expect(timers).toMatchObject({
|
||||
__skyType: 'map',
|
||||
entries: expect.arrayContaining([
|
||||
{ key: -1, keyType: 'number', value: true },
|
||||
{ key: 0, keyType: 'number', value: true },
|
||||
{ key: 604800, keyType: 'number', value: true },
|
||||
]),
|
||||
})
|
||||
expect(darkChat?.structure?.fields?.AllowedDisappearTimers.kind).toBe('map')
|
||||
expect(phone?.structure?.fields?.Keybind.kind).toBe('optionalString')
|
||||
expect(companies?.structure?.fields?.Definitions.kind).toBe('table')
|
||||
expect(
|
||||
garage?.structure?.fields?.VehicleImages.fields?.ModelNames.kind,
|
||||
).toBe('map')
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,8 @@ const { randomUUID } = require('node:crypto')
|
||||
const cors = require('cors')
|
||||
const express = require('express')
|
||||
|
||||
const { loadConfiguratorSections } = require('./configurator-fixture.cjs')
|
||||
|
||||
const app = express()
|
||||
const port = Number(process.argv[2]) || 3001
|
||||
|
||||
@@ -4742,7 +4744,7 @@ function adminMockBootstrap() {
|
||||
}
|
||||
}
|
||||
|
||||
const adminMockConfigurator = {
|
||||
const adminMockConfiguratorBase = {
|
||||
enabled: true,
|
||||
revision: 4,
|
||||
sections: [
|
||||
@@ -4922,6 +4924,11 @@ const adminMockConfigurator = {
|
||||
updatedBy: 'Alex Morgan',
|
||||
}
|
||||
|
||||
const adminMockConfigurator = {
|
||||
...adminMockConfiguratorBase,
|
||||
sections: loadConfiguratorSections(),
|
||||
}
|
||||
|
||||
app.post('/api/:endpoint', async (request, response, next) => {
|
||||
const endpoint = request.params.endpoint
|
||||
const loggedBody = { ...request.body }
|
||||
|
||||
@@ -206,7 +206,7 @@ Locales["de"] = {
|
||||
tabs = { overview = "Übersicht", players = "Spieler", devices = "Geräte", apps = "Apps", accounts = "Accounts", messages = "Nachrichten", calls = "Anrufe", moderation = "Moderation", audit = "Audit", configurator = "Phone Configurator" },
|
||||
overview = { eyebrow = "Server", title = "Dashboard", body = "Spieler, Geräte, Apps und Handydaten.", stats = "Server-Handystatistik", online = "Online", devices = "Geräte", accounts = "Accounts", audit = "Audit-Einträge", control = "Navigation", features = "Module", featuresBody = "Öffne ein Verwaltungsmodul.", recent = "Letzte Aktivitäten", playerFeature = "Identität, Finanzen, Job und Dienst", deviceFeature = "IMEI, SIM, Nummer und Aktivität", appFeature = "Handy-Apps installieren oder entfernen", accountFeature = "Accountzugriff und geschützte Zugangsdaten", messageFeature = "Letzte SMS-Aktivitäten prüfen", callFeature = "Letzte Anrufaktivitäten prüfen", moderationFeature = "Zugriff, Nummer oder Gerätedaten zurücksetzen", auditFeature = "Sensible Admin-Aktionen prüfen", configuratorFeature = "config.lua und media.lua über SQL verwalten" },
|
||||
appearance = { eyebrow = "Darstellung", title = "Akzentfarbe", body = "Ändere den Akzent im gesamten Admin-Arbeitsbereich.", colors = { emerald = "Smaragd", blue = "Blau", violet = "Violett", orange = "Orange", red = "Rot" } },
|
||||
configurator = { context = "Runtime-Konfiguration", eyebrow = "Systemwerkzeug", sections = "Konfiguration", search = "Einstellungen oder Pfade suchen", configScope = "config.lua", mediaScope = "media.lua", noResults = "Keine passenden Einstellungen", loading = "SQL-Konfiguration wird geladen...", title = "Phone Configurator", body = "Verwalte Handy- und Media-Einstellungen im geschützten Admin-Bereich.", disabledTitle = "SQL-Konfiguration ist nicht aktiv", disabledBody = "Aktiviere den Configurator am Anfang der config.lua und starte sky_phone neu. Bis dahin bleiben die Dateiwerte aktiv und die Bearbeitung gesperrt.", manualSave = "Manuelles Speichern", restartNotice = "Nichts wird automatisch gespeichert. Der grüne Haken schreibt alle vorgemerkten Werte. Startgebundene Einstellungen werden nach einem Neustart von sky_phone vollständig aktiv.", fieldCount = "{count} Felder", secretConfigured = "Secret gesetzt · Ersatzwert eingeben", invalidValue = "Prüfe den markierten Tabellen- oder Zahlenwert.", saved = "SQL-Konfiguration gespeichert.", table = { list = "Liste", table = "Schlüsseltabelle", vector = "Vektor", addRow = "Zeile hinzufügen", addField = "Feld hinzufügen", remove = "Entfernen", emptyList = "Noch keine Zeilen. Füge die erste Zeile mit Plus hinzu.", emptyTable = "Noch keine Felder. Füge unten den ersten Schlüssel hinzu.", keyPlaceholder = "Neuer Schlüssel", convertToList = "Als Liste nutzen", convertToTable = "Als Schlüsseltabelle nutzen", types = { string = "Text", number = "Zahl", boolean = "Schalter", list = "Liste", table = "Tabelle" } } },
|
||||
configurator = { context = "Runtime-Konfiguration", eyebrow = "Systemwerkzeug", sections = "Konfiguration", search = "Einstellungen oder Pfade suchen", configScope = "config.lua", mediaScope = "media.lua", noResults = "Keine passenden Einstellungen", loading = "SQL-Konfiguration wird geladen...", title = "Phone Configurator", body = "Verwalte Handy- und Media-Einstellungen im geschützten Admin-Bereich.", disabledTitle = "SQL-Konfiguration ist nicht aktiv", disabledBody = "Aktiviere den Configurator am Anfang der config.lua und starte sky_phone neu. Bis dahin bleiben die Dateiwerte aktiv und die Bearbeitung gesperrt.", manualSave = "Manuelles Speichern", restartNotice = "Nichts wird automatisch gespeichert. Der grüne Haken schreibt alle vorgemerkten Werte. Startgebundene Einstellungen werden nach einem Neustart von sky_phone vollständig aktiv.", fieldCount = "{count} Felder", secretConfigured = "Secret gesetzt · Ersatzwert eingeben", invalidValue = "Prüfe den markierten Tabellen- oder Zahlenwert.", saved = "SQL-Konfiguration gespeichert.", table = { list = "Liste", table = "Schlüsseltabelle", vector = "Vektor", addRow = "Zeile hinzufügen", addField = "Feld hinzufügen", remove = "Entfernen", emptyList = "Noch keine Zeilen. Füge die erste Zeile mit Plus hinzu.", emptyTable = "Noch keine Felder. Füge unten den ersten Schlüssel hinzu.", keyPlaceholder = "Neuer Schlüssel", convertToList = "Als Liste nutzen", convertToMap = "Als typisierte Schlüsseltabelle nutzen", convertToTable = "Als Schlüsseltabelle nutzen", types = { string = "Text", number = "Zahl", boolean = "Schalter", list = "Liste", table = "Tabelle" } } },
|
||||
players = { eyebrow = "Aktive Sitzungen", title = "Online-Spieler", online = "Jetzt online", empty = "Keine Spieler gefunden", emptyBody = "Passe die Suche an oder aktualisiere die Spielerliste." },
|
||||
search = { players = "Name, ID, Job oder Nummer suchen", apps = "Apps suchen", clear = "Suche leeren" },
|
||||
detail = { character = "Charakterprofil", data = "Spielerdatenübersicht", cash = "Bargeld", bank = "Bank", job = "Job", duty = "Dienst", onDuty = "Im Dienst", offDuty = "Außer Dienst", identity = "Identität", playerData = "Spielerdaten", identifier = "Charakter-Identifier", birthdate = "Geburtsdatum", grade = "Job-Rang", unknown = "Unbekannt" },
|
||||
|
||||
@@ -206,7 +206,7 @@ Locales["en"] = {
|
||||
tabs = { overview = "Overview", players = "Players", devices = "Devices", apps = "Apps", accounts = "Accounts", messages = "Messages", calls = "Calls", moderation = "Moderation", audit = "Audit", configurator = "Phone configurator" },
|
||||
overview = { eyebrow = "Server", title = "Dashboard", body = "Players, devices, apps, and phone data.", stats = "Server phone statistics", online = "Online", devices = "Devices", accounts = "Accounts", audit = "Audit entries", control = "Navigation", features = "Modules", featuresBody = "Open an administration module.", recent = "Recent activity", playerFeature = "Identity, finances, job, and duty", deviceFeature = "IMEI, SIM, number, and activity", appFeature = "Install or remove phone apps", accountFeature = "Account access and protected credentials", messageFeature = "Review recent SMS activity", callFeature = "Review recent call activity", moderationFeature = "Reset access, number, or device data", auditFeature = "Review sensitive admin actions", configuratorFeature = "Manage config.lua and media.lua through SQL" },
|
||||
appearance = { eyebrow = "Appearance", title = "Accent color", body = "Change the accent across the complete admin workspace.", colors = { emerald = "Emerald", blue = "Blue", violet = "Violet", orange = "Orange", red = "Red" } },
|
||||
configurator = { context = "Runtime configuration", eyebrow = "System tool", sections = "Configuration", search = "Search settings or paths", configScope = "config.lua", mediaScope = "media.lua", noResults = "No matching settings", loading = "Loading SQL configuration...", title = "Phone configurator", body = "Manage phone and media settings from the protected admin workspace.", disabledTitle = "SQL configuration is not active", disabledBody = "Enable the configurator at the beginning of config.lua and restart sky_phone. Until then, file values remain active and editing is locked.", manualSave = "Manual save", restartNotice = "Nothing is written automatically. Press the green check to persist all staged values. Startup-bound settings take full effect after restarting sky_phone.", fieldCount = "{count} fields", secretConfigured = "Secret configured · enter a replacement", invalidValue = "Check the highlighted table or number value.", saved = "SQL configuration saved.", table = { list = "List", table = "Key table", vector = "Vector", addRow = "Add row", addField = "Add field", remove = "Remove", emptyList = "No rows yet. Add the first row with plus.", emptyTable = "No fields yet. Add the first key below.", keyPlaceholder = "New key", convertToList = "Use list", convertToTable = "Use key table", types = { string = "Text", number = "Number", boolean = "Switch", list = "List", table = "Table" } } },
|
||||
configurator = { context = "Runtime configuration", eyebrow = "System tool", sections = "Configuration", search = "Search settings or paths", configScope = "config.lua", mediaScope = "media.lua", noResults = "No matching settings", loading = "Loading SQL configuration...", title = "Phone configurator", body = "Manage phone and media settings from the protected admin workspace.", disabledTitle = "SQL configuration is not active", disabledBody = "Enable the configurator at the beginning of config.lua and restart sky_phone. Until then, file values remain active and editing is locked.", manualSave = "Manual save", restartNotice = "Nothing is written automatically. Press the green check to persist all staged values. Startup-bound settings take full effect after restarting sky_phone.", fieldCount = "{count} fields", secretConfigured = "Secret configured · enter a replacement", invalidValue = "Check the highlighted table or number value.", saved = "SQL configuration saved.", table = { list = "List", table = "Key table", vector = "Vector", addRow = "Add row", addField = "Add field", remove = "Remove", emptyList = "No rows yet. Add the first row with plus.", emptyTable = "No fields yet. Add the first key below.", keyPlaceholder = "New key", convertToList = "Use list", convertToMap = "Use typed key table", convertToTable = "Use key table", types = { string = "Text", number = "Number", boolean = "Switch", list = "List", table = "Table" } } },
|
||||
players = { eyebrow = "Active sessions", title = "Online players", online = "Online now", empty = "No players found", emptyBody = "Adjust the search or refresh the live player list." },
|
||||
search = { players = "Search name, ID, job, or number", apps = "Search apps", clear = "Clear search" },
|
||||
detail = { character = "Character profile", data = "Player data overview", cash = "Cash", bank = "Bank", job = "Job", duty = "Duty", onDuty = "On duty", offDuty = "Off duty", identity = "Identity", playerData = "Player data", identifier = "Character identifier", birthdate = "Birthdate", grade = "Job grade", unknown = "Unknown" },
|
||||
|
||||
@@ -17,6 +17,14 @@ local function deserialize_value(value)
|
||||
tonumber(value.w) or 0.0
|
||||
)
|
||||
end
|
||||
if value.__skyType == "map" then
|
||||
local decoded = {}
|
||||
for _, entry in ipairs(value.entries or {}) do
|
||||
local key = entry.keyType == "number" and tonumber(entry.key) or entry.key
|
||||
decoded[key] = deserialize_value(entry.value)
|
||||
end
|
||||
return decoded
|
||||
end
|
||||
|
||||
local decoded = {}
|
||||
for key, child in pairs(value) do
|
||||
|
||||
@@ -4,6 +4,8 @@ local TABLE_NAME = "sky_phone_configurator"
|
||||
local CONFIG_ROW_ID = 1
|
||||
local MAX_CHANGES = 1000
|
||||
local MAX_PAYLOAD_BYTES = 8 * 1024 * 1024
|
||||
local MAX_STRUCTURED_DEPTH = 20
|
||||
local MAX_STRUCTURED_ENTRIES = 20000
|
||||
local REDACTED_VALUE = "***REDACTED***"
|
||||
local configurator_enabled = Config.PhoneConfigurator.Enabled == true
|
||||
local default_config
|
||||
@@ -13,6 +15,7 @@ local stored_media
|
||||
local revision = 1
|
||||
local updated_at
|
||||
local updated_by_name
|
||||
local is_sequence
|
||||
|
||||
local CLIENT_CONFIG_KEYS = {
|
||||
AdminPanel = true,
|
||||
@@ -100,6 +103,40 @@ local function serialize_value(value, active)
|
||||
end
|
||||
active[value] = true
|
||||
|
||||
local has_numeric_map_key = false
|
||||
if not is_sequence(value) then
|
||||
for key in pairs(value) do
|
||||
if type(key) == "number" then
|
||||
has_numeric_map_key = true
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if has_numeric_map_key then
|
||||
local entries = {}
|
||||
for key, child in pairs(value) do
|
||||
if type(key) ~= "string" and type(key) ~= "number" then
|
||||
error("[sky_phone] Phone configurator only supports string and numeric table keys.")
|
||||
end
|
||||
entries[#entries + 1] = {
|
||||
key = key,
|
||||
keyType = type(key),
|
||||
value = serialize_value(child, active),
|
||||
}
|
||||
end
|
||||
table.sort(entries, function(left, right)
|
||||
if left.keyType ~= right.keyType then
|
||||
return left.keyType < right.keyType
|
||||
end
|
||||
if left.keyType == "number" then
|
||||
return left.key < right.key
|
||||
end
|
||||
return tostring(left.key) < tostring(right.key)
|
||||
end)
|
||||
active[value] = nil
|
||||
return { __skyType = "map", entries = entries }
|
||||
end
|
||||
|
||||
local serialized = {}
|
||||
for key, child in pairs(value) do
|
||||
if type(key) ~= "string" and type(key) ~= "number" then
|
||||
@@ -130,6 +167,14 @@ local function deserialize_value(value)
|
||||
tonumber(value.w) or 0.0
|
||||
)
|
||||
end
|
||||
if value.__skyType == "map" then
|
||||
local decoded = {}
|
||||
for _, entry in ipairs(value.entries or {}) do
|
||||
local key = entry.keyType == "number" and tonumber(entry.key) or entry.key
|
||||
decoded[key] = deserialize_value(entry.value)
|
||||
end
|
||||
return decoded
|
||||
end
|
||||
|
||||
local decoded = {}
|
||||
for key, child in pairs(value) do
|
||||
@@ -138,7 +183,7 @@ local function deserialize_value(value)
|
||||
return decoded
|
||||
end
|
||||
|
||||
local function is_sequence(value)
|
||||
is_sequence = function(value)
|
||||
if type(value) ~= "table" then
|
||||
return false
|
||||
end
|
||||
@@ -155,11 +200,85 @@ local function is_sequence(value)
|
||||
return count > 0 and count == maximum
|
||||
end
|
||||
|
||||
local function upgrade_legacy_map(defaults, saved)
|
||||
local default_types = {}
|
||||
local numeric_only = true
|
||||
for _, entry in ipairs(defaults.entries or {}) do
|
||||
default_types[tostring(entry.key)] = entry.keyType
|
||||
if entry.keyType ~= "number" then
|
||||
numeric_only = false
|
||||
end
|
||||
end
|
||||
|
||||
local entries = {}
|
||||
for key, child in pairs(saved) do
|
||||
local key_type = default_types[tostring(key)] or (numeric_only and "number" or type(key))
|
||||
local normalized_key = key_type == "number" and tonumber(key) or tostring(key)
|
||||
if normalized_key == nil then
|
||||
error("[sky_phone] Phone configurator could not migrate a numeric configuration key.")
|
||||
end
|
||||
entries[#entries + 1] = {
|
||||
key = normalized_key,
|
||||
keyType = key_type,
|
||||
value = copy_value(child),
|
||||
}
|
||||
end
|
||||
table.sort(entries, function(left, right)
|
||||
if left.keyType ~= right.keyType then
|
||||
return left.keyType < right.keyType
|
||||
end
|
||||
if left.keyType == "number" then
|
||||
return left.key < right.key
|
||||
end
|
||||
return tostring(left.key) < tostring(right.key)
|
||||
end)
|
||||
return { __skyType = "map", entries = entries }
|
||||
end
|
||||
|
||||
local function merge_values(defaults, saved)
|
||||
if type(defaults) ~= "table" or type(saved) ~= "table" then
|
||||
return copy_value(saved)
|
||||
end
|
||||
if defaults.__skyType or saved.__skyType or is_sequence(defaults) or is_sequence(saved) then
|
||||
if defaults.__skyType == "map" and not saved.__skyType then
|
||||
saved = upgrade_legacy_map(defaults, saved)
|
||||
end
|
||||
if defaults.__skyType == "map" and saved.__skyType == "map" then
|
||||
local saved_entries = {}
|
||||
for _, entry in ipairs(saved.entries or {}) do
|
||||
saved_entries[entry.keyType .. ":" .. tostring(entry.key)] = entry
|
||||
end
|
||||
|
||||
local entries = {}
|
||||
local included = {}
|
||||
for _, entry in ipairs(defaults.entries or {}) do
|
||||
local identity = entry.keyType .. ":" .. tostring(entry.key)
|
||||
local saved_entry = saved_entries[identity]
|
||||
entries[#entries + 1] = {
|
||||
key = entry.key,
|
||||
keyType = entry.keyType,
|
||||
value = saved_entry and merge_values(entry.value, saved_entry.value) or copy_value(entry.value),
|
||||
}
|
||||
included[identity] = true
|
||||
end
|
||||
for _, entry in ipairs(saved.entries or {}) do
|
||||
local identity = entry.keyType .. ":" .. tostring(entry.key)
|
||||
if not included[identity] then
|
||||
entries[#entries + 1] = copy_value(entry)
|
||||
end
|
||||
end
|
||||
return { __skyType = "map", entries = entries }
|
||||
end
|
||||
if defaults.__skyType or saved.__skyType then
|
||||
return copy_value(saved)
|
||||
end
|
||||
if is_sequence(defaults) then
|
||||
local merged = copy_value(saved)
|
||||
for index, child in ipairs(defaults) do
|
||||
merged[index] = saved[index] ~= nil and merge_values(child, saved[index]) or copy_value(child)
|
||||
end
|
||||
return merged
|
||||
end
|
||||
if is_sequence(saved) then
|
||||
return copy_value(saved)
|
||||
end
|
||||
|
||||
@@ -245,6 +364,17 @@ local function mask_admin_value(value, path)
|
||||
if type(value) ~= "table" then
|
||||
return type(value) == "string" and sensitive_path(path) and REDACTED_VALUE or value
|
||||
end
|
||||
if value.__skyType == "map" then
|
||||
local entries = {}
|
||||
for index, entry in ipairs(value.entries or {}) do
|
||||
entries[index] = {
|
||||
key = entry.key,
|
||||
keyType = entry.keyType,
|
||||
value = mask_admin_value(entry.value, path .. "." .. tostring(entry.key)),
|
||||
}
|
||||
end
|
||||
return { __skyType = "map", entries = entries }
|
||||
end
|
||||
|
||||
local masked = {}
|
||||
for key, child in pairs(value) do
|
||||
@@ -260,6 +390,29 @@ local function restore_redacted_values(value, current, path)
|
||||
end
|
||||
return value
|
||||
end
|
||||
if value.__skyType == "map" then
|
||||
local current_entries = {}
|
||||
if type(current) == "table" and current.__skyType == "map" then
|
||||
for _, entry in ipairs(current.entries or {}) do
|
||||
current_entries[entry.keyType .. ":" .. tostring(entry.key)] = entry.value
|
||||
end
|
||||
end
|
||||
|
||||
local entries = {}
|
||||
for index, entry in ipairs(value.entries or {}) do
|
||||
local identity = entry.keyType .. ":" .. tostring(entry.key)
|
||||
entries[index] = {
|
||||
key = entry.key,
|
||||
keyType = entry.keyType,
|
||||
value = restore_redacted_values(
|
||||
entry.value,
|
||||
current_entries[identity],
|
||||
path .. "." .. tostring(entry.key)
|
||||
),
|
||||
}
|
||||
end
|
||||
return { __skyType = "map", entries = entries }
|
||||
end
|
||||
|
||||
local restored = {}
|
||||
for key, child in pairs(value) do
|
||||
@@ -269,7 +422,86 @@ local function restore_redacted_values(value, current, path)
|
||||
return restored
|
||||
end
|
||||
|
||||
local function add_field(fields, field_index, scope, path, value)
|
||||
local function empty_structure_kind(scope, path)
|
||||
if scope ~= "config" then
|
||||
return nil
|
||||
end
|
||||
if path == "Garage.VehicleImages.ModelNames" then
|
||||
return "map"
|
||||
end
|
||||
if path == "FlipTok.MusicTracks" or path:match("^Companies%.Definitions%.[^.]+%.Services$") then
|
||||
return "list"
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function build_structure(value, scope, path)
|
||||
local value_type = type(value)
|
||||
if scope == "config" and path == "Phone.Keybind" then
|
||||
return { kind = "optionalString" }
|
||||
end
|
||||
if value_type ~= "table" then
|
||||
return {
|
||||
kind = "value",
|
||||
valueType = value_type,
|
||||
}
|
||||
end
|
||||
|
||||
if value.__skyType == "vector2" or value.__skyType == "vector3" or value.__skyType == "vector4" then
|
||||
return {
|
||||
kind = "vector",
|
||||
vectorType = value.__skyType,
|
||||
}
|
||||
end
|
||||
if value.__skyType == "map" then
|
||||
local entries = {}
|
||||
for index, entry in ipairs(value.entries or {}) do
|
||||
entries[index] = {
|
||||
key = entry.key,
|
||||
keyType = entry.keyType,
|
||||
structure = build_structure(entry.value, scope, path .. "." .. tostring(entry.key)),
|
||||
}
|
||||
end
|
||||
return {
|
||||
entries = entries,
|
||||
kind = "map",
|
||||
}
|
||||
end
|
||||
local empty_kind = next(value) == nil and empty_structure_kind(scope, path) or nil
|
||||
if empty_kind == "map" then
|
||||
return {
|
||||
entries = {},
|
||||
kind = "map",
|
||||
}
|
||||
end
|
||||
if empty_kind == "list" then
|
||||
return {
|
||||
items = {},
|
||||
kind = "list",
|
||||
}
|
||||
end
|
||||
if is_sequence(value) then
|
||||
local items = {}
|
||||
for index, child in ipairs(value) do
|
||||
items[index] = build_structure(child, scope, path .. "." .. tostring(index))
|
||||
end
|
||||
return {
|
||||
items = items,
|
||||
kind = "list",
|
||||
}
|
||||
end
|
||||
|
||||
local fields = {}
|
||||
for key, child in pairs(value) do
|
||||
fields[key] = build_structure(child, scope, path .. "." .. tostring(key))
|
||||
end
|
||||
return {
|
||||
fields = fields,
|
||||
kind = "table",
|
||||
}
|
||||
end
|
||||
|
||||
local function add_field(fields, field_index, scope, path, value, default_value)
|
||||
local value_type = type(value)
|
||||
local field_type = value_type
|
||||
if value_type == "table" then
|
||||
@@ -277,6 +509,9 @@ local function add_field(fields, field_index, scope, path, value)
|
||||
elseif value_type ~= "boolean" and value_type ~= "number" and value_type ~= "string" then
|
||||
return
|
||||
end
|
||||
if scope == "config" and path == "Phone.Keybind" then
|
||||
field_type = "stringOrFalse"
|
||||
end
|
||||
|
||||
local sensitive = type(value) == "string" and sensitive_path(path)
|
||||
local field = {
|
||||
@@ -285,6 +520,7 @@ local function add_field(fields, field_index, scope, path, value)
|
||||
path = path,
|
||||
scope = scope,
|
||||
sensitive = sensitive,
|
||||
structure = field_type == "json" and build_structure(default_value, scope, path) or nil,
|
||||
type = field_type,
|
||||
value = sensitive and "" or mask_admin_value(value, path),
|
||||
}
|
||||
@@ -292,26 +528,7 @@ local function add_field(fields, field_index, scope, path, value)
|
||||
field_index[scope .. ":" .. path] = field
|
||||
end
|
||||
|
||||
local function flatten_fields(fields, field_index, scope, path, value)
|
||||
if type(value) ~= "table" or value.__skyType or is_sequence(value) or next(value) == nil then
|
||||
add_field(fields, field_index, scope, path, value)
|
||||
return
|
||||
end
|
||||
|
||||
local keys = {}
|
||||
for key in pairs(value) do
|
||||
keys[#keys + 1] = key
|
||||
end
|
||||
table.sort(keys, function(left, right)
|
||||
return tostring(left) < tostring(right)
|
||||
end)
|
||||
for _, key in ipairs(keys) do
|
||||
local child_path = path == "" and tostring(key) or (path .. "." .. tostring(key))
|
||||
flatten_fields(fields, field_index, scope, child_path, value[key])
|
||||
end
|
||||
end
|
||||
|
||||
local function build_sections(scope, payload, sections, field_index)
|
||||
local function build_sections(scope, payload, defaults, sections, field_index)
|
||||
local general_fields = {}
|
||||
local keys = {}
|
||||
for key in pairs(payload) do
|
||||
@@ -325,10 +542,7 @@ local function build_sections(scope, payload, sections, field_index)
|
||||
local value = payload[key]
|
||||
if type(value) == "table" and not value.__skyType and not is_sequence(value) and next(value) ~= nil then
|
||||
local fields = {}
|
||||
flatten_fields(fields, field_index, scope, tostring(key), value)
|
||||
table.sort(fields, function(left, right)
|
||||
return left.path < right.path
|
||||
end)
|
||||
add_field(fields, field_index, scope, tostring(key), value, defaults[key])
|
||||
sections[#sections + 1] = {
|
||||
fields = fields,
|
||||
id = scope .. ":" .. tostring(key),
|
||||
@@ -336,7 +550,7 @@ local function build_sections(scope, payload, sections, field_index)
|
||||
scope = scope,
|
||||
}
|
||||
else
|
||||
add_field(general_fields, field_index, scope, tostring(key), value)
|
||||
add_field(general_fields, field_index, scope, tostring(key), value, defaults[key])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -353,8 +567,8 @@ end
|
||||
local function build_admin_data()
|
||||
local sections = {}
|
||||
local field_index = {}
|
||||
build_sections("config", stored_config, sections, field_index)
|
||||
build_sections("media", stored_media, sections, field_index)
|
||||
build_sections("config", stored_config, default_config, sections, field_index)
|
||||
build_sections("media", stored_media, default_media, sections, field_index)
|
||||
return {
|
||||
enabled = configurator_enabled,
|
||||
revision = revision,
|
||||
@@ -400,6 +614,147 @@ local function get_path(root, path)
|
||||
return value
|
||||
end
|
||||
|
||||
local function validate_structured_value(value, depth, state)
|
||||
local value_type = type(value)
|
||||
if value_type == "string" then
|
||||
return #value <= 65535
|
||||
end
|
||||
if value_type == "number" then
|
||||
return value == value and value ~= math.huge and value ~= -math.huge
|
||||
end
|
||||
if value_type == "boolean" or value_type == "nil" then
|
||||
return true
|
||||
end
|
||||
if value_type ~= "table" or depth > MAX_STRUCTURED_DEPTH then
|
||||
return false
|
||||
end
|
||||
|
||||
state.entries = state.entries + 1
|
||||
if state.entries > MAX_STRUCTURED_ENTRIES then
|
||||
return false
|
||||
end
|
||||
|
||||
local sky_type = value.__skyType
|
||||
if sky_type then
|
||||
if sky_type == "vector2" or sky_type == "vector3" or sky_type == "vector4" then
|
||||
local axes = sky_type == "vector2" and { "x", "y" }
|
||||
or sky_type == "vector3" and { "x", "y", "z" }
|
||||
or { "x", "y", "z", "w" }
|
||||
for _, axis in ipairs(axes) do
|
||||
local coordinate = value[axis]
|
||||
if type(coordinate) ~= "number"
|
||||
or coordinate ~= coordinate
|
||||
or coordinate == math.huge
|
||||
or coordinate == -math.huge
|
||||
then
|
||||
return false
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
if sky_type ~= "map" or type(value.entries) ~= "table" then
|
||||
return false
|
||||
end
|
||||
if next(value.entries) and not is_sequence(value.entries) then
|
||||
return false
|
||||
end
|
||||
|
||||
local seen = {}
|
||||
for _, entry in ipairs(value.entries) do
|
||||
if type(entry) ~= "table" or (entry.keyType ~= "string" and entry.keyType ~= "number") then
|
||||
return false
|
||||
end
|
||||
local key = entry.key
|
||||
if entry.keyType == "string" then
|
||||
if type(key) ~= "string" or key == "" or #key > 255 then
|
||||
return false
|
||||
end
|
||||
elseif type(key) ~= "number"
|
||||
or key ~= key
|
||||
or key == math.huge
|
||||
or key == -math.huge
|
||||
then
|
||||
return false
|
||||
end
|
||||
|
||||
local identity = entry.keyType .. ":" .. tostring(key)
|
||||
if seen[identity] then
|
||||
return false
|
||||
end
|
||||
seen[identity] = true
|
||||
if not validate_structured_value(entry.value, depth + 1, state) then
|
||||
return false
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
for key, child in pairs(value) do
|
||||
if type(key) ~= "string" and type(key) ~= "number" then
|
||||
return false
|
||||
end
|
||||
if not validate_structured_value(child, depth + 1, state) then
|
||||
return false
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function validate_locked_structure(structure, value)
|
||||
if type(structure) ~= "table" or type(structure.kind) ~= "string" then
|
||||
return false
|
||||
end
|
||||
if structure.kind == "value" then
|
||||
return type(value) == structure.valueType
|
||||
end
|
||||
if structure.kind == "optionalString" then
|
||||
return value == false or type(value) == "string"
|
||||
end
|
||||
if structure.kind == "vector" then
|
||||
return type(value) == "table" and value.__skyType == structure.vectorType
|
||||
end
|
||||
if structure.kind == "list" then
|
||||
if type(value) ~= "table" or value.__skyType or (next(value) and not is_sequence(value)) then
|
||||
return false
|
||||
end
|
||||
for index, item_structure in ipairs(structure.items or {}) do
|
||||
if value[index] == nil or not validate_locked_structure(item_structure, value[index]) then
|
||||
return false
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
if structure.kind == "table" then
|
||||
if type(value) ~= "table" or value.__skyType or is_sequence(value) then
|
||||
return false
|
||||
end
|
||||
for key, field_structure in pairs(structure.fields or {}) do
|
||||
if value[key] == nil or not validate_locked_structure(field_structure, value[key]) then
|
||||
return false
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
if structure.kind ~= "map" or type(value) ~= "table" then
|
||||
return false
|
||||
end
|
||||
if value.__skyType ~= "map" then
|
||||
return next(value) == nil and #(structure.entries or {}) == 0
|
||||
end
|
||||
|
||||
local entries = {}
|
||||
for _, entry in ipairs(value.entries or {}) do
|
||||
entries[entry.keyType .. ":" .. tostring(entry.key)] = entry.value
|
||||
end
|
||||
for _, entry in ipairs(structure.entries or {}) do
|
||||
local child = entries[entry.keyType .. ":" .. tostring(entry.key)]
|
||||
if child == nil or not validate_locked_structure(entry.structure, child) then
|
||||
return false
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function normalize_change_value(field, value)
|
||||
if field.type == "boolean" then
|
||||
return type(value) == "boolean" and value or nil
|
||||
@@ -416,8 +771,24 @@ local function normalize_change_value(field, value)
|
||||
end
|
||||
return value
|
||||
end
|
||||
if field.type == "stringOrFalse" then
|
||||
if value == false then
|
||||
return false
|
||||
end
|
||||
if type(value) ~= "string" or #value > 65535 then
|
||||
return nil
|
||||
end
|
||||
return value
|
||||
end
|
||||
if field.type == "json" and type(value) == "table" then
|
||||
return serialize_value(value)
|
||||
if not validate_structured_value(value, 0, { entries = 0 }) then
|
||||
return nil
|
||||
end
|
||||
local serialized = serialize_value(value)
|
||||
if not validate_locked_structure(field.structure, serialized) then
|
||||
return nil
|
||||
end
|
||||
return serialized
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user