服务器配置
This commit is contained in:
1 parent
a1b4a63552
commit
fa7c8244b0
33 files changed
+1882
-363
No files matched your search
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
const { t } = useI18n()
|
||||
const { isAdmin } = useAuth()
|
||||
const {
|
||||
concurrency,
|
||||
threads,
|
||||
maxConcurrency,
|
||||
maxThreads,
|
||||
preset,
|
||||
bitratePercent,
|
||||
minBitratePercent,
|
||||
maxBitratePercent,
|
||||
load
|
||||
} = 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)
|
||||
bitratePercent.value = Number.isFinite(value) ? value : 80
|
||||
}
|
||||
|
||||
function selectPreset(value: string) {
|
||||
preset.value = value
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="shrink-0">{{ t('preferences.transcodeConcurrency') }}</span>
|
||||
<UInput
|
||||
v-model.number="concurrency"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="maxConcurrency"
|
||||
size="xs"
|
||||
class="w-16 tabular-nums"
|
||||
:disabled="!isAdmin"
|
||||
/>
|
||||
<span class="text-muted">{{ t('preferences.unitCount') }}</span>
|
||||
<span class="text-xs text-muted">{{ t('preferences.concurrentHint') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="shrink-0">{{ t('preferences.transcodeThreads') }}</span>
|
||||
<UInput
|
||||
v-model.number="threads"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="maxThreads"
|
||||
size="xs"
|
||||
class="w-16 tabular-nums"
|
||||
:disabled="!isAdmin"
|
||||
/>
|
||||
<span class="text-muted">{{ t('preferences.unitCount') }}</span>
|
||||
<span class="text-xs text-muted">{{ t('preferences.threadsHint') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span class="shrink-0">{{ t('preferences.transcodeQuality') }}</span>
|
||||
<div class="flex gap-1 rounded-lg border border-default p-0.5">
|
||||
<UButton
|
||||
v-for="item in presetItems"
|
||||
:key="item.value"
|
||||
size="xs"
|
||||
class="flex-1 justify-center"
|
||||
:variant="preset === item.value ? 'solid' : 'ghost'"
|
||||
:color="preset === item.value ? 'primary' : 'neutral'"
|
||||
:label="item.label"
|
||||
:disabled="!isAdmin"
|
||||
@click="selectPreset(item.value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-2 text-sm"
|
||||
:class="preset === 'compress' ? '' : 'opacity-40'"
|
||||
>
|
||||
<span class="shrink-0">{{ t('preferences.compressBitrate') }}</span>
|
||||
<input
|
||||
type="range"
|
||||
:min="minBitratePercent"
|
||||
:max="maxBitratePercent"
|
||||
:value="bitratePercent"
|
||||
:disabled="!isAdmin || preset !== 'compress'"
|
||||
class="h-2 min-w-24 flex-1 cursor-pointer appearance-none rounded-full bg-default accent-primary disabled:opacity-40"
|
||||
@input="onBitrateInput"
|
||||
>
|
||||
<span class="w-12 shrink-0 text-right tabular-nums text-muted">
|
||||
{{ bitratePercent }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -218,71 +187,7 @@ onMounted(() => {
|
||||
v-if="transcodeToHls"
|
||||
class="ml-7 space-y-3 border-t border-default pt-4"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="shrink-0">{{ t('preferences.transcodeConcurrency') }}</span>
|
||||
<UInput
|
||||
v-model.number="transcodeConcurrency"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="maxTranscodeConcurrency"
|
||||
size="xs"
|
||||
class="w-16 tabular-nums"
|
||||
:disabled="!isAdmin"
|
||||
/>
|
||||
<span class="text-muted">{{ t('preferences.unitCount') }}</span>
|
||||
<span class="text-xs text-muted">{{ t('preferences.concurrentHint') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="shrink-0">{{ t('preferences.transcodeThreads') }}</span>
|
||||
<UInput
|
||||
v-model.number="transcodeThreads"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="maxTranscodeThreads"
|
||||
size="xs"
|
||||
class="w-16 tabular-nums"
|
||||
:disabled="!isAdmin"
|
||||
/>
|
||||
<span class="text-muted">{{ t('preferences.unitCount') }}</span>
|
||||
<span class="text-xs text-muted">{{ t('preferences.threadsHint') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="shrink-0">{{ t('preferences.transcodeQuality') }}</span>
|
||||
<div class="flex gap-1 rounded-lg border border-default p-0.5">
|
||||
<UButton
|
||||
v-for="item in presetItems"
|
||||
:key="item.value"
|
||||
size="xs"
|
||||
class="flex-1 justify-center"
|
||||
:variant="transcodePreset === item.value ? 'solid' : 'ghost'"
|
||||
:color="transcodePreset === item.value ? 'primary' : 'neutral'"
|
||||
:label="item.label"
|
||||
:disabled="!isAdmin"
|
||||
@click="selectPreset(item.value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-2 text-sm"
|
||||
:class="transcodePreset === 'compress' ? '' : 'opacity-40'"
|
||||
>
|
||||
<span class="shrink-0">{{ t('preferences.compressBitrate') }}</span>
|
||||
<input
|
||||
type="range"
|
||||
:min="minBitratePercent"
|
||||
:max="maxBitratePercent"
|
||||
:value="transcodeBitratePercent"
|
||||
:disabled="!isAdmin || transcodePreset !== 'compress'"
|
||||
class="h-2 min-w-24 flex-1 cursor-pointer appearance-none rounded-full bg-default accent-primary disabled:opacity-40"
|
||||
@input="onBitrateInput"
|
||||
>
|
||||
<span class="w-12 shrink-0 text-right tabular-nums text-muted">
|
||||
{{ transcodeBitratePercent }}%
|
||||
</span>
|
||||
</div>
|
||||
<TranscodeSettingsControls />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
currentOrigin: string
|
||||
effectiveSiteUrl: string
|
||||
}>()
|
||||
|
||||
const siteBaseUrl = defineModel<string>('siteBaseUrl', { required: true })
|
||||
const imageBaseUrl = defineModel<string>('imageBaseUrl', { required: true })
|
||||
const videoBaseUrl = defineModel<string>('videoBaseUrl', { required: true })
|
||||
const fileBaseUrl = defineModel<string>('fileBaseUrl', { required: true })
|
||||
const imageSeparated = defineModel<boolean>('imageSeparated', { required: true })
|
||||
const videoSeparated = defineModel<boolean>('videoSeparated', { required: true })
|
||||
const fileSeparated = defineModel<boolean>('fileSeparated', { required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function fillDetected(target: 'site' | 'image' | 'video' | 'file') {
|
||||
if (!props.currentOrigin) return
|
||||
if (target === 'site') siteBaseUrl.value = props.currentOrigin
|
||||
else if (target === 'image') imageBaseUrl.value = props.currentOrigin
|
||||
else if (target === 'video') videoBaseUrl.value = props.currentOrigin
|
||||
else fileBaseUrl.value = props.currentOrigin
|
||||
}
|
||||
|
||||
function effectiveUrl(separated: boolean, configuredUrl: string): string {
|
||||
return separated
|
||||
? configuredUrl.trim() || props.currentOrigin
|
||||
: siteBaseUrl.value.trim() || props.effectiveSiteUrl || props.currentOrigin
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<SettingsGroup>
|
||||
<div class="space-y-3 py-4 sm:pr-4">
|
||||
<p class="text-sm font-medium text-highlighted">
|
||||
{{ t('settings.siteBaseUrl') }}
|
||||
</p>
|
||||
<p class="text-xs leading-relaxed text-muted">
|
||||
{{ t('settings.siteBaseUrlRoleHint') }}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<UInput
|
||||
v-model="siteBaseUrl"
|
||||
size="sm"
|
||||
:placeholder="t('settings.siteBaseUrlPlaceholder')"
|
||||
class="min-w-0 flex-1 font-mono"
|
||||
/>
|
||||
<UButton
|
||||
v-if="currentOrigin"
|
||||
:label="t('settings.fillDetectedOrigin')"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
@click="fillDetected('site')"
|
||||
/>
|
||||
</div>
|
||||
<p class="truncate rounded-lg bg-muted/30 px-3 py-2 text-xs text-muted">
|
||||
{{ t('settings.siteBaseUrlActive', { url: effectiveSiteUrl || currentOrigin }) }}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup>
|
||||
<div class="space-y-3 py-4 sm:pl-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<p class="text-sm font-medium text-highlighted">
|
||||
{{ t('settings.imageBaseUrl') }}
|
||||
</p>
|
||||
<USwitch v-model="imageSeparated" />
|
||||
</div>
|
||||
<p class="text-xs leading-relaxed text-muted">
|
||||
{{ t('settings.imageBaseUrlRoleHint') }}
|
||||
</p>
|
||||
<div
|
||||
class="flex gap-2"
|
||||
:class="imageSeparated ? '' : 'pointer-events-none opacity-50'"
|
||||
>
|
||||
<UInput
|
||||
v-model="imageBaseUrl"
|
||||
size="sm"
|
||||
:placeholder="t('settings.imageBaseUrlPlaceholder')"
|
||||
class="min-w-0 flex-1 font-mono"
|
||||
/>
|
||||
<UButton
|
||||
v-if="currentOrigin"
|
||||
:label="t('settings.fillDetectedOrigin')"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
@click="fillDetected('image')"
|
||||
/>
|
||||
</div>
|
||||
<p class="truncate rounded-lg bg-muted/30 px-3 py-2 text-xs text-muted">
|
||||
{{ t('settings.imageBaseUrlActive', {
|
||||
url: effectiveUrl(imageSeparated, imageBaseUrl)
|
||||
}) }}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup>
|
||||
<div class="space-y-3 py-4 sm:pr-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<p class="text-sm font-medium text-highlighted">
|
||||
{{ t('settings.videoBaseUrl') }}
|
||||
</p>
|
||||
<USwitch v-model="videoSeparated" />
|
||||
</div>
|
||||
<p class="text-xs leading-relaxed text-muted">
|
||||
{{ t('settings.videoBaseUrlRoleHint') }}
|
||||
</p>
|
||||
<div
|
||||
class="flex gap-2"
|
||||
:class="videoSeparated ? '' : 'pointer-events-none opacity-50'"
|
||||
>
|
||||
<UInput
|
||||
v-model="videoBaseUrl"
|
||||
size="sm"
|
||||
:placeholder="t('settings.videoBaseUrlPlaceholder')"
|
||||
class="min-w-0 flex-1 font-mono"
|
||||
/>
|
||||
<UButton
|
||||
v-if="currentOrigin"
|
||||
:label="t('settings.fillDetectedOrigin')"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
@click="fillDetected('video')"
|
||||
/>
|
||||
</div>
|
||||
<p class="truncate rounded-lg bg-muted/30 px-3 py-2 text-xs text-muted">
|
||||
{{ t('settings.videoBaseUrlActive', {
|
||||
url: effectiveUrl(videoSeparated, videoBaseUrl)
|
||||
}) }}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup>
|
||||
<div class="space-y-3 py-4 sm:pl-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<p class="text-sm font-medium text-highlighted">
|
||||
{{ t('settings.fileBaseUrl') }}
|
||||
</p>
|
||||
<USwitch v-model="fileSeparated" />
|
||||
</div>
|
||||
<p class="text-xs leading-relaxed text-muted">
|
||||
{{ t('settings.fileBaseUrlRoleHint') }}
|
||||
</p>
|
||||
<div
|
||||
class="flex gap-2"
|
||||
:class="fileSeparated ? '' : 'pointer-events-none opacity-50'"
|
||||
>
|
||||
<UInput
|
||||
v-model="fileBaseUrl"
|
||||
size="sm"
|
||||
:placeholder="t('settings.fileBaseUrlPlaceholder')"
|
||||
class="min-w-0 flex-1 font-mono"
|
||||
/>
|
||||
<UButton
|
||||
v-if="currentOrigin"
|
||||
:label="t('settings.fillDetectedOrigin')"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
@click="fillDetected('file')"
|
||||
/>
|
||||
</div>
|
||||
<p class="truncate rounded-lg bg-muted/30 px-3 py-2 text-xs text-muted">
|
||||
{{ t('settings.fileBaseUrlActive', {
|
||||
url: effectiveUrl(fileSeparated, fileBaseUrl)
|
||||
}) }}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,11 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
type PolicySource = 'env' | 'db' | 'default'
|
||||
type MimeType = 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif' | 'image/svg+xml' | 'image/x-icon'
|
||||
type VideoExtension = 'mp4' | 'm4v' | 'webm' | 'mov' | 'mkv' | 'avi' | 'flv' | 'wmv' | 'mpg' | 'mpeg'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
part: 'upload-preference' | 'image-processing' | 'login-rate'
|
||||
uploadMaxFileSizeMbSource?: PolicySource
|
||||
allowedMimeTypesSource?: PolicySource
|
||||
allowedVideoExtensionsSource?: PolicySource
|
||||
forbiddenFileExtensionsSource?: PolicySource
|
||||
uploadRateIpMaxSource?: PolicySource
|
||||
uploadRateTokenMaxSource?: PolicySource
|
||||
uploadRateWindowMinutesSource?: PolicySource
|
||||
@@ -13,16 +16,21 @@ defineProps<{
|
||||
loginRateWindowMinutesSource?: PolicySource
|
||||
preserveOriginalUploadSource?: PolicySource
|
||||
mimeTypeOptions?: ReadonlyArray<{ mime: MimeType, label: string }>
|
||||
videoExtensionOptions?: ReadonlyArray<{ extension: VideoExtension, label: string }>
|
||||
forbiddenFileExtensionOptions?: ReadonlyArray<{ extension: string, label: string }>
|
||||
}>()
|
||||
|
||||
const uploadMaxFileSizeMbDraft = defineModel<number>('uploadMaxFileSizeMb')
|
||||
const allowedMimeTypesDraft = defineModel<MimeType[]>('allowedMimeTypes')
|
||||
const allowedVideoExtensionsDraft = defineModel<VideoExtension[]>('allowedVideoExtensions')
|
||||
const forbiddenFileExtensionsDraft = defineModel<string[]>('forbiddenFileExtensions')
|
||||
const uploadRateIpMaxDraft = defineModel<number>('uploadRateIpMax')
|
||||
const uploadRateTokenMaxDraft = defineModel<number>('uploadRateTokenMax')
|
||||
const uploadRateWindowMinutesDraft = defineModel<number>('uploadRateWindowMinutes')
|
||||
const loginRateMaxDraft = defineModel<number>('loginRateMax')
|
||||
const loginRateWindowMinutesDraft = defineModel<number>('loginRateWindowMinutes')
|
||||
const preserveOriginalUploadDraft = defineModel<boolean>('preserveOriginalUpload')
|
||||
const customForbiddenExtension = ref('')
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -53,6 +61,70 @@ function isMimeChecked(mime: MimeType): boolean {
|
||||
return allowedMimeTypesDraft.value?.includes(mime) ?? false
|
||||
}
|
||||
|
||||
function toggleVideoExtension(extension: VideoExtension, checked: boolean) {
|
||||
if (!allowedVideoExtensionsDraft.value) return
|
||||
if (checked) {
|
||||
if (!allowedVideoExtensionsDraft.value.includes(extension)) {
|
||||
allowedVideoExtensionsDraft.value = [
|
||||
...allowedVideoExtensionsDraft.value,
|
||||
extension
|
||||
]
|
||||
}
|
||||
return
|
||||
}
|
||||
if (allowedVideoExtensionsDraft.value.length <= 1) return
|
||||
allowedVideoExtensionsDraft.value = allowedVideoExtensionsDraft.value
|
||||
.filter(item => item !== extension)
|
||||
}
|
||||
|
||||
function isVideoExtensionChecked(extension: VideoExtension): boolean {
|
||||
return allowedVideoExtensionsDraft.value?.includes(extension) ?? false
|
||||
}
|
||||
|
||||
function toggleForbiddenExtension(extension: string, checked: boolean) {
|
||||
if (!forbiddenFileExtensionsDraft.value) return
|
||||
if (checked) {
|
||||
if (!forbiddenFileExtensionsDraft.value.includes(extension)) {
|
||||
forbiddenFileExtensionsDraft.value = [
|
||||
...forbiddenFileExtensionsDraft.value,
|
||||
extension
|
||||
]
|
||||
}
|
||||
return
|
||||
}
|
||||
forbiddenFileExtensionsDraft.value = forbiddenFileExtensionsDraft.value
|
||||
.filter(item => item !== extension)
|
||||
}
|
||||
|
||||
function isForbiddenExtensionChecked(extension: string): boolean {
|
||||
return forbiddenFileExtensionsDraft.value?.includes(extension) ?? false
|
||||
}
|
||||
|
||||
const allForbiddenExtensionOptions = computed(() => {
|
||||
const options = new Map(
|
||||
(props.forbiddenFileExtensionOptions ?? []).map(option => [
|
||||
option.extension,
|
||||
option.label
|
||||
])
|
||||
)
|
||||
for (const extension of forbiddenFileExtensionsDraft.value ?? []) {
|
||||
if (!options.has(extension)) options.set(extension, extension.toUpperCase())
|
||||
}
|
||||
return [...options].map(([extension, label]) => ({ extension, label }))
|
||||
})
|
||||
|
||||
function addCustomForbiddenExtension() {
|
||||
const values = customForbiddenExtension.value
|
||||
.split(',')
|
||||
.map(value => value.trim().toLowerCase().replace(/^\.+/, ''))
|
||||
.filter(value => /^[a-z0-9][a-z0-9_-]{0,15}$/.test(value))
|
||||
if (!values.length) return
|
||||
const next = new Set(forbiddenFileExtensionsDraft.value ?? [])
|
||||
for (const value of values) next.add(value)
|
||||
forbiddenFileExtensionsDraft.value = [...next]
|
||||
customForbiddenExtension.value = ''
|
||||
}
|
||||
|
||||
function clampUploadMaxFileSizeMb(value: unknown): number {
|
||||
const num = Number(value)
|
||||
if (!Number.isFinite(num)) return uploadMaxFileSizeMbDraft.value ?? 10
|
||||
@@ -118,7 +190,7 @@ function onUploadMaxFileSizeNumberInput(value: string | number) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-4">
|
||||
<div class="border-t border-default/50 py-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-sm font-medium text-highlighted">
|
||||
{{ t('settings.allowedMimeTypes') }}
|
||||
@@ -158,6 +230,105 @@ function onUploadMaxFileSizeNumberInput(value: string | number) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-default/50 py-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-sm font-medium text-highlighted">
|
||||
{{ t('settings.allowedVideoExtensions') }}
|
||||
</p>
|
||||
<UBadge
|
||||
v-if="allowedVideoExtensionsSource"
|
||||
:color="sourceBadge(allowedVideoExtensionsSource).color"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
{{ sourceBadge(allowedVideoExtensionsSource).label }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<p class="mt-1 text-xs leading-relaxed text-muted">
|
||||
{{ t('settings.allowedVideoExtensionsHint') }}
|
||||
</p>
|
||||
<div
|
||||
class="mt-3 flex flex-wrap gap-2"
|
||||
:class="allowedVideoExtensionsSource === 'env' ? 'pointer-events-none opacity-60' : ''"
|
||||
>
|
||||
<label
|
||||
v-for="option in videoExtensionOptions ?? []"
|
||||
:key="option.extension"
|
||||
class="inline-flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors"
|
||||
:class="isVideoExtensionChecked(option.extension)
|
||||
? 'border-primary/40 bg-primary/5'
|
||||
: 'border-default bg-muted/20 hover:bg-muted/40'"
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="isVideoExtensionChecked(option.extension)"
|
||||
@update:model-value="(v) => toggleVideoExtension(option.extension, v === true)"
|
||||
@click.stop
|
||||
/>
|
||||
<span class="font-medium text-highlighted">{{ option.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-default/50 py-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-sm font-medium text-highlighted">
|
||||
{{ t('settings.forbiddenFileExtensions') }}
|
||||
</p>
|
||||
<UBadge
|
||||
v-if="forbiddenFileExtensionsSource"
|
||||
:color="sourceBadge(forbiddenFileExtensionsSource).color"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
{{ sourceBadge(forbiddenFileExtensionsSource).label }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<p class="mt-1 text-xs leading-relaxed text-muted">
|
||||
{{ t('settings.forbiddenFileExtensionsHint') }}
|
||||
</p>
|
||||
<div
|
||||
class="mt-3 flex flex-wrap gap-2"
|
||||
:class="forbiddenFileExtensionsSource === 'env' ? 'pointer-events-none opacity-60' : ''"
|
||||
>
|
||||
<label
|
||||
v-for="option in allForbiddenExtensionOptions"
|
||||
:key="option.extension"
|
||||
class="inline-flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors"
|
||||
:class="isForbiddenExtensionChecked(option.extension)
|
||||
? 'border-error/40 bg-error/5'
|
||||
: 'border-default bg-muted/20 hover:bg-muted/40'"
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="isForbiddenExtensionChecked(option.extension)"
|
||||
@update:model-value="(v) => toggleForbiddenExtension(option.extension, v === true)"
|
||||
@click.stop
|
||||
/>
|
||||
<span class="font-medium text-highlighted">{{ option.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 flex max-w-md items-center gap-2"
|
||||
:class="forbiddenFileExtensionsSource === 'env' ? 'pointer-events-none opacity-60' : ''"
|
||||
>
|
||||
<UInput
|
||||
v-model="customForbiddenExtension"
|
||||
size="sm"
|
||||
class="min-w-0 flex-1"
|
||||
:placeholder="t('settings.forbiddenFileExtensionsCustomPlaceholder')"
|
||||
:disabled="forbiddenFileExtensionsSource === 'env'"
|
||||
@keydown.enter.prevent="addCustomForbiddenExtension"
|
||||
/>
|
||||
<UButton
|
||||
:label="t('settings.forbiddenFileExtensionsAdd')"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
color="neutral"
|
||||
:disabled="forbiddenFileExtensionsSource === 'env'"
|
||||
@click="addCustomForbiddenExtension"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-4">
|
||||
<p class="text-sm font-medium text-highlighted">
|
||||
{{ t('settings.uploadRateGroup') }}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
import type { UploadMediaKind } from '~/composables/useUploadPreferences'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
part: 'upload-preference' | 'image-processing'
|
||||
}>()
|
||||
kind?: UploadMediaKind
|
||||
}>(), {
|
||||
kind: 'image'
|
||||
})
|
||||
|
||||
const {
|
||||
compressEnabled,
|
||||
@@ -9,7 +14,7 @@ const {
|
||||
copyFormat,
|
||||
clientWebpQuality,
|
||||
loadPreferences
|
||||
} = useUploadPreferences()
|
||||
} = useUploadPreferences(props.kind)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
const { transcodeToHls, loadPreferences } = useUploadPreferences('video')
|
||||
const { t } = useI18n()
|
||||
|
||||
onMounted(() => {
|
||||
loadPreferences()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsToggleRow
|
||||
v-model="transcodeToHls"
|
||||
:title="t('preferences.transcodeToHls')"
|
||||
:hint="t('preferences.transcodeToHlsHint')"
|
||||
/>
|
||||
<div
|
||||
v-if="transcodeToHls"
|
||||
class="border-t border-default/50 py-4"
|
||||
>
|
||||
<TranscodeSettingsControls />
|
||||
</div>
|
||||
</template>
|
||||
@@ -218,6 +218,8 @@ export function useAuth() {
|
||||
domainSeparation?: boolean
|
||||
siteBaseUrl?: string
|
||||
imageBaseUrl?: string
|
||||
videoBaseUrl?: string
|
||||
fileBaseUrl?: string
|
||||
}): Promise<AuthActionResult> {
|
||||
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
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -47,6 +47,9 @@ async function copyToClipboard(text: string): Promise<boolean> {
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
interface UploadPolicy {
|
||||
allowedMimeTypes: string[]
|
||||
allowedVideoExtensions: string[]
|
||||
forbiddenFileExtensions: string[]
|
||||
}
|
||||
|
||||
const policy = ref<UploadPolicy | null>(null)
|
||||
const loading = ref(false)
|
||||
let pending: Promise<UploadPolicy> | null = null
|
||||
|
||||
export function useUploadPolicy() {
|
||||
async function load(force = false): Promise<UploadPolicy> {
|
||||
if (policy.value && !force) return policy.value
|
||||
if (pending && !force) return pending
|
||||
|
||||
loading.value = true
|
||||
pending = $fetch<UploadPolicy>('/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
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ interface UserTokenResponse {
|
||||
refererConfigured: boolean
|
||||
siteBaseUrl: string
|
||||
imageBaseUrl: string
|
||||
videoBaseUrl: string
|
||||
fileBaseUrl: string
|
||||
appVersion: string
|
||||
}
|
||||
}
|
||||
|
||||
+243
-150
@@ -1,15 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { SettingsTab } from '~/types/settings'
|
||||
import { ADMIN_SETTINGS_TABS, USER_SETTINGS_TABS } from '~/types/settings'
|
||||
import { willActivateDomainSeparation } from '~/utils/domain-separation'
|
||||
import SettingsUserPreferencesFields from '~/components/settings/SettingsUserPreferencesFields.vue'
|
||||
import SettingsUploadPolicyFields from '~/components/settings/SettingsUploadPolicyFields.vue'
|
||||
import SettingsVideoProcessingFields from '~/components/settings/SettingsVideoProcessingFields.vue'
|
||||
|
||||
type SettingSource = 'env' | 'db' | 'none'
|
||||
type WebpQualitySource = 'env' | 'db' | 'default'
|
||||
type PolicySource = 'env' | 'db' | 'default'
|
||||
type LoginVerificationMethod = 'slider' | 'turnstile' | 'cap'
|
||||
type MimeType = 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif' | 'image/svg+xml' | 'image/x-icon'
|
||||
type VideoExtension = 'mp4' | 'm4v' | 'webm' | 'mov' | 'mkv' | 'avi' | 'flv' | 'wmv' | 'mpg' | 'mpeg'
|
||||
|
||||
interface SettingsResponse {
|
||||
apiUploadToken: string
|
||||
@@ -23,7 +24,16 @@ interface SettingsResponse {
|
||||
imageBaseUrl: string
|
||||
imageBaseUrlConfigured: string
|
||||
imageBaseUrlSource: SettingSource
|
||||
imageDomainSeparated: boolean
|
||||
videoBaseUrl: string
|
||||
videoBaseUrlSource: SettingSource
|
||||
videoDomainSeparated: boolean
|
||||
fileBaseUrl: string
|
||||
fileBaseUrlSource: SettingSource
|
||||
fileDomainSeparated: boolean
|
||||
effectiveImageBaseUrl: string
|
||||
effectiveVideoBaseUrl: string
|
||||
effectiveFileBaseUrl: string
|
||||
siteBaseUrl: string
|
||||
siteBaseUrlConfigured: string
|
||||
siteBaseUrlSource: SettingSource
|
||||
@@ -49,6 +59,10 @@ interface SettingsResponse {
|
||||
uploadMaxFileSizeMbSource: PolicySource
|
||||
allowedMimeTypes: MimeType[]
|
||||
allowedMimeTypesSource: PolicySource
|
||||
allowedVideoExtensions: VideoExtension[]
|
||||
allowedVideoExtensionsSource: PolicySource
|
||||
forbiddenFileExtensions: string[]
|
||||
forbiddenFileExtensionsSource: PolicySource
|
||||
uploadRateIpMax: number
|
||||
uploadRateIpMaxSource: PolicySource
|
||||
uploadRateTokenMax: number
|
||||
@@ -63,6 +77,8 @@ interface SettingsResponse {
|
||||
preserveOriginalUploadSource: PolicySource
|
||||
fileBedAccess: 'public' | 'token'
|
||||
mimeTypeOptions: ReadonlyArray<{ mime: MimeType, label: string }>
|
||||
videoExtensionOptions: ReadonlyArray<{ extension: VideoExtension, label: string }>
|
||||
forbiddenFileExtensionOptions: ReadonlyArray<{ extension: string, label: string }>
|
||||
}
|
||||
|
||||
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
|
||||
@@ -136,8 +152,11 @@ const showSaveBar = computed(() =>
|
||||
)
|
||||
|
||||
const checkingRelease = ref(false)
|
||||
const { resetToDefaults: resetUploadPreferences } = useUploadPreferences()
|
||||
const { resetToDefaults: resetImagePreferences } = useUploadPreferences('image')
|
||||
const { resetToDefaults: resetVideoPreferences } = useUploadPreferences('video')
|
||||
const { resetToDefaults: resetFilePreferences } = useUploadPreferences('file')
|
||||
const { load: reloadUserSettings } = useUserAutoDeleteSettings()
|
||||
const { load: reloadUploadPolicy } = useUploadPolicy()
|
||||
|
||||
const domainSeparationDocUrl = computed(() =>
|
||||
locale.value === 'en'
|
||||
@@ -151,6 +170,11 @@ const savingServer = ref(false)
|
||||
const refererDraft = ref('')
|
||||
const siteBaseUrlDraft = ref('')
|
||||
const imageBaseUrlDraft = ref('')
|
||||
const videoBaseUrlDraft = ref('')
|
||||
const fileBaseUrlDraft = ref('')
|
||||
const imageDomainSeparatedDraft = ref(false)
|
||||
const videoDomainSeparatedDraft = ref(false)
|
||||
const fileDomainSeparatedDraft = ref(false)
|
||||
const hideFolderInUrlDraft = ref(false)
|
||||
const storageUseDatePathDraft = ref(true)
|
||||
const allowRegistrationDraft = ref(false)
|
||||
@@ -159,13 +183,12 @@ const turnstileSiteKeyDraft = ref('')
|
||||
const turnstileSecretKeyDraft = ref('')
|
||||
const capApiEndpointDraft = ref('')
|
||||
const capSecretDraft = ref('')
|
||||
const domainSeparationDraft = ref(false)
|
||||
const disableDomainSeparationOpen = ref(false)
|
||||
const pendingDomainSeparationDisable = ref(false)
|
||||
const domainSeparationSaveConfirmOpen = ref(false)
|
||||
|
||||
const uploadMaxFileSizeMbDraft = ref(10)
|
||||
const allowedMimeTypesDraft = ref<MimeType[]>([])
|
||||
const allowedVideoExtensionsDraft = ref<VideoExtension[]>([])
|
||||
const forbiddenFileExtensionsDraft = ref<string[]>([])
|
||||
const uploadRateIpMaxDraft = ref(60)
|
||||
const uploadRateTokenMaxDraft = ref(120)
|
||||
const uploadRateWindowMinutesDraft = ref(15)
|
||||
@@ -203,9 +226,14 @@ function mimeListsEqual(left: readonly string[], right: readonly string[]): bool
|
||||
const hasServerChanges = computed(() => {
|
||||
if (!settings.value) return false
|
||||
return refererDraft.value !== settings.value.allowedRefererHosts
|
||||
|| domainSeparationDraft.value !== settings.value.domainSeparation
|
||||
|| siteBaseUrlDraft.value !== (settings.value.domainSeparation ? settings.value.siteBaseUrl : '')
|
||||
|| anyResourceDomainSeparated.value !== settings.value.domainSeparation
|
||||
|| siteBaseUrlDraft.value !== settings.value.siteBaseUrl
|
||||
|| imageBaseUrlDraft.value !== settings.value.imageBaseUrl
|
||||
|| videoBaseUrlDraft.value !== settings.value.videoBaseUrl
|
||||
|| fileBaseUrlDraft.value !== settings.value.fileBaseUrl
|
||||
|| imageDomainSeparatedDraft.value !== settings.value.imageDomainSeparated
|
||||
|| videoDomainSeparatedDraft.value !== settings.value.videoDomainSeparated
|
||||
|| fileDomainSeparatedDraft.value !== settings.value.fileDomainSeparated
|
||||
|| hideFolderInUrlDraft.value !== settings.value.hideFolderInUrl
|
||||
|| storageUseDatePathDraft.value !== settings.value.storageUseDatePath
|
||||
|| allowRegistrationDraft.value !== settings.value.allowRegistration
|
||||
@@ -216,6 +244,8 @@ const hasServerChanges = computed(() => {
|
||||
|| capSecretDraft.value !== settings.value.capSecret
|
||||
|| uploadMaxFileSizeMbDraft.value !== settings.value.uploadMaxFileSizeMb
|
||||
|| !mimeListsEqual(allowedMimeTypesDraft.value, settings.value.allowedMimeTypes)
|
||||
|| !mimeListsEqual(allowedVideoExtensionsDraft.value, settings.value.allowedVideoExtensions)
|
||||
|| !mimeListsEqual(forbiddenFileExtensionsDraft.value, settings.value.forbiddenFileExtensions)
|
||||
|| uploadRateIpMaxDraft.value !== settings.value.uploadRateIpMax
|
||||
|| uploadRateTokenMaxDraft.value !== settings.value.uploadRateTokenMax
|
||||
|| uploadRateWindowMinutesDraft.value !== settings.value.uploadRateWindowMinutes
|
||||
@@ -225,19 +255,27 @@ const hasServerChanges = computed(() => {
|
||||
|| fileBedAccessDraft.value !== settings.value.fileBedAccess
|
||||
})
|
||||
|
||||
const anyResourceDomainSeparated = computed(() =>
|
||||
imageDomainSeparatedDraft.value
|
||||
|| videoDomainSeparatedDraft.value
|
||||
|| fileDomainSeparatedDraft.value
|
||||
)
|
||||
|
||||
const domainSeparationWouldActivate = computed(() =>
|
||||
willActivateDomainSeparation(
|
||||
domainSeparationDraft.value,
|
||||
siteBaseUrlDraft.value,
|
||||
imageBaseUrlDraft.value
|
||||
)
|
||||
anyResourceDomainSeparated.value
|
||||
&& Boolean(siteBaseUrlDraft.value.trim())
|
||||
)
|
||||
|
||||
const domainSeparationFieldsChanged = computed(() => {
|
||||
if (!settings.value) return false
|
||||
return domainSeparationDraft.value !== settings.value.domainSeparation
|
||||
|| siteBaseUrlDraft.value !== (settings.value.domainSeparation ? settings.value.siteBaseUrl : '')
|
||||
return anyResourceDomainSeparated.value !== settings.value.domainSeparation
|
||||
|| siteBaseUrlDraft.value !== settings.value.siteBaseUrl
|
||||
|| imageBaseUrlDraft.value !== settings.value.imageBaseUrl
|
||||
|| videoBaseUrlDraft.value !== settings.value.videoBaseUrl
|
||||
|| fileBaseUrlDraft.value !== settings.value.fileBaseUrl
|
||||
|| imageDomainSeparatedDraft.value !== settings.value.imageDomainSeparated
|
||||
|| videoDomainSeparatedDraft.value !== settings.value.videoDomainSeparated
|
||||
|| fileDomainSeparatedDraft.value !== settings.value.fileDomainSeparated
|
||||
})
|
||||
|
||||
const needsDomainSeparationSaveConfirm = computed(() =>
|
||||
@@ -267,8 +305,13 @@ function errorStatus(error: unknown): number {
|
||||
function applySettings(data: SettingsResponse) {
|
||||
settings.value = data
|
||||
refererDraft.value = data.allowedRefererHosts
|
||||
siteBaseUrlDraft.value = data.domainSeparation ? data.siteBaseUrl : ''
|
||||
siteBaseUrlDraft.value = data.siteBaseUrl
|
||||
imageBaseUrlDraft.value = data.imageBaseUrl
|
||||
videoBaseUrlDraft.value = data.videoBaseUrl
|
||||
fileBaseUrlDraft.value = data.fileBaseUrl
|
||||
imageDomainSeparatedDraft.value = data.imageDomainSeparated
|
||||
videoDomainSeparatedDraft.value = data.videoDomainSeparated
|
||||
fileDomainSeparatedDraft.value = data.fileDomainSeparated
|
||||
hideFolderInUrlDraft.value = data.hideFolderInUrl
|
||||
storageUseDatePathDraft.value = data.storageUseDatePath
|
||||
allowRegistrationDraft.value = data.allowRegistration
|
||||
@@ -277,9 +320,10 @@ function applySettings(data: SettingsResponse) {
|
||||
turnstileSecretKeyDraft.value = data.turnstileSecretKey
|
||||
capApiEndpointDraft.value = data.capApiEndpoint
|
||||
capSecretDraft.value = data.capSecret
|
||||
domainSeparationDraft.value = data.domainSeparation
|
||||
uploadMaxFileSizeMbDraft.value = data.uploadMaxFileSizeMb
|
||||
allowedMimeTypesDraft.value = [...data.allowedMimeTypes]
|
||||
allowedVideoExtensionsDraft.value = [...data.allowedVideoExtensions]
|
||||
forbiddenFileExtensionsDraft.value = [...data.forbiddenFileExtensions]
|
||||
uploadRateIpMaxDraft.value = data.uploadRateIpMax
|
||||
uploadRateTokenMaxDraft.value = data.uploadRateTokenMax
|
||||
uploadRateWindowMinutesDraft.value = data.uploadRateWindowMinutes
|
||||
@@ -310,38 +354,28 @@ function handlePatchError(error: unknown, fallback: string) {
|
||||
toast.add({ title: fallback, color: 'error' })
|
||||
}
|
||||
|
||||
function fillDetectedSiteOrigin() {
|
||||
if (settings.value?.runtime.currentOrigin) {
|
||||
siteBaseUrlDraft.value = settings.value.runtime.currentOrigin
|
||||
}
|
||||
function fillDetectedOrigin(target: 'site' | 'image' | 'video' | 'file') {
|
||||
const origin = settings.value?.runtime.currentOrigin
|
||||
if (!origin) return
|
||||
if (target === 'site') siteBaseUrlDraft.value = origin
|
||||
else if (target === 'image') imageBaseUrlDraft.value = origin
|
||||
else if (target === 'video') videoBaseUrlDraft.value = origin
|
||||
else fileBaseUrlDraft.value = origin
|
||||
}
|
||||
|
||||
function onDomainSeparationDraftChange(next: boolean | 'indeterminate') {
|
||||
const enabled = next === true
|
||||
if (!enabled && domainSeparationDraft.value) {
|
||||
pendingDomainSeparationDisable.value = true
|
||||
disableDomainSeparationOpen.value = true
|
||||
return
|
||||
}
|
||||
domainSeparationDraft.value = enabled
|
||||
}
|
||||
|
||||
function confirmDisableDomainSeparation() {
|
||||
domainSeparationDraft.value = false
|
||||
siteBaseUrlDraft.value = ''
|
||||
disableDomainSeparationOpen.value = false
|
||||
pendingDomainSeparationDisable.value = false
|
||||
}
|
||||
|
||||
function cancelDisableDomainSeparation() {
|
||||
disableDomainSeparationOpen.value = false
|
||||
pendingDomainSeparationDisable.value = false
|
||||
function effectiveResourceDraft(
|
||||
separated: boolean,
|
||||
configuredUrl: string
|
||||
): string {
|
||||
return separated
|
||||
? configuredUrl.trim() || settings.value?.runtime.currentOrigin || ''
|
||||
: siteBaseUrlDraft.value.trim() || settings.value?.runtime.currentOrigin || ''
|
||||
}
|
||||
|
||||
async function saveServerSettings() {
|
||||
if (!isAuthenticated.value || !hasServerChanges.value) return
|
||||
|
||||
if (domainSeparationDraft.value) {
|
||||
if (anyResourceDomainSeparated.value) {
|
||||
if (!siteBaseUrlDraft.value.trim() || !imageBaseUrlDraft.value.trim()) {
|
||||
toast.add({ title: t('settings.domainSeparationRequired'), color: 'error' })
|
||||
return
|
||||
@@ -366,12 +400,21 @@ async function performSaveServerSettings() {
|
||||
toast.add({ title: t('settings.allowedMimeTypesHint'), color: 'error' })
|
||||
return
|
||||
}
|
||||
if (allowedVideoExtensionsDraft.value.length === 0) {
|
||||
toast.add({ title: t('settings.allowedVideoExtensionsHint'), color: 'error' })
|
||||
return
|
||||
}
|
||||
|
||||
await patchSettings({
|
||||
allowedRefererHosts: refererDraft.value,
|
||||
domainSeparation: domainSeparationDraft.value,
|
||||
siteBaseUrl: domainSeparationDraft.value ? siteBaseUrlDraft.value : '',
|
||||
domainSeparation: anyResourceDomainSeparated.value,
|
||||
siteBaseUrl: siteBaseUrlDraft.value,
|
||||
imageBaseUrl: imageBaseUrlDraft.value,
|
||||
videoBaseUrl: videoBaseUrlDraft.value,
|
||||
fileBaseUrl: fileBaseUrlDraft.value,
|
||||
imageDomainSeparated: imageDomainSeparatedDraft.value,
|
||||
videoDomainSeparated: videoDomainSeparatedDraft.value,
|
||||
fileDomainSeparated: fileDomainSeparatedDraft.value,
|
||||
hideFolderInUrl: hideFolderInUrlDraft.value,
|
||||
storageUseDatePath: storageUseDatePathDraft.value,
|
||||
allowRegistration: allowRegistrationDraft.value,
|
||||
@@ -382,6 +425,8 @@ async function performSaveServerSettings() {
|
||||
capSecret: capSecretDraft.value,
|
||||
uploadMaxFileSizeMb: uploadMaxFileSizeMbDraft.value,
|
||||
allowedMimeTypes: allowedMimeTypesDraft.value,
|
||||
allowedVideoExtensions: allowedVideoExtensionsDraft.value,
|
||||
forbiddenFileExtensions: forbiddenFileExtensionsDraft.value,
|
||||
uploadRateIpMax: uploadRateIpMaxDraft.value,
|
||||
uploadRateTokenMax: uploadRateTokenMaxDraft.value,
|
||||
uploadRateWindowMinutes: uploadRateWindowMinutesDraft.value,
|
||||
@@ -390,6 +435,7 @@ async function performSaveServerSettings() {
|
||||
preserveOriginalUpload: preserveOriginalUploadDraft.value,
|
||||
fileBedAccess: fileBedAccessDraft.value
|
||||
})
|
||||
await reloadUploadPolicy(true)
|
||||
toast.add({ title: t('settings.saved'), color: 'success' })
|
||||
} catch (error: unknown) {
|
||||
handlePatchError(error, t('settings.saveFailed'))
|
||||
@@ -466,7 +512,9 @@ const loginVerificationOptions = computed(() => [
|
||||
|
||||
function restoreDefaults() {
|
||||
if (activeTab.value === 'basic') {
|
||||
resetUploadPreferences()
|
||||
resetImagePreferences()
|
||||
resetVideoPreferences()
|
||||
resetFilePreferences()
|
||||
void reloadUserSettings()
|
||||
}
|
||||
if (settings.value) {
|
||||
@@ -580,16 +628,22 @@ watch(isAdmin, () => {
|
||||
<SettingsUploadPolicyFields
|
||||
v-model:upload-max-file-size-mb="uploadMaxFileSizeMbDraft"
|
||||
v-model:allowed-mime-types="allowedMimeTypesDraft"
|
||||
v-model:allowed-video-extensions="allowedVideoExtensionsDraft"
|
||||
v-model:forbidden-file-extensions="forbiddenFileExtensionsDraft"
|
||||
v-model:upload-rate-ip-max="uploadRateIpMaxDraft"
|
||||
v-model:upload-rate-token-max="uploadRateTokenMaxDraft"
|
||||
v-model:upload-rate-window-minutes="uploadRateWindowMinutesDraft"
|
||||
part="upload-preference"
|
||||
:upload-max-file-size-mb-source="settings.uploadMaxFileSizeMbSource"
|
||||
:allowed-mime-types-source="settings.allowedMimeTypesSource"
|
||||
:allowed-video-extensions-source="settings.allowedVideoExtensionsSource"
|
||||
:forbidden-file-extensions-source="settings.forbiddenFileExtensionsSource"
|
||||
:upload-rate-ip-max-source="settings.uploadRateIpMaxSource"
|
||||
:upload-rate-token-max-source="settings.uploadRateTokenMaxSource"
|
||||
:upload-rate-window-minutes-source="settings.uploadRateWindowMinutesSource"
|
||||
:mime-type-options="settings.mimeTypeOptions"
|
||||
:video-extension-options="settings.videoExtensionOptions"
|
||||
:forbidden-file-extension-options="settings.forbiddenFileExtensionOptions"
|
||||
/>
|
||||
<SettingsUserPreferencesFields part="upload-preference" />
|
||||
</SettingsGroup>
|
||||
@@ -609,6 +663,15 @@ watch(isAdmin, () => {
|
||||
</SettingsGroup>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
:title="t('preferences.videoProcessing')"
|
||||
:hint="t('settings.videoProcessingHint')"
|
||||
>
|
||||
<SettingsGroup>
|
||||
<SettingsVideoProcessingFields />
|
||||
</SettingsGroup>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection :title="t('settings.systemInfo')">
|
||||
<SettingsGroup>
|
||||
<SettingsSystemInfo
|
||||
@@ -647,14 +710,8 @@ watch(isAdmin, () => {
|
||||
{{ t('settings.runtimeDetected', { url: settings.runtime.currentOrigin }) }}
|
||||
</span>
|
||||
</p>
|
||||
<SettingsToggleRow
|
||||
:model-value="domainSeparationDraft"
|
||||
:title="t('setup.domainSeparation')"
|
||||
:hint="t('setup.domainSeparationHint')"
|
||||
@update:model-value="onDomainSeparationDraftChange"
|
||||
/>
|
||||
<div
|
||||
v-if="domainSeparationDraft"
|
||||
v-if="anyResourceDomainSeparated"
|
||||
class="py-4"
|
||||
>
|
||||
<div class="flex flex-col gap-3 rounded-xl border border-warning/30 bg-warning/5 px-4 py-3 sm:flex-row sm:items-start">
|
||||
@@ -688,6 +745,17 @@ watch(isAdmin, () => {
|
||||
class="mt-4"
|
||||
/>
|
||||
</div>
|
||||
<SettingsDomainConfiguration
|
||||
v-model:site-base-url="siteBaseUrlDraft"
|
||||
v-model:image-base-url="imageBaseUrlDraft"
|
||||
v-model:video-base-url="videoBaseUrlDraft"
|
||||
v-model:file-base-url="fileBaseUrlDraft"
|
||||
v-model:image-separated="imageDomainSeparatedDraft"
|
||||
v-model:video-separated="videoDomainSeparatedDraft"
|
||||
v-model:file-separated="fileDomainSeparatedDraft"
|
||||
:current-origin="settings.runtime.currentOrigin"
|
||||
:effective-site-url="settings.effectiveSiteBaseUrl"
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -740,24 +808,17 @@ watch(isAdmin, () => {
|
||||
</SettingsGroup>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection :title="t('settings.imageDomainSection')">
|
||||
<div
|
||||
v-if="domainSeparationDraft"
|
||||
class="grid gap-4 sm:grid-cols-2 sm:divide-x sm:divide-default"
|
||||
>
|
||||
<SettingsSection
|
||||
v-if="false && settings"
|
||||
:title="t('settings.resourceDomainSection')"
|
||||
>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<SettingsGroup>
|
||||
<div class="space-y-3 py-4 sm:pr-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-sm font-medium text-highlighted">
|
||||
{{ t('settings.siteBaseUrl') }}
|
||||
</p>
|
||||
<UBadge
|
||||
color="success"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
{{ t('settings.badgeEnabled') }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<p class="text-xs leading-relaxed text-muted">
|
||||
{{ t('settings.siteBaseUrlRoleHint') }}
|
||||
@@ -770,21 +831,21 @@ watch(isAdmin, () => {
|
||||
class="min-w-0 flex-1 font-mono"
|
||||
/>
|
||||
<UButton
|
||||
v-if="settings.runtime.currentOrigin"
|
||||
v-if="settings?.runtime.currentOrigin"
|
||||
:label="t('settings.fillDetectedOrigin')"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
@click="fillDetectedSiteOrigin"
|
||||
@click="fillDetectedOrigin('site')"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="settings.effectiveSiteBaseUrl"
|
||||
v-if="settings?.effectiveSiteBaseUrl"
|
||||
class="truncate rounded-lg bg-muted/30 px-3 py-2 text-xs text-muted"
|
||||
:title="settings.effectiveSiteBaseUrl"
|
||||
:title="settings?.effectiveSiteBaseUrl"
|
||||
>
|
||||
{{ t('settings.siteBaseUrlActive', { url: settings.effectiveSiteBaseUrl }) }}
|
||||
{{ t('settings.siteBaseUrlActive', { url: settings?.effectiveSiteBaseUrl }) }}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
@@ -795,74 +856,129 @@ watch(isAdmin, () => {
|
||||
<p class="text-sm font-medium text-highlighted">
|
||||
{{ t('settings.imageBaseUrl') }}
|
||||
</p>
|
||||
<UBadge
|
||||
color="success"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
{{ t('settings.badgeEnabled') }}
|
||||
</UBadge>
|
||||
<USwitch v-model="imageDomainSeparatedDraft" />
|
||||
</div>
|
||||
<p class="text-xs leading-relaxed text-muted">
|
||||
{{ t('settings.imageBaseUrlRoleHint') }}
|
||||
</p>
|
||||
<UInput
|
||||
v-model="imageBaseUrlDraft"
|
||||
size="sm"
|
||||
:placeholder="t('settings.imageBaseUrlPlaceholder')"
|
||||
class="w-full font-mono"
|
||||
/>
|
||||
<p
|
||||
v-if="settings.effectiveImageBaseUrl"
|
||||
class="truncate rounded-lg bg-muted/30 px-3 py-2 text-xs text-muted"
|
||||
:title="settings.effectiveImageBaseUrl"
|
||||
<div
|
||||
class="flex gap-2"
|
||||
:class="imageDomainSeparatedDraft ? '' : 'pointer-events-none opacity-50'"
|
||||
>
|
||||
{{ t('settings.imageBaseUrlActive', { url: settings.effectiveImageBaseUrl }) }}
|
||||
<UInput
|
||||
v-model="imageBaseUrlDraft"
|
||||
size="sm"
|
||||
:placeholder="t('settings.imageBaseUrlPlaceholder')"
|
||||
class="min-w-0 flex-1 font-mono"
|
||||
/>
|
||||
<UButton
|
||||
v-if="settings?.runtime.currentOrigin"
|
||||
:label="t('settings.fillDetectedOrigin')"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
@click="fillDetectedOrigin('image')"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="effectiveResourceDraft(imageDomainSeparatedDraft, imageBaseUrlDraft)"
|
||||
class="truncate rounded-lg bg-muted/30 px-3 py-2 text-xs text-muted"
|
||||
:title="effectiveResourceDraft(imageDomainSeparatedDraft, imageBaseUrlDraft)"
|
||||
>
|
||||
{{ t('settings.imageBaseUrlActive', {
|
||||
url: effectiveResourceDraft(imageDomainSeparatedDraft, imageBaseUrlDraft)
|
||||
}) }}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup>
|
||||
<div class="space-y-3 py-4 sm:pr-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-sm font-medium text-highlighted">
|
||||
{{ t('settings.videoBaseUrl') }}
|
||||
</p>
|
||||
<USwitch v-model="videoDomainSeparatedDraft" />
|
||||
</div>
|
||||
<p class="text-xs leading-relaxed text-muted">
|
||||
{{ t('settings.videoBaseUrlRoleHint') }}
|
||||
</p>
|
||||
<div
|
||||
class="flex gap-2"
|
||||
:class="videoDomainSeparatedDraft ? '' : 'pointer-events-none opacity-50'"
|
||||
>
|
||||
<UInput
|
||||
v-model="videoBaseUrlDraft"
|
||||
size="sm"
|
||||
:placeholder="t('settings.videoBaseUrlPlaceholder')"
|
||||
class="min-w-0 flex-1 font-mono"
|
||||
/>
|
||||
<UButton
|
||||
v-if="settings?.runtime.currentOrigin"
|
||||
:label="t('settings.fillDetectedOrigin')"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
@click="fillDetectedOrigin('video')"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="effectiveResourceDraft(videoDomainSeparatedDraft, videoBaseUrlDraft)"
|
||||
class="truncate rounded-lg bg-muted/30 px-3 py-2 text-xs text-muted"
|
||||
:title="effectiveResourceDraft(videoDomainSeparatedDraft, videoBaseUrlDraft)"
|
||||
>
|
||||
{{ t('settings.videoBaseUrlActive', {
|
||||
url: effectiveResourceDraft(videoDomainSeparatedDraft, videoBaseUrlDraft)
|
||||
}) }}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup>
|
||||
<div class="space-y-3 py-4 sm:pl-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-sm font-medium text-highlighted">
|
||||
{{ t('settings.fileBaseUrl') }}
|
||||
</p>
|
||||
<USwitch v-model="fileDomainSeparatedDraft" />
|
||||
</div>
|
||||
<p class="text-xs leading-relaxed text-muted">
|
||||
{{ t('settings.fileBaseUrlRoleHint') }}
|
||||
</p>
|
||||
<div
|
||||
class="flex gap-2"
|
||||
:class="fileDomainSeparatedDraft ? '' : 'pointer-events-none opacity-50'"
|
||||
>
|
||||
<UInput
|
||||
v-model="fileBaseUrlDraft"
|
||||
size="sm"
|
||||
:placeholder="t('settings.fileBaseUrlPlaceholder')"
|
||||
class="min-w-0 flex-1 font-mono"
|
||||
/>
|
||||
<UButton
|
||||
v-if="settings?.runtime.currentOrigin"
|
||||
:label="t('settings.fillDetectedOrigin')"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
@click="fillDetectedOrigin('file')"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="effectiveResourceDraft(fileDomainSeparatedDraft, fileBaseUrlDraft)"
|
||||
class="truncate rounded-lg bg-muted/30 px-3 py-2 text-xs text-muted"
|
||||
:title="effectiveResourceDraft(fileDomainSeparatedDraft, fileBaseUrlDraft)"
|
||||
>
|
||||
{{ t('settings.fileBaseUrlActive', {
|
||||
url: effectiveResourceDraft(fileDomainSeparatedDraft, fileBaseUrlDraft)
|
||||
}) }}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
|
||||
<SettingsGroup v-else>
|
||||
<div class="space-y-3 py-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-sm font-medium text-highlighted">
|
||||
{{ t('settings.imageBaseUrl') }}
|
||||
</p>
|
||||
<UBadge
|
||||
color="success"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
{{ t('settings.badgeOptional') }}
|
||||
</UBadge>
|
||||
<UBadge
|
||||
v-if="settings.imageBaseUrlSource === 'env'"
|
||||
:color="sourceBadge(settings.imageBaseUrlSource).color"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
{{ sourceBadge(settings.imageBaseUrlSource).label }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<p class="text-xs leading-relaxed text-muted">
|
||||
{{ t('settings.imageBaseUrlSingleHint') }}
|
||||
</p>
|
||||
<UInput
|
||||
v-model="imageBaseUrlDraft"
|
||||
size="sm"
|
||||
:placeholder="t('settings.imageBaseUrlPlaceholder')"
|
||||
class="w-full font-mono"
|
||||
/>
|
||||
<p
|
||||
v-if="settings.effectiveImageBaseUrl"
|
||||
class="truncate rounded-lg bg-muted/30 px-3 py-2 text-xs text-muted"
|
||||
:title="settings.effectiveImageBaseUrl"
|
||||
>
|
||||
{{ t('settings.imageBaseUrlActive', { url: settings.effectiveImageBaseUrl }) }}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</SettingsSection>
|
||||
</SettingsPanel>
|
||||
<div
|
||||
@@ -1068,29 +1184,6 @@ watch(isAdmin, () => {
|
||||
</section>
|
||||
</AppShell>
|
||||
|
||||
<UModal
|
||||
:open="disableDomainSeparationOpen"
|
||||
:title="t('settings.disableDomainSeparationTitle')"
|
||||
:description="t('settings.disableDomainSeparationDesc')"
|
||||
@update:open="(v) => { if (!v) cancelDisableDomainSeparation() }"
|
||||
>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton
|
||||
:label="t('common.cancel')"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
@click="cancelDisableDomainSeparation"
|
||||
/>
|
||||
<UButton
|
||||
:label="t('settings.disableDomainSeparationConfirm')"
|
||||
color="warning"
|
||||
@click="confirmDisableDomainSeparation"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
<UModal
|
||||
:open="domainSeparationSaveConfirmOpen"
|
||||
:title="t('domainSeparationConfirm.title')"
|
||||
|
||||
+27
-1
@@ -26,6 +26,8 @@ const allowRegistration = ref(false)
|
||||
const domainSeparation = ref(false)
|
||||
const siteBaseUrl = ref('')
|
||||
const imageBaseUrl = ref('')
|
||||
const videoBaseUrl = ref('')
|
||||
const fileBaseUrl = ref('')
|
||||
const loading = ref(false)
|
||||
const domainSeparationConfirmOpen = ref(false)
|
||||
|
||||
@@ -180,7 +182,9 @@ async function performSubmit() {
|
||||
allowRegistration: allowRegistration.value,
|
||||
domainSeparation: domainSeparation.value,
|
||||
siteBaseUrl: domainSeparation.value ? siteBaseUrl.value.trim() : undefined,
|
||||
imageBaseUrl: domainSeparation.value ? imageBaseUrl.value.trim() : undefined
|
||||
imageBaseUrl: domainSeparation.value ? imageBaseUrl.value.trim() : undefined,
|
||||
videoBaseUrl: domainSeparation.value ? videoBaseUrl.value.trim() : undefined,
|
||||
fileBaseUrl: domainSeparation.value ? fileBaseUrl.value.trim() : undefined
|
||||
}
|
||||
const result = isMigrate.value
|
||||
? await migrate(payload)
|
||||
@@ -363,6 +367,28 @@ async function performSubmit() {
|
||||
class="w-full font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm">{{ t('setup.videoBaseUrl') }}</label>
|
||||
<p class="text-xs text-muted">
|
||||
{{ t('setup.videoBaseUrlHint') }}
|
||||
</p>
|
||||
<UInput
|
||||
v-model="videoBaseUrl"
|
||||
:placeholder="t('setup.videoBaseUrlPlaceholder')"
|
||||
class="w-full font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm">{{ t('setup.fileBaseUrl') }}</label>
|
||||
<p class="text-xs text-muted">
|
||||
{{ t('setup.fileBaseUrlHint') }}
|
||||
</p>
|
||||
<UInput
|
||||
v-model="fileBaseUrl"
|
||||
:placeholder="t('setup.fileBaseUrlPlaceholder')"
|
||||
class="w-full font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export function hostnameFromBaseUrl(value: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** 勾选分离且网站域、图片域主机名不同 → 保存后将启用双域名中间件隔离 */
|
||||
/** 勾选分离且后台域、图片域主机名不同 → 保存后启用多域名中间件隔离 */
|
||||
export function willActivateDomainSeparation(
|
||||
enabled: boolean,
|
||||
siteBaseUrl: string,
|
||||
|
||||
Reference in new issue
Block a user