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 @@
+
+
+
+
+
+ {{ t('preferences.transcodeConcurrency') }}
+
+ {{ t('preferences.unitCount') }}
+ {{ t('preferences.concurrentHint') }}
+
+
+
+ {{ t('preferences.transcodeThreads') }}
+
+ {{ t('preferences.unitCount') }}
+ {{ t('preferences.threadsHint') }}
+
+
+
+
{{ t('preferences.transcodeQuality') }}
+
+
+
+
+
+
+ {{ t('preferences.compressBitrate') }}
+
+
+ {{ bitratePercent }}%
+
+
+
+
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 @@
+
+
+
+
+
+
+
+ {{ t('settings.siteBaseUrl') }}
+
+
+ {{ t('settings.siteBaseUrlRoleHint') }}
+
+
+
+
+
+
+ {{ t('settings.siteBaseUrlActive', { url: effectiveSiteUrl || currentOrigin }) }}
+
+
+
+
+
+
+
+
+ {{ t('settings.imageBaseUrl') }}
+
+
+
+
+ {{ t('settings.imageBaseUrlRoleHint') }}
+
+
+
+
+
+
+ {{ t('settings.imageBaseUrlActive', {
+ url: effectiveUrl(imageSeparated, imageBaseUrl)
+ }) }}
+
+
+
+
+
+
+
+
+ {{ t('settings.videoBaseUrl') }}
+
+
+
+
+ {{ t('settings.videoBaseUrlRoleHint') }}
+
+
+
+
+
+
+ {{ t('settings.videoBaseUrlActive', {
+ url: effectiveUrl(videoSeparated, videoBaseUrl)
+ }) }}
+
+
+
+
+
+
+
+
+ {{ t('settings.fileBaseUrl') }}
+
+
+
+
+ {{ t('settings.fileBaseUrlRoleHint') }}
+
+
+
+
+
+
+ {{ t('settings.fileBaseUrlActive', {
+ url: effectiveUrl(fileSeparated, fileBaseUrl)
+ }) }}
+
+
+
+
+
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 @@