mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 17:01:18 +00:00
ADD - added Extern Image/Video Import
This commit is contained in:
@@ -209,7 +209,11 @@ Database migrations run automatically. Existing `sky_phone_mail_accounts` instal
|
||||
Camera and Gallery media is stored in `sky_phone_media`. Signed-out captures belong to the current
|
||||
IMEI; linking an iFruit account moves those rows into the account gallery so every linked phone sees
|
||||
them. Signing out hides cloud media without deleting it. Factory reset removes device-local media
|
||||
and attempts to delete its remote FiveManage files, while account-owned media remains in the cloud.
|
||||
and attempts to delete only Phone-created remote FiveManage files. Media imported from a website is
|
||||
removed locally but never deleted at its source. Register import websites under
|
||||
`Config.Media.Import.Websites` in `sky_phone/config/media.lua`; built-in adapters support FiveManage
|
||||
files and version-1 JSON manifests. The Gallery import form accepts direct HTTPS image/video links
|
||||
only when their hostname matches the selected website's `AllowedMediaHosts`.
|
||||
|
||||
For a fresh manual database installation, import `sky_phone/sql/install.sql`. It contains the complete current table, key, index, collation, and foreign-key schema. Runtime migrations remain authoritative for upgrading an existing installation and must stay enabled.
|
||||
|
||||
|
||||
@@ -3432,13 +3432,45 @@ const defaultLocales: LocaleTree = {
|
||||
zoomOut: 'Zoom out',
|
||||
resetZoom: 'Reset zoom',
|
||||
filters: { all: 'All', photos: 'Photos', videos: 'Videos' },
|
||||
import: {
|
||||
action: 'Import',
|
||||
title: 'Import',
|
||||
chooseSource: 'Choose a website',
|
||||
linkTitle: 'Import from Link',
|
||||
linkBody:
|
||||
'Paste a direct link to an image or video from this website.',
|
||||
linkLabel: 'Image or video link',
|
||||
linkPlaceholder: 'https://...',
|
||||
linkCompleted: 'Media imported.',
|
||||
loading: 'Loading media...',
|
||||
emptyTitle: 'No Media',
|
||||
emptyBody: 'This website has no media of this type.',
|
||||
loadMore: 'Load More',
|
||||
alreadyImported: 'Imported',
|
||||
failed: 'Failed',
|
||||
completed: 'Imported {imported} media.',
|
||||
partial: 'Imported {imported} of {total} media.',
|
||||
},
|
||||
errors: {
|
||||
cancelled: 'The media action was cancelled.',
|
||||
capture_failed: 'Unable to capture the game view.',
|
||||
invalid_import_request: 'The import request is invalid.',
|
||||
invalid_import_media: 'This media item cannot be imported.',
|
||||
invalid_media_type: 'The media type is invalid.',
|
||||
invalid_upload: 'The upload could not be verified.',
|
||||
invalid_upload_token: 'The upload session is no longer valid.',
|
||||
missing_config: 'Gallery uploads are not configured.',
|
||||
import_media_not_allowed: 'This media item is not allowed.',
|
||||
import_media_too_large: 'This media item is too large.',
|
||||
import_media_unavailable: 'This media item is no longer available.',
|
||||
import_provider_failed: 'The website could not load its media.',
|
||||
import_provider_unauthorized: 'The website credentials are invalid.',
|
||||
import_source_not_found: 'This website is not registered.',
|
||||
import_source_unavailable: 'This website is temporarily unavailable.',
|
||||
invalid_import_url: 'Enter a valid HTTPS media link.',
|
||||
import_url_not_allowed: 'This link is not from the selected website.',
|
||||
import_url_unavailable: 'The linked media could not be reached.',
|
||||
import_size_unavailable: 'The website did not provide the media size.',
|
||||
not_found: 'The media item no longer exists.',
|
||||
operation_in_progress:
|
||||
'Another media operation is already in progress.',
|
||||
|
||||
@@ -8,6 +8,44 @@ export type PhoneMedia = {
|
||||
url: string
|
||||
}
|
||||
|
||||
export type MediaImportSource = {
|
||||
id: string
|
||||
label: string
|
||||
mediaTypes: MediaType[]
|
||||
}
|
||||
|
||||
export type MediaImportSources = {
|
||||
maxSelection: number
|
||||
sources: MediaImportSource[]
|
||||
}
|
||||
|
||||
export type ExternalMedia = {
|
||||
externalId: string
|
||||
filename: string
|
||||
imported: boolean
|
||||
mediaType: MediaType
|
||||
size: number
|
||||
sourceId: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export type MediaImportPage = {
|
||||
hasMore: boolean
|
||||
items: ExternalMedia[]
|
||||
page: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export type MediaImportFailure = {
|
||||
error: string
|
||||
externalId: string
|
||||
}
|
||||
|
||||
export type MediaImportResult = {
|
||||
failed: MediaImportFailure[]
|
||||
imported: PhoneMedia[]
|
||||
}
|
||||
|
||||
export type UploadReady = {
|
||||
captureToken: string
|
||||
correlationId: string
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
filterMedia,
|
||||
formatMediaSize,
|
||||
formatRecordingDuration,
|
||||
hasNextMediaPage,
|
||||
mediaErrorKey,
|
||||
@@ -44,8 +45,19 @@ describe('media utilities', () => {
|
||||
expect(formatRecordingDuration(3_725_000)).toBe('62:05')
|
||||
})
|
||||
|
||||
it('formats imported media sizes with the active locale', () => {
|
||||
expect(formatMediaSize(512, 'en')).toBe('512 B')
|
||||
expect(formatMediaSize(1_572_864, 'en')).toBe('1.5 MB')
|
||||
})
|
||||
|
||||
it('maps unknown server failures to the localized default', () => {
|
||||
expect(mediaErrorKey('upload_timeout')).toBe('upload_timeout')
|
||||
expect(mediaErrorKey('import_media_too_large')).toBe(
|
||||
'import_media_too_large',
|
||||
)
|
||||
expect(mediaErrorKey('import_url_not_allowed')).toBe(
|
||||
'import_url_not_allowed',
|
||||
)
|
||||
expect(mediaErrorKey('private_provider_error')).toBe('request_failed')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -37,14 +37,40 @@ export function formatRecordingDuration(elapsedMs: number): string {
|
||||
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function formatMediaSize(bytes: number, locale: string): string {
|
||||
const safeBytes = Math.max(0, bytes)
|
||||
if (safeBytes < 1024) return `${safeBytes} B`
|
||||
const units = ['KB', 'MB', 'GB']
|
||||
let value = safeBytes / 1024
|
||||
let unit = units[0]
|
||||
for (let index = 1; index < units.length && value >= 1024; index += 1) {
|
||||
value /= 1024
|
||||
unit = units[index]
|
||||
}
|
||||
return `${new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value)} ${unit}`
|
||||
}
|
||||
|
||||
export function mediaErrorKey(error?: string): string {
|
||||
const known = new Set([
|
||||
'cancelled',
|
||||
'capture_failed',
|
||||
'invalid_media_type',
|
||||
'invalid_import_request',
|
||||
'invalid_import_media',
|
||||
'invalid_import_url',
|
||||
'invalid_upload',
|
||||
'invalid_upload_token',
|
||||
'missing_config',
|
||||
'import_media_not_allowed',
|
||||
'import_media_too_large',
|
||||
'import_media_unavailable',
|
||||
'import_provider_failed',
|
||||
'import_provider_unauthorized',
|
||||
'import_source_not_found',
|
||||
'import_source_unavailable',
|
||||
'import_url_not_allowed',
|
||||
'import_url_unavailable',
|
||||
'import_size_unavailable',
|
||||
'not_found',
|
||||
'operation_in_progress',
|
||||
'owner_changed',
|
||||
|
||||
@@ -4,6 +4,9 @@ import {
|
||||
kButton,
|
||||
kDialog,
|
||||
kLink,
|
||||
kList,
|
||||
kListInput,
|
||||
kListItem,
|
||||
kNavbar,
|
||||
kNavbarBackLink,
|
||||
kPage,
|
||||
@@ -12,14 +15,30 @@ import {
|
||||
kSegmentedButton,
|
||||
kToast,
|
||||
} from 'konsta/vue'
|
||||
import { Play, RotateCcw, Share2, Trash2, ZoomIn, ZoomOut } from 'lucide-vue-next'
|
||||
import {
|
||||
ChevronRight,
|
||||
Globe2,
|
||||
Link2,
|
||||
Play,
|
||||
RotateCcw,
|
||||
Share2,
|
||||
Trash2,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { useMessageMediaStore } from '@/stores/messageMedia'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import { useEasyShareStore } from '@/stores/easyshare'
|
||||
import type { DeleteResult, GalleryFilter, PhoneMedia } from '@/types/media'
|
||||
import type {
|
||||
DeleteResult,
|
||||
GalleryFilter,
|
||||
MediaImportSource,
|
||||
MediaImportSources,
|
||||
PhoneMedia,
|
||||
} from '@/types/media'
|
||||
import {
|
||||
hasNextMediaPage,
|
||||
MEDIA_PAGE_SIZE,
|
||||
@@ -60,6 +79,12 @@ const fetching = ref(false)
|
||||
const hasMore = ref(true)
|
||||
const loadError = ref('')
|
||||
const selected = ref<PhoneMedia | null>(null)
|
||||
const importMode = ref<'form' | 'gallery' | 'sources'>('gallery')
|
||||
const importSources = ref<MediaImportSource[]>([])
|
||||
const importSource = ref<MediaImportSource | null>(null)
|
||||
const importUrl = ref('')
|
||||
const importError = ref('')
|
||||
const importing = ref(false)
|
||||
const deleteDialogOpened = ref(false)
|
||||
const cancelButtonColors = {
|
||||
fillBgIos: 'bg-[#8e8e93] active:bg-[#7a7a7f]',
|
||||
@@ -148,6 +173,71 @@ function showToast(text: string): void {
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
async function loadImportSources(): Promise<void> {
|
||||
const response = await nuiCall<MediaImportSources>('media:import:sources')
|
||||
if (!response.success || !response.data) return
|
||||
importSources.value = response.data.sources
|
||||
}
|
||||
|
||||
function selectImportSource(source: MediaImportSource): void {
|
||||
importSource.value = source
|
||||
importMode.value = 'form'
|
||||
importUrl.value = ''
|
||||
importError.value = ''
|
||||
}
|
||||
|
||||
function openImport(): void {
|
||||
if (importSources.value.length === 1) {
|
||||
selectImportSource(importSources.value[0])
|
||||
return
|
||||
}
|
||||
importMode.value = 'sources'
|
||||
}
|
||||
|
||||
function closeImport(): void {
|
||||
importMode.value = 'gallery'
|
||||
importSource.value = null
|
||||
importUrl.value = ''
|
||||
importError.value = ''
|
||||
}
|
||||
|
||||
function backFromImportForm(): void {
|
||||
if (importSources.value.length > 1) {
|
||||
importMode.value = 'sources'
|
||||
importSource.value = null
|
||||
importUrl.value = ''
|
||||
importError.value = ''
|
||||
return
|
||||
}
|
||||
closeImport()
|
||||
}
|
||||
|
||||
function updateImportUrl(event: Event): void {
|
||||
importUrl.value = (event.target as HTMLInputElement).value
|
||||
importError.value = ''
|
||||
}
|
||||
|
||||
async function commitUrlImport(): Promise<void> {
|
||||
const url = importUrl.value.trim()
|
||||
if (!importSource.value || !url || importing.value) return
|
||||
importing.value = true
|
||||
importError.value = ''
|
||||
const response = await nuiCall<PhoneMedia>('media:import:url', {
|
||||
sourceId: importSource.value.id,
|
||||
url,
|
||||
})
|
||||
importing.value = false
|
||||
if (!response.success || !response.data) {
|
||||
importError.value = phone.t(
|
||||
`Apps.photos.errors.${mediaErrorKey(response.error)}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
media.value = mergeMedia(media.value, [response.data])
|
||||
closeImport()
|
||||
showToast(phone.t('Apps.photos.import.linkCompleted'))
|
||||
}
|
||||
|
||||
function formatDate(timestamp: number): string {
|
||||
return new Intl.DateTimeFormat(phone.lang, {
|
||||
dateStyle: 'medium',
|
||||
@@ -421,6 +511,7 @@ watch(hasMore, () => void nextTick().then(observeMore))
|
||||
onMounted(() => {
|
||||
window.addEventListener('message', onMessage)
|
||||
void loadGallery()
|
||||
void loadImportSources()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -434,12 +525,105 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<k-page
|
||||
v-if="!selected"
|
||||
v-if="importMode === 'sources'"
|
||||
class="gallery-import-page !pt-[44px]"
|
||||
:aria-label="phone.t('Apps.photos.import.chooseSource')"
|
||||
>
|
||||
<k-navbar :title="phone.t('Apps.photos.import.title')">
|
||||
<template #left>
|
||||
<k-navbar-back-link
|
||||
component="button"
|
||||
:text="phone.t('Common.back')"
|
||||
@click="closeImport"
|
||||
/>
|
||||
</template>
|
||||
</k-navbar>
|
||||
<k-block class="gallery-import-intro">
|
||||
{{ phone.t('Apps.photos.import.chooseSource') }}
|
||||
</k-block>
|
||||
<k-list inset strong>
|
||||
<k-list-item
|
||||
v-for="source in importSources"
|
||||
:key="source.id"
|
||||
link
|
||||
:chevron="false"
|
||||
:title="source.label"
|
||||
:subtitle="
|
||||
source.mediaTypes
|
||||
.map((type) =>
|
||||
phone.t(
|
||||
type === 'photo'
|
||||
? 'Apps.photos.filters.photos'
|
||||
: 'Apps.photos.filters.videos',
|
||||
),
|
||||
)
|
||||
.join(' · ')
|
||||
"
|
||||
@click="selectImportSource(source)"
|
||||
>
|
||||
<template #media><Globe2 :size="22" /></template>
|
||||
<template #after><ChevronRight :size="18" /></template>
|
||||
</k-list-item>
|
||||
</k-list>
|
||||
</k-page>
|
||||
|
||||
<k-page
|
||||
v-else-if="importMode === 'form' && importSource"
|
||||
class="gallery-import-page !pt-[44px]"
|
||||
:aria-label="phone.t('Apps.photos.import.title')"
|
||||
>
|
||||
<k-navbar :title="importSource.label">
|
||||
<template #left>
|
||||
<k-navbar-back-link
|
||||
component="button"
|
||||
:text="phone.t('Common.back')"
|
||||
@click="backFromImportForm"
|
||||
/>
|
||||
</template>
|
||||
</k-navbar>
|
||||
|
||||
<div class="gallery-import-form">
|
||||
<div class="gallery-import-form-icon"><Link2 :size="34" /></div>
|
||||
<h2>{{ phone.t('Apps.photos.import.linkTitle') }}</h2>
|
||||
<p>{{ phone.t('Apps.photos.import.linkBody') }}</p>
|
||||
<k-list inset strong class="gallery-import-url-list">
|
||||
<k-list-input
|
||||
outline
|
||||
input-id="gallery-import-url"
|
||||
inputmode="url"
|
||||
maxlength="2048"
|
||||
:error="importError || undefined"
|
||||
:label="phone.t('Apps.photos.import.linkLabel')"
|
||||
:placeholder="phone.t('Apps.photos.import.linkPlaceholder')"
|
||||
type="url"
|
||||
:value="importUrl"
|
||||
@input="updateImportUrl"
|
||||
@keyup.enter="commitUrlImport"
|
||||
/>
|
||||
</k-list>
|
||||
<k-button
|
||||
class="gallery-import-submit"
|
||||
large
|
||||
rounded
|
||||
:disabled="!importUrl.trim() || importing"
|
||||
@click="commitUrlImport"
|
||||
>
|
||||
<k-preloader v-if="importing" class="mr-2" />
|
||||
{{ phone.t('Apps.photos.import.action') }}
|
||||
</k-button>
|
||||
</div>
|
||||
</k-page>
|
||||
|
||||
<k-page
|
||||
v-else-if="!selected"
|
||||
class="gallery-page !pt-[44px]"
|
||||
:aria-label="phone.t('Apps.photos.name')"
|
||||
>
|
||||
<k-navbar :title="phone.t('Apps.photos.name')">
|
||||
<template v-if="requestedMessageMedia" #left>
|
||||
<k-navbar
|
||||
v-if="requestedMessageMedia"
|
||||
:title="phone.t('Apps.photos.name')"
|
||||
>
|
||||
<template #left>
|
||||
<k-navbar-back-link
|
||||
component="button"
|
||||
:text="phone.t('Common.back')"
|
||||
@@ -456,6 +640,17 @@ onBeforeUnmount(() => {
|
||||
</k-link>
|
||||
</template>
|
||||
</k-navbar>
|
||||
<k-navbar
|
||||
v-else
|
||||
:title="phone.t('Apps.photos.name')"
|
||||
right-class="!ms-auto"
|
||||
>
|
||||
<template v-if="importSources.length" #right>
|
||||
<k-link component="button" @click="openImport">
|
||||
{{ phone.t('Apps.photos.import.action') }}
|
||||
</k-link>
|
||||
</template>
|
||||
</k-navbar>
|
||||
|
||||
<div class="gallery-content">
|
||||
<div v-if="loading" class="gallery-state">
|
||||
@@ -708,6 +903,54 @@ onBeforeUnmount(() => {
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.gallery-import-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.gallery-import-intro {
|
||||
margin-bottom: 0;
|
||||
color: #8e8e93;
|
||||
}
|
||||
.gallery-import-form {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 48px 16px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
.gallery-import-form-icon {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 22px;
|
||||
background: #0a84ff;
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 28px #0a84ff4d;
|
||||
}
|
||||
.gallery-import-form h2 {
|
||||
margin: 20px 0 7px;
|
||||
font-size: 21px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.gallery-import-form p {
|
||||
max-width: 280px;
|
||||
margin: 0;
|
||||
color: #8e8e93;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.gallery-import-url-list {
|
||||
width: 100%;
|
||||
margin-top: 26px;
|
||||
}
|
||||
.gallery-import-submit {
|
||||
width: calc(100% - 32px);
|
||||
margin-top: 14px;
|
||||
}
|
||||
.gallery-content {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
|
||||
@@ -2336,7 +2336,6 @@ let mockMedia = [
|
||||
url: 'https://picsum.photos/seed/sky-phone-5/800/600',
|
||||
},
|
||||
]
|
||||
|
||||
const weazelNewsCategoryIds = ['official', 'events', 'jobs', 'news', 'business']
|
||||
let weazelNewsSequence = 8
|
||||
let weazelNewsArticles = [
|
||||
@@ -2524,6 +2523,52 @@ function pageWeazelNewsArticles(items, data) {
|
||||
}
|
||||
}
|
||||
|
||||
const mockImportSources = [
|
||||
{
|
||||
id: 'media_archive',
|
||||
label: 'Media Archive',
|
||||
mediaTypes: ['photo', 'video'],
|
||||
},
|
||||
{ id: 'event_cdn', label: 'Event CDN', mediaTypes: ['photo'] },
|
||||
]
|
||||
const mockImportMedia = [
|
||||
{
|
||||
externalId: 'archive-photo-1',
|
||||
filename: 'Vespucci Sunset.jpg',
|
||||
imported: false,
|
||||
mediaType: 'photo',
|
||||
size: 2_481_152,
|
||||
sourceId: 'media_archive',
|
||||
url: 'https://picsum.photos/seed/sky-import-1/900/1200',
|
||||
},
|
||||
{
|
||||
externalId: 'archive-photo-2',
|
||||
filename: 'Downtown Meet.jpg',
|
||||
imported: false,
|
||||
mediaType: 'photo',
|
||||
size: 3_114_205,
|
||||
sourceId: 'media_archive',
|
||||
url: 'https://picsum.photos/seed/sky-import-2/1200/900',
|
||||
},
|
||||
{
|
||||
externalId: 'archive-video-1',
|
||||
filename: 'Flower Clip.mp4',
|
||||
imported: false,
|
||||
mediaType: 'video',
|
||||
size: 8_241_152,
|
||||
sourceId: 'media_archive',
|
||||
url: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4',
|
||||
},
|
||||
{
|
||||
externalId: 'event-photo-1',
|
||||
filename: 'Opening Night.jpg',
|
||||
imported: false,
|
||||
mediaType: 'photo',
|
||||
size: 1_824_331,
|
||||
sourceId: 'event_cdn',
|
||||
url: 'https://picsum.photos/seed/sky-event-1/900/1200',
|
||||
},
|
||||
]
|
||||
const marketplaceInquiries = [
|
||||
{
|
||||
id: '4903b923-409a-437e-971f-b7a2b10e9e31',
|
||||
@@ -7880,6 +7925,79 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
response.json({ success: true, data: media })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'media:import:sources') {
|
||||
response.json({
|
||||
success: true,
|
||||
data: { maxSelection: 10, sources: mockImportSources },
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'media:import:list') {
|
||||
const page = Math.max(1, Number(request.body.page) || 1)
|
||||
const limit = 30
|
||||
const filtered = mockImportMedia.filter(
|
||||
(item) =>
|
||||
item.sourceId === request.body.sourceId &&
|
||||
item.mediaType === request.body.mediaType,
|
||||
)
|
||||
const offset = (page - 1) * limit
|
||||
response.json({
|
||||
success: true,
|
||||
data: {
|
||||
hasMore: offset + limit < filtered.length,
|
||||
items: filtered.slice(offset, offset + limit),
|
||||
page,
|
||||
total: filtered.length,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'media:import:commit') {
|
||||
const externalIds = Array.isArray(request.body.externalIds)
|
||||
? request.body.externalIds
|
||||
: []
|
||||
const imported = []
|
||||
const failed = []
|
||||
for (const externalId of externalIds) {
|
||||
const item = mockImportMedia.find(
|
||||
(candidate) =>
|
||||
candidate.externalId === externalId &&
|
||||
candidate.sourceId === request.body.sourceId,
|
||||
)
|
||||
if (!item) {
|
||||
failed.push({ error: 'import_media_unavailable', externalId })
|
||||
continue
|
||||
}
|
||||
item.imported = true
|
||||
const media = {
|
||||
createdAt: Date.now(),
|
||||
id: Math.max(0, ...mockMedia.map((entry) => Number(entry.id) || 0)) + 1,
|
||||
mediaType: item.mediaType,
|
||||
url: item.url,
|
||||
}
|
||||
mockMedia.unshift(media)
|
||||
imported.push(media)
|
||||
}
|
||||
response.json({ success: true, data: { failed, imported } })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'media:import:url') {
|
||||
const url = String(request.body.url || '').trim()
|
||||
if (!url.startsWith('https://')) {
|
||||
response.json({ success: false, error: 'invalid_import_url' })
|
||||
return
|
||||
}
|
||||
const mediaType = /\.(mp4|webm)(?:[?#]|$)/i.test(url) ? 'video' : 'photo'
|
||||
const media = {
|
||||
createdAt: Date.now(),
|
||||
id: Math.max(0, ...mockMedia.map((entry) => Number(entry.id) || 0)) + 1,
|
||||
mediaType,
|
||||
url,
|
||||
}
|
||||
mockMedia.unshift(media)
|
||||
response.json({ success: true, data: media })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'gallery:list') {
|
||||
if (request.body.mockState === 'error') {
|
||||
response.json({ success: false, error: 'service_unavailable' })
|
||||
|
||||
@@ -1557,10 +1557,26 @@ Locales["en"] = {
|
||||
deleteBody = "This photo or video will be permanently deleted.", deleted = "Media deleted.",
|
||||
zoomIn = "Zoom in", zoomOut = "Zoom out", resetZoom = "Reset zoom",
|
||||
filters = { all = "All", photos = "Photos", videos = "Videos" },
|
||||
import = {
|
||||
action = "Import", title = "Import", chooseSource = "Choose a website",
|
||||
linkTitle = "Import from Link", linkBody = "Paste a direct link to an image or video from this website.",
|
||||
linkLabel = "Image or video link", linkPlaceholder = "https://...", linkCompleted = "Media imported.",
|
||||
loading = "Loading media...", emptyTitle = "No Media",
|
||||
emptyBody = "This website has no media of this type.", loadMore = "Load More",
|
||||
alreadyImported = "Imported", failed = "Failed", completed = "Imported {imported} media.",
|
||||
partial = "Imported {imported} of {total} media.",
|
||||
},
|
||||
errors = {
|
||||
cancelled = "The media action was cancelled.", capture_failed = "Unable to capture the game view.",
|
||||
invalid_import_request = "The import request is invalid.", invalid_import_media = "This media item cannot be imported.",
|
||||
invalid_media_type = "The media type is invalid.", invalid_upload = "The upload could not be verified.",
|
||||
invalid_upload_token = "The upload session is no longer valid.", missing_config = "Gallery uploads are not configured.",
|
||||
import_media_not_allowed = "This media item is not allowed.", import_media_too_large = "This media item is too large.",
|
||||
import_media_unavailable = "This media item is no longer available.", import_provider_failed = "The website could not load its media.",
|
||||
import_provider_unauthorized = "The website credentials are invalid.", import_source_not_found = "This website is not registered.",
|
||||
import_source_unavailable = "This website is temporarily unavailable.",
|
||||
invalid_import_url = "Enter a valid HTTPS media link.", import_url_not_allowed = "This link is not from the selected website.",
|
||||
import_url_unavailable = "The linked media could not be reached.", import_size_unavailable = "The website did not provide the media size.",
|
||||
not_found = "The media item no longer exists.", owner_changed = "The active phone account changed.",
|
||||
operation_in_progress = "Another media operation is already in progress.",
|
||||
rate_limited = "Too many media actions. Try again shortly.", request_failed = "The Gallery request failed.",
|
||||
|
||||
@@ -10,6 +10,48 @@ Config.Media = {
|
||||
RequestTimeoutMs = 10000,
|
||||
UploadTimeoutMs = 25000,
|
||||
},
|
||||
Import = {
|
||||
Enabled = true,
|
||||
PageSize = 30,
|
||||
MaxSelection = 10,
|
||||
MaxPhotoBytes = 15 * 1024 * 1024,
|
||||
MaxVideoBytes = 150 * 1024 * 1024,
|
||||
RevalidateAfterSeconds = 3600,
|
||||
ListActionsPerMinute = 60,
|
||||
ImportActionsPerMinute = 20,
|
||||
CandidateTtlSeconds = 300,
|
||||
ManifestCacheSeconds = 30,
|
||||
ManifestMaxBytes = 2 * 1024 * 1024,
|
||||
ManifestMaxItems = 5000,
|
||||
Websites = {
|
||||
{
|
||||
Id = "fivemanage",
|
||||
Label = "FiveManage",
|
||||
Enabled = true,
|
||||
Adapter = "fivemanage",
|
||||
Path = "sky_phone/imports",
|
||||
MediaTypes = { "photo", "video" },
|
||||
-- Direct links entered in Gallery must use one of these hosts or a subdomain.
|
||||
AllowedMediaHosts = { "fivemanage.com" },
|
||||
},
|
||||
--[[
|
||||
{
|
||||
Id = "city_media",
|
||||
Label = "City Media",
|
||||
Enabled = true,
|
||||
Adapter = "manifest",
|
||||
ManifestUrl = "https://media.example.com/sky-phone/media.json",
|
||||
MediaTypes = { "photo", "video" },
|
||||
AllowedMediaHosts = { "media.example.com", "cdn.example.com" },
|
||||
Auth = {
|
||||
Type = "bearer",
|
||||
TokenConvar = "sky_phone_city_media_token",
|
||||
},
|
||||
RequiredAce = "sky_phone.import.city_media",
|
||||
},
|
||||
]]
|
||||
},
|
||||
},
|
||||
Photo = {
|
||||
Encoding = "jpg",
|
||||
Quality = 0.95,
|
||||
|
||||
@@ -71,6 +71,9 @@ server_scripts {
|
||||
'source/server/sim.lua',
|
||||
'source/server/payphones.lua',
|
||||
'source/server/calls.lua',
|
||||
'source/server/media_import.lua',
|
||||
'source/server/media_import/fivemanage.lua',
|
||||
'source/server/media_import/manifest.lua',
|
||||
'source/server/media.lua',
|
||||
'source/server/weazel_news.lua',
|
||||
'source/server/messages.lua',
|
||||
|
||||
@@ -265,6 +265,10 @@ local server_callbacks = {
|
||||
"flare:send",
|
||||
"gallery:list",
|
||||
"media:config",
|
||||
"media:import:sources",
|
||||
"media:import:list",
|
||||
"media:import:commit",
|
||||
"media:import:url",
|
||||
}
|
||||
|
||||
local function get_locale()
|
||||
@@ -637,6 +641,15 @@ for _, callback_name in ipairs(server_callbacks) do
|
||||
return
|
||||
end
|
||||
local result = Bridge.Callbacks.Trigger("sky_phone:" .. callback_name, data)
|
||||
if callback_name:match("^media:import:") and (not result or not result.success) then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] NUI callback '%s' failed: %s.",
|
||||
callback_name,
|
||||
tostring(result and result.error or "no server response"),
|
||||
{ always = true }
|
||||
)
|
||||
end
|
||||
if type(result) == "table" then
|
||||
cb(result)
|
||||
return
|
||||
|
||||
@@ -378,6 +378,14 @@ local schema = {
|
||||
{ name = "remote_id", type = "VARCHAR(128) NOT NULL" },
|
||||
{ name = "media_type", type = "ENUM('photo', 'video') NOT NULL" },
|
||||
{ name = "mime_type", type = "VARCHAR(120) NULL" },
|
||||
{ name = "origin", type = "ENUM('phone_upload', 'website_import') NOT NULL DEFAULT 'phone_upload'" },
|
||||
{
|
||||
name = "source_id",
|
||||
type = "VARCHAR(64) NULL",
|
||||
characterSet = "ascii",
|
||||
collation = "ascii_bin",
|
||||
},
|
||||
{ name = "verified_at", type = "DATETIME NULL" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
@@ -2603,4 +2611,16 @@ Bridge.Database.EnsureIndex("sky_phone_devices", "uniq_sky_phone_devices_sim", "
|
||||
Bridge.Database.Query("UPDATE `sky_phone_contacts` SET `contact_id` = `id` WHERE `contact_id` IS NULL", {})
|
||||
Bridge.Database.EnsureIndex("sky_phone_contacts", "uniq_sky_phone_contacts_account_contact", "(`account_id`, `contact_id`)", { unique = true })
|
||||
Bridge.Database.EnsureIndex("sky_phone_contacts", "uniq_sky_phone_contacts_device_contact", "(`device_imei`, `contact_id`)", { unique = true })
|
||||
Bridge.Database.EnsureIndex(
|
||||
"sky_phone_media",
|
||||
"uniq_sky_phone_media_account_source",
|
||||
"(`account_id`, `source_id`, `remote_id`, `origin`)",
|
||||
{ unique = true }
|
||||
)
|
||||
Bridge.Database.EnsureIndex(
|
||||
"sky_phone_media",
|
||||
"uniq_sky_phone_media_device_source",
|
||||
"(`device_imei`, `source_id`, `remote_id`, `origin`)",
|
||||
{ unique = true }
|
||||
)
|
||||
Bridge.Database.CompleteMigration("sky_phone")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
Bridge.Database.AfterMigration("sky_phone", function()
|
||||
SkyPhoneMedia = {}
|
||||
SkyPhoneMediaImport.Initialize()
|
||||
|
||||
local pending_uploads = {}
|
||||
local pending_deletes = {}
|
||||
@@ -76,7 +77,7 @@ local function request_presigned_url()
|
||||
if not api_configured() then
|
||||
return nil, "missing_config"
|
||||
end
|
||||
local config = media_config()
|
||||
local config = Config.Media.FiveManage
|
||||
local response = http_request(
|
||||
tostring(config.BaseUrl):gsub("/+$", "") .. "/presigned-url",
|
||||
"GET",
|
||||
@@ -99,9 +100,12 @@ local function get_remote_file(remote_id)
|
||||
if not api_configured() then
|
||||
return nil, "missing_config"
|
||||
end
|
||||
local config = media_config()
|
||||
local config = Config.Media.FiveManage
|
||||
local response = http_request(
|
||||
("%s/%s"):format(tostring(config.BaseUrl):gsub("/+$", ""), remote_id),
|
||||
("%s/%s"):format(
|
||||
tostring(config.BaseUrl):gsub("/+$", ""),
|
||||
SkyPhoneMediaImport.UrlEncode(remote_id)
|
||||
),
|
||||
"GET",
|
||||
"",
|
||||
{ ["Authorization"] = media_api_key() },
|
||||
@@ -114,9 +118,12 @@ local function delete_remote_file(remote_id)
|
||||
if not api_configured() then
|
||||
return false, "missing_config"
|
||||
end
|
||||
local config = media_config()
|
||||
local config = Config.Media.FiveManage
|
||||
local response = http_request(
|
||||
("%s/%s"):format(tostring(config.BaseUrl):gsub("/+$", ""), remote_id),
|
||||
("%s/%s"):format(
|
||||
tostring(config.BaseUrl):gsub("/+$", ""),
|
||||
SkyPhoneMediaImport.UrlEncode(remote_id)
|
||||
),
|
||||
"DELETE",
|
||||
"",
|
||||
{ ["Authorization"] = media_api_key() },
|
||||
@@ -171,7 +178,9 @@ function SkyPhoneMedia.ResolveOwnedMedia(source, media_id, media_type)
|
||||
params[#params + 1] = value
|
||||
end
|
||||
local rows = Bridge.Database.Query(([[
|
||||
SELECT `url`, `media_type`, `mime_type` FROM `sky_phone_media`
|
||||
SELECT `url`, `media_type`, `mime_type`, `origin`, `source_id`, `remote_id`,
|
||||
UNIX_TIMESTAMP(`verified_at`) AS `verified_at`
|
||||
FROM `sky_phone_media`
|
||||
WHERE `id` = ? AND %s
|
||||
LIMIT 1
|
||||
]]):format(condition), params)
|
||||
@@ -190,6 +199,34 @@ function SkyPhoneMedia.ResolveOwnedMedia(source, media_id, media_type)
|
||||
)
|
||||
return nil, "invalid_attachment"
|
||||
end
|
||||
if media.origin == "website_import" then
|
||||
local verified_at = tonumber(media.verified_at) or 0
|
||||
local revalidate_after = math.max(
|
||||
1,
|
||||
math.floor(tonumber(Config.Media.Import.RevalidateAfterSeconds) or 3600)
|
||||
)
|
||||
if os.time() - verified_at >= revalidate_after then
|
||||
local refreshed, refresh_error
|
||||
if type(media.remote_id) == "string" and media.remote_id:sub(1, 4) == "url:" then
|
||||
refreshed, refresh_error = SkyPhoneMediaImport.ResolveUrl(media.source_id, media.url)
|
||||
else
|
||||
refreshed, refresh_error = SkyPhoneMediaImport.Resolve(media.source_id, media.remote_id)
|
||||
end
|
||||
if not refreshed then
|
||||
return nil, refresh_error
|
||||
end
|
||||
if refreshed.mediaType ~= media_type then
|
||||
return nil, "import_media_not_allowed"
|
||||
end
|
||||
Bridge.Database.Query([[
|
||||
UPDATE `sky_phone_media`
|
||||
SET `url` = ?, `mime_type` = ?, `verified_at` = CURRENT_TIMESTAMP
|
||||
WHERE `id` = ? AND `origin` = 'website_import'
|
||||
]], { refreshed.url, refreshed.mimeType, id })
|
||||
media.url = refreshed.url
|
||||
media.mime_type = refreshed.mimeType
|
||||
end
|
||||
end
|
||||
return media.url, nil, media.mime_type
|
||||
end
|
||||
|
||||
@@ -499,7 +536,7 @@ RegisterNetEvent("sky_phone:media:request-upload", function(data)
|
||||
photo = Config.Media.Photo,
|
||||
presignedUrl = presigned_url,
|
||||
requestId = request_id,
|
||||
uploadTimeoutMs = media_config().UploadTimeoutMs,
|
||||
uploadTimeoutMs = Config.Media.FiveManage.UploadTimeoutMs,
|
||||
video = Config.Media.Video,
|
||||
})
|
||||
end)
|
||||
@@ -604,7 +641,7 @@ RegisterNetEvent("sky_phone:media:delete", function(data)
|
||||
query_params[#query_params + 1] = value
|
||||
end
|
||||
local rows = Bridge.Database.Query(([[
|
||||
SELECT `id`, `remote_id` FROM `sky_phone_media`
|
||||
SELECT `id`, `remote_id`, `origin` FROM `sky_phone_media`
|
||||
WHERE `id` = ? AND %s LIMIT 1
|
||||
]]):format(condition), query_params)
|
||||
local row = rows[1]
|
||||
@@ -612,32 +649,35 @@ RegisterNetEvent("sky_phone:media:delete", function(data)
|
||||
delete_result(src, correlation_id, false, "not_found", media_id)
|
||||
return
|
||||
end
|
||||
if pending_deletes[row.remote_id] then
|
||||
local delete_key = row.origin == "phone_upload" and row.remote_id or ("import:%s"):format(media_id)
|
||||
if pending_deletes[delete_key] then
|
||||
delete_result(src, correlation_id, false, "operation_in_progress", media_id)
|
||||
return
|
||||
end
|
||||
pending_deletes[row.remote_id] = src
|
||||
local references = Bridge.Database.Query(
|
||||
"SELECT COUNT(*) AS `count` FROM `sky_phone_media` WHERE `remote_id` = ?",
|
||||
{ row.remote_id }
|
||||
)
|
||||
if (tonumber(references[1] and references[1].count) or 0) <= 1 then
|
||||
local deleted, delete_error = delete_remote_file(row.remote_id)
|
||||
if not deleted then
|
||||
pending_deletes[row.remote_id] = nil
|
||||
delete_result(src, correlation_id, false, delete_error, media_id)
|
||||
return
|
||||
pending_deletes[delete_key] = src
|
||||
if row.origin == "phone_upload" then
|
||||
local references = Bridge.Database.Query(
|
||||
"SELECT COUNT(*) AS `count` FROM `sky_phone_media` WHERE `remote_id` = ?",
|
||||
{ row.remote_id }
|
||||
)
|
||||
if (tonumber(references[1] and references[1].count) or 0) <= 1 then
|
||||
local deleted, delete_error = delete_remote_file(row.remote_id)
|
||||
if not deleted then
|
||||
pending_deletes[delete_key] = nil
|
||||
delete_result(src, correlation_id, false, delete_error, media_id)
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
Bridge.Database.Query(("DELETE FROM `sky_phone_media` WHERE `id` = ? AND %s"):format(condition), query_params)
|
||||
pending_deletes[row.remote_id] = nil
|
||||
pending_deletes[delete_key] = nil
|
||||
delete_result(src, correlation_id, true, nil, media_id)
|
||||
end)
|
||||
|
||||
function SkyPhoneMedia.GetDeviceRemoteIds(imei)
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `id`, `remote_id` FROM `sky_phone_media`
|
||||
WHERE `account_id` IS NULL AND `device_imei` = ?
|
||||
WHERE `account_id` IS NULL AND `device_imei` = ? AND `origin` = 'phone_upload'
|
||||
]], { imei })
|
||||
return rows
|
||||
end
|
||||
@@ -678,7 +718,7 @@ AddEventHandler("playerDropped", function()
|
||||
end)
|
||||
|
||||
if not api_configured() then
|
||||
print(("^3[sky_phone] Camera and Gallery uploads are disabled until the %s server convar is set.^7")
|
||||
print(("^3[sky_phone] Camera uploads and FiveManage imports are disabled until the %s server convar is set.^7")
|
||||
:format(tostring(media_config().ApiKeyConvar)))
|
||||
end
|
||||
end)
|
||||
|
||||
@@ -0,0 +1,746 @@
|
||||
SkyPhoneMediaImport = {}
|
||||
|
||||
local adapters = {}
|
||||
local websites = {}
|
||||
local import_candidates = {}
|
||||
local initialized = false
|
||||
local media_types_by_mime = {
|
||||
["image/gif"] = "photo",
|
||||
["image/jpeg"] = "photo",
|
||||
["image/png"] = "photo",
|
||||
["image/webp"] = "photo",
|
||||
["video/mp4"] = "video",
|
||||
["video/quicktime"] = "video",
|
||||
["video/webm"] = "video",
|
||||
}
|
||||
|
||||
local function session_owner(source)
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
if not session then
|
||||
return nil, error_response
|
||||
end
|
||||
|
||||
local device = SkyPhone.LoadDevice(session.imei)
|
||||
if not device then
|
||||
return nil, { success = false, error = "device_not_found" }
|
||||
end
|
||||
|
||||
return {
|
||||
account_id = device.account_id and tonumber(device.account_id) or nil,
|
||||
imei = session.imei,
|
||||
}
|
||||
end
|
||||
|
||||
local function owner_condition(owner)
|
||||
if owner.account_id then
|
||||
return "`account_id` = ?", { owner.account_id }
|
||||
end
|
||||
|
||||
return "`account_id` IS NULL AND `device_imei` = ?", { owner.imei }
|
||||
end
|
||||
|
||||
local function valid_source_id(value)
|
||||
return type(value) == "string"
|
||||
and #value >= 1
|
||||
and #value <= 64
|
||||
and value:match("^[a-z0-9_%-]+$") ~= nil
|
||||
end
|
||||
|
||||
local function valid_external_id(value)
|
||||
return type(value) == "string"
|
||||
and #value >= 1
|
||||
and #value <= 128
|
||||
and value:match("^[%w_.:%-]+$") ~= nil
|
||||
end
|
||||
|
||||
local function website_accessible(source, website)
|
||||
return not website.RequiredAce or website.RequiredAce == "" or IsPlayerAceAllowed(source, website.RequiredAce)
|
||||
end
|
||||
|
||||
local function media_type_set(values)
|
||||
local allowed = {}
|
||||
if type(values) ~= "table" then
|
||||
return allowed
|
||||
end
|
||||
|
||||
for _, value in ipairs(values) do
|
||||
if value == "photo" or value == "video" then
|
||||
allowed[value] = true
|
||||
end
|
||||
end
|
||||
return allowed
|
||||
end
|
||||
|
||||
local function url_host(value)
|
||||
if type(value) ~= "string" or #value > Config.Media.UrlMaxLength or value:find("%c") then
|
||||
return nil
|
||||
end
|
||||
|
||||
local authority = value:match("^https://([^/%?#]+)")
|
||||
if not authority or authority:find("@", 1, true) then
|
||||
return nil
|
||||
end
|
||||
|
||||
local host = authority:match("^([^:]+)")
|
||||
return host and host:lower() or nil
|
||||
end
|
||||
|
||||
function SkyPhoneMediaImport.ResponseHeader(headers, name)
|
||||
if type(headers) ~= "table" then
|
||||
return nil
|
||||
end
|
||||
local requested_name = name:lower()
|
||||
for header_name, value in pairs(headers) do
|
||||
if type(header_name) == "string" and header_name:lower() == requested_name then
|
||||
if type(value) == "table" then
|
||||
return value[1]
|
||||
end
|
||||
return value
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function allowed_host(website, host)
|
||||
for _, configured_host in ipairs(website.AllowedMediaHosts) do
|
||||
local candidate = type(configured_host) == "string" and configured_host:lower():gsub("^%.", "") or ""
|
||||
if candidate ~= "" and (host == candidate or host:sub(-#candidate - 1) == "." .. candidate) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function normalize_media(website, item)
|
||||
if type(item) ~= "table" or not valid_external_id(item.externalId) then
|
||||
return nil, "invalid_import_media"
|
||||
end
|
||||
|
||||
local media_type = item.mediaType
|
||||
if not website._media_types[media_type] then
|
||||
return nil, "import_media_not_allowed"
|
||||
end
|
||||
|
||||
local mime_type = type(item.mimeType) == "string"
|
||||
and item.mimeType:lower():match("^%s*([^;%s]+)") or nil
|
||||
if not mime_type or media_types_by_mime[mime_type] ~= media_type then
|
||||
local path = type(item.url) == "string" and item.url:match("^https://[^/]+(/[^?#]*)") or nil
|
||||
local extension = path and path:match("%.([%w]+)$") or nil
|
||||
local mime_by_extension = {
|
||||
gif = "image/gif",
|
||||
jpeg = "image/jpeg",
|
||||
jpg = "image/jpeg",
|
||||
mov = "video/quicktime",
|
||||
mp4 = "video/mp4",
|
||||
png = "image/png",
|
||||
webm = "video/webm",
|
||||
webp = "image/webp",
|
||||
}
|
||||
mime_type = extension and mime_by_extension[extension:lower()] or nil
|
||||
end
|
||||
|
||||
local size = tonumber(item.size)
|
||||
if not size or size <= 0 or size ~= math.floor(size) then
|
||||
return nil, "invalid_import_media"
|
||||
end
|
||||
|
||||
local size_limit = media_type == "photo"
|
||||
and tonumber(Config.Media.Import.MaxPhotoBytes)
|
||||
or tonumber(Config.Media.Import.MaxVideoBytes)
|
||||
if not size_limit or size > size_limit then
|
||||
return nil, "import_media_too_large"
|
||||
end
|
||||
|
||||
local host = url_host(item.url)
|
||||
if not host or not allowed_host(website, host) then
|
||||
return nil, "import_media_not_allowed"
|
||||
end
|
||||
|
||||
local filename = type(item.filename) == "string" and item.filename:match("^%s*(.-)%s*$") or ""
|
||||
if filename == "" then
|
||||
filename = item.externalId
|
||||
elseif #filename > 160 then
|
||||
filename = filename:sub(1, 160)
|
||||
end
|
||||
|
||||
return {
|
||||
externalId = item.externalId,
|
||||
filename = filename,
|
||||
mediaType = media_type,
|
||||
mimeType = mime_type,
|
||||
size = size,
|
||||
sourceId = website.Id,
|
||||
url = item.url,
|
||||
}
|
||||
end
|
||||
|
||||
local function remember_candidate(source, media)
|
||||
local expires_at = os.time() + math.max(
|
||||
30,
|
||||
math.floor(tonumber(Config.Media.Import.CandidateTtlSeconds) or 300)
|
||||
)
|
||||
import_candidates[source] = import_candidates[source] or {}
|
||||
import_candidates[source][media.sourceId] = import_candidates[source][media.sourceId] or {}
|
||||
import_candidates[source][media.sourceId][media.externalId] = expires_at
|
||||
end
|
||||
|
||||
local function candidate_allowed(source, source_id, external_id)
|
||||
local by_source = import_candidates[source] and import_candidates[source][source_id]
|
||||
local expires_at = by_source and by_source[external_id]
|
||||
if not expires_at or expires_at < os.time() then
|
||||
if by_source then
|
||||
by_source[external_id] = nil
|
||||
end
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function validate_website(definition)
|
||||
if type(definition) ~= "table"
|
||||
or definition.Enabled == false
|
||||
or not valid_source_id(definition.Id)
|
||||
or type(definition.Label) ~= "string"
|
||||
or definition.Label == ""
|
||||
or #definition.Label > 64
|
||||
or type(definition.Adapter) ~= "string"
|
||||
then
|
||||
return nil, "invalid_definition"
|
||||
end
|
||||
|
||||
local adapter = adapters[definition.Adapter]
|
||||
if not adapter then
|
||||
return nil, "unknown_adapter"
|
||||
end
|
||||
|
||||
if type(definition.AllowedMediaHosts) ~= "table" or #definition.AllowedMediaHosts < 1 then
|
||||
return nil, "missing_allowed_hosts"
|
||||
end
|
||||
|
||||
local allowed_media_types = media_type_set(definition.MediaTypes)
|
||||
if not allowed_media_types.photo and not allowed_media_types.video then
|
||||
return nil, "missing_media_types"
|
||||
end
|
||||
|
||||
if definition.RequiredAce ~= nil and type(definition.RequiredAce) ~= "string" then
|
||||
return nil, "invalid_required_ace"
|
||||
end
|
||||
|
||||
definition._adapter = adapter
|
||||
definition._media_types = allowed_media_types
|
||||
local valid, validation_error = adapter.Validate(definition)
|
||||
if not valid then
|
||||
return nil, validation_error
|
||||
end
|
||||
|
||||
return definition
|
||||
end
|
||||
|
||||
local function build_registry()
|
||||
websites = {}
|
||||
local config = Config.Media.Import
|
||||
if not config.Enabled then
|
||||
return
|
||||
end
|
||||
|
||||
for index, definition in ipairs(config.Websites or {}) do
|
||||
local website, website_error = validate_website(definition)
|
||||
if website then
|
||||
if websites[website.Id] then
|
||||
error(("[sky_phone] Duplicate media import website id '%s'."):format(website.Id))
|
||||
end
|
||||
websites[website.Id] = website
|
||||
else
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] Media import website at index %s was disabled: %s.",
|
||||
tostring(index),
|
||||
tostring(website_error)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function find_imported(owner, source_id, items)
|
||||
if #items == 0 then
|
||||
return
|
||||
end
|
||||
|
||||
local condition, params = owner_condition(owner)
|
||||
params[#params + 1] = source_id
|
||||
local placeholders = {}
|
||||
for index, item in ipairs(items) do
|
||||
placeholders[index] = "?"
|
||||
params[#params + 1] = item.externalId
|
||||
end
|
||||
|
||||
local rows = Bridge.Database.Query(([[
|
||||
SELECT `remote_id` FROM `sky_phone_media`
|
||||
WHERE %s AND `origin` = 'website_import' AND `source_id` = ?
|
||||
AND `remote_id` IN (%s)
|
||||
]]):format(condition, table.concat(placeholders, ", ")), params)
|
||||
local imported = {}
|
||||
for _, row in ipairs(rows) do
|
||||
imported[row.remote_id] = true
|
||||
end
|
||||
for _, item in ipairs(items) do
|
||||
item.imported = imported[item.externalId] or false
|
||||
end
|
||||
end
|
||||
|
||||
local function select_owned_import(owner, source_id, remote_id)
|
||||
local condition, params = owner_condition(owner)
|
||||
params[#params + 1] = source_id
|
||||
params[#params + 1] = remote_id
|
||||
local rows = Bridge.Database.Query(([[
|
||||
SELECT `id`, `url`, `media_type` AS `mediaType`, `mime_type` AS `mimeType`,
|
||||
UNIX_TIMESTAMP(`created_at`) * 1000 AS `createdAt`
|
||||
FROM `sky_phone_media`
|
||||
WHERE %s AND `origin` = 'website_import'
|
||||
AND `source_id` = ? AND `remote_id` = ?
|
||||
LIMIT 1
|
||||
]]):format(condition), params)
|
||||
local row = rows[1]
|
||||
if row then
|
||||
row.id = tonumber(row.id)
|
||||
row.createdAt = tonumber(row.createdAt) or 0
|
||||
end
|
||||
return row
|
||||
end
|
||||
|
||||
local function store_import(owner, media)
|
||||
local existing = select_owned_import(owner, media.sourceId, media.externalId)
|
||||
if existing then
|
||||
Bridge.Database.Query([[
|
||||
UPDATE `sky_phone_media`
|
||||
SET `url` = ?, `media_type` = ?, `mime_type` = ?, `verified_at` = CURRENT_TIMESTAMP
|
||||
WHERE `id` = ?
|
||||
]], { media.url, media.mediaType, media.mimeType, existing.id })
|
||||
existing.url = media.url
|
||||
existing.mediaType = media.mediaType
|
||||
existing.mimeType = media.mimeType
|
||||
return existing
|
||||
end
|
||||
|
||||
local result
|
||||
if owner.account_id then
|
||||
result = Bridge.Database.Query([[
|
||||
INSERT IGNORE INTO `sky_phone_media`
|
||||
(`account_id`, `device_imei`, `url`, `remote_id`, `media_type`, `mime_type`, `origin`, `source_id`, `verified_at`)
|
||||
VALUES (?, NULL, ?, ?, ?, ?, 'website_import', ?, CURRENT_TIMESTAMP)
|
||||
]], { owner.account_id, media.url, media.externalId, media.mediaType, media.mimeType, media.sourceId })
|
||||
else
|
||||
result = Bridge.Database.Query([[
|
||||
INSERT IGNORE INTO `sky_phone_media`
|
||||
(`account_id`, `device_imei`, `url`, `remote_id`, `media_type`, `mime_type`, `origin`, `source_id`, `verified_at`)
|
||||
VALUES (NULL, ?, ?, ?, ?, ?, 'website_import', ?, CURRENT_TIMESTAMP)
|
||||
]], { owner.imei, media.url, media.externalId, media.mediaType, media.mimeType, media.sourceId })
|
||||
end
|
||||
|
||||
local media_id = type(result) == "number" and result or (type(result) == "table" and tonumber(result.insertId))
|
||||
if media_id and media_id < 1 then
|
||||
media_id = nil
|
||||
end
|
||||
if not media_id then
|
||||
local concurrent = select_owned_import(owner, media.sourceId, media.externalId)
|
||||
if concurrent then
|
||||
return concurrent
|
||||
end
|
||||
return nil, "request_failed"
|
||||
end
|
||||
|
||||
return {
|
||||
createdAt = os.time() * 1000,
|
||||
id = media_id,
|
||||
mediaType = media.mediaType,
|
||||
url = media.url,
|
||||
}
|
||||
end
|
||||
|
||||
function SkyPhoneMediaImport.RegisterAdapter(name, adapter)
|
||||
assert(type(name) == "string" and name ~= "", "Media import adapter name must be a string")
|
||||
assert(type(adapter) == "table", "Media import adapter must be a table")
|
||||
assert(type(adapter.Validate) == "function", "Media import adapter requires Validate")
|
||||
assert(type(adapter.List) == "function", "Media import adapter requires List")
|
||||
assert(type(adapter.Resolve) == "function", "Media import adapter requires Resolve")
|
||||
assert(not adapters[name], ("Media import adapter '%s' is already registered"):format(name))
|
||||
adapters[name] = adapter
|
||||
end
|
||||
|
||||
function SkyPhoneMediaImport.HttpRequest(url, headers, timeout_ms, method)
|
||||
local request = promise.new()
|
||||
local settled = false
|
||||
local request_method = method or "GET"
|
||||
local request_host = url_host(url) or "invalid-host"
|
||||
PerformHttpRequest(url, function(status, response_body, response_headers, error_data)
|
||||
if settled then
|
||||
return
|
||||
end
|
||||
settled = true
|
||||
local response_status = tonumber(status) or 0
|
||||
if response_status == 0 then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] Media import HTTP %s request to '%s' failed: %s.",
|
||||
request_method,
|
||||
request_host,
|
||||
tostring(error_data or "unknown transport error"),
|
||||
{ always = true }
|
||||
)
|
||||
elseif response_status >= 400 then
|
||||
Bridge.Debug(
|
||||
"debug",
|
||||
"[sky_phone] Media import HTTP %s request to '%s' returned status %s.",
|
||||
request_method,
|
||||
request_host,
|
||||
tostring(response_status)
|
||||
)
|
||||
end
|
||||
request:resolve({
|
||||
body = response_body or "",
|
||||
error = error_data,
|
||||
headers = response_headers or {},
|
||||
status = response_status,
|
||||
})
|
||||
end, request_method, "", headers or {}, { followLocation = false })
|
||||
SetTimeout(timeout_ms, function()
|
||||
if settled then
|
||||
return
|
||||
end
|
||||
settled = true
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] Media import HTTP %s request to '%s' timed out after %s ms.",
|
||||
request_method,
|
||||
request_host,
|
||||
tostring(timeout_ms),
|
||||
{ always = true }
|
||||
)
|
||||
request:resolve({ body = "", error = "request timeout", headers = {}, status = 0 })
|
||||
end)
|
||||
return Citizen.Await(request)
|
||||
end
|
||||
|
||||
function SkyPhoneMediaImport.ResolveUrl(source_id, url)
|
||||
if not initialized or not valid_source_id(source_id) or type(url) ~= "string" then
|
||||
return nil, "invalid_import_url"
|
||||
end
|
||||
|
||||
local website = websites[source_id]
|
||||
local trimmed_url = url:match("^%s*(.-)%s*$")
|
||||
local host = website and url_host(trimmed_url) or nil
|
||||
if not website or not host or not allowed_host(website, host) then
|
||||
return nil, "import_url_not_allowed"
|
||||
end
|
||||
|
||||
if type(website._adapter.ResolveUrl) == "function" then
|
||||
local item, resolve_error = website._adapter.ResolveUrl(website, trimmed_url)
|
||||
if not item then
|
||||
return nil, resolve_error
|
||||
end
|
||||
return normalize_media(website, item)
|
||||
end
|
||||
|
||||
local response = SkyPhoneMediaImport.HttpRequest(
|
||||
trimmed_url,
|
||||
{},
|
||||
tonumber(website.RequestTimeoutMs or Config.Media.FiveManage.RequestTimeoutMs) or 10000,
|
||||
"HEAD"
|
||||
)
|
||||
if response.status == 0 then
|
||||
return nil, "import_source_unavailable"
|
||||
end
|
||||
if response.status < 200 or response.status >= 300 then
|
||||
return nil, "import_url_unavailable"
|
||||
end
|
||||
|
||||
local content_type = SkyPhoneMediaImport.ResponseHeader(response.headers, "content-type")
|
||||
content_type = type(content_type) == "string" and content_type:lower():match("^%s*([^;%s]+)") or nil
|
||||
local media_type = content_type and media_types_by_mime[content_type] or nil
|
||||
if not media_type or not website._media_types[media_type] then
|
||||
return nil, "import_media_not_allowed"
|
||||
end
|
||||
|
||||
local content_length = tonumber(SkyPhoneMediaImport.ResponseHeader(response.headers, "content-length"))
|
||||
if not content_length or content_length <= 0 or content_length ~= math.floor(content_length) then
|
||||
return nil, "import_size_unavailable"
|
||||
end
|
||||
|
||||
local external_id = ("url:%08x%08x"):format(
|
||||
joaat(trimmed_url) & 0xffffffff,
|
||||
joaat("sky_phone:" .. trimmed_url) & 0xffffffff
|
||||
)
|
||||
local url_path = trimmed_url:match("^https://[^/]+(/[^?#]*)") or ""
|
||||
return normalize_media(website, {
|
||||
externalId = external_id,
|
||||
filename = url_path:match("/([^/]+)$") or external_id,
|
||||
mediaType = media_type,
|
||||
mimeType = content_type,
|
||||
size = content_length,
|
||||
url = trimmed_url,
|
||||
})
|
||||
end
|
||||
|
||||
function SkyPhoneMediaImport.UrlEncode(value)
|
||||
return tostring(value):gsub("\n", "\r\n"):gsub("([^%w%-_%.~])", function(character)
|
||||
return ("%%%02X"):format(character:byte())
|
||||
end)
|
||||
end
|
||||
|
||||
function SkyPhoneMediaImport.Resolve(source_id, external_id)
|
||||
if not initialized or not valid_source_id(source_id) or not valid_external_id(external_id) then
|
||||
return nil, "import_source_unavailable"
|
||||
end
|
||||
|
||||
local website = websites[source_id]
|
||||
if not website then
|
||||
return nil, "import_source_unavailable"
|
||||
end
|
||||
|
||||
local item, resolve_error = website._adapter.Resolve(website, external_id)
|
||||
if not item then
|
||||
return nil, resolve_error
|
||||
end
|
||||
|
||||
return normalize_media(website, item)
|
||||
end
|
||||
|
||||
function SkyPhoneMediaImport.Initialize()
|
||||
assert(not initialized, "Media import was initialized more than once")
|
||||
build_registry()
|
||||
initialized = true
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:media:import:sources", function(source)
|
||||
local owner, error_response = session_owner(source)
|
||||
if not owner then
|
||||
return error_response
|
||||
end
|
||||
|
||||
local sources = {}
|
||||
for _, website in pairs(websites) do
|
||||
if website_accessible(source, website) then
|
||||
local media_types = {}
|
||||
if website._media_types.photo then
|
||||
media_types[#media_types + 1] = "photo"
|
||||
end
|
||||
if website._media_types.video then
|
||||
media_types[#media_types + 1] = "video"
|
||||
end
|
||||
sources[#sources + 1] = {
|
||||
id = website.Id,
|
||||
label = website.Label,
|
||||
mediaTypes = media_types,
|
||||
}
|
||||
end
|
||||
end
|
||||
table.sort(sources, function(left, right)
|
||||
return left.label:lower() < right.label:lower()
|
||||
end)
|
||||
return {
|
||||
success = true,
|
||||
data = {
|
||||
maxSelection = math.max(1, math.floor(tonumber(Config.Media.Import.MaxSelection) or 1)),
|
||||
sources = sources,
|
||||
},
|
||||
}
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:media:import:list", function(source, data)
|
||||
local owner, error_response = session_owner(source)
|
||||
if not owner then
|
||||
return error_response
|
||||
end
|
||||
if not SkyPhone.AllowOperation(
|
||||
source,
|
||||
"media_import_list",
|
||||
tonumber(Config.Media.Import.ListActionsPerMinute) or 60,
|
||||
60
|
||||
) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
|
||||
data = type(data) == "table" and data or {}
|
||||
local website = valid_source_id(data.sourceId) and websites[data.sourceId] or nil
|
||||
local media_type = data.mediaType
|
||||
local page = math.floor(tonumber(data.page) or 1)
|
||||
if not website or not website_accessible(source, website) then
|
||||
return { success = false, error = "import_source_not_found" }
|
||||
end
|
||||
if not website._media_types[media_type] or page < 1 or page > 10000 then
|
||||
return { success = false, error = "invalid_import_request" }
|
||||
end
|
||||
|
||||
local limit = math.max(1, math.min(math.floor(tonumber(Config.Media.Import.PageSize) or 30), 100))
|
||||
local result, list_error = website._adapter.List(website, media_type, page, limit)
|
||||
if not result then
|
||||
return { success = false, error = list_error }
|
||||
end
|
||||
if type(result) ~= "table" or type(result.items) ~= "table" then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] Import source '%s' returned an invalid list response.",
|
||||
website.Id
|
||||
)
|
||||
return { success = false, error = "import_provider_failed" }
|
||||
end
|
||||
|
||||
local items = {}
|
||||
for _, item in ipairs(result.items) do
|
||||
local normalized, normalize_error = normalize_media(website, item)
|
||||
if normalized then
|
||||
items[#items + 1] = normalized
|
||||
remember_candidate(source, normalized)
|
||||
else
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] Rejected media '%s' from import source '%s': %s.",
|
||||
tostring(item.externalId),
|
||||
website.Id,
|
||||
tostring(normalize_error)
|
||||
)
|
||||
end
|
||||
end
|
||||
find_imported(owner, website.Id, items)
|
||||
return {
|
||||
success = true,
|
||||
data = {
|
||||
hasMore = result.hasMore == true,
|
||||
items = items,
|
||||
page = page,
|
||||
total = math.max(0, math.floor(tonumber(result.total) or #items)),
|
||||
},
|
||||
}
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:media:import:commit", function(source, data)
|
||||
local owner, error_response = session_owner(source)
|
||||
if not owner then
|
||||
return error_response
|
||||
end
|
||||
if not SkyPhone.AllowOperation(
|
||||
source,
|
||||
"media_import_commit",
|
||||
tonumber(Config.Media.Import.ImportActionsPerMinute) or 20,
|
||||
60
|
||||
) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
|
||||
data = type(data) == "table" and data or {}
|
||||
local website = valid_source_id(data.sourceId) and websites[data.sourceId] or nil
|
||||
if not website or not website_accessible(source, website) then
|
||||
return { success = false, error = "import_source_not_found" }
|
||||
end
|
||||
if type(data.externalIds) ~= "table" then
|
||||
return { success = false, error = "invalid_import_request" }
|
||||
end
|
||||
|
||||
local maximum = math.max(1, math.floor(tonumber(Config.Media.Import.MaxSelection) or 1))
|
||||
if #data.externalIds < 1 or #data.externalIds > maximum then
|
||||
return { success = false, error = "invalid_import_request" }
|
||||
end
|
||||
|
||||
local unique_ids = {}
|
||||
local requested_ids = {}
|
||||
for _, external_id in ipairs(data.externalIds) do
|
||||
if not valid_external_id(external_id) or unique_ids[external_id] then
|
||||
return { success = false, error = "invalid_import_request" }
|
||||
end
|
||||
if not candidate_allowed(source, website.Id, external_id) then
|
||||
return { success = false, error = "invalid_import_request" }
|
||||
end
|
||||
unique_ids[external_id] = true
|
||||
requested_ids[#requested_ids + 1] = external_id
|
||||
end
|
||||
|
||||
local imported = {}
|
||||
local failed = {}
|
||||
for _, external_id in ipairs(requested_ids) do
|
||||
local item, resolve_error = website._adapter.Resolve(website, external_id)
|
||||
local normalized, normalize_error
|
||||
if item then
|
||||
normalized, normalize_error = normalize_media(website, item)
|
||||
end
|
||||
if not normalized then
|
||||
failed[#failed + 1] = {
|
||||
error = resolve_error or normalize_error or "import_provider_failed",
|
||||
externalId = external_id,
|
||||
}
|
||||
else
|
||||
local stored, store_error = store_import(owner, normalized)
|
||||
if stored then
|
||||
imported[#imported + 1] = stored
|
||||
else
|
||||
failed[#failed + 1] = {
|
||||
error = store_error or "request_failed",
|
||||
externalId = external_id,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
success = true,
|
||||
data = {
|
||||
failed = failed,
|
||||
imported = imported,
|
||||
},
|
||||
}
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:media:import:url", function(source, data)
|
||||
local owner, error_response = session_owner(source)
|
||||
if not owner then
|
||||
return error_response
|
||||
end
|
||||
if not SkyPhone.AllowOperation(
|
||||
source,
|
||||
"media_import_url",
|
||||
tonumber(Config.Media.Import.ImportActionsPerMinute) or 20,
|
||||
60
|
||||
) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
|
||||
data = type(data) == "table" and data or {}
|
||||
local website = valid_source_id(data.sourceId) and websites[data.sourceId] or nil
|
||||
if not website or not website_accessible(source, website) then
|
||||
return { success = false, error = "import_source_not_found" }
|
||||
end
|
||||
if type(data.url) ~= "string" or #data.url < 1 or #data.url > Config.Media.UrlMaxLength then
|
||||
return { success = false, error = "invalid_import_url" }
|
||||
end
|
||||
|
||||
local normalized, resolve_error = SkyPhoneMediaImport.ResolveUrl(website.Id, data.url)
|
||||
if not normalized then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] Media URL import failed for player %s, source '%s', host '%s': %s.",
|
||||
tostring(source),
|
||||
website.Id,
|
||||
tostring(url_host(data.url) or "invalid-host"),
|
||||
tostring(resolve_error),
|
||||
{ always = true }
|
||||
)
|
||||
return { success = false, error = resolve_error }
|
||||
end
|
||||
Bridge.Debug(
|
||||
"debug",
|
||||
"[sky_phone] Media URL import resolved for player %s via source '%s' as %s '%s' (%s bytes).",
|
||||
tostring(source),
|
||||
website.Id,
|
||||
normalized.mediaType,
|
||||
normalized.externalId,
|
||||
tostring(normalized.size)
|
||||
)
|
||||
local stored, store_error = store_import(owner, normalized)
|
||||
if not stored then
|
||||
return { success = false, error = store_error or "request_failed" }
|
||||
end
|
||||
return { success = true, data = stored }
|
||||
end)
|
||||
|
||||
AddEventHandler("playerDropped", function()
|
||||
import_candidates[source] = nil
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,240 @@
|
||||
local function api_key(website)
|
||||
local convar = website.ApiKeyConvar or Config.Media.FiveManage.ApiKeyConvar
|
||||
if type(convar) ~= "string" or convar == "" then
|
||||
return ""
|
||||
end
|
||||
return GetConvar(convar, "")
|
||||
end
|
||||
|
||||
local function provider_error(response, not_found_error)
|
||||
if response.status == 0 then
|
||||
return "import_source_unavailable"
|
||||
end
|
||||
if response.status == 401 or response.status == 403 then
|
||||
return "import_provider_unauthorized"
|
||||
end
|
||||
if response.status == 404 and not_found_error then
|
||||
return not_found_error
|
||||
end
|
||||
return "import_provider_failed"
|
||||
end
|
||||
|
||||
local function decode_response(response, not_found_error)
|
||||
if type(response) ~= "table" or response.status < 200 or response.status >= 300 then
|
||||
return nil, provider_error(response or { status = 0 }, not_found_error)
|
||||
end
|
||||
|
||||
local success, decoded = pcall(json.decode, response.body or "")
|
||||
if not success or type(decoded) ~= "table" then
|
||||
return nil, "import_provider_failed"
|
||||
end
|
||||
return decoded
|
||||
end
|
||||
|
||||
local function media_type(value)
|
||||
local normalized = type(value) == "string" and value:lower() or ""
|
||||
if normalized == "image" or normalized:find("image/", 1, true) == 1 then
|
||||
return "photo"
|
||||
end
|
||||
if normalized == "video" or normalized:find("video/", 1, true) == 1 then
|
||||
return "video"
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local media_extensions = {
|
||||
gif = true,
|
||||
jpeg = true,
|
||||
jpg = true,
|
||||
mov = true,
|
||||
mp4 = true,
|
||||
png = true,
|
||||
webm = true,
|
||||
webp = true,
|
||||
}
|
||||
|
||||
local function public_file_id(url)
|
||||
local path = type(url) == "string" and url:match("^https://[^/%?#]+(/[^?#]*)") or nil
|
||||
local segment = path and path:match("/([^/]+)$") or nil
|
||||
if not segment or segment == "" or segment:find("%", 1, true) then
|
||||
return nil
|
||||
end
|
||||
|
||||
local extension = segment:match("%.([%w]+)$")
|
||||
if extension and media_extensions[extension:lower()] then
|
||||
segment = segment:sub(1, -#extension - 2)
|
||||
end
|
||||
if #segment < 1 or #segment > 128 or not segment:match("^[%w_%-]+$") then
|
||||
return nil
|
||||
end
|
||||
return segment
|
||||
end
|
||||
|
||||
local function normalize_file(file)
|
||||
if type(file) ~= "table" then
|
||||
return {}
|
||||
end
|
||||
return {
|
||||
externalId = file.id,
|
||||
filename = file.filename,
|
||||
mediaType = media_type(file.type or file.mimeType),
|
||||
mimeType = file.mimeType or file.type,
|
||||
size = file.size,
|
||||
url = file.url,
|
||||
}
|
||||
end
|
||||
|
||||
local function resolve_file(website, external_id)
|
||||
local provider_url = tostring(website.BaseUrl or Config.Media.FiveManage.BaseUrl):gsub("/+$", "")
|
||||
local response = SkyPhoneMediaImport.HttpRequest(
|
||||
("%s/%s"):format(provider_url, SkyPhoneMediaImport.UrlEncode(external_id)),
|
||||
{ ["Authorization"] = api_key(website) },
|
||||
tonumber(website.RequestTimeoutMs or Config.Media.FiveManage.RequestTimeoutMs) or 10000
|
||||
)
|
||||
local decoded, response_error = decode_response(response, "import_media_unavailable")
|
||||
if not decoded then
|
||||
return nil, response_error
|
||||
end
|
||||
|
||||
local file = type(decoded.data) == "table" and decoded.data or decoded
|
||||
if file.id ~= external_id then
|
||||
return nil, "invalid_import_media"
|
||||
end
|
||||
return normalize_file(file)
|
||||
end
|
||||
|
||||
local function probe_public_url(website, url)
|
||||
local timeout = tonumber(website.RequestTimeoutMs or Config.Media.FiveManage.RequestTimeoutMs) or 10000
|
||||
local response = SkyPhoneMediaImport.HttpRequest(url, {}, timeout, "HEAD")
|
||||
local content_type = SkyPhoneMediaImport.ResponseHeader(response.headers, "content-type")
|
||||
local content_length = tonumber(SkyPhoneMediaImport.ResponseHeader(response.headers, "content-length"))
|
||||
|
||||
if response.status == 0 or (response.status >= 200 and response.status < 300
|
||||
and (not content_type or not content_length or content_length <= 0))
|
||||
then
|
||||
Bridge.Debug(
|
||||
"debug",
|
||||
"[sky_phone] FiveManage HEAD probe did not provide usable metadata; trying a one-byte range request."
|
||||
)
|
||||
response = SkyPhoneMediaImport.HttpRequest(url, { ["Range"] = "bytes=0-0" }, timeout)
|
||||
content_type = SkyPhoneMediaImport.ResponseHeader(response.headers, "content-type")
|
||||
local content_range = SkyPhoneMediaImport.ResponseHeader(response.headers, "content-range")
|
||||
content_length = type(content_range) == "string" and tonumber(content_range:match("/(%d+)$"))
|
||||
or tonumber(SkyPhoneMediaImport.ResponseHeader(response.headers, "content-length"))
|
||||
end
|
||||
|
||||
if response.status == 0 then
|
||||
return nil, "import_source_unavailable"
|
||||
end
|
||||
if response.status < 200 or response.status >= 300 then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] FiveManage public URL probe returned HTTP %s.",
|
||||
tostring(response.status),
|
||||
{ always = true }
|
||||
)
|
||||
return nil, "import_url_unavailable"
|
||||
end
|
||||
|
||||
local normalized_type = media_type(content_type)
|
||||
if not normalized_type then
|
||||
return nil, "import_media_not_allowed"
|
||||
end
|
||||
if not content_length or content_length <= 0 or content_length ~= math.floor(content_length) then
|
||||
return nil, "import_size_unavailable"
|
||||
end
|
||||
|
||||
local url_path = url:match("^https://[^/]+(/[^?#]*)") or ""
|
||||
local external_id = ("url:%08x%08x"):format(
|
||||
joaat(url) & 0xffffffff,
|
||||
joaat("sky_phone:" .. url) & 0xffffffff
|
||||
)
|
||||
return {
|
||||
externalId = external_id,
|
||||
filename = url_path:match("/([^/]+)$") or external_id,
|
||||
mediaType = normalized_type,
|
||||
mimeType = type(content_type) == "string"
|
||||
and content_type:lower():match("^%s*([^;%s]+)") or nil,
|
||||
size = content_length,
|
||||
url = url,
|
||||
}
|
||||
end
|
||||
|
||||
SkyPhoneMediaImport.RegisterAdapter("fivemanage", {
|
||||
Validate = function(website)
|
||||
local url = tostring(website.BaseUrl or Config.Media.FiveManage.BaseUrl):gsub("/+$", "")
|
||||
if not url:match("^https://") or #url > Config.Media.UrlMaxLength then
|
||||
return false, "invalid_base_url"
|
||||
end
|
||||
if type(website.Path) ~= "string" or website.Path == "" or #website.Path > 180 then
|
||||
return false, "missing_import_path"
|
||||
end
|
||||
if api_key(website) == "" then
|
||||
return false, "missing_api_key"
|
||||
end
|
||||
return true
|
||||
end,
|
||||
|
||||
List = function(website, requested_type, page, limit)
|
||||
local provider_type = requested_type == "photo" and "image" or "video"
|
||||
local provider_url = tostring(website.BaseUrl or Config.Media.FiveManage.BaseUrl):gsub("/+$", "")
|
||||
local url = ("%s?page=%s&limit=%s&type=%s&path=%s"):format(
|
||||
provider_url,
|
||||
page,
|
||||
limit,
|
||||
provider_type,
|
||||
SkyPhoneMediaImport.UrlEncode(website.Path)
|
||||
)
|
||||
local response = SkyPhoneMediaImport.HttpRequest(
|
||||
url,
|
||||
{ ["Authorization"] = api_key(website) },
|
||||
tonumber(website.RequestTimeoutMs or Config.Media.FiveManage.RequestTimeoutMs) or 10000
|
||||
)
|
||||
local decoded, response_error = decode_response(response)
|
||||
if not decoded then
|
||||
return nil, response_error
|
||||
end
|
||||
|
||||
local files = type(decoded.data) == "table" and decoded.data or {}
|
||||
local items = {}
|
||||
for _, file in ipairs(files) do
|
||||
items[#items + 1] = normalize_file(file)
|
||||
end
|
||||
local pagination = type(decoded.pagination) == "table" and decoded.pagination or {}
|
||||
local total = math.max(0, math.floor(tonumber(pagination.total) or #items))
|
||||
local current_page = math.max(1, math.floor(tonumber(pagination.page) or page))
|
||||
local page_limit = math.max(1, math.floor(tonumber(pagination.limit) or limit))
|
||||
return {
|
||||
hasMore = current_page * page_limit < total,
|
||||
items = items,
|
||||
total = total,
|
||||
}
|
||||
end,
|
||||
|
||||
Resolve = resolve_file,
|
||||
|
||||
ResolveUrl = function(website, url)
|
||||
local external_id = public_file_id(url)
|
||||
if external_id then
|
||||
local file, resolve_error = resolve_file(website, external_id)
|
||||
if file then
|
||||
Bridge.Debug(
|
||||
"debug",
|
||||
"[sky_phone] FiveManage URL resolved through authenticated metadata for file '%s'.",
|
||||
external_id
|
||||
)
|
||||
return file
|
||||
end
|
||||
if resolve_error == "import_provider_unauthorized" then
|
||||
return nil, resolve_error
|
||||
end
|
||||
Bridge.Debug(
|
||||
"debug",
|
||||
"[sky_phone] FiveManage metadata lookup for file '%s' failed with '%s'; probing the public URL.",
|
||||
external_id,
|
||||
tostring(resolve_error)
|
||||
)
|
||||
end
|
||||
return probe_public_url(website, url)
|
||||
end,
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
local manifest_cache = {}
|
||||
|
||||
local function authentication_headers(website)
|
||||
local auth = website.Auth
|
||||
if not auth or auth.Type == nil or auth.Type == "none" then
|
||||
return {}
|
||||
end
|
||||
|
||||
if auth.Type == "bearer" then
|
||||
return { ["Authorization"] = "Bearer " .. GetConvar(auth.TokenConvar, "") }
|
||||
end
|
||||
return { [auth.Header] = GetConvar(auth.ValueConvar, "") }
|
||||
end
|
||||
|
||||
local function validate_authentication(auth)
|
||||
if auth == nil then
|
||||
return true
|
||||
end
|
||||
if type(auth) ~= "table" then
|
||||
return false, "invalid_auth"
|
||||
end
|
||||
if auth.Type == nil or auth.Type == "none" then
|
||||
return true
|
||||
end
|
||||
if auth.Type == "bearer" then
|
||||
if type(auth.TokenConvar) ~= "string" or auth.TokenConvar == ""
|
||||
or GetConvar(auth.TokenConvar, "") == ""
|
||||
then
|
||||
return false, "missing_auth_convar"
|
||||
end
|
||||
return true
|
||||
end
|
||||
if auth.Type == "header" then
|
||||
if type(auth.Header) ~= "string" or not auth.Header:match("^[%w%-]+$")
|
||||
or type(auth.ValueConvar) ~= "string" or auth.ValueConvar == ""
|
||||
or GetConvar(auth.ValueConvar, "") == ""
|
||||
then
|
||||
return false, "invalid_header_auth"
|
||||
end
|
||||
return true
|
||||
end
|
||||
return false, "unknown_auth_type"
|
||||
end
|
||||
|
||||
local function fetch_manifest(website)
|
||||
local now = os.time()
|
||||
local cached = manifest_cache[website.Id]
|
||||
if cached and cached.expires_at > now then
|
||||
return cached.items
|
||||
end
|
||||
|
||||
local response = SkyPhoneMediaImport.HttpRequest(
|
||||
website.ManifestUrl,
|
||||
authentication_headers(website),
|
||||
tonumber(website.RequestTimeoutMs) or 10000
|
||||
)
|
||||
if response.status == 0 then
|
||||
return nil, "import_source_unavailable"
|
||||
end
|
||||
if response.status == 401 or response.status == 403 then
|
||||
return nil, "import_provider_unauthorized"
|
||||
end
|
||||
if response.status < 200 or response.status >= 300 then
|
||||
return nil, "import_provider_failed"
|
||||
end
|
||||
|
||||
local max_bytes = math.max(1024, math.floor(tonumber(Config.Media.Import.ManifestMaxBytes) or 2097152))
|
||||
if #response.body > max_bytes then
|
||||
return nil, "import_provider_failed"
|
||||
end
|
||||
|
||||
local success, decoded = pcall(json.decode, response.body)
|
||||
if not success or type(decoded) ~= "table" or tonumber(decoded.version) ~= 1
|
||||
or type(decoded.items) ~= "table"
|
||||
then
|
||||
return nil, "import_provider_failed"
|
||||
end
|
||||
|
||||
local maximum_items = math.max(1, math.floor(tonumber(Config.Media.Import.ManifestMaxItems) or 5000))
|
||||
if #decoded.items > maximum_items then
|
||||
return nil, "import_provider_failed"
|
||||
end
|
||||
|
||||
local items = {}
|
||||
for _, item in ipairs(decoded.items) do
|
||||
if type(item) == "table" then
|
||||
items[#items + 1] = {
|
||||
externalId = item.id,
|
||||
filename = item.filename,
|
||||
mediaType = item.type,
|
||||
mimeType = item.mimeType,
|
||||
size = item.size,
|
||||
url = item.url,
|
||||
}
|
||||
end
|
||||
end
|
||||
manifest_cache[website.Id] = {
|
||||
expires_at = now + math.max(1, math.floor(tonumber(website.CacheSeconds)
|
||||
or tonumber(Config.Media.Import.ManifestCacheSeconds) or 30)),
|
||||
items = items,
|
||||
}
|
||||
return items
|
||||
end
|
||||
|
||||
SkyPhoneMediaImport.RegisterAdapter("manifest", {
|
||||
Validate = function(website)
|
||||
if type(website.ManifestUrl) ~= "string"
|
||||
or not website.ManifestUrl:match("^https://")
|
||||
or #website.ManifestUrl > Config.Media.UrlMaxLength
|
||||
then
|
||||
return false, "invalid_manifest_url"
|
||||
end
|
||||
return validate_authentication(website.Auth)
|
||||
end,
|
||||
|
||||
List = function(website, requested_type, page, limit)
|
||||
local manifest, manifest_error = fetch_manifest(website)
|
||||
if not manifest then
|
||||
return nil, manifest_error
|
||||
end
|
||||
|
||||
local matching = {}
|
||||
for _, item in ipairs(manifest) do
|
||||
if item.mediaType == requested_type then
|
||||
matching[#matching + 1] = item
|
||||
end
|
||||
end
|
||||
local first = (page - 1) * limit + 1
|
||||
local last = math.min(#matching, first + limit - 1)
|
||||
local items = {}
|
||||
for index = first, last do
|
||||
items[#items + 1] = matching[index]
|
||||
end
|
||||
return {
|
||||
hasMore = last < #matching,
|
||||
items = items,
|
||||
total = #matching,
|
||||
}
|
||||
end,
|
||||
|
||||
Resolve = function(website, external_id)
|
||||
local manifest, manifest_error = fetch_manifest(website)
|
||||
if not manifest then
|
||||
return nil, manifest_error
|
||||
end
|
||||
for _, item in ipairs(manifest) do
|
||||
if item.externalId == external_id then
|
||||
return item
|
||||
end
|
||||
end
|
||||
return nil, "import_media_unavailable"
|
||||
end,
|
||||
})
|
||||
@@ -153,8 +153,14 @@ CREATE TABLE IF NOT EXISTS `sky_phone_media` (
|
||||
`url` TEXT NOT NULL,
|
||||
`remote_id` VARCHAR(128) NOT NULL,
|
||||
`media_type` ENUM('photo', 'video') NOT NULL,
|
||||
`mime_type` VARCHAR(120) NULL,
|
||||
`origin` ENUM('phone_upload', 'website_import') NOT NULL DEFAULT 'phone_upload',
|
||||
`source_id` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
||||
`verified_at` DATETIME NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_sky_phone_media_account_source` (`account_id`, `source_id`, `remote_id`, `origin`),
|
||||
UNIQUE KEY `uniq_sky_phone_media_device_source` (`device_imei`, `source_id`, `remote_id`, `origin`),
|
||||
KEY `idx_sky_phone_media_account` (`account_id`, `created_at`, `id`),
|
||||
KEY `idx_sky_phone_media_device` (`device_imei`, `created_at`, `id`),
|
||||
FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE,
|
||||
|
||||
Reference in New Issue
Block a user