mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:08:59 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6b03ac2b7 | |||
| 9dd330da93 | |||
| a9b0c4e0f4 | |||
| 8e2523d555 | |||
| f5b28c7fda | |||
| 3a45aa159a | |||
| 22165d64df |
+36
-3
@@ -11,6 +11,7 @@ import {
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { SkyProvider } from '@/ui'
|
||||
import AdminPanel from '@/components/AdminPanel.vue'
|
||||
import PhoneHomeIndicator from '@/components/PhoneHomeIndicator.vue'
|
||||
import PhoneControlCenter from '@/components/PhoneControlCenter.vue'
|
||||
import PhoneDynamicIsland from '@/components/PhoneDynamicIsland.vue'
|
||||
@@ -114,8 +115,13 @@ type AppMessage = {
|
||||
| CustomAppCatalogEventData
|
||||
| CustomAppEventData
|
||||
| NavigationEventData
|
||||
| AdminPanelOpenPayload
|
||||
}
|
||||
|
||||
type AdminPanelOpenPayload = Required<
|
||||
Pick<PhoneOpenPayload, 'fallbackLocales' | 'lang' | 'locales'>
|
||||
>
|
||||
|
||||
type CustomAppCatalogEventData = {
|
||||
apps?: unknown
|
||||
}
|
||||
@@ -355,6 +361,9 @@ const appTransitionName = computed(() =>
|
||||
route.query.transition === 'app-switch' ? 'app-switch' : 'app-window',
|
||||
)
|
||||
const isLocked = ref(false)
|
||||
const adminPanelOpen = ref(
|
||||
isDevelopment && developmentParameters.has('adminPanel'),
|
||||
)
|
||||
const springboardEditing = ref(false)
|
||||
const isUnlocking = ref(false)
|
||||
const passcodeBusy = ref(false)
|
||||
@@ -627,6 +636,8 @@ function loadUnlockedPhoneData(): void {
|
||||
}
|
||||
|
||||
function completePhoneSetup(): void {
|
||||
const requestedRoute = pendingUnlockRoute.value
|
||||
pendingUnlockRoute.value = null
|
||||
setupPreviewDismissed.value = true
|
||||
setupAppearanceSelected.value = false
|
||||
isLocked.value = false
|
||||
@@ -634,7 +645,7 @@ function completePhoneSetup(): void {
|
||||
passcodeVisible.value = false
|
||||
passcodeRequired.value = false
|
||||
controlCenterOpened.value = false
|
||||
void router.replace('/')
|
||||
void router.replace(requestedRoute ?? '/')
|
||||
loadUnlockedPhoneData()
|
||||
}
|
||||
|
||||
@@ -718,7 +729,15 @@ function openDevelopmentPayphonePreview(): void {
|
||||
function onMessage(event: MessageEvent<AppMessage>): void {
|
||||
if (!isTrustedRootMessageSource(event.source, window)) return
|
||||
|
||||
if (event.data?.type === 'custom-apps:catalog') {
|
||||
if (event.data?.type === 'admin:open') {
|
||||
const data = event.data.data as AdminPanelOpenPayload | undefined
|
||||
if (data?.lang && data.locales && data.fallbackLocales) {
|
||||
phone.setLocale(data.lang, data.locales, data.fallbackLocales)
|
||||
}
|
||||
adminPanelOpen.value = true
|
||||
} else if (event.data?.type === 'admin:close') {
|
||||
adminPanelOpen.value = false
|
||||
} else if (event.data?.type === 'custom-apps:catalog') {
|
||||
appCatalog.replaceCatalog(event.data.data)
|
||||
const catalogPayload = event.data.data as
|
||||
| { apps?: unknown; debug?: unknown }
|
||||
@@ -763,7 +782,12 @@ function onMessage(event: MessageEvent<AppMessage>): void {
|
||||
isPhoneAppId(data.appId) &&
|
||||
appStore.isInstalled(data.appId)
|
||||
) {
|
||||
void router.push(`/apps/${data.appId}`)
|
||||
const requestedRoute = `/apps/${data.appId}`
|
||||
if (setupRequired.value || isLocked.value) {
|
||||
pendingUnlockRoute.value = requestedRoute
|
||||
} else {
|
||||
void router.push(requestedRoute)
|
||||
}
|
||||
} else {
|
||||
console.error('[Navigation] Ignored an unavailable app target.')
|
||||
}
|
||||
@@ -1726,6 +1750,15 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SkyProvider
|
||||
v-if="adminPanelOpen"
|
||||
dark
|
||||
:safe-areas="false"
|
||||
accent="#74d66f"
|
||||
accent-soft="rgba(116, 214, 111, 0.14)"
|
||||
>
|
||||
<AdminPanel @close="adminPanelOpen = false" />
|
||||
</SkyProvider>
|
||||
<PhoneMediaCapture />
|
||||
<PhoneMemoRecorder />
|
||||
<RadioHud />
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(
|
||||
new URL('./AdminPanel.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const app = readFileSync(new URL('../App.vue', import.meta.url), 'utf8')
|
||||
const apps = readFileSync(new URL('../config/apps.ts', import.meta.url), 'utf8')
|
||||
const store = readFileSync(
|
||||
new URL('../stores/admin.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const server = readFileSync(
|
||||
new URL('../../../sky_phone/source/server/admin.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const phoneServer = readFileSync(
|
||||
new URL('../../../sky_phone/source/server/phone.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const persistenceServer = readFileSync(
|
||||
new URL(
|
||||
'../../../sky_phone/source/server/phone_persistence.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
const simServer = readFileSync(
|
||||
new URL('../../../sky_phone/source/server/sim.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const phoneClient = readFileSync(
|
||||
new URL('../../../sky_phone/source/client/main.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const focusClient = readFileSync(
|
||||
new URL('../../../sky_phone/source/client/focus.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const bridge = readFileSync(
|
||||
new URL(
|
||||
'../../../sky_phone/source/client/nui_server_bridge.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
const config = readFileSync(
|
||||
new URL('../../../sky_phone/config/config.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const schema = readFileSync(
|
||||
new URL('../../../sky_phone/sql/install.sql', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('standalone admin panel contracts', () => {
|
||||
it('renders as a dedicated full-screen editor outside the phone shell', () => {
|
||||
expect(apps).not.toContain("id: 'admin'")
|
||||
expect(app).toContain("event.data?.type === 'admin:open'")
|
||||
expect(app).toContain('v-if="adminPanelOpen"')
|
||||
expect(app).toContain('<AdminPanel')
|
||||
expect(source).toContain("import { SkyButton } from '@/ui'")
|
||||
expect(source).toContain('class="admin-panel-overlay"')
|
||||
expect(source).toContain('class="admin-panel-rail"')
|
||||
expect(source).toContain('class="admin-panel-directory"')
|
||||
expect(source).toContain('class="admin-panel-editor"')
|
||||
expect(source).toContain('pointer-events: auto')
|
||||
expect(source).toContain('@media (prefers-reduced-motion: reduce)')
|
||||
})
|
||||
|
||||
it('uses a compact transparent shell with dedicated admin workspaces', () => {
|
||||
expect(source).toContain('background: transparent')
|
||||
expect(source).toContain('width: min(76vw, 1220px)')
|
||||
expect(source).toContain('height: min(74vh, 700px)')
|
||||
expect(source).toContain('--admin-row-hover: linear-gradient')
|
||||
expect(source).toContain('--admin-row-active: linear-gradient')
|
||||
expect(source).toContain('background: var(--admin-nav-active)')
|
||||
expect(source).not.toContain('admin-panel-brand__mark')
|
||||
expect(source).not.toContain('admin-panel-profile-heading__status')
|
||||
expect(source).not.toContain('backdrop-filter: blur(2px)')
|
||||
|
||||
for (const tab of [
|
||||
'overview',
|
||||
'players',
|
||||
'devices',
|
||||
'apps',
|
||||
'accounts',
|
||||
'messages',
|
||||
'calls',
|
||||
'moderation',
|
||||
'audit',
|
||||
]) {
|
||||
expect(source).toContain(`selectTab('${tab}')`)
|
||||
expect(source).toContain(`t('tabs.${tab}')`)
|
||||
}
|
||||
})
|
||||
|
||||
it('removes manual reload and applies a persistent global accent choice', () => {
|
||||
expect(source).not.toContain('<RefreshCw')
|
||||
expect(source).not.toContain("kind: 'refresh'")
|
||||
expect(source).toContain("'sky-phone-admin-accent'")
|
||||
expect(source).toContain(':style="{ \'--admin-accent\': accentColor }"')
|
||||
expect(source).toContain('color-mix(in srgb, var(--admin-green)')
|
||||
})
|
||||
|
||||
it('stages app changes locally and saves them only from the toolbar action', () => {
|
||||
expect(source).toContain('const drafts = ref<')
|
||||
expect(source).toContain('@click="saveChanges"')
|
||||
expect(source).toContain("t('editor.noAutoSave')")
|
||||
expect(store).toContain("'admin:save-apps'")
|
||||
expect(store).not.toContain("'admin:set-app'")
|
||||
expect(server).toContain(
|
||||
'Bridge.Callbacks.Register("sky_phone:admin:save-apps"',
|
||||
)
|
||||
})
|
||||
|
||||
it('connects every operation through the standard NUI callback bridge', () => {
|
||||
expect(bridge).toContain('admin = [[')
|
||||
for (const endpoint of [
|
||||
'admin:bootstrap',
|
||||
'admin:player',
|
||||
'admin:save-apps',
|
||||
'admin:reveal-password',
|
||||
'admin:activity',
|
||||
'admin:reset-passcode',
|
||||
'admin:change-number',
|
||||
'admin:factory-reset',
|
||||
]) {
|
||||
expect(store).toContain(endpoint)
|
||||
}
|
||||
})
|
||||
|
||||
it('protects activity views and device moderation with ownership and audit checks', () => {
|
||||
for (const endpoint of [
|
||||
'activity',
|
||||
'reset-passcode',
|
||||
'change-number',
|
||||
'factory-reset',
|
||||
]) {
|
||||
expect(server).toContain(
|
||||
`Bridge.Callbacks.Register("sky_phone:admin:${endpoint}"`,
|
||||
)
|
||||
}
|
||||
expect(server).toContain('data.kind ~= "messages"')
|
||||
expect(server).toContain('data.kind ~= "calls"')
|
||||
expect(server).toContain('"view_messages"')
|
||||
expect(server).toContain('"view_calls"')
|
||||
expect(server).toContain('"reset_passcode"')
|
||||
expect(server).toContain('"change_number"')
|
||||
expect(server).toContain('"factory_reset"')
|
||||
expect(simServer).toContain('function SkyPhoneSim.ChangeNumber(')
|
||||
expect(simServer).toContain('UPDATE IGNORE `sky_phone_sims`')
|
||||
expect(persistenceServer).toContain(
|
||||
'function SkyPhonePersistence.FactoryReset(imei)',
|
||||
)
|
||||
})
|
||||
|
||||
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)',
|
||||
)
|
||||
expect(server).toContain('Config.AdminPanel.ReadRequestsPerMinute')
|
||||
expect(server).toContain('Config.AdminPanel.ActionRequestsPerMinute')
|
||||
expect(server).toContain('Config.AdminPanel.CredentialRevealsPerMinute')
|
||||
expect(config).toContain('Config.AdminPanel = {')
|
||||
})
|
||||
|
||||
it('opens directly from the configurable command with dedicated focus', () => {
|
||||
expect(config).toContain('Command = "phoneadmin"')
|
||||
expect(server).toContain(
|
||||
'RegisterCommand(Config.AdminPanel.Command, function(command_source)',
|
||||
)
|
||||
expect(server).toContain(
|
||||
'TriggerClientEvent("sky_phone:admin:launch", player_source)',
|
||||
)
|
||||
expect(phoneServer).not.toContain(
|
||||
'RegisterCommand(Config.AdminPanel.Command',
|
||||
)
|
||||
expect(phoneClient).toContain('RegisterNetEvent("sky_phone:admin:launch"')
|
||||
expect(phoneClient).toContain('SkyPhoneFocus.SetAdminPanel(true)')
|
||||
expect(focusClient).toContain('function SkyPhoneFocus.SetAdminPanel(open)')
|
||||
})
|
||||
|
||||
it('validates ownership, app policy, and revision before a batch mutation', () => {
|
||||
expect(server).toContain('find_owned_device(target_source, data.imei)')
|
||||
expect(server).toContain('app_metadata(change.appId)')
|
||||
expect(server).toContain('error = "device_not_owned"')
|
||||
expect(server).toContain('error = "app_protected"')
|
||||
expect(server).toContain('AND `revision` = ?')
|
||||
expect(server).toContain('SkyPhone.RefreshDevice(data.imei)')
|
||||
})
|
||||
|
||||
it('gates plaintext account reveals behind confirmation and audit logging', () => {
|
||||
expect(source).toContain('revealDialogImei')
|
||||
expect(source).toContain("t('credentials.revealTitle')")
|
||||
expect(server).toContain('"reveal_account_password"')
|
||||
expect(server).toContain('write_audit(')
|
||||
expect(server).not.toContain('passcode_hash` AS')
|
||||
expect(schema).toContain(
|
||||
'CREATE TABLE IF NOT EXISTS `sky_phone_admin_audit`',
|
||||
)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -328,7 +328,10 @@ onBeforeUnmount(() => {
|
||||
class="app-icon"
|
||||
:class="[
|
||||
app.iconClass,
|
||||
{ 'app-icon--image': !iconFailed && app.id !== 'calendar' },
|
||||
{
|
||||
'app-icon--image':
|
||||
!iconFailed && Boolean(app.iconImage) && app.id !== 'calendar',
|
||||
},
|
||||
]"
|
||||
:style="iconStyle"
|
||||
>
|
||||
@@ -337,7 +340,7 @@ onBeforeUnmount(() => {
|
||||
<b>{{ calendarDay }}</b>
|
||||
</span>
|
||||
<img
|
||||
v-else-if="!iconFailed"
|
||||
v-else-if="!iconFailed && app.iconImage"
|
||||
:src="app.iconImage"
|
||||
alt=""
|
||||
draggable="false"
|
||||
|
||||
@@ -14,12 +14,6 @@ import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
||||
|
||||
type RecordingChunk = { blob: Blob; durationMs: number }
|
||||
type PendingVideo = { blob: Blob; fileName: string }
|
||||
type UploadFailureDebug = {
|
||||
correlationId: string
|
||||
message: string
|
||||
stage: string
|
||||
status?: number
|
||||
}
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const pendingVideos = new Map<string, PendingVideo>()
|
||||
@@ -136,10 +130,7 @@ function cleanupRecording(): void {
|
||||
try {
|
||||
activeRecorder.stop()
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[Camera] Could not stop the failed media recorder.',
|
||||
error,
|
||||
)
|
||||
console.error('[Camera] Could not stop the failed media recorder.', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,7 +245,8 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
|
||||
const activeRecorder = recorder
|
||||
removeRecorderErrorListener = bindMediaRecorderError(
|
||||
activeRecorder,
|
||||
() => generation === recordingGeneration && recorder === activeRecorder,
|
||||
() =>
|
||||
generation === recordingGeneration && recorder === activeRecorder,
|
||||
(event) => {
|
||||
console.error('[Camera] Media recorder failed while recording.', event)
|
||||
cleanupRecording()
|
||||
@@ -425,32 +417,8 @@ async function capturePhotoBlob(ready: UploadReady): Promise<Blob> {
|
||||
}
|
||||
}
|
||||
|
||||
async function failUpload(
|
||||
requestId: string,
|
||||
error: string,
|
||||
debug: UploadFailureDebug,
|
||||
): Promise<void> {
|
||||
console.error('[Sky Phone Media] Upload failed.', {
|
||||
correlationId: debug.correlationId,
|
||||
detail: debug.message,
|
||||
error,
|
||||
stage: debug.stage,
|
||||
status: debug.status,
|
||||
})
|
||||
const response = await nuiCall('media:failUpload', {
|
||||
correlationId: debug.correlationId,
|
||||
debugMessage: debug.message,
|
||||
debugStage: debug.stage,
|
||||
debugStatus: debug.status,
|
||||
error,
|
||||
requestId,
|
||||
})
|
||||
if (!response.success) {
|
||||
console.error('[Sky Phone Media] Could not forward upload diagnostics.', {
|
||||
correlationId: debug.correlationId,
|
||||
error: response.error,
|
||||
})
|
||||
}
|
||||
async function failUpload(requestId: string, error: string): Promise<void> {
|
||||
await nuiCall('media:failUpload', { error, requestId })
|
||||
}
|
||||
|
||||
async function uploadReady(ready: UploadReady): Promise<void> {
|
||||
@@ -467,87 +435,49 @@ async function uploadReady(ready: UploadReady): Promise<void> {
|
||||
blob = await capturePhotoBlob(ready)
|
||||
fileName = `camera-${ready.correlationId}.${ready.photo?.Encoding ?? 'jpg'}`
|
||||
}
|
||||
} catch (error) {
|
||||
await failUpload(ready.requestId, 'capture_failed', {
|
||||
correlationId: ready.correlationId,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stage: 'capture',
|
||||
})
|
||||
} catch {
|
||||
await failUpload(ready.requestId, 'capture_failed')
|
||||
return
|
||||
}
|
||||
|
||||
console.info('[Sky Phone Media] Capture prepared for upload.', {
|
||||
bytes: blob.size,
|
||||
correlationId: ready.correlationId,
|
||||
mimeType: blob.type,
|
||||
type: ready.mediaType,
|
||||
})
|
||||
|
||||
const form = new FormData()
|
||||
form.append('file', blob, fileName)
|
||||
form.append(
|
||||
'metadata',
|
||||
JSON.stringify({ captureToken: ready.captureToken, source: 'sky_phone' }),
|
||||
)
|
||||
const controller = new AbortController()
|
||||
const timeout = window.setTimeout(
|
||||
() => controller.abort(),
|
||||
ready.uploadTimeoutMs ?? 25000,
|
||||
)
|
||||
let debugStage = 'provider_request'
|
||||
let debugStatus: number | undefined
|
||||
try {
|
||||
const response = await fetch(ready.presignedUrl, {
|
||||
body: form,
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
})
|
||||
debugStatus = response.status
|
||||
debugStage = 'provider_response'
|
||||
console.info('[Sky Phone Media] FiveManage upload responded.', {
|
||||
correlationId: ready.correlationId,
|
||||
status: response.status,
|
||||
})
|
||||
const text = await response.text()
|
||||
const body = JSON.parse(text) as {
|
||||
data?: { id?: string; url?: string }
|
||||
error?: string
|
||||
id?: string
|
||||
message?: string
|
||||
url?: string
|
||||
}
|
||||
const uploaded = body.data ?? body
|
||||
if (!response.ok || !uploaded.id || !uploaded.url) {
|
||||
throw new Error(
|
||||
(typeof body.error === 'string' && body.error) ||
|
||||
(typeof body.message === 'string' && body.message) ||
|
||||
'upload_failed',
|
||||
)
|
||||
throw new Error('upload_failed')
|
||||
}
|
||||
debugStage = 'completion_callback'
|
||||
const completion = await nuiCall('media:completeUpload', {
|
||||
correlationId: ready.correlationId,
|
||||
await nuiCall('media:completeUpload', {
|
||||
remoteId: uploaded.id,
|
||||
requestId: ready.requestId,
|
||||
url: uploaded.url,
|
||||
})
|
||||
if (!completion.success) {
|
||||
throw new Error(completion.error ?? 'completion_callback_failed')
|
||||
}
|
||||
console.info(
|
||||
'[Sky Phone Media] Upload completion forwarded to the server.',
|
||||
{
|
||||
correlationId: ready.correlationId,
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
await failUpload(
|
||||
ready.requestId,
|
||||
error instanceof DOMException && error.name === 'AbortError'
|
||||
? 'upload_timeout'
|
||||
: 'upload_failed',
|
||||
{
|
||||
correlationId: ready.correlationId,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stage: debugStage,
|
||||
status: debugStatus,
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
window.clearTimeout(timeout)
|
||||
@@ -593,12 +523,7 @@ function onMessage(event: MessageEvent): void {
|
||||
)
|
||||
}
|
||||
} else if (message.type === 'media:uploadReady') {
|
||||
const ready = message.data as UploadReady
|
||||
console.info('[Sky Phone Media] Upload-ready received.', {
|
||||
correlationId: ready.correlationId,
|
||||
type: ready.mediaType,
|
||||
})
|
||||
void uploadReady(ready)
|
||||
void uploadReady(message.data as UploadReady)
|
||||
} else if (message.type === 'media:uploadResult') {
|
||||
const correlationId = String(message.data?.correlationId ?? '')
|
||||
if (correlationId) pendingVideos.delete(correlationId)
|
||||
|
||||
@@ -365,7 +365,6 @@ async function stopRecording(data: Record<string, unknown>): Promise<void> {
|
||||
mimeType,
|
||||
note: finalMetadata.note,
|
||||
pinned: finalMetadata.pinned,
|
||||
sizeBytes: blob.size,
|
||||
title: finalMetadata.title,
|
||||
waveform,
|
||||
}
|
||||
@@ -459,6 +458,14 @@ async function uploadReady(ready: MemoUploadReady): Promise<void> {
|
||||
pending.requestId = ready.requestId
|
||||
const form = new FormData()
|
||||
form.append('file', pending.blob, pending.fileName)
|
||||
form.append(
|
||||
'metadata',
|
||||
JSON.stringify({
|
||||
captureToken: ready.captureToken,
|
||||
purpose: 'memo',
|
||||
source: 'sky_phone',
|
||||
}),
|
||||
)
|
||||
const controller = new AbortController()
|
||||
pending.abortController = controller
|
||||
const timeout = window.setTimeout(
|
||||
|
||||
@@ -748,6 +748,7 @@ export function getPhoneAppLabel(
|
||||
}
|
||||
|
||||
export function isPhoneAppRemovable(app: PhoneAppDefinition): boolean {
|
||||
if (app.adminOnly) return false
|
||||
return app.kind === 'external'
|
||||
? app.removable && !app.defaultInstalled
|
||||
: !DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) &&
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const mediaConfig = readFileSync(
|
||||
new URL('../../sky_phone/config/media.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const manifest = readFileSync(
|
||||
new URL('../../sky_phone/fxmanifest.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const mediaProviderConfig = readFileSync(
|
||||
new URL(
|
||||
'../../sky_phone/source/server/media_provider_config.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
const mediaImportAdapter = readFileSync(
|
||||
new URL(
|
||||
'../../sky_phone/source/server/media_import/fivemanage.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
const mediaServer = readFileSync(
|
||||
new URL('../../sky_phone/source/server/media.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const memoServer = readFileSync(
|
||||
new URL('../../sky_phone/source/server/memos.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const mediaCapture = readFileSync(
|
||||
new URL('./components/PhoneMediaCapture.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const memoRecorder = readFileSync(
|
||||
new URL('./components/PhoneMemoRecorder.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('FiveManage server configuration contract', () => {
|
||||
it('keeps the provider token in the server-only media config', () => {
|
||||
expect(mediaConfig).toMatch(/FiveManage\s*=\s*{\s*ApiKey\s*=/)
|
||||
expect(mediaProviderConfig).toContain(
|
||||
'return trim_key(Config.Media.FiveManage.ApiKey)',
|
||||
)
|
||||
expect(mediaProviderConfig).not.toContain('GetConvar')
|
||||
})
|
||||
|
||||
it('uses one resolver for Camera uploads and FiveManage imports', () => {
|
||||
expect(
|
||||
manifest.indexOf("'source/server/media_provider_config.lua'"),
|
||||
).toBeLessThan(manifest.indexOf("'source/server/media_import.lua'"))
|
||||
expect(mediaServer).toContain(
|
||||
'SkyPhoneMediaProviderConfig.FiveManageApiKey()',
|
||||
)
|
||||
expect(mediaImportAdapter).toContain(
|
||||
'SkyPhoneMediaProviderConfig.FiveManageApiKey(website.ApiKey)',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the direct FiveManage upload response flow for Camera and voice memos', () => {
|
||||
expect(mediaConfig).not.toContain('VerificationRetryDelaysMs')
|
||||
expect(mediaCapture).toContain("form.append('file', blob, fileName)")
|
||||
expect(mediaCapture).not.toContain("form.append('path'")
|
||||
expect(mediaCapture).not.toContain("form.append(\n 'metadata'")
|
||||
expect(memoRecorder).toContain(
|
||||
"form.append('file', pending.blob, pending.fileName)",
|
||||
)
|
||||
expect(memoRecorder).not.toContain("form.append('path'")
|
||||
expect(memoRecorder).not.toContain("form.append(\n 'metadata'")
|
||||
expect(mediaServer).toContain(
|
||||
'Accepting the direct FiveManage upload response',
|
||||
)
|
||||
expect(mediaServer).toContain('remote_id = remote_id')
|
||||
expect(mediaServer).toContain('url = uploaded_url')
|
||||
expect(mediaServer).not.toContain('"HEAD"')
|
||||
expect(mediaServer).not.toContain('authenticated upload-path lookup')
|
||||
})
|
||||
|
||||
it('allowlists the FiveManage API and media hosts', () => {
|
||||
expect(mediaServer).toContain('["api.fivemanage.com"] = true')
|
||||
expect(mediaServer).toContain('["fmapi.net"] = true')
|
||||
expect(mediaServer).toContain(
|
||||
'uploaded_host:lower() ~= "r2.fivemanage.com"',
|
||||
)
|
||||
})
|
||||
|
||||
it('validates and preserves the recorded memo size before upload', () => {
|
||||
expect(memoRecorder).toContain('sizeBytes: blob.size')
|
||||
expect(memoServer).toContain(
|
||||
'local size_bytes = tonumber(data.sizeBytes)',
|
||||
)
|
||||
expect(memoServer).toContain(
|
||||
'size_bytes < 1 or size_bytes > Config.Memos.MaximumBytes',
|
||||
)
|
||||
expect(memoServer).toContain('size_bytes = memo.size_bytes')
|
||||
expect(mediaServer).toContain('size = state.size_bytes')
|
||||
})
|
||||
})
|
||||
@@ -1,21 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const mediaCapture = readFileSync(
|
||||
new URL('./components/PhoneMediaCapture.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const mediaServer = readFileSync(
|
||||
new URL('../../sky_phone/source/server/media.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('media upload diagnostics contracts', () => {
|
||||
it('forwards browser upload failure context to the server log', () => {
|
||||
expect(mediaCapture).toContain("let debugStage = 'provider_request'")
|
||||
expect(mediaCapture).toContain('debugStatus: debug.status')
|
||||
expect(mediaServer).toContain('Client-reported upload failure')
|
||||
expect(mediaServer).toContain('diagnostic_text(data.debugMessage, 240)')
|
||||
})
|
||||
})
|
||||
@@ -29,4 +29,10 @@ describe('neutral phone navigation contract', () => {
|
||||
expect(navigationSource).toContain('if not installed_apps[normalized_app_id] then')
|
||||
expect(navigationSource).toContain('if current_app_id ~= normalized_app_id then')
|
||||
})
|
||||
|
||||
it('defers command-driven app routes until setup or device unlock completes', () => {
|
||||
expect(appSource).toContain('if (setupRequired.value || isLocked.value)')
|
||||
expect(appSource).toContain('pendingUnlockRoute.value = requestedRoute')
|
||||
expect(appSource).toContain("void router.replace(requestedRoute ?? '/')")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import type {
|
||||
AdminAuditEntry,
|
||||
AdminActivityResponse,
|
||||
AdminBootstrap,
|
||||
AdminCallActivity,
|
||||
AdminCredential,
|
||||
AdminMessageActivity,
|
||||
AdminPlayerDetail,
|
||||
AdminPlayerSummary,
|
||||
AdminStats,
|
||||
} from '@/types/admin'
|
||||
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||
|
||||
const EMPTY_STATS: AdminStats = { accounts: 0, devices: 0, online: 0 }
|
||||
|
||||
export const useAdminStore = defineStore('admin', {
|
||||
state: () => ({
|
||||
actionKey: '',
|
||||
activityKey: '',
|
||||
audit: [] as AdminAuditEntry[],
|
||||
detailLoading: false,
|
||||
error: '',
|
||||
initialized: false,
|
||||
loading: false,
|
||||
players: [] as AdminPlayerSummary[],
|
||||
revealedCredentials: {} as Record<string, AdminCredential>,
|
||||
deviceActivity: {} as Record<
|
||||
string,
|
||||
{ calls?: AdminCallActivity[]; messages?: AdminMessageActivity[] }
|
||||
>,
|
||||
selectedPlayer: null as AdminPlayerDetail | null,
|
||||
stats: { ...EMPTY_STATS },
|
||||
}),
|
||||
actions: {
|
||||
async load(): Promise<boolean> {
|
||||
this.loading = true
|
||||
const response = await nuiCall<AdminBootstrap>('admin:bootstrap')
|
||||
this.loading = false
|
||||
if (!response.success || !response.data) {
|
||||
this.error = response.error ?? 'request_failed'
|
||||
return false
|
||||
}
|
||||
this.players = response.data.players
|
||||
this.stats = response.data.stats
|
||||
this.audit = response.data.audit
|
||||
this.error = ''
|
||||
this.initialized = true
|
||||
return true
|
||||
},
|
||||
async openPlayer(source: number): Promise<boolean> {
|
||||
this.detailLoading = true
|
||||
this.revealedCredentials = {}
|
||||
const response = await nuiCall<AdminPlayerDetail>('admin:player', {
|
||||
source,
|
||||
})
|
||||
this.detailLoading = false
|
||||
if (!response.success || !response.data) {
|
||||
this.error = response.error ?? 'request_failed'
|
||||
return false
|
||||
}
|
||||
this.selectedPlayer = response.data
|
||||
this.error = ''
|
||||
return true
|
||||
},
|
||||
closePlayer(): void {
|
||||
this.selectedPlayer = null
|
||||
this.revealedCredentials = {}
|
||||
},
|
||||
async saveApps(
|
||||
source: number,
|
||||
imei: string,
|
||||
revision: number,
|
||||
changes: Array<{ appId: string; installed: boolean }>,
|
||||
): Promise<NuiResponse<AdminPlayerDetail>> {
|
||||
this.actionKey = `${imei}:save`
|
||||
const response = await nuiCall<AdminPlayerDetail>('admin:save-apps', {
|
||||
changes,
|
||||
imei,
|
||||
revision,
|
||||
source,
|
||||
})
|
||||
this.actionKey = ''
|
||||
if (response.success && response.data) {
|
||||
this.selectedPlayer = response.data
|
||||
this.error = ''
|
||||
} else {
|
||||
this.error = response.error ?? 'request_failed'
|
||||
}
|
||||
return response
|
||||
},
|
||||
async revealPassword(
|
||||
source: number,
|
||||
imei: string,
|
||||
): Promise<NuiResponse<AdminCredential>> {
|
||||
this.actionKey = `${imei}:password`
|
||||
const response = await nuiCall<AdminCredential>('admin:reveal-password', {
|
||||
imei,
|
||||
source,
|
||||
})
|
||||
this.actionKey = ''
|
||||
if (response.success && response.data) {
|
||||
this.revealedCredentials[imei] = response.data
|
||||
this.error = ''
|
||||
} else {
|
||||
this.error = response.error ?? 'request_failed'
|
||||
}
|
||||
return response
|
||||
},
|
||||
async loadActivity(
|
||||
source: number,
|
||||
imei: string,
|
||||
kind: 'messages' | 'calls',
|
||||
): Promise<boolean> {
|
||||
this.activityKey = `${imei}:${kind}`
|
||||
const response = await nuiCall<AdminActivityResponse>('admin:activity', {
|
||||
imei,
|
||||
kind,
|
||||
source,
|
||||
})
|
||||
this.activityKey = ''
|
||||
if (!response.success || !response.data) {
|
||||
this.error = response.error ?? 'request_failed'
|
||||
return false
|
||||
}
|
||||
const activity = this.deviceActivity[imei] ?? {}
|
||||
if (response.data.kind === 'messages') {
|
||||
activity.messages = response.data.entries
|
||||
} else {
|
||||
activity.calls = response.data.entries
|
||||
}
|
||||
this.deviceActivity[imei] = activity
|
||||
this.error = ''
|
||||
return true
|
||||
},
|
||||
async resetPasscode(
|
||||
source: number,
|
||||
imei: string,
|
||||
): Promise<NuiResponse<AdminPlayerDetail>> {
|
||||
this.actionKey = `${imei}:reset-passcode`
|
||||
const response = await nuiCall<AdminPlayerDetail>(
|
||||
'admin:reset-passcode',
|
||||
{ imei, source },
|
||||
)
|
||||
this.actionKey = ''
|
||||
if (response.success && response.data) {
|
||||
this.selectedPlayer = response.data
|
||||
delete this.revealedCredentials[imei]
|
||||
this.error = ''
|
||||
} else {
|
||||
this.error = response.error ?? 'request_failed'
|
||||
}
|
||||
return response
|
||||
},
|
||||
async changeNumber(
|
||||
source: number,
|
||||
imei: string,
|
||||
phoneNumber: string,
|
||||
): Promise<NuiResponse<AdminPlayerDetail>> {
|
||||
this.actionKey = `${imei}:change-number`
|
||||
const response = await nuiCall<AdminPlayerDetail>('admin:change-number', {
|
||||
imei,
|
||||
phoneNumber,
|
||||
source,
|
||||
})
|
||||
this.actionKey = ''
|
||||
if (response.success && response.data) {
|
||||
this.selectedPlayer = response.data
|
||||
delete this.deviceActivity[imei]
|
||||
this.error = ''
|
||||
} else {
|
||||
this.error = response.error ?? 'request_failed'
|
||||
}
|
||||
return response
|
||||
},
|
||||
async factoryReset(
|
||||
source: number,
|
||||
imei: string,
|
||||
): Promise<NuiResponse<AdminPlayerDetail>> {
|
||||
this.actionKey = `${imei}:factory-reset`
|
||||
const response = await nuiCall<AdminPlayerDetail>('admin:factory-reset', {
|
||||
imei,
|
||||
source,
|
||||
})
|
||||
this.actionKey = ''
|
||||
if (response.success && response.data) {
|
||||
this.selectedPlayer = response.data
|
||||
delete this.deviceActivity[imei]
|
||||
delete this.revealedCredentials[imei]
|
||||
this.error = ''
|
||||
} else {
|
||||
this.error = response.error ?? 'request_failed'
|
||||
}
|
||||
return response
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -75,6 +75,13 @@ describe('app store', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('drops the retired admin app from persisted phone layouts', () => {
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
apps.hydrate({ claimedApps: ['admin'] })
|
||||
expect(apps.homeLayout.grid).not.toContain('admin')
|
||||
})
|
||||
|
||||
it('migrates current layouts so dock apps are not repeated in the grid', () => {
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
|
||||
@@ -58,11 +58,12 @@ function getDefaultDockIds(): LaunchablePhoneAppId[] {
|
||||
}
|
||||
|
||||
function getDefaultInstalledIds(): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter((app) =>
|
||||
isExternalPhoneApp(app)
|
||||
return PHONE_APPS.filter((app) => {
|
||||
if (app.adminOnly) return false
|
||||
return isExternalPhoneApp(app)
|
||||
? app.defaultInstalled
|
||||
: DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id),
|
||||
).map((app) => app.id)
|
||||
: DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id)
|
||||
}).map((app) => app.id)
|
||||
}
|
||||
|
||||
function isProtectedHomeApp(appId: LaunchablePhoneAppId): boolean {
|
||||
@@ -237,13 +238,15 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
layoutVersion === 5 ||
|
||||
layoutVersion === 6
|
||||
this.claimedApps = Array.isArray(data?.claimedApps)
|
||||
? data.claimedApps.filter(
|
||||
(id): id is LaunchablePhoneAppId =>
|
||||
typeof id === 'string' &&
|
||||
(isPhoneAppId(id) ||
|
||||
(supportsPersistedExternalApps &&
|
||||
isValidExternalPhoneAppId(id))),
|
||||
)
|
||||
? data.claimedApps.filter((id): id is LaunchablePhoneAppId => {
|
||||
if (typeof id !== 'string') return false
|
||||
const app = getPhoneApp(id)
|
||||
if (app?.adminOnly) return false
|
||||
return (
|
||||
isPhoneAppId(id) ||
|
||||
(supportsPersistedExternalApps && isValidExternalPhoneAppId(id))
|
||||
)
|
||||
})
|
||||
: []
|
||||
this.uninstalledApps = Array.isArray(data?.uninstalledApps)
|
||||
? data.uninstalledApps.filter((id): id is LaunchablePhoneAppId => {
|
||||
@@ -308,9 +311,10 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
}
|
||||
},
|
||||
isInstalled(appId: LaunchablePhoneAppId): boolean {
|
||||
const app = getPhoneApp(appId)
|
||||
if (app?.adminOnly) return false
|
||||
if (this.uninstalledApps.includes(appId)) return false
|
||||
if (this.claimedApps.includes(appId)) return true
|
||||
const app = getPhoneApp(appId)
|
||||
if (!app) return false
|
||||
return isExternalPhoneApp(app)
|
||||
? app.defaultInstalled
|
||||
|
||||
+439
-41
@@ -805,7 +805,257 @@ const citywarnFallbackLocales = {
|
||||
},
|
||||
}
|
||||
|
||||
const adminPanelFallbackLocales = {
|
||||
name: 'Phone Admin',
|
||||
subtitle: 'Administration',
|
||||
navigation: 'Admin navigation',
|
||||
refresh: 'Refresh admin data',
|
||||
loading: 'Loading protected data...',
|
||||
tabs: {
|
||||
overview: 'Overview',
|
||||
players: 'Players',
|
||||
devices: 'Devices',
|
||||
apps: 'Apps',
|
||||
accounts: 'Accounts',
|
||||
messages: 'Messages',
|
||||
calls: 'Calls',
|
||||
moderation: 'Moderation',
|
||||
audit: 'Audit',
|
||||
},
|
||||
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',
|
||||
},
|
||||
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',
|
||||
},
|
||||
},
|
||||
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',
|
||||
},
|
||||
devices: {
|
||||
eyebrow: 'Device control',
|
||||
title: 'Phones',
|
||||
body: 'Inspect every phone assigned to the selected player.',
|
||||
choose: 'Choose phone',
|
||||
empty: 'No phone found',
|
||||
emptyBody: 'This player currently has no phone device that can be managed.',
|
||||
noNumber: 'No phone number',
|
||||
noSim: 'No SIM',
|
||||
imei: 'IMEI',
|
||||
updated: 'Last activity',
|
||||
apps: 'Claimed apps',
|
||||
account: 'Linked account',
|
||||
},
|
||||
credentials: {
|
||||
eyebrow: 'Protected data',
|
||||
title: 'Credentials',
|
||||
email: 'iFruit email',
|
||||
password: 'iFruit password',
|
||||
reveal: 'Reveal',
|
||||
copy: 'Copy password',
|
||||
copied: 'Password copied.',
|
||||
noAccount: 'No iFruit account is linked to this phone.',
|
||||
passcode: 'Device passcode',
|
||||
passcodeHashed: '{length}-digit PIN · securely hashed and not recoverable',
|
||||
passcodeDisabled: 'No passcode configured',
|
||||
revealTitle: 'Reveal protected password?',
|
||||
revealBody:
|
||||
'This action is server-authorized, rate-limited, and written to the admin audit log.',
|
||||
cancel: 'Cancel',
|
||||
confirmReveal: 'Reveal password',
|
||||
},
|
||||
apps: {
|
||||
eyebrow: 'Remote management',
|
||||
title: 'App access',
|
||||
description:
|
||||
'Stage app access for this device. Nothing changes until you save.',
|
||||
installed: 'Installed',
|
||||
available: 'Available',
|
||||
protected: 'System app',
|
||||
changes: '{count} pending changes',
|
||||
},
|
||||
activity: {
|
||||
protected: 'Protected activity',
|
||||
messagesTitle: 'Messages',
|
||||
messagesBody: 'Recent SMS activity for the selected SIM.',
|
||||
callsTitle: 'Calls',
|
||||
callsBody: 'Recent call activity for the selected SIM.',
|
||||
loading: 'Loading activity...',
|
||||
incoming: 'Incoming',
|
||||
outgoing: 'Outgoing',
|
||||
mediaMessage: '{type} message',
|
||||
noMessages: 'No message activity found.',
|
||||
noCalls: 'No call activity found.',
|
||||
status: {
|
||||
completed: 'Completed',
|
||||
missed: 'Missed',
|
||||
rejected: 'Rejected',
|
||||
busy: 'Busy',
|
||||
unanswered: 'Unanswered',
|
||||
cancelled: 'Cancelled',
|
||||
failed: 'Failed',
|
||||
ringing: 'Ringing',
|
||||
},
|
||||
},
|
||||
moderation: {
|
||||
eyebrow: 'Device administration',
|
||||
title: 'Moderation actions',
|
||||
body: 'Every action is server-authorized, rate-limited, and audited.',
|
||||
resetPasscode: 'Reset passcode',
|
||||
resetPasscodeBody: 'Remove the device PIN and clear failed attempts.',
|
||||
changeNumber: 'Change number',
|
||||
changeNumberBody: 'Assign a new unique number to the current SIM.',
|
||||
factoryReset: 'Factory reset',
|
||||
factoryResetBody: 'Clear local device data and disconnect the account.',
|
||||
saveFirst: 'Save or discard pending app changes first.',
|
||||
phoneNumber: 'New phone number',
|
||||
phoneNumberPlaceholder: 'Enter the full configured number',
|
||||
typeToConfirm: 'Type {word} to confirm the factory reset.',
|
||||
confirmWord: 'RESET',
|
||||
cancel: 'Cancel',
|
||||
'reset-passcodeSuccess': 'Passcode reset.',
|
||||
'change-numberSuccess': 'Phone number changed.',
|
||||
'factory-resetSuccess': 'Phone factory reset completed.',
|
||||
dialogs: {
|
||||
'reset-passcodeTitle': 'Reset device passcode?',
|
||||
'reset-passcodeBody':
|
||||
'The player can unlock this phone without the previous PIN afterward.',
|
||||
'change-numberTitle': 'Change phone number?',
|
||||
'change-numberBody':
|
||||
'The new number must match the configured server number format and be unique.',
|
||||
'factory-resetTitle': 'Factory reset this phone?',
|
||||
'factory-resetBody':
|
||||
'This clears local device data, app settings, security, and the linked account. This cannot be undone.',
|
||||
},
|
||||
confirm: {
|
||||
'reset-passcode': 'Reset passcode',
|
||||
'change-number': 'Change number',
|
||||
'factory-reset': 'Factory reset',
|
||||
},
|
||||
},
|
||||
editor: {
|
||||
brand: 'SKY PHONE',
|
||||
workspace: 'ADMIN',
|
||||
players: 'Player directory',
|
||||
audit: 'Audit log',
|
||||
selectPlayer:
|
||||
'Select a player to inspect identity, devices, credentials, and app access.',
|
||||
save: 'Save changes',
|
||||
saveHint: 'Apply pending changes',
|
||||
saved: 'Changes saved.',
|
||||
unsaved: 'Unsaved changes',
|
||||
close: 'Close admin panel',
|
||||
refresh: 'Refresh live data',
|
||||
online: 'LIVE',
|
||||
profile: 'PROFILE',
|
||||
financial: 'FINANCIAL',
|
||||
device: 'DEVICE',
|
||||
security: 'SECURITY',
|
||||
noAutoSave: 'Manual save',
|
||||
noAutoSaveBody: 'Changes stay local until the green check is pressed.',
|
||||
discardTitle: 'Discard unsaved changes?',
|
||||
discardBody: 'Your staged app changes have not been saved.',
|
||||
keepEditing: 'Keep editing',
|
||||
discard: 'Discard changes',
|
||||
saveFailed: 'Some changes could not be saved.',
|
||||
noSelection: 'No player selected',
|
||||
},
|
||||
audit: {
|
||||
eyebrow: 'Accountability',
|
||||
title: 'Audit trail',
|
||||
body: 'Sensitive reveals and remote app changes are recorded here.',
|
||||
empty: 'No admin actions yet',
|
||||
emptyBody: 'Protected actions will appear here after they are performed.',
|
||||
by: '{actor} · target ID {target}',
|
||||
actions: {
|
||||
grant_app: 'App installed',
|
||||
revoke_app: 'App removed',
|
||||
reveal_account_password: 'Password revealed',
|
||||
view_messages: 'Messages viewed',
|
||||
view_calls: 'Calls viewed',
|
||||
reset_passcode: 'Passcode reset',
|
||||
change_number: 'Phone number changed',
|
||||
factory_reset: 'Phone factory reset',
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
not_authorized: 'You do not have access to the admin panel.',
|
||||
rate_limited: 'Too many admin requests. Please wait.',
|
||||
player_unavailable: 'That player is no longer online.',
|
||||
device_not_owned: 'That phone no longer belongs to the selected player.',
|
||||
invalid_app: 'That app is not registered on the server.',
|
||||
app_protected: 'This system app cannot be removed.',
|
||||
revision_conflict:
|
||||
'The phone changed in the meantime. Refresh and try again.',
|
||||
account_not_found: 'No iFruit account is linked to this phone.',
|
||||
invalid_phone_number:
|
||||
'Enter a phone number in the configured server format.',
|
||||
phone_number_unchanged: 'This SIM already uses that phone number.',
|
||||
phone_number_taken: 'That phone number is already assigned.',
|
||||
no_sim: 'This phone has no SIM that can be changed.',
|
||||
passcode_not_set: 'This phone has no passcode configured.',
|
||||
device_not_found: 'This phone no longer exists.',
|
||||
metadata_unsupported: 'The phone inventory metadata could not be updated.',
|
||||
invalid_request: 'The admin request was invalid.',
|
||||
request_failed: 'The admin request failed.',
|
||||
default: 'The admin panel is temporarily unavailable.',
|
||||
},
|
||||
}
|
||||
|
||||
const defaultLocales: LocaleTree = {
|
||||
AdminPanel: adminPanelFallbackLocales,
|
||||
Apps: {
|
||||
citywarn: citywarnFallbackLocales,
|
||||
crypto: cryptoFallbackLocales,
|
||||
@@ -2432,46 +2682,190 @@ const defaultLocales: LocaleTree = {
|
||||
'neon-drop': 'Neon block-dropping puzzle',
|
||||
},
|
||||
previews: {
|
||||
citywarn: { first: 'Live alerts', second: 'Safety zones', third: 'Incident updates' },
|
||||
crypto: { first: 'Synthetic markets', second: 'Portfolio', third: 'Wallet transfers' },
|
||||
health: { first: 'Activity rings', second: 'Medical ID', third: 'Health records' },
|
||||
'weazel-news': { first: 'Top stories', second: 'Local reports', third: 'Breaking news' },
|
||||
companies: { first: 'Business directory', second: 'Job requests', third: 'Services' },
|
||||
music: { first: 'Now playing', second: 'Playlists', third: 'Music library' },
|
||||
picstagram: { first: 'Photo feed', second: 'Stories', third: 'Profiles' },
|
||||
feather: { first: 'Short posts', second: 'Following feed', third: 'Conversations' },
|
||||
fliptok: { first: 'Video feed', second: 'Creator tools', third: 'Trends' },
|
||||
flare: { first: 'Discover people', second: 'Matches', third: 'Live moments' },
|
||||
calendar: { first: 'Upcoming events', second: 'Day planner', third: 'Reminders' },
|
||||
radio: { first: 'Live channels', second: 'Team radio', third: 'Favorites' },
|
||||
'local-pages': { first: 'Local pages', second: 'Reviews', third: 'City discovery' },
|
||||
crewlink: { first: 'Crew roster', second: 'Shared locations', third: 'Coordination' },
|
||||
phone: { first: 'Recent calls', second: 'Contacts', third: 'Voicemail' },
|
||||
messages: { first: 'Conversations', second: 'Media sharing', third: 'Quick replies' },
|
||||
darkchat: { first: 'Private chats', second: 'Secure groups', third: 'Invitations' },
|
||||
garage: { first: 'Vehicle list', second: 'Parking locations', third: 'Valet' },
|
||||
house: { first: 'Property access', second: 'Residents', third: 'Management' },
|
||||
map: { first: 'Live navigation', second: 'Nearby places', third: 'Route guidance' },
|
||||
skyride: { first: 'Ride booking', second: 'Driver tracking', third: 'Trip history' },
|
||||
banking: { first: 'Account balance', second: 'Transfers', third: 'Transactions' },
|
||||
billing: { first: 'Open invoices', second: 'Payment requests', third: 'Payment history' },
|
||||
citywarn: {
|
||||
first: 'Live alerts',
|
||||
second: 'Safety zones',
|
||||
third: 'Incident updates',
|
||||
},
|
||||
crypto: {
|
||||
first: 'Synthetic markets',
|
||||
second: 'Portfolio',
|
||||
third: 'Wallet transfers',
|
||||
},
|
||||
health: {
|
||||
first: 'Activity rings',
|
||||
second: 'Medical ID',
|
||||
third: 'Health records',
|
||||
},
|
||||
'weazel-news': {
|
||||
first: 'Top stories',
|
||||
second: 'Local reports',
|
||||
third: 'Breaking news',
|
||||
},
|
||||
companies: {
|
||||
first: 'Business directory',
|
||||
second: 'Job requests',
|
||||
third: 'Services',
|
||||
},
|
||||
music: {
|
||||
first: 'Now playing',
|
||||
second: 'Playlists',
|
||||
third: 'Music library',
|
||||
},
|
||||
picstagram: {
|
||||
first: 'Photo feed',
|
||||
second: 'Stories',
|
||||
third: 'Profiles',
|
||||
},
|
||||
feather: {
|
||||
first: 'Short posts',
|
||||
second: 'Following feed',
|
||||
third: 'Conversations',
|
||||
},
|
||||
fliptok: {
|
||||
first: 'Video feed',
|
||||
second: 'Creator tools',
|
||||
third: 'Trends',
|
||||
},
|
||||
flare: {
|
||||
first: 'Discover people',
|
||||
second: 'Matches',
|
||||
third: 'Live moments',
|
||||
},
|
||||
calendar: {
|
||||
first: 'Upcoming events',
|
||||
second: 'Day planner',
|
||||
third: 'Reminders',
|
||||
},
|
||||
radio: {
|
||||
first: 'Live channels',
|
||||
second: 'Team radio',
|
||||
third: 'Favorites',
|
||||
},
|
||||
'local-pages': {
|
||||
first: 'Local pages',
|
||||
second: 'Reviews',
|
||||
third: 'City discovery',
|
||||
},
|
||||
crewlink: {
|
||||
first: 'Crew roster',
|
||||
second: 'Shared locations',
|
||||
third: 'Coordination',
|
||||
},
|
||||
phone: {
|
||||
first: 'Recent calls',
|
||||
second: 'Contacts',
|
||||
third: 'Voicemail',
|
||||
},
|
||||
messages: {
|
||||
first: 'Conversations',
|
||||
second: 'Media sharing',
|
||||
third: 'Quick replies',
|
||||
},
|
||||
darkchat: {
|
||||
first: 'Private chats',
|
||||
second: 'Secure groups',
|
||||
third: 'Invitations',
|
||||
},
|
||||
garage: {
|
||||
first: 'Vehicle list',
|
||||
second: 'Parking locations',
|
||||
third: 'Valet',
|
||||
},
|
||||
house: {
|
||||
first: 'Property access',
|
||||
second: 'Residents',
|
||||
third: 'Management',
|
||||
},
|
||||
map: {
|
||||
first: 'Live navigation',
|
||||
second: 'Nearby places',
|
||||
third: 'Route guidance',
|
||||
},
|
||||
skyride: {
|
||||
first: 'Ride booking',
|
||||
second: 'Driver tracking',
|
||||
third: 'Trip history',
|
||||
},
|
||||
banking: {
|
||||
first: 'Account balance',
|
||||
second: 'Transfers',
|
||||
third: 'Transactions',
|
||||
},
|
||||
billing: {
|
||||
first: 'Open invoices',
|
||||
second: 'Payment requests',
|
||||
third: 'Payment history',
|
||||
},
|
||||
mail: { first: 'Inbox', second: 'Attachments', third: 'Mailboxes' },
|
||||
notes: { first: 'Notes', second: 'Checklists', third: 'Pinned ideas' },
|
||||
memos: { first: 'Voice recordings', second: 'Playback', third: 'Favorites' },
|
||||
calculator: { first: 'Basic calculation', second: 'Scientific tools', third: 'History' },
|
||||
camera: { first: 'Photo mode', second: 'Video capture', third: 'Zoom controls' },
|
||||
memos: {
|
||||
first: 'Voice recordings',
|
||||
second: 'Playback',
|
||||
third: 'Favorites',
|
||||
},
|
||||
calculator: {
|
||||
first: 'Basic calculation',
|
||||
second: 'Scientific tools',
|
||||
third: 'History',
|
||||
},
|
||||
camera: {
|
||||
first: 'Photo mode',
|
||||
second: 'Video capture',
|
||||
third: 'Zoom controls',
|
||||
},
|
||||
clock: { first: 'World clock', second: 'Alarms', third: 'Timers' },
|
||||
weather: { first: 'Current weather', second: 'Hourly forecast', third: 'Seven-day outlook' },
|
||||
photos: { first: 'Media library', second: 'Albums', third: 'Shared media' },
|
||||
settings: { first: 'Device controls', second: 'Privacy', third: 'Personalization' },
|
||||
weather: {
|
||||
first: 'Current weather',
|
||||
second: 'Hourly forecast',
|
||||
third: 'Seven-day outlook',
|
||||
},
|
||||
photos: {
|
||||
first: 'Media library',
|
||||
second: 'Albums',
|
||||
third: 'Shared media',
|
||||
},
|
||||
settings: {
|
||||
first: 'Device controls',
|
||||
second: 'Privacy',
|
||||
third: 'Personalization',
|
||||
},
|
||||
snake: { first: 'High score', second: 'Speed', third: 'Classic grid' },
|
||||
memory: { first: 'Matched pairs', second: 'Best time', third: 'Card themes' },
|
||||
'number-merge': { first: 'Highest tile', second: 'Score', third: 'Strategy grid' },
|
||||
minesweeper: { first: 'Mine counter', second: 'Best time', third: 'Difficulty' },
|
||||
'tower-stack': { first: 'Tower height', second: 'Perfect drops', third: 'High score' },
|
||||
'sky-flappy': { first: 'Flight score', second: 'Best run', third: 'Obstacles' },
|
||||
citymarkt: { first: 'Listings', second: 'Categories', third: 'Saved offers' },
|
||||
'neon-drop': { first: 'Lines cleared', second: 'Level', third: 'Neon pieces' },
|
||||
memory: {
|
||||
first: 'Matched pairs',
|
||||
second: 'Best time',
|
||||
third: 'Card themes',
|
||||
},
|
||||
'number-merge': {
|
||||
first: 'Highest tile',
|
||||
second: 'Score',
|
||||
third: 'Strategy grid',
|
||||
},
|
||||
minesweeper: {
|
||||
first: 'Mine counter',
|
||||
second: 'Best time',
|
||||
third: 'Difficulty',
|
||||
},
|
||||
'tower-stack': {
|
||||
first: 'Tower height',
|
||||
second: 'Perfect drops',
|
||||
third: 'High score',
|
||||
},
|
||||
'sky-flappy': {
|
||||
first: 'Flight score',
|
||||
second: 'Best run',
|
||||
third: 'Obstacles',
|
||||
},
|
||||
citymarkt: {
|
||||
first: 'Listings',
|
||||
second: 'Categories',
|
||||
third: 'Saved offers',
|
||||
},
|
||||
'neon-drop': {
|
||||
first: 'Lines cleared',
|
||||
second: 'Level',
|
||||
third: 'Neon pieces',
|
||||
},
|
||||
},
|
||||
search: {
|
||||
recommended: 'Recommended',
|
||||
@@ -4012,11 +4406,6 @@ const defaultLocales: LocaleTree = {
|
||||
invalid_media_type: 'The uploaded media type is invalid.',
|
||||
invalid_upload: 'The upload could not be verified.',
|
||||
invalid_upload_token: 'The upload session is no longer valid.',
|
||||
media_provider_failed: 'The camera upload service is unavailable.',
|
||||
media_provider_rate_limited:
|
||||
'The camera upload service is busy. Try again shortly.',
|
||||
media_provider_unauthorized:
|
||||
'The configured FiveManage API key was rejected.',
|
||||
missing_config: 'Camera uploads are not configured.',
|
||||
microphone_unavailable:
|
||||
'Allow microphone access or mute the microphone before recording.',
|
||||
@@ -5188,6 +5577,15 @@ export const usePhoneStore = defineStore('phone', {
|
||||
this.cameraLandscape = false
|
||||
this.isOpen = false
|
||||
},
|
||||
setLocale(
|
||||
lang: string,
|
||||
locales: LocaleTree,
|
||||
fallbackLocales: LocaleTree,
|
||||
): void {
|
||||
this.lang = lang
|
||||
this.locales = locales
|
||||
this.fallbackLocales = fallbackLocales
|
||||
},
|
||||
open(payload: PhoneOpenPayload = {}): void {
|
||||
const nextImei = payload.device?.imei ?? this.device?.imei ?? null
|
||||
const nextToken = payload.token ?? this.deviceSessionToken
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
export type AdminStats = {
|
||||
accounts: number
|
||||
devices: number
|
||||
online: number
|
||||
}
|
||||
|
||||
export type AdminPlayerSummary = {
|
||||
deviceCount: number
|
||||
grade: number
|
||||
identifier: string
|
||||
job: string
|
||||
name: string
|
||||
onDuty: boolean
|
||||
phoneNumber: string | null
|
||||
serverName: string
|
||||
source: number
|
||||
}
|
||||
|
||||
export type AdminDevice = {
|
||||
account: {
|
||||
email: string
|
||||
id: number
|
||||
passwordAvailable: boolean
|
||||
} | null
|
||||
apps: {
|
||||
claimed: string[]
|
||||
revision: number
|
||||
uninstalled: string[]
|
||||
}
|
||||
createdAt: string
|
||||
imei: string
|
||||
name: string
|
||||
number: string | null
|
||||
security: {
|
||||
enabled: boolean
|
||||
failedAttempts: number
|
||||
length: number | null
|
||||
lockedUntil: number
|
||||
}
|
||||
simRegistered: boolean
|
||||
simType: string | null
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type AdminPlayerDetail = {
|
||||
birthdate: string
|
||||
devices: AdminDevice[]
|
||||
firstName: string
|
||||
identifier: string
|
||||
job: {
|
||||
grade: number
|
||||
gradeLabel: string
|
||||
label: string
|
||||
name: string
|
||||
onDuty: boolean
|
||||
}
|
||||
lastName: string
|
||||
money: {
|
||||
bank: number
|
||||
cash: number
|
||||
currency: string
|
||||
}
|
||||
name: string
|
||||
serverName: string
|
||||
source: number
|
||||
}
|
||||
|
||||
export type AdminAuditEntry = {
|
||||
action: string
|
||||
actorName: string
|
||||
createdAt: string
|
||||
details: Record<string, unknown>
|
||||
deviceImei: string | null
|
||||
id: number
|
||||
targetIdentifier: string
|
||||
targetSource: number | null
|
||||
}
|
||||
|
||||
export type AdminBootstrap = {
|
||||
audit: AdminAuditEntry[]
|
||||
players: AdminPlayerSummary[]
|
||||
stats: AdminStats
|
||||
}
|
||||
|
||||
export type AdminCredential = {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export type AdminMessageActivity = {
|
||||
body: string
|
||||
createdAt: string
|
||||
direction: 'incoming' | 'outgoing'
|
||||
id: string
|
||||
messageType: string
|
||||
otherNumber: string
|
||||
readAt: string | null
|
||||
}
|
||||
|
||||
export type AdminCallActivity = {
|
||||
answeredAt: string | null
|
||||
direction: 'incoming' | 'outgoing'
|
||||
durationSeconds: number
|
||||
endedAt: string | null
|
||||
id: string
|
||||
otherNumber: string
|
||||
startedAt: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export type AdminActivityResponse =
|
||||
| { entries: AdminMessageActivity[]; kind: 'messages' }
|
||||
| { entries: AdminCallActivity[]; kind: 'calls' }
|
||||
@@ -68,6 +68,7 @@ export type AppLaunchOrigin = {
|
||||
}
|
||||
|
||||
type PhoneAppDefinitionBase = {
|
||||
adminOnly?: boolean
|
||||
category: PhoneAppCategory
|
||||
dockOrder: number | null
|
||||
gridOrder: number
|
||||
|
||||
@@ -70,6 +70,7 @@ export type MediaImportResult = {
|
||||
}
|
||||
|
||||
export type UploadReady = {
|
||||
captureToken: string
|
||||
correlationId: string
|
||||
mediaType: MediaType
|
||||
photo?: {
|
||||
|
||||
@@ -39,6 +39,7 @@ export type MemoRecordingMetadata = {
|
||||
export type MemoUploadReady = {
|
||||
requestId: string
|
||||
correlationId: string
|
||||
captureToken: string
|
||||
presignedUrl: string
|
||||
uploadTimeoutMs?: number
|
||||
}
|
||||
|
||||
@@ -13,7 +13,10 @@ const previewModules = import.meta.glob<string>(
|
||||
|
||||
const APP_STORE_PREVIEW_IMAGES = Object.fromEntries(
|
||||
Object.entries(previewModules).map(([path, imageUrl]) => {
|
||||
const appId = path.split('/').at(-1)?.replace(/\.jpg$/, '')
|
||||
const appId = path
|
||||
.split('/')
|
||||
.at(-1)
|
||||
?.replace(/\.jpg$/, '')
|
||||
if (!appId) throw new Error(`Invalid App Store preview path: ${path}`)
|
||||
return [appId, imageUrl]
|
||||
}),
|
||||
|
||||
@@ -17,7 +17,8 @@ function escapeRegExp(value: string): string {
|
||||
describe('App Store preview catalog', () => {
|
||||
it('contains a real captured screenshot for every built-in store app', () => {
|
||||
const storeAppIds = PHONE_APPS.filter(
|
||||
(app) => app.kind !== 'external' && app.id !== 'app-store',
|
||||
(app) =>
|
||||
app.kind !== 'external' && app.id !== 'app-store' && !app.adminOnly,
|
||||
).map((app) => app.id)
|
||||
|
||||
expect([...APP_STORE_PREVIEW_IMAGE_IDS].sort()).toEqual(storeAppIds.sort())
|
||||
@@ -25,7 +26,8 @@ describe('App Store preview catalog', () => {
|
||||
|
||||
it('provides specialized preview data for every built-in store app', () => {
|
||||
const storeAppIds = PHONE_APPS.filter(
|
||||
(app) => app.kind !== 'external' && app.id !== 'app-store',
|
||||
(app) =>
|
||||
app.kind !== 'external' && app.id !== 'app-store' && !app.adminOnly,
|
||||
).map((app) => app.id)
|
||||
|
||||
expect([...PREVIEWABLE_BUILTIN_APP_IDS].sort()).toEqual(
|
||||
|
||||
@@ -158,13 +158,6 @@ describe('media utilities', () => {
|
||||
expect(mediaErrorKey('profile_photo_required')).toBe(
|
||||
'profile_photo_required',
|
||||
)
|
||||
expect(mediaErrorKey('media_provider_failed')).toBe('media_provider_failed')
|
||||
expect(mediaErrorKey('media_provider_rate_limited')).toBe(
|
||||
'media_provider_rate_limited',
|
||||
)
|
||||
expect(mediaErrorKey('media_provider_unauthorized')).toBe(
|
||||
'media_provider_unauthorized',
|
||||
)
|
||||
expect(mediaErrorKey('private_provider_error')).toBe('request_failed')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -95,9 +95,6 @@ export function mediaErrorKey(error?: string): string {
|
||||
'invalid_import_url',
|
||||
'invalid_upload',
|
||||
'invalid_upload_token',
|
||||
'media_provider_failed',
|
||||
'media_provider_rate_limited',
|
||||
'media_provider_unauthorized',
|
||||
'missing_config',
|
||||
'import_media_not_allowed',
|
||||
'import_media_too_large',
|
||||
|
||||
@@ -28,7 +28,7 @@ const launchStyle = computed(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="app"
|
||||
v-if="app && !app.adminOnly"
|
||||
class="app-window"
|
||||
:class="{ 'app-window--citywarn': app.id === 'citywarn' }"
|
||||
:style="launchStyle"
|
||||
|
||||
@@ -119,7 +119,7 @@ const downloadDateDescription = computed(() =>
|
||||
)
|
||||
const catalog = computed(() =>
|
||||
PHONE_APPS.filter((app): app is LaunchablePhoneAppDefinition => {
|
||||
if (!isLaunchablePhoneApp(app) || app.id === 'app-store') {
|
||||
if (!isLaunchablePhoneApp(app) || app.id === 'app-store' || app.adminOnly) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -140,6 +140,7 @@ const dailyCandidates = computed(() =>
|
||||
PHONE_APPS.filter(
|
||||
(app): app is LaunchablePhoneAppDefinition =>
|
||||
isLaunchablePhoneApp(app) &&
|
||||
!app.adminOnly &&
|
||||
!isExternalPhoneApp(app) &&
|
||||
app.id !== 'app-store' &&
|
||||
!DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) &&
|
||||
|
||||
@@ -134,30 +134,10 @@ async function requestPhoto(): Promise<void> {
|
||||
window.setTimeout(() => void completeDevelopmentCapture(id, 'photo'), 700)
|
||||
return
|
||||
}
|
||||
console.info('[Sky Phone Media] Camera requested a photo upload.', {
|
||||
correlationId: id,
|
||||
})
|
||||
const response = await nuiCall('media:requestUpload', {
|
||||
await nuiCall('media:requestUpload', {
|
||||
correlationId: id,
|
||||
mediaType: 'photo',
|
||||
})
|
||||
if (!response.success) {
|
||||
console.error('[Sky Phone Media] Photo upload request was rejected.', {
|
||||
correlationId: id,
|
||||
error: response.error,
|
||||
})
|
||||
window.postMessage(
|
||||
{
|
||||
data: {
|
||||
correlationId: id,
|
||||
error: response.error ?? 'request_failed',
|
||||
success: false,
|
||||
},
|
||||
type: 'media:uploadResult',
|
||||
},
|
||||
'*',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function startRecording(): void {
|
||||
@@ -394,11 +374,6 @@ function onMessage(event: MessageEvent): void {
|
||||
} else if (message.type === 'media:uploadResult') {
|
||||
const result = message.data as UploadResult
|
||||
if (!result?.correlationId) return
|
||||
console.info('[Sky Phone Media] Camera received an upload result.', {
|
||||
correlationId: result.correlationId,
|
||||
error: result.error,
|
||||
success: result.success,
|
||||
})
|
||||
savingVideo.value = false
|
||||
if (result.success && result.media) {
|
||||
latestMedia.value = result.media
|
||||
|
||||
@@ -3098,9 +3098,7 @@ const deviceData = {
|
||||
ringtoneVolume: 80,
|
||||
streamerMode: false,
|
||||
wallpaper: 'custom',
|
||||
wallpaperHistory: [
|
||||
{ imageUrl: demoWallpaperUrl, wallpaper: 'custom' },
|
||||
],
|
||||
wallpaperHistory: [{ imageUrl: demoWallpaperUrl, wallpaper: 'custom' }],
|
||||
wallpaperImageUrl: demoWallpaperUrl,
|
||||
},
|
||||
version: 1,
|
||||
@@ -4650,6 +4648,100 @@ function companyWorkContext(testScenario = '') {
|
||||
}
|
||||
}
|
||||
|
||||
const adminMockApps = {
|
||||
claimed: ['citymarkt', 'darkchat', 'feather', 'local-pages'],
|
||||
revision: 3,
|
||||
uninstalled: ['crypto', 'skyride'],
|
||||
}
|
||||
|
||||
const adminMockDevices = {
|
||||
1: { account: true, number: '555-0101', security: true },
|
||||
2: { account: true, number: '555-0102', security: true },
|
||||
}
|
||||
|
||||
function adminMockPlayerDetail(source = 1) {
|
||||
const primary = source === 1
|
||||
const deviceState = adminMockDevices[source] ?? adminMockDevices[1]
|
||||
return {
|
||||
birthdate: primary ? '1994-04-16' : '1998-11-03',
|
||||
devices: [
|
||||
{
|
||||
account: deviceState.account
|
||||
? {
|
||||
email: primary ? 'demo@ifruit.com' : 'jordan@ifruit.com',
|
||||
id: primary ? 1 : 2,
|
||||
passwordAvailable: true,
|
||||
}
|
||||
: null,
|
||||
apps: { ...adminMockApps },
|
||||
createdAt: '2026-08-15 18:42:00',
|
||||
imei: primary ? '356938035643809' : '356938035643810',
|
||||
name: primary ? 'Personal iFruit Phone' : 'Service iFruit Phone',
|
||||
number: deviceState.number,
|
||||
security: {
|
||||
enabled: deviceState.security,
|
||||
failedAttempts: 0,
|
||||
length: deviceState.security ? 6 : null,
|
||||
lockedUntil: 0,
|
||||
},
|
||||
simRegistered: true,
|
||||
simType: 'standard',
|
||||
updatedAt: '2026-08-20 19:04:00',
|
||||
},
|
||||
],
|
||||
firstName: primary ? 'Alex' : 'Jordan',
|
||||
identifier: primary ? 'char1:demo' : 'char1:jordan',
|
||||
job: {
|
||||
grade: primary ? 4 : 1,
|
||||
gradeLabel: primary ? 'Chief' : 'Officer',
|
||||
label: 'Los Santos Police Department',
|
||||
name: 'police',
|
||||
onDuty: true,
|
||||
},
|
||||
lastName: primary ? 'Morgan' : 'Blake',
|
||||
money: {
|
||||
bank: primary ? 182450 : 28450,
|
||||
cash: primary ? 2740 : 950,
|
||||
currency: '$',
|
||||
},
|
||||
name: primary ? 'Alex Morgan' : 'Jordan Blake',
|
||||
serverName: primary ? 'Skyline' : 'JordanB',
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
function adminMockBootstrap() {
|
||||
return {
|
||||
audit: [
|
||||
{
|
||||
action: 'grant_app',
|
||||
actorName: 'Skyline',
|
||||
createdAt: '2026-08-20 19:04:00',
|
||||
details: { appId: 'darkchat' },
|
||||
deviceImei: '356938035643810',
|
||||
id: 1,
|
||||
targetIdentifier: 'char1:jordan',
|
||||
targetSource: 2,
|
||||
},
|
||||
],
|
||||
players: [1, 2].map((source) => {
|
||||
const player = adminMockPlayerDetail(source)
|
||||
return {
|
||||
deviceCount: player.devices.length,
|
||||
grade: player.job.grade,
|
||||
identifier: player.identifier,
|
||||
job: player.job.name,
|
||||
name: player.name,
|
||||
onDuty: player.job.onDuty,
|
||||
phoneNumber: player.devices[0]?.number ?? null,
|
||||
serverName: player.serverName,
|
||||
source,
|
||||
}
|
||||
}),
|
||||
stats: { accounts: 24, devices: 31, online: 2 },
|
||||
}
|
||||
}
|
||||
|
||||
app.post('/api/:endpoint', async (request, response, next) => {
|
||||
const endpoint = request.params.endpoint
|
||||
const loggedBody = { ...request.body }
|
||||
@@ -4663,6 +4755,128 @@ app.post('/api/:endpoint', async (request, response, next) => {
|
||||
response.json({ success: true, data: musicBootstrap() })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'admin:bootstrap') {
|
||||
response.json({ success: true, data: adminMockBootstrap() })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'admin:player') {
|
||||
response.json({
|
||||
success: true,
|
||||
data: adminMockPlayerDetail(Number(request.body.source) || 1),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'admin:save-apps') {
|
||||
const changes = Array.isArray(request.body.changes)
|
||||
? request.body.changes
|
||||
: []
|
||||
for (const change of changes) {
|
||||
const appId = String(change.appId ?? '')
|
||||
const installed = change.installed === true
|
||||
adminMockApps.claimed = adminMockApps.claimed.filter((id) => id !== appId)
|
||||
adminMockApps.uninstalled = adminMockApps.uninstalled.filter(
|
||||
(id) => id !== appId,
|
||||
)
|
||||
if (installed) adminMockApps.claimed.push(appId)
|
||||
else adminMockApps.uninstalled.push(appId)
|
||||
}
|
||||
adminMockApps.revision += 1
|
||||
response.json({
|
||||
success: true,
|
||||
data: adminMockPlayerDetail(Number(request.body.source) || 1),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'admin:close') {
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'admin:reveal-password') {
|
||||
response.json({
|
||||
success: true,
|
||||
data: { email: 'demo@ifruit.com', password: 'mock-only-password' },
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'admin:activity') {
|
||||
if (request.body.kind === 'messages') {
|
||||
response.json({
|
||||
success: true,
|
||||
data: {
|
||||
kind: 'messages',
|
||||
entries: [
|
||||
{
|
||||
body: 'Meet at Mission Row in ten minutes.',
|
||||
createdAt: '2026-08-20 19:03:00',
|
||||
direction: 'outgoing',
|
||||
id: 'admin-message-1',
|
||||
messageType: 'text',
|
||||
otherNumber: '555-0144',
|
||||
readAt: '2026-08-20 19:03:30',
|
||||
},
|
||||
{
|
||||
body: 'Copy, I am on my way.',
|
||||
createdAt: '2026-08-20 18:58:00',
|
||||
direction: 'incoming',
|
||||
id: 'admin-message-2',
|
||||
messageType: 'text',
|
||||
otherNumber: '555-0199',
|
||||
readAt: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
response.json({
|
||||
success: true,
|
||||
data: {
|
||||
kind: 'calls',
|
||||
entries: [
|
||||
{
|
||||
answeredAt: '2026-08-20 18:49:05',
|
||||
direction: 'incoming',
|
||||
durationSeconds: 184,
|
||||
endedAt: '2026-08-20 18:52:09',
|
||||
id: 'admin-call-1',
|
||||
otherNumber: '555-0177',
|
||||
startedAt: '2026-08-20 18:49:00',
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
answeredAt: null,
|
||||
direction: 'outgoing',
|
||||
durationSeconds: 0,
|
||||
endedAt: '2026-08-20 17:13:18',
|
||||
id: 'admin-call-2',
|
||||
otherNumber: '555-0112',
|
||||
startedAt: '2026-08-20 17:13:00',
|
||||
status: 'missed',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'admin:reset-passcode') {
|
||||
const source = Number(request.body.source) || 1
|
||||
adminMockDevices[source].security = false
|
||||
response.json({ success: true, data: adminMockPlayerDetail(source) })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'admin:change-number') {
|
||||
const source = Number(request.body.source) || 1
|
||||
adminMockDevices[source].number = String(request.body.phoneNumber ?? '')
|
||||
response.json({ success: true, data: adminMockPlayerDetail(source) })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'admin:factory-reset') {
|
||||
const source = Number(request.body.source) || 1
|
||||
adminMockDevices[source].account = false
|
||||
adminMockDevices[source].security = false
|
||||
response.json({ success: true, data: adminMockPlayerDetail(source) })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'music:add-youtube') {
|
||||
const value = String(request.body.url ?? '')
|
||||
const customTitle = String(request.body.title ?? '').trim()
|
||||
|
||||
@@ -61,6 +61,18 @@ Config.Security = {
|
||||
AttemptsPerMinute = 12,
|
||||
}
|
||||
|
||||
Config.AdminPanel = {
|
||||
Enabled = true,
|
||||
Command = "phoneadmin",
|
||||
AdminGroups = { "admin", "superadmin" },
|
||||
MaximumPlayers = 128,
|
||||
ReadRequestsPerMinute = 60,
|
||||
ActionRequestsPerMinute = 30,
|
||||
CredentialRevealsPerMinute = 6,
|
||||
AuditLimit = 40,
|
||||
ActivityLimit = 40,
|
||||
}
|
||||
|
||||
Config.Sim = {
|
||||
Enabled = true, -- false: devices receive a persistent random number automatically; hex/esx require false
|
||||
RegisteredItem = "sky_phone_sim_registered",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
Locales["de"] = {
|
||||
CommandDescription = "Öffne dein Handy.",
|
||||
AdminCommand = {
|
||||
CommandDescription = "Öffne das geschützte Handy-Admin-Panel.",
|
||||
Errors = {
|
||||
disabled = "Das Handy-Admin-Panel ist deaktiviert.",
|
||||
not_authorized = "Du hast keinen Zugriff auf das Handy-Admin-Panel.",
|
||||
default = "Das Handy-Admin-Panel konnte nicht geöffnet werden.",
|
||||
},
|
||||
},
|
||||
Controls = {
|
||||
OpenPhone = "Handy öffnen",
|
||||
},
|
||||
@@ -193,6 +201,27 @@ Locales["de"] = {
|
||||
contacts = { name = "Favoriten", description = "Rufe deine Lieblingskontakte an oder schreibe ihnen.", choose = "Lieblingskontakte" },
|
||||
},
|
||||
},
|
||||
AdminPanel = {
|
||||
name = "Phone Admin", subtitle = "Administration", navigation = "Admin-Navigation", refresh = "Admin-Daten aktualisieren", loading = "Geschützte Daten werden geladen...",
|
||||
tabs = { overview = "Übersicht", players = "Spieler", devices = "Geräte", apps = "Apps", accounts = "Accounts", messages = "Nachrichten", calls = "Anrufe", moderation = "Moderation", audit = "Audit" },
|
||||
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" },
|
||||
appearance = { eyebrow = "Darstellung", title = "Akzentfarbe", body = "Ändere den Akzent im gesamten Admin-Arbeitsbereich.", colors = { emerald = "Smaragd", blue = "Blau", violet = "Violett", orange = "Orange", red = "Rot" } },
|
||||
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" },
|
||||
devices = { eyebrow = "Gerätesteuerung", title = "Handys", body = "Prüfe alle Handys des ausgewählten Spielers.", choose = "Handy auswählen", empty = "Kein Handy gefunden", emptyBody = "Dieser Spieler besitzt aktuell kein verwaltbares Handy.", noNumber = "Keine Telefonnummer", noSim = "Keine SIM", imei = "IMEI", updated = "Letzte Aktivität", apps = "Zugewiesene Apps", account = "Verknüpfter Account" },
|
||||
credentials = { eyebrow = "Geschützte Daten", title = "Zugangsdaten", email = "iFruit-E-Mail", password = "iFruit-Passwort", reveal = "Anzeigen", copy = "Passwort kopieren", copied = "Passwort kopiert.", noAccount = "Mit diesem Handy ist kein iFruit-Account verknüpft.", passcode = "Gerätecode", passcodeHashed = "{length}-stellige PIN · sicher gehasht und nicht wiederherstellbar", passcodeDisabled = "Kein Gerätecode eingerichtet", revealTitle = "Geschütztes Passwort anzeigen?", revealBody = "Diese Aktion wird serverseitig autorisiert, rate-limitiert und im Admin-Audit protokolliert.", cancel = "Abbrechen", confirmReveal = "Passwort anzeigen" },
|
||||
apps = { eyebrow = "Fernverwaltung", title = "App-Zugriff", description = "Lege App-Zugriffe für dieses Gerät als Entwurf fest. Erst Speichern übernimmt die Änderungen.", installed = "Installiert", available = "Verfügbar", protected = "System-App", changes = "{count} offene Änderungen" },
|
||||
activity = { protected = "Geschützte Aktivität", messagesTitle = "Nachrichten", messagesBody = "Letzte SMS-Aktivitäten der ausgewählten SIM.", callsTitle = "Anrufe", callsBody = "Letzte Anrufaktivitäten der ausgewählten SIM.", loading = "Aktivitäten werden geladen...", incoming = "Eingehend", outgoing = "Ausgehend", mediaMessage = "{type}-Nachricht", noMessages = "Keine Nachrichtenaktivität gefunden.", noCalls = "Keine Anrufaktivität gefunden.", status = { completed = "Abgeschlossen", missed = "Verpasst", rejected = "Abgelehnt", busy = "Besetzt", unanswered = "Unbeantwortet", cancelled = "Abgebrochen", failed = "Fehlgeschlagen", ringing = "Klingelt" } },
|
||||
moderation = {
|
||||
eyebrow = "Geräteverwaltung", title = "Moderationsaktionen", body = "Jede Aktion wird serverseitig autorisiert, rate-limitiert und protokolliert.", resetPasscode = "Gerätecode zurücksetzen", resetPasscodeBody = "Entfernt die Geräte-PIN und fehlgeschlagene Versuche.", changeNumber = "Nummer ändern", changeNumberBody = "Weist der aktuellen SIM eine neue eindeutige Nummer zu.", factoryReset = "Werksreset", factoryResetBody = "Löscht lokale Gerätedaten und trennt den Account.", saveFirst = "Speichere oder verwirf zuerst offene App-Änderungen.", phoneNumber = "Neue Telefonnummer", phoneNumberPlaceholder = "Vollständige konfigurierte Nummer eingeben", typeToConfirm = "Tippe {word}, um den Werksreset zu bestätigen.", confirmWord = "RESET", cancel = "Abbrechen", ["reset-passcodeSuccess"] = "Gerätecode zurückgesetzt.", ["change-numberSuccess"] = "Telefonnummer geändert.", ["factory-resetSuccess"] = "Werksreset abgeschlossen.",
|
||||
dialogs = { ["reset-passcodeTitle"] = "Gerätecode zurücksetzen?", ["reset-passcodeBody"] = "Der Spieler kann dieses Handy danach ohne die bisherige PIN entsperren.", ["change-numberTitle"] = "Telefonnummer ändern?", ["change-numberBody"] = "Die neue Nummer muss dem Serverformat entsprechen und eindeutig sein.", ["factory-resetTitle"] = "Werksreset für dieses Handy?", ["factory-resetBody"] = "Lokale Gerätedaten, App-Einstellungen, Sicherheit und der verknüpfte Account werden gelöscht. Das kann nicht rückgängig gemacht werden." },
|
||||
confirm = { ["reset-passcode"] = "Gerätecode zurücksetzen", ["change-number"] = "Nummer ändern", ["factory-reset"] = "Werksreset" },
|
||||
},
|
||||
audit = { eyebrow = "Nachvollziehbarkeit", title = "Audit-Verlauf", body = "Sensible Anzeigen und App-Änderungen werden hier protokolliert.", empty = "Noch keine Admin-Aktionen", emptyBody = "Geschützte Aktionen erscheinen hier, nachdem sie ausgeführt wurden.", by = "{actor} · Ziel-ID {target}", actions = { grant_app = "App installiert", revoke_app = "App entfernt", reveal_account_password = "Passwort angezeigt", view_messages = "Nachrichten angesehen", view_calls = "Anrufe angesehen", reset_passcode = "Gerätecode zurückgesetzt", change_number = "Telefonnummer geändert", factory_reset = "Werksreset ausgeführt" } },
|
||||
editor = { brand = "SKY PHONE", workspace = "ADMIN", players = "Spielerverzeichnis", audit = "Audit-Protokoll", selectPlayer = "Wähle einen Spieler, um Identität, Geräte, Zugangsdaten und App-Zugriffe zu prüfen.", save = "Änderungen speichern", saveHint = "Offene Änderungen übernehmen", saved = "Änderungen gespeichert.", unsaved = "Ungespeicherte Änderungen", close = "Admin-Panel schließen", refresh = "Live-Daten aktualisieren", online = "LIVE", profile = "PROFIL", financial = "FINANZEN", device = "GERÄT", security = "SICHERHEIT", noAutoSave = "Manuelles Speichern", noAutoSaveBody = "Änderungen bleiben lokal, bis der grüne Haken gedrückt wird.", discardTitle = "Ungespeicherte Änderungen verwerfen?", discardBody = "Deine vorgemerkten App-Änderungen wurden noch nicht gespeichert.", keepEditing = "Weiter bearbeiten", discard = "Änderungen verwerfen", saveFailed = "Einige Änderungen konnten nicht gespeichert werden.", noSelection = "Kein Spieler ausgewählt" },
|
||||
errors = { not_authorized = "Du hast keinen Zugriff auf das Admin-Panel.", rate_limited = "Zu viele Admin-Anfragen. Bitte warte.", player_unavailable = "Dieser Spieler ist nicht mehr online.", device_not_owned = "Dieses Handy gehört nicht mehr zum ausgewählten Spieler.", invalid_app = "Diese App ist nicht auf dem Server registriert.", app_protected = "Diese System-App kann nicht entfernt werden.", revision_conflict = "Das Handy wurde zwischenzeitlich geändert. Aktualisiere und versuche es erneut.", account_not_found = "Mit diesem Handy ist kein iFruit-Account verknüpft.", invalid_phone_number = "Gib eine Telefonnummer im konfigurierten Serverformat ein.", phone_number_unchanged = "Diese SIM verwendet diese Telefonnummer bereits.", phone_number_taken = "Diese Telefonnummer ist bereits vergeben.", no_sim = "Dieses Handy besitzt keine änderbare SIM.", passcode_not_set = "Für dieses Handy ist kein Gerätecode eingerichtet.", device_not_found = "Dieses Handy existiert nicht mehr.", metadata_unsupported = "Die Inventar-Metadaten des Handys konnten nicht aktualisiert werden.", invalid_request = "Die Admin-Anfrage war ungültig.", request_failed = "Die Admin-Anfrage ist fehlgeschlagen.", default = "Das Admin-Panel ist vorübergehend nicht verfügbar." },
|
||||
},
|
||||
Apps = {
|
||||
health = {
|
||||
name = "Gesundheit",
|
||||
@@ -1426,9 +1455,7 @@ Locales["de"] = {
|
||||
errors = {
|
||||
cancelled = "Die Aufnahme ist abgesagt.", capture_failed = "Die Spielansicht kann nicht erfasst werden.",
|
||||
invalid_media_type = "Der hochgeladene Medientyp ist ungültig.", invalid_upload = "Der Upload konnte nicht überprüft werden.",
|
||||
invalid_upload_token = "Die Upload-Sitzung ist nicht mehr gültig.", media_provider_failed = "Der Kamera-Upload-Dienst ist nicht verfügbar.",
|
||||
media_provider_rate_limited = "Der Kamera-Upload-Dienst ist ausgelastet. Versuch es gleich erneut.", media_provider_unauthorized = "Der konfigurierte FiveManage API-Schlüssel wurde abgelehnt.",
|
||||
missing_config = "Kamera-Uploads sind nicht konfiguriert.",
|
||||
invalid_upload_token = "Die Upload-Sitzung ist nicht mehr gültig.", missing_config = "Kamera-Uploads sind nicht konfiguriert.",
|
||||
microphone_unavailable = "Erlaube den Mikrofonzugriff oder schalte das Mikrofon vor der Aufnahme stumm.",
|
||||
not_found = "Das Medienobjekt existiert nicht mehr.", owner_changed = "Das aktive Telefonkonto hat sich beim Upload geändert.",
|
||||
operation_in_progress = "Eine weitere Medienoperation ist bereits im Gange.",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
Locales["en"] = {
|
||||
CommandDescription = "Open your phone.",
|
||||
AdminCommand = {
|
||||
CommandDescription = "Open the protected phone admin panel.",
|
||||
Errors = {
|
||||
disabled = "The phone admin panel is disabled.",
|
||||
not_authorized = "You do not have access to the phone admin panel.",
|
||||
default = "The phone admin panel could not be opened.",
|
||||
},
|
||||
},
|
||||
Controls = {
|
||||
OpenPhone = "Open phone",
|
||||
},
|
||||
@@ -193,6 +201,27 @@ Locales["en"] = {
|
||||
contacts = { name = "Favorites", description = "Call or message your favorite contacts.", choose = "Favorite Contacts" },
|
||||
},
|
||||
},
|
||||
AdminPanel = {
|
||||
name = "Phone Admin", subtitle = "Administration", navigation = "Admin navigation", refresh = "Refresh admin data", loading = "Loading protected data...",
|
||||
tabs = { overview = "Overview", players = "Players", devices = "Devices", apps = "Apps", accounts = "Accounts", messages = "Messages", calls = "Calls", moderation = "Moderation", audit = "Audit" },
|
||||
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" },
|
||||
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" } },
|
||||
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" },
|
||||
devices = { eyebrow = "Device control", title = "Phones", body = "Inspect every phone assigned to the selected player.", choose = "Choose phone", empty = "No phone found", emptyBody = "This player currently has no phone device that can be managed.", noNumber = "No phone number", noSim = "No SIM", imei = "IMEI", updated = "Last activity", apps = "Claimed apps", account = "Linked account" },
|
||||
credentials = { eyebrow = "Protected data", title = "Credentials", email = "iFruit email", password = "iFruit password", reveal = "Reveal", copy = "Copy password", copied = "Password copied.", noAccount = "No iFruit account is linked to this phone.", passcode = "Device passcode", passcodeHashed = "{length}-digit PIN · securely hashed and not recoverable", passcodeDisabled = "No passcode configured", revealTitle = "Reveal protected password?", revealBody = "This action is server-authorized, rate-limited, and written to the admin audit log.", cancel = "Cancel", confirmReveal = "Reveal password" },
|
||||
apps = { eyebrow = "Remote management", title = "App access", description = "Stage app access for this device. Nothing changes until you save.", installed = "Installed", available = "Available", protected = "System app", changes = "{count} pending changes" },
|
||||
activity = { protected = "Protected activity", messagesTitle = "Messages", messagesBody = "Recent SMS activity for the selected SIM.", callsTitle = "Calls", callsBody = "Recent call activity for the selected SIM.", loading = "Loading activity...", incoming = "Incoming", outgoing = "Outgoing", mediaMessage = "{type} message", noMessages = "No message activity found.", noCalls = "No call activity found.", status = { completed = "Completed", missed = "Missed", rejected = "Rejected", busy = "Busy", unanswered = "Unanswered", cancelled = "Cancelled", failed = "Failed", ringing = "Ringing" } },
|
||||
moderation = {
|
||||
eyebrow = "Device administration", title = "Moderation actions", body = "Every action is server-authorized, rate-limited, and audited.", resetPasscode = "Reset passcode", resetPasscodeBody = "Remove the device PIN and clear failed attempts.", changeNumber = "Change number", changeNumberBody = "Assign a new unique number to the current SIM.", factoryReset = "Factory reset", factoryResetBody = "Clear local device data and disconnect the account.", saveFirst = "Save or discard pending app changes first.", phoneNumber = "New phone number", phoneNumberPlaceholder = "Enter the full configured number", typeToConfirm = "Type {word} to confirm the factory reset.", confirmWord = "RESET", cancel = "Cancel", ["reset-passcodeSuccess"] = "Passcode reset.", ["change-numberSuccess"] = "Phone number changed.", ["factory-resetSuccess"] = "Phone factory reset completed.",
|
||||
dialogs = { ["reset-passcodeTitle"] = "Reset device passcode?", ["reset-passcodeBody"] = "The player can unlock this phone without the previous PIN afterward.", ["change-numberTitle"] = "Change phone number?", ["change-numberBody"] = "The new number must match the configured server number format and be unique.", ["factory-resetTitle"] = "Factory reset this phone?", ["factory-resetBody"] = "This clears local device data, app settings, security, and the linked account. This cannot be undone." },
|
||||
confirm = { ["reset-passcode"] = "Reset passcode", ["change-number"] = "Change number", ["factory-reset"] = "Factory reset" },
|
||||
},
|
||||
audit = { eyebrow = "Accountability", title = "Audit trail", body = "Sensitive reveals and remote app changes are recorded here.", empty = "No admin actions yet", emptyBody = "Protected actions will appear here after they are performed.", by = "{actor} · target ID {target}", actions = { grant_app = "App installed", revoke_app = "App removed", reveal_account_password = "Password revealed", view_messages = "Messages viewed", view_calls = "Calls viewed", reset_passcode = "Passcode reset", change_number = "Phone number changed", factory_reset = "Phone factory reset" } },
|
||||
editor = { brand = "SKY PHONE", workspace = "ADMIN", players = "Player directory", audit = "Audit log", selectPlayer = "Select a player to inspect identity, devices, credentials, and app access.", save = "Save changes", saveHint = "Apply pending changes", saved = "Changes saved.", unsaved = "Unsaved changes", close = "Close admin panel", refresh = "Refresh live data", online = "LIVE", profile = "PROFILE", financial = "FINANCIAL", device = "DEVICE", security = "SECURITY", noAutoSave = "Manual save", noAutoSaveBody = "Changes stay local until the green check is pressed.", discardTitle = "Discard unsaved changes?", discardBody = "Your staged app changes have not been saved.", keepEditing = "Keep editing", discard = "Discard changes", saveFailed = "Some changes could not be saved.", noSelection = "No player selected" },
|
||||
errors = { not_authorized = "You do not have access to the admin panel.", rate_limited = "Too many admin requests. Please wait.", player_unavailable = "That player is no longer online.", device_not_owned = "That phone no longer belongs to the selected player.", invalid_app = "That app is not registered on the server.", app_protected = "This system app cannot be removed.", revision_conflict = "The phone changed in the meantime. Refresh and try again.", account_not_found = "No iFruit account is linked to this phone.", invalid_phone_number = "Enter a phone number in the configured server format.", phone_number_unchanged = "This SIM already uses that phone number.", phone_number_taken = "That phone number is already assigned.", no_sim = "This phone has no SIM that can be changed.", passcode_not_set = "This phone has no passcode configured.", device_not_found = "This phone no longer exists.", metadata_unsupported = "The phone inventory metadata could not be updated.", invalid_request = "The admin request was invalid.", request_failed = "The admin request failed.", default = "The admin panel is temporarily unavailable." },
|
||||
},
|
||||
Apps = {
|
||||
health = {
|
||||
name = "Health",
|
||||
@@ -1426,9 +1455,7 @@ Locales["en"] = {
|
||||
errors = {
|
||||
cancelled = "Capture cancelled.", capture_failed = "Unable to capture the game view.",
|
||||
invalid_media_type = "The uploaded media type is invalid.", invalid_upload = "The upload could not be verified.",
|
||||
invalid_upload_token = "The upload session is no longer valid.", media_provider_failed = "The camera upload service is unavailable.",
|
||||
media_provider_rate_limited = "The camera upload service is busy. Try again shortly.", media_provider_unauthorized = "The configured FiveManage API key was rejected.",
|
||||
missing_config = "Camera uploads are not configured.",
|
||||
invalid_upload_token = "The upload session is no longer valid.", missing_config = "Camera uploads are not configured.",
|
||||
microphone_unavailable = "Allow microphone access or mute the microphone before recording.",
|
||||
not_found = "The media item no longer exists.", owner_changed = "The active phone account changed during upload.",
|
||||
operation_in_progress = "Another media operation is already in progress.",
|
||||
|
||||
@@ -6,7 +6,7 @@ use_experimental_fxv2_oal 'yes'
|
||||
|
||||
author 'Sky-Systems'
|
||||
description 'Sky Phone'
|
||||
version '0.2.1'
|
||||
version '0.1.0'
|
||||
|
||||
provide 'lb-phone'
|
||||
provide '17mov_Phone'
|
||||
@@ -99,6 +99,7 @@ server_scripts {
|
||||
'source/server/phone.lua',
|
||||
'source/server/device_directory.lua',
|
||||
'source/server/db_migrate.lua',
|
||||
'source/server/admin.lua',
|
||||
'source/server/lb_phone_migration.lua',
|
||||
'source/server/custom_app_storage.lua',
|
||||
'source/server/payphones.lua',
|
||||
@@ -106,7 +107,6 @@ server_scripts {
|
||||
'source/server/notifications.lua',
|
||||
'source/shared/public_api.lua',
|
||||
'source/server/public_api.lua',
|
||||
'source/server/media_provider_config.lua',
|
||||
'source/server/media_import.lua',
|
||||
'source/server/media_import/fivemanage.lua',
|
||||
'source/server/media_import/manifest.lua',
|
||||
|
||||
@@ -448,13 +448,6 @@ RegisterNUICallback("media:requestUpload", function(data, cb)
|
||||
cb({ success = false, error = "invalid_request" })
|
||||
return
|
||||
end
|
||||
Bridge.Debug(
|
||||
"debug",
|
||||
"[sky_phone][media-debug] NUI requested an upload (correlation=%s, type=%s).",
|
||||
tostring(data.correlationId),
|
||||
tostring(data.mediaType),
|
||||
{ notice = true }
|
||||
)
|
||||
TriggerServerEvent("sky_phone:media:request-upload", data)
|
||||
cb({ success = true })
|
||||
end)
|
||||
@@ -464,15 +457,6 @@ RegisterNUICallback("media:completeUpload", function(data, cb)
|
||||
cb({ success = false, error = "invalid_request" })
|
||||
return
|
||||
end
|
||||
Bridge.Debug(
|
||||
"debug",
|
||||
"[sky_phone][media-debug] NUI completed the provider upload (correlation=%s, remote-id=%s, url=%s, original-url=%s).",
|
||||
tostring(data.correlationId),
|
||||
type(data.remoteId) == "string" and "present" or "missing",
|
||||
type(data.url) == "string" and "present" or "missing",
|
||||
type(data.originalUrl) == "string" and "present" or "missing",
|
||||
{ notice = true }
|
||||
)
|
||||
TriggerServerEvent("sky_phone:media:complete-upload", data)
|
||||
cb({ success = true })
|
||||
end)
|
||||
@@ -491,16 +475,6 @@ RegisterNUICallback("media:failUpload", function(data, cb)
|
||||
cb({ success = false, error = "invalid_request" })
|
||||
return
|
||||
end
|
||||
local debug_message = tostring(data.debugMessage or "unknown"):gsub("[\r\n]", " "):sub(1, 240)
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone][media-debug] NUI reported an upload failure (correlation=%s, error=%s, stage=%s, status=%s, detail=%s).",
|
||||
tostring(data.correlationId),
|
||||
tostring(data.error),
|
||||
tostring(data.debugStage),
|
||||
tostring(data.debugStatus),
|
||||
debug_message
|
||||
)
|
||||
TriggerServerEvent("sky_phone:media:fail-upload", data)
|
||||
cb({ success = true })
|
||||
end)
|
||||
@@ -560,26 +534,10 @@ RegisterNUICallback("memos:failUpload", function(data, cb)
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:media:upload-ready", function(data)
|
||||
Bridge.Debug(
|
||||
"debug",
|
||||
"[sky_phone][media-debug] Client received upload-ready (correlation=%s, type=%s, presigned-url=%s).",
|
||||
tostring(type(data) == "table" and data.correlationId),
|
||||
tostring(type(data) == "table" and data.mediaType),
|
||||
type(data) == "table" and type(data.presignedUrl) == "string" and "present" or "missing",
|
||||
{ notice = true }
|
||||
)
|
||||
SendNUIMessage({ type = "media:uploadReady", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:media:upload-result", function(data)
|
||||
Bridge.Debug(
|
||||
"debug",
|
||||
"[sky_phone][media-debug] Client received upload-result (correlation=%s, success=%s, error=%s).",
|
||||
tostring(type(data) == "table" and data.correlationId),
|
||||
tostring(type(data) == "table" and data.success),
|
||||
tostring(type(data) == "table" and data.error),
|
||||
{ notice = true }
|
||||
)
|
||||
SendNUIMessage({ type = "media:uploadResult", data = data })
|
||||
end)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ local blocked_phone_controls = { 24, 140, 141, 142, 257, 263, 264 }
|
||||
local blocked_phone_look_controls = { 1, 2, 3, 4, 5, 6 }
|
||||
local focused_control_groups = { 0, 1, 2 }
|
||||
local state = {
|
||||
admin_panel_open = false,
|
||||
activity_suspended = false,
|
||||
allow_movement = Config.Phone.AllowMovement,
|
||||
call_focus = false,
|
||||
@@ -42,6 +43,15 @@ function SkyPhoneFocus.ApplyGameInputControls(block_look)
|
||||
end
|
||||
|
||||
function SkyPhoneFocus.Resolve(state)
|
||||
if state.admin_panel_open then
|
||||
return {
|
||||
block_game = true,
|
||||
cursor = true,
|
||||
focused = true,
|
||||
game_input = false,
|
||||
keep_input = false,
|
||||
}
|
||||
end
|
||||
if state.activity_suspended then
|
||||
return { block_game = false, cursor = false, focused = false, game_input = false, keep_input = false }
|
||||
end
|
||||
@@ -112,6 +122,15 @@ function SkyPhoneFocus.SetPhone(open, cursor_disabled)
|
||||
SkyPhoneFocus.Reapply()
|
||||
end
|
||||
|
||||
function SkyPhoneFocus.SetAdminPanel(open)
|
||||
state.admin_panel_open = open == true
|
||||
if state.admin_panel_open then
|
||||
state.notification_focus = false
|
||||
state.text_input_focused = false
|
||||
end
|
||||
SkyPhoneFocus.Reapply()
|
||||
end
|
||||
|
||||
function SkyPhoneFocus.SetCall(active)
|
||||
state.call_focus = active == true
|
||||
SkyPhoneFocus.Reapply()
|
||||
@@ -142,6 +161,7 @@ function SkyPhoneFocus.SetExternalGameInput(owner_resource, allow_game_input)
|
||||
end
|
||||
|
||||
function SkyPhoneFocus.Reset()
|
||||
state.admin_panel_open = false
|
||||
state.activity_suspended = false
|
||||
state.call_focus = false
|
||||
state.camera_active = false
|
||||
|
||||
@@ -8,6 +8,7 @@ local equipped_phone_number = nil
|
||||
local nui_generation = 0
|
||||
local live_activity_active = false
|
||||
local open_home_requested = false
|
||||
local admin_panel_open = false
|
||||
|
||||
local function get_equipped_phone_number()
|
||||
if not device_payload or not device_payload.device.sim then
|
||||
@@ -64,6 +65,26 @@ Bridge.Debug("debug", "[sky_phone] Client script initialized.", { always = true
|
||||
|
||||
local locale, locale_name = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
|
||||
|
||||
local function send_admin_panel_open()
|
||||
SendNUIMessage({
|
||||
type = "admin:open",
|
||||
data = {
|
||||
lang = locale_name,
|
||||
locales = locale.Nui,
|
||||
fallbackLocales = Locales.en.Nui,
|
||||
},
|
||||
})
|
||||
end
|
||||
|
||||
local function close_admin_panel()
|
||||
if not admin_panel_open then
|
||||
return
|
||||
end
|
||||
admin_panel_open = false
|
||||
SkyPhoneFocus.SetAdminPanel(false)
|
||||
SendNUIMessage({ type = "admin:close" })
|
||||
end
|
||||
|
||||
local function send_open_message()
|
||||
if not device_payload then
|
||||
return
|
||||
@@ -224,6 +245,9 @@ RegisterNUICallback("ui:ready", function(data, cb)
|
||||
if open_requested and device_payload then
|
||||
send_open_message()
|
||||
end
|
||||
if admin_panel_open then
|
||||
send_admin_panel_open()
|
||||
end
|
||||
SkyPhoneCalls.ReplayNui()
|
||||
SkyPhoneSimPicker.ReplayNui()
|
||||
SkyPhoneFocus.Reapply()
|
||||
@@ -269,12 +293,42 @@ RegisterNUICallback("ui:opened", function(data, cb)
|
||||
cb({ success = true })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:admin:launch", function()
|
||||
if is_open or open_requested then
|
||||
close_phone()
|
||||
end
|
||||
admin_panel_open = true
|
||||
SkyPhoneFocus.SetAdminPanel(true)
|
||||
send_admin_panel_open()
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:admin:command-error", function(error_code)
|
||||
local messages = locale.AdminCommand.Errors
|
||||
Bridge.Framework.Notify(
|
||||
"iFruit",
|
||||
messages[error_code] or messages.default,
|
||||
"error",
|
||||
5000
|
||||
)
|
||||
end)
|
||||
|
||||
RegisterNUICallback("admin:close", function(data, cb)
|
||||
if type(data) ~= "table" then
|
||||
cb({ success = false, error = "invalid_request" })
|
||||
return
|
||||
end
|
||||
close_admin_panel()
|
||||
cb({ success = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback("ui:input-focus", function(data, cb)
|
||||
if type(data) ~= "table" or type(data.active) ~= "boolean" then
|
||||
cb({ success = false, error = "invalid_request" })
|
||||
return
|
||||
end
|
||||
SkyPhoneFocus.SetTextInputFocused(data.active and (is_open or open_requested))
|
||||
SkyPhoneFocus.SetTextInputFocused(
|
||||
data.active and (is_open or open_requested or admin_panel_open)
|
||||
)
|
||||
cb({ success = true })
|
||||
end)
|
||||
|
||||
@@ -359,6 +413,13 @@ CreateThread(function()
|
||||
if Config.TestData.Enabled then
|
||||
TriggerEvent("chat:addSuggestion", "/" .. Config.TestData.Command, locale.TestData.CommandDescription)
|
||||
end
|
||||
if Config.AdminPanel.Enabled then
|
||||
TriggerEvent(
|
||||
"chat:addSuggestion",
|
||||
"/" .. Config.AdminPanel.Command,
|
||||
locale.AdminCommand.CommandDescription
|
||||
)
|
||||
end
|
||||
end)
|
||||
|
||||
AddEventHandler("onResourceStop", function(resource_name)
|
||||
@@ -369,6 +430,7 @@ AddEventHandler("onResourceStop", function(resource_name)
|
||||
is_open = false
|
||||
open_requested = false
|
||||
open_without_focus = false
|
||||
admin_panel_open = false
|
||||
|
||||
TriggerEvent("sky_phone:animation:reset")
|
||||
SkyPhoneCalls.Reset()
|
||||
@@ -381,4 +443,7 @@ AddEventHandler("onResourceStop", function(resource_name)
|
||||
if Config.TestData.Enabled then
|
||||
TriggerEvent("chat:removeSuggestion", "/" .. Config.TestData.Command)
|
||||
end
|
||||
if Config.AdminPanel.Enabled then
|
||||
TriggerEvent("chat:removeSuggestion", "/" .. Config.AdminPanel.Command)
|
||||
end
|
||||
end)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
local callback_groups = {
|
||||
account = [[login register logout devices remove-device]],
|
||||
admin = [[
|
||||
bootstrap player save-apps reveal-password activity
|
||||
reset-passcode change-number factory-reset
|
||||
]],
|
||||
banking = [[overview transfer]],
|
||||
billing = [[overview list detail markRead pay dispute]],
|
||||
calendar = [[list create update delete]],
|
||||
|
||||
@@ -0,0 +1,864 @@
|
||||
Bridge.Database.AfterMigration("sky_phone", function()
|
||||
local BUILTIN_APPS = {
|
||||
banking = true,
|
||||
billing = true,
|
||||
calculator = true,
|
||||
calendar = true,
|
||||
camera = true,
|
||||
citymarkt = true,
|
||||
citywarn = true,
|
||||
clock = true,
|
||||
companies = true,
|
||||
crewlink = true,
|
||||
crypto = true,
|
||||
darkchat = true,
|
||||
feather = true,
|
||||
flare = true,
|
||||
fliptok = true,
|
||||
garage = true,
|
||||
health = true,
|
||||
house = true,
|
||||
["app-store"] = true,
|
||||
["local-pages"] = true,
|
||||
mail = true,
|
||||
map = true,
|
||||
memos = true,
|
||||
memory = true,
|
||||
messages = true,
|
||||
minesweeper = true,
|
||||
music = true,
|
||||
["neon-drop"] = true,
|
||||
notes = true,
|
||||
["number-merge"] = true,
|
||||
phone = true,
|
||||
photos = true,
|
||||
picstagram = true,
|
||||
radio = true,
|
||||
settings = true,
|
||||
["sky-flappy"] = true,
|
||||
skyride = true,
|
||||
snake = true,
|
||||
["tower-stack"] = true,
|
||||
weather = true,
|
||||
["weazel-news"] = true,
|
||||
}
|
||||
|
||||
local DEFAULT_INSTALLED_APPS = {
|
||||
["app-store"] = true,
|
||||
calculator = true,
|
||||
calendar = true,
|
||||
camera = true,
|
||||
citywarn = true,
|
||||
clock = true,
|
||||
health = true,
|
||||
mail = true,
|
||||
map = true,
|
||||
memos = true,
|
||||
messages = true,
|
||||
notes = true,
|
||||
phone = true,
|
||||
photos = true,
|
||||
settings = true,
|
||||
weather = true,
|
||||
}
|
||||
|
||||
local PROTECTED_APPS = {
|
||||
["app-store"] = true,
|
||||
camera = true,
|
||||
citywarn = true,
|
||||
health = true,
|
||||
mail = true,
|
||||
messages = true,
|
||||
phone = true,
|
||||
photos = true,
|
||||
settings = true,
|
||||
}
|
||||
|
||||
local function affected_rows(result)
|
||||
if type(result) == "number" then
|
||||
return result
|
||||
end
|
||||
|
||||
return type(result) == "table" and tonumber(result.affectedRows) or 0
|
||||
end
|
||||
|
||||
local function trim(value)
|
||||
if type(value) ~= "string" then
|
||||
return ""
|
||||
end
|
||||
return value:match("^%s*(.-)%s*$")
|
||||
end
|
||||
|
||||
local function player_name(source)
|
||||
local first_name = trim(Bridge.Framework.GetFirstname(source))
|
||||
local last_name = trim(Bridge.Framework.GetLastname(source))
|
||||
local character_name = trim((first_name .. " " .. last_name))
|
||||
return character_name ~= "" and character_name or GetPlayerName(source) or ("Player %s"):format(source)
|
||||
end
|
||||
|
||||
local function require_admin(source, operation, maximum)
|
||||
if not Config.AdminPanel.Enabled
|
||||
or not Bridge.Framework.HasAdminGroup(source, Config.AdminPanel.AdminGroups)
|
||||
then
|
||||
Bridge.Debug("warn", "[sky_phone] Rejected admin panel access from source %s.", tostring(source))
|
||||
return nil, { success = false, error = "not_authorized" }
|
||||
end
|
||||
if not SkyPhone.AllowOperation(source, "admin_" .. operation, maximum, 60) then
|
||||
return nil, { success = false, error = "rate_limited" }
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
if type(Config.AdminPanel.Command) ~= "string" or Config.AdminPanel.Command == "" then
|
||||
error("[sky_phone] Config.AdminPanel.Command must be a non-empty command name.")
|
||||
end
|
||||
|
||||
RegisterCommand(Config.AdminPanel.Command, function(command_source)
|
||||
local player_source = tonumber(command_source)
|
||||
if not player_source or player_source < 1 then
|
||||
Bridge.Debug("warn", "[sky_phone] The admin panel command can only be used by a player.")
|
||||
return
|
||||
end
|
||||
if not Config.AdminPanel.Enabled then
|
||||
TriggerClientEvent("sky_phone:admin:command-error", player_source, "disabled")
|
||||
return
|
||||
end
|
||||
if not Bridge.Framework.HasAdminGroup(player_source, Config.AdminPanel.AdminGroups) then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] Rejected admin panel command from source %s.",
|
||||
tostring(player_source)
|
||||
)
|
||||
TriggerClientEvent("sky_phone:admin:command-error", player_source, "not_authorized")
|
||||
return
|
||||
end
|
||||
|
||||
TriggerClientEvent("sky_phone:admin:launch", player_source)
|
||||
end, false)
|
||||
|
||||
local function normalize_source(value)
|
||||
local player_source = tonumber(value)
|
||||
if not player_source or player_source < 1 or player_source ~= math.floor(player_source) then
|
||||
return nil
|
||||
end
|
||||
if not Bridge.Framework.GetIdentifier(player_source) then
|
||||
return nil
|
||||
end
|
||||
return player_source
|
||||
end
|
||||
|
||||
local function collect_device_imeis(source, identifier)
|
||||
local imeis = {}
|
||||
local seen = {}
|
||||
local function add_imei(imei)
|
||||
if SkyPhoneImei.IsValid(imei) and not seen[imei] then
|
||||
seen[imei] = true
|
||||
imeis[#imeis + 1] = imei
|
||||
end
|
||||
end
|
||||
|
||||
for _, item in ipairs(Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)) do
|
||||
add_imei(item.metadata and item.metadata.imei)
|
||||
end
|
||||
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `device_imei` AS `imei`
|
||||
FROM `sky_phone_character_devices`
|
||||
WHERE `owner_identifier` = ?
|
||||
UNION
|
||||
SELECT device.`imei`
|
||||
FROM `sky_phone_devices` device
|
||||
JOIN `sky_phone_sims` sim ON sim.`id` = device.`sim_id`
|
||||
WHERE sim.`owner_identifier` = ?
|
||||
]], { identifier, identifier })
|
||||
for _, row in ipairs(rows) do
|
||||
add_imei(row.imei)
|
||||
end
|
||||
|
||||
table.sort(imeis)
|
||||
return imeis
|
||||
end
|
||||
|
||||
local function normalize_app_ids(value)
|
||||
local normalized = {}
|
||||
local seen = {}
|
||||
if type(value) ~= "table" then
|
||||
return normalized
|
||||
end
|
||||
|
||||
for index = 1, #value do
|
||||
local app_id = value[index]
|
||||
if type(app_id) == "string"
|
||||
and #app_id > 0
|
||||
and #app_id <= 64
|
||||
and not seen[app_id]
|
||||
then
|
||||
seen[app_id] = true
|
||||
normalized[#normalized + 1] = app_id
|
||||
end
|
||||
end
|
||||
return normalized
|
||||
end
|
||||
|
||||
local function load_app_payload(encoded)
|
||||
if type(encoded) ~= "string" or encoded == "" then
|
||||
return {}, {}, {}
|
||||
end
|
||||
local payload = json.decode(encoded)
|
||||
if type(payload) ~= "table" then
|
||||
error("[sky_phone] Stored admin target app payload is not a JSON object.")
|
||||
end
|
||||
return payload, normalize_app_ids(payload.claimedApps), normalize_app_ids(payload.uninstalledApps)
|
||||
end
|
||||
|
||||
local function load_player_devices(source, identifier)
|
||||
local imeis = collect_device_imeis(source, identifier)
|
||||
if #imeis == 0 then
|
||||
return {}
|
||||
end
|
||||
|
||||
local placeholders = {}
|
||||
for index = 1, #imeis do
|
||||
placeholders[index] = "?"
|
||||
end
|
||||
local rows = Bridge.Database.Query(([[
|
||||
SELECT device.`imei`, device.`device_name`, device.`created_at`, device.`updated_at`,
|
||||
sim.`phone_number`, sim.`sim_type`, sim.`registered_at`,
|
||||
account.`id` AS `account_id`, account.`email` AS `account_email`,
|
||||
security.`passcode_length`, security.`failed_attempts`, security.`locked_until`,
|
||||
app_data.`payload` AS `apps_payload`, app_data.`revision` AS `apps_revision`
|
||||
FROM `sky_phone_devices` device
|
||||
LEFT JOIN `sky_phone_sims` sim ON sim.`id` = device.`sim_id`
|
||||
LEFT JOIN `sky_phone_accounts` account ON account.`id` = device.`account_id`
|
||||
LEFT JOIN `sky_phone_device_security` security ON security.`device_imei` = device.`imei`
|
||||
LEFT JOIN `sky_phone_device_data` app_data
|
||||
ON app_data.`device_imei` = device.`imei` AND app_data.`namespace` = 'apps'
|
||||
WHERE device.`imei` IN (%s)
|
||||
ORDER BY device.`updated_at` DESC, device.`imei` ASC
|
||||
]]):format(table.concat(placeholders, ", ")), imeis)
|
||||
|
||||
local devices = {}
|
||||
for _, row in ipairs(rows) do
|
||||
local _, claimed_apps, uninstalled_apps = load_app_payload(row.apps_payload)
|
||||
devices[#devices + 1] = {
|
||||
imei = row.imei,
|
||||
name = row.device_name,
|
||||
createdAt = row.created_at,
|
||||
updatedAt = row.updated_at,
|
||||
number = row.phone_number,
|
||||
simType = row.sim_type,
|
||||
simRegistered = row.registered_at ~= nil,
|
||||
account = row.account_id and {
|
||||
id = tonumber(row.account_id),
|
||||
email = row.account_email,
|
||||
passwordAvailable = true,
|
||||
} or nil,
|
||||
security = {
|
||||
enabled = row.passcode_length ~= nil,
|
||||
length = row.passcode_length and tonumber(row.passcode_length) or nil,
|
||||
failedAttempts = tonumber(row.failed_attempts) or 0,
|
||||
lockedUntil = tonumber(row.locked_until) or 0,
|
||||
},
|
||||
apps = {
|
||||
claimed = claimed_apps,
|
||||
uninstalled = uninstalled_apps,
|
||||
revision = tonumber(row.apps_revision) or 0,
|
||||
},
|
||||
}
|
||||
end
|
||||
return devices
|
||||
end
|
||||
|
||||
local function build_player_summary(source)
|
||||
local identifier = Bridge.Framework.GetIdentifier(source)
|
||||
local devices = load_player_devices(source, identifier)
|
||||
local job = Bridge.Framework.GetJob(source)
|
||||
return {
|
||||
source = source,
|
||||
identifier = identifier,
|
||||
name = player_name(source),
|
||||
serverName = GetPlayerName(source) or "",
|
||||
job = job.label ~= "" and job.label or job.name,
|
||||
grade = job.grade,
|
||||
onDuty = job.onDuty,
|
||||
deviceCount = #devices,
|
||||
phoneNumber = devices[1] and devices[1].number or nil,
|
||||
}
|
||||
end
|
||||
|
||||
local function list_players()
|
||||
local sources = Bridge.Framework.GetPlayers()
|
||||
table.sort(sources, function(left, right)
|
||||
return tonumber(left) < tonumber(right)
|
||||
end)
|
||||
|
||||
local players = {}
|
||||
local maximum = math.max(1, math.floor(tonumber(Config.AdminPanel.MaximumPlayers) or 128))
|
||||
for index = 1, math.min(#sources, maximum) do
|
||||
local player_source = tonumber(sources[index])
|
||||
if player_source and Bridge.Framework.GetIdentifier(player_source) then
|
||||
players[#players + 1] = build_player_summary(player_source)
|
||||
end
|
||||
end
|
||||
return players
|
||||
end
|
||||
|
||||
local function load_player_detail(source)
|
||||
local identifier = Bridge.Framework.GetIdentifier(source)
|
||||
local job = Bridge.Framework.GetJob(source)
|
||||
return {
|
||||
source = source,
|
||||
identifier = identifier,
|
||||
name = player_name(source),
|
||||
serverName = GetPlayerName(source) or "",
|
||||
firstName = trim(Bridge.Framework.GetFirstname(source)),
|
||||
lastName = trim(Bridge.Framework.GetLastname(source)),
|
||||
birthdate = trim(Bridge.Framework.GetBirthdate(source)),
|
||||
job = {
|
||||
name = job.name,
|
||||
label = job.label,
|
||||
grade = job.grade,
|
||||
gradeLabel = job.gradeLabel,
|
||||
onDuty = job.onDuty,
|
||||
},
|
||||
money = {
|
||||
bank = tonumber(Bridge.Framework.GetMoney(source, "bank")) or 0,
|
||||
cash = tonumber(Bridge.Framework.GetMoney(source, "cash")) or 0,
|
||||
currency = Config.Banking.Currency,
|
||||
},
|
||||
devices = load_player_devices(source, identifier),
|
||||
}
|
||||
end
|
||||
|
||||
local function find_owned_device(source, imei)
|
||||
local identifier = Bridge.Framework.GetIdentifier(source)
|
||||
for _, device in ipairs(load_player_devices(source, identifier)) do
|
||||
if device.imei == imei then
|
||||
return device, identifier
|
||||
end
|
||||
end
|
||||
return nil, identifier
|
||||
end
|
||||
|
||||
local function load_device_sim(imei)
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT sim.`id`, sim.`phone_number`
|
||||
FROM `sky_phone_devices` device
|
||||
JOIN `sky_phone_sims` sim ON sim.`id` = device.`sim_id`
|
||||
WHERE device.`imei` = ?
|
||||
LIMIT 1
|
||||
]], { imei })
|
||||
return rows[1]
|
||||
end
|
||||
|
||||
local function app_metadata(app_id)
|
||||
if BUILTIN_APPS[app_id] then
|
||||
return {
|
||||
defaultInstalled = DEFAULT_INSTALLED_APPS[app_id] == true,
|
||||
removable = PROTECTED_APPS[app_id] ~= true,
|
||||
}
|
||||
end
|
||||
if SkyPhoneApps.GetPolicy(app_id) then
|
||||
return { defaultInstalled = false, removable = true }
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function remove_app_id(values, app_id)
|
||||
local next_values = {}
|
||||
for index = 1, #values do
|
||||
if values[index] ~= app_id then
|
||||
next_values[#next_values + 1] = values[index]
|
||||
end
|
||||
end
|
||||
return next_values
|
||||
end
|
||||
|
||||
local function add_app_id(values, app_id)
|
||||
for index = 1, #values do
|
||||
if values[index] == app_id then
|
||||
return values
|
||||
end
|
||||
end
|
||||
values[#values + 1] = app_id
|
||||
return values
|
||||
end
|
||||
|
||||
local function write_audit(actor_source, target_source, target_identifier, imei, action, details)
|
||||
local actor_identifier = Bridge.Framework.GetIdentifier(actor_source)
|
||||
local result = Bridge.Database.Query([[
|
||||
INSERT INTO `sky_phone_admin_audit`
|
||||
(`actor_identifier`, `actor_name`, `target_identifier`, `target_source`, `device_imei`, `action`, `details`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
]], {
|
||||
actor_identifier,
|
||||
player_name(actor_source),
|
||||
target_identifier,
|
||||
target_source,
|
||||
imei,
|
||||
action,
|
||||
json.encode(details or {}),
|
||||
})
|
||||
if affected_rows(result) ~= 1 then
|
||||
error("[sky_phone] Admin audit insert did not affect exactly one row.")
|
||||
end
|
||||
end
|
||||
|
||||
local function load_audit()
|
||||
local limit = math.max(1, math.min(100, math.floor(tonumber(Config.AdminPanel.AuditLimit) or 40)))
|
||||
local rows = Bridge.Database.Query(([[
|
||||
SELECT `id`, `actor_name`, `target_identifier`, `target_source`, `device_imei`,
|
||||
`action`, `details`, `created_at`
|
||||
FROM `sky_phone_admin_audit`
|
||||
ORDER BY `id` DESC
|
||||
LIMIT %s
|
||||
]]):format(limit), {})
|
||||
local audit = {}
|
||||
for _, row in ipairs(rows) do
|
||||
audit[#audit + 1] = {
|
||||
id = tonumber(row.id),
|
||||
actorName = row.actor_name,
|
||||
targetIdentifier = row.target_identifier,
|
||||
targetSource = row.target_source and tonumber(row.target_source) or nil,
|
||||
deviceImei = row.device_imei,
|
||||
action = row.action,
|
||||
details = json.decode(row.details),
|
||||
createdAt = row.created_at,
|
||||
}
|
||||
end
|
||||
return audit
|
||||
end
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:admin:bootstrap", function(source)
|
||||
local authorized, error_response = require_admin(
|
||||
source,
|
||||
"bootstrap",
|
||||
Config.AdminPanel.ReadRequestsPerMinute
|
||||
)
|
||||
if not authorized then
|
||||
return error_response
|
||||
end
|
||||
|
||||
local players = list_players()
|
||||
local totals = Bridge.Database.Query([[
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM `sky_phone_devices`) AS `devices`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_accounts`) AS `accounts`
|
||||
]], {})
|
||||
return {
|
||||
success = true,
|
||||
data = {
|
||||
players = players,
|
||||
stats = {
|
||||
online = #players,
|
||||
devices = tonumber(totals[1] and totals[1].devices) or 0,
|
||||
accounts = tonumber(totals[1] and totals[1].accounts) or 0,
|
||||
},
|
||||
audit = load_audit(),
|
||||
},
|
||||
}
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:admin:player", function(source, data)
|
||||
local authorized, error_response = require_admin(
|
||||
source,
|
||||
"player",
|
||||
Config.AdminPanel.ReadRequestsPerMinute
|
||||
)
|
||||
if not authorized then
|
||||
return error_response
|
||||
end
|
||||
local target_source = normalize_source(data and data.source)
|
||||
if not target_source then
|
||||
return { success = false, error = "player_unavailable" }
|
||||
end
|
||||
return { success = true, data = load_player_detail(target_source) }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:admin:save-apps", function(source, data)
|
||||
local authorized, error_response = require_admin(
|
||||
source,
|
||||
"save_apps",
|
||||
Config.AdminPanel.ActionRequestsPerMinute
|
||||
)
|
||||
if not authorized then
|
||||
return error_response
|
||||
end
|
||||
if type(data) ~= "table"
|
||||
or not SkyPhoneImei.IsValid(data.imei)
|
||||
or type(data.revision) ~= "number"
|
||||
or data.revision < 0
|
||||
or data.revision ~= math.floor(data.revision)
|
||||
or type(data.changes) ~= "table"
|
||||
or #data.changes < 1
|
||||
or #data.changes > 128
|
||||
then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
|
||||
local target_source = normalize_source(data.source)
|
||||
if not target_source then
|
||||
return { success = false, error = "player_unavailable" }
|
||||
end
|
||||
local target_device, target_identifier = find_owned_device(target_source, data.imei)
|
||||
if not target_device then
|
||||
return { success = false, error = "device_not_owned" }
|
||||
end
|
||||
local normalized_changes = {}
|
||||
local seen_apps = {}
|
||||
for index = 1, #data.changes do
|
||||
local change = data.changes[index]
|
||||
if type(change) ~= "table"
|
||||
or type(change.appId) ~= "string"
|
||||
or #change.appId < 1
|
||||
or #change.appId > 64
|
||||
or type(change.installed) ~= "boolean"
|
||||
or seen_apps[change.appId]
|
||||
then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
local metadata = app_metadata(change.appId)
|
||||
if not metadata then
|
||||
return { success = false, error = "invalid_app" }
|
||||
end
|
||||
if not change.installed and not metadata.removable then
|
||||
return { success = false, error = "app_protected" }
|
||||
end
|
||||
seen_apps[change.appId] = true
|
||||
normalized_changes[#normalized_changes + 1] = {
|
||||
appId = change.appId,
|
||||
installed = change.installed,
|
||||
metadata = metadata,
|
||||
}
|
||||
end
|
||||
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `payload`, `revision`
|
||||
FROM `sky_phone_device_data`
|
||||
WHERE `device_imei` = ? AND `namespace` = 'apps'
|
||||
LIMIT 1
|
||||
]], { data.imei })
|
||||
local current_revision = tonumber(rows[1] and rows[1].revision) or 0
|
||||
if current_revision ~= data.revision then
|
||||
return { success = false, error = "revision_conflict" }
|
||||
end
|
||||
|
||||
local payload, claimed_apps, uninstalled_apps = load_app_payload(rows[1] and rows[1].payload)
|
||||
for index = 1, #normalized_changes do
|
||||
local change = normalized_changes[index]
|
||||
if change.installed then
|
||||
uninstalled_apps = remove_app_id(uninstalled_apps, change.appId)
|
||||
if not change.metadata.defaultInstalled then
|
||||
claimed_apps = add_app_id(claimed_apps, change.appId)
|
||||
end
|
||||
else
|
||||
claimed_apps = remove_app_id(claimed_apps, change.appId)
|
||||
uninstalled_apps = add_app_id(uninstalled_apps, change.appId)
|
||||
end
|
||||
end
|
||||
payload.claimedApps = claimed_apps
|
||||
payload.uninstalledApps = #uninstalled_apps > 0 and uninstalled_apps or nil
|
||||
|
||||
local encoded = json.encode(payload)
|
||||
if #encoded > 100000 then
|
||||
return { success = false, error = "payload_too_large" }
|
||||
end
|
||||
|
||||
if rows[1] then
|
||||
local result = Bridge.Database.Query([[
|
||||
UPDATE `sky_phone_device_data`
|
||||
SET `payload` = ?, `revision` = `revision` + 1
|
||||
WHERE `device_imei` = ? AND `namespace` = 'apps' AND `revision` = ?
|
||||
]], { encoded, data.imei, data.revision })
|
||||
if affected_rows(result) ~= 1 then
|
||||
return { success = false, error = "revision_conflict" }
|
||||
end
|
||||
else
|
||||
local result = Bridge.Database.Query([[
|
||||
INSERT IGNORE INTO `sky_phone_device_data` (`device_imei`, `namespace`, `payload`)
|
||||
VALUES (?, 'apps', ?)
|
||||
]], { data.imei, encoded })
|
||||
if affected_rows(result) ~= 1 then
|
||||
return { success = false, error = "revision_conflict" }
|
||||
end
|
||||
end
|
||||
|
||||
for index = 1, #normalized_changes do
|
||||
local change = normalized_changes[index]
|
||||
write_audit(
|
||||
source,
|
||||
target_source,
|
||||
target_identifier,
|
||||
data.imei,
|
||||
change.installed and "grant_app" or "revoke_app",
|
||||
{ appId = change.appId }
|
||||
)
|
||||
end
|
||||
SkyPhone.RefreshDevice(data.imei)
|
||||
return { success = true, data = load_player_detail(target_source) }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:admin:reveal-password", function(source, data)
|
||||
local authorized, error_response = require_admin(
|
||||
source,
|
||||
"reveal_password",
|
||||
Config.AdminPanel.CredentialRevealsPerMinute
|
||||
)
|
||||
if not authorized then
|
||||
return error_response
|
||||
end
|
||||
if type(data) ~= "table" or not SkyPhoneImei.IsValid(data.imei) then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
|
||||
local target_source = normalize_source(data.source)
|
||||
if not target_source then
|
||||
return { success = false, error = "player_unavailable" }
|
||||
end
|
||||
local device, target_identifier = find_owned_device(target_source, data.imei)
|
||||
if not device then
|
||||
return { success = false, error = "device_not_owned" }
|
||||
end
|
||||
|
||||
local accounts = Bridge.Database.Query([[
|
||||
SELECT account.`email`, account.`password`
|
||||
FROM `sky_phone_devices` device
|
||||
JOIN `sky_phone_accounts` account ON account.`id` = device.`account_id`
|
||||
WHERE device.`imei` = ?
|
||||
LIMIT 1
|
||||
]], { data.imei })
|
||||
if not accounts[1] then
|
||||
return { success = false, error = "account_not_found" }
|
||||
end
|
||||
|
||||
write_audit(
|
||||
source,
|
||||
target_source,
|
||||
target_identifier,
|
||||
data.imei,
|
||||
"reveal_account_password",
|
||||
{ email = accounts[1].email }
|
||||
)
|
||||
return {
|
||||
success = true,
|
||||
data = {
|
||||
email = accounts[1].email,
|
||||
password = accounts[1].password,
|
||||
},
|
||||
}
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:admin:activity", function(source, data)
|
||||
local authorized, error_response = require_admin(
|
||||
source,
|
||||
"activity",
|
||||
Config.AdminPanel.ReadRequestsPerMinute
|
||||
)
|
||||
if not authorized then
|
||||
return error_response
|
||||
end
|
||||
if type(data) ~= "table"
|
||||
or not SkyPhoneImei.IsValid(data.imei)
|
||||
or (data.kind ~= "messages" and data.kind ~= "calls")
|
||||
then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
|
||||
local target_source = normalize_source(data.source)
|
||||
if not target_source then
|
||||
return { success = false, error = "player_unavailable" }
|
||||
end
|
||||
local device, target_identifier = find_owned_device(target_source, data.imei)
|
||||
if not device then
|
||||
return { success = false, error = "device_not_owned" }
|
||||
end
|
||||
local audit_action = data.kind == "messages" and "view_messages" or "view_calls"
|
||||
local sim = load_device_sim(data.imei)
|
||||
if not sim then
|
||||
write_audit(
|
||||
source,
|
||||
target_source,
|
||||
target_identifier,
|
||||
data.imei,
|
||||
audit_action,
|
||||
{ count = 0 }
|
||||
)
|
||||
return { success = true, data = { kind = data.kind, entries = {} } }
|
||||
end
|
||||
|
||||
local limit = math.max(1, math.min(100, math.floor(tonumber(Config.AdminPanel.ActivityLimit) or 40)))
|
||||
local entries = {}
|
||||
if data.kind == "messages" then
|
||||
local rows = Bridge.Database.Query(([[
|
||||
SELECT `id`, `sender_sim_id`, `sender_number`, `recipient_number`, `message_type`,
|
||||
`body`, `read_at`, `created_at`
|
||||
FROM `sky_phone_sms_messages`
|
||||
WHERE `sender_sim_id` = ? OR `recipient_sim_id` = ?
|
||||
ORDER BY `created_at` DESC
|
||||
LIMIT %s
|
||||
]]):format(limit), { sim.id, sim.id })
|
||||
for _, row in ipairs(rows) do
|
||||
local outgoing = row.sender_sim_id == sim.id
|
||||
entries[#entries + 1] = {
|
||||
id = row.id,
|
||||
direction = outgoing and "outgoing" or "incoming",
|
||||
otherNumber = outgoing and row.recipient_number or row.sender_number,
|
||||
messageType = row.message_type,
|
||||
body = row.body,
|
||||
readAt = row.read_at,
|
||||
createdAt = row.created_at,
|
||||
}
|
||||
end
|
||||
else
|
||||
local rows = Bridge.Database.Query(([[
|
||||
SELECT `id`, `caller_sim_id`, `caller_number`, `callee_number`, `status`,
|
||||
`started_at`, `answered_at`, `ended_at`, `duration_seconds`
|
||||
FROM `sky_phone_calls`
|
||||
WHERE `caller_sim_id` = ? OR `callee_sim_id` = ?
|
||||
ORDER BY `started_at` DESC
|
||||
LIMIT %s
|
||||
]]):format(limit), { sim.id, sim.id })
|
||||
for _, row in ipairs(rows) do
|
||||
local outgoing = row.caller_sim_id == sim.id
|
||||
entries[#entries + 1] = {
|
||||
id = row.id,
|
||||
direction = outgoing and "outgoing" or "incoming",
|
||||
otherNumber = outgoing and row.callee_number or row.caller_number,
|
||||
status = row.status,
|
||||
startedAt = row.started_at,
|
||||
answeredAt = row.answered_at,
|
||||
endedAt = row.ended_at,
|
||||
durationSeconds = tonumber(row.duration_seconds) or 0,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
write_audit(
|
||||
source,
|
||||
target_source,
|
||||
target_identifier,
|
||||
data.imei,
|
||||
audit_action,
|
||||
{ count = #entries }
|
||||
)
|
||||
return { success = true, data = { kind = data.kind, entries = entries } }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:admin:reset-passcode", function(source, data)
|
||||
local authorized, error_response = require_admin(
|
||||
source,
|
||||
"reset_passcode",
|
||||
Config.AdminPanel.ActionRequestsPerMinute
|
||||
)
|
||||
if not authorized then
|
||||
return error_response
|
||||
end
|
||||
if type(data) ~= "table" or not SkyPhoneImei.IsValid(data.imei) then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
|
||||
local target_source = normalize_source(data.source)
|
||||
if not target_source then
|
||||
return { success = false, error = "player_unavailable" }
|
||||
end
|
||||
local device, target_identifier = find_owned_device(target_source, data.imei)
|
||||
if not device then
|
||||
return { success = false, error = "device_not_owned" }
|
||||
end
|
||||
local result = Bridge.Database.Query(
|
||||
"DELETE FROM `sky_phone_device_security` WHERE `device_imei` = ?",
|
||||
{ data.imei }
|
||||
)
|
||||
if affected_rows(result) ~= 1 then
|
||||
return { success = false, error = "passcode_not_set" }
|
||||
end
|
||||
|
||||
write_audit(source, target_source, target_identifier, data.imei, "reset_passcode", {})
|
||||
SkyPhone.RefreshDevice(data.imei)
|
||||
return { success = true, data = load_player_detail(target_source) }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:admin:change-number", function(source, data)
|
||||
local authorized, error_response = require_admin(
|
||||
source,
|
||||
"change_number",
|
||||
Config.AdminPanel.ActionRequestsPerMinute
|
||||
)
|
||||
if not authorized then
|
||||
return error_response
|
||||
end
|
||||
if type(data) ~= "table"
|
||||
or not SkyPhoneImei.IsValid(data.imei)
|
||||
or (type(data.phoneNumber) ~= "string" and type(data.phoneNumber) ~= "number")
|
||||
then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
|
||||
local target_source = normalize_source(data.source)
|
||||
if not target_source then
|
||||
return { success = false, error = "player_unavailable" }
|
||||
end
|
||||
local device, target_identifier = find_owned_device(target_source, data.imei)
|
||||
if not device then
|
||||
return { success = false, error = "device_not_owned" }
|
||||
end
|
||||
local sim = load_device_sim(data.imei)
|
||||
if not sim then
|
||||
return { success = false, error = "no_sim" }
|
||||
end
|
||||
|
||||
local changed, number_or_error = SkyPhoneSim.ChangeNumber(
|
||||
target_source,
|
||||
data.imei,
|
||||
sim.id,
|
||||
data.phoneNumber
|
||||
)
|
||||
if not changed then
|
||||
return { success = false, error = number_or_error }
|
||||
end
|
||||
|
||||
SkyPhoneCompanies.ClearCallAvailability(target_source)
|
||||
SkyPhoneCalls.EndForSim(sim.id, "number_changed")
|
||||
write_audit(source, target_source, target_identifier, data.imei, "change_number", {
|
||||
previousNumber = sim.phone_number,
|
||||
phoneNumber = number_or_error,
|
||||
})
|
||||
SkyPhone.RefreshDevice(data.imei)
|
||||
return { success = true, data = load_player_detail(target_source) }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:admin:factory-reset", function(source, data)
|
||||
local authorized, error_response = require_admin(
|
||||
source,
|
||||
"factory_reset",
|
||||
Config.AdminPanel.ActionRequestsPerMinute
|
||||
)
|
||||
if not authorized then
|
||||
return error_response
|
||||
end
|
||||
if type(data) ~= "table" or not SkyPhoneImei.IsValid(data.imei) then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
|
||||
local target_source = normalize_source(data.source)
|
||||
if not target_source then
|
||||
return { success = false, error = "player_unavailable" }
|
||||
end
|
||||
local device, target_identifier = find_owned_device(target_source, data.imei)
|
||||
if not device then
|
||||
return { success = false, error = "device_not_owned" }
|
||||
end
|
||||
|
||||
local reset, phone_number_or_error = SkyPhonePersistence.FactoryReset(data.imei)
|
||||
if not reset then
|
||||
return { success = false, error = phone_number_or_error }
|
||||
end
|
||||
|
||||
write_audit(source, target_source, target_identifier, data.imei, "factory_reset", {})
|
||||
if phone_number_or_error then
|
||||
TriggerEvent("sky_phone:server:factoryReset", target_source, phone_number_or_error)
|
||||
end
|
||||
SkyPhone.RefreshDevice(data.imei)
|
||||
return { success = true, data = load_player_detail(target_source) }
|
||||
end)
|
||||
end)
|
||||
@@ -416,6 +416,46 @@ local schema = {
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_admin_audit",
|
||||
columns = {
|
||||
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
|
||||
{
|
||||
name = "actor_identifier",
|
||||
type = "VARCHAR(80) NOT NULL",
|
||||
characterSet = "ascii",
|
||||
collation = "ascii_bin",
|
||||
},
|
||||
{ name = "actor_name", type = "VARCHAR(120) NOT NULL" },
|
||||
{
|
||||
name = "target_identifier",
|
||||
type = "VARCHAR(80) NOT NULL",
|
||||
characterSet = "ascii",
|
||||
collation = "ascii_bin",
|
||||
},
|
||||
{ name = "target_source", type = "INT UNSIGNED NULL" },
|
||||
{
|
||||
name = "device_imei",
|
||||
type = "CHAR(15) NULL",
|
||||
characterSet = "ascii",
|
||||
collation = "ascii_bin",
|
||||
},
|
||||
{
|
||||
name = "action",
|
||||
type = "VARCHAR(48) NOT NULL",
|
||||
characterSet = "ascii",
|
||||
collation = "ascii_bin",
|
||||
},
|
||||
{ name = "details", type = "LONGTEXT NOT NULL" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_admin_audit_created", columns = "(`created_at`, `id`)" },
|
||||
{ name = "idx_sky_phone_admin_audit_target", columns = "(`target_identifier`, `created_at`)" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_notes",
|
||||
columns = {
|
||||
|
||||
+113
-216
@@ -4,19 +4,36 @@ SkyPhoneMediaImport.Initialize()
|
||||
|
||||
local pending_uploads = {}
|
||||
local pending_deletes = {}
|
||||
local allowed_fivemanage_hosts = {
|
||||
["api.fivemanage.com"] = true,
|
||||
["fmapi.net"] = true,
|
||||
local allowed_remote_mimes = {
|
||||
audio = {
|
||||
["audio/ogg"] = true,
|
||||
["audio/webm"] = true,
|
||||
},
|
||||
photo = {
|
||||
["image/jpeg"] = true,
|
||||
["image/png"] = true,
|
||||
["image/webp"] = true,
|
||||
},
|
||||
video = {
|
||||
["video/mp4"] = true,
|
||||
["video/webm"] = true,
|
||||
},
|
||||
}
|
||||
|
||||
local function diagnostic_text(value, maximum_length)
|
||||
return tostring(value or "unknown"):gsub("[\r\n]", " "):sub(1, maximum_length)
|
||||
local function media_config()
|
||||
return Config.Media.FiveManage
|
||||
end
|
||||
|
||||
local function media_debug(message, ...)
|
||||
local arguments = { ... }
|
||||
arguments[#arguments + 1] = { notice = true }
|
||||
Bridge.Debug("debug", "[sky_phone][media-debug] " .. message, table.unpack(arguments))
|
||||
local function media_api_key()
|
||||
local api_key = media_config().ApiKey
|
||||
if type(api_key) ~= "string" then
|
||||
return ""
|
||||
end
|
||||
return api_key:match("^%s*(.-)%s*$")
|
||||
end
|
||||
|
||||
local function api_configured()
|
||||
return media_api_key() ~= ""
|
||||
end
|
||||
|
||||
local function http_request(url, method, body, headers, timeout_ms)
|
||||
@@ -48,9 +65,6 @@ local function response_error_message(response)
|
||||
if type(response) ~= "table" then
|
||||
return "invalid response"
|
||||
end
|
||||
if type(response.status) == "number" and response.status >= 200 and response.status < 300 then
|
||||
return "none"
|
||||
end
|
||||
if type(response.error) == "string" and response.error ~= "" then
|
||||
return response.error:sub(1, 240)
|
||||
end
|
||||
@@ -61,10 +75,6 @@ local function response_error_message(response)
|
||||
return message:sub(1, 240)
|
||||
end
|
||||
end
|
||||
local response_body = tostring(response.body or ""):gsub("[\r\n]", " ")
|
||||
if response_body ~= "" then
|
||||
return response_body:sub(1, 240)
|
||||
end
|
||||
return "no provider error message"
|
||||
end
|
||||
|
||||
@@ -85,22 +95,8 @@ local function decode_response(response)
|
||||
return decoded.data or decoded
|
||||
end
|
||||
|
||||
local function fivemanage_file_base(value)
|
||||
if type(value) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
local host = value:match("^https://([^/%?#]+)")
|
||||
host = host and host:lower() or nil
|
||||
if not host or not allowed_fivemanage_hosts[host] then
|
||||
return nil
|
||||
end
|
||||
return ("https://%s/api/v3/file"):format(host), host
|
||||
end
|
||||
|
||||
local function request_presigned_url()
|
||||
local api_key = SkyPhoneMediaProviderConfig.FiveManageApiKey()
|
||||
media_debug("Starting FiveManage presigned upload request (api-key=%s).", api_key ~= "" and "present" or "missing")
|
||||
if api_key == "" then
|
||||
if not api_configured() then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] FiveManage presigned upload request failed: Config.Media.FiveManage.ApiKey is empty or invalid.",
|
||||
@@ -113,10 +109,9 @@ local function request_presigned_url()
|
||||
tostring(config.BaseUrl):gsub("/+$", "") .. "/presigned-url",
|
||||
"GET",
|
||||
"",
|
||||
{ ["Authorization"] = api_key },
|
||||
{ ["Authorization"] = media_api_key() },
|
||||
tonumber(config.RequestTimeoutMs) or 10000
|
||||
)
|
||||
media_debug("FiveManage presigned upload request returned HTTP %s.", tostring(response.status))
|
||||
if response.status == 401 or response.status == 403 then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
@@ -155,41 +150,40 @@ local function request_presigned_url()
|
||||
)
|
||||
return nil, "media_provider_failed"
|
||||
end
|
||||
local provider_base_url, provider_host = fivemanage_file_base(presigned_url)
|
||||
if not provider_base_url then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] FiveManage returned a presigned URL on an unexpected host.",
|
||||
{ always = true }
|
||||
)
|
||||
return nil, "media_provider_failed"
|
||||
end
|
||||
media_debug("FiveManage returned a valid presigned upload URL (host=%s).", provider_host)
|
||||
return presigned_url
|
||||
end
|
||||
|
||||
local function encode_remote_path(value)
|
||||
local segments = {}
|
||||
for segment in tostring(value):gmatch("[^/]+") do
|
||||
segments[#segments + 1] = SkyPhoneMediaImport.UrlEncode(segment)
|
||||
local function get_remote_file(remote_id)
|
||||
if not api_configured() then
|
||||
return nil, "missing_config"
|
||||
end
|
||||
return table.concat(segments, "/")
|
||||
local config = Config.Media.FiveManage
|
||||
local response = http_request(
|
||||
("%s/%s"):format(
|
||||
tostring(config.BaseUrl):gsub("/+$", ""),
|
||||
SkyPhoneMediaImport.UrlEncode(remote_id)
|
||||
),
|
||||
"GET",
|
||||
"",
|
||||
{ ["Authorization"] = media_api_key() },
|
||||
tonumber(config.RequestTimeoutMs) or 10000
|
||||
)
|
||||
return decode_response(response)
|
||||
end
|
||||
|
||||
local function delete_remote_file(remote_id)
|
||||
local api_key = SkyPhoneMediaProviderConfig.FiveManageApiKey()
|
||||
if api_key == "" then
|
||||
if not api_configured() then
|
||||
return false, "missing_config"
|
||||
end
|
||||
local config = Config.Media.FiveManage
|
||||
local response = http_request(
|
||||
("%s/%s"):format(
|
||||
tostring(config.BaseUrl):gsub("/+$", ""),
|
||||
encode_remote_path(remote_id)
|
||||
SkyPhoneMediaImport.UrlEncode(remote_id)
|
||||
),
|
||||
"DELETE",
|
||||
"",
|
||||
{ ["Authorization"] = api_key },
|
||||
{ ["Authorization"] = media_api_key() },
|
||||
tonumber(config.RequestTimeoutMs) or 10000
|
||||
)
|
||||
if response.status < 200 or response.status >= 300 then
|
||||
@@ -295,14 +289,6 @@ function SkyPhoneMedia.ResolveOwnedMedia(source, media_id, media_type)
|
||||
end
|
||||
|
||||
local function upload_result(source, correlation_id, success, error_code, media)
|
||||
media_debug(
|
||||
"Sending upload result (source=%s, correlation=%s, success=%s, error=%s, media=%s).",
|
||||
tostring(source),
|
||||
diagnostic_text(correlation_id, 80),
|
||||
tostring(success),
|
||||
diagnostic_text(error_code, 80),
|
||||
type(media) == "table" and "present" or "missing"
|
||||
)
|
||||
TriggerClientEvent("sky_phone:media:upload-result", source, {
|
||||
correlationId = correlation_id,
|
||||
success = success,
|
||||
@@ -329,42 +315,79 @@ local function delete_many_result(source, correlation_id, success, error_code, d
|
||||
})
|
||||
end
|
||||
|
||||
local function parse_metadata(value)
|
||||
if type(value) == "table" then
|
||||
return value
|
||||
end
|
||||
if type(value) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
local success, decoded = pcall(json.decode, value)
|
||||
return success and type(decoded) == "table" and decoded or nil
|
||||
end
|
||||
|
||||
local function valid_remote_id(value)
|
||||
return type(value) == "string" and #value >= 4 and #value <= 128 and value:match("^[%w_%-]+$") ~= nil
|
||||
end
|
||||
|
||||
local function verify_remote_upload(state, remote_id, uploaded_url)
|
||||
if not valid_remote_id(remote_id) or type(uploaded_url) ~= "string"
|
||||
or #uploaded_url > Config.Media.UrlMaxLength
|
||||
if not valid_remote_id(remote_id) or type(uploaded_url) ~= "string" or #uploaded_url > 2048
|
||||
or not uploaded_url:match("^https://")
|
||||
then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone][media-debug] Upload completion payload is invalid (remote-id=%s, url=%s).",
|
||||
valid_remote_id(remote_id) and "valid" or "invalid",
|
||||
type(uploaded_url) == "string" and uploaded_url:match("^https://") and "https" or "invalid"
|
||||
)
|
||||
return nil, "invalid_upload"
|
||||
end
|
||||
|
||||
local uploaded_host = uploaded_url:match("^https://([^/%?#]+)")
|
||||
if not uploaded_host or uploaded_host:lower() ~= "r2.fivemanage.com" then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone][media-debug] FiveManage upload returned an unexpected media host."
|
||||
)
|
||||
local remote, remote_error = get_remote_file(remote_id)
|
||||
if not remote then
|
||||
return nil, remote_error
|
||||
end
|
||||
if remote.id ~= remote_id then
|
||||
return nil, "invalid_upload"
|
||||
end
|
||||
media_debug(
|
||||
"Accepting the direct FiveManage upload response (type=%s, size=%s).",
|
||||
tostring(state.media_type),
|
||||
tostring(state.size_bytes)
|
||||
)
|
||||
if remote.url ~= uploaded_url and remote.originalUrl ~= uploaded_url then
|
||||
return nil, "invalid_upload"
|
||||
end
|
||||
local verified_url = remote.url or uploaded_url
|
||||
if type(verified_url) ~= "string" or #verified_url > Config.Media.UrlMaxLength
|
||||
or not verified_url:match("^https://")
|
||||
then
|
||||
return nil, "invalid_upload"
|
||||
end
|
||||
local metadata = parse_metadata(remote.metadata)
|
||||
if not metadata or metadata.captureToken ~= state.capture_token or metadata.source ~= "sky_phone" then
|
||||
return nil, "invalid_upload_token"
|
||||
end
|
||||
if state.purpose and metadata.purpose ~= state.purpose then
|
||||
return nil, "invalid_upload", true
|
||||
end
|
||||
local allowed_mimes = allowed_remote_mimes[state.media_type]
|
||||
if not allowed_mimes then
|
||||
return nil, "invalid_media_type", true
|
||||
end
|
||||
local remote_mime = tostring(remote.mimeType or ""):lower():match("^%s*([^;%s]+)") or ""
|
||||
local remote_type = tostring(remote.type or ""):lower():match("^%s*([^;%s]+)") or ""
|
||||
if remote_mime == "" and allowed_mimes[remote_type] then
|
||||
remote_mime = remote_type
|
||||
end
|
||||
if remote_type == "" then
|
||||
remote_type = remote_mime
|
||||
end
|
||||
if state.media_type == "photo" and remote_type ~= "" and not remote_type:find("image", 1, true) then
|
||||
return nil, "invalid_media_type", true
|
||||
end
|
||||
if state.media_type == "video" and remote_type ~= "" and not remote_type:find("video", 1, true) then
|
||||
return nil, "invalid_media_type", true
|
||||
end
|
||||
if state.media_type == "audio" and remote_type ~= "" and not remote_type:find("audio", 1, true) then
|
||||
return nil, "invalid_media_type", true
|
||||
end
|
||||
if remote_mime ~= "" and not allowed_mimes[remote_mime] then
|
||||
return nil, "invalid_media_type", true
|
||||
end
|
||||
return {
|
||||
mime_type = state.mime_type,
|
||||
mime_type = allowed_mimes[remote_mime] and remote_mime or state.mime_type,
|
||||
remote_id = remote_id,
|
||||
size = state.size_bytes,
|
||||
url = uploaded_url,
|
||||
size = tonumber(remote.size),
|
||||
url = verified_url,
|
||||
}, nil, true
|
||||
end
|
||||
|
||||
@@ -378,12 +401,6 @@ local function expire_upload(request_id)
|
||||
return
|
||||
end
|
||||
pending_uploads[request_id] = nil
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone][media-debug] Upload session expired before completion (source=%s, correlation=%s).",
|
||||
tostring(state.source),
|
||||
diagnostic_text(state.correlation_id, 80)
|
||||
)
|
||||
upload_result(state.source, state.correlation_id, false, "upload_timeout")
|
||||
end
|
||||
|
||||
@@ -643,81 +660,35 @@ RegisterNetEvent("sky_phone:media:request-upload", function(data)
|
||||
data = type(data) == "table" and data or {}
|
||||
local correlation_id = data.correlationId
|
||||
local media_type = data.mediaType
|
||||
media_debug(
|
||||
"Server received upload request (source=%s, correlation=%s, type=%s).",
|
||||
tostring(src),
|
||||
diagnostic_text(correlation_id, 80),
|
||||
diagnostic_text(media_type, 20)
|
||||
)
|
||||
if type(correlation_id) ~= "string" or #correlation_id > 80
|
||||
or (media_type ~= "photo" and media_type ~= "video")
|
||||
then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone][media-debug] Upload request validation failed (source=%s, correlation-type=%s, correlation-length=%s, media-type=%s).",
|
||||
tostring(src),
|
||||
type(correlation_id),
|
||||
type(correlation_id) == "string" and tostring(#correlation_id) or "invalid",
|
||||
diagnostic_text(media_type, 20)
|
||||
)
|
||||
upload_result(src, correlation_id, false, "invalid_request")
|
||||
return
|
||||
end
|
||||
if not SkyPhone.AllowOperation(src, "media_write", 20, 60) then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone][media-debug] Upload request was rate limited (source=%s, correlation=%s).",
|
||||
tostring(src),
|
||||
diagnostic_text(correlation_id, 80)
|
||||
)
|
||||
upload_result(src, correlation_id, false, "rate_limited")
|
||||
return
|
||||
end
|
||||
local owner, error_response = session_owner(src)
|
||||
if not owner then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone][media-debug] Upload request has no valid phone session (source=%s, correlation=%s, error=%s).",
|
||||
tostring(src),
|
||||
diagnostic_text(correlation_id, 80),
|
||||
diagnostic_text(error_response and error_response.error, 80)
|
||||
)
|
||||
upload_result(src, correlation_id, false, error_response.error)
|
||||
return
|
||||
end
|
||||
media_debug(
|
||||
"Upload request session resolved (source=%s, correlation=%s, owner=%s).",
|
||||
tostring(src),
|
||||
diagnostic_text(correlation_id, 80),
|
||||
owner.account_id and "account" or "device"
|
||||
)
|
||||
local presigned_url, presigned_error = request_presigned_url()
|
||||
if not presigned_url then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone][media-debug] Presigned upload request failed (source=%s, correlation=%s, error=%s).",
|
||||
tostring(src),
|
||||
diagnostic_text(correlation_id, 80),
|
||||
diagnostic_text(presigned_error, 80)
|
||||
)
|
||||
upload_result(src, correlation_id, false, presigned_error)
|
||||
return
|
||||
end
|
||||
local ids = Bridge.Database.Query("SELECT UUID() AS `request_id`", {})
|
||||
local ids = Bridge.Database.Query("SELECT UUID() AS `request_id`, UUID() AS `capture_token`", {})
|
||||
local request_id = ids[1] and ids[1].request_id
|
||||
if type(request_id) ~= "string" then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone][media-debug] Database did not generate an upload request ID (source=%s, correlation=%s, rows=%s, request-id=%s).",
|
||||
tostring(src),
|
||||
diagnostic_text(correlation_id, 80),
|
||||
tostring(type(ids) == "table" and #ids or 0),
|
||||
type(request_id)
|
||||
)
|
||||
local capture_token = ids[1] and ids[1].capture_token
|
||||
if type(request_id) ~= "string" or type(capture_token) ~= "string" then
|
||||
upload_result(src, correlation_id, false, "request_failed")
|
||||
return
|
||||
end
|
||||
pending_uploads[request_id] = {
|
||||
capture_token = capture_token,
|
||||
correlation_id = correlation_id,
|
||||
media_type = media_type,
|
||||
mime_type = media_type == "video" and "video/webm"
|
||||
@@ -729,13 +700,8 @@ RegisterNetEvent("sky_phone:media:request-upload", function(data)
|
||||
SetTimeout(tonumber(Config.Media.UploadSessionTimeoutMs) or 60000, function()
|
||||
expire_upload(request_id)
|
||||
end)
|
||||
media_debug(
|
||||
"Sending upload-ready to client (source=%s, correlation=%s, type=%s).",
|
||||
tostring(src),
|
||||
diagnostic_text(correlation_id, 80),
|
||||
tostring(media_type)
|
||||
)
|
||||
TriggerClientEvent("sky_phone:media:upload-ready", src, {
|
||||
captureToken = capture_token,
|
||||
correlationId = correlation_id,
|
||||
mediaType = media_type,
|
||||
photo = Config.Media.Photo,
|
||||
@@ -752,55 +718,18 @@ RegisterNetEvent("sky_phone:media:complete-upload", function(data)
|
||||
local request_id = data.requestId
|
||||
local state = type(request_id) == "string" and pending_uploads[request_id] or nil
|
||||
if not state or state.source ~= src or state.completing then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone][media-debug] Rejected upload completion (source=%s, request-id=%s, session=%s, owner-match=%s, completing=%s).",
|
||||
tostring(src),
|
||||
type(request_id) == "string" and "present" or "invalid",
|
||||
state and "present" or "missing",
|
||||
tostring(state and state.source == src),
|
||||
tostring(state and state.completing)
|
||||
)
|
||||
return
|
||||
end
|
||||
media_debug(
|
||||
"Server received upload completion (source=%s, correlation=%s, remote-id=%s, url=%s).",
|
||||
tostring(src),
|
||||
diagnostic_text(state.correlation_id, 80),
|
||||
type(data.remoteId) == "string" and "present" or "missing",
|
||||
type(data.url) == "string" and "present" or "missing"
|
||||
)
|
||||
state.completing = true
|
||||
local owner, error_response = session_owner(src)
|
||||
if not owner or not owners_match(owner, state.owner) then
|
||||
pending_uploads[request_id] = nil
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone][media-debug] Upload owner changed before completion (source=%s, correlation=%s, session=%s, owner-match=%s, error=%s).",
|
||||
tostring(src),
|
||||
diagnostic_text(state.correlation_id, 80),
|
||||
owner and "present" or "missing",
|
||||
tostring(owner and owners_match(owner, state.owner)),
|
||||
diagnostic_text(error_response and error_response.error, 80)
|
||||
)
|
||||
upload_result(src, state.correlation_id, false, error_response and error_response.error or "owner_changed")
|
||||
return
|
||||
end
|
||||
local verified, verify_error, trusted_remote = verify_remote_upload(
|
||||
state,
|
||||
data.remoteId,
|
||||
data.url
|
||||
)
|
||||
local verified, verify_error, trusted_remote = verify_remote_upload(state, data.remoteId, data.url)
|
||||
if not verified then
|
||||
pending_uploads[request_id] = nil
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone][media-debug] Uploaded file verification failed (source=%s, correlation=%s, error=%s, trusted-remote=%s).",
|
||||
tostring(src),
|
||||
diagnostic_text(state.correlation_id, 80),
|
||||
diagnostic_text(verify_error, 80),
|
||||
tostring(trusted_remote)
|
||||
)
|
||||
if trusted_remote then
|
||||
local deleted, delete_error = delete_remote_file(data.remoteId)
|
||||
if not deleted then
|
||||
@@ -831,22 +760,9 @@ RegisterNetEvent("sky_phone:media:complete-upload", function(data)
|
||||
local media_id = type(result) == "number" and result or (type(result) == "table" and tonumber(result.insertId))
|
||||
if not media_id then
|
||||
delete_remote_file(verified.remote_id)
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone][media-debug] Database insert did not return a media ID (source=%s, correlation=%s, result-type=%s).",
|
||||
tostring(src),
|
||||
diagnostic_text(state.correlation_id, 80),
|
||||
type(result)
|
||||
)
|
||||
upload_result(src, state.correlation_id, false, "request_failed")
|
||||
return
|
||||
end
|
||||
media_debug(
|
||||
"Media upload completed successfully (source=%s, correlation=%s, media-id=%s).",
|
||||
tostring(src),
|
||||
diagnostic_text(state.correlation_id, 80),
|
||||
tostring(media_id)
|
||||
)
|
||||
upload_result(src, state.correlation_id, true, nil, {
|
||||
id = media_id,
|
||||
url = verified.url,
|
||||
@@ -871,15 +787,6 @@ RegisterNetEvent("sky_phone:media:fail-upload", function(data)
|
||||
local request_id = type(data) == "table" and data.requestId or nil
|
||||
local state = type(request_id) == "string" and pending_uploads[request_id] or nil
|
||||
if not state or state.source ~= src or state.completing then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone][media-debug] Rejected client upload failure report (source=%s, request-id=%s, session=%s, owner-match=%s, completing=%s).",
|
||||
tostring(src),
|
||||
type(request_id) == "string" and "present" or "invalid",
|
||||
state and "present" or "missing",
|
||||
tostring(state and state.source == src),
|
||||
tostring(state and state.completing)
|
||||
)
|
||||
return
|
||||
end
|
||||
local allowed_errors = {
|
||||
@@ -890,16 +797,6 @@ RegisterNetEvent("sky_phone:media:fail-upload", function(data)
|
||||
}
|
||||
pending_uploads[request_id] = nil
|
||||
local error_code = allowed_errors[data.error] and data.error or "upload_failed"
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone][media-debug] Client-reported upload failure (source=%s, correlation=%s, error=%s, stage=%s, status=%s, detail=%s).",
|
||||
tostring(src),
|
||||
diagnostic_text(state.correlation_id, 80),
|
||||
diagnostic_text(error_code, 80),
|
||||
diagnostic_text(data.debugStage, 40),
|
||||
diagnostic_text(data.debugStatus, 20),
|
||||
diagnostic_text(data.debugMessage, 240)
|
||||
)
|
||||
upload_result(src, state.correlation_id, false, error_code)
|
||||
end)
|
||||
|
||||
@@ -1084,7 +981,7 @@ AddEventHandler("playerDropped", function()
|
||||
end
|
||||
end)
|
||||
|
||||
if SkyPhoneMediaProviderConfig.FiveManageApiKey() == "" then
|
||||
if not api_configured() then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] FiveManage media integration is disabled because Config.Media.FiveManage.ApiKey is empty in config/media.lua. Camera photo and video uploads, Voice Memo uploads, remote Gallery deletion, and FiveManage imports are unavailable. Add a FiveManage V3 token with Media access and restart sky_phone.",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
local function api_key(website)
|
||||
return SkyPhoneMediaProviderConfig.FiveManageApiKey(website.ApiKey)
|
||||
local configured_key = website.ApiKey or Config.Media.FiveManage.ApiKey
|
||||
if type(configured_key) ~= "string" then
|
||||
return ""
|
||||
end
|
||||
return configured_key:match("^%s*(.-)%s*$")
|
||||
end
|
||||
|
||||
local function provider_error(response, not_found_error)
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
SkyPhoneMediaProviderConfig = {}
|
||||
|
||||
local function trim_key(value)
|
||||
if type(value) ~= "string" then
|
||||
return ""
|
||||
end
|
||||
return value:match("^%s*(.-)%s*$")
|
||||
end
|
||||
|
||||
function SkyPhoneMediaProviderConfig.FiveManageApiKey(override_key)
|
||||
local api_key = trim_key(override_key)
|
||||
if api_key ~= "" then
|
||||
return api_key
|
||||
end
|
||||
|
||||
return trim_key(Config.Media.FiveManage.ApiKey)
|
||||
end
|
||||
@@ -170,7 +170,6 @@ local function validate_upload(data)
|
||||
local title_length = title and utf8.len(title) or nil
|
||||
local note_length = note and utf8.len(note) or nil
|
||||
local duration_ms = tonumber(data.durationMs)
|
||||
local size_bytes = tonumber(data.sizeBytes)
|
||||
local normalized_mime = allowed_audio_mimes[data.mimeType]
|
||||
local waveform = normalize_waveform(data.waveform)
|
||||
if not title_length or title_length < 1 or title_length > Config.Memos.TitleMaxLength
|
||||
@@ -178,8 +177,6 @@ local function validate_upload(data)
|
||||
or not duration_ms or duration_ms ~= duration_ms
|
||||
or duration_ms == math.huge or duration_ms == -math.huge
|
||||
or duration_ms < 300 or duration_ms > Config.Memos.MaximumDurationMs
|
||||
or not size_bytes or size_bytes ~= math.floor(size_bytes)
|
||||
or size_bytes < 1 or size_bytes > Config.Memos.MaximumBytes
|
||||
or not normalized_mime or not waveform or type(data.pinned) ~= "boolean"
|
||||
then
|
||||
return nil
|
||||
@@ -191,7 +188,6 @@ local function validate_upload(data)
|
||||
mime_type = normalized_mime,
|
||||
note = note,
|
||||
pinned = data.pinned,
|
||||
size_bytes = size_bytes,
|
||||
title = title,
|
||||
waveform = waveform,
|
||||
}
|
||||
@@ -377,18 +373,20 @@ RegisterNetEvent("sky_phone:memos:request-upload", function(data)
|
||||
upload_result(src, memo.correlation_id, false, presigned_error)
|
||||
return
|
||||
end
|
||||
local ids = Bridge.Database.Query("SELECT UUID() AS `request_id`", {})
|
||||
local ids = Bridge.Database.Query("SELECT UUID() AS `request_id`, UUID() AS `capture_token`", {})
|
||||
local request_id = ids[1] and ids[1].request_id
|
||||
if type(request_id) ~= "string" then
|
||||
error("[sky_phone] Database did not generate a voice memo upload request ID.")
|
||||
local capture_token = ids[1] and ids[1].capture_token
|
||||
if type(request_id) ~= "string" or type(capture_token) ~= "string" then
|
||||
error("[sky_phone] Database did not generate a voice memo upload token.")
|
||||
end
|
||||
pending_uploads[request_id] = {
|
||||
capture_token = capture_token,
|
||||
media_type = "audio",
|
||||
mime_type = memo.mime_type,
|
||||
memo = memo,
|
||||
owner = owner,
|
||||
owner_key = pending_owner_key,
|
||||
size_bytes = memo.size_bytes,
|
||||
purpose = "memo",
|
||||
source = src,
|
||||
}
|
||||
SetTimeout(Config.Memos.UploadSessionTimeoutMs, function()
|
||||
@@ -397,6 +395,7 @@ RegisterNetEvent("sky_phone:memos:request-upload", function(data)
|
||||
TriggerClientEvent("sky_phone:memos:upload-ready", src, {
|
||||
requestId = request_id,
|
||||
correlationId = memo.correlation_id,
|
||||
captureToken = capture_token,
|
||||
presignedUrl = presigned_url,
|
||||
uploadTimeoutMs = Config.Media.FiveManage.UploadTimeoutMs,
|
||||
})
|
||||
@@ -413,22 +412,14 @@ RegisterNetEvent("sky_phone:memos:complete-upload", function(data)
|
||||
local owner, error_response = device_owner(src, state.owner.imei)
|
||||
if not owner or owner.imei ~= state.owner.imei or owner.account_id ~= state.owner.account_id then
|
||||
pending_uploads[request_id] = nil
|
||||
local rejected, _, trusted_remote = SkyPhoneMedia.VerifyRemoteUpload(
|
||||
state,
|
||||
data.remoteId,
|
||||
data.url
|
||||
)
|
||||
local rejected, _, trusted_remote = SkyPhoneMedia.VerifyRemoteUpload(state, data.remoteId, data.url)
|
||||
if rejected or trusted_remote then
|
||||
discard_verified_upload(data.remoteId)
|
||||
end
|
||||
upload_result(src, state.memo.correlation_id, false, error_response and error_response.error or "owner_changed")
|
||||
return
|
||||
end
|
||||
local verified, verify_error, trusted_remote = SkyPhoneMedia.VerifyRemoteUpload(
|
||||
state,
|
||||
data.remoteId,
|
||||
data.url
|
||||
)
|
||||
local verified, verify_error, trusted_remote = SkyPhoneMedia.VerifyRemoteUpload(state, data.remoteId, data.url)
|
||||
if not verified then
|
||||
pending_uploads[request_id] = nil
|
||||
if trusted_remote then
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
Bridge.Database.AfterMigration("sky_phone", function()
|
||||
SkyPhonePersistence = {}
|
||||
|
||||
local max_device_data_bytes = 100000
|
||||
local allowed_device_namespaces = {
|
||||
settings = true,
|
||||
@@ -150,6 +152,69 @@ Bridge.Callbacks.Register("sky_phone:notifications:save", function(source, data)
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
function SkyPhonePersistence.FactoryReset(imei)
|
||||
if not SkyPhoneImei.IsValid(imei) then
|
||||
return false, "invalid_request"
|
||||
end
|
||||
|
||||
local device = SkyPhone.LoadDevice(imei)
|
||||
if not device then
|
||||
return false, "device_not_found"
|
||||
end
|
||||
local phone_number = device and device.phone_number or nil
|
||||
local media_remote_ids = SkyPhoneMedia.GetDeviceRemoteIds(imei)
|
||||
if not Bridge.Database.Transaction({
|
||||
{
|
||||
query = "DELETE FROM `sky_phone_device_security` WHERE `device_imei` = ?",
|
||||
params = { imei },
|
||||
},
|
||||
{
|
||||
query = "DELETE FROM `sky_phone_device_data` WHERE `device_imei` = ?",
|
||||
params = { imei },
|
||||
},
|
||||
{
|
||||
query = "DELETE FROM `sky_phone_custom_app_data` WHERE `device_imei` = ?",
|
||||
params = { imei },
|
||||
},
|
||||
{
|
||||
query = "DELETE FROM `sky_phone_notes` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
||||
params = { imei },
|
||||
},
|
||||
{
|
||||
query = "DELETE FROM `sky_phone_media` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
||||
params = { imei },
|
||||
},
|
||||
{
|
||||
query = "DELETE FROM `sky_phone_music_playlists` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
||||
params = { imei },
|
||||
},
|
||||
{
|
||||
query = "DELETE FROM `sky_phone_music_youtube_songs` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
||||
params = { imei },
|
||||
},
|
||||
{
|
||||
query = "DELETE FROM `sky_phone_contacts` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
||||
params = { imei },
|
||||
},
|
||||
{
|
||||
query = "DELETE FROM `sky_phone_call_entries` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
||||
params = { imei },
|
||||
},
|
||||
{
|
||||
query = "DELETE FROM `sky_phone_fliptok_sessions` WHERE `device_imei` = ?",
|
||||
params = { imei },
|
||||
},
|
||||
{
|
||||
query = "UPDATE `sky_phone_devices` SET `account_id` = NULL, `device_name` = ? WHERE `imei` = ?",
|
||||
params = { Config.Phone.DeviceName, imei },
|
||||
},
|
||||
}) then
|
||||
return false, "request_failed"
|
||||
end
|
||||
SkyPhoneMedia.CleanupRemoteFiles(media_remote_ids)
|
||||
return true, phone_number
|
||||
end
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source)
|
||||
if not SkyPhone.AllowOperation(source, "factory_reset", 3, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
@@ -158,58 +223,11 @@ Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
local device = SkyPhone.LoadDevice(session.imei)
|
||||
local phone_number = device and device.phone_number or nil
|
||||
local media_remote_ids = SkyPhoneMedia.GetDeviceRemoteIds(session.imei)
|
||||
if not Bridge.Database.Transaction({
|
||||
{
|
||||
query = "DELETE FROM `sky_phone_device_security` WHERE `device_imei` = ?",
|
||||
params = { session.imei },
|
||||
},
|
||||
{
|
||||
query = "DELETE FROM `sky_phone_device_data` WHERE `device_imei` = ?",
|
||||
params = { session.imei },
|
||||
},
|
||||
{
|
||||
query = "DELETE FROM `sky_phone_custom_app_data` WHERE `device_imei` = ?",
|
||||
params = { session.imei },
|
||||
},
|
||||
{
|
||||
query = "DELETE FROM `sky_phone_notes` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
||||
params = { session.imei },
|
||||
},
|
||||
{
|
||||
query = "DELETE FROM `sky_phone_media` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
||||
params = { session.imei },
|
||||
},
|
||||
{
|
||||
query = "DELETE FROM `sky_phone_music_playlists` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
||||
params = { session.imei },
|
||||
},
|
||||
{
|
||||
query = "DELETE FROM `sky_phone_music_youtube_songs` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
||||
params = { session.imei },
|
||||
},
|
||||
{
|
||||
query = "DELETE FROM `sky_phone_contacts` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
||||
params = { session.imei },
|
||||
},
|
||||
{
|
||||
query = "DELETE FROM `sky_phone_call_entries` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
||||
params = { session.imei },
|
||||
},
|
||||
{
|
||||
query = "DELETE FROM `sky_phone_fliptok_sessions` WHERE `device_imei` = ?",
|
||||
params = { session.imei },
|
||||
},
|
||||
{
|
||||
query = "UPDATE `sky_phone_devices` SET `account_id` = NULL, `device_name` = ? WHERE `imei` = ?",
|
||||
params = { Config.Phone.DeviceName, session.imei },
|
||||
},
|
||||
}) then
|
||||
return { success = false, error = "request_failed" }
|
||||
|
||||
local reset, phone_number = SkyPhonePersistence.FactoryReset(session.imei)
|
||||
if not reset then
|
||||
return { success = false, error = phone_number }
|
||||
end
|
||||
SkyPhoneMedia.CleanupRemoteFiles(media_remote_ids)
|
||||
session.unlocked = true
|
||||
if phone_number then
|
||||
TriggerEvent("sky_phone:server:factoryReset", source, phone_number)
|
||||
|
||||
@@ -153,6 +153,62 @@ end
|
||||
|
||||
SkyPhoneSim.PrepareDevice = prepare_device
|
||||
|
||||
function SkyPhoneSim.ChangeNumber(source, imei, sim_id, value)
|
||||
local number = SkyPhoneSimNumber.Normalize(value, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
|
||||
if not number or SkyPhoneCompanies.IsServiceNumber(number) then
|
||||
return false, "invalid_phone_number"
|
||||
end
|
||||
|
||||
local sim = load_sim(sim_id)
|
||||
if not sim then
|
||||
return false, "no_sim"
|
||||
end
|
||||
if sim.phone_number == number then
|
||||
return false, "phone_number_unchanged"
|
||||
end
|
||||
|
||||
local existing = Bridge.Database.Query(
|
||||
"SELECT `id` FROM `sky_phone_sims` WHERE `phone_number` = ? AND `id` <> ? LIMIT 1",
|
||||
{ number, sim_id }
|
||||
)
|
||||
if existing[1] then
|
||||
return false, "phone_number_taken"
|
||||
end
|
||||
|
||||
local phone_slot
|
||||
if unique_phones then
|
||||
for _, slot in ipairs(Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)) do
|
||||
if slot.metadata and slot.metadata.imei == imei then
|
||||
phone_slot = slot
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local previous_number = sim.phone_number
|
||||
sim.phone_number = number
|
||||
if phone_slot and not set_phone_sim_metadata(source, phone_slot, sim) then
|
||||
return false, "metadata_unsupported"
|
||||
end
|
||||
|
||||
local result = Bridge.Database.Query([[
|
||||
UPDATE IGNORE `sky_phone_sims`
|
||||
SET `phone_number` = ?
|
||||
WHERE `id` = ? AND `phone_number` = ?
|
||||
]], { number, sim_id, previous_number })
|
||||
if affected_rows(result) ~= 1 then
|
||||
if phone_slot then
|
||||
sim.phone_number = previous_number
|
||||
if not set_phone_sim_metadata(source, phone_slot, sim) then
|
||||
error("[sky_phone] Could not restore SIM metadata after a failed admin number change.")
|
||||
end
|
||||
end
|
||||
return false, "phone_number_taken"
|
||||
end
|
||||
|
||||
return true, number
|
||||
end
|
||||
|
||||
local function resolve_used_sim(source, used_item, item_name)
|
||||
local slot_id = used_item and (used_item.slot or used_item.id)
|
||||
local slot = slot_id and Bridge.Inventory.GetSlot(source, slot_id) or nil
|
||||
|
||||
@@ -35,6 +35,7 @@ local ALLOWED_PERMISSIONS = {
|
||||
}
|
||||
|
||||
local RESERVED_APP_IDS = {
|
||||
admin = true,
|
||||
["app-store"] = true,
|
||||
banking = true,
|
||||
crypto = true,
|
||||
|
||||
@@ -185,6 +185,21 @@ CREATE TABLE IF NOT EXISTS `sky_phone_device_security` (
|
||||
FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_admin_audit` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`actor_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`actor_name` VARCHAR(120) NOT NULL,
|
||||
`target_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`target_source` INT UNSIGNED NULL,
|
||||
`device_imei` CHAR(15) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
||||
`action` VARCHAR(48) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`details` LONGTEXT NOT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_sky_phone_admin_audit_created` (`created_at`, `id`),
|
||||
KEY `idx_sky_phone_admin_audit_target` (`target_identifier`, `created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_notes` (
|
||||
`id` VARCHAR(64) NOT NULL,
|
||||
`account_id` BIGINT UNSIGNED NULL,
|
||||
|
||||
@@ -23,7 +23,11 @@ Bridge = {
|
||||
},
|
||||
Debug = function()
|
||||
end,
|
||||
Framework = {},
|
||||
Framework = {
|
||||
HasAdminGroup = function()
|
||||
return true
|
||||
end,
|
||||
},
|
||||
Inventory = {
|
||||
GetResourceName = function()
|
||||
return "test_inventory"
|
||||
@@ -36,6 +40,11 @@ Bridge = {
|
||||
}
|
||||
|
||||
Config = {
|
||||
AdminPanel = {
|
||||
AdminGroups = { "admin" },
|
||||
Command = "phoneadmin",
|
||||
Enabled = true,
|
||||
},
|
||||
Phone = {
|
||||
DevelopmentCommand = false,
|
||||
DeviceName = "Test Phone",
|
||||
@@ -79,6 +88,12 @@ function AddEventHandler(_, callback)
|
||||
assert(type(callback) == "function")
|
||||
end
|
||||
|
||||
function RegisterCommand(name, callback, restricted)
|
||||
assert(name == "phoneadmin")
|
||||
assert(type(callback) == "function")
|
||||
assert(restricted == false)
|
||||
end
|
||||
|
||||
function TriggerClientEvent()
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user