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