同步设置

This commit is contained in:
tianmo committed 2026-10-17 19:01:13 +08:00
1 parent 832261739f
commit 92aaf6fbb8
28 files changed
+705 -227

No files matched your search

+3 -1
View File
@@ -31,7 +31,9 @@
# WEBP_QUALITY=80 # WEBP_QUALITY=80
# 上传限制(也可在设置 → 访问控制配置;环境变量优先级更高) # 上传限制(也可在设置 → 访问控制配置;环境变量优先级更高)
# UPLOAD_MAX_FILE_SIZE_MB=10 # UPLOAD_IMAGE_MAX_FILE_SIZE_MB=10
# UPLOAD_VIDEO_MAX_FILE_SIZE_MB=500
# UPLOAD_FILE_MAX_FILE_SIZE_MB=100
# ALLOWED_MIME_TYPES=image/jpeg,image/png,image/webp,image/gif,image/svg+xml,image/x-icon # ALLOWED_MIME_TYPES=image/jpeg,image/png,image/webp,image/gif,image/svg+xml,image/x-icon
# UPLOAD_RATE_IP_MAX=60 # UPLOAD_RATE_IP_MAX=60
# UPLOAD_RATE_TOKEN_MAX=120 # UPLOAD_RATE_TOKEN_MAX=120
+24 -1
View File
@@ -7,22 +7,44 @@
.upload-card-flip-inner { .upload-card-flip-inner {
position: relative; position: relative;
transition: transform 0.55s cubic-bezier(0.4, 0, 0.2, 1); transition:
transform 0.55s cubic-bezier(0.4, 0, 0.2, 1),
min-height 0.35s ease;
transform-style: preserve-3d; transform-style: preserve-3d;
} }
.upload-card-flip.is-flipped .upload-card-flip-inner { .upload-card-flip.is-flipped .upload-card-flip-inner {
min-height: min(40rem, calc(100vh - 11rem));
transform: rotateY(180deg); transform: rotateY(180deg);
} }
.upload-card-flip--file.is-flipped .upload-card-flip-inner {
min-height: min(27rem, calc(100vh - 11rem));
}
.upload-card-flip.is-flipped .upload-card-face--front {
visibility: hidden;
pointer-events: none;
}
.upload-card-flip.is-flipped .upload-settings-btn {
display: none;
}
.upload-card-face { .upload-card-face {
backface-visibility: hidden; backface-visibility: hidden;
-webkit-backface-visibility: hidden; -webkit-backface-visibility: hidden;
} }
.upload-card-face--front {
position: relative;
z-index: 1;
}
.upload-card-face--back { .upload-card-face--back {
position: absolute; position: absolute;
inset: 0; inset: 0;
z-index: 2;
transform: rotateY(180deg); transform: rotateY(180deg);
} }
@@ -33,6 +55,7 @@
.upload-card-flip.is-flipped .upload-card-flip-inner { .upload-card-flip.is-flipped .upload-card-flip-inner {
transform: none; transform: none;
min-height: 0;
} }
.upload-card-flip .upload-card-face--back { .upload-card-flip .upload-card-face--back {
+1 -10
View File
@@ -74,16 +74,7 @@ function fileIcon(item: FileItem): string {
<p class="truncate text-xs text-muted"> <p class="truncate text-xs text-muted">
{{ uploadedLabel }} · {{ formatSize(item.size) }} {{ uploadedLabel }} · {{ formatSize(item.size) }}
</p> </p>
<div <TagBadgeList :tags="item.tags" />
v-if="item.tags?.length"
class="flex flex-wrap gap-1"
>
<TagBadge
v-for="tag in item.tags"
:key="tag.id"
:tag="tag"
/>
</div>
</div> </div>
<div class="flex items-center justify-around border-t border-default px-2 py-2"> <div class="flex items-center justify-around border-t border-default px-2 py-2">
+5 -14
View File
@@ -633,20 +633,11 @@ defineExpose({ refresh })
{{ uploadedLabel(item) }} {{ uploadedLabel(item) }}
</td> </td>
<td class="hidden px-3 py-2 md:table-cell"> <td class="hidden px-3 py-2 md:table-cell">
<div <TagBadgeList
v-if="item.tags?.length" :tags="item.tags"
class="flex flex-wrap gap-1" compact
> :empty-label="t('tags.unlabeled')"
<TagBadge />
v-for="tag in item.tags"
:key="tag.id"
:tag="tag"
/>
</div>
<span
v-else
class="text-muted"
>{{ t('tags.unlabeled') }}</span>
</td> </td>
<td class="px-3 py-2"> <td class="px-3 py-2">
<div class="flex items-center justify-end gap-1"> <div class="flex items-center justify-end gap-1">
+8 -4
View File
@@ -32,24 +32,28 @@ onMounted(() => {
const text = computed(() => { const text = computed(() => {
const allowedVideos = uploadPolicy.value?.allowedVideoExtensions ?? [] const allowedVideos = uploadPolicy.value?.allowedVideoExtensions ?? []
const forbiddenFiles = uploadPolicy.value?.forbiddenFileExtensions ?? [] const forbiddenFiles = uploadPolicy.value?.forbiddenFileExtensions ?? []
const videoMaxFileSizeMb = uploadPolicy.value?.videoMaxFileSizeMb ?? 500
const fileMaxFileSizeMb = uploadPolicy.value?.fileMaxFileSizeMb ?? 100
return props.kind === 'video' 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: allowedVideos.length uploadFormats: allowedVideos.length
? t('library.video.uploadFormatsAllowed', { ? t('library.video.uploadFormatsAllowed', {
formats: allowedVideos.map(item => item.toUpperCase()).join(' / ') formats: allowedVideos.map(item => item.toUpperCase()).join(' / '),
size: videoMaxFileSizeMb
}) })
: t('library.video.uploadFormats') : t('library.video.uploadFormats', { size: videoMaxFileSizeMb })
} }
: { : {
uploadTitle: t('library.file.uploadTitle'), uploadTitle: t('library.file.uploadTitle'),
uploadHint: t('library.file.uploadHint'), uploadHint: t('library.file.uploadHint'),
uploadFormats: forbiddenFiles.length uploadFormats: forbiddenFiles.length
? t('library.file.uploadFormatsBlocked', { ? t('library.file.uploadFormatsBlocked', {
formats: forbiddenFiles.map(item => item.toUpperCase()).join(' / ') formats: forbiddenFiles.map(item => item.toUpperCase()).join(' / '),
size: fileMaxFileSizeMb
}) })
: t('library.file.uploadFormatsUnrestricted') : t('library.file.uploadFormatsUnrestricted', { size: fileMaxFileSizeMb })
} }
}) })
+1 -1
View File
@@ -17,7 +17,7 @@ const emit = defineEmits<{
'created': [tag: ImageTag] 'created': [tag: ImageTag]
}>() }>()
const COMMON_LIMIT = 12 const COMMON_LIMIT = 10
const { t } = useI18n() const { t } = useI18n()
const toast = useToast() const toast = useToast()
const { createTag } = useTags() const { createTag } = useTags()
+70
View File
@@ -0,0 +1,70 @@
<script setup lang="ts">
import type { ImageTag } from '~/types/image'
const props = withDefaults(defineProps<{
tags?: ImageTag[]
max?: number
compact?: boolean
emptyLabel?: string
}>(), {
tags: () => [],
max: 3,
compact: false,
emptyLabel: ''
})
const emit = defineEmits<{
'tag-click': [tagId: number]
}>()
const { t } = useI18n()
const visibleTags = computed(() => props.tags.slice(0, props.max))
const hiddenTags = computed(() => props.tags.slice(props.max))
</script>
<template>
<div
v-if="tags.length"
class="flex flex-wrap items-center"
:class="compact ? 'gap-1' : 'gap-1.5'"
>
<TagBadge
v-for="tag in visibleTags"
:key="tag.id"
:tag="tag"
@click="emit('tag-click', tag.id)"
/>
<UPopover
v-if="hiddenTags.length"
:content="{ side: 'bottom', align: 'start' }"
>
<button
type="button"
class="inline-flex rounded-md border border-default text-muted transition-colors hover:border-primary/40 hover:text-primary"
:class="compact ? 'px-1.5 py-0.5 text-[11px]' : 'px-2 py-0.5 text-xs'"
:aria-label="t('tags.moreTags', { n: hiddenTags.length })"
>
+{{ hiddenTags.length }}
</button>
<template #content>
<div
class="flex max-w-56 flex-wrap gap-1.5 p-2"
>
<TagBadge
v-for="tag in hiddenTags"
:key="tag.id"
:tag="tag"
@click="emit('tag-click', tag.id)"
/>
</div>
</template>
</UPopover>
</div>
<span
v-else-if="emptyLabel"
class="text-muted"
>
{{ emptyLabel }}
</span>
</template>
+1 -1
View File
@@ -43,7 +43,7 @@ function onBack() {
<template> <template>
<div <div
class="upload-card-flip" class="upload-card-flip"
:class="{ 'is-flipped': flipped }" :class="[`upload-card-flip--${kind}`, { 'is-flipped': flipped }]"
> >
<div class="upload-card-flip-inner"> <div class="upload-card-flip-inner">
<div class="upload-card-face upload-card-face--front"> <div class="upload-card-face upload-card-face--front">
+116 -26
View File
@@ -1,5 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { UploadMediaKind } from '~/composables/useUploadPreferences' import {
normalizeUploadTagNames,
type UploadMediaKind
} from '~/composables/useUploadPreferences'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
/** 每个上传页各自一份偏好 */ /** 每个上传页各自一份偏好 */
@@ -17,20 +20,36 @@ const {
autoCopyMarkdown, autoCopyMarkdown,
copyFormat, copyFormat,
clientWebpQuality, clientWebpQuality,
lastTagIds, uploadTagNames,
transcodeToHls, transcodeToHls,
loadPreferences loadPreferences
} = useUploadPreferences(props.kind) } = useUploadPreferences(props.kind)
const { items: tagItems, fetchTags } = useTags() const uploadTagDraft = ref('')
/** 视频床 / 文件床没有图片压缩相关选项 */ /** 视频床 / 文件床没有图片压缩相关选项 */
const isImageKind = computed(() => props.kind === 'image') const isImageKind = computed(() => props.kind === 'image')
const isVideoKind = computed(() => props.kind === 'video') const isVideoKind = computed(() => props.kind === 'video')
const gridClass = computed(() => { const processingGridClass = computed(() => {
if (isImageKind.value) return 'sm:grid-cols-3 sm:gap-6' if (isImageKind.value || isVideoKind.value) return 'md:col-start-1 md:row-start-1'
if (isVideoKind.value) return 'sm:grid-cols-2 sm:gap-6' return ''
return 'mx-auto w-full max-w-md'
}) })
const qualityGridClass = computed(() => {
if (isImageKind.value) return 'md:col-start-1 md:row-start-2'
return ''
})
const convenienceGridClass = computed(() => {
if (isImageKind.value) return 'md:col-start-2 md:row-start-1'
if (isVideoKind.value) return 'md:col-start-1 md:row-start-2'
return ''
})
const tagsGridClass = computed(() => {
if (isImageKind.value) return 'md:col-start-2 md:row-start-2'
if (isVideoKind.value) return 'md:col-start-2 md:row-start-1 md:row-span-2'
return ''
})
const tagsLayoutClass = computed(() => [
tagsGridClass.value
])
const { t } = useI18n() const { t } = useI18n()
const { const {
@@ -74,11 +93,39 @@ function onClientQualityInput(event: Event) {
clientWebpQuality.value = Number.isFinite(value) ? value : 80 clientWebpQuality.value = Number.isFinite(value) ? value : 80
} }
function commitUploadTags() {
const names = normalizeUploadTagNames(uploadTagDraft.value)
if (!names.length) return
uploadTagNames.value = normalizeUploadTagNames([
...uploadTagNames.value,
...names
])
uploadTagDraft.value = ''
}
function onUploadTagKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' || event.key === ',' || event.key === ',') {
event.preventDefault()
commitUploadTags()
return
}
if (
event.key === 'Backspace'
&& !uploadTagDraft.value
&& uploadTagNames.value.length
) {
uploadTagNames.value = uploadTagNames.value.slice(0, -1)
}
}
function removeUploadTag(name: string) {
uploadTagNames.value = uploadTagNames.value.filter(item => item !== name)
}
onMounted(() => { onMounted(() => {
loadPreferences() loadPreferences()
void loadUserSettings() void loadUserSettings()
void loadTranscodeSettings() void loadTranscodeSettings()
void fetchTags()
}) })
</script> </script>
@@ -113,12 +160,12 @@ onMounted(() => {
<div class="flex flex-1 flex-col justify-start overflow-y-auto p-5 sm:p-6"> <div class="flex flex-1 flex-col justify-start overflow-y-auto p-5 sm:p-6">
<div <div
class="grid gap-5" class="grid gap-5 md:grid-cols-2 md:gap-6"
:class="gridClass"
> >
<div <div
v-if="isImageKind" v-if="isImageKind"
class="space-y-4" class="space-y-4 rounded-xl border border-default/70 bg-muted/10 p-4"
:class="processingGridClass"
> >
<h3 class="text-sm font-semibold"> <h3 class="text-sm font-semibold">
{{ t('preferences.processing') }} {{ t('preferences.processing') }}
@@ -179,7 +226,8 @@ onMounted(() => {
<div <div
v-if="isVideoKind" v-if="isVideoKind"
class="space-y-4" class="space-y-4 rounded-xl border border-default/70 bg-muted/10 p-4"
:class="processingGridClass"
> >
<h3 class="text-sm font-semibold"> <h3 class="text-sm font-semibold">
{{ t('preferences.videoProcessing') }} {{ t('preferences.videoProcessing') }}
@@ -217,7 +265,8 @@ onMounted(() => {
<div <div
v-if="isImageKind" v-if="isImageKind"
class="space-y-4" class="space-y-4 rounded-xl border border-default/70 bg-muted/10 p-4"
:class="qualityGridClass"
> >
<h3 class="text-sm font-semibold"> <h3 class="text-sm font-semibold">
{{ t('preferences.webpQuality') }} {{ t('preferences.webpQuality') }}
@@ -239,7 +288,10 @@ onMounted(() => {
> >
</div> </div>
<div class="space-y-4"> <div
class="space-y-4 rounded-xl border border-default/70 bg-muted/10 p-4"
:class="convenienceGridClass"
>
<h3 class="text-sm font-semibold"> <h3 class="text-sm font-semibold">
{{ t('preferences.convenience') }} {{ t('preferences.convenience') }}
</h3> </h3>
@@ -271,19 +323,57 @@ onMounted(() => {
/> />
</div> </div>
</div> </div>
</div>
<div class="mt-5 space-y-3 border-t border-default pt-5"> <div
<h3 class="text-sm font-semibold"> class="space-y-3 rounded-xl border border-default/70 bg-muted/10 p-4"
{{ t('preferences.uploadTags') }} :class="tagsLayoutClass"
</h3> >
<p class="text-xs leading-relaxed text-muted"> <div class="space-y-3">
{{ t('preferences.uploadTagsHint') }} <h3 class="text-sm font-semibold">
</p> {{ t('preferences.uploadTags') }}
<TagAssignPanel </h3>
v-model="lastTagIds" <p class="text-xs leading-relaxed text-muted">
:tags="tagItems" {{ t('preferences.uploadTagsHint') }}
/> </p>
</div>
<UInput
v-model="uploadTagDraft"
size="md"
icon="i-lucide-tags"
maxlength="32"
:placeholder="t('preferences.uploadTagsPlaceholder')"
@keydown="onUploadTagKeydown"
@blur="commitUploadTags"
/>
<div
v-if="uploadTagNames.length"
class="space-y-2 pt-1"
>
<p class="text-xs font-medium text-muted">
{{ t('preferences.uploadTagsPending', { n: uploadTagNames.length }) }}
</p>
<div class="flex flex-wrap gap-2.5">
<span
v-for="name in uploadTagNames"
:key="name"
class="relative inline-flex max-w-full items-center rounded-full border border-primary/35 bg-primary/10 px-3 py-1 text-xs font-medium text-primary"
>
<span class="truncate">{{ name }}</span>
<button
type="button"
class="absolute -top-1.5 -right-1.5 inline-flex size-4 items-center justify-center rounded-full border border-primary/40 bg-elevated text-primary shadow-sm transition-colors hover:bg-primary hover:text-inverted"
:aria-label="t('preferences.uploadTagsRemove', { name })"
@click="removeUploadTag(name)"
>
<UIcon
name="i-lucide-x"
class="size-2.5"
/>
</button>
</span>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
+1 -10
View File
@@ -109,16 +109,7 @@ const uploadedLabel = computed(() => {
<p class="truncate text-xs text-muted"> <p class="truncate text-xs text-muted">
{{ uploadedLabel }} · {{ formatFileSize(item.size) }} {{ uploadedLabel }} · {{ formatFileSize(item.size) }}
</p> </p>
<div <TagBadgeList :tags="item.tags" />
v-if="item.tags?.length"
class="flex flex-wrap gap-1"
>
<TagBadge
v-for="tag in item.tags"
:key="tag.id"
:tag="tag"
/>
</div>
</div> </div>
<div class="flex items-center justify-around border-t border-default px-2 py-2"> <div class="flex items-center justify-around border-t border-default px-2 py-2">
+5 -14
View File
@@ -659,20 +659,11 @@ defineExpose({ refresh })
{{ uploadedLabel(item) }} {{ uploadedLabel(item) }}
</td> </td>
<td class="hidden px-3 py-2 md:table-cell"> <td class="hidden px-3 py-2 md:table-cell">
<div <TagBadgeList
v-if="item.tags?.length" :tags="item.tags"
class="flex flex-wrap gap-1" compact
> :empty-label="t('tags.unlabeled')"
<TagBadge />
v-for="tag in item.tags"
:key="tag.id"
:tag="tag"
/>
</div>
<span
v-else
class="text-muted"
>{{ t('tags.unlabeled') }}</span>
</td> </td>
<td class="px-3 py-2"> <td class="px-3 py-2">
<div class="flex items-center justify-end gap-1"> <div class="flex items-center justify-end gap-1">
@@ -5,7 +5,9 @@ type VideoExtension = 'mp4' | 'm4v' | 'webm' | 'mov' | 'mkv' | 'avi' | 'flv' | '
const props = defineProps<{ const props = defineProps<{
part: 'upload-preference' | 'image-processing' | 'login-rate' part: 'upload-preference' | 'image-processing' | 'login-rate'
uploadMaxFileSizeMbSource?: PolicySource uploadImageMaxFileSizeMbSource?: PolicySource
uploadVideoMaxFileSizeMbSource?: PolicySource
uploadFileMaxFileSizeMbSource?: PolicySource
allowedMimeTypesSource?: PolicySource allowedMimeTypesSource?: PolicySource
allowedVideoExtensionsSource?: PolicySource allowedVideoExtensionsSource?: PolicySource
forbiddenFileExtensionsSource?: PolicySource forbiddenFileExtensionsSource?: PolicySource
@@ -20,7 +22,9 @@ const props = defineProps<{
forbiddenFileExtensionOptions?: ReadonlyArray<{ extension: string, label: string }> forbiddenFileExtensionOptions?: ReadonlyArray<{ extension: string, label: string }>
}>() }>()
const uploadMaxFileSizeMbDraft = defineModel<number>('uploadMaxFileSizeMb') const uploadImageMaxFileSizeMbDraft = defineModel<number>('uploadImageMaxFileSizeMb')
const uploadVideoMaxFileSizeMbDraft = defineModel<number>('uploadVideoMaxFileSizeMb')
const uploadFileMaxFileSizeMbDraft = defineModel<number>('uploadFileMaxFileSizeMb')
const allowedMimeTypesDraft = defineModel<MimeType[]>('allowedMimeTypes') const allowedMimeTypesDraft = defineModel<MimeType[]>('allowedMimeTypes')
const allowedVideoExtensionsDraft = defineModel<VideoExtension[]>('allowedVideoExtensions') const allowedVideoExtensionsDraft = defineModel<VideoExtension[]>('allowedVideoExtensions')
const forbiddenFileExtensionsDraft = defineModel<string[]>('forbiddenFileExtensions') const forbiddenFileExtensionsDraft = defineModel<string[]>('forbiddenFileExtensions')
@@ -125,67 +129,109 @@ function addCustomForbiddenExtension() {
customForbiddenExtension.value = '' customForbiddenExtension.value = ''
} }
function clampUploadMaxFileSizeMb(value: unknown): number { type UploadSizeKind = 'image' | 'video' | 'file'
const num = Number(value)
if (!Number.isFinite(num)) return uploadMaxFileSizeMbDraft.value ?? 10 const uploadSizeFields = computed(() => [
return Math.min(100, Math.max(1, Math.round(num))) {
kind: 'image' as const,
title: t('settings.uploadImageMaxFileSize'),
hint: t('settings.uploadImageMaxFileSizeHint'),
source: props.uploadImageMaxFileSizeMbSource,
value: uploadImageMaxFileSizeMbDraft.value ?? 10,
max: 100
},
{
kind: 'video' as const,
title: t('settings.uploadVideoMaxFileSize'),
hint: t('settings.uploadVideoMaxFileSizeHint'),
source: props.uploadVideoMaxFileSizeMbSource,
value: uploadVideoMaxFileSizeMbDraft.value ?? 500,
max: 2048
},
{
kind: 'file' as const,
title: t('settings.uploadFileMaxFileSize'),
hint: t('settings.uploadFileMaxFileSizeHint'),
source: props.uploadFileMaxFileSizeMbSource,
value: uploadFileMaxFileSizeMbDraft.value ?? 100,
max: 1024
}
])
function uploadSizeModel(kind: UploadSizeKind) {
if (kind === 'video') return uploadVideoMaxFileSizeMbDraft
if (kind === 'file') return uploadFileMaxFileSizeMbDraft
return uploadImageMaxFileSizeMbDraft
} }
function onUploadMaxFileSizeInput(event: Event) { function clampUploadMaxFileSizeMb(kind: UploadSizeKind, value: unknown): number {
uploadMaxFileSizeMbDraft.value = clampUploadMaxFileSizeMb( const num = Number(value)
const model = uploadSizeModel(kind)
const max = kind === 'video' ? 2048 : kind === 'file' ? 1024 : 100
if (!Number.isFinite(num)) return model.value ?? (kind === 'video' ? 500 : kind === 'file' ? 100 : 10)
return Math.min(max, Math.max(0, Math.round(num)))
}
function onUploadMaxFileSizeInput(kind: UploadSizeKind, event: Event) {
uploadSizeModel(kind).value = clampUploadMaxFileSizeMb(kind,
(event.target as HTMLInputElement).value (event.target as HTMLInputElement).value
) )
} }
function onUploadMaxFileSizeNumberInput(value: string | number) { function onUploadMaxFileSizeNumberInput(kind: UploadSizeKind, value: string | number) {
uploadMaxFileSizeMbDraft.value = clampUploadMaxFileSizeMb(value) uploadSizeModel(kind).value = clampUploadMaxFileSizeMb(kind, value)
} }
</script> </script>
<template> <template>
<template v-if="part === 'upload-preference'"> <template v-if="part === 'upload-preference'">
<div class="py-4"> <div class="space-y-5 py-4">
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.uploadMaxFileSize') }}
</p>
<UBadge
v-if="uploadMaxFileSizeMbSource"
:color="sourceBadge(uploadMaxFileSizeMbSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(uploadMaxFileSizeMbSource).label }}
</UBadge>
</div>
<p class="mt-1 text-xs leading-relaxed text-muted">
{{ t('settings.uploadMaxFileSizeHint') }}
</p>
<div <div
class="mt-3 flex items-center gap-3" v-for="field in uploadSizeFields"
:class="uploadMaxFileSizeMbSource === 'env' ? 'pointer-events-none opacity-60' : ''" :key="field.kind"
> >
<input <div class="flex flex-wrap items-center gap-2">
type="range" <p class="text-sm font-medium text-highlighted">
min="1" {{ field.title }}
max="100" </p>
:value="uploadMaxFileSizeMbDraft" <UBadge
class="h-1.5 min-w-0 flex-1 cursor-pointer appearance-none rounded-full bg-muted accent-primary" v-if="field.source"
:disabled="uploadMaxFileSizeMbSource === 'env'" :color="sourceBadge(field.source).color"
@input="onUploadMaxFileSizeInput" variant="subtle"
>
<div class="flex shrink-0 items-center gap-1.5">
<UInput
:model-value="uploadMaxFileSizeMbDraft"
type="number"
:min="1"
:max="100"
size="xs" size="xs"
class="w-14 tabular-nums" >
:disabled="uploadMaxFileSizeMbSource === 'env'" {{ sourceBadge(field.source).label }}
@update:model-value="onUploadMaxFileSizeNumberInput" </UBadge>
/> </div>
<span class="text-xs text-muted">MB</span> <p class="mt-1 text-xs leading-relaxed text-muted">
{{ field.hint }}
</p>
<div
class="mt-3 flex items-center gap-3"
:class="field.source === 'env' ? 'pointer-events-none opacity-60' : ''"
>
<input
type="range"
min="0"
:max="field.max"
:value="field.value"
class="h-1.5 min-w-0 flex-1 cursor-pointer appearance-none rounded-full bg-muted accent-primary"
:disabled="field.source === 'env'"
@input="onUploadMaxFileSizeInput(field.kind, $event)"
>
<div class="flex shrink-0 items-center gap-1.5">
<UInput
:model-value="field.value"
type="number"
:min="0"
:max="field.max"
size="xs"
class="w-16 tabular-nums"
:disabled="field.source === 'env'"
@update:model-value="onUploadMaxFileSizeNumberInput(field.kind, $event)"
/>
<span class="text-xs text-muted">MB</span>
</div>
</div> </div>
</div> </div>
</div> </div>
+25 -13
View File
@@ -4,21 +4,22 @@ import type {
} from '~/types/image' } from '~/types/image'
import { isUnauthorizedError } from './useAuth' import { isUnauthorizedError } from './useAuth'
import { prepareFilesForUpload } from './useClipboardImage' import { prepareFilesForUpload } from './useClipboardImage'
import { normalizeUploadTagNames } from './useUploadPreferences'
const MAX_FILES = 10 const MAX_FILES = 10
export function useImageUpload() { export function useImageUpload() {
const toast = useToast() const toast = useToast()
const { t } = useI18n() const { t } = useI18n()
const { compressEnabled, clientWebpQuality, lastTagIds } = useUploadPreferences() const { compressEnabled, clientWebpQuality, uploadTagNames } = useUploadPreferences()
const { items: tagItems } = useTags() const { load: loadUploadPolicy } = useUploadPolicy()
const uploading = ref(false) const uploading = ref(false)
const progressItems = ref<UploadProgressItem[]>([]) const progressItems = ref<UploadProgressItem[]>([])
const lastUploadResult = ref<UploadResponse | null>(null) const lastUploadResult = ref<UploadResponse | null>(null)
async function uploadFiles( async function uploadFiles(
files: File[], files: File[],
options?: { notify?: boolean, tagIds?: number[] } options?: { notify?: boolean }
) { ) {
if (uploading.value) return if (uploading.value) return
@@ -43,10 +44,27 @@ export function useImageUpload() {
})) }))
try { try {
const prepared = await prepareFilesForUpload(files, { const preparedFiles = await prepareFilesForUpload(files, {
enabled: compressEnabled.value, enabled: compressEnabled.value,
quality: clientWebpQuality.value / 100 quality: clientWebpQuality.value / 100
}) })
const currentPolicy = await loadUploadPolicy().catch(() => null)
const maxFileSizeMb = currentPolicy?.imageMaxFileSizeMb ?? 10
const prepared = preparedFiles.filter((file) => {
if (file.size <= maxFileSizeMb * 1024 * 1024) return true
toast.add({
title: t('files.fileTooLarge', {
name: file.name,
limit: maxFileSizeMb
}),
color: 'error'
})
return false
})
if (!prepared.length) {
progressItems.value = []
return
}
progressItems.value = prepared.map((file, index) => ({ progressItems.value = prepared.map((file, index) => ({
name: file.name, name: file.name,
@@ -59,15 +77,9 @@ export function useImageUpload() {
formData.append('files', file, file.name) formData.append('files', file, file.name)
} }
const tagIds = options?.tagIds ?? lastTagIds.value const tagNames = normalizeUploadTagNames(uploadTagNames.value).join(',')
if (tagIds.length) { if (tagNames) {
const names = tagIds formData.append('tagNames', tagNames)
.map(id => tagItems.value.find(tag => tag.id === id)?.name)
.filter((name): name is string => Boolean(name))
if (names.length) {
formData.append('tagNames', JSON.stringify(names))
}
lastTagIds.value = tagIds
} }
const response = await $fetch<UploadResponse>('/api/images/upload', { const response = await $fetch<UploadResponse>('/api/images/upload', {
+18 -10
View File
@@ -2,6 +2,7 @@ import type { UploadProgressItem } from '~/types/image'
import type { FileItem, FileListResponse, FileUploadResponse } from '~/types/file' import type { FileItem, FileListResponse, FileUploadResponse } from '~/types/file'
import type { CopyFormat, UploadMediaKind } from './useUploadPreferences' import type { CopyFormat, UploadMediaKind } from './useUploadPreferences'
import { isUnauthorizedError } from './useAuth' import { isUnauthorizedError } from './useAuth'
import { normalizeUploadTagNames } from './useUploadPreferences'
const MAX_FILES = 10 const MAX_FILES = 10
@@ -54,10 +55,9 @@ export function useMediaUpload(kind: UploadMediaKind) {
autoCopyMarkdown, autoCopyMarkdown,
copyFormat, copyFormat,
transcodeToHls, transcodeToHls,
lastTagIds, uploadTagNames,
loadPreferences loadPreferences
} = useUploadPreferences(kind) } = useUploadPreferences(kind)
const { items: tagItems, fetchTags } = useTags()
const uploading = ref(false) const uploading = ref(false)
const progressItems = ref<UploadProgressItem[]>([]) const progressItems = ref<UploadProgressItem[]>([])
@@ -209,6 +209,19 @@ export function useMediaUpload(kind: UploadMediaKind) {
}) })
return false return false
} }
const maxFileSizeMb = kind === 'video'
? (currentPolicy?.videoMaxFileSizeMb ?? 500)
: (currentPolicy?.fileMaxFileSizeMb ?? 100)
if (file.size > maxFileSizeMb * 1024 * 1024) {
toast.add({
title: t('files.fileTooLarge', {
name: file.name,
limit: maxFileSizeMb
}),
color: 'error'
})
return false
}
return true return true
}) })
if (!acceptedFiles.length) return if (!acceptedFiles.length) return
@@ -234,14 +247,9 @@ export function useMediaUpload(kind: UploadMediaKind) {
if (kind === 'video' && transcodeToHls.value) { if (kind === 'video' && transcodeToHls.value) {
formData.append('transcode', '1') formData.append('transcode', '1')
} }
if (lastTagIds.value.length) { const tagNames = normalizeUploadTagNames(uploadTagNames.value).join(',')
await fetchTags() if (tagNames) {
const names = lastTagIds.value formData.append('tagNames', tagNames)
.map(id => tagItems.value.find(tag => tag.id === id)?.name)
.filter((name): name is string => Boolean(name))
if (names.length) {
formData.append('tagNames', JSON.stringify(names))
}
} }
progressItems.value = progressItems.value.map(item => ({ progressItems.value = progressItems.value.map(item => ({
+7 -1
View File
@@ -2,6 +2,9 @@ interface UploadPolicy {
allowedMimeTypes: string[] allowedMimeTypes: string[]
allowedVideoExtensions: string[] allowedVideoExtensions: string[]
forbiddenFileExtensions: string[] forbiddenFileExtensions: string[]
imageMaxFileSizeMb: number
videoMaxFileSizeMb: number
fileMaxFileSizeMb: number
} }
const policy = ref<UploadPolicy | null>(null) const policy = ref<UploadPolicy | null>(null)
@@ -21,7 +24,10 @@ export function useUploadPolicy() {
policy.value = { policy.value = {
allowedMimeTypes: [...data.allowedMimeTypes], allowedMimeTypes: [...data.allowedMimeTypes],
allowedVideoExtensions: [...data.allowedVideoExtensions], allowedVideoExtensions: [...data.allowedVideoExtensions],
forbiddenFileExtensions: [...data.forbiddenFileExtensions] forbiddenFileExtensions: [...data.forbiddenFileExtensions],
imageMaxFileSizeMb: data.imageMaxFileSizeMb,
videoMaxFileSizeMb: data.videoMaxFileSizeMb,
fileMaxFileSizeMb: data.fileMaxFileSizeMb
} }
return policy.value return policy.value
}) })
+27 -10
View File
@@ -13,7 +13,7 @@ export interface UploadPreferences {
autoCopyMarkdown: boolean autoCopyMarkdown: boolean
copyFormat: CopyFormat copyFormat: CopyFormat
clientWebpQuality: number clientWebpQuality: number
lastTagIds: number[] uploadTagNames: string[]
/** 视频床专属:上传后转码为 HLS(m3u8 + 分片) */ /** 视频床专属:上传后转码为 HLS(m3u8 + 分片) */
transcodeToHls: boolean transcodeToHls: boolean
} }
@@ -23,10 +23,21 @@ const DEFAULTS: UploadPreferences = {
autoCopyMarkdown: false, autoCopyMarkdown: false,
copyFormat: 'markdown', copyFormat: 'markdown',
clientWebpQuality: 80, clientWebpQuality: 80,
lastTagIds: [], uploadTagNames: [],
transcodeToHls: false transcodeToHls: false
} }
export function normalizeUploadTagNames(value: string | readonly string[]): string[] {
const parts = typeof value === 'string'
? value.split(/[,,\n]/)
: value.flatMap(item => String(item).split(/[,,\n]/))
return [...new Set(
parts
.map(name => name.trim())
.filter(name => Boolean(name) && name.length <= 32)
)]
}
function clampQuality(value: unknown): number { function clampQuality(value: unknown): number {
const num = Number(value) const num = Number(value)
if (!Number.isFinite(num)) return DEFAULTS.clientWebpQuality if (!Number.isFinite(num)) return DEFAULTS.clientWebpQuality
@@ -49,9 +60,8 @@ function readStored(kind: UploadMediaKind): UploadPreferences {
autoCopyMarkdown: parsed.autoCopyMarkdown === true, autoCopyMarkdown: parsed.autoCopyMarkdown === true,
copyFormat: isCopyFormat(parsed.copyFormat) ? parsed.copyFormat : DEFAULTS.copyFormat, copyFormat: isCopyFormat(parsed.copyFormat) ? parsed.copyFormat : DEFAULTS.copyFormat,
clientWebpQuality: clampQuality(parsed.clientWebpQuality), clientWebpQuality: clampQuality(parsed.clientWebpQuality),
lastTagIds: Array.isArray(parsed.lastTagIds) /** 标签输入只在当前页面会话内生效,刷新后默认为空。 */
? parsed.lastTagIds.filter(id => Number.isInteger(id) && id > 0) uploadTagNames: [...DEFAULTS.uploadTagNames],
: DEFAULTS.lastTagIds,
transcodeToHls: parsed.transcodeToHls === true transcodeToHls: parsed.transcodeToHls === true
} }
} catch { } catch {
@@ -69,17 +79,23 @@ export function useUploadPreferences(kind: UploadMediaKind = 'image') {
const autoCopyMarkdown = useState(`upload-pref-auto-md-${kind}`, () => DEFAULTS.autoCopyMarkdown) const autoCopyMarkdown = useState(`upload-pref-auto-md-${kind}`, () => DEFAULTS.autoCopyMarkdown)
const copyFormat = useState<CopyFormat>(`upload-pref-copy-format-${kind}`, () => DEFAULTS.copyFormat) const copyFormat = useState<CopyFormat>(`upload-pref-copy-format-${kind}`, () => DEFAULTS.copyFormat)
const clientWebpQuality = useState(`upload-pref-webp-quality-${kind}`, () => DEFAULTS.clientWebpQuality) const clientWebpQuality = useState(`upload-pref-webp-quality-${kind}`, () => DEFAULTS.clientWebpQuality)
const lastTagIds = useState<number[]>(`upload-pref-last-tag-ids-${kind}`, () => DEFAULTS.lastTagIds) const uploadTagNames = useState<string[]>(
`upload-pref-tag-names-${kind}`,
() => [...DEFAULTS.uploadTagNames]
)
const transcodeToHls = useState(`upload-pref-transcode-${kind}`, () => DEFAULTS.transcodeToHls) const transcodeToHls = useState(`upload-pref-transcode-${kind}`, () => DEFAULTS.transcodeToHls)
const loaded = useState(`upload-pref-loaded-${kind}`, () => false) const loaded = useState(`upload-pref-loaded-${kind}`, () => false)
function loadPreferences() { function loadPreferences() {
const firstLoad = !loaded.value
const stored = readStored(kind) const stored = readStored(kind)
compressEnabled.value = stored.compressEnabled compressEnabled.value = stored.compressEnabled
autoCopyMarkdown.value = stored.autoCopyMarkdown autoCopyMarkdown.value = stored.autoCopyMarkdown
copyFormat.value = stored.copyFormat copyFormat.value = stored.copyFormat
clientWebpQuality.value = stored.clientWebpQuality clientWebpQuality.value = stored.clientWebpQuality
lastTagIds.value = stored.lastTagIds if (firstLoad) {
uploadTagNames.value = [...stored.uploadTagNames]
}
transcodeToHls.value = stored.transcodeToHls transcodeToHls.value = stored.transcodeToHls
loaded.value = true loaded.value = true
} }
@@ -94,7 +110,7 @@ export function useUploadPreferences(kind: UploadMediaKind = 'image') {
autoCopyMarkdown: autoCopyMarkdown.value, autoCopyMarkdown: autoCopyMarkdown.value,
copyFormat: copyFormat.value, copyFormat: copyFormat.value,
clientWebpQuality: clampQuality(clientWebpQuality.value), clientWebpQuality: clampQuality(clientWebpQuality.value),
lastTagIds: lastTagIds.value, uploadTagNames: [...DEFAULTS.uploadTagNames],
transcodeToHls: transcodeToHls.value transcodeToHls: transcodeToHls.value
}) })
} }
@@ -104,12 +120,13 @@ export function useUploadPreferences(kind: UploadMediaKind = 'image') {
autoCopyMarkdown.value = DEFAULTS.autoCopyMarkdown autoCopyMarkdown.value = DEFAULTS.autoCopyMarkdown
copyFormat.value = DEFAULTS.copyFormat copyFormat.value = DEFAULTS.copyFormat
clientWebpQuality.value = DEFAULTS.clientWebpQuality clientWebpQuality.value = DEFAULTS.clientWebpQuality
uploadTagNames.value = [...DEFAULTS.uploadTagNames]
transcodeToHls.value = DEFAULTS.transcodeToHls transcodeToHls.value = DEFAULTS.transcodeToHls
savePreferences() savePreferences()
} }
watch( watch(
[compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality, lastTagIds, transcodeToHls], [compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality, transcodeToHls],
() => { () => {
if (loaded.value) { if (loaded.value) {
savePreferences() savePreferences()
@@ -123,7 +140,7 @@ export function useUploadPreferences(kind: UploadMediaKind = 'image') {
autoCopyMarkdown, autoCopyMarkdown,
copyFormat, copyFormat,
clientWebpQuality, clientWebpQuality,
lastTagIds, uploadTagNames,
transcodeToHls, transcodeToHls,
loaded, loaded,
loadPreferences, loadPreferences,
+6
View File
@@ -3,6 +3,7 @@ import type { ImageItem, ImageTag } from '~/types/image'
import { buildImageBbcode } from '~/utils/image-display' import { buildImageBbcode } from '~/utils/image-display'
const { uploading, progressItems, uploadFiles } = useImageUpload() const { uploading, progressItems, uploadFiles } = useImageUpload()
const { policy: uploadPolicy, load: loadUploadPolicy } = useUploadPolicy()
const { const {
autoCopyMarkdown, autoCopyMarkdown,
@@ -16,6 +17,9 @@ const { recordRecent } = useRecentTags()
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus } = useAuth() const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus } = useAuth()
const toast = useToast() const toast = useToast()
const { t } = useI18n() const { t } = useI18n()
const imageFormats = computed(() =>
t('upload.formats', { size: uploadPolicy.value?.imageMaxFileSizeMb ?? 10 })
)
const sessionItems = ref<ImageItem[]>([]) const sessionItems = ref<ImageItem[]>([])
const deletingKeys = ref<Set<string>>(new Set()) const deletingKeys = ref<Set<string>>(new Set())
const emptySelection = ref(new Set<string>()) const emptySelection = ref(new Set<string>())
@@ -39,6 +43,7 @@ const showProgress = computed(
async function loadPage() { async function loadPage() {
try { try {
loadPreferences() loadPreferences()
await loadUploadPolicy().catch(() => {})
} catch (error: unknown) { } catch (error: unknown) {
handleAuthError(error) handleAuthError(error)
} }
@@ -227,6 +232,7 @@ async function confirmBatchTags(tagIds: number[]) {
<AppShell v-else> <AppShell v-else>
<UploadFlipCard <UploadFlipCard
:disabled="uploading" :disabled="uploading"
:formats="imageFormats"
@upload="handleUpload" @upload="handleUpload"
/> />
+24 -8
View File
@@ -55,8 +55,12 @@ interface SettingsResponse {
capApiEndpoint: string capApiEndpoint: string
capSecret: string capSecret: string
appVersion: string appVersion: string
uploadMaxFileSizeMb: number uploadImageMaxFileSizeMb: number
uploadMaxFileSizeMbSource: PolicySource uploadImageMaxFileSizeMbSource: PolicySource
uploadVideoMaxFileSizeMb: number
uploadVideoMaxFileSizeMbSource: PolicySource
uploadFileMaxFileSizeMb: number
uploadFileMaxFileSizeMbSource: PolicySource
allowedMimeTypes: MimeType[] allowedMimeTypes: MimeType[]
allowedMimeTypesSource: PolicySource allowedMimeTypesSource: PolicySource
allowedVideoExtensions: VideoExtension[] allowedVideoExtensions: VideoExtension[]
@@ -185,7 +189,9 @@ const capApiEndpointDraft = ref('')
const capSecretDraft = ref('') const capSecretDraft = ref('')
const domainSeparationSaveConfirmOpen = ref(false) const domainSeparationSaveConfirmOpen = ref(false)
const uploadMaxFileSizeMbDraft = ref(10) const uploadImageMaxFileSizeMbDraft = ref(10)
const uploadVideoMaxFileSizeMbDraft = ref(500)
const uploadFileMaxFileSizeMbDraft = ref(100)
const allowedMimeTypesDraft = ref<MimeType[]>([]) const allowedMimeTypesDraft = ref<MimeType[]>([])
const allowedVideoExtensionsDraft = ref<VideoExtension[]>([]) const allowedVideoExtensionsDraft = ref<VideoExtension[]>([])
const forbiddenFileExtensionsDraft = ref<string[]>([]) const forbiddenFileExtensionsDraft = ref<string[]>([])
@@ -242,7 +248,9 @@ const hasServerChanges = computed(() => {
|| turnstileSecretKeyDraft.value !== settings.value.turnstileSecretKey || turnstileSecretKeyDraft.value !== settings.value.turnstileSecretKey
|| capApiEndpointDraft.value !== settings.value.capApiEndpoint || capApiEndpointDraft.value !== settings.value.capApiEndpoint
|| capSecretDraft.value !== settings.value.capSecret || capSecretDraft.value !== settings.value.capSecret
|| uploadMaxFileSizeMbDraft.value !== settings.value.uploadMaxFileSizeMb || uploadImageMaxFileSizeMbDraft.value !== settings.value.uploadImageMaxFileSizeMb
|| uploadVideoMaxFileSizeMbDraft.value !== settings.value.uploadVideoMaxFileSizeMb
|| uploadFileMaxFileSizeMbDraft.value !== settings.value.uploadFileMaxFileSizeMb
|| !mimeListsEqual(allowedMimeTypesDraft.value, settings.value.allowedMimeTypes) || !mimeListsEqual(allowedMimeTypesDraft.value, settings.value.allowedMimeTypes)
|| !mimeListsEqual(allowedVideoExtensionsDraft.value, settings.value.allowedVideoExtensions) || !mimeListsEqual(allowedVideoExtensionsDraft.value, settings.value.allowedVideoExtensions)
|| !mimeListsEqual(forbiddenFileExtensionsDraft.value, settings.value.forbiddenFileExtensions) || !mimeListsEqual(forbiddenFileExtensionsDraft.value, settings.value.forbiddenFileExtensions)
@@ -320,7 +328,9 @@ 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
uploadMaxFileSizeMbDraft.value = data.uploadMaxFileSizeMb uploadImageMaxFileSizeMbDraft.value = data.uploadImageMaxFileSizeMb
uploadVideoMaxFileSizeMbDraft.value = data.uploadVideoMaxFileSizeMb
uploadFileMaxFileSizeMbDraft.value = data.uploadFileMaxFileSizeMb
allowedMimeTypesDraft.value = [...data.allowedMimeTypes] allowedMimeTypesDraft.value = [...data.allowedMimeTypes]
allowedVideoExtensionsDraft.value = [...data.allowedVideoExtensions] allowedVideoExtensionsDraft.value = [...data.allowedVideoExtensions]
forbiddenFileExtensionsDraft.value = [...data.forbiddenFileExtensions] forbiddenFileExtensionsDraft.value = [...data.forbiddenFileExtensions]
@@ -423,7 +433,9 @@ async function performSaveServerSettings() {
turnstileSecretKey: turnstileSecretKeyDraft.value, turnstileSecretKey: turnstileSecretKeyDraft.value,
capApiEndpoint: capApiEndpointDraft.value, capApiEndpoint: capApiEndpointDraft.value,
capSecret: capSecretDraft.value, capSecret: capSecretDraft.value,
uploadMaxFileSizeMb: uploadMaxFileSizeMbDraft.value, uploadImageMaxFileSizeMb: uploadImageMaxFileSizeMbDraft.value,
uploadVideoMaxFileSizeMb: uploadVideoMaxFileSizeMbDraft.value,
uploadFileMaxFileSizeMb: uploadFileMaxFileSizeMbDraft.value,
allowedMimeTypes: allowedMimeTypesDraft.value, allowedMimeTypes: allowedMimeTypesDraft.value,
allowedVideoExtensions: allowedVideoExtensionsDraft.value, allowedVideoExtensions: allowedVideoExtensionsDraft.value,
forbiddenFileExtensions: forbiddenFileExtensionsDraft.value, forbiddenFileExtensions: forbiddenFileExtensionsDraft.value,
@@ -626,7 +638,9 @@ watch(isAdmin, () => {
> >
<SettingsGroup> <SettingsGroup>
<SettingsUploadPolicyFields <SettingsUploadPolicyFields
v-model:upload-max-file-size-mb="uploadMaxFileSizeMbDraft" v-model:upload-image-max-file-size-mb="uploadImageMaxFileSizeMbDraft"
v-model:upload-video-max-file-size-mb="uploadVideoMaxFileSizeMbDraft"
v-model:upload-file-max-file-size-mb="uploadFileMaxFileSizeMbDraft"
v-model:allowed-mime-types="allowedMimeTypesDraft" v-model:allowed-mime-types="allowedMimeTypesDraft"
v-model:allowed-video-extensions="allowedVideoExtensionsDraft" v-model:allowed-video-extensions="allowedVideoExtensionsDraft"
v-model:forbidden-file-extensions="forbiddenFileExtensionsDraft" v-model:forbidden-file-extensions="forbiddenFileExtensionsDraft"
@@ -634,7 +648,9 @@ watch(isAdmin, () => {
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-image-max-file-size-mb-source="settings.uploadImageMaxFileSizeMbSource"
:upload-video-max-file-size-mb-source="settings.uploadVideoMaxFileSizeMbSource"
:upload-file-max-file-size-mb-source="settings.uploadFileMaxFileSizeMbSource"
:allowed-mime-types-source="settings.allowedMimeTypesSource" :allowed-mime-types-source="settings.allowedMimeTypesSource"
:allowed-video-extensions-source="settings.allowedVideoExtensionsSource" :allowed-video-extensions-source="settings.allowedVideoExtensionsSource"
:forbidden-file-extensions-source="settings.forbiddenFileExtensionsSource" :forbidden-file-extensions-source="settings.forbiddenFileExtensionsSource"
+17 -7
View File
@@ -158,7 +158,7 @@
"upload": { "upload": {
"title": "Drop images here to upload", "title": "Drop images here to upload",
"hint": "Or click to select files, press {ctrl} + {v} to paste", "hint": "Or click to select files, press {ctrl} + {v} to paste",
"formats": "JPEG / PNG / WebP / GIF / SVG / ICO, max 10 MB each, up to 10 files per batch", "formats": "JPEG / PNG / WebP / GIF / SVG / ICO, max {size} MB each, up to 10 files per batch",
"selectFiles": "Select images", "selectFiles": "Select images",
"settings": "Upload settings", "settings": "Upload settings",
"progress": "Upload progress", "progress": "Upload progress",
@@ -329,7 +329,10 @@
"autoCopy": "Copy link after upload", "autoCopy": "Copy link after upload",
"autoCopyHint": "Copy to clipboard in the selected format", "autoCopyHint": "Copy to clipboard in the selected format",
"uploadTags": "Upload tags", "uploadTags": "Upload tags",
"uploadTagsHint": "Tags selected here are applied to images, videos, and files; missing tags are created automatically." "uploadTagsHint": "Press Enter, type a comma, or leave the field to add a tag. Leave empty to upload without tags.",
"uploadTagsPlaceholder": "Enter tag",
"uploadTagsPending": "Pending tags ({n})",
"uploadTagsRemove": "Remove tag {name}"
}, },
"settings": { "settings": {
"title": "System settings", "title": "System settings",
@@ -475,6 +478,12 @@
"minutesUnit": "min", "minutesUnit": "min",
"uploadMaxFileSize": "Max file size", "uploadMaxFileSize": "Max file size",
"uploadMaxFileSizeHint": "Uploads larger than this are rejected (1–100 MB).", "uploadMaxFileSizeHint": "Uploads larger than this are rejected (1–100 MB).",
"uploadImageMaxFileSize": "Image size limit",
"uploadImageMaxFileSizeHint": "Images larger than this are rejected, up to 100 MB. 0 MB blocks uploads.",
"uploadVideoMaxFileSize": "Video size limit",
"uploadVideoMaxFileSizeHint": "Videos larger than this are rejected, up to 2048 MB. 0 MB blocks uploads.",
"uploadFileMaxFileSize": "File size limit",
"uploadFileMaxFileSizeHint": "Regular files larger than this are rejected, up to 1024 MB. 0 MB blocks uploads.",
"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", "allowedVideoExtensions": "Allowed video formats",
@@ -1169,6 +1178,7 @@
"maxFiles": "Up to {n} files per upload", "maxFiles": "Up to {n} files per upload",
"uploadSuccess": "Uploaded {n} file(s)", "uploadSuccess": "Uploaded {n} file(s)",
"uploadFailed": "Upload failed, please try again", "uploadFailed": "Upload failed, please try again",
"fileTooLarge": "{name}: exceeds the {limit} MB per-file limit",
"loadFailed": "Failed to load files", "loadFailed": "Failed to load files",
"copyLink": "Copy link", "copyLink": "Copy link",
"download": "Download", "download": "Download",
@@ -1218,9 +1228,9 @@
"subtitle": "Manage uploaded files, copy links or download", "subtitle": "Manage uploaded files, copy links or download",
"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 {size} MB per file",
"uploadFormatsBlocked": "{formats} blocked; other file types allowed, up to 100MB per file", "uploadFormatsBlocked": "{formats} blocked; other file types allowed, up to {size} MB per file",
"uploadFormatsUnrestricted": "Any file type allowed, up to 100MB per file", "uploadFormatsUnrestricted": "Any file type allowed, up to {size} MB per file",
"empty": "No files yet — upload one to get started" "empty": "No files yet — upload one to get started"
}, },
"video": { "video": {
@@ -1243,8 +1253,8 @@
"downloadMp4Failed": "Video merge failed. Please try again later.", "downloadMp4Failed": "Video merge failed. Please try again later.",
"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 {size} MB per file",
"uploadFormatsAllowed": "{formats} supported, up to 100MB per file", "uploadFormatsAllowed": "{formats} supported, up to {size} MB per file",
"empty": "No videos yet — upload one to get started" "empty": "No videos yet — upload one to get started"
} }
}, },
+17 -7
View File
@@ -158,7 +158,7 @@
"upload": { "upload": {
"title": "拖拽图片到此处上传", "title": "拖拽图片到此处上传",
"hint": "或点击选择文件,按 {ctrl} + {v} 粘贴图片", "hint": "或点击选择文件,按 {ctrl} + {v} 粘贴图片",
"formats": "支持 JPEG / PNG / WebP / GIF / SVG / ICO,单张最大 10 MB,每次最多 10 张", "formats": "支持 JPEG / PNG / WebP / GIF / SVG / ICO,单张最大 {size} MB,每次最多 10 张",
"selectFiles": "选择图片", "selectFiles": "选择图片",
"settings": "上传设置", "settings": "上传设置",
"progress": "上传进度", "progress": "上传进度",
@@ -329,7 +329,10 @@
"autoCopy": "上传成功自动复制链接", "autoCopy": "上传成功自动复制链接",
"autoCopyHint": "上传成功后,自动复制链接到剪贴板。", "autoCopyHint": "上传成功后,自动复制链接到剪贴板。",
"uploadTags": "上传标签", "uploadTags": "上传标签",
"uploadTagsHint": "图片、视频和文件共用当前上传页选择的标签;不存在的标签会自动创建。" "uploadTagsHint": "输入后按 Enter、逗号或移开焦点加入待添加列表;留空则不添加标签。",
"uploadTagsPlaceholder": "输入标签",
"uploadTagsPending": "待添加标签({n})",
"uploadTagsRemove": "删除标签 {name}"
}, },
"settings": { "settings": {
"title": "系统设置", "title": "系统设置",
@@ -475,6 +478,12 @@
"minutesUnit": "分钟", "minutesUnit": "分钟",
"uploadMaxFileSize": "单文件大小上限", "uploadMaxFileSize": "单文件大小上限",
"uploadMaxFileSizeHint": "超过此大小的图片将被拒绝上传(1–100 MB)。", "uploadMaxFileSizeHint": "超过此大小的图片将被拒绝上传(1–100 MB)。",
"uploadImageMaxFileSize": "图片大小上限",
"uploadImageMaxFileSizeHint": "超过此大小的图片将被拒绝上传,最大 100 MB。0 MB 表示禁止上传。",
"uploadVideoMaxFileSize": "视频大小上限",
"uploadVideoMaxFileSizeHint": "超过此大小的视频将被拒绝上传,最大 2048 MB。0 MB 表示禁止上传。",
"uploadFileMaxFileSize": "文件大小上限",
"uploadFileMaxFileSizeHint": "超过此大小的普通文件将被拒绝上传,最大 1024 MB。0 MB 表示禁止上传。",
"allowedMimeTypes": "允许的图片格式", "allowedMimeTypes": "允许的图片格式",
"allowedMimeTypesHint": "至少保留一种格式;服务端会校验文件签名,不仅看扩展名。", "allowedMimeTypesHint": "至少保留一种格式;服务端会校验文件签名,不仅看扩展名。",
"allowedVideoExtensions": "允许的视频格式", "allowedVideoExtensions": "允许的视频格式",
@@ -1172,6 +1181,7 @@
"maxFiles": "每次最多上传 {n} 个文件", "maxFiles": "每次最多上传 {n} 个文件",
"uploadSuccess": "已上传 {n} 个文件", "uploadSuccess": "已上传 {n} 个文件",
"uploadFailed": "上传失败,请稍后重试", "uploadFailed": "上传失败,请稍后重试",
"fileTooLarge": "{name}:超过单文件上限 {limit} MB",
"loadFailed": "读取文件列表失败", "loadFailed": "读取文件列表失败",
"copyLink": "复制直链", "copyLink": "复制直链",
"download": "下载", "download": "下载",
@@ -1221,9 +1231,9 @@
"subtitle": "管理已上传的文件,复制直链或下载", "subtitle": "管理已上传的文件,复制直链或下载",
"uploadTitle": "拖拽文件到此处上传", "uploadTitle": "拖拽文件到此处上传",
"uploadHint": "或点击选择文件,单次最多 10 个", "uploadHint": "或点击选择文件,单次最多 10 个",
"uploadFormats": "支持任意类型(禁止 HTML / HTM / SVG),单个文件最大 100MB", "uploadFormats": "支持任意类型(禁止 HTML / HTM / SVG),单个文件最大 {size} MB",
"uploadFormatsBlocked": "禁止 {formats};其他文件类型可上传,单个文件最大 100MB", "uploadFormatsBlocked": "禁止 {formats};其他文件类型可上传,单个文件最大 {size} MB",
"uploadFormatsUnrestricted": "支持任意类型,单个文件最大 100MB", "uploadFormatsUnrestricted": "支持任意类型,单个文件最大 {size} MB",
"empty": "还没有文件,上传一个试试" "empty": "还没有文件,上传一个试试"
}, },
"video": { "video": {
@@ -1246,8 +1256,8 @@
"downloadMp4Failed": "视频合并失败,请稍后重试", "downloadMp4Failed": "视频合并失败,请稍后重试",
"uploadTitle": "拖拽视频到此处上传", "uploadTitle": "拖拽视频到此处上传",
"uploadHint": "或点击选择视频文件,单次最多 10 个", "uploadHint": "或点击选择视频文件,单次最多 10 个",
"uploadFormats": "支持 MP4 / WebM / MOV / MKV 等,单个文件最大 100MB", "uploadFormats": "支持 MP4 / WebM / MOV / MKV 等,单个文件最大 {size} MB",
"uploadFormatsAllowed": "支持 {formats},单个文件最大 100MB", "uploadFormatsAllowed": "支持 {formats},单个文件最大 {size} MB",
"empty": "还没有视频,上传一个试试" "empty": "还没有视频,上传一个试试"
} }
}, },
+38 -1
View File
@@ -51,6 +51,7 @@ import {
parseLoginRateMax, parseLoginRateMax,
parseLoginRateWindowMinutes, parseLoginRateWindowMinutes,
parseUploadMaxFileSizeMb, parseUploadMaxFileSizeMb,
parseUploadMaxFileSizeMbForKind,
parseUploadRateIpMax, parseUploadRateIpMax,
parseUploadRateTokenMax, parseUploadRateTokenMax,
parseUploadRateWindowMinutes, parseUploadRateWindowMinutes,
@@ -78,6 +79,9 @@ interface SettingsPatchBody {
capApiEndpoint?: unknown capApiEndpoint?: unknown
capSecret?: unknown capSecret?: unknown
uploadMaxFileSizeMb?: unknown uploadMaxFileSizeMb?: unknown
uploadImageMaxFileSizeMb?: unknown
uploadVideoMaxFileSizeMb?: unknown
uploadFileMaxFileSizeMb?: unknown
allowedMimeTypes?: unknown allowedMimeTypes?: unknown
allowedVideoExtensions?: unknown allowedVideoExtensions?: unknown
forbiddenFileExtensions?: unknown forbiddenFileExtensions?: unknown
@@ -360,11 +364,44 @@ export default defineEventHandler(async (event) => {
if (body.uploadMaxFileSizeMb !== undefined) { if (body.uploadMaxFileSizeMb !== undefined) {
const mb = parseUploadMaxFileSizeMb(String(body.uploadMaxFileSizeMb)) const mb = parseUploadMaxFileSizeMb(String(body.uploadMaxFileSizeMb))
if (mb === null) { if (mb === null) {
createApiError(event, 'INVALID_REQUEST', '单文件大小需为 1–100 MB 的整数', 400) createApiError(event, 'INVALID_REQUEST', '图片大小需为 0–100 MB 的整数', 400)
} }
setUploadPolicySettings({ uploadMaxFileSizeMb: mb }) setUploadPolicySettings({ uploadMaxFileSizeMb: mb })
} }
if (body.uploadImageMaxFileSizeMb !== undefined) {
const mb = parseUploadMaxFileSizeMbForKind(
String(body.uploadImageMaxFileSizeMb),
'image'
)
if (mb === null) {
createApiError(event, 'INVALID_REQUEST', '图片大小需为 0–100 MB 的整数', 400)
}
setUploadPolicySettings({ uploadImageMaxFileSizeMb: mb })
}
if (body.uploadVideoMaxFileSizeMb !== undefined) {
const mb = parseUploadMaxFileSizeMbForKind(
String(body.uploadVideoMaxFileSizeMb),
'video'
)
if (mb === null) {
createApiError(event, 'INVALID_REQUEST', '视频大小需为 0–2048 MB 的整数', 400)
}
setUploadPolicySettings({ uploadVideoMaxFileSizeMb: mb })
}
if (body.uploadFileMaxFileSizeMb !== undefined) {
const mb = parseUploadMaxFileSizeMbForKind(
String(body.uploadFileMaxFileSizeMb),
'file'
)
if (mb === null) {
createApiError(event, 'INVALID_REQUEST', '文件大小需为 0–1024 MB 的整数', 400)
}
setUploadPolicySettings({ uploadFileMaxFileSizeMb: mb })
}
if (body.allowedMimeTypes !== undefined) { if (body.allowedMimeTypes !== undefined) {
const raw = Array.isArray(body.allowedMimeTypes) const raw = Array.isArray(body.allowedMimeTypes)
? body.allowedMimeTypes.map(item => String(item)) ? body.allowedMimeTypes.map(item => String(item))
+5 -1
View File
@@ -1,6 +1,7 @@
import { import {
getAllowedMimeTypesList, getAllowedMimeTypesList,
getAllowedVideoExtensions, getAllowedVideoExtensions,
getEffectiveUploadMaxFileSizeMb,
getForbiddenFileExtensions getForbiddenFileExtensions
} from '../utils/upload-policy' } from '../utils/upload-policy'
@@ -9,6 +10,9 @@ export default defineEventHandler((event) => {
return { return {
allowedMimeTypes: getAllowedMimeTypesList(), allowedMimeTypes: getAllowedMimeTypesList(),
allowedVideoExtensions: getAllowedVideoExtensions(), allowedVideoExtensions: getAllowedVideoExtensions(),
forbiddenFileExtensions: getForbiddenFileExtensions() forbiddenFileExtensions: getForbiddenFileExtensions(),
imageMaxFileSizeMb: getEffectiveUploadMaxFileSizeMb('image'),
videoMaxFileSizeMb: getEffectiveUploadMaxFileSizeMb('video'),
fileMaxFileSizeMb: getEffectiveUploadMaxFileSizeMb('file')
} }
}) })
+6 -8
View File
@@ -1,10 +1,6 @@
import type { H3Event } from 'h3' 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 {
MAX_FILE_BED_FILE_SIZE,
MAX_VIDEO_FILE_SIZE
} from './constants'
import { contentTypeFromKey } from './content-type' import { contentTypeFromKey } from './content-type'
import { import {
extensionFromFilename, extensionFromFilename,
@@ -21,7 +17,9 @@ import { putImage } from './storage'
import { generateVideoThumbnail } from './transcode' import { generateVideoThumbnail } from './transcode'
import { updateImageThumb } from './image-index' import { updateImageThumb } from './image-index'
import { import {
formatEffectiveMaxFileSizeMessage,
getAllowedVideoExtensions, getAllowedVideoExtensions,
getEffectiveMaxFileSizeBytes,
isForbiddenFileExtension, isForbiddenFileExtension,
isVideoExtensionAllowed isVideoExtensionAllowed
} from './upload-policy' } from './upload-policy'
@@ -126,13 +124,13 @@ export async function processSingleFileUpload(
} }
} }
if (bytes.byteLength > MAX_FILE_BED_FILE_SIZE) { if (bytes.byteLength > getEffectiveMaxFileSizeBytes('file')) {
return { return {
error: { error: {
name: originalName, name: originalName,
error: { error: {
code: 'FILE_TOO_LARGE', code: 'FILE_TOO_LARGE',
message: `单个文件不能超过 ${Math.round(MAX_FILE_BED_FILE_SIZE / 1024 / 1024)}MB` message: formatEffectiveMaxFileSizeMessage('file')
} }
} }
} }
@@ -271,13 +269,13 @@ export async function processSingleVideoUpload(
} }
} }
if (bytes.byteLength > MAX_VIDEO_FILE_SIZE) { if (bytes.byteLength > getEffectiveMaxFileSizeBytes('video')) {
return { return {
error: { error: {
name: originalName, name: originalName,
error: { error: {
code: 'FILE_TOO_LARGE', code: 'FILE_TOO_LARGE',
message: `单个视频不能超过 ${Math.round(MAX_VIDEO_FILE_SIZE / 1024 / 1024)}MB` message: formatEffectiveMaxFileSizeMessage('video')
} }
} }
} }
+23 -1
View File
@@ -1,4 +1,8 @@
import { findUserByUsername } from '../db' import {
findUserByUsername,
getUploadSourcesForKeys,
insertActivityLog
} from '../db'
import { import {
getImageIndexRow, getImageIndexRow,
insertImageIndex, insertImageIndex,
@@ -52,6 +56,24 @@ export async function applySiteSyncMedia(
mediaType: media.mediaType mediaType: media.mediaType
}) })
if (
media.uploadSource
&& getUploadSourcesForKeys([media.key]).get(media.key) !== media.uploadSource
) {
insertActivityLog({
action: 'upload',
key: media.key,
originalName: media.originalName,
size: media.size,
contentType: media.contentType,
source: media.uploadSource,
userId: owner?.id ?? null,
backendId: backend.id,
status: 'success',
createdAt: media.uploadedAt
})
}
if (media.thumbKey) updateImageThumb(media.key, media.thumbKey) if (media.thumbKey) updateImageThumb(media.key, media.thumbKey)
if (media.mediaType === 'video') { if (media.mediaType === 'video') {
updateImageTranscode(media.key, { updateImageTranscode(media.key, {
+4
View File
@@ -1,4 +1,5 @@
import type { ImageIndexRow } from '../image-index' import type { ImageIndexRow } from '../image-index'
import { getUploadSourcesForKeys } from '../db'
import { getTagsForImageKeys } from '../tags' import { getTagsForImageKeys } from '../tags'
import type { import type {
SiteSyncMediaTransfer, SiteSyncMediaTransfer,
@@ -10,6 +11,8 @@ export function toSiteSyncMediaTransfer(
ownerUsername: string | null, ownerUsername: string | null,
tags: SiteSyncTag[] tags: SiteSyncTag[]
): SiteSyncMediaTransfer { ): SiteSyncMediaTransfer {
const source = getUploadSourcesForKeys([row.key]).get(row.key)
const uploadSource = source === 'web' || source === 'api' ? source : undefined
return { return {
key: row.key, key: row.key,
ownerUsername, ownerUsername,
@@ -18,6 +21,7 @@ export function toSiteSyncMediaTransfer(
size: row.size, size: row.size,
uploadedAt: row.uploaded_at, uploadedAt: row.uploaded_at,
mediaType: row.media_type, mediaType: row.media_type,
...(uploadSource ? { uploadSource } : {}),
thumbKey: row.thumb_key, thumbKey: row.thumb_key,
hlsKey: row.hls_key, hlsKey: row.hls_key,
transcodeStatus: row.transcode_status as SiteSyncMediaTransfer['transcodeStatus'], transcodeStatus: row.transcode_status as SiteSyncMediaTransfer['transcodeStatus'],
+1
View File
@@ -13,6 +13,7 @@ export interface SiteSyncMediaTransfer {
size: number size: number
uploadedAt: string uploadedAt: string
mediaType: MediaType mediaType: MediaType
uploadSource?: 'web' | 'api'
thumbKey: string | null thumbKey: string | null
hlsKey: string | null hlsKey: string | null
transcodeStatus: TranscodeStatus transcodeStatus: TranscodeStatus
+11 -1
View File
@@ -8,6 +8,7 @@ import {
parseLoginRateMax, parseLoginRateMax,
parsePreserveOriginalUpload, parsePreserveOriginalUpload,
parseUploadMaxFileSizeMb, parseUploadMaxFileSizeMb,
parseUploadMaxFileSizeMbForKind,
parseUploadRateIpMax, parseUploadRateIpMax,
serializeAllowedMimeTypes, serializeAllowedMimeTypes,
serializeAllowedVideoExtensions, serializeAllowedVideoExtensions,
@@ -18,10 +19,19 @@ import {
describe('upload-policy parsers', () => { describe('upload-policy parsers', () => {
it('parses upload max file size mb', () => { it('parses upload max file size mb', () => {
expect(parseUploadMaxFileSizeMb('10')).toBe(10) expect(parseUploadMaxFileSizeMb('10')).toBe(10)
expect(parseUploadMaxFileSizeMb('0')).toBeNull() expect(parseUploadMaxFileSizeMb('0')).toBe(0)
expect(parseUploadMaxFileSizeMb('101')).toBeNull() expect(parseUploadMaxFileSizeMb('101')).toBeNull()
}) })
it('parses separate image, video and file size limits', () => {
expect(parseUploadMaxFileSizeMbForKind('0', 'image')).toBe(0)
expect(parseUploadMaxFileSizeMbForKind('100', 'image')).toBe(100)
expect(parseUploadMaxFileSizeMbForKind('2048', 'video')).toBe(2048)
expect(parseUploadMaxFileSizeMbForKind('2049', 'video')).toBeNull()
expect(parseUploadMaxFileSizeMbForKind('1024', 'file')).toBe(1024)
expect(parseUploadMaxFileSizeMbForKind('1025', 'file')).toBeNull()
})
it('normalizes allowed mime types', () => { it('normalizes allowed mime types', () => {
expect(normalizeAllowedMimeTypes('image/png, image/jpeg, image/png')).toEqual([ expect(normalizeAllowedMimeTypes('image/png, image/jpeg, image/png')).toEqual([
'image/png', 'image/png',
+146 -28
View File
@@ -10,6 +10,9 @@ import { getActiveBackendRow } from './storage/resolver'
import { TELEGRAM_MAX_FILE_BYTES } from './storage/telegram' 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_UPLOAD_IMAGE_MAX_FILE_SIZE_MB = 'upload_image_max_file_size_mb'
export const SETTINGS_UPLOAD_VIDEO_MAX_FILE_SIZE_MB = 'upload_video_max_file_size_mb'
export const SETTINGS_UPLOAD_FILE_MAX_FILE_SIZE_MB = 'upload_file_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_ALLOWED_VIDEO_EXTENSIONS = 'allowed_video_extensions'
export const SETTINGS_FORBIDDEN_FILE_EXTENSIONS = 'forbidden_file_extensions' export const SETTINGS_FORBIDDEN_FILE_EXTENSIONS = 'forbidden_file_extensions'
@@ -21,8 +24,14 @@ export const SETTINGS_LOGIN_RATE_WINDOW_MINUTES = 'login_rate_window_minutes'
export const SETTINGS_PRESERVE_ORIGINAL_UPLOAD = 'preserve_original_upload' export const SETTINGS_PRESERVE_ORIGINAL_UPLOAD = 'preserve_original_upload'
export const DEFAULT_UPLOAD_MAX_FILE_SIZE_MB = 10 export const DEFAULT_UPLOAD_MAX_FILE_SIZE_MB = 10
export const MIN_UPLOAD_MAX_FILE_SIZE_MB = 1 export const MIN_UPLOAD_MAX_FILE_SIZE_MB = 0
export const MAX_UPLOAD_MAX_FILE_SIZE_MB = 100 export const MAX_UPLOAD_MAX_FILE_SIZE_MB = 100
export const DEFAULT_UPLOAD_IMAGE_MAX_FILE_SIZE_MB = 10
export const DEFAULT_UPLOAD_VIDEO_MAX_FILE_SIZE_MB = 500
export const DEFAULT_UPLOAD_FILE_MAX_FILE_SIZE_MB = 100
export const MAX_UPLOAD_IMAGE_MAX_FILE_SIZE_MB = 100
export const MAX_UPLOAD_VIDEO_MAX_FILE_SIZE_MB = 2048
export const MAX_UPLOAD_FILE_MAX_FILE_SIZE_MB = 1024
export const DEFAULT_UPLOAD_RATE_IP_MAX = 60 export const DEFAULT_UPLOAD_RATE_IP_MAX = 60
export const DEFAULT_UPLOAD_RATE_TOKEN_MAX = 120 export const DEFAULT_UPLOAD_RATE_TOKEN_MAX = 120
@@ -78,6 +87,41 @@ export const FORBIDDEN_FILE_EXTENSION_OPTIONS = [
})) }))
export type PolicySource = 'env' | 'db' | 'default' export type PolicySource = 'env' | 'db' | 'default'
export type UploadMaxFileSizeKind = 'image' | 'video' | 'file'
const uploadMaxFileSizeConfig: Record<UploadMaxFileSizeKind, {
setting: string
env: string
fallbackEnv?: string
legacySetting?: string
defaultMb: number
maxMb: number
label: string
}> = {
image: {
setting: SETTINGS_UPLOAD_IMAGE_MAX_FILE_SIZE_MB,
env: 'UPLOAD_IMAGE_MAX_FILE_SIZE_MB',
fallbackEnv: 'UPLOAD_MAX_FILE_SIZE_MB',
legacySetting: SETTINGS_UPLOAD_MAX_FILE_SIZE_MB,
defaultMb: DEFAULT_UPLOAD_IMAGE_MAX_FILE_SIZE_MB,
maxMb: MAX_UPLOAD_IMAGE_MAX_FILE_SIZE_MB,
label: '图片'
},
video: {
setting: SETTINGS_UPLOAD_VIDEO_MAX_FILE_SIZE_MB,
env: 'UPLOAD_VIDEO_MAX_FILE_SIZE_MB',
defaultMb: DEFAULT_UPLOAD_VIDEO_MAX_FILE_SIZE_MB,
maxMb: MAX_UPLOAD_VIDEO_MAX_FILE_SIZE_MB,
label: '视频'
},
file: {
setting: SETTINGS_UPLOAD_FILE_MAX_FILE_SIZE_MB,
env: 'UPLOAD_FILE_MAX_FILE_SIZE_MB',
defaultMb: DEFAULT_UPLOAD_FILE_MAX_FILE_SIZE_MB,
maxMb: MAX_UPLOAD_FILE_MAX_FILE_SIZE_MB,
label: '文件'
}
}
function readEnv(name: string): string { function readEnv(name: string): string {
try { try {
@@ -100,6 +144,17 @@ export function parseUploadMaxFileSizeMb(raw: string): number | null {
return parsePositiveInt(raw, MIN_UPLOAD_MAX_FILE_SIZE_MB, MAX_UPLOAD_MAX_FILE_SIZE_MB) return parsePositiveInt(raw, MIN_UPLOAD_MAX_FILE_SIZE_MB, MAX_UPLOAD_MAX_FILE_SIZE_MB)
} }
export function parseUploadMaxFileSizeMbForKind(
raw: string,
kind: UploadMaxFileSizeKind
): number | null {
return parsePositiveInt(
raw,
MIN_UPLOAD_MAX_FILE_SIZE_MB,
uploadMaxFileSizeConfig[kind].maxMb
)
}
export function parseUploadRateIpMax(raw: string): number | null { export function parseUploadRateIpMax(raw: string): number | null {
return parsePositiveInt(raw, MIN_UPLOAD_RATE_MAX, MAX_UPLOAD_RATE_IP_MAX) return parsePositiveInt(raw, MIN_UPLOAD_RATE_MAX, MAX_UPLOAD_RATE_IP_MAX)
} }
@@ -208,9 +263,36 @@ export function serializeForbiddenFileExtensions(extensions: readonly string[]):
.join(',') .join(',')
} }
function getUploadMaxFileSizeMbSource(): PolicySource { function getConfiguredUploadMaxFileSizeMbFromDb(kind: UploadMaxFileSizeKind): number | null {
if (getSetting(SETTINGS_UPLOAD_MAX_FILE_SIZE_MB) !== null) return 'db' const config = uploadMaxFileSizeConfig[kind]
if (parseUploadMaxFileSizeMb(readEnv('UPLOAD_MAX_FILE_SIZE_MB')) !== null) return 'env' const direct = getSetting(config.setting)
if (direct !== null) {
const parsed = parseUploadMaxFileSizeMbForKind(direct, kind)
if (parsed !== null) return parsed
}
if (config.legacySetting) {
const legacy = getSetting(config.legacySetting)
if (legacy !== null) {
const parsed = parseUploadMaxFileSizeMbForKind(legacy, kind)
if (parsed !== null) return parsed
}
}
return null
}
function getConfiguredUploadMaxFileSizeMbFromEnv(kind: UploadMaxFileSizeKind): number | null {
const config = uploadMaxFileSizeConfig[kind]
const direct = parseUploadMaxFileSizeMbForKind(readEnv(config.env), kind)
if (direct !== null) return direct
if (config.fallbackEnv) {
return parseUploadMaxFileSizeMbForKind(readEnv(config.fallbackEnv), kind)
}
return null
}
function getUploadMaxFileSizeMbSource(kind: UploadMaxFileSizeKind): PolicySource {
if (getConfiguredUploadMaxFileSizeMbFromDb(kind) !== null) return 'db'
if (getConfiguredUploadMaxFileSizeMbFromEnv(kind) !== null) return 'env'
return 'default' return 'default'
} }
@@ -270,23 +352,18 @@ function getPreserveOriginalUploadSource(): PolicySource {
return 'default' return 'default'
} }
export function getUploadMaxFileSizeMb(): number { export function getUploadMaxFileSizeMb(kind: UploadMaxFileSizeKind = 'image'): number {
const dbRaw = getSetting(SETTINGS_UPLOAD_MAX_FILE_SIZE_MB) return getConfiguredUploadMaxFileSizeMbFromDb(kind)
if (dbRaw !== null) { ?? getConfiguredUploadMaxFileSizeMbFromEnv(kind)
const parsed = parseUploadMaxFileSizeMb(dbRaw) ?? uploadMaxFileSizeConfig[kind].defaultMb
if (parsed !== null) return parsed
}
const fromEnv = parseUploadMaxFileSizeMb(readEnv('UPLOAD_MAX_FILE_SIZE_MB'))
if (fromEnv !== null) return fromEnv
return DEFAULT_UPLOAD_MAX_FILE_SIZE_MB
} }
export function getMaxFileSizeBytes(): number { export function getMaxFileSizeBytes(kind: UploadMaxFileSizeKind = 'image'): number {
return getUploadMaxFileSizeMb() * 1024 * 1024 return getUploadMaxFileSizeMb(kind) * 1024 * 1024
} }
export function getEffectiveMaxFileSizeBytes(): number { export function getEffectiveMaxFileSizeBytes(kind: UploadMaxFileSizeKind = 'image'): number {
const configured = getMaxFileSizeBytes() const configured = getMaxFileSizeBytes(kind)
const row = getActiveBackendRow() const row = getActiveBackendRow()
if (row?.type === 'telegram') { if (row?.type === 'telegram') {
return Math.min(configured, TELEGRAM_MAX_FILE_BYTES) return Math.min(configured, TELEGRAM_MAX_FILE_BYTES)
@@ -294,6 +371,10 @@ export function getEffectiveMaxFileSizeBytes(): number {
return configured return configured
} }
export function getEffectiveUploadMaxFileSizeMb(kind: UploadMaxFileSizeKind): number {
return Math.floor(getEffectiveMaxFileSizeBytes(kind) / (1024 * 1024))
}
export function getAllowedMimeTypesList(): AllowedMimeType[] { export function getAllowedMimeTypesList(): AllowedMimeType[] {
const dbRaw = getSetting(SETTINGS_ALLOWED_MIME_TYPES) const dbRaw = getSetting(SETTINGS_ALLOWED_MIME_TYPES)
if (dbRaw !== null) { if (dbRaw !== null) {
@@ -418,27 +499,48 @@ export function getLoginRateLimitSettings(): {
} }
} }
export function formatMaxFileSizeMessage(): string { export function formatMaxFileSizeMessage(kind: UploadMaxFileSizeKind = 'image'): string {
return `图片大小不能超过 ${getUploadMaxFileSizeMb()} MB` return `${uploadMaxFileSizeConfig[kind].label}大小不能超过 ${getUploadMaxFileSizeMb(kind)} MB`
} }
export function formatEffectiveMaxFileSizeMessage(): string { export function formatEffectiveMaxFileSizeMessage(
kind: UploadMaxFileSizeKind = 'image'
): string {
const row = getActiveBackendRow() const row = getActiveBackendRow()
if (row?.type === 'telegram') { if (row?.type === 'telegram') {
const mb = TELEGRAM_MAX_FILE_BYTES / (1024 * 1024) const mb = TELEGRAM_MAX_FILE_BYTES / (1024 * 1024)
const siteMb = getUploadMaxFileSizeMb() const siteMb = getUploadMaxFileSizeMb(kind)
if (siteMb * 1024 * 1024 <= TELEGRAM_MAX_FILE_BYTES) { if (siteMb * 1024 * 1024 <= TELEGRAM_MAX_FILE_BYTES) {
return formatMaxFileSizeMessage() return formatMaxFileSizeMessage(kind)
} }
return `图片大小不能超过 ${mb} MB(Telegram 存储上限)` return `${uploadMaxFileSizeConfig[kind].label}大小不能超过 ${mb} MB(Telegram 存储上限)`
} }
return formatMaxFileSizeMessage() return formatMaxFileSizeMessage(kind)
} }
export function getUploadPolicySettingsPayload() { export function getUploadPolicySettingsPayload() {
return { return {
uploadMaxFileSizeMb: getUploadMaxFileSizeMb(), uploadImageMaxFileSizeMb: getUploadMaxFileSizeMb('image'),
uploadMaxFileSizeMbSource: getUploadMaxFileSizeMbSource(), uploadImageMaxFileSizeMbSource: getUploadMaxFileSizeMbSource('image'),
uploadImageMaxFileSizeMbEnvFallback: parseUploadMaxFileSizeMbForKind(
readEnv('UPLOAD_IMAGE_MAX_FILE_SIZE_MB'),
'image'
) ?? parseUploadMaxFileSizeMb(readEnv('UPLOAD_MAX_FILE_SIZE_MB'))
?? DEFAULT_UPLOAD_IMAGE_MAX_FILE_SIZE_MB,
uploadVideoMaxFileSizeMb: getUploadMaxFileSizeMb('video'),
uploadVideoMaxFileSizeMbSource: getUploadMaxFileSizeMbSource('video'),
uploadVideoMaxFileSizeMbEnvFallback: parseUploadMaxFileSizeMbForKind(
readEnv('UPLOAD_VIDEO_MAX_FILE_SIZE_MB'),
'video'
) ?? DEFAULT_UPLOAD_VIDEO_MAX_FILE_SIZE_MB,
uploadFileMaxFileSizeMb: getUploadMaxFileSizeMb('file'),
uploadFileMaxFileSizeMbSource: getUploadMaxFileSizeMbSource('file'),
uploadFileMaxFileSizeMbEnvFallback: parseUploadMaxFileSizeMbForKind(
readEnv('UPLOAD_FILE_MAX_FILE_SIZE_MB'),
'file'
) ?? DEFAULT_UPLOAD_FILE_MAX_FILE_SIZE_MB,
uploadMaxFileSizeMb: getUploadMaxFileSizeMb('image'),
uploadMaxFileSizeMbSource: getUploadMaxFileSizeMbSource('image'),
uploadMaxFileSizeMbEnvFallback: parseUploadMaxFileSizeMb(readEnv('UPLOAD_MAX_FILE_SIZE_MB')) uploadMaxFileSizeMbEnvFallback: parseUploadMaxFileSizeMb(readEnv('UPLOAD_MAX_FILE_SIZE_MB'))
?? DEFAULT_UPLOAD_MAX_FILE_SIZE_MB, ?? DEFAULT_UPLOAD_MAX_FILE_SIZE_MB,
allowedMimeTypes: getAllowedMimeTypesList(), allowedMimeTypes: getAllowedMimeTypesList(),
@@ -477,6 +579,9 @@ export function getUploadPolicySettingsPayload() {
export function setUploadPolicySettings(input: { export function setUploadPolicySettings(input: {
uploadMaxFileSizeMb?: number uploadMaxFileSizeMb?: number
uploadImageMaxFileSizeMb?: number
uploadVideoMaxFileSizeMb?: number
uploadFileMaxFileSizeMb?: number
allowedMimeTypes?: readonly string[] allowedMimeTypes?: readonly string[]
allowedVideoExtensions?: readonly string[] allowedVideoExtensions?: readonly string[]
forbiddenFileExtensions?: readonly string[] forbiddenFileExtensions?: readonly string[]
@@ -487,8 +592,21 @@ export function setUploadPolicySettings(input: {
loginRateWindowMinutes?: number loginRateWindowMinutes?: number
preserveOriginalUpload?: boolean preserveOriginalUpload?: boolean
}): void { }): void {
if (input.uploadMaxFileSizeMb !== undefined) { const imageMaxFileSizeMb = input.uploadImageMaxFileSizeMb ?? input.uploadMaxFileSizeMb
setSetting(SETTINGS_UPLOAD_MAX_FILE_SIZE_MB, String(input.uploadMaxFileSizeMb)) if (imageMaxFileSizeMb !== undefined) {
setSetting(SETTINGS_UPLOAD_IMAGE_MAX_FILE_SIZE_MB, String(imageMaxFileSizeMb))
}
if (input.uploadVideoMaxFileSizeMb !== undefined) {
setSetting(
SETTINGS_UPLOAD_VIDEO_MAX_FILE_SIZE_MB,
String(input.uploadVideoMaxFileSizeMb)
)
}
if (input.uploadFileMaxFileSizeMb !== undefined) {
setSetting(
SETTINGS_UPLOAD_FILE_MAX_FILE_SIZE_MB,
String(input.uploadFileMaxFileSizeMb)
)
} }
if (input.allowedMimeTypes !== undefined) { if (input.allowedMimeTypes !== undefined) {
setSetting(SETTINGS_ALLOWED_MIME_TYPES, serializeAllowedMimeTypes(input.allowedMimeTypes)) setSetting(SETTINGS_ALLOWED_MIME_TYPES, serializeAllowedMimeTypes(input.allowedMimeTypes))