diff --git a/app/components/MediaUploadPanel.vue b/app/components/MediaUploadPanel.vue index 07a908f..1359bc2 100644 --- a/app/components/MediaUploadPanel.vue +++ b/app/components/MediaUploadPanel.vue @@ -8,6 +8,10 @@ const props = defineProps<{ const { t } = useI18n() const toast = useToast() +const { + policy: uploadPolicy, + load: loadUploadPolicy +} = useUploadPolicy() const { uploading, progressItems, @@ -19,20 +23,44 @@ const { } = useMediaUpload(props.kind) onMounted(() => { - void loadActiveTranscodes() + void Promise.all([ + loadActiveTranscodes(), + loadUploadPolicy().catch(() => {}) + ]) }) -const text = computed(() => props.kind === 'video' - ? { - uploadTitle: t('library.video.uploadTitle'), - uploadHint: t('library.video.uploadHint'), - uploadFormats: t('library.video.uploadFormats') - } - : { - uploadTitle: t('library.file.uploadTitle'), - uploadHint: t('library.file.uploadHint'), - uploadFormats: t('library.file.uploadFormats') - }) +const text = computed(() => { + const allowedVideos = uploadPolicy.value?.allowedVideoExtensions ?? [] + const forbiddenFiles = uploadPolicy.value?.forbiddenFileExtensions ?? [] + return props.kind === 'video' + ? { + uploadTitle: t('library.video.uploadTitle'), + uploadHint: t('library.video.uploadHint'), + uploadFormats: allowedVideos.length + ? t('library.video.uploadFormatsAllowed', { + formats: allowedVideos.map(item => item.toUpperCase()).join(' / ') + }) + : t('library.video.uploadFormats') + } + : { + uploadTitle: t('library.file.uploadTitle'), + uploadHint: t('library.file.uploadHint'), + uploadFormats: forbiddenFiles.length + ? t('library.file.uploadFormatsBlocked', { + formats: forbiddenFiles.map(item => item.toUpperCase()).join(' / ') + }) + : t('library.file.uploadFormatsUnrestricted') + } +}) + +const accept = computed(() => { + if (props.kind !== 'video') return undefined + const extensions = uploadPolicy.value?.allowedVideoExtensions ?? [] + if (!extensions.length) return undefined + return extensions + .map(extension => `.${extension}`) + .join(',') +}) const showProgress = computed(() => uploading.value @@ -66,6 +94,7 @@ async function removeItem(item: FileItem) { :title="text.uploadTitle" :hint="text.uploadHint" :formats="text.uploadFormats" + :accept="accept" :disabled="uploading" @upload="uploadFiles" /> diff --git a/app/components/TranscodeSettingsControls.vue b/app/components/TranscodeSettingsControls.vue new file mode 100644 index 0000000..a8f926b --- /dev/null +++ b/app/components/TranscodeSettingsControls.vue @@ -0,0 +1,106 @@ + + + diff --git a/app/components/UploadPreferencesPanel.vue b/app/components/UploadPreferencesPanel.vue index d16a3b2..5f6f5ae 100644 --- a/app/components/UploadPreferencesPanel.vue +++ b/app/components/UploadPreferencesPanel.vue @@ -39,32 +39,6 @@ const { load: loadUserSettings } = useUserAutoDeleteSettings() -const { isAdmin } = useAuth() -const { - concurrency: transcodeConcurrency, - threads: transcodeThreads, - maxConcurrency: maxTranscodeConcurrency, - maxThreads: maxTranscodeThreads, - preset: transcodePreset, - bitratePercent: transcodeBitratePercent, - minBitratePercent, - maxBitratePercent, - load: loadTranscodeSettings -} = useTranscodeSettings() - -const presetItems = computed(() => [ - { value: 'copy', label: t('preferences.presetCopy') }, - { value: 'veryfast', label: t('preferences.presetSpeed') }, - { value: 'medium', label: t('preferences.presetBalanced') }, - { value: 'slow', label: t('preferences.presetQuality') }, - { value: 'compress', label: t('preferences.presetCompress') } -]) - -function onBitrateInput(event: Event) { - const value = Number((event.target as HTMLInputElement).value) - transcodeBitratePercent.value = Number.isFinite(value) ? value : 80 -} - const copyFormatItems = computed(() => [ { label: t('copy.url'), value: 'url' as const }, { label: t('copy.markdown'), value: 'markdown' as const }, @@ -87,14 +61,9 @@ function onClientQualityInput(event: Event) { clientWebpQuality.value = Number.isFinite(value) ? value : 80 } -function selectPreset(value: string) { - transcodePreset.value = value -} - onMounted(() => { loadPreferences() void loadUserSettings() - void loadTranscodeSettings() }) @@ -218,71 +187,7 @@ onMounted(() => { v-if="transcodeToHls" class="ml-7 space-y-3 border-t border-default pt-4" > -
- {{ t('preferences.transcodeConcurrency') }} - - {{ t('preferences.unitCount') }} - {{ t('preferences.concurrentHint') }} -
- -
- {{ t('preferences.transcodeThreads') }} - - {{ t('preferences.unitCount') }} - {{ t('preferences.threadsHint') }} -
- -
- {{ t('preferences.transcodeQuality') }} -
- -
-
- -
- {{ t('preferences.compressBitrate') }} - - - {{ transcodeBitratePercent }}% - -
+ diff --git a/app/components/settings/SettingsDomainConfiguration.vue b/app/components/settings/SettingsDomainConfiguration.vue new file mode 100644 index 0000000..04bd677 --- /dev/null +++ b/app/components/settings/SettingsDomainConfiguration.vue @@ -0,0 +1,182 @@ + + + diff --git a/app/components/settings/SettingsUploadPolicyFields.vue b/app/components/settings/SettingsUploadPolicyFields.vue index 8736344..34ffa87 100644 --- a/app/components/settings/SettingsUploadPolicyFields.vue +++ b/app/components/settings/SettingsUploadPolicyFields.vue @@ -1,11 +1,14 @@ + + diff --git a/app/composables/useAuth.ts b/app/composables/useAuth.ts index cdd7e7c..0f490f3 100644 --- a/app/composables/useAuth.ts +++ b/app/composables/useAuth.ts @@ -218,6 +218,8 @@ export function useAuth() { domainSeparation?: boolean siteBaseUrl?: string imageBaseUrl?: string + videoBaseUrl?: string + fileBaseUrl?: string }): Promise { const validationError = validateAuthInput(input.username, input.password) if (validationError) { @@ -236,7 +238,9 @@ export function useAuth() { allowRegistration: input.allowRegistration, domainSeparation: input.domainSeparation, siteBaseUrl: input.siteBaseUrl, - imageBaseUrl: input.imageBaseUrl + imageBaseUrl: input.imageBaseUrl, + videoBaseUrl: input.videoBaseUrl, + fileBaseUrl: input.fileBaseUrl } } ) diff --git a/app/composables/useMediaUpload.ts b/app/composables/useMediaUpload.ts index b6b2beb..445eb46 100644 --- a/app/composables/useMediaUpload.ts +++ b/app/composables/useMediaUpload.ts @@ -47,6 +47,9 @@ async function copyToClipboard(text: string): Promise { export function useMediaUpload(kind: UploadMediaKind) { const toast = useToast() const { t } = useI18n() + const { + load: loadUploadPolicy + } = useUploadPolicy() const { autoCopyMarkdown, copyFormat, @@ -176,13 +179,45 @@ export function useMediaUpload(kind: UploadMediaKind) { async function uploadFiles(files: File[]) { if (!files.length || uploading.value) return - if (files.length > MAX_FILES) { + const currentPolicy = await loadUploadPolicy().catch(() => null) + const acceptedFiles = files.filter((file) => { + const extension = file.name.split('.').pop()?.toLowerCase() ?? '' + if ( + kind === 'video' + && currentPolicy + && !currentPolicy.allowedVideoExtensions.includes(extension) + ) { + toast.add({ + title: `${file.name}:${t('files.videoFormatBlocked', { + format: extension ? extension.toUpperCase() : '—' + })}`, + color: 'error' + }) + return false + } + if ( + kind === 'file' + && currentPolicy?.forbiddenFileExtensions.includes(extension) + ) { + toast.add({ + title: `${file.name}:${t('files.fileFormatBlocked', { + format: extension.toUpperCase() + })}`, + color: 'error' + }) + return false + } + return true + }) + if (!acceptedFiles.length) return + + if (acceptedFiles.length > MAX_FILES) { toast.add({ title: t('files.maxFiles', { n: MAX_FILES }), color: 'warning' }) return } uploading.value = true - progressItems.value = files.map(file => ({ + progressItems.value = acceptedFiles.map(file => ({ name: file.name, status: 'pending', progress: 0 @@ -190,7 +225,7 @@ export function useMediaUpload(kind: UploadMediaKind) { try { const formData = new FormData() - for (const file of files) { + for (const file of acceptedFiles) { formData.append('files', file, file.name) } @@ -215,7 +250,7 @@ export function useMediaUpload(kind: UploadMediaKind) { await loadApiToken() } - progressItems.value = files.map((file) => { + progressItems.value = acceptedFiles.map((file) => { const failed = response.errors.find(error => error.name === file.name) return { name: file.name, diff --git a/app/composables/useUploadPolicy.ts b/app/composables/useUploadPolicy.ts new file mode 100644 index 0000000..3cb054b --- /dev/null +++ b/app/composables/useUploadPolicy.ts @@ -0,0 +1,40 @@ +interface UploadPolicy { + allowedMimeTypes: string[] + allowedVideoExtensions: string[] + forbiddenFileExtensions: string[] +} + +const policy = ref(null) +const loading = ref(false) +let pending: Promise | null = null + +export function useUploadPolicy() { + async function load(force = false): Promise { + if (policy.value && !force) return policy.value + if (pending && !force) return pending + + loading.value = true + pending = $fetch('/api/upload-policy', { + credentials: 'include' + }) + .then((data) => { + policy.value = { + allowedMimeTypes: [...data.allowedMimeTypes], + allowedVideoExtensions: [...data.allowedVideoExtensions], + forbiddenFileExtensions: [...data.forbiddenFileExtensions] + } + return policy.value + }) + .finally(() => { + loading.value = false + pending = null + }) + return pending + } + + return { + policy, + loading, + load + } +} diff --git a/app/pages/api.vue b/app/pages/api.vue index a75f093..ee2a466 100644 --- a/app/pages/api.vue +++ b/app/pages/api.vue @@ -8,6 +8,8 @@ interface UserTokenResponse { refererConfigured: boolean siteBaseUrl: string imageBaseUrl: string + videoBaseUrl: string + fileBaseUrl: string appVersion: string } } diff --git a/app/pages/settings/index.vue b/app/pages/settings/index.vue index 1689782..2937b82 100644 --- a/app/pages/settings/index.vue +++ b/app/pages/settings/index.vue @@ -1,15 +1,16 @@