服务器配置

This commit is contained in:
tianmo committed 2026-10-17 16:09:31 +08:00
1 parent a1b4a63552
commit fa7c8244b0
33 files changed
+1856 -337

No files matched your search

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