服务器配置
This commit is contained in:
1 parent
a1b4a63552
commit
fa7c8244b0
33 files changed
+1856
-337
No files matched your search
@@ -8,6 +8,10 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const {
|
||||||
|
policy: uploadPolicy,
|
||||||
|
load: loadUploadPolicy
|
||||||
|
} = useUploadPolicy()
|
||||||
const {
|
const {
|
||||||
uploading,
|
uploading,
|
||||||
progressItems,
|
progressItems,
|
||||||
@@ -19,20 +23,44 @@ const {
|
|||||||
} = useMediaUpload(props.kind)
|
} = useMediaUpload(props.kind)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadActiveTranscodes()
|
void Promise.all([
|
||||||
|
loadActiveTranscodes(),
|
||||||
|
loadUploadPolicy().catch(() => {})
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
const text = computed(() => props.kind === 'video'
|
const text = computed(() => {
|
||||||
|
const allowedVideos = uploadPolicy.value?.allowedVideoExtensions ?? []
|
||||||
|
const forbiddenFiles = uploadPolicy.value?.forbiddenFileExtensions ?? []
|
||||||
|
return props.kind === 'video'
|
||||||
? {
|
? {
|
||||||
uploadTitle: t('library.video.uploadTitle'),
|
uploadTitle: t('library.video.uploadTitle'),
|
||||||
uploadHint: t('library.video.uploadHint'),
|
uploadHint: t('library.video.uploadHint'),
|
||||||
uploadFormats: t('library.video.uploadFormats')
|
uploadFormats: allowedVideos.length
|
||||||
|
? t('library.video.uploadFormatsAllowed', {
|
||||||
|
formats: allowedVideos.map(item => item.toUpperCase()).join(' / ')
|
||||||
|
})
|
||||||
|
: t('library.video.uploadFormats')
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
uploadTitle: t('library.file.uploadTitle'),
|
uploadTitle: t('library.file.uploadTitle'),
|
||||||
uploadHint: t('library.file.uploadHint'),
|
uploadHint: t('library.file.uploadHint'),
|
||||||
uploadFormats: t('library.file.uploadFormats')
|
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(() =>
|
const showProgress = computed(() =>
|
||||||
uploading.value
|
uploading.value
|
||||||
@@ -66,6 +94,7 @@ async function removeItem(item: FileItem) {
|
|||||||
:title="text.uploadTitle"
|
:title="text.uploadTitle"
|
||||||
:hint="text.uploadHint"
|
:hint="text.uploadHint"
|
||||||
:formats="text.uploadFormats"
|
:formats="text.uploadFormats"
|
||||||
|
:accept="accept"
|
||||||
:disabled="uploading"
|
:disabled="uploading"
|
||||||
@upload="uploadFiles"
|
@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
|
load: loadUserSettings
|
||||||
} = useUserAutoDeleteSettings()
|
} = 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(() => [
|
const copyFormatItems = computed(() => [
|
||||||
{ label: t('copy.url'), value: 'url' as const },
|
{ label: t('copy.url'), value: 'url' as const },
|
||||||
{ label: t('copy.markdown'), value: 'markdown' 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
|
clientWebpQuality.value = Number.isFinite(value) ? value : 80
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectPreset(value: string) {
|
|
||||||
transcodePreset.value = value
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadPreferences()
|
loadPreferences()
|
||||||
void loadUserSettings()
|
void loadUserSettings()
|
||||||
void loadTranscodeSettings()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -218,71 +187,7 @@ onMounted(() => {
|
|||||||
v-if="transcodeToHls"
|
v-if="transcodeToHls"
|
||||||
class="ml-7 space-y-3 border-t border-default pt-4"
|
class="ml-7 space-y-3 border-t border-default pt-4"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 text-sm">
|
<TranscodeSettingsControls />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<script setup lang="ts">
|
||||||
type PolicySource = 'env' | 'db' | 'default'
|
type PolicySource = 'env' | 'db' | 'default'
|
||||||
type MimeType = 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif' | 'image/svg+xml' | 'image/x-icon'
|
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'
|
part: 'upload-preference' | 'image-processing' | 'login-rate'
|
||||||
uploadMaxFileSizeMbSource?: PolicySource
|
uploadMaxFileSizeMbSource?: PolicySource
|
||||||
allowedMimeTypesSource?: PolicySource
|
allowedMimeTypesSource?: PolicySource
|
||||||
|
allowedVideoExtensionsSource?: PolicySource
|
||||||
|
forbiddenFileExtensionsSource?: PolicySource
|
||||||
uploadRateIpMaxSource?: PolicySource
|
uploadRateIpMaxSource?: PolicySource
|
||||||
uploadRateTokenMaxSource?: PolicySource
|
uploadRateTokenMaxSource?: PolicySource
|
||||||
uploadRateWindowMinutesSource?: PolicySource
|
uploadRateWindowMinutesSource?: PolicySource
|
||||||
@@ -13,16 +16,21 @@ defineProps<{
|
|||||||
loginRateWindowMinutesSource?: PolicySource
|
loginRateWindowMinutesSource?: PolicySource
|
||||||
preserveOriginalUploadSource?: PolicySource
|
preserveOriginalUploadSource?: PolicySource
|
||||||
mimeTypeOptions?: ReadonlyArray<{ mime: MimeType, label: string }>
|
mimeTypeOptions?: ReadonlyArray<{ mime: MimeType, label: string }>
|
||||||
|
videoExtensionOptions?: ReadonlyArray<{ extension: VideoExtension, label: string }>
|
||||||
|
forbiddenFileExtensionOptions?: ReadonlyArray<{ extension: string, label: string }>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const uploadMaxFileSizeMbDraft = defineModel<number>('uploadMaxFileSizeMb')
|
const uploadMaxFileSizeMbDraft = defineModel<number>('uploadMaxFileSizeMb')
|
||||||
const allowedMimeTypesDraft = defineModel<MimeType[]>('allowedMimeTypes')
|
const allowedMimeTypesDraft = defineModel<MimeType[]>('allowedMimeTypes')
|
||||||
|
const allowedVideoExtensionsDraft = defineModel<VideoExtension[]>('allowedVideoExtensions')
|
||||||
|
const forbiddenFileExtensionsDraft = defineModel<string[]>('forbiddenFileExtensions')
|
||||||
const uploadRateIpMaxDraft = defineModel<number>('uploadRateIpMax')
|
const uploadRateIpMaxDraft = defineModel<number>('uploadRateIpMax')
|
||||||
const uploadRateTokenMaxDraft = defineModel<number>('uploadRateTokenMax')
|
const uploadRateTokenMaxDraft = defineModel<number>('uploadRateTokenMax')
|
||||||
const uploadRateWindowMinutesDraft = defineModel<number>('uploadRateWindowMinutes')
|
const uploadRateWindowMinutesDraft = defineModel<number>('uploadRateWindowMinutes')
|
||||||
const loginRateMaxDraft = defineModel<number>('loginRateMax')
|
const loginRateMaxDraft = defineModel<number>('loginRateMax')
|
||||||
const loginRateWindowMinutesDraft = defineModel<number>('loginRateWindowMinutes')
|
const loginRateWindowMinutesDraft = defineModel<number>('loginRateWindowMinutes')
|
||||||
const preserveOriginalUploadDraft = defineModel<boolean>('preserveOriginalUpload')
|
const preserveOriginalUploadDraft = defineModel<boolean>('preserveOriginalUpload')
|
||||||
|
const customForbiddenExtension = ref('')
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -53,6 +61,70 @@ function isMimeChecked(mime: MimeType): boolean {
|
|||||||
return allowedMimeTypesDraft.value?.includes(mime) ?? false
|
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 {
|
function clampUploadMaxFileSizeMb(value: unknown): number {
|
||||||
const num = Number(value)
|
const num = Number(value)
|
||||||
if (!Number.isFinite(num)) return uploadMaxFileSizeMbDraft.value ?? 10
|
if (!Number.isFinite(num)) return uploadMaxFileSizeMbDraft.value ?? 10
|
||||||
@@ -118,7 +190,7 @@ function onUploadMaxFileSizeNumberInput(value: string | number) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="py-4">
|
<div class="border-t border-default/50 py-4">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<p class="text-sm font-medium text-highlighted">
|
<p class="text-sm font-medium text-highlighted">
|
||||||
{{ t('settings.allowedMimeTypes') }}
|
{{ t('settings.allowedMimeTypes') }}
|
||||||
@@ -158,6 +230,105 @@ function onUploadMaxFileSizeNumberInput(value: string | number) {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="py-4">
|
||||||
<p class="text-sm font-medium text-highlighted">
|
<p class="text-sm font-medium text-highlighted">
|
||||||
{{ t('settings.uploadRateGroup') }}
|
{{ t('settings.uploadRateGroup') }}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
import type { UploadMediaKind } from '~/composables/useUploadPreferences'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
part: 'upload-preference' | 'image-processing'
|
part: 'upload-preference' | 'image-processing'
|
||||||
}>()
|
kind?: UploadMediaKind
|
||||||
|
}>(), {
|
||||||
|
kind: 'image'
|
||||||
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
compressEnabled,
|
compressEnabled,
|
||||||
@@ -9,7 +14,7 @@ const {
|
|||||||
copyFormat,
|
copyFormat,
|
||||||
clientWebpQuality,
|
clientWebpQuality,
|
||||||
loadPreferences
|
loadPreferences
|
||||||
} = useUploadPreferences()
|
} = useUploadPreferences(props.kind)
|
||||||
|
|
||||||
const { t } = useI18n()
|
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
|
domainSeparation?: boolean
|
||||||
siteBaseUrl?: string
|
siteBaseUrl?: string
|
||||||
imageBaseUrl?: string
|
imageBaseUrl?: string
|
||||||
|
videoBaseUrl?: string
|
||||||
|
fileBaseUrl?: string
|
||||||
}): Promise<AuthActionResult> {
|
}): Promise<AuthActionResult> {
|
||||||
const validationError = validateAuthInput(input.username, input.password)
|
const validationError = validateAuthInput(input.username, input.password)
|
||||||
if (validationError) {
|
if (validationError) {
|
||||||
@@ -236,7 +238,9 @@ export function useAuth() {
|
|||||||
allowRegistration: input.allowRegistration,
|
allowRegistration: input.allowRegistration,
|
||||||
domainSeparation: input.domainSeparation,
|
domainSeparation: input.domainSeparation,
|
||||||
siteBaseUrl: input.siteBaseUrl,
|
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) {
|
export function useMediaUpload(kind: UploadMediaKind) {
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const {
|
||||||
|
load: loadUploadPolicy
|
||||||
|
} = useUploadPolicy()
|
||||||
const {
|
const {
|
||||||
autoCopyMarkdown,
|
autoCopyMarkdown,
|
||||||
copyFormat,
|
copyFormat,
|
||||||
@@ -176,13 +179,45 @@ export function useMediaUpload(kind: UploadMediaKind) {
|
|||||||
async function uploadFiles(files: File[]) {
|
async function uploadFiles(files: File[]) {
|
||||||
if (!files.length || uploading.value) return
|
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' })
|
toast.add({ title: t('files.maxFiles', { n: MAX_FILES }), color: 'warning' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
uploading.value = true
|
uploading.value = true
|
||||||
progressItems.value = files.map(file => ({
|
progressItems.value = acceptedFiles.map(file => ({
|
||||||
name: file.name,
|
name: file.name,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
progress: 0
|
progress: 0
|
||||||
@@ -190,7 +225,7 @@ export function useMediaUpload(kind: UploadMediaKind) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
for (const file of files) {
|
for (const file of acceptedFiles) {
|
||||||
formData.append('files', file, file.name)
|
formData.append('files', file, file.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,7 +250,7 @@ export function useMediaUpload(kind: UploadMediaKind) {
|
|||||||
await loadApiToken()
|
await loadApiToken()
|
||||||
}
|
}
|
||||||
|
|
||||||
progressItems.value = files.map((file) => {
|
progressItems.value = acceptedFiles.map((file) => {
|
||||||
const failed = response.errors.find(error => error.name === file.name)
|
const failed = response.errors.find(error => error.name === file.name)
|
||||||
return {
|
return {
|
||||||
name: file.name,
|
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
|
refererConfigured: boolean
|
||||||
siteBaseUrl: string
|
siteBaseUrl: string
|
||||||
imageBaseUrl: string
|
imageBaseUrl: string
|
||||||
|
videoBaseUrl: string
|
||||||
|
fileBaseUrl: string
|
||||||
appVersion: string
|
appVersion: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+225
-132
@@ -1,15 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { SettingsTab } from '~/types/settings'
|
import type { SettingsTab } from '~/types/settings'
|
||||||
import { ADMIN_SETTINGS_TABS, USER_SETTINGS_TABS } 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 SettingsUserPreferencesFields from '~/components/settings/SettingsUserPreferencesFields.vue'
|
||||||
import SettingsUploadPolicyFields from '~/components/settings/SettingsUploadPolicyFields.vue'
|
import SettingsUploadPolicyFields from '~/components/settings/SettingsUploadPolicyFields.vue'
|
||||||
|
import SettingsVideoProcessingFields from '~/components/settings/SettingsVideoProcessingFields.vue'
|
||||||
|
|
||||||
type SettingSource = 'env' | 'db' | 'none'
|
type SettingSource = 'env' | 'db' | 'none'
|
||||||
type WebpQualitySource = 'env' | 'db' | 'default'
|
type WebpQualitySource = 'env' | 'db' | 'default'
|
||||||
type PolicySource = 'env' | 'db' | 'default'
|
type PolicySource = 'env' | 'db' | 'default'
|
||||||
type LoginVerificationMethod = 'slider' | 'turnstile' | 'cap'
|
type LoginVerificationMethod = 'slider' | 'turnstile' | 'cap'
|
||||||
type MimeType = 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif' | 'image/svg+xml' | 'image/x-icon'
|
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 {
|
interface SettingsResponse {
|
||||||
apiUploadToken: string
|
apiUploadToken: string
|
||||||
@@ -23,7 +24,16 @@ interface SettingsResponse {
|
|||||||
imageBaseUrl: string
|
imageBaseUrl: string
|
||||||
imageBaseUrlConfigured: string
|
imageBaseUrlConfigured: string
|
||||||
imageBaseUrlSource: SettingSource
|
imageBaseUrlSource: SettingSource
|
||||||
|
imageDomainSeparated: boolean
|
||||||
|
videoBaseUrl: string
|
||||||
|
videoBaseUrlSource: SettingSource
|
||||||
|
videoDomainSeparated: boolean
|
||||||
|
fileBaseUrl: string
|
||||||
|
fileBaseUrlSource: SettingSource
|
||||||
|
fileDomainSeparated: boolean
|
||||||
effectiveImageBaseUrl: string
|
effectiveImageBaseUrl: string
|
||||||
|
effectiveVideoBaseUrl: string
|
||||||
|
effectiveFileBaseUrl: string
|
||||||
siteBaseUrl: string
|
siteBaseUrl: string
|
||||||
siteBaseUrlConfigured: string
|
siteBaseUrlConfigured: string
|
||||||
siteBaseUrlSource: SettingSource
|
siteBaseUrlSource: SettingSource
|
||||||
@@ -49,6 +59,10 @@ interface SettingsResponse {
|
|||||||
uploadMaxFileSizeMbSource: PolicySource
|
uploadMaxFileSizeMbSource: PolicySource
|
||||||
allowedMimeTypes: MimeType[]
|
allowedMimeTypes: MimeType[]
|
||||||
allowedMimeTypesSource: PolicySource
|
allowedMimeTypesSource: PolicySource
|
||||||
|
allowedVideoExtensions: VideoExtension[]
|
||||||
|
allowedVideoExtensionsSource: PolicySource
|
||||||
|
forbiddenFileExtensions: string[]
|
||||||
|
forbiddenFileExtensionsSource: PolicySource
|
||||||
uploadRateIpMax: number
|
uploadRateIpMax: number
|
||||||
uploadRateIpMaxSource: PolicySource
|
uploadRateIpMaxSource: PolicySource
|
||||||
uploadRateTokenMax: number
|
uploadRateTokenMax: number
|
||||||
@@ -63,6 +77,8 @@ interface SettingsResponse {
|
|||||||
preserveOriginalUploadSource: PolicySource
|
preserveOriginalUploadSource: PolicySource
|
||||||
fileBedAccess: 'public' | 'token'
|
fileBedAccess: 'public' | 'token'
|
||||||
mimeTypeOptions: ReadonlyArray<{ mime: MimeType, label: string }>
|
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()
|
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
|
||||||
@@ -136,8 +152,11 @@ const showSaveBar = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const checkingRelease = ref(false)
|
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: reloadUserSettings } = useUserAutoDeleteSettings()
|
||||||
|
const { load: reloadUploadPolicy } = useUploadPolicy()
|
||||||
|
|
||||||
const domainSeparationDocUrl = computed(() =>
|
const domainSeparationDocUrl = computed(() =>
|
||||||
locale.value === 'en'
|
locale.value === 'en'
|
||||||
@@ -151,6 +170,11 @@ const savingServer = ref(false)
|
|||||||
const refererDraft = ref('')
|
const refererDraft = ref('')
|
||||||
const siteBaseUrlDraft = ref('')
|
const siteBaseUrlDraft = ref('')
|
||||||
const imageBaseUrlDraft = 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 hideFolderInUrlDraft = ref(false)
|
||||||
const storageUseDatePathDraft = ref(true)
|
const storageUseDatePathDraft = ref(true)
|
||||||
const allowRegistrationDraft = ref(false)
|
const allowRegistrationDraft = ref(false)
|
||||||
@@ -159,13 +183,12 @@ const turnstileSiteKeyDraft = ref('')
|
|||||||
const turnstileSecretKeyDraft = ref('')
|
const turnstileSecretKeyDraft = ref('')
|
||||||
const capApiEndpointDraft = ref('')
|
const capApiEndpointDraft = ref('')
|
||||||
const capSecretDraft = ref('')
|
const capSecretDraft = ref('')
|
||||||
const domainSeparationDraft = ref(false)
|
|
||||||
const disableDomainSeparationOpen = ref(false)
|
|
||||||
const pendingDomainSeparationDisable = ref(false)
|
|
||||||
const domainSeparationSaveConfirmOpen = ref(false)
|
const domainSeparationSaveConfirmOpen = ref(false)
|
||||||
|
|
||||||
const uploadMaxFileSizeMbDraft = ref(10)
|
const uploadMaxFileSizeMbDraft = ref(10)
|
||||||
const allowedMimeTypesDraft = ref<MimeType[]>([])
|
const allowedMimeTypesDraft = ref<MimeType[]>([])
|
||||||
|
const allowedVideoExtensionsDraft = ref<VideoExtension[]>([])
|
||||||
|
const forbiddenFileExtensionsDraft = ref<string[]>([])
|
||||||
const uploadRateIpMaxDraft = ref(60)
|
const uploadRateIpMaxDraft = ref(60)
|
||||||
const uploadRateTokenMaxDraft = ref(120)
|
const uploadRateTokenMaxDraft = ref(120)
|
||||||
const uploadRateWindowMinutesDraft = ref(15)
|
const uploadRateWindowMinutesDraft = ref(15)
|
||||||
@@ -203,9 +226,14 @@ function mimeListsEqual(left: readonly string[], right: readonly string[]): bool
|
|||||||
const hasServerChanges = computed(() => {
|
const hasServerChanges = computed(() => {
|
||||||
if (!settings.value) return false
|
if (!settings.value) return false
|
||||||
return refererDraft.value !== settings.value.allowedRefererHosts
|
return refererDraft.value !== settings.value.allowedRefererHosts
|
||||||
|| domainSeparationDraft.value !== settings.value.domainSeparation
|
|| anyResourceDomainSeparated.value !== settings.value.domainSeparation
|
||||||
|| siteBaseUrlDraft.value !== (settings.value.domainSeparation ? settings.value.siteBaseUrl : '')
|
|| siteBaseUrlDraft.value !== settings.value.siteBaseUrl
|
||||||
|| imageBaseUrlDraft.value !== settings.value.imageBaseUrl
|
|| 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
|
|| hideFolderInUrlDraft.value !== settings.value.hideFolderInUrl
|
||||||
|| storageUseDatePathDraft.value !== settings.value.storageUseDatePath
|
|| storageUseDatePathDraft.value !== settings.value.storageUseDatePath
|
||||||
|| allowRegistrationDraft.value !== settings.value.allowRegistration
|
|| allowRegistrationDraft.value !== settings.value.allowRegistration
|
||||||
@@ -216,6 +244,8 @@ const hasServerChanges = computed(() => {
|
|||||||
|| capSecretDraft.value !== settings.value.capSecret
|
|| capSecretDraft.value !== settings.value.capSecret
|
||||||
|| uploadMaxFileSizeMbDraft.value !== settings.value.uploadMaxFileSizeMb
|
|| uploadMaxFileSizeMbDraft.value !== settings.value.uploadMaxFileSizeMb
|
||||||
|| !mimeListsEqual(allowedMimeTypesDraft.value, settings.value.allowedMimeTypes)
|
|| !mimeListsEqual(allowedMimeTypesDraft.value, settings.value.allowedMimeTypes)
|
||||||
|
|| !mimeListsEqual(allowedVideoExtensionsDraft.value, settings.value.allowedVideoExtensions)
|
||||||
|
|| !mimeListsEqual(forbiddenFileExtensionsDraft.value, settings.value.forbiddenFileExtensions)
|
||||||
|| uploadRateIpMaxDraft.value !== settings.value.uploadRateIpMax
|
|| uploadRateIpMaxDraft.value !== settings.value.uploadRateIpMax
|
||||||
|| uploadRateTokenMaxDraft.value !== settings.value.uploadRateTokenMax
|
|| uploadRateTokenMaxDraft.value !== settings.value.uploadRateTokenMax
|
||||||
|| uploadRateWindowMinutesDraft.value !== settings.value.uploadRateWindowMinutes
|
|| uploadRateWindowMinutesDraft.value !== settings.value.uploadRateWindowMinutes
|
||||||
@@ -225,19 +255,27 @@ const hasServerChanges = computed(() => {
|
|||||||
|| fileBedAccessDraft.value !== settings.value.fileBedAccess
|
|| fileBedAccessDraft.value !== settings.value.fileBedAccess
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const anyResourceDomainSeparated = computed(() =>
|
||||||
|
imageDomainSeparatedDraft.value
|
||||||
|
|| videoDomainSeparatedDraft.value
|
||||||
|
|| fileDomainSeparatedDraft.value
|
||||||
|
)
|
||||||
|
|
||||||
const domainSeparationWouldActivate = computed(() =>
|
const domainSeparationWouldActivate = computed(() =>
|
||||||
willActivateDomainSeparation(
|
anyResourceDomainSeparated.value
|
||||||
domainSeparationDraft.value,
|
&& Boolean(siteBaseUrlDraft.value.trim())
|
||||||
siteBaseUrlDraft.value,
|
|
||||||
imageBaseUrlDraft.value
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const domainSeparationFieldsChanged = computed(() => {
|
const domainSeparationFieldsChanged = computed(() => {
|
||||||
if (!settings.value) return false
|
if (!settings.value) return false
|
||||||
return domainSeparationDraft.value !== settings.value.domainSeparation
|
return anyResourceDomainSeparated.value !== settings.value.domainSeparation
|
||||||
|| siteBaseUrlDraft.value !== (settings.value.domainSeparation ? settings.value.siteBaseUrl : '')
|
|| siteBaseUrlDraft.value !== settings.value.siteBaseUrl
|
||||||
|| imageBaseUrlDraft.value !== settings.value.imageBaseUrl
|
|| 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(() =>
|
const needsDomainSeparationSaveConfirm = computed(() =>
|
||||||
@@ -267,8 +305,13 @@ function errorStatus(error: unknown): number {
|
|||||||
function applySettings(data: SettingsResponse) {
|
function applySettings(data: SettingsResponse) {
|
||||||
settings.value = data
|
settings.value = data
|
||||||
refererDraft.value = data.allowedRefererHosts
|
refererDraft.value = data.allowedRefererHosts
|
||||||
siteBaseUrlDraft.value = data.domainSeparation ? data.siteBaseUrl : ''
|
siteBaseUrlDraft.value = data.siteBaseUrl
|
||||||
imageBaseUrlDraft.value = data.imageBaseUrl
|
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
|
hideFolderInUrlDraft.value = data.hideFolderInUrl
|
||||||
storageUseDatePathDraft.value = data.storageUseDatePath
|
storageUseDatePathDraft.value = data.storageUseDatePath
|
||||||
allowRegistrationDraft.value = data.allowRegistration
|
allowRegistrationDraft.value = data.allowRegistration
|
||||||
@@ -277,9 +320,10 @@ function applySettings(data: SettingsResponse) {
|
|||||||
turnstileSecretKeyDraft.value = data.turnstileSecretKey
|
turnstileSecretKeyDraft.value = data.turnstileSecretKey
|
||||||
capApiEndpointDraft.value = data.capApiEndpoint
|
capApiEndpointDraft.value = data.capApiEndpoint
|
||||||
capSecretDraft.value = data.capSecret
|
capSecretDraft.value = data.capSecret
|
||||||
domainSeparationDraft.value = data.domainSeparation
|
|
||||||
uploadMaxFileSizeMbDraft.value = data.uploadMaxFileSizeMb
|
uploadMaxFileSizeMbDraft.value = data.uploadMaxFileSizeMb
|
||||||
allowedMimeTypesDraft.value = [...data.allowedMimeTypes]
|
allowedMimeTypesDraft.value = [...data.allowedMimeTypes]
|
||||||
|
allowedVideoExtensionsDraft.value = [...data.allowedVideoExtensions]
|
||||||
|
forbiddenFileExtensionsDraft.value = [...data.forbiddenFileExtensions]
|
||||||
uploadRateIpMaxDraft.value = data.uploadRateIpMax
|
uploadRateIpMaxDraft.value = data.uploadRateIpMax
|
||||||
uploadRateTokenMaxDraft.value = data.uploadRateTokenMax
|
uploadRateTokenMaxDraft.value = data.uploadRateTokenMax
|
||||||
uploadRateWindowMinutesDraft.value = data.uploadRateWindowMinutes
|
uploadRateWindowMinutesDraft.value = data.uploadRateWindowMinutes
|
||||||
@@ -310,38 +354,28 @@ function handlePatchError(error: unknown, fallback: string) {
|
|||||||
toast.add({ title: fallback, color: 'error' })
|
toast.add({ title: fallback, color: 'error' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillDetectedSiteOrigin() {
|
function fillDetectedOrigin(target: 'site' | 'image' | 'video' | 'file') {
|
||||||
if (settings.value?.runtime.currentOrigin) {
|
const origin = settings.value?.runtime.currentOrigin
|
||||||
siteBaseUrlDraft.value = 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') {
|
function effectiveResourceDraft(
|
||||||
const enabled = next === true
|
separated: boolean,
|
||||||
if (!enabled && domainSeparationDraft.value) {
|
configuredUrl: string
|
||||||
pendingDomainSeparationDisable.value = true
|
): string {
|
||||||
disableDomainSeparationOpen.value = true
|
return separated
|
||||||
return
|
? configuredUrl.trim() || settings.value?.runtime.currentOrigin || ''
|
||||||
}
|
: siteBaseUrlDraft.value.trim() || settings.value?.runtime.currentOrigin || ''
|
||||||
domainSeparationDraft.value = enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmDisableDomainSeparation() {
|
|
||||||
domainSeparationDraft.value = false
|
|
||||||
siteBaseUrlDraft.value = ''
|
|
||||||
disableDomainSeparationOpen.value = false
|
|
||||||
pendingDomainSeparationDisable.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelDisableDomainSeparation() {
|
|
||||||
disableDomainSeparationOpen.value = false
|
|
||||||
pendingDomainSeparationDisable.value = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveServerSettings() {
|
async function saveServerSettings() {
|
||||||
if (!isAuthenticated.value || !hasServerChanges.value) return
|
if (!isAuthenticated.value || !hasServerChanges.value) return
|
||||||
|
|
||||||
if (domainSeparationDraft.value) {
|
if (anyResourceDomainSeparated.value) {
|
||||||
if (!siteBaseUrlDraft.value.trim() || !imageBaseUrlDraft.value.trim()) {
|
if (!siteBaseUrlDraft.value.trim() || !imageBaseUrlDraft.value.trim()) {
|
||||||
toast.add({ title: t('settings.domainSeparationRequired'), color: 'error' })
|
toast.add({ title: t('settings.domainSeparationRequired'), color: 'error' })
|
||||||
return
|
return
|
||||||
@@ -366,12 +400,21 @@ async function performSaveServerSettings() {
|
|||||||
toast.add({ title: t('settings.allowedMimeTypesHint'), color: 'error' })
|
toast.add({ title: t('settings.allowedMimeTypesHint'), color: 'error' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (allowedVideoExtensionsDraft.value.length === 0) {
|
||||||
|
toast.add({ title: t('settings.allowedVideoExtensionsHint'), color: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
await patchSettings({
|
await patchSettings({
|
||||||
allowedRefererHosts: refererDraft.value,
|
allowedRefererHosts: refererDraft.value,
|
||||||
domainSeparation: domainSeparationDraft.value,
|
domainSeparation: anyResourceDomainSeparated.value,
|
||||||
siteBaseUrl: domainSeparationDraft.value ? siteBaseUrlDraft.value : '',
|
siteBaseUrl: siteBaseUrlDraft.value,
|
||||||
imageBaseUrl: imageBaseUrlDraft.value,
|
imageBaseUrl: imageBaseUrlDraft.value,
|
||||||
|
videoBaseUrl: videoBaseUrlDraft.value,
|
||||||
|
fileBaseUrl: fileBaseUrlDraft.value,
|
||||||
|
imageDomainSeparated: imageDomainSeparatedDraft.value,
|
||||||
|
videoDomainSeparated: videoDomainSeparatedDraft.value,
|
||||||
|
fileDomainSeparated: fileDomainSeparatedDraft.value,
|
||||||
hideFolderInUrl: hideFolderInUrlDraft.value,
|
hideFolderInUrl: hideFolderInUrlDraft.value,
|
||||||
storageUseDatePath: storageUseDatePathDraft.value,
|
storageUseDatePath: storageUseDatePathDraft.value,
|
||||||
allowRegistration: allowRegistrationDraft.value,
|
allowRegistration: allowRegistrationDraft.value,
|
||||||
@@ -382,6 +425,8 @@ async function performSaveServerSettings() {
|
|||||||
capSecret: capSecretDraft.value,
|
capSecret: capSecretDraft.value,
|
||||||
uploadMaxFileSizeMb: uploadMaxFileSizeMbDraft.value,
|
uploadMaxFileSizeMb: uploadMaxFileSizeMbDraft.value,
|
||||||
allowedMimeTypes: allowedMimeTypesDraft.value,
|
allowedMimeTypes: allowedMimeTypesDraft.value,
|
||||||
|
allowedVideoExtensions: allowedVideoExtensionsDraft.value,
|
||||||
|
forbiddenFileExtensions: forbiddenFileExtensionsDraft.value,
|
||||||
uploadRateIpMax: uploadRateIpMaxDraft.value,
|
uploadRateIpMax: uploadRateIpMaxDraft.value,
|
||||||
uploadRateTokenMax: uploadRateTokenMaxDraft.value,
|
uploadRateTokenMax: uploadRateTokenMaxDraft.value,
|
||||||
uploadRateWindowMinutes: uploadRateWindowMinutesDraft.value,
|
uploadRateWindowMinutes: uploadRateWindowMinutesDraft.value,
|
||||||
@@ -390,6 +435,7 @@ async function performSaveServerSettings() {
|
|||||||
preserveOriginalUpload: preserveOriginalUploadDraft.value,
|
preserveOriginalUpload: preserveOriginalUploadDraft.value,
|
||||||
fileBedAccess: fileBedAccessDraft.value
|
fileBedAccess: fileBedAccessDraft.value
|
||||||
})
|
})
|
||||||
|
await reloadUploadPolicy(true)
|
||||||
toast.add({ title: t('settings.saved'), color: 'success' })
|
toast.add({ title: t('settings.saved'), color: 'success' })
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
handlePatchError(error, t('settings.saveFailed'))
|
handlePatchError(error, t('settings.saveFailed'))
|
||||||
@@ -466,7 +512,9 @@ const loginVerificationOptions = computed(() => [
|
|||||||
|
|
||||||
function restoreDefaults() {
|
function restoreDefaults() {
|
||||||
if (activeTab.value === 'basic') {
|
if (activeTab.value === 'basic') {
|
||||||
resetUploadPreferences()
|
resetImagePreferences()
|
||||||
|
resetVideoPreferences()
|
||||||
|
resetFilePreferences()
|
||||||
void reloadUserSettings()
|
void reloadUserSettings()
|
||||||
}
|
}
|
||||||
if (settings.value) {
|
if (settings.value) {
|
||||||
@@ -580,16 +628,22 @@ watch(isAdmin, () => {
|
|||||||
<SettingsUploadPolicyFields
|
<SettingsUploadPolicyFields
|
||||||
v-model:upload-max-file-size-mb="uploadMaxFileSizeMbDraft"
|
v-model:upload-max-file-size-mb="uploadMaxFileSizeMbDraft"
|
||||||
v-model:allowed-mime-types="allowedMimeTypesDraft"
|
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-ip-max="uploadRateIpMaxDraft"
|
||||||
v-model:upload-rate-token-max="uploadRateTokenMaxDraft"
|
v-model:upload-rate-token-max="uploadRateTokenMaxDraft"
|
||||||
v-model:upload-rate-window-minutes="uploadRateWindowMinutesDraft"
|
v-model:upload-rate-window-minutes="uploadRateWindowMinutesDraft"
|
||||||
part="upload-preference"
|
part="upload-preference"
|
||||||
:upload-max-file-size-mb-source="settings.uploadMaxFileSizeMbSource"
|
:upload-max-file-size-mb-source="settings.uploadMaxFileSizeMbSource"
|
||||||
:allowed-mime-types-source="settings.allowedMimeTypesSource"
|
: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-ip-max-source="settings.uploadRateIpMaxSource"
|
||||||
:upload-rate-token-max-source="settings.uploadRateTokenMaxSource"
|
:upload-rate-token-max-source="settings.uploadRateTokenMaxSource"
|
||||||
:upload-rate-window-minutes-source="settings.uploadRateWindowMinutesSource"
|
:upload-rate-window-minutes-source="settings.uploadRateWindowMinutesSource"
|
||||||
:mime-type-options="settings.mimeTypeOptions"
|
:mime-type-options="settings.mimeTypeOptions"
|
||||||
|
:video-extension-options="settings.videoExtensionOptions"
|
||||||
|
:forbidden-file-extension-options="settings.forbiddenFileExtensionOptions"
|
||||||
/>
|
/>
|
||||||
<SettingsUserPreferencesFields part="upload-preference" />
|
<SettingsUserPreferencesFields part="upload-preference" />
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
@@ -609,6 +663,15 @@ watch(isAdmin, () => {
|
|||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
|
<SettingsSection
|
||||||
|
:title="t('preferences.videoProcessing')"
|
||||||
|
:hint="t('settings.videoProcessingHint')"
|
||||||
|
>
|
||||||
|
<SettingsGroup>
|
||||||
|
<SettingsVideoProcessingFields />
|
||||||
|
</SettingsGroup>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection :title="t('settings.systemInfo')">
|
<SettingsSection :title="t('settings.systemInfo')">
|
||||||
<SettingsGroup>
|
<SettingsGroup>
|
||||||
<SettingsSystemInfo
|
<SettingsSystemInfo
|
||||||
@@ -647,14 +710,8 @@ watch(isAdmin, () => {
|
|||||||
{{ t('settings.runtimeDetected', { url: settings.runtime.currentOrigin }) }}
|
{{ t('settings.runtimeDetected', { url: settings.runtime.currentOrigin }) }}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<SettingsToggleRow
|
|
||||||
:model-value="domainSeparationDraft"
|
|
||||||
:title="t('setup.domainSeparation')"
|
|
||||||
:hint="t('setup.domainSeparationHint')"
|
|
||||||
@update:model-value="onDomainSeparationDraftChange"
|
|
||||||
/>
|
|
||||||
<div
|
<div
|
||||||
v-if="domainSeparationDraft"
|
v-if="anyResourceDomainSeparated"
|
||||||
class="py-4"
|
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">
|
<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"
|
class="mt-4"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
@@ -740,24 +808,17 @@ watch(isAdmin, () => {
|
|||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection :title="t('settings.imageDomainSection')">
|
<SettingsSection
|
||||||
<div
|
v-if="false && settings"
|
||||||
v-if="domainSeparationDraft"
|
:title="t('settings.resourceDomainSection')"
|
||||||
class="grid gap-4 sm:grid-cols-2 sm:divide-x sm:divide-default"
|
|
||||||
>
|
>
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
<SettingsGroup>
|
<SettingsGroup>
|
||||||
<div class="space-y-3 py-4 sm:pr-4">
|
<div class="space-y-3 py-4 sm:pr-4">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<p class="text-sm font-medium text-highlighted">
|
<p class="text-sm font-medium text-highlighted">
|
||||||
{{ t('settings.siteBaseUrl') }}
|
{{ t('settings.siteBaseUrl') }}
|
||||||
</p>
|
</p>
|
||||||
<UBadge
|
|
||||||
color="success"
|
|
||||||
variant="subtle"
|
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
{{ t('settings.badgeEnabled') }}
|
|
||||||
</UBadge>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs leading-relaxed text-muted">
|
<p class="text-xs leading-relaxed text-muted">
|
||||||
{{ t('settings.siteBaseUrlRoleHint') }}
|
{{ t('settings.siteBaseUrlRoleHint') }}
|
||||||
@@ -770,21 +831,21 @@ watch(isAdmin, () => {
|
|||||||
class="min-w-0 flex-1 font-mono"
|
class="min-w-0 flex-1 font-mono"
|
||||||
/>
|
/>
|
||||||
<UButton
|
<UButton
|
||||||
v-if="settings.runtime.currentOrigin"
|
v-if="settings?.runtime.currentOrigin"
|
||||||
:label="t('settings.fillDetectedOrigin')"
|
:label="t('settings.fillDetectedOrigin')"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="shrink-0"
|
class="shrink-0"
|
||||||
@click="fillDetectedSiteOrigin"
|
@click="fillDetectedOrigin('site')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="settings.effectiveSiteBaseUrl"
|
v-if="settings?.effectiveSiteBaseUrl"
|
||||||
class="truncate rounded-lg bg-muted/30 px-3 py-2 text-xs text-muted"
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
@@ -795,74 +856,129 @@ watch(isAdmin, () => {
|
|||||||
<p class="text-sm font-medium text-highlighted">
|
<p class="text-sm font-medium text-highlighted">
|
||||||
{{ t('settings.imageBaseUrl') }}
|
{{ t('settings.imageBaseUrl') }}
|
||||||
</p>
|
</p>
|
||||||
<UBadge
|
<USwitch v-model="imageDomainSeparatedDraft" />
|
||||||
color="success"
|
|
||||||
variant="subtle"
|
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
{{ t('settings.badgeEnabled') }}
|
|
||||||
</UBadge>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs leading-relaxed text-muted">
|
<p class="text-xs leading-relaxed text-muted">
|
||||||
{{ t('settings.imageBaseUrlRoleHint') }}
|
{{ t('settings.imageBaseUrlRoleHint') }}
|
||||||
</p>
|
</p>
|
||||||
|
<div
|
||||||
|
class="flex gap-2"
|
||||||
|
:class="imageDomainSeparatedDraft ? '' : 'pointer-events-none opacity-50'"
|
||||||
|
>
|
||||||
<UInput
|
<UInput
|
||||||
v-model="imageBaseUrlDraft"
|
v-model="imageBaseUrlDraft"
|
||||||
size="sm"
|
size="sm"
|
||||||
:placeholder="t('settings.imageBaseUrlPlaceholder')"
|
:placeholder="t('settings.imageBaseUrlPlaceholder')"
|
||||||
class="w-full font-mono"
|
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
|
<p
|
||||||
v-if="settings.effectiveImageBaseUrl"
|
v-if="effectiveResourceDraft(imageDomainSeparatedDraft, imageBaseUrlDraft)"
|
||||||
class="truncate rounded-lg bg-muted/30 px-3 py-2 text-xs text-muted"
|
class="truncate rounded-lg bg-muted/30 px-3 py-2 text-xs text-muted"
|
||||||
:title="settings.effectiveImageBaseUrl"
|
:title="effectiveResourceDraft(imageDomainSeparatedDraft, imageBaseUrlDraft)"
|
||||||
>
|
>
|
||||||
{{ t('settings.imageBaseUrlActive', { url: settings.effectiveImageBaseUrl }) }}
|
{{ t('settings.imageBaseUrlActive', {
|
||||||
|
url: effectiveResourceDraft(imageDomainSeparatedDraft, imageBaseUrlDraft)
|
||||||
|
}) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
</div>
|
|
||||||
|
|
||||||
<SettingsGroup v-else>
|
<SettingsGroup>
|
||||||
<div class="space-y-3 py-4">
|
<div class="space-y-3 py-4 sm:pr-4">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<p class="text-sm font-medium text-highlighted">
|
<p class="text-sm font-medium text-highlighted">
|
||||||
{{ t('settings.imageBaseUrl') }}
|
{{ t('settings.videoBaseUrl') }}
|
||||||
</p>
|
</p>
|
||||||
<UBadge
|
<USwitch v-model="videoDomainSeparatedDraft" />
|
||||||
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>
|
</div>
|
||||||
<p class="text-xs leading-relaxed text-muted">
|
<p class="text-xs leading-relaxed text-muted">
|
||||||
{{ t('settings.imageBaseUrlSingleHint') }}
|
{{ t('settings.videoBaseUrlRoleHint') }}
|
||||||
</p>
|
</p>
|
||||||
<UInput
|
<div
|
||||||
v-model="imageBaseUrlDraft"
|
class="flex gap-2"
|
||||||
size="sm"
|
:class="videoDomainSeparatedDraft ? '' : 'pointer-events-none opacity-50'"
|
||||||
: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 }) }}
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</SettingsGroup>
|
</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>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
</SettingsPanel>
|
</SettingsPanel>
|
||||||
<div
|
<div
|
||||||
@@ -1068,29 +1184,6 @@ watch(isAdmin, () => {
|
|||||||
</section>
|
</section>
|
||||||
</AppShell>
|
</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
|
<UModal
|
||||||
:open="domainSeparationSaveConfirmOpen"
|
:open="domainSeparationSaveConfirmOpen"
|
||||||
:title="t('domainSeparationConfirm.title')"
|
:title="t('domainSeparationConfirm.title')"
|
||||||
|
|||||||
+27
-1
@@ -26,6 +26,8 @@ const allowRegistration = ref(false)
|
|||||||
const domainSeparation = ref(false)
|
const domainSeparation = ref(false)
|
||||||
const siteBaseUrl = ref('')
|
const siteBaseUrl = ref('')
|
||||||
const imageBaseUrl = ref('')
|
const imageBaseUrl = ref('')
|
||||||
|
const videoBaseUrl = ref('')
|
||||||
|
const fileBaseUrl = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const domainSeparationConfirmOpen = ref(false)
|
const domainSeparationConfirmOpen = ref(false)
|
||||||
|
|
||||||
@@ -180,7 +182,9 @@ async function performSubmit() {
|
|||||||
allowRegistration: allowRegistration.value,
|
allowRegistration: allowRegistration.value,
|
||||||
domainSeparation: domainSeparation.value,
|
domainSeparation: domainSeparation.value,
|
||||||
siteBaseUrl: domainSeparation.value ? siteBaseUrl.value.trim() : undefined,
|
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
|
const result = isMigrate.value
|
||||||
? await migrate(payload)
|
? await migrate(payload)
|
||||||
@@ -363,6 +367,28 @@ async function performSubmit() {
|
|||||||
class="w-full font-mono text-sm"
|
class="w-full font-mono text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export function hostnameFromBaseUrl(value: string): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 勾选分离且网站域、图片域主机名不同 → 保存后将启用双域名中间件隔离 */
|
/** 勾选分离且后台域、图片域主机名不同 → 保存后启用多域名中间件隔离 */
|
||||||
export function willActivateDomainSeparation(
|
export function willActivateDomainSeparation(
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
siteBaseUrl: string,
|
siteBaseUrl: string,
|
||||||
|
|||||||
+47
-16
@@ -88,8 +88,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"domainSeparationConfirm": {
|
"domainSeparationConfirm": {
|
||||||
"title": "Save dual-domain settings?",
|
"title": "Save separated-domain settings?",
|
||||||
"description": "After saving, only the site URL can serve the admin UI and API, and only the image URL can serve public image links. Access via IP, an unlisted Host, or a reverse proxy that does not forward Host correctly may lock you out of the admin UI.\n\nIf locked out, run docker exec pichost clear-domains (local dev: npm run clear-domains), or disable domain separation in Settings when you can access it again.\n\nSave anyway?",
|
"description": "After saving, the admin URL serves the admin UI and API, while the image, video and file URLs serve only their corresponding media links. Access via IP, an unlisted Host, or a reverse proxy that does not forward Host correctly may lock you out of the admin UI.\n\nIf locked out, run docker exec pichost clear-domains (local dev: npm run clear-domains), or disable domain separation in Settings when you can access it again.\n\nSave anyway?",
|
||||||
"confirm": "Save anyway"
|
"confirm": "Save anyway"
|
||||||
},
|
},
|
||||||
"setup": {
|
"setup": {
|
||||||
@@ -99,9 +99,9 @@
|
|||||||
"passwordPlaceholder": "Set login password",
|
"passwordPlaceholder": "Set login password",
|
||||||
"confirmPlaceholder": "Enter password again",
|
"confirmPlaceholder": "Enter password again",
|
||||||
"allowRegistration": "Allow user registration",
|
"allowRegistration": "Allow user registration",
|
||||||
"domainSeparation": "Separate admin and image domains",
|
"domainSeparation": "Separate admin, image, video and file domains",
|
||||||
"domainSeparationHint": "Admin and API use the site URL; image links use a dedicated image URL",
|
"domainSeparationHint": "Allow admin, images, videos and files to each use a dedicated domain.",
|
||||||
"domainSeparationProxyHint": "Point both domains to this host and reverse-proxy to port 6892 (Nginx, Caddy, NPM, etc.)",
|
"domainSeparationProxyHint": "Point the configured domains to this host and reverse-proxy to port 6892 (Nginx, Caddy, NPM, etc.)",
|
||||||
"domainSeparationProxyExample": "View example",
|
"domainSeparationProxyExample": "View example",
|
||||||
"siteBaseUrl": "Site URL",
|
"siteBaseUrl": "Site URL",
|
||||||
"siteBaseUrlHint": "No trailing slash; admin UI and API upload endpoint",
|
"siteBaseUrlHint": "No trailing slash; admin UI and API upload endpoint",
|
||||||
@@ -109,7 +109,13 @@
|
|||||||
"imageBaseUrl": "Image URL",
|
"imageBaseUrl": "Image URL",
|
||||||
"imageBaseUrlHint": "No trailing slash; public image link prefix",
|
"imageBaseUrlHint": "No trailing slash; public image link prefix",
|
||||||
"imageBaseUrlPlaceholder": "https://pic.example.com",
|
"imageBaseUrlPlaceholder": "https://pic.example.com",
|
||||||
"domainSeparationInvalid": "Enter valid site and image URLs with different hostnames",
|
"videoBaseUrl": "Video URL",
|
||||||
|
"videoBaseUrlHint": "No trailing slash; falls back to the image URL when empty",
|
||||||
|
"videoBaseUrlPlaceholder": "https://video.example.com",
|
||||||
|
"fileBaseUrl": "File URL",
|
||||||
|
"fileBaseUrlHint": "No trailing slash; falls back to the image URL when empty",
|
||||||
|
"fileBaseUrlPlaceholder": "https://file.example.com",
|
||||||
|
"domainSeparationInvalid": "Enter valid admin, image, video and file URLs",
|
||||||
"migrateSubmit": "Complete migration",
|
"migrateSubmit": "Complete migration",
|
||||||
"initSubmit": "Create and continue",
|
"initSubmit": "Create and continue",
|
||||||
"migrateSuccess": "Migration complete",
|
"migrateSuccess": "Migration complete",
|
||||||
@@ -326,7 +332,7 @@
|
|||||||
"pageTitle": "Settings & preferences",
|
"pageTitle": "Settings & preferences",
|
||||||
"pageSubtitle": "Manage system configuration and activity logs",
|
"pageSubtitle": "Manage system configuration and activity logs",
|
||||||
"pageSubtitleBasic": "Configure basic options so PicHost works better for you.",
|
"pageSubtitleBasic": "Configure basic options so PicHost works better for you.",
|
||||||
"pageSubtitleDomains": "Configure site and image URLs and upload path rules",
|
"pageSubtitleDomains": "Configure admin, image, video and file domains plus path rules",
|
||||||
"pageSubtitleAccess": "Configure image access policies to prevent hotlink abuse",
|
"pageSubtitleAccess": "Configure image access policies to prevent hotlink abuse",
|
||||||
"pageSubtitleLogs": "View uploads, deletions, edits, and configuration changes",
|
"pageSubtitleLogs": "View uploads, deletions, edits, and configuration changes",
|
||||||
"searchPlaceholder": "Search settings…",
|
"searchPlaceholder": "Search settings…",
|
||||||
@@ -337,11 +343,20 @@
|
|||||||
"defaultCopyFormatHint": "Link format copied automatically after upload",
|
"defaultCopyFormatHint": "Link format copied automatically after upload",
|
||||||
"systemVersionHint": "Currently running PicHost version",
|
"systemVersionHint": "Currently running PicHost version",
|
||||||
"badgeEnabled": "Enabled",
|
"badgeEnabled": "Enabled",
|
||||||
"domainSeparationProxyTitle": "Both domains must resolve to this host",
|
"domainSeparationProxyTitle": "Configured domains must resolve to this host",
|
||||||
"domainSeparationProxyBody": "Point the site and image URLs to this host and reverse-proxy to port 6892 (Nginx, Caddy, NPM, etc.).",
|
"domainSeparationProxyBody": "Point the admin and configured image, video and file domains to this host and reverse-proxy to port 6892 (Nginx, Caddy, NPM, etc.).",
|
||||||
"siteBaseUrlRoleHint": "URL for the admin UI",
|
"siteBaseUrlRoleHint": "Admin and API use this domain; resources use it when their switches are off",
|
||||||
"imageBaseUrlRoleHint": "URL for serving and displaying images",
|
"imageBaseUrlRoleHint": "Use this domain for images when on; use the site domain when off",
|
||||||
"imageDomainSection": "Image domain",
|
"resourceDomainSection": "Resource domains",
|
||||||
|
"videoBaseUrl": "Video URL",
|
||||||
|
"videoBaseUrlRoleHint": "Use this domain for videos, HLS and thumbnails when on; use the site domain when off",
|
||||||
|
"videoBaseUrlPlaceholder": "https://video.example.com",
|
||||||
|
"videoBaseUrlActive": "Active: {url}",
|
||||||
|
"fileBaseUrl": "File URL",
|
||||||
|
"fileBaseUrlRoleHint": "Use this domain for file links and downloads when on; use the site domain when off",
|
||||||
|
"fileBaseUrlPlaceholder": "https://file.example.com",
|
||||||
|
"fileBaseUrlActive": "Active: {url}",
|
||||||
|
"fallbackImageDomain": "Uses image URL",
|
||||||
"navBasic": "Basic",
|
"navBasic": "Basic",
|
||||||
"navDomains": "Domains & paths",
|
"navDomains": "Domains & paths",
|
||||||
"navAccess": "Access control",
|
"navAccess": "Access control",
|
||||||
@@ -402,7 +417,7 @@
|
|||||||
"defaultCopyFormat": "Default copy format",
|
"defaultCopyFormat": "Default copy format",
|
||||||
"refererPlaceholder": "example.com, www.example.com",
|
"refererPlaceholder": "example.com, www.example.com",
|
||||||
"domainSettings": "Domain settings",
|
"domainSettings": "Domain settings",
|
||||||
"domainSeparationActive": "Domain separation enabled: image host serves images only; site host serves admin and API",
|
"domainSeparationActive": "Domain separation enabled: admin, images, videos and files can use dedicated domains",
|
||||||
"siteBaseUrl": "Site URL",
|
"siteBaseUrl": "Site URL",
|
||||||
"siteBaseUrlHint": "No trailing slash; admin UI and API upload endpoint",
|
"siteBaseUrlHint": "No trailing slash; admin UI and API upload endpoint",
|
||||||
"siteBaseUrlPlaceholder": "https://admin.example.com",
|
"siteBaseUrlPlaceholder": "https://admin.example.com",
|
||||||
@@ -412,13 +427,17 @@
|
|||||||
"imageBaseUrlSingleHint": "No trailing slash; leave empty to use the current request host; single-domain setups do not need a site URL",
|
"imageBaseUrlSingleHint": "No trailing slash; leave empty to use the current request host; single-domain setups do not need a site URL",
|
||||||
"imageBaseUrlPlaceholder": "https://pic.example.com",
|
"imageBaseUrlPlaceholder": "https://pic.example.com",
|
||||||
"imageBaseUrlActive": "Active: {url}",
|
"imageBaseUrlActive": "Active: {url}",
|
||||||
|
"resourceBaseUrl": "Domain",
|
||||||
|
"resourceBaseUrlSingleHint": "No trailing slash; leave empty to use the current request host. When domain separation is off, this domain serves images, videos and files.",
|
||||||
|
"resourceBaseUrlPlaceholder": "https://cdn.example.com",
|
||||||
|
"resourceBaseUrlActive": "Current resource domain: {url}",
|
||||||
"runtimeDetected": "Detected current access: {url} (will not be saved automatically)",
|
"runtimeDetected": "Detected current access: {url} (will not be saved automatically)",
|
||||||
"fillDetectedOrigin": "Use detected URL",
|
"fillDetectedOrigin": "Use detected URL",
|
||||||
"hostRoleUnknown": "Current Host does not match configured domains; reverse-proxy bypass may be possible",
|
"hostRoleUnknown": "Current Host does not match configured domains; reverse-proxy bypass may be possible",
|
||||||
"disableDomainSeparationTitle": "Disable domain separation",
|
"disableDomainSeparationTitle": "Disable domain separation",
|
||||||
"disableDomainSeparationDesc": "This clears the site URL and keeps only the image URL. Continue?",
|
"disableDomainSeparationDesc": "This clears the admin URL and keeps the image, video and file URLs. Continue?",
|
||||||
"disableDomainSeparationConfirm": "Disable",
|
"disableDomainSeparationConfirm": "Disable",
|
||||||
"domainSeparationRequired": "Both site and image URLs are required when domain separation is enabled",
|
"domainSeparationRequired": "Admin and image URLs are required when domain separation is enabled",
|
||||||
"hideFolderInUrl": "Use short image links",
|
"hideFolderInUrl": "Use short image links",
|
||||||
"hideFolderInUrlHint": "When on, links are only domain + filename, e.g. img.example.com/abc.webp. When off, date or custom folders are kept, e.g. img.example.com/2026/08/abc.webp",
|
"hideFolderInUrlHint": "When on, links are only domain + filename, e.g. img.example.com/abc.webp. When off, date or custom folders are kept, e.g. img.example.com/2026/08/abc.webp",
|
||||||
"storageUseDatePath": "Group uploads by year/month",
|
"storageUseDatePath": "Group uploads by year/month",
|
||||||
@@ -445,6 +464,7 @@
|
|||||||
"uploadLimits": "Upload limits",
|
"uploadLimits": "Upload limits",
|
||||||
"uploadLimitsHint": "Site-wide upload policy and your personal convenience options. Applies after save.",
|
"uploadLimitsHint": "Site-wide upload policy and your personal convenience options. Applies after save.",
|
||||||
"imageProcessingHint": "Site-wide format conversion and personal compression/cleanup.",
|
"imageProcessingHint": "Site-wide format conversion and personal compression/cleanup.",
|
||||||
|
"videoProcessingHint": "Site-wide transcode resources and personal video upload preferences, shared with the video upload page.",
|
||||||
"serverPolicy": "Site-wide",
|
"serverPolicy": "Site-wide",
|
||||||
"personalPreference": "Personal",
|
"personalPreference": "Personal",
|
||||||
"uploadRateGroup": "Upload rate limits",
|
"uploadRateGroup": "Upload rate limits",
|
||||||
@@ -453,6 +473,12 @@
|
|||||||
"uploadMaxFileSizeHint": "Uploads larger than this are rejected (1–100 MB).",
|
"uploadMaxFileSizeHint": "Uploads larger than this are rejected (1–100 MB).",
|
||||||
"allowedMimeTypes": "Allowed image formats",
|
"allowedMimeTypes": "Allowed image formats",
|
||||||
"allowedMimeTypesHint": "Keep at least one format enabled. The server validates file signatures, not just extensions.",
|
"allowedMimeTypesHint": "Keep at least one format enabled. The server validates file signatures, not just extensions.",
|
||||||
|
"allowedVideoExtensions": "Allowed video formats",
|
||||||
|
"allowedVideoExtensionsHint": "Keep at least one video container enabled. Both the upload page and server enforce this list.",
|
||||||
|
"forbiddenFileExtensions": "Forbidden file formats",
|
||||||
|
"forbiddenFileExtensionsHint": "These extensions are rejected by the file upload page and server. You can add custom extensions.",
|
||||||
|
"forbiddenFileExtensionsCustomPlaceholder": "Add extensions, e.g. exe, php",
|
||||||
|
"forbiddenFileExtensionsAdd": "Add",
|
||||||
"preserveOriginalUpload": "Preserve originals (disable WebP conversion)",
|
"preserveOriginalUpload": "Preserve originals (disable WebP conversion)",
|
||||||
"preserveOriginalUploadHint": "When enabled, JPEG/PNG/GIF uploads are stored as-is instead of converting to WebP.",
|
"preserveOriginalUploadHint": "When enabled, JPEG/PNG/GIF uploads are stored as-is instead of converting to WebP.",
|
||||||
"rateLimits": "Rate limits",
|
"rateLimits": "Rate limits",
|
||||||
@@ -1129,7 +1155,9 @@
|
|||||||
"copyLink": "Copy link",
|
"copyLink": "Copy link",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"deleted": "Deleted",
|
"deleted": "Deleted",
|
||||||
"deleteFailed": "Delete failed"
|
"deleteFailed": "Delete failed",
|
||||||
|
"videoFormatBlocked": "{format} videos are not allowed",
|
||||||
|
"fileFormatBlocked": "{format} files are not allowed"
|
||||||
},
|
},
|
||||||
"host": {
|
"host": {
|
||||||
"video": "Video",
|
"video": "Video",
|
||||||
@@ -1173,6 +1201,8 @@
|
|||||||
"uploadTitle": "Drop files here to upload",
|
"uploadTitle": "Drop files here to upload",
|
||||||
"uploadHint": "or click to choose files, up to 10 at a time",
|
"uploadHint": "or click to choose files, up to 10 at a time",
|
||||||
"uploadFormats": "Any type allowed (HTML / HTM / SVG blocked), up to 100MB per file",
|
"uploadFormats": "Any type allowed (HTML / HTM / SVG blocked), up to 100MB per file",
|
||||||
|
"uploadFormatsBlocked": "{formats} blocked; other file types allowed, up to 100MB per file",
|
||||||
|
"uploadFormatsUnrestricted": "Any file type allowed, up to 100MB per file",
|
||||||
"empty": "No files yet — upload one to get started"
|
"empty": "No files yet — upload one to get started"
|
||||||
},
|
},
|
||||||
"video": {
|
"video": {
|
||||||
@@ -1196,6 +1226,7 @@
|
|||||||
"uploadTitle": "Drop videos here to upload",
|
"uploadTitle": "Drop videos here to upload",
|
||||||
"uploadHint": "or click to choose video files, up to 10 at a time",
|
"uploadHint": "or click to choose video files, up to 10 at a time",
|
||||||
"uploadFormats": "MP4 / WebM / MOV / MKV and more, up to 100MB per file",
|
"uploadFormats": "MP4 / WebM / MOV / MKV and more, up to 100MB per file",
|
||||||
|
"uploadFormatsAllowed": "{formats} supported, up to 100MB per file",
|
||||||
"empty": "No videos yet — upload one to get started"
|
"empty": "No videos yet — upload one to get started"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+47
-16
@@ -88,8 +88,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"domainSeparationConfirm": {
|
"domainSeparationConfirm": {
|
||||||
"title": "确认保存双域名配置?",
|
"title": "确认保存独立域名配置?",
|
||||||
"description": "保存后,仅网站域名可访问管理后台与 API,仅图片域名可提供图片直链。若通过 IP、未配置的 Host,或反代未正确传递 Host 访问,可能无法打开后台。\n\n若发生锁死,可执行 docker exec pichost clear-domains(本地开发:npm run clear-domains)清除域名配置,或在能访问时于设置中关闭「域名分离」。\n\n确定仍要保存吗?",
|
"description": "保存后,后台域名用于管理后台与 API;图片、视频、文件域名分别只提供对应媒体直链。若通过 IP、未配置的 Host,或反代未正确传递 Host 访问,可能无法打开后台。\n\n若发生锁死,可执行 docker exec pichost clear-domains(本地开发:npm run clear-domains)清除域名配置,或在能访问时于设置中关闭「域名分离」。\n\n确定仍要保存吗?",
|
||||||
"confirm": "确认保存"
|
"confirm": "确认保存"
|
||||||
},
|
},
|
||||||
"setup": {
|
"setup": {
|
||||||
@@ -99,9 +99,9 @@
|
|||||||
"passwordPlaceholder": "设置登录密码",
|
"passwordPlaceholder": "设置登录密码",
|
||||||
"confirmPlaceholder": "再次输入密码",
|
"confirmPlaceholder": "再次输入密码",
|
||||||
"allowRegistration": "允许其他用户注册",
|
"allowRegistration": "允许其他用户注册",
|
||||||
"domainSeparation": "启用后台与图片域名分离",
|
"domainSeparation": "启用后台与图片、视频、文件各自分离",
|
||||||
"domainSeparationHint": "启用后,可以使用不同的域名访问后台和图片资源,便于配置 CDN 和自定义域名。",
|
"domainSeparationHint": "启用后,后台、图片、视频、文件能够各自使用独立的域名。",
|
||||||
"domainSeparationProxyHint": "需将两个域名解析到本机,并通过 Nginx、Caddy、NPM 等反代到端口 6892",
|
"domainSeparationProxyHint": "需将配置的域名解析到本机,并通过 Nginx、Caddy、NPM 等反代到端口 6892",
|
||||||
"domainSeparationProxyExample": "查看示例",
|
"domainSeparationProxyExample": "查看示例",
|
||||||
"siteBaseUrl": "网站域名",
|
"siteBaseUrl": "网站域名",
|
||||||
"siteBaseUrlHint": "末尾不加 /;管理后台、API 上传地址",
|
"siteBaseUrlHint": "末尾不加 /;管理后台、API 上传地址",
|
||||||
@@ -109,7 +109,13 @@
|
|||||||
"imageBaseUrl": "图片域名",
|
"imageBaseUrl": "图片域名",
|
||||||
"imageBaseUrlHint": "末尾不加 /;生成图片直链地址",
|
"imageBaseUrlHint": "末尾不加 /;生成图片直链地址",
|
||||||
"imageBaseUrlPlaceholder": "https://pic.example.com",
|
"imageBaseUrlPlaceholder": "https://pic.example.com",
|
||||||
"domainSeparationInvalid": "请填写有效的网站域名与图片域名,且两者不能相同",
|
"videoBaseUrl": "视频域名",
|
||||||
|
"videoBaseUrlHint": "末尾不加 /;留空时沿用图片域名",
|
||||||
|
"videoBaseUrlPlaceholder": "https://video.example.com",
|
||||||
|
"fileBaseUrl": "文件域名",
|
||||||
|
"fileBaseUrlHint": "末尾不加 /;留空时沿用图片域名",
|
||||||
|
"fileBaseUrlPlaceholder": "https://file.example.com",
|
||||||
|
"domainSeparationInvalid": "请填写有效的后台、图片、视频、文件域名",
|
||||||
"migrateSubmit": "完成迁移",
|
"migrateSubmit": "完成迁移",
|
||||||
"initSubmit": "创建并进入",
|
"initSubmit": "创建并进入",
|
||||||
"migrateSuccess": "账号迁移完成",
|
"migrateSuccess": "账号迁移完成",
|
||||||
@@ -326,7 +332,7 @@
|
|||||||
"pageTitle": "设置与偏好",
|
"pageTitle": "设置与偏好",
|
||||||
"pageSubtitle": "管理系统配置与操作日志",
|
"pageSubtitle": "管理系统配置与操作日志",
|
||||||
"pageSubtitleBasic": "配置系统的基本选项,让 PicHost 更好地为你服务。",
|
"pageSubtitleBasic": "配置系统的基本选项,让 PicHost 更好地为你服务。",
|
||||||
"pageSubtitleDomains": "配置网站域名、图片域名以及图片存储路径等相关设置",
|
"pageSubtitleDomains": "配置后台、图片、视频、文件域名以及存储路径等相关设置",
|
||||||
"pageSubtitleAccess": "配置图片访问安全策略,防止资源被恶意盗用。",
|
"pageSubtitleAccess": "配置图片访问安全策略,防止资源被恶意盗用。",
|
||||||
"pageSubtitleLogs": "查看用户上传、删除、修改与系统配置记录",
|
"pageSubtitleLogs": "查看用户上传、删除、修改与系统配置记录",
|
||||||
"searchPlaceholder": "搜索设置项…",
|
"searchPlaceholder": "搜索设置项…",
|
||||||
@@ -337,11 +343,20 @@
|
|||||||
"defaultCopyFormatHint": "设置上传后自动复制的链接格式。",
|
"defaultCopyFormatHint": "设置上传后自动复制的链接格式。",
|
||||||
"systemVersionHint": "当前运行的 PicHost 版本。",
|
"systemVersionHint": "当前运行的 PicHost 版本。",
|
||||||
"badgeEnabled": "已启用",
|
"badgeEnabled": "已启用",
|
||||||
"domainSeparationProxyTitle": "需要将两个域名解析到本机",
|
"domainSeparationProxyTitle": "需要将配置的域名解析到本机",
|
||||||
"domainSeparationProxyBody": "请将网站域名和图片域名都解析到本机,并通过 Nginx、Caddy、NPM 等反向代理到本机的 6892 端口。",
|
"domainSeparationProxyBody": "请将后台域名和已配置的图片、视频、文件域名解析到本机,并通过 Nginx、Caddy、NPM 等反向代理到本机的 6892 端口。",
|
||||||
"siteBaseUrlRoleHint": "用于访问后台管理界面的网站域名。",
|
"siteBaseUrlRoleHint": "后台与 API 使用该域名;资源开关关闭时,图片、视频、文件也使用它。",
|
||||||
"imageBaseUrlRoleHint": "用于访问和显示图片资源的域名。",
|
"imageBaseUrlRoleHint": "打开后图片单独走该域名;关闭时使用网站域名。",
|
||||||
"imageDomainSection": "图片域名",
|
"resourceDomainSection": "资源域名",
|
||||||
|
"videoBaseUrl": "视频域名",
|
||||||
|
"videoBaseUrlRoleHint": "打开后视频、HLS 和缩略图单独走该域名;关闭时使用网站域名。",
|
||||||
|
"videoBaseUrlPlaceholder": "https://video.example.com",
|
||||||
|
"videoBaseUrlActive": "当前生效:{url}",
|
||||||
|
"fileBaseUrl": "文件域名",
|
||||||
|
"fileBaseUrlRoleHint": "打开后文件直链和下载单独走该域名;关闭时使用网站域名。",
|
||||||
|
"fileBaseUrlPlaceholder": "https://file.example.com",
|
||||||
|
"fileBaseUrlActive": "当前生效:{url}",
|
||||||
|
"fallbackImageDomain": "沿用图片域名",
|
||||||
"navBasic": "基础设置",
|
"navBasic": "基础设置",
|
||||||
"navDomains": "域名与路径",
|
"navDomains": "域名与路径",
|
||||||
"navAccess": "访问控制",
|
"navAccess": "访问控制",
|
||||||
@@ -402,7 +417,7 @@
|
|||||||
"defaultCopyFormat": "默认复制格式",
|
"defaultCopyFormat": "默认复制格式",
|
||||||
"refererPlaceholder": "example.com, www.example.com",
|
"refererPlaceholder": "example.com, www.example.com",
|
||||||
"domainSettings": "域名配置",
|
"domainSettings": "域名配置",
|
||||||
"domainSeparationActive": "已启用域名分离:图片域仅出图,管理域提供后台与 API",
|
"domainSeparationActive": "已启用域名分离:后台、图片、视频、文件可使用独立域名",
|
||||||
"siteBaseUrl": "网站域名",
|
"siteBaseUrl": "网站域名",
|
||||||
"siteBaseUrlHint": "末尾不加 /;管理后台、API 上传地址",
|
"siteBaseUrlHint": "末尾不加 /;管理后台、API 上传地址",
|
||||||
"siteBaseUrlPlaceholder": "https://admin.example.com",
|
"siteBaseUrlPlaceholder": "https://admin.example.com",
|
||||||
@@ -412,13 +427,17 @@
|
|||||||
"imageBaseUrlSingleHint": "末尾不加 /;留空则使用当前访问域名;单域名部署无需填写网站域名",
|
"imageBaseUrlSingleHint": "末尾不加 /;留空则使用当前访问域名;单域名部署无需填写网站域名",
|
||||||
"imageBaseUrlPlaceholder": "https://pic.example.com",
|
"imageBaseUrlPlaceholder": "https://pic.example.com",
|
||||||
"imageBaseUrlActive": "当前生效:{url}",
|
"imageBaseUrlActive": "当前生效:{url}",
|
||||||
|
"resourceBaseUrl": "域名",
|
||||||
|
"resourceBaseUrlSingleHint": "末尾不加 /;留空则使用当前访问域名。关闭域名分离时,该域名统一用于图片、视频和文件资源。",
|
||||||
|
"resourceBaseUrlPlaceholder": "https://cdn.example.com",
|
||||||
|
"resourceBaseUrlActive": "当前资源域名:{url}",
|
||||||
"runtimeDetected": "检测到当前访问:{url}(不会自动写入配置)",
|
"runtimeDetected": "检测到当前访问:{url}(不会自动写入配置)",
|
||||||
"fillDetectedOrigin": "填入检测地址",
|
"fillDetectedOrigin": "填入检测地址",
|
||||||
"hostRoleUnknown": "当前访问 Host 与配置不一致,可能存在反代绕过风险",
|
"hostRoleUnknown": "当前访问 Host 与配置不一致,可能存在反代绕过风险",
|
||||||
"disableDomainSeparationTitle": "关闭域名分离",
|
"disableDomainSeparationTitle": "关闭域名分离",
|
||||||
"disableDomainSeparationDesc": "将清除管理域名配置,仅保留图片域名。确定继续?",
|
"disableDomainSeparationDesc": "将清除后台域名配置,并保留图片、视频、文件域名。确定继续?",
|
||||||
"disableDomainSeparationConfirm": "确认关闭",
|
"disableDomainSeparationConfirm": "确认关闭",
|
||||||
"domainSeparationRequired": "启用域名分离时需同时填写网站域名与图片域名",
|
"domainSeparationRequired": "启用域名分离时需同时填写后台域名与图片域名",
|
||||||
"hideFolderInUrl": "使用简短图片链接",
|
"hideFolderInUrl": "使用简短图片链接",
|
||||||
"hideFolderInUrlHint": "启用后,生成的图片链接将使用简短格式(如 /abc123.png),不包含年月目录。",
|
"hideFolderInUrlHint": "启用后,生成的图片链接将使用简短格式(如 /abc123.png),不包含年月目录。",
|
||||||
"storageUseDatePath": "上传目录按年/月分组",
|
"storageUseDatePath": "上传目录按年/月分组",
|
||||||
@@ -445,6 +464,7 @@
|
|||||||
"uploadLimits": "上传限制",
|
"uploadLimits": "上传限制",
|
||||||
"uploadLimitsHint": "全站上传策略与个人上传便捷选项,保存后立即生效。",
|
"uploadLimitsHint": "全站上传策略与个人上传便捷选项,保存后立即生效。",
|
||||||
"imageProcessingHint": "全站格式转换与个人端压缩、清理策略。",
|
"imageProcessingHint": "全站格式转换与个人端压缩、清理策略。",
|
||||||
|
"videoProcessingHint": "全站视频转码资源与个人视频上传偏好,保存后与视频上传页同步。",
|
||||||
"serverPolicy": "全站策略",
|
"serverPolicy": "全站策略",
|
||||||
"personalPreference": "个人偏好",
|
"personalPreference": "个人偏好",
|
||||||
"uploadRateGroup": "上传限流",
|
"uploadRateGroup": "上传限流",
|
||||||
@@ -453,6 +473,12 @@
|
|||||||
"uploadMaxFileSizeHint": "超过此大小的图片将被拒绝上传(1–100 MB)。",
|
"uploadMaxFileSizeHint": "超过此大小的图片将被拒绝上传(1–100 MB)。",
|
||||||
"allowedMimeTypes": "允许的图片格式",
|
"allowedMimeTypes": "允许的图片格式",
|
||||||
"allowedMimeTypesHint": "至少保留一种格式;服务端会校验文件签名,不仅看扩展名。",
|
"allowedMimeTypesHint": "至少保留一种格式;服务端会校验文件签名,不仅看扩展名。",
|
||||||
|
"allowedVideoExtensions": "允许的视频格式",
|
||||||
|
"allowedVideoExtensionsHint": "至少保留一种视频容器格式;视频上传页和服务端都会按此列表校验。",
|
||||||
|
"forbiddenFileExtensions": "不允许的文件格式",
|
||||||
|
"forbiddenFileExtensionsHint": "文件上传页和服务端都会拒绝这些扩展名,可额外添加自定义后缀。",
|
||||||
|
"forbiddenFileExtensionsCustomPlaceholder": "添加后缀,如 exe, php",
|
||||||
|
"forbiddenFileExtensionsAdd": "添加",
|
||||||
"preserveOriginalUpload": "保留原图(关闭 WebP 转换)",
|
"preserveOriginalUpload": "保留原图(关闭 WebP 转换)",
|
||||||
"preserveOriginalUploadHint": "开启后上传的 JPEG/PNG/GIF 不再转为 WebP,按原格式存储;关闭时可节省空间。",
|
"preserveOriginalUploadHint": "开启后上传的 JPEG/PNG/GIF 不再转为 WebP,按原格式存储;关闭时可节省空间。",
|
||||||
"rateLimits": "限流",
|
"rateLimits": "限流",
|
||||||
@@ -1132,7 +1158,9 @@
|
|||||||
"copyLink": "复制直链",
|
"copyLink": "复制直链",
|
||||||
"download": "下载",
|
"download": "下载",
|
||||||
"deleted": "已删除",
|
"deleted": "已删除",
|
||||||
"deleteFailed": "删除失败"
|
"deleteFailed": "删除失败",
|
||||||
|
"videoFormatBlocked": "不允许上传 {format} 视频格式",
|
||||||
|
"fileFormatBlocked": "不允许上传 {format} 文件格式"
|
||||||
},
|
},
|
||||||
"host": {
|
"host": {
|
||||||
"video": "视频",
|
"video": "视频",
|
||||||
@@ -1176,6 +1204,8 @@
|
|||||||
"uploadTitle": "拖拽文件到此处上传",
|
"uploadTitle": "拖拽文件到此处上传",
|
||||||
"uploadHint": "或点击选择文件,单次最多 10 个",
|
"uploadHint": "或点击选择文件,单次最多 10 个",
|
||||||
"uploadFormats": "支持任意类型(禁止 HTML / HTM / SVG),单个文件最大 100MB",
|
"uploadFormats": "支持任意类型(禁止 HTML / HTM / SVG),单个文件最大 100MB",
|
||||||
|
"uploadFormatsBlocked": "禁止 {formats};其他文件类型可上传,单个文件最大 100MB",
|
||||||
|
"uploadFormatsUnrestricted": "支持任意类型,单个文件最大 100MB",
|
||||||
"empty": "还没有文件,上传一个试试"
|
"empty": "还没有文件,上传一个试试"
|
||||||
},
|
},
|
||||||
"video": {
|
"video": {
|
||||||
@@ -1199,6 +1229,7 @@
|
|||||||
"uploadTitle": "拖拽视频到此处上传",
|
"uploadTitle": "拖拽视频到此处上传",
|
||||||
"uploadHint": "或点击选择视频文件,单次最多 10 个",
|
"uploadHint": "或点击选择视频文件,单次最多 10 个",
|
||||||
"uploadFormats": "支持 MP4 / WebM / MOV / MKV 等,单个文件最大 100MB",
|
"uploadFormats": "支持 MP4 / WebM / MOV / MKV 等,单个文件最大 100MB",
|
||||||
|
"uploadFormatsAllowed": "支持 {formats},单个文件最大 100MB",
|
||||||
"empty": "还没有视频,上传一个试试"
|
"empty": "还没有视频,上传一个试试"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ import {
|
|||||||
SETTINGS_STORAGE_USE_DATE_PATH,
|
SETTINGS_STORAGE_USE_DATE_PATH,
|
||||||
SETTINGS_IMAGE_BASE_URL,
|
SETTINGS_IMAGE_BASE_URL,
|
||||||
SETTINGS_SITE_BASE_URL,
|
SETTINGS_SITE_BASE_URL,
|
||||||
|
SETTINGS_VIDEO_BASE_URL,
|
||||||
|
SETTINGS_FILE_BASE_URL,
|
||||||
|
SETTINGS_DOMAIN_SEPARATION,
|
||||||
|
SETTINGS_IMAGE_DOMAIN_SEPARATED,
|
||||||
|
SETTINGS_VIDEO_DOMAIN_SEPARATED,
|
||||||
|
SETTINGS_FILE_DOMAIN_SEPARATED,
|
||||||
SETTINGS_WEBP_QUALITY,
|
SETTINGS_WEBP_QUALITY,
|
||||||
SETTINGS_LOGIN_VERIFICATION_METHOD,
|
SETTINGS_LOGIN_VERIFICATION_METHOD,
|
||||||
SETTINGS_TURNSTILE_SITE_KEY,
|
SETTINGS_TURNSTILE_SITE_KEY,
|
||||||
@@ -28,6 +34,8 @@ import {
|
|||||||
parseLoginVerificationMethod,
|
parseLoginVerificationMethod,
|
||||||
MAX_AUTO_DELETE_DAYS,
|
MAX_AUTO_DELETE_DAYS,
|
||||||
normalizeImageBaseUrl,
|
normalizeImageBaseUrl,
|
||||||
|
normalizeVideoBaseUrl,
|
||||||
|
normalizeFileBaseUrl,
|
||||||
normalizeRefererHosts,
|
normalizeRefererHosts,
|
||||||
normalizeSiteBaseUrl,
|
normalizeSiteBaseUrl,
|
||||||
parseAutoDeleteDays,
|
parseAutoDeleteDays,
|
||||||
@@ -38,6 +46,8 @@ import {
|
|||||||
} from '../../utils/env'
|
} from '../../utils/env'
|
||||||
import {
|
import {
|
||||||
normalizeAllowedMimeTypes,
|
normalizeAllowedMimeTypes,
|
||||||
|
normalizeAllowedVideoExtensions,
|
||||||
|
normalizeForbiddenFileExtensions,
|
||||||
parseLoginRateMax,
|
parseLoginRateMax,
|
||||||
parseLoginRateWindowMinutes,
|
parseLoginRateWindowMinutes,
|
||||||
parseUploadMaxFileSizeMb,
|
parseUploadMaxFileSizeMb,
|
||||||
@@ -52,6 +62,11 @@ interface SettingsPatchBody {
|
|||||||
allowedRefererHosts?: unknown
|
allowedRefererHosts?: unknown
|
||||||
siteBaseUrl?: unknown
|
siteBaseUrl?: unknown
|
||||||
imageBaseUrl?: unknown
|
imageBaseUrl?: unknown
|
||||||
|
videoBaseUrl?: unknown
|
||||||
|
fileBaseUrl?: unknown
|
||||||
|
imageDomainSeparated?: unknown
|
||||||
|
videoDomainSeparated?: unknown
|
||||||
|
fileDomainSeparated?: unknown
|
||||||
domainSeparation?: unknown
|
domainSeparation?: unknown
|
||||||
hideFolderInUrl?: unknown
|
hideFolderInUrl?: unknown
|
||||||
storageUseDatePath?: unknown
|
storageUseDatePath?: unknown
|
||||||
@@ -64,6 +79,8 @@ interface SettingsPatchBody {
|
|||||||
capSecret?: unknown
|
capSecret?: unknown
|
||||||
uploadMaxFileSizeMb?: unknown
|
uploadMaxFileSizeMb?: unknown
|
||||||
allowedMimeTypes?: unknown
|
allowedMimeTypes?: unknown
|
||||||
|
allowedVideoExtensions?: unknown
|
||||||
|
forbiddenFileExtensions?: unknown
|
||||||
uploadRateIpMax?: unknown
|
uploadRateIpMax?: unknown
|
||||||
uploadRateTokenMax?: unknown
|
uploadRateTokenMax?: unknown
|
||||||
uploadRateWindowMinutes?: unknown
|
uploadRateWindowMinutes?: unknown
|
||||||
@@ -109,8 +126,18 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
const existingSite = normalizeSiteBaseUrl(getSetting(SETTINGS_SITE_BASE_URL) ?? '')
|
const existingSite = normalizeSiteBaseUrl(getSetting(SETTINGS_SITE_BASE_URL) ?? '')
|
||||||
const existingImage = normalizeImageBaseUrl(getSetting(SETTINGS_IMAGE_BASE_URL) ?? '')
|
const existingImage = normalizeImageBaseUrl(getSetting(SETTINGS_IMAGE_BASE_URL) ?? '')
|
||||||
|
const existingVideo = normalizeVideoBaseUrl(getSetting(SETTINGS_VIDEO_BASE_URL) ?? '')
|
||||||
|
const existingFile = normalizeFileBaseUrl(getSetting(SETTINGS_FILE_BASE_URL) ?? '')
|
||||||
|
const existingImageSeparated = getSetting(SETTINGS_IMAGE_DOMAIN_SEPARATED) === 'true'
|
||||||
|
const existingVideoSeparated = getSetting(SETTINGS_VIDEO_DOMAIN_SEPARATED) === 'true'
|
||||||
|
const existingFileSeparated = getSetting(SETTINGS_FILE_DOMAIN_SEPARATED) === 'true'
|
||||||
let nextSite = existingSite
|
let nextSite = existingSite
|
||||||
let nextImage = existingImage
|
let nextImage = existingImage
|
||||||
|
let nextVideo = existingVideo
|
||||||
|
let nextFile = existingFile
|
||||||
|
let nextImageSeparated = existingImageSeparated
|
||||||
|
let nextVideoSeparated = existingVideoSeparated
|
||||||
|
let nextFileSeparated = existingFileSeparated
|
||||||
|
|
||||||
if (body.siteBaseUrl !== undefined) {
|
if (body.siteBaseUrl !== undefined) {
|
||||||
const configured = normalizeSiteBaseUrl(
|
const configured = normalizeSiteBaseUrl(
|
||||||
@@ -142,6 +169,36 @@ export default defineEventHandler(async (event) => {
|
|||||||
nextImage = configured
|
nextImage = configured
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (body.videoBaseUrl !== undefined) {
|
||||||
|
const configured = normalizeVideoBaseUrl(
|
||||||
|
typeof body.videoBaseUrl === 'string' ? body.videoBaseUrl : ''
|
||||||
|
)
|
||||||
|
if (configured && !isValidImageBaseUrl(configured)) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '视频域名需为 http(s) 地址,且末尾不加 /', 400)
|
||||||
|
}
|
||||||
|
nextVideo = configured
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.fileBaseUrl !== undefined) {
|
||||||
|
const configured = normalizeFileBaseUrl(
|
||||||
|
typeof body.fileBaseUrl === 'string' ? body.fileBaseUrl : ''
|
||||||
|
)
|
||||||
|
if (configured && !isValidImageBaseUrl(configured)) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '文件域名需为 http(s) 地址,且末尾不加 /', 400)
|
||||||
|
}
|
||||||
|
nextFile = configured
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.imageDomainSeparated !== undefined) {
|
||||||
|
nextImageSeparated = Boolean(body.imageDomainSeparated)
|
||||||
|
}
|
||||||
|
if (body.videoDomainSeparated !== undefined) {
|
||||||
|
nextVideoSeparated = Boolean(body.videoDomainSeparated)
|
||||||
|
}
|
||||||
|
if (body.fileDomainSeparated !== undefined) {
|
||||||
|
nextFileSeparated = Boolean(body.fileDomainSeparated)
|
||||||
|
}
|
||||||
|
|
||||||
const domainSeparationProvided = body.domainSeparation !== undefined
|
const domainSeparationProvided = body.domainSeparation !== undefined
|
||||||
const wantSeparation = domainSeparationProvided
|
const wantSeparation = domainSeparationProvided
|
||||||
? Boolean(body.domainSeparation)
|
? Boolean(body.domainSeparation)
|
||||||
@@ -150,19 +207,38 @@ export default defineEventHandler(async (event) => {
|
|||||||
const patchError = validateSettingsDomainPatch({
|
const patchError = validateSettingsDomainPatch({
|
||||||
existingSite,
|
existingSite,
|
||||||
existingImage,
|
existingImage,
|
||||||
|
existingVideo,
|
||||||
|
existingFile,
|
||||||
nextSite,
|
nextSite,
|
||||||
nextImage,
|
nextImage,
|
||||||
|
nextVideo,
|
||||||
|
nextFile,
|
||||||
wantSeparation,
|
wantSeparation,
|
||||||
siteBaseUrlProvided: body.siteBaseUrl !== undefined
|
siteBaseUrlProvided: body.siteBaseUrl !== undefined
|
||||||
})
|
})
|
||||||
if (patchError) {
|
if (patchError) {
|
||||||
createApiError(event, 'INVALID_REQUEST', patchError, 400)
|
createApiError(event, 'INVALID_REQUEST', patchError, 400)
|
||||||
}
|
}
|
||||||
|
if (wantSeparation === true) {
|
||||||
|
if (nextImageSeparated && !nextImage) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '开启图片独立域名时必须填写图片域名', 400)
|
||||||
|
}
|
||||||
|
if (nextVideoSeparated && !nextVideo) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '开启视频独立域名时必须填写视频域名', 400)
|
||||||
|
}
|
||||||
|
if (nextFileSeparated && !nextFile) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '开启文件独立域名时必须填写文件域名', 400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (wantSeparation === false) {
|
if (wantSeparation === false) {
|
||||||
nextSite = ''
|
nextSite = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (body.domainSeparation !== undefined) {
|
||||||
|
setSetting(SETTINGS_DOMAIN_SEPARATION, wantSeparation ? 'true' : 'false')
|
||||||
|
}
|
||||||
|
|
||||||
if (body.siteBaseUrl !== undefined || body.domainSeparation !== undefined) {
|
if (body.siteBaseUrl !== undefined || body.domainSeparation !== undefined) {
|
||||||
setSetting(SETTINGS_SITE_BASE_URL, nextSite)
|
setSetting(SETTINGS_SITE_BASE_URL, nextSite)
|
||||||
}
|
}
|
||||||
@@ -170,6 +246,21 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (body.imageBaseUrl !== undefined) {
|
if (body.imageBaseUrl !== undefined) {
|
||||||
setSetting(SETTINGS_IMAGE_BASE_URL, nextImage)
|
setSetting(SETTINGS_IMAGE_BASE_URL, nextImage)
|
||||||
}
|
}
|
||||||
|
if (body.videoBaseUrl !== undefined || body.domainSeparation !== undefined) {
|
||||||
|
setSetting(SETTINGS_VIDEO_BASE_URL, nextVideo)
|
||||||
|
}
|
||||||
|
if (body.fileBaseUrl !== undefined || body.domainSeparation !== undefined) {
|
||||||
|
setSetting(SETTINGS_FILE_BASE_URL, nextFile)
|
||||||
|
}
|
||||||
|
if (body.imageDomainSeparated !== undefined) {
|
||||||
|
setSetting(SETTINGS_IMAGE_DOMAIN_SEPARATED, nextImageSeparated ? 'true' : 'false')
|
||||||
|
}
|
||||||
|
if (body.videoDomainSeparated !== undefined) {
|
||||||
|
setSetting(SETTINGS_VIDEO_DOMAIN_SEPARATED, nextVideoSeparated ? 'true' : 'false')
|
||||||
|
}
|
||||||
|
if (body.fileDomainSeparated !== undefined) {
|
||||||
|
setSetting(SETTINGS_FILE_DOMAIN_SEPARATED, nextFileSeparated ? 'true' : 'false')
|
||||||
|
}
|
||||||
|
|
||||||
if (body.autoDeleteDays !== undefined) {
|
if (body.autoDeleteDays !== undefined) {
|
||||||
const days = parseAutoDeleteDays(String(body.autoDeleteDays))
|
const days = parseAutoDeleteDays(String(body.autoDeleteDays))
|
||||||
@@ -289,6 +380,26 @@ export default defineEventHandler(async (event) => {
|
|||||||
setUploadPolicySettings({ allowedMimeTypes: normalized })
|
setUploadPolicySettings({ allowedMimeTypes: normalized })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (body.allowedVideoExtensions !== undefined) {
|
||||||
|
const raw = Array.isArray(body.allowedVideoExtensions)
|
||||||
|
? body.allowedVideoExtensions.map(item => String(item))
|
||||||
|
: String(body.allowedVideoExtensions).split(',')
|
||||||
|
const normalized = normalizeAllowedVideoExtensions(raw.join(','))
|
||||||
|
if (normalized.length === 0) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '至少选择一种允许上传的视频格式', 400)
|
||||||
|
}
|
||||||
|
setUploadPolicySettings({ allowedVideoExtensions: normalized })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.forbiddenFileExtensions !== undefined) {
|
||||||
|
const raw = Array.isArray(body.forbiddenFileExtensions)
|
||||||
|
? body.forbiddenFileExtensions.map(item => String(item))
|
||||||
|
: String(body.forbiddenFileExtensions).split(',')
|
||||||
|
setUploadPolicySettings({
|
||||||
|
forbiddenFileExtensions: normalizeForbiddenFileExtensions(raw.join(','))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (body.uploadRateIpMax !== undefined) {
|
if (body.uploadRateIpMax !== undefined) {
|
||||||
const value = parseUploadRateIpMax(String(body.uploadRateIpMax))
|
const value = parseUploadRateIpMax(String(body.uploadRateIpMax))
|
||||||
if (value === null) {
|
if (value === null) {
|
||||||
|
|||||||
@@ -4,10 +4,23 @@ import {
|
|||||||
createUserAccount,
|
createUserAccount,
|
||||||
isInitialized
|
isInitialized
|
||||||
} from '../utils/auth'
|
} from '../utils/auth'
|
||||||
import { setAllowRegistration, setSetting, SETTINGS_IMAGE_BASE_URL, SETTINGS_SITE_BASE_URL } from '../utils/db'
|
|
||||||
import {
|
import {
|
||||||
|
setAllowRegistration,
|
||||||
|
setSetting,
|
||||||
|
SETTINGS_IMAGE_BASE_URL,
|
||||||
|
SETTINGS_SITE_BASE_URL,
|
||||||
|
SETTINGS_VIDEO_BASE_URL,
|
||||||
|
SETTINGS_FILE_BASE_URL,
|
||||||
|
SETTINGS_DOMAIN_SEPARATION,
|
||||||
|
SETTINGS_IMAGE_DOMAIN_SEPARATED,
|
||||||
|
SETTINGS_VIDEO_DOMAIN_SEPARATED,
|
||||||
|
SETTINGS_FILE_DOMAIN_SEPARATED
|
||||||
|
} from '../utils/db'
|
||||||
|
import {
|
||||||
|
normalizeFileBaseUrl,
|
||||||
normalizeImageBaseUrl,
|
normalizeImageBaseUrl,
|
||||||
normalizeSiteBaseUrl,
|
normalizeSiteBaseUrl,
|
||||||
|
normalizeVideoBaseUrl,
|
||||||
validateDomainSeparationPair
|
validateDomainSeparationPair
|
||||||
} from '../utils/env'
|
} from '../utils/env'
|
||||||
import { clientIp, logInfo } from '../utils/logger'
|
import { clientIp, logInfo } from '../utils/logger'
|
||||||
@@ -19,6 +32,8 @@ interface SetupBody {
|
|||||||
domainSeparation?: boolean
|
domainSeparation?: boolean
|
||||||
siteBaseUrl?: string
|
siteBaseUrl?: string
|
||||||
imageBaseUrl?: string
|
imageBaseUrl?: string
|
||||||
|
videoBaseUrl?: string
|
||||||
|
fileBaseUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
@@ -42,12 +57,27 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (body?.domainSeparation) {
|
if (body?.domainSeparation) {
|
||||||
const siteBaseUrl = normalizeSiteBaseUrl(body.siteBaseUrl ?? '')
|
const siteBaseUrl = normalizeSiteBaseUrl(body.siteBaseUrl ?? '')
|
||||||
const imageBaseUrl = normalizeImageBaseUrl(body.imageBaseUrl ?? '')
|
const imageBaseUrl = normalizeImageBaseUrl(body.imageBaseUrl ?? '')
|
||||||
const pairError = validateDomainSeparationPair(siteBaseUrl, imageBaseUrl)
|
const videoBaseUrl = normalizeVideoBaseUrl(body.videoBaseUrl ?? '')
|
||||||
|
const fileBaseUrl = normalizeFileBaseUrl(body.fileBaseUrl ?? '')
|
||||||
|
const pairError = validateDomainSeparationPair(
|
||||||
|
siteBaseUrl,
|
||||||
|
imageBaseUrl,
|
||||||
|
videoBaseUrl,
|
||||||
|
fileBaseUrl
|
||||||
|
)
|
||||||
if (pairError) {
|
if (pairError) {
|
||||||
createApiError(event, 'INVALID_REQUEST', pairError, 400)
|
createApiError(event, 'INVALID_REQUEST', pairError, 400)
|
||||||
}
|
}
|
||||||
setSetting(SETTINGS_SITE_BASE_URL, siteBaseUrl)
|
setSetting(SETTINGS_SITE_BASE_URL, siteBaseUrl)
|
||||||
setSetting(SETTINGS_IMAGE_BASE_URL, imageBaseUrl)
|
setSetting(SETTINGS_IMAGE_BASE_URL, imageBaseUrl)
|
||||||
|
setSetting(SETTINGS_VIDEO_BASE_URL, videoBaseUrl)
|
||||||
|
setSetting(SETTINGS_FILE_BASE_URL, fileBaseUrl)
|
||||||
|
setSetting(SETTINGS_DOMAIN_SEPARATION, 'true')
|
||||||
|
setSetting(SETTINGS_IMAGE_DOMAIN_SEPARATED, imageBaseUrl ? 'true' : 'false')
|
||||||
|
setSetting(SETTINGS_VIDEO_DOMAIN_SEPARATED, videoBaseUrl ? 'true' : 'false')
|
||||||
|
setSetting(SETTINGS_FILE_DOMAIN_SEPARATED, fileBaseUrl ? 'true' : 'false')
|
||||||
|
} else {
|
||||||
|
setSetting(SETTINGS_DOMAIN_SEPARATION, 'false')
|
||||||
}
|
}
|
||||||
|
|
||||||
await createSession(event, user.id)
|
await createSession(event, user.id)
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import {
|
||||||
|
getAllowedMimeTypesList,
|
||||||
|
getAllowedVideoExtensions,
|
||||||
|
getForbiddenFileExtensions
|
||||||
|
} from '../utils/upload-policy'
|
||||||
|
|
||||||
|
export default defineEventHandler((event) => {
|
||||||
|
setResponseHeader(event, 'Cache-Control', 'no-store')
|
||||||
|
return {
|
||||||
|
allowedMimeTypes: getAllowedMimeTypesList(),
|
||||||
|
allowedVideoExtensions: getAllowedVideoExtensions(),
|
||||||
|
forbiddenFileExtensions: getForbiddenFileExtensions()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import { requireUserAuth } from '../../utils/access'
|
import { requireUserAuth } from '../../utils/access'
|
||||||
import { ensureUserApiToken } from '../../utils/db'
|
import { ensureUserApiToken } from '../../utils/db'
|
||||||
import { getImageBaseUrl, getSiteBaseUrl, isHideFolderInUrl } from '../../utils/env'
|
import {
|
||||||
|
getFileBaseUrl,
|
||||||
|
getImageBaseUrl,
|
||||||
|
getSiteBaseUrl,
|
||||||
|
getVideoBaseUrl,
|
||||||
|
isHideFolderInUrl
|
||||||
|
} from '../../utils/env'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const user = await requireUserAuth(event)
|
const user = await requireUserAuth(event)
|
||||||
@@ -15,6 +21,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
refererConfigured: false,
|
refererConfigured: false,
|
||||||
siteBaseUrl: getSiteBaseUrl(event),
|
siteBaseUrl: getSiteBaseUrl(event),
|
||||||
imageBaseUrl: getImageBaseUrl(event),
|
imageBaseUrl: getImageBaseUrl(event),
|
||||||
|
videoBaseUrl: getVideoBaseUrl(event),
|
||||||
|
fileBaseUrl: getFileBaseUrl(event),
|
||||||
hideFolderInUrl: isHideFolderInUrl(event),
|
hideFolderInUrl: isHideFolderInUrl(event),
|
||||||
appVersion: config.appVersion as string
|
appVersion: config.appVersion as string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,21 +4,29 @@ import { DatabaseSync } from 'node:sqlite'
|
|||||||
|
|
||||||
const SITE_KEY = 'site_base_url'
|
const SITE_KEY = 'site_base_url'
|
||||||
const IMAGE_KEY = 'image_base_url'
|
const IMAGE_KEY = 'image_base_url'
|
||||||
|
const VIDEO_KEY = 'video_base_url'
|
||||||
|
const FILE_KEY = 'file_base_url'
|
||||||
|
const DOMAIN_SEPARATION_KEY = 'domain_separation_enabled'
|
||||||
|
|
||||||
const args = process.argv.slice(2)
|
const args = process.argv.slice(2)
|
||||||
const siteOnly = args.includes('--site')
|
const siteOnly = args.includes('--site')
|
||||||
const imageOnly = args.includes('--image')
|
const imageOnly = args.includes('--image')
|
||||||
|
const videoOnly = args.includes('--video')
|
||||||
|
const fileOnly = args.includes('--file')
|
||||||
|
const selectiveFlags = [siteOnly, imageOnly, videoOnly, fileOnly].filter(Boolean).length
|
||||||
|
|
||||||
if (args.some(arg => arg.startsWith('--') && arg !== '--site' && arg !== '--image')) {
|
if (args.some(arg => !['--site', '--image', '--video', '--file'].includes(arg))) {
|
||||||
console.error('Usage: clear-domains [--site] [--image]')
|
console.error('Usage: clear-domains [--site] [--image] [--video] [--file]')
|
||||||
console.error(' (no flags) Clear both site and image URLs from settings')
|
console.error(' (no flags) Clear all domain URLs from settings')
|
||||||
console.error(' --site Clear site URL only')
|
console.error(' --site Clear site URL only')
|
||||||
console.error(' --image Clear image URL only')
|
console.error(' --image Clear image URL only')
|
||||||
|
console.error(' --video Clear video URL only')
|
||||||
|
console.error(' --file Clear file URL only')
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (siteOnly && imageOnly) {
|
if (selectiveFlags > 1) {
|
||||||
console.error('Usage: specify at most one of --site or --image')
|
console.error('Usage: specify at most one selective domain flag')
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,12 +45,18 @@ function readSetting(key) {
|
|||||||
|
|
||||||
const beforeSite = readSetting(SITE_KEY)
|
const beforeSite = readSetting(SITE_KEY)
|
||||||
const beforeImage = readSetting(IMAGE_KEY)
|
const beforeImage = readSetting(IMAGE_KEY)
|
||||||
|
const beforeVideo = readSetting(VIDEO_KEY)
|
||||||
|
const beforeFile = readSetting(FILE_KEY)
|
||||||
|
|
||||||
const keysToClear = siteOnly
|
const keysToClear = siteOnly
|
||||||
? [SITE_KEY]
|
? [SITE_KEY]
|
||||||
: imageOnly
|
: imageOnly
|
||||||
? [IMAGE_KEY]
|
? [IMAGE_KEY]
|
||||||
: [SITE_KEY, IMAGE_KEY]
|
: videoOnly
|
||||||
|
? [VIDEO_KEY]
|
||||||
|
: fileOnly
|
||||||
|
? [FILE_KEY]
|
||||||
|
: [SITE_KEY, IMAGE_KEY, VIDEO_KEY, FILE_KEY, DOMAIN_SEPARATION_KEY]
|
||||||
|
|
||||||
for (const key of keysToClear) {
|
for (const key of keysToClear) {
|
||||||
db.prepare('DELETE FROM settings WHERE key = ?').run(key)
|
db.prepare('DELETE FROM settings WHERE key = ?').run(key)
|
||||||
@@ -57,4 +71,10 @@ if (keysToClear.includes(SITE_KEY)) {
|
|||||||
if (keysToClear.includes(IMAGE_KEY)) {
|
if (keysToClear.includes(IMAGE_KEY)) {
|
||||||
console.log(` image_base_url: ${beforeImage ?? '(unset)'} → (removed)`)
|
console.log(` image_base_url: ${beforeImage ?? '(unset)'} → (removed)`)
|
||||||
}
|
}
|
||||||
|
if (keysToClear.includes(VIDEO_KEY)) {
|
||||||
|
console.log(` video_base_url: ${beforeVideo ?? '(unset)'} → (removed)`)
|
||||||
|
}
|
||||||
|
if (keysToClear.includes(FILE_KEY)) {
|
||||||
|
console.log(` file_base_url: ${beforeFile ?? '(unset)'} → (removed)`)
|
||||||
|
}
|
||||||
console.log('[PicHost] Restart is not required. Refresh the admin page or use your previous access URL.')
|
console.log('[PicHost] Restart is not required. Refresh the admin page or use your previous access URL.')
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
shouldBlockImageHostRequest,
|
shouldBlockImageHostRequest,
|
||||||
|
shouldBlockVideoHostRequest,
|
||||||
|
shouldBlockFileHostRequest,
|
||||||
shouldBlockSiteHostImageRequest,
|
shouldBlockSiteHostImageRequest,
|
||||||
shouldBlockUnknownHostRequest
|
shouldBlockUnknownHostRequest
|
||||||
} from '../utils/host-isolation'
|
} from '../utils/host-isolation'
|
||||||
@@ -10,6 +12,8 @@ export default defineEventHandler((event) => {
|
|||||||
if (
|
if (
|
||||||
shouldBlockUnknownHostRequest(event)
|
shouldBlockUnknownHostRequest(event)
|
||||||
|| shouldBlockImageHostRequest(event)
|
|| shouldBlockImageHostRequest(event)
|
||||||
|
|| shouldBlockVideoHostRequest(event)
|
||||||
|
|| shouldBlockFileHostRequest(event)
|
||||||
|| shouldBlockSiteHostImageRequest(event)
|
|| shouldBlockSiteHostImageRequest(event)
|
||||||
) {
|
) {
|
||||||
setHeader(event, 'Cache-Control', HOST_ISOLATION_CACHE_CONTROL)
|
setHeader(event, 'Cache-Control', HOST_ISOLATION_CACHE_CONTROL)
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export const VIDEO_EXTENSIONS = [
|
|||||||
'mpeg'
|
'mpeg'
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
export type VideoExtension = (typeof VIDEO_EXTENSIONS)[number]
|
||||||
|
|
||||||
/** 文件床直链缓存:公开访问可长期缓存,Token 访问不能进共享缓存 */
|
/** 文件床直链缓存:公开访问可长期缓存,Token 访问不能进共享缓存 */
|
||||||
export const FILE_CACHE_CONTROL
|
export const FILE_CACHE_CONTROL
|
||||||
= 'public, max-age=86400, s-maxage=604800, stale-while-revalidate=86400'
|
= 'public, max-age=86400, s-maxage=604800, stale-while-revalidate=86400'
|
||||||
|
|||||||
@@ -509,6 +509,12 @@ export const SETTINGS_HIDE_FOLDER_IN_URL = 'hide_folder_in_url'
|
|||||||
export const SETTINGS_STORAGE_USE_DATE_PATH = 'storage_use_date_path'
|
export const SETTINGS_STORAGE_USE_DATE_PATH = 'storage_use_date_path'
|
||||||
export const SETTINGS_SITE_BASE_URL = 'site_base_url'
|
export const SETTINGS_SITE_BASE_URL = 'site_base_url'
|
||||||
export const SETTINGS_IMAGE_BASE_URL = 'image_base_url'
|
export const SETTINGS_IMAGE_BASE_URL = 'image_base_url'
|
||||||
|
export const SETTINGS_VIDEO_BASE_URL = 'video_base_url'
|
||||||
|
export const SETTINGS_FILE_BASE_URL = 'file_base_url'
|
||||||
|
export const SETTINGS_DOMAIN_SEPARATION = 'domain_separation_enabled'
|
||||||
|
export const SETTINGS_IMAGE_DOMAIN_SEPARATED = 'image_domain_separated'
|
||||||
|
export const SETTINGS_VIDEO_DOMAIN_SEPARATED = 'video_domain_separated'
|
||||||
|
export const SETTINGS_FILE_DOMAIN_SEPARATED = 'file_domain_separated'
|
||||||
export const SETTINGS_AUTO_DELETE_DAYS = 'auto_delete_days'
|
export const SETTINGS_AUTO_DELETE_DAYS = 'auto_delete_days'
|
||||||
export const SETTINGS_AUTO_DELETE_ENABLED_AT = 'auto_delete_enabled_at'
|
export const SETTINGS_AUTO_DELETE_ENABLED_AT = 'auto_delete_enabled_at'
|
||||||
export const SETTINGS_ALLOW_REGISTRATION = 'allow_registration'
|
export const SETTINGS_ALLOW_REGISTRATION = 'allow_registration'
|
||||||
|
|||||||
@@ -244,11 +244,11 @@ describe('validateDomainSeparationPair', () => {
|
|||||||
expect(validateDomainSeparationPair('', 'https://pic.example.com')).toContain('同时填写')
|
expect(validateDomainSeparationPair('', 'https://pic.example.com')).toContain('同时填写')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects identical hostnames', () => {
|
it('allows identical hostnames for compatible multi-role hosting', () => {
|
||||||
expect(validateDomainSeparationPair(
|
expect(validateDomainSeparationPair(
|
||||||
'https://same.example.com',
|
'https://same.example.com',
|
||||||
'https://same.example.com'
|
'https://same.example.com'
|
||||||
)).toContain('相同主机名')
|
)).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('accepts different hostnames', () => {
|
it('accepts different hostnames', () => {
|
||||||
@@ -257,6 +257,24 @@ describe('validateDomainSeparationPair', () => {
|
|||||||
'https://pic.example.com'
|
'https://pic.example.com'
|
||||||
)).toBeNull()
|
)).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('accepts distinct image, video and file hostnames', () => {
|
||||||
|
expect(validateDomainSeparationPair(
|
||||||
|
'https://admin.example.com',
|
||||||
|
'https://pic.example.com',
|
||||||
|
'https://video.example.com',
|
||||||
|
'https://file.example.com'
|
||||||
|
)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows duplicate media hostnames', () => {
|
||||||
|
expect(validateDomainSeparationPair(
|
||||||
|
'https://admin.example.com',
|
||||||
|
'https://pic.example.com',
|
||||||
|
'https://pic.example.com',
|
||||||
|
'https://file.example.com'
|
||||||
|
)).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('validateSettingsDomainPatch', () => {
|
describe('validateSettingsDomainPatch', () => {
|
||||||
|
|||||||
+157
-11
@@ -10,6 +10,12 @@ import {
|
|||||||
SETTINGS_STORAGE_USE_DATE_PATH,
|
SETTINGS_STORAGE_USE_DATE_PATH,
|
||||||
SETTINGS_IMAGE_BASE_URL,
|
SETTINGS_IMAGE_BASE_URL,
|
||||||
SETTINGS_SITE_BASE_URL,
|
SETTINGS_SITE_BASE_URL,
|
||||||
|
SETTINGS_VIDEO_BASE_URL,
|
||||||
|
SETTINGS_FILE_BASE_URL,
|
||||||
|
SETTINGS_DOMAIN_SEPARATION,
|
||||||
|
SETTINGS_IMAGE_DOMAIN_SEPARATED,
|
||||||
|
SETTINGS_VIDEO_DOMAIN_SEPARATED,
|
||||||
|
SETTINGS_FILE_DOMAIN_SEPARATED,
|
||||||
SETTINGS_FILE_BED_ACCESS,
|
SETTINGS_FILE_BED_ACCESS,
|
||||||
SETTINGS_WEBP_QUALITY,
|
SETTINGS_WEBP_QUALITY,
|
||||||
SETTINGS_LOGIN_VERIFICATION_METHOD,
|
SETTINGS_LOGIN_VERIFICATION_METHOD,
|
||||||
@@ -84,6 +90,14 @@ export function normalizeSiteBaseUrl(raw: string): string {
|
|||||||
return normalizeBaseUrl(raw)
|
return normalizeBaseUrl(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeVideoBaseUrl(raw: string): string {
|
||||||
|
return normalizeBaseUrl(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeFileBaseUrl(raw: string): string {
|
||||||
|
return normalizeBaseUrl(raw)
|
||||||
|
}
|
||||||
|
|
||||||
export function hostnameFromBaseUrl(value: string): string | null {
|
export function hostnameFromBaseUrl(value: string): string | null {
|
||||||
if (!value) return null
|
if (!value) return null
|
||||||
try {
|
try {
|
||||||
@@ -137,6 +151,38 @@ export function getSiteBaseUrlConfigured(event: H3Event): string {
|
|||||||
return getSiteBaseUrlFromEnv(event)
|
return getSiteBaseUrlFromEnv(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getVideoBaseUrlFromEnv(event: H3Event): string {
|
||||||
|
return normalizeVideoBaseUrl(getRuntimeEnv(event, 'VIDEO_BASE_URL', 'videoBaseUrl'))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVideoBaseUrlSource(event: H3Event): SettingSource {
|
||||||
|
if (getSetting(SETTINGS_VIDEO_BASE_URL) !== null) return 'db'
|
||||||
|
if (getVideoBaseUrlFromEnv(event)) return 'env'
|
||||||
|
return 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVideoBaseUrlConfigured(event: H3Event): string {
|
||||||
|
const dbRaw = getSetting(SETTINGS_VIDEO_BASE_URL)
|
||||||
|
if (dbRaw !== null) return normalizeVideoBaseUrl(dbRaw)
|
||||||
|
return getVideoBaseUrlFromEnv(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileBaseUrlFromEnv(event: H3Event): string {
|
||||||
|
return normalizeFileBaseUrl(getRuntimeEnv(event, 'FILE_BASE_URL', 'fileBaseUrl'))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileBaseUrlSource(event: H3Event): SettingSource {
|
||||||
|
if (getSetting(SETTINGS_FILE_BASE_URL) !== null) return 'db'
|
||||||
|
if (getFileBaseUrlFromEnv(event)) return 'env'
|
||||||
|
return 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileBaseUrlConfigured(event: H3Event): string {
|
||||||
|
const dbRaw = getSetting(SETTINGS_FILE_BASE_URL)
|
||||||
|
if (dbRaw !== null) return normalizeFileBaseUrl(dbRaw)
|
||||||
|
return getFileBaseUrlFromEnv(event)
|
||||||
|
}
|
||||||
|
|
||||||
export function getSiteBaseUrl(event: H3Event): string {
|
export function getSiteBaseUrl(event: H3Event): string {
|
||||||
const dbRaw = getSetting(SETTINGS_SITE_BASE_URL)
|
const dbRaw = getSetting(SETTINGS_SITE_BASE_URL)
|
||||||
if (dbRaw !== null) {
|
if (dbRaw !== null) {
|
||||||
@@ -155,6 +201,15 @@ export function getSiteBaseUrl(event: H3Event): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getImageBaseUrl(event: H3Event): string {
|
export function getImageBaseUrl(event: H3Event): string {
|
||||||
|
if (isDomainSeparationActive(event) && !isImageDomainSeparated(event)) {
|
||||||
|
return getSiteBaseUrl(event)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
getSetting(SETTINGS_DOMAIN_SEPARATION) === 'false'
|
||||||
|
&& getSetting(SETTINGS_IMAGE_DOMAIN_SEPARATED) === 'false'
|
||||||
|
) {
|
||||||
|
return getSiteBaseUrl(event)
|
||||||
|
}
|
||||||
const dbRaw = getSetting(SETTINGS_IMAGE_BASE_URL)
|
const dbRaw = getSetting(SETTINGS_IMAGE_BASE_URL)
|
||||||
if (dbRaw !== null) {
|
if (dbRaw !== null) {
|
||||||
const configured = normalizeImageBaseUrl(dbRaw)
|
const configured = normalizeImageBaseUrl(dbRaw)
|
||||||
@@ -172,6 +227,64 @@ export function getImageBaseUrl(event: H3Event): string {
|
|||||||
return requestUrl.origin
|
return requestUrl.origin
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getVideoBaseUrl(event: H3Event): string {
|
||||||
|
if (isDomainSeparationActive(event) && !isVideoDomainSeparated(event)) {
|
||||||
|
return getSiteBaseUrl(event)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
getSetting(SETTINGS_DOMAIN_SEPARATION) === 'false'
|
||||||
|
&& getSetting(SETTINGS_VIDEO_DOMAIN_SEPARATED) === 'false'
|
||||||
|
) {
|
||||||
|
return getSiteBaseUrl(event)
|
||||||
|
}
|
||||||
|
return getVideoBaseUrlConfigured(event) || getImageBaseUrl(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileBaseUrl(event: H3Event): string {
|
||||||
|
if (isDomainSeparationActive(event) && !isFileDomainSeparated(event)) {
|
||||||
|
return getSiteBaseUrl(event)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
getSetting(SETTINGS_DOMAIN_SEPARATION) === 'false'
|
||||||
|
&& getSetting(SETTINGS_FILE_DOMAIN_SEPARATED) === 'false'
|
||||||
|
) {
|
||||||
|
return getSiteBaseUrl(event)
|
||||||
|
}
|
||||||
|
return getFileBaseUrlConfigured(event) || getImageBaseUrl(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMediaBaseUrl(event: H3Event, key: string): string {
|
||||||
|
if (key.startsWith('videos/')) return getVideoBaseUrl(event)
|
||||||
|
if (key.startsWith('files/')) return getFileBaseUrl(event)
|
||||||
|
return getImageBaseUrl(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStoredBoolean(raw: string | null): boolean | null {
|
||||||
|
if (raw === null) return null
|
||||||
|
return raw === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isImageDomainSeparated(event: H3Event): boolean {
|
||||||
|
if (!isDomainSeparationActive(event)) return false
|
||||||
|
const explicit = parseStoredBoolean(getSetting(SETTINGS_IMAGE_DOMAIN_SEPARATED))
|
||||||
|
if (explicit !== null) return explicit
|
||||||
|
return Boolean(getImageBaseUrlConfigured(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isVideoDomainSeparated(event: H3Event): boolean {
|
||||||
|
if (!isDomainSeparationActive(event)) return false
|
||||||
|
const explicit = parseStoredBoolean(getSetting(SETTINGS_VIDEO_DOMAIN_SEPARATED))
|
||||||
|
if (explicit !== null) return explicit
|
||||||
|
return Boolean(getVideoBaseUrlConfigured(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFileDomainSeparated(event: H3Event): boolean {
|
||||||
|
if (!isDomainSeparationActive(event)) return false
|
||||||
|
const explicit = parseStoredBoolean(getSetting(SETTINGS_FILE_DOMAIN_SEPARATED))
|
||||||
|
if (explicit !== null) return explicit
|
||||||
|
return Boolean(getFileBaseUrlConfigured(event))
|
||||||
|
}
|
||||||
|
|
||||||
export function isHideFolderInUrlFromEnv(): boolean {
|
export function isHideFolderInUrlFromEnv(): boolean {
|
||||||
try {
|
try {
|
||||||
return process.env.HIDE_FOLDER_IN_URL?.trim().toLowerCase() === 'true'
|
return process.env.HIDE_FOLDER_IN_URL?.trim().toLowerCase() === 'true'
|
||||||
@@ -555,26 +668,32 @@ export function isValidSiteBaseUrl(value: string): boolean {
|
|||||||
|
|
||||||
export function validateDomainSeparationPair(
|
export function validateDomainSeparationPair(
|
||||||
siteBaseUrl: string,
|
siteBaseUrl: string,
|
||||||
imageBaseUrl: string
|
imageBaseUrl: string,
|
||||||
|
videoBaseUrl = '',
|
||||||
|
fileBaseUrl = ''
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!siteBaseUrl || !imageBaseUrl) {
|
if (!siteBaseUrl || !imageBaseUrl) {
|
||||||
return '启用域名分离时需同时填写网站域名与图片域名'
|
return '启用域名分离时需同时填写后台域名与图片域名'
|
||||||
}
|
}
|
||||||
if (!isValidSiteBaseUrl(siteBaseUrl) || !isValidImageBaseUrl(imageBaseUrl)) {
|
if (
|
||||||
|
!isValidSiteBaseUrl(siteBaseUrl)
|
||||||
|
|| !isValidImageBaseUrl(imageBaseUrl)
|
||||||
|
|| (videoBaseUrl && !isValidImageBaseUrl(videoBaseUrl))
|
||||||
|
|| (fileBaseUrl && !isValidImageBaseUrl(fileBaseUrl))
|
||||||
|
) {
|
||||||
return '域名需为 http(s) 地址,且末尾不加 /'
|
return '域名需为 http(s) 地址,且末尾不加 /'
|
||||||
}
|
}
|
||||||
const siteHost = hostnameFromBaseUrl(siteBaseUrl)
|
const siteHost = hostnameFromBaseUrl(siteBaseUrl)
|
||||||
const imageHost = hostnameFromBaseUrl(imageBaseUrl)
|
const imageHost = hostnameFromBaseUrl(imageBaseUrl)
|
||||||
if (!siteHost || !imageHost) {
|
const videoHost = hostnameFromBaseUrl(videoBaseUrl)
|
||||||
|
const fileHost = hostnameFromBaseUrl(fileBaseUrl)
|
||||||
|
if (!siteHost || !imageHost || (videoBaseUrl && !videoHost) || (fileBaseUrl && !fileHost)) {
|
||||||
return '域名格式无效'
|
return '域名格式无效'
|
||||||
}
|
}
|
||||||
if (siteHost === imageHost) {
|
|
||||||
return '网站域名与图片域名不能使用相同主机名'
|
|
||||||
}
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RequestHostRole = 'site' | 'image' | 'unknown' | 'single'
|
export type RequestHostRole = 'site' | 'image' | 'video' | 'file' | 'unknown' | 'single'
|
||||||
|
|
||||||
export function getIsolationRequestHost(event: H3Event): string {
|
export function getIsolationRequestHost(event: H3Event): string {
|
||||||
const requestUrl = getRequestURL(event, {
|
const requestUrl = getRequestURL(event, {
|
||||||
@@ -587,8 +706,12 @@ export function getIsolationRequestHost(event: H3Event): string {
|
|||||||
export function validateSettingsDomainPatch(input: {
|
export function validateSettingsDomainPatch(input: {
|
||||||
existingSite: string
|
existingSite: string
|
||||||
existingImage: string
|
existingImage: string
|
||||||
|
existingVideo?: string
|
||||||
|
existingFile?: string
|
||||||
nextSite: string
|
nextSite: string
|
||||||
nextImage: string
|
nextImage: string
|
||||||
|
nextVideo?: string
|
||||||
|
nextFile?: string
|
||||||
wantSeparation: boolean | null
|
wantSeparation: boolean | null
|
||||||
siteBaseUrlProvided: boolean
|
siteBaseUrlProvided: boolean
|
||||||
}): string | null {
|
}): string | null {
|
||||||
@@ -597,6 +720,8 @@ export function validateSettingsDomainPatch(input: {
|
|||||||
existingImage,
|
existingImage,
|
||||||
nextSite,
|
nextSite,
|
||||||
nextImage,
|
nextImage,
|
||||||
|
nextVideo = '',
|
||||||
|
nextFile = '',
|
||||||
wantSeparation,
|
wantSeparation,
|
||||||
siteBaseUrlProvided
|
siteBaseUrlProvided
|
||||||
} = input
|
} = input
|
||||||
@@ -609,9 +734,9 @@ export function validateSettingsDomainPatch(input: {
|
|||||||
|
|
||||||
if (wantSeparation === true) {
|
if (wantSeparation === true) {
|
||||||
if (!nextSite || !nextImage) {
|
if (!nextSite || !nextImage) {
|
||||||
return '启用域名分离时需同时填写网站域名与图片域名'
|
return '启用域名分离时需同时填写后台域名与图片域名'
|
||||||
}
|
}
|
||||||
return validateDomainSeparationPair(nextSite, nextImage)
|
return validateDomainSeparationPair(nextSite, nextImage, nextVideo, nextFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wantSeparation === false) {
|
if (wantSeparation === false) {
|
||||||
@@ -623,7 +748,7 @@ export function validateSettingsDomainPatch(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (nextSite && nextImage) {
|
if (nextSite && nextImage) {
|
||||||
return validateDomainSeparationPair(nextSite, nextImage)
|
return validateDomainSeparationPair(nextSite, nextImage, nextVideo, nextFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
@@ -647,12 +772,18 @@ export function getRequestRuntime(event: H3Event) {
|
|||||||
|
|
||||||
const siteHost = hostnameFromBaseUrl(getSiteBaseUrlConfigured(event))
|
const siteHost = hostnameFromBaseUrl(getSiteBaseUrlConfigured(event))
|
||||||
const imageHost = hostnameFromBaseUrl(getImageBaseUrlConfigured(event))
|
const imageHost = hostnameFromBaseUrl(getImageBaseUrlConfigured(event))
|
||||||
|
const videoHost = hostnameFromBaseUrl(getVideoBaseUrl(event))
|
||||||
|
const fileHost = hostnameFromBaseUrl(getFileBaseUrl(event))
|
||||||
|
|
||||||
let hostRole: RequestHostRole = 'unknown'
|
let hostRole: RequestHostRole = 'unknown'
|
||||||
if (currentHost === siteHost) {
|
if (currentHost === siteHost) {
|
||||||
hostRole = 'site'
|
hostRole = 'site'
|
||||||
} else if (currentHost === imageHost) {
|
} else if (currentHost === imageHost) {
|
||||||
hostRole = 'image'
|
hostRole = 'image'
|
||||||
|
} else if (currentHost === videoHost) {
|
||||||
|
hostRole = 'video'
|
||||||
|
} else if (currentHost === fileHost) {
|
||||||
|
hostRole = 'file'
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -668,8 +799,12 @@ export function getSettingsPayload(event: H3Event) {
|
|||||||
const allowedRefererHosts = getAllowedRefererHostsRaw(event)
|
const allowedRefererHosts = getAllowedRefererHostsRaw(event)
|
||||||
const siteBaseUrlConfigured = getSiteBaseUrlConfigured(event)
|
const siteBaseUrlConfigured = getSiteBaseUrlConfigured(event)
|
||||||
const imageBaseUrlConfigured = getImageBaseUrlConfigured(event)
|
const imageBaseUrlConfigured = getImageBaseUrlConfigured(event)
|
||||||
|
const videoBaseUrlConfigured = getVideoBaseUrlConfigured(event)
|
||||||
|
const fileBaseUrlConfigured = getFileBaseUrlConfigured(event)
|
||||||
const effectiveSiteBaseUrl = getSiteBaseUrl(event)
|
const effectiveSiteBaseUrl = getSiteBaseUrl(event)
|
||||||
const effectiveImageBaseUrl = getImageBaseUrl(event)
|
const effectiveImageBaseUrl = getImageBaseUrl(event)
|
||||||
|
const effectiveVideoBaseUrl = getVideoBaseUrl(event)
|
||||||
|
const effectiveFileBaseUrl = getFileBaseUrl(event)
|
||||||
const autoDeleteDays = getAutoDeleteDays(event)
|
const autoDeleteDays = getAutoDeleteDays(event)
|
||||||
const appVersion = config.appVersion as string
|
const appVersion = config.appVersion as string
|
||||||
const domainSeparation = isDomainSeparationActive(event)
|
const domainSeparation = isDomainSeparationActive(event)
|
||||||
@@ -693,8 +828,17 @@ export function getSettingsPayload(event: H3Event) {
|
|||||||
imageBaseUrl: imageBaseUrlConfigured,
|
imageBaseUrl: imageBaseUrlConfigured,
|
||||||
imageBaseUrlConfigured,
|
imageBaseUrlConfigured,
|
||||||
imageBaseUrlSource: getImageBaseUrlSource(event),
|
imageBaseUrlSource: getImageBaseUrlSource(event),
|
||||||
|
imageDomainSeparated: isImageDomainSeparated(event),
|
||||||
|
videoBaseUrl: videoBaseUrlConfigured,
|
||||||
|
videoBaseUrlSource: getVideoBaseUrlSource(event),
|
||||||
|
videoDomainSeparated: isVideoDomainSeparated(event),
|
||||||
|
fileBaseUrl: fileBaseUrlConfigured,
|
||||||
|
fileBaseUrlSource: getFileBaseUrlSource(event),
|
||||||
|
fileDomainSeparated: isFileDomainSeparated(event),
|
||||||
effectiveSiteBaseUrl,
|
effectiveSiteBaseUrl,
|
||||||
effectiveImageBaseUrl,
|
effectiveImageBaseUrl,
|
||||||
|
effectiveVideoBaseUrl,
|
||||||
|
effectiveFileBaseUrl,
|
||||||
domainSeparation,
|
domainSeparation,
|
||||||
runtime: getRequestRuntime(event),
|
runtime: getRequestRuntime(event),
|
||||||
hideFolderInUrl,
|
hideFolderInUrl,
|
||||||
@@ -725,6 +869,8 @@ export function getSettingsPayload(event: H3Event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isDomainSeparationActive(event: H3Event): boolean {
|
export function isDomainSeparationActive(event: H3Event): boolean {
|
||||||
|
const explicit = getSetting(SETTINGS_DOMAIN_SEPARATION)
|
||||||
|
if (explicit !== null) return explicit === 'true'
|
||||||
const siteHost = hostnameFromBaseUrl(getSiteBaseUrlConfigured(event))
|
const siteHost = hostnameFromBaseUrl(getSiteBaseUrlConfigured(event))
|
||||||
const imageHost = hostnameFromBaseUrl(getImageBaseUrlConfigured(event))
|
const imageHost = hostnameFromBaseUrl(getImageBaseUrlConfigured(event))
|
||||||
return Boolean(siteHost && imageHost && siteHost !== imageHost)
|
return Boolean(siteHost && imageHost && siteHost !== imageHost)
|
||||||
|
|||||||
+10
-11
@@ -2,7 +2,6 @@ import type { H3Event } from 'h3'
|
|||||||
import type { UploadErrorItem } from '~/types/image'
|
import type { UploadErrorItem } from '~/types/image'
|
||||||
import type { FileItem } from '~/types/file'
|
import type { FileItem } from '~/types/file'
|
||||||
import {
|
import {
|
||||||
FORBIDDEN_FILE_EXTENSIONS,
|
|
||||||
MAX_FILE_BED_FILE_SIZE,
|
MAX_FILE_BED_FILE_SIZE,
|
||||||
MAX_VIDEO_FILE_SIZE
|
MAX_VIDEO_FILE_SIZE
|
||||||
} from './constants'
|
} from './constants'
|
||||||
@@ -10,8 +9,7 @@ import { contentTypeFromKey } from './content-type'
|
|||||||
import {
|
import {
|
||||||
extensionFromFilename,
|
extensionFromFilename,
|
||||||
generateFileKey,
|
generateFileKey,
|
||||||
generateVideoKey,
|
generateVideoKey
|
||||||
isVideoExtension
|
|
||||||
} from './image-key'
|
} from './image-key'
|
||||||
import { getStorageLayout } from './env'
|
import { getStorageLayout } from './env'
|
||||||
import { resolveImageUrl } from './image-response'
|
import { resolveImageUrl } from './image-response'
|
||||||
@@ -22,12 +20,13 @@ import { logException, logInfo } from './logger'
|
|||||||
import { putImage } from './storage'
|
import { putImage } from './storage'
|
||||||
import { generateVideoThumbnail } from './transcode'
|
import { generateVideoThumbnail } from './transcode'
|
||||||
import { updateImageThumb } from './image-index'
|
import { updateImageThumb } from './image-index'
|
||||||
|
import {
|
||||||
|
getAllowedVideoExtensions,
|
||||||
|
isForbiddenFileExtension,
|
||||||
|
isVideoExtensionAllowed
|
||||||
|
} from './upload-policy'
|
||||||
|
|
||||||
/** 文件床禁止上传的扩展名(html / htm / svg) */
|
export { isForbiddenFileExtension }
|
||||||
export function isForbiddenFileExtension(extension: string): boolean {
|
|
||||||
if (!extension) return false
|
|
||||||
return (FORBIDDEN_FILE_EXTENSIONS as readonly string[]).includes(extension.toLowerCase())
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 下载文件名清洗:保留中文等字符,去掉路径与控制字符 */
|
/** 下载文件名清洗:保留中文等字符,去掉路径与控制字符 */
|
||||||
export function sanitizeDownloadName(name: string): string {
|
export function sanitizeDownloadName(name: string): string {
|
||||||
@@ -141,7 +140,7 @@ export async function processSingleFileUpload(
|
|||||||
name: originalName,
|
name: originalName,
|
||||||
error: {
|
error: {
|
||||||
code: 'UNSUPPORTED_FILE_TYPE',
|
code: 'UNSUPPORTED_FILE_TYPE',
|
||||||
message: '不允许上传 html / htm / svg 文件'
|
message: `不允许上传 ${extension.toUpperCase()} 文件`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -271,13 +270,13 @@ export async function processSingleVideoUpload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const extension = extensionFromFilename(originalName)
|
const extension = extensionFromFilename(originalName)
|
||||||
if (!isVideoExtension(extension)) {
|
if (!isVideoExtensionAllowed(extension)) {
|
||||||
return {
|
return {
|
||||||
error: {
|
error: {
|
||||||
name: originalName,
|
name: originalName,
|
||||||
error: {
|
error: {
|
||||||
code: 'UNSUPPORTED_FILE_TYPE',
|
code: 'UNSUPPORTED_FILE_TYPE',
|
||||||
message: '只支持常见视频格式(MP4 / WebM / MOV / MKV 等)'
|
message: `只支持 ${getAllowedVideoExtensions().map(item => item.toUpperCase()).join(' / ')} 视频格式`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import type { H3Event } from 'h3'
|
import type { H3Event } from 'h3'
|
||||||
import {
|
import {
|
||||||
|
shouldBlockFileHostRequest,
|
||||||
shouldBlockImageHostRequest,
|
shouldBlockImageHostRequest,
|
||||||
shouldBlockSiteHostImageRequest,
|
shouldBlockSiteHostImageRequest,
|
||||||
shouldBlockUnknownHostRequest
|
shouldBlockUnknownHostRequest,
|
||||||
|
shouldBlockVideoHostRequest
|
||||||
} from './host-isolation'
|
} from './host-isolation'
|
||||||
|
|
||||||
function mockEvent(): H3Event {
|
function mockEvent(): H3Event {
|
||||||
@@ -32,6 +34,7 @@ describe('shouldBlockImageHostRequest', () => {
|
|||||||
expect(shouldBlockImageHostRequest(event, {
|
expect(shouldBlockImageHostRequest(event, {
|
||||||
separationActive: true,
|
separationActive: true,
|
||||||
imageHost,
|
imageHost,
|
||||||
|
videoHost: 'video.example.com',
|
||||||
requestHost: imageHost,
|
requestHost: imageHost,
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
pathname: '/settings',
|
pathname: '/settings',
|
||||||
@@ -39,11 +42,25 @@ describe('shouldBlockImageHostRequest', () => {
|
|||||||
})).toBe(true)
|
})).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('allows video paths on image host when no video domain is configured', () => {
|
||||||
|
const event = mockEvent()
|
||||||
|
expect(shouldBlockImageHostRequest(event, {
|
||||||
|
separationActive: true,
|
||||||
|
imageHost,
|
||||||
|
videoHost: null,
|
||||||
|
fileHost: null,
|
||||||
|
requestHost: imageHost,
|
||||||
|
method: 'GET',
|
||||||
|
pathname: '/videos/2026/08/demo.mp4'
|
||||||
|
})).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('allows image paths on image host', () => {
|
it('allows image paths on image host', () => {
|
||||||
const event = mockEvent()
|
const event = mockEvent()
|
||||||
expect(shouldBlockImageHostRequest(event, {
|
expect(shouldBlockImageHostRequest(event, {
|
||||||
separationActive: true,
|
separationActive: true,
|
||||||
imageHost,
|
imageHost,
|
||||||
|
videoHost: 'video.example.com',
|
||||||
requestHost: imageHost,
|
requestHost: imageHost,
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
pathname: '/images/2026/08/demo.webp',
|
pathname: '/images/2026/08/demo.webp',
|
||||||
@@ -62,6 +79,39 @@ describe('shouldBlockImageHostRequest', () => {
|
|||||||
})).toBe(true)
|
})).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('blocks video paths on the image host', () => {
|
||||||
|
const event = mockEvent()
|
||||||
|
expect(shouldBlockImageHostRequest(event, {
|
||||||
|
separationActive: true,
|
||||||
|
imageHost,
|
||||||
|
videoHost: 'video.example.com',
|
||||||
|
requestHost: imageHost,
|
||||||
|
method: 'GET',
|
||||||
|
pathname: '/videos/2026/08/demo.mp4'
|
||||||
|
})).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows admin and all media paths when one host serves every role', () => {
|
||||||
|
const event = mockEvent()
|
||||||
|
for (const pathname of [
|
||||||
|
'/settings',
|
||||||
|
'/images/2026/08/demo.webp',
|
||||||
|
'/videos/2026/08/demo.mp4',
|
||||||
|
'/files/2026/08/demo.pdf'
|
||||||
|
]) {
|
||||||
|
expect(shouldBlockImageHostRequest(event, {
|
||||||
|
separationActive: true,
|
||||||
|
siteHost: imageHost,
|
||||||
|
imageHost,
|
||||||
|
videoHost: imageHost,
|
||||||
|
fileHost: imageHost,
|
||||||
|
requestHost: imageHost,
|
||||||
|
method: 'GET',
|
||||||
|
pathname
|
||||||
|
})).toBe(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('does not block site host admin paths', () => {
|
it('does not block site host admin paths', () => {
|
||||||
const event = mockEvent()
|
const event = mockEvent()
|
||||||
expect(shouldBlockImageHostRequest(event, {
|
expect(shouldBlockImageHostRequest(event, {
|
||||||
@@ -74,6 +124,47 @@ describe('shouldBlockImageHostRequest', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('video and file media hosts', () => {
|
||||||
|
const videoHost = 'video.example.com'
|
||||||
|
const fileHost = 'file.example.com'
|
||||||
|
|
||||||
|
it('allows only video paths on the video host', () => {
|
||||||
|
const event = mockEvent()
|
||||||
|
expect(shouldBlockVideoHostRequest(event, {
|
||||||
|
separationActive: true,
|
||||||
|
videoHost,
|
||||||
|
requestHost: videoHost,
|
||||||
|
method: 'GET',
|
||||||
|
pathname: '/videos/2026/08/demo.mp4'
|
||||||
|
})).toBe(false)
|
||||||
|
expect(shouldBlockVideoHostRequest(event, {
|
||||||
|
separationActive: true,
|
||||||
|
videoHost,
|
||||||
|
requestHost: videoHost,
|
||||||
|
method: 'GET',
|
||||||
|
pathname: '/images/2026/08/demo.webp'
|
||||||
|
})).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows only file paths on the file host', () => {
|
||||||
|
const event = mockEvent()
|
||||||
|
expect(shouldBlockFileHostRequest(event, {
|
||||||
|
separationActive: true,
|
||||||
|
fileHost,
|
||||||
|
requestHost: fileHost,
|
||||||
|
method: 'GET',
|
||||||
|
pathname: '/files/2026/08/demo.pdf'
|
||||||
|
})).toBe(false)
|
||||||
|
expect(shouldBlockFileHostRequest(event, {
|
||||||
|
separationActive: true,
|
||||||
|
fileHost,
|
||||||
|
requestHost: fileHost,
|
||||||
|
method: 'GET',
|
||||||
|
pathname: '/videos/2026/08/demo.mp4'
|
||||||
|
})).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('shouldBlockSiteHostImageRequest', () => {
|
describe('shouldBlockSiteHostImageRequest', () => {
|
||||||
const imageHost = 'image.example.com'
|
const imageHost = 'image.example.com'
|
||||||
const siteHost = 'admin.example.com'
|
const siteHost = 'admin.example.com'
|
||||||
@@ -146,6 +237,44 @@ describe('shouldBlockSiteHostImageRequest', () => {
|
|||||||
pathname: '/images/2026/08/demo.webp'
|
pathname: '/images/2026/08/demo.webp'
|
||||||
})).toBe(false)
|
})).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('blocks video and file paths on the site host', () => {
|
||||||
|
const event = mockEvent()
|
||||||
|
expect(shouldBlockSiteHostImageRequest(event, {
|
||||||
|
separationActive: true,
|
||||||
|
siteHost,
|
||||||
|
requestHost: siteHost,
|
||||||
|
method: 'GET',
|
||||||
|
pathname: '/videos/2026/08/demo.mp4'
|
||||||
|
})).toBe(true)
|
||||||
|
expect(shouldBlockSiteHostImageRequest(event, {
|
||||||
|
separationActive: true,
|
||||||
|
siteHost,
|
||||||
|
requestHost: siteHost,
|
||||||
|
method: 'GET',
|
||||||
|
pathname: '/files/2026/08/demo.pdf'
|
||||||
|
})).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows media paths when all roles share the same host', () => {
|
||||||
|
const event = mockEvent()
|
||||||
|
for (const pathname of [
|
||||||
|
'/images/2026/08/demo.webp',
|
||||||
|
'/videos/2026/08/demo.mp4',
|
||||||
|
'/files/2026/08/demo.pdf'
|
||||||
|
]) {
|
||||||
|
expect(shouldBlockSiteHostImageRequest(event, {
|
||||||
|
separationActive: true,
|
||||||
|
siteHost,
|
||||||
|
imageHost: siteHost,
|
||||||
|
videoHost: siteHost,
|
||||||
|
fileHost: siteHost,
|
||||||
|
requestHost: siteHost,
|
||||||
|
method: 'GET',
|
||||||
|
pathname
|
||||||
|
})).toBe(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('shouldBlockUnknownHostRequest', () => {
|
describe('shouldBlockUnknownHostRequest', () => {
|
||||||
@@ -202,6 +331,26 @@ describe('shouldBlockUnknownHostRequest', () => {
|
|||||||
})).toBe(false)
|
})).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('allows configured video and file hosts', () => {
|
||||||
|
const event = mockEvent()
|
||||||
|
expect(shouldBlockUnknownHostRequest(event, {
|
||||||
|
separationActive: true,
|
||||||
|
requestHost: 'video.example.com',
|
||||||
|
siteHost,
|
||||||
|
imageHost,
|
||||||
|
videoHost: 'video.example.com',
|
||||||
|
fileHost: 'file.example.com'
|
||||||
|
})).toBe(false)
|
||||||
|
expect(shouldBlockUnknownHostRequest(event, {
|
||||||
|
separationActive: true,
|
||||||
|
requestHost: 'file.example.com',
|
||||||
|
siteHost,
|
||||||
|
imageHost,
|
||||||
|
videoHost: 'video.example.com',
|
||||||
|
fileHost: 'file.example.com'
|
||||||
|
})).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('allows localhost in development', () => {
|
it('allows localhost in development', () => {
|
||||||
const previous = process.env.NODE_ENV
|
const previous = process.env.NODE_ENV
|
||||||
process.env.NODE_ENV = 'development'
|
process.env.NODE_ENV = 'development'
|
||||||
|
|||||||
+160
-20
@@ -2,10 +2,16 @@ import type { H3Event } from 'h3'
|
|||||||
import {
|
import {
|
||||||
getImageBaseUrlConfigured,
|
getImageBaseUrlConfigured,
|
||||||
getSiteBaseUrlConfigured,
|
getSiteBaseUrlConfigured,
|
||||||
|
getVideoBaseUrlConfigured,
|
||||||
|
getFileBaseUrlConfigured,
|
||||||
hostnameFromBaseUrl,
|
hostnameFromBaseUrl,
|
||||||
|
isFileDomainSeparated,
|
||||||
|
isImageDomainSeparated,
|
||||||
isDomainSeparationActive,
|
isDomainSeparationActive,
|
||||||
isHideFolderInUrl
|
isHideFolderInUrl,
|
||||||
|
isVideoDomainSeparated
|
||||||
} from './env'
|
} from './env'
|
||||||
|
import { validateFileKey, validateImageKey, validateStorageKey, validateVideoKey } from './image-key'
|
||||||
import { requestPathToImageKey } from './serve-image'
|
import { requestPathToImageKey } from './serve-image'
|
||||||
|
|
||||||
const DEV_LOCAL_HOSTS = new Set(['localhost', '127.0.0.1'])
|
const DEV_LOCAL_HOSTS = new Set(['localhost', '127.0.0.1'])
|
||||||
@@ -22,15 +28,34 @@ function isDevLocalHost(host: string): boolean {
|
|||||||
return process.env.NODE_ENV === 'development' && DEV_LOCAL_HOSTS.has(host)
|
return process.env.NODE_ENV === 'development' && DEV_LOCAL_HOSTS.has(host)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function configuredHostOrNull(loader: () => string | null): string | null {
|
||||||
|
try {
|
||||||
|
return loader()
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getConfiguredSiteHost(event: H3Event): string | null {
|
export function getConfiguredSiteHost(event: H3Event): string | null {
|
||||||
return hostnameFromBaseUrl(getSiteBaseUrlConfigured(event))
|
return hostnameFromBaseUrl(getSiteBaseUrlConfigured(event))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getConfiguredImageHost(event: H3Event): string | null {
|
export function getConfiguredImageHost(event: H3Event): string | null {
|
||||||
|
if (!isImageDomainSeparated(event)) return null
|
||||||
return hostnameFromBaseUrl(getImageBaseUrlConfigured(event))
|
return hostnameFromBaseUrl(getImageBaseUrlConfigured(event))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 双域名开启时,拒绝未配置的第三 Host(开发环境 localhost 例外) */
|
export function getConfiguredVideoHost(event: H3Event): string | null {
|
||||||
|
if (!isVideoDomainSeparated(event)) return null
|
||||||
|
return hostnameFromBaseUrl(getVideoBaseUrlConfigured(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfiguredFileHost(event: H3Event): string | null {
|
||||||
|
if (!isFileDomainSeparated(event)) return null
|
||||||
|
return hostnameFromBaseUrl(getFileBaseUrlConfigured(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 域名分离开启时,拒绝未配置的 Host(开发环境 localhost 例外) */
|
||||||
export function shouldBlockUnknownHostRequest(
|
export function shouldBlockUnknownHostRequest(
|
||||||
event: H3Event,
|
event: H3Event,
|
||||||
options?: {
|
options?: {
|
||||||
@@ -38,6 +63,8 @@ export function shouldBlockUnknownHostRequest(
|
|||||||
separationActive?: boolean
|
separationActive?: boolean
|
||||||
siteHost?: string | null
|
siteHost?: string | null
|
||||||
imageHost?: string | null
|
imageHost?: string | null
|
||||||
|
videoHost?: string | null
|
||||||
|
fileHost?: string | null
|
||||||
}
|
}
|
||||||
): boolean {
|
): boolean {
|
||||||
const separationActive = options?.separationActive ?? isDomainSeparationActive(event)
|
const separationActive = options?.separationActive ?? isDomainSeparationActive(event)
|
||||||
@@ -45,15 +72,48 @@ export function shouldBlockUnknownHostRequest(
|
|||||||
|
|
||||||
const siteHost = options?.siteHost ?? getConfiguredSiteHost(event)
|
const siteHost = options?.siteHost ?? getConfiguredSiteHost(event)
|
||||||
const imageHost = options?.imageHost ?? getConfiguredImageHost(event)
|
const imageHost = options?.imageHost ?? getConfiguredImageHost(event)
|
||||||
|
const videoHost = options?.videoHost
|
||||||
|
?? configuredHostOrNull(() => getConfiguredVideoHost(event))
|
||||||
|
const fileHost = options?.fileHost
|
||||||
|
?? configuredHostOrNull(() => getConfiguredFileHost(event))
|
||||||
if (!siteHost || !imageHost) return false
|
if (!siteHost || !imageHost) return false
|
||||||
|
|
||||||
const requestHost = (options?.requestHost ?? getIsolationRequestHost(event)).toLowerCase()
|
const requestHost = (options?.requestHost ?? getIsolationRequestHost(event)).toLowerCase()
|
||||||
if (requestHost === siteHost || requestHost === imageHost) return false
|
if (
|
||||||
|
requestHost === siteHost
|
||||||
|
|| requestHost === imageHost
|
||||||
|
|| (videoHost && requestHost === videoHost)
|
||||||
|
|| (fileHost && requestHost === fileHost)
|
||||||
|
) return false
|
||||||
if (isDevLocalHost(requestHost)) return false
|
if (isDevLocalHost(requestHost)) return false
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestKey(
|
||||||
|
event: H3Event,
|
||||||
|
options?: {
|
||||||
|
pathname?: string
|
||||||
|
hideFolder?: boolean
|
||||||
|
}
|
||||||
|
): string | null {
|
||||||
|
const pathname = options?.pathname ?? getRequestURL(event, {
|
||||||
|
xForwardedHost: true,
|
||||||
|
xForwardedProto: true
|
||||||
|
}).pathname
|
||||||
|
return requestPathToImageKey(pathname, {
|
||||||
|
hideFolder: options?.hideFolder ?? isHideFolderInUrl(event)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaKindForKey(key: string | null): 'image' | 'video' | 'file' | null {
|
||||||
|
if (!key || !validateStorageKey(key)) return null
|
||||||
|
if (validateImageKey(key)) return 'image'
|
||||||
|
if (validateVideoKey(key)) return 'video'
|
||||||
|
if (validateFileKey(key)) return 'file'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
export function shouldBlockImageHostRequest(
|
export function shouldBlockImageHostRequest(
|
||||||
event: H3Event,
|
event: H3Event,
|
||||||
options?: {
|
options?: {
|
||||||
@@ -63,6 +123,8 @@ export function shouldBlockImageHostRequest(
|
|||||||
separationActive?: boolean
|
separationActive?: boolean
|
||||||
imageHost?: string | null
|
imageHost?: string | null
|
||||||
siteHost?: string | null
|
siteHost?: string | null
|
||||||
|
videoHost?: string | null
|
||||||
|
fileHost?: string | null
|
||||||
hideFolder?: boolean
|
hideFolder?: boolean
|
||||||
}
|
}
|
||||||
): boolean {
|
): boolean {
|
||||||
@@ -74,21 +136,85 @@ export function shouldBlockImageHostRequest(
|
|||||||
|
|
||||||
const requestHost = (options?.requestHost ?? getIsolationRequestHost(event)).toLowerCase()
|
const requestHost = (options?.requestHost ?? getIsolationRequestHost(event)).toLowerCase()
|
||||||
if (requestHost !== imageHost) return false
|
if (requestHost !== imageHost) return false
|
||||||
|
const siteHost = options?.siteHost ?? configuredHostOrNull(() => getConfiguredSiteHost(event))
|
||||||
|
if (siteHost === imageHost) return false
|
||||||
|
|
||||||
const method = (options?.method ?? getMethod(event)).toUpperCase()
|
const method = (options?.method ?? getMethod(event)).toUpperCase()
|
||||||
if (method !== 'GET' && method !== 'HEAD') return true
|
if (method !== 'GET' && method !== 'HEAD') return true
|
||||||
|
|
||||||
const pathname = options?.pathname ?? getRequestURL(event, {
|
const kind = mediaKindForKey(requestKey(event, options))
|
||||||
xForwardedHost: true,
|
if (kind === 'image') return false
|
||||||
xForwardedProto: true
|
const videoHost = options?.videoHost
|
||||||
}).pathname
|
?? configuredHostOrNull(() => getConfiguredVideoHost(event))
|
||||||
const hideFolder = options?.hideFolder ?? isHideFolderInUrl(event)
|
const fileHost = options?.fileHost
|
||||||
return requestPathToImageKey(pathname, {
|
?? configuredHostOrNull(() => getConfiguredFileHost(event))
|
||||||
hideFolder
|
if (kind === 'video' && (!videoHost || videoHost === imageHost)) return false
|
||||||
}) === null
|
if (kind === 'file' && (!fileHost || fileHost === imageHost)) return false
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 网站域禁止直链出图(仅图片域可 GET/HEAD 图片路径) */
|
function shouldBlockMediaHostRequest(
|
||||||
|
event: H3Event,
|
||||||
|
expectedKind: 'video' | 'file',
|
||||||
|
configuredHost: string | null,
|
||||||
|
options?: {
|
||||||
|
method?: string
|
||||||
|
pathname?: string
|
||||||
|
requestHost?: string
|
||||||
|
separationActive?: boolean
|
||||||
|
hideFolder?: boolean
|
||||||
|
}
|
||||||
|
): boolean {
|
||||||
|
const separationActive = options?.separationActive ?? isDomainSeparationActive(event)
|
||||||
|
if (!separationActive || !configuredHost) return false
|
||||||
|
const siteHost = configuredHostOrNull(() => getConfiguredSiteHost(event))
|
||||||
|
if (siteHost === configuredHost) return false
|
||||||
|
const requestHost = (options?.requestHost ?? getIsolationRequestHost(event)).toLowerCase()
|
||||||
|
if (requestHost !== configuredHost) return false
|
||||||
|
const method = (options?.method ?? getMethod(event)).toUpperCase()
|
||||||
|
if (method !== 'GET' && method !== 'HEAD') return true
|
||||||
|
return mediaKindForKey(requestKey(event, options)) !== expectedKind
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldBlockVideoHostRequest(
|
||||||
|
event: H3Event,
|
||||||
|
options?: {
|
||||||
|
method?: string
|
||||||
|
pathname?: string
|
||||||
|
requestHost?: string
|
||||||
|
separationActive?: boolean
|
||||||
|
videoHost?: string | null
|
||||||
|
hideFolder?: boolean
|
||||||
|
}
|
||||||
|
): boolean {
|
||||||
|
return shouldBlockMediaHostRequest(
|
||||||
|
event,
|
||||||
|
'video',
|
||||||
|
options?.videoHost ?? getConfiguredVideoHost(event),
|
||||||
|
options
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldBlockFileHostRequest(
|
||||||
|
event: H3Event,
|
||||||
|
options?: {
|
||||||
|
method?: string
|
||||||
|
pathname?: string
|
||||||
|
requestHost?: string
|
||||||
|
separationActive?: boolean
|
||||||
|
fileHost?: string | null
|
||||||
|
hideFolder?: boolean
|
||||||
|
}
|
||||||
|
): boolean {
|
||||||
|
return shouldBlockMediaHostRequest(
|
||||||
|
event,
|
||||||
|
'file',
|
||||||
|
options?.fileHost ?? getConfiguredFileHost(event),
|
||||||
|
options
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 网站域禁止直接访问任何媒体路径(仅对应媒体域可 GET/HEAD) */
|
||||||
export function shouldBlockSiteHostImageRequest(
|
export function shouldBlockSiteHostImageRequest(
|
||||||
event: H3Event,
|
event: H3Event,
|
||||||
options?: {
|
options?: {
|
||||||
@@ -97,6 +223,9 @@ export function shouldBlockSiteHostImageRequest(
|
|||||||
requestHost?: string
|
requestHost?: string
|
||||||
separationActive?: boolean
|
separationActive?: boolean
|
||||||
siteHost?: string | null
|
siteHost?: string | null
|
||||||
|
imageHost?: string | null
|
||||||
|
videoHost?: string | null
|
||||||
|
fileHost?: string | null
|
||||||
hideFolder?: boolean
|
hideFolder?: boolean
|
||||||
}
|
}
|
||||||
): boolean {
|
): boolean {
|
||||||
@@ -112,12 +241,23 @@ export function shouldBlockSiteHostImageRequest(
|
|||||||
const method = (options?.method ?? getMethod(event)).toUpperCase()
|
const method = (options?.method ?? getMethod(event)).toUpperCase()
|
||||||
if (method !== 'GET' && method !== 'HEAD') return false
|
if (method !== 'GET' && method !== 'HEAD') return false
|
||||||
|
|
||||||
const pathname = options?.pathname ?? getRequestURL(event, {
|
const kind = mediaKindForKey(requestKey(event, options))
|
||||||
xForwardedHost: true,
|
if (!kind) return false
|
||||||
xForwardedProto: true
|
if (kind === 'image') {
|
||||||
}).pathname
|
const imageHost = options?.imageHost
|
||||||
const hideFolder = options?.hideFolder ?? isHideFolderInUrl(event)
|
?? configuredHostOrNull(() => getConfiguredImageHost(event))
|
||||||
return requestPathToImageKey(pathname, {
|
return imageHost !== siteHost
|
||||||
hideFolder
|
}
|
||||||
}) !== null
|
if (kind === 'video') {
|
||||||
|
const imageHost = options?.imageHost
|
||||||
|
?? configuredHostOrNull(() => getConfiguredImageHost(event))
|
||||||
|
const videoHost = options?.videoHost
|
||||||
|
?? configuredHostOrNull(() => getConfiguredVideoHost(event))
|
||||||
|
return (videoHost || imageHost) !== siteHost
|
||||||
|
}
|
||||||
|
const imageHost = options?.imageHost
|
||||||
|
?? configuredHostOrNull(() => getConfiguredImageHost(event))
|
||||||
|
const fileHost = options?.fileHost
|
||||||
|
?? configuredHostOrNull(() => getConfiguredFileHost(event))
|
||||||
|
return (fileHost || imageHost) !== siteHost
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@ import type { AllowedMimeType } from './constants'
|
|||||||
import type { StoredImage } from './storage'
|
import type { StoredImage } from './storage'
|
||||||
import type { TagItem } from './tags'
|
import type { TagItem } from './tags'
|
||||||
import { getTagsForImageKeys } from './tags'
|
import { getTagsForImageKeys } from './tags'
|
||||||
import { getImageBaseUrl, isHideFolderInUrl } from './env'
|
import { getMediaBaseUrl, isHideFolderInUrl } from './env'
|
||||||
import { toPublicImagePath } from './image-public-path'
|
import { toPublicImagePath } from './image-public-path'
|
||||||
import { getActiveBackendRow } from './storage/resolver'
|
import { getActiveBackendRow } from './storage/resolver'
|
||||||
import { getStorageBackendRow, getStorageBackendFromEnv } from './storage-backends'
|
import { getStorageBackendRow, getStorageBackendFromEnv } from './storage-backends'
|
||||||
@@ -130,7 +130,7 @@ export function resolveImageUrl(
|
|||||||
const publicPath = hideFolder ? toPublicImagePath(key, true) : objectKey
|
const publicPath = hideFolder ? toPublicImagePath(key, true) : objectKey
|
||||||
return `${base}/${publicPath}`
|
return `${base}/${publicPath}`
|
||||||
}
|
}
|
||||||
return buildImageUrl(getImageBaseUrl(event), key, hideFolder)
|
return buildImageUrl(getMediaBaseUrl(event, key), key, hideFolder)
|
||||||
}
|
}
|
||||||
|
|
||||||
const backendRow = backendId
|
const backendRow = backendId
|
||||||
@@ -141,7 +141,7 @@ export function resolveImageUrl(
|
|||||||
return buildPublicImageUrl(backendRow, key, hideFolder)
|
return buildPublicImageUrl(backendRow, key, hideFolder)
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildImageUrl(getImageBaseUrl(event), key, hideFolder)
|
return buildImageUrl(getMediaBaseUrl(event, key), key, hideFolder)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildMarkdown(url: string, alt: string): string {
|
export function buildMarkdown(url: string, alt: string): string {
|
||||||
|
|||||||
@@ -7,10 +7,15 @@ import {
|
|||||||
import {
|
import {
|
||||||
getAllowedRefererHostsRaw,
|
getAllowedRefererHostsRaw,
|
||||||
getFileBedAccessMode,
|
getFileBedAccessMode,
|
||||||
|
getFileBaseUrlConfigured,
|
||||||
getImageBaseUrlConfigured,
|
getImageBaseUrlConfigured,
|
||||||
|
getVideoBaseUrlConfigured,
|
||||||
getSiteBaseUrlConfigured,
|
getSiteBaseUrlConfigured,
|
||||||
hostnameFromBaseUrl,
|
hostnameFromBaseUrl,
|
||||||
isRefererConfigured
|
isFileDomainSeparated,
|
||||||
|
isImageDomainSeparated,
|
||||||
|
isRefererConfigured,
|
||||||
|
isVideoDomainSeparated
|
||||||
} from './env'
|
} from './env'
|
||||||
import { isFileAccessAuthorized } from './access'
|
import { isFileAccessAuthorized } from './access'
|
||||||
import { buildAttachmentDisposition } from './file-bed'
|
import { buildAttachmentDisposition } from './file-bed'
|
||||||
@@ -51,11 +56,15 @@ function getAllowedHosts(event: H3Event): Set<string> {
|
|||||||
})
|
})
|
||||||
hosts.add(requestUrl.hostname.toLowerCase())
|
hosts.add(requestUrl.hostname.toLowerCase())
|
||||||
|
|
||||||
// 双域名分离时,管理域页面会引用图片域直链,需互相放行 Referer
|
// 域名分离时,后台页面会引用各资源域直链,需互相放行 Referer
|
||||||
const siteHost = hostnameFromBaseUrl(getSiteBaseUrlConfigured(event))
|
const siteHost = hostnameFromBaseUrl(getSiteBaseUrlConfigured(event))
|
||||||
const imageHost = hostnameFromBaseUrl(getImageBaseUrlConfigured(event))
|
const imageHost = hostnameFromBaseUrl(getImageBaseUrlConfigured(event))
|
||||||
|
const videoHost = hostnameFromBaseUrl(getVideoBaseUrlConfigured(event))
|
||||||
|
const fileHost = hostnameFromBaseUrl(getFileBaseUrlConfigured(event))
|
||||||
if (siteHost) hosts.add(siteHost)
|
if (siteHost) hosts.add(siteHost)
|
||||||
if (imageHost) hosts.add(imageHost)
|
if (imageHost && isImageDomainSeparated(event)) hosts.add(imageHost)
|
||||||
|
if (videoHost && isVideoDomainSeparated(event)) hosts.add(videoHost)
|
||||||
|
if (fileHost && isFileDomainSeparated(event)) hosts.add(fileHost)
|
||||||
|
|
||||||
return hosts
|
return hosts
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,15 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import {
|
import {
|
||||||
DEFAULT_UPLOAD_MAX_FILE_SIZE_MB,
|
DEFAULT_UPLOAD_MAX_FILE_SIZE_MB,
|
||||||
normalizeAllowedMimeTypes,
|
normalizeAllowedMimeTypes,
|
||||||
|
normalizeAllowedVideoExtensions,
|
||||||
|
normalizeForbiddenFileExtensions,
|
||||||
parseLoginRateMax,
|
parseLoginRateMax,
|
||||||
parsePreserveOriginalUpload,
|
parsePreserveOriginalUpload,
|
||||||
parseUploadMaxFileSizeMb,
|
parseUploadMaxFileSizeMb,
|
||||||
parseUploadRateIpMax,
|
parseUploadRateIpMax,
|
||||||
serializeAllowedMimeTypes,
|
serializeAllowedMimeTypes,
|
||||||
|
serializeAllowedVideoExtensions,
|
||||||
|
serializeForbiddenFileExtensions,
|
||||||
serializePreserveOriginalUpload
|
serializePreserveOriginalUpload
|
||||||
} from './upload-policy'
|
} from './upload-policy'
|
||||||
|
|
||||||
@@ -30,6 +34,25 @@ describe('upload-policy parsers', () => {
|
|||||||
expect(serializeAllowedMimeTypes(['image/webp', 'image/jpeg'])).toBe('image/jpeg,image/webp')
|
expect(serializeAllowedMimeTypes(['image/webp', 'image/jpeg'])).toBe('image/jpeg,image/webp')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('normalizes and serializes allowed video extensions', () => {
|
||||||
|
expect(normalizeAllowedVideoExtensions('.MOV, mp4,mkv,mp4,ts')).toEqual([
|
||||||
|
'mov',
|
||||||
|
'mp4',
|
||||||
|
'mkv'
|
||||||
|
])
|
||||||
|
expect(serializeAllowedVideoExtensions(['mkv', 'mp4'])).toBe('mp4,mkv')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes and serializes forbidden file extensions', () => {
|
||||||
|
expect(normalizeForbiddenFileExtensions('.HTML, exe,php,exe,bad/ext')).toEqual([
|
||||||
|
'html',
|
||||||
|
'exe',
|
||||||
|
'php'
|
||||||
|
])
|
||||||
|
expect(serializeForbiddenFileExtensions(['php', 'svg', 'html']))
|
||||||
|
.toBe('html,svg,php')
|
||||||
|
})
|
||||||
|
|
||||||
it('parses rate limit values', () => {
|
it('parses rate limit values', () => {
|
||||||
expect(parseUploadRateIpMax('60')).toBe(60)
|
expect(parseUploadRateIpMax('60')).toBe(60)
|
||||||
expect(parseLoginRateMax('10')).toBe(10)
|
expect(parseLoginRateMax('10')).toBe(10)
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
ALLOWED_MIME_TYPES,
|
ALLOWED_MIME_TYPES,
|
||||||
|
FORBIDDEN_FILE_EXTENSIONS,
|
||||||
|
VIDEO_EXTENSIONS,
|
||||||
|
type VideoExtension,
|
||||||
type AllowedMimeType
|
type AllowedMimeType
|
||||||
} from './constants'
|
} from './constants'
|
||||||
import { getSetting, setSetting } from './db'
|
import { getSetting, setSetting } from './db'
|
||||||
@@ -8,6 +11,8 @@ import { TELEGRAM_MAX_FILE_BYTES } from './storage/telegram'
|
|||||||
|
|
||||||
export const SETTINGS_UPLOAD_MAX_FILE_SIZE_MB = 'upload_max_file_size_mb'
|
export const SETTINGS_UPLOAD_MAX_FILE_SIZE_MB = 'upload_max_file_size_mb'
|
||||||
export const SETTINGS_ALLOWED_MIME_TYPES = 'allowed_mime_types'
|
export const SETTINGS_ALLOWED_MIME_TYPES = 'allowed_mime_types'
|
||||||
|
export const SETTINGS_ALLOWED_VIDEO_EXTENSIONS = 'allowed_video_extensions'
|
||||||
|
export const SETTINGS_FORBIDDEN_FILE_EXTENSIONS = 'forbidden_file_extensions'
|
||||||
export const SETTINGS_UPLOAD_RATE_IP_MAX = 'upload_rate_ip_max'
|
export const SETTINGS_UPLOAD_RATE_IP_MAX = 'upload_rate_ip_max'
|
||||||
export const SETTINGS_UPLOAD_RATE_TOKEN_MAX = 'upload_rate_token_max'
|
export const SETTINGS_UPLOAD_RATE_TOKEN_MAX = 'upload_rate_token_max'
|
||||||
export const SETTINGS_UPLOAD_RATE_WINDOW_MINUTES = 'upload_rate_window_minutes'
|
export const SETTINGS_UPLOAD_RATE_WINDOW_MINUTES = 'upload_rate_window_minutes'
|
||||||
@@ -41,6 +46,37 @@ export const ALL_MIME_TYPE_OPTIONS = [
|
|||||||
{ mime: 'image/x-icon', label: 'ICO' }
|
{ mime: 'image/x-icon', label: 'ICO' }
|
||||||
] as const satisfies ReadonlyArray<{ mime: AllowedMimeType, label: string }>
|
] as const satisfies ReadonlyArray<{ mime: AllowedMimeType, label: string }>
|
||||||
|
|
||||||
|
export const ALL_VIDEO_EXTENSION_OPTIONS = VIDEO_EXTENSIONS.map(extension => ({
|
||||||
|
extension,
|
||||||
|
label: extension.toUpperCase()
|
||||||
|
}))
|
||||||
|
|
||||||
|
export const FORBIDDEN_FILE_EXTENSION_OPTIONS = [
|
||||||
|
'html',
|
||||||
|
'htm',
|
||||||
|
'xhtml',
|
||||||
|
'svg',
|
||||||
|
'xml',
|
||||||
|
'js',
|
||||||
|
'mjs',
|
||||||
|
'cjs',
|
||||||
|
'php',
|
||||||
|
'phtml',
|
||||||
|
'sh',
|
||||||
|
'bash',
|
||||||
|
'zsh',
|
||||||
|
'bat',
|
||||||
|
'cmd',
|
||||||
|
'ps1',
|
||||||
|
'exe',
|
||||||
|
'dll',
|
||||||
|
'msi',
|
||||||
|
'scr'
|
||||||
|
].map(extension => ({
|
||||||
|
extension,
|
||||||
|
label: extension.toUpperCase()
|
||||||
|
}))
|
||||||
|
|
||||||
export type PolicySource = 'env' | 'db' | 'default'
|
export type PolicySource = 'env' | 'db' | 'default'
|
||||||
|
|
||||||
function readEnv(name: string): string {
|
function readEnv(name: string): string {
|
||||||
@@ -119,12 +155,79 @@ export function serializeAllowedMimeTypes(mimes: readonly string[]): string {
|
|||||||
.join(',')
|
.join(',')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeAllowedVideoExtensions(raw: string): VideoExtension[] {
|
||||||
|
const allowedSet = new Set<string>(VIDEO_EXTENSIONS)
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const result: VideoExtension[] = []
|
||||||
|
for (const part of raw.split(',')) {
|
||||||
|
const extension = part.trim().toLowerCase().replace(/^\.+/, '')
|
||||||
|
if (!extension || seen.has(extension) || !allowedSet.has(extension)) continue
|
||||||
|
seen.add(extension)
|
||||||
|
result.push(extension as VideoExtension)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeAllowedVideoExtensions(extensions: readonly string[]): string {
|
||||||
|
const normalized = new Set(normalizeAllowedVideoExtensions(extensions.join(',')))
|
||||||
|
return VIDEO_EXTENSIONS
|
||||||
|
.filter(extension => normalized.has(extension))
|
||||||
|
.join(',')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeForbiddenFileExtensions(raw: string): string[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const result: string[] = []
|
||||||
|
for (const part of raw.split(',')) {
|
||||||
|
const extension = part
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/^\.+/, '')
|
||||||
|
if (!extension || seen.has(extension)) continue
|
||||||
|
if (!/^[a-z0-9][a-z0-9_-]{0,15}$/.test(extension)) continue
|
||||||
|
seen.add(extension)
|
||||||
|
result.push(extension)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeForbiddenFileExtensions(extensions: readonly string[]): string {
|
||||||
|
const configuredOrder = new Map(
|
||||||
|
FORBIDDEN_FILE_EXTENSION_OPTIONS.map((option, index) => [option.extension, index])
|
||||||
|
)
|
||||||
|
return normalizeForbiddenFileExtensions(extensions.join(','))
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftOrder = configuredOrder.get(left)
|
||||||
|
const rightOrder = configuredOrder.get(right)
|
||||||
|
if (leftOrder !== undefined || rightOrder !== undefined) {
|
||||||
|
return (leftOrder ?? Number.MAX_SAFE_INTEGER)
|
||||||
|
- (rightOrder ?? Number.MAX_SAFE_INTEGER)
|
||||||
|
}
|
||||||
|
return left.localeCompare(right)
|
||||||
|
})
|
||||||
|
.join(',')
|
||||||
|
}
|
||||||
|
|
||||||
function getUploadMaxFileSizeMbSource(): PolicySource {
|
function getUploadMaxFileSizeMbSource(): PolicySource {
|
||||||
if (getSetting(SETTINGS_UPLOAD_MAX_FILE_SIZE_MB) !== null) return 'db'
|
if (getSetting(SETTINGS_UPLOAD_MAX_FILE_SIZE_MB) !== null) return 'db'
|
||||||
if (parseUploadMaxFileSizeMb(readEnv('UPLOAD_MAX_FILE_SIZE_MB')) !== null) return 'env'
|
if (parseUploadMaxFileSizeMb(readEnv('UPLOAD_MAX_FILE_SIZE_MB')) !== null) return 'env'
|
||||||
return 'default'
|
return 'default'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAllowedVideoExtensionsSource(): PolicySource {
|
||||||
|
if (getSetting(SETTINGS_ALLOWED_VIDEO_EXTENSIONS) !== null) return 'db'
|
||||||
|
if (normalizeAllowedVideoExtensions(readEnv('ALLOWED_VIDEO_EXTENSIONS')).length > 0) {
|
||||||
|
return 'env'
|
||||||
|
}
|
||||||
|
return 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getForbiddenFileExtensionsSource(): PolicySource {
|
||||||
|
if (getSetting(SETTINGS_FORBIDDEN_FILE_EXTENSIONS) !== null) return 'db'
|
||||||
|
if (readEnv('FORBIDDEN_FILE_EXTENSIONS')) return 'env'
|
||||||
|
return 'default'
|
||||||
|
}
|
||||||
|
|
||||||
function getAllowedMimeTypesSource(): PolicySource {
|
function getAllowedMimeTypesSource(): PolicySource {
|
||||||
if (getSetting(SETTINGS_ALLOWED_MIME_TYPES) !== null) return 'db'
|
if (getSetting(SETTINGS_ALLOWED_MIME_TYPES) !== null) return 'db'
|
||||||
if (normalizeAllowedMimeTypes(readEnv('ALLOWED_MIME_TYPES')).length > 0) return 'env'
|
if (normalizeAllowedMimeTypes(readEnv('ALLOWED_MIME_TYPES')).length > 0) return 'env'
|
||||||
@@ -202,6 +305,35 @@ export function getAllowedMimeTypesList(): AllowedMimeType[] {
|
|||||||
return [...ALLOWED_MIME_TYPES]
|
return [...ALLOWED_MIME_TYPES]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAllowedVideoExtensions(): VideoExtension[] {
|
||||||
|
const dbRaw = getSetting(SETTINGS_ALLOWED_VIDEO_EXTENSIONS)
|
||||||
|
if (dbRaw !== null) {
|
||||||
|
const parsed = normalizeAllowedVideoExtensions(dbRaw)
|
||||||
|
if (parsed.length > 0) return parsed
|
||||||
|
}
|
||||||
|
const fromEnv = normalizeAllowedVideoExtensions(readEnv('ALLOWED_VIDEO_EXTENSIONS'))
|
||||||
|
if (fromEnv.length > 0) return fromEnv
|
||||||
|
return [...VIDEO_EXTENSIONS]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getForbiddenFileExtensions(): string[] {
|
||||||
|
const dbRaw = getSetting(SETTINGS_FORBIDDEN_FILE_EXTENSIONS)
|
||||||
|
if (dbRaw !== null) return normalizeForbiddenFileExtensions(dbRaw)
|
||||||
|
const envRaw = readEnv('FORBIDDEN_FILE_EXTENSIONS')
|
||||||
|
if (envRaw) return normalizeForbiddenFileExtensions(envRaw)
|
||||||
|
return [...FORBIDDEN_FILE_EXTENSIONS]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isVideoExtensionAllowed(extension: string): boolean {
|
||||||
|
const normalized = extension.trim().toLowerCase().replace(/^\.+/, '')
|
||||||
|
return getAllowedVideoExtensions().includes(normalized as VideoExtension)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isForbiddenFileExtension(extension: string): boolean {
|
||||||
|
const normalized = extension.trim().toLowerCase().replace(/^\.+/, '')
|
||||||
|
return Boolean(normalized) && getForbiddenFileExtensions().includes(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
export function isMimeTypeAllowed(mime: string): mime is AllowedMimeType {
|
export function isMimeTypeAllowed(mime: string): mime is AllowedMimeType {
|
||||||
return getAllowedMimeTypesList().includes(mime as AllowedMimeType)
|
return getAllowedMimeTypesList().includes(mime as AllowedMimeType)
|
||||||
}
|
}
|
||||||
@@ -312,6 +444,16 @@ export function getUploadPolicySettingsPayload() {
|
|||||||
allowedMimeTypes: getAllowedMimeTypesList(),
|
allowedMimeTypes: getAllowedMimeTypesList(),
|
||||||
allowedMimeTypesSource: getAllowedMimeTypesSource(),
|
allowedMimeTypesSource: getAllowedMimeTypesSource(),
|
||||||
allowedMimeTypesEnvFallback: normalizeAllowedMimeTypes(readEnv('ALLOWED_MIME_TYPES')),
|
allowedMimeTypesEnvFallback: normalizeAllowedMimeTypes(readEnv('ALLOWED_MIME_TYPES')),
|
||||||
|
allowedVideoExtensions: getAllowedVideoExtensions(),
|
||||||
|
allowedVideoExtensionsSource: getAllowedVideoExtensionsSource(),
|
||||||
|
allowedVideoExtensionsEnvFallback: normalizeAllowedVideoExtensions(
|
||||||
|
readEnv('ALLOWED_VIDEO_EXTENSIONS')
|
||||||
|
),
|
||||||
|
forbiddenFileExtensions: getForbiddenFileExtensions(),
|
||||||
|
forbiddenFileExtensionsSource: getForbiddenFileExtensionsSource(),
|
||||||
|
forbiddenFileExtensionsEnvFallback: normalizeForbiddenFileExtensions(
|
||||||
|
readEnv('FORBIDDEN_FILE_EXTENSIONS')
|
||||||
|
),
|
||||||
uploadRateIpMax: getUploadRateLimitSettings().ipMax,
|
uploadRateIpMax: getUploadRateLimitSettings().ipMax,
|
||||||
uploadRateIpMaxSource: getUploadRateIpMaxSource(),
|
uploadRateIpMaxSource: getUploadRateIpMaxSource(),
|
||||||
uploadRateTokenMax: getUploadRateLimitSettings().tokenMax,
|
uploadRateTokenMax: getUploadRateLimitSettings().tokenMax,
|
||||||
@@ -327,13 +469,17 @@ export function getUploadPolicySettingsPayload() {
|
|||||||
mimeTypeOptions: ALL_MIME_TYPE_OPTIONS.map(option => ({
|
mimeTypeOptions: ALL_MIME_TYPE_OPTIONS.map(option => ({
|
||||||
mime: option.mime,
|
mime: option.mime,
|
||||||
label: option.label
|
label: option.label
|
||||||
}))
|
})),
|
||||||
|
videoExtensionOptions: ALL_VIDEO_EXTENSION_OPTIONS,
|
||||||
|
forbiddenFileExtensionOptions: FORBIDDEN_FILE_EXTENSION_OPTIONS
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setUploadPolicySettings(input: {
|
export function setUploadPolicySettings(input: {
|
||||||
uploadMaxFileSizeMb?: number
|
uploadMaxFileSizeMb?: number
|
||||||
allowedMimeTypes?: readonly string[]
|
allowedMimeTypes?: readonly string[]
|
||||||
|
allowedVideoExtensions?: readonly string[]
|
||||||
|
forbiddenFileExtensions?: readonly string[]
|
||||||
uploadRateIpMax?: number
|
uploadRateIpMax?: number
|
||||||
uploadRateTokenMax?: number
|
uploadRateTokenMax?: number
|
||||||
uploadRateWindowMinutes?: number
|
uploadRateWindowMinutes?: number
|
||||||
@@ -347,6 +493,18 @@ export function setUploadPolicySettings(input: {
|
|||||||
if (input.allowedMimeTypes !== undefined) {
|
if (input.allowedMimeTypes !== undefined) {
|
||||||
setSetting(SETTINGS_ALLOWED_MIME_TYPES, serializeAllowedMimeTypes(input.allowedMimeTypes))
|
setSetting(SETTINGS_ALLOWED_MIME_TYPES, serializeAllowedMimeTypes(input.allowedMimeTypes))
|
||||||
}
|
}
|
||||||
|
if (input.allowedVideoExtensions !== undefined) {
|
||||||
|
setSetting(
|
||||||
|
SETTINGS_ALLOWED_VIDEO_EXTENSIONS,
|
||||||
|
serializeAllowedVideoExtensions(input.allowedVideoExtensions)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (input.forbiddenFileExtensions !== undefined) {
|
||||||
|
setSetting(
|
||||||
|
SETTINGS_FORBIDDEN_FILE_EXTENSIONS,
|
||||||
|
serializeForbiddenFileExtensions(input.forbiddenFileExtensions)
|
||||||
|
)
|
||||||
|
}
|
||||||
if (input.uploadRateIpMax !== undefined) {
|
if (input.uploadRateIpMax !== undefined) {
|
||||||
setSetting(SETTINGS_UPLOAD_RATE_IP_MAX, String(input.uploadRateIpMax))
|
setSetting(SETTINGS_UPLOAD_RATE_IP_MAX, String(input.uploadRateIpMax))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user