同步设置
This commit is contained in:
1 parent
832261739f
commit
92aaf6fbb8
28 files changed
+705
-227
No files matched your search
+3
-1
@@ -31,7 +31,9 @@
|
||||
# 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
|
||||
# UPLOAD_RATE_IP_MAX=60
|
||||
# UPLOAD_RATE_TOKEN_MAX=120
|
||||
|
||||
+24
-1
@@ -7,22 +7,44 @@
|
||||
|
||||
.upload-card-flip-inner {
|
||||
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;
|
||||
}
|
||||
|
||||
.upload-card-flip.is-flipped .upload-card-flip-inner {
|
||||
min-height: min(40rem, calc(100vh - 11rem));
|
||||
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 {
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.upload-card-face--front {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.upload-card-face--back {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
@@ -33,6 +55,7 @@
|
||||
|
||||
.upload-card-flip.is-flipped .upload-card-flip-inner {
|
||||
transform: none;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.upload-card-flip .upload-card-face--back {
|
||||
|
||||
@@ -74,16 +74,7 @@ function fileIcon(item: FileItem): string {
|
||||
<p class="truncate text-xs text-muted">
|
||||
{{ uploadedLabel }} · {{ formatSize(item.size) }}
|
||||
</p>
|
||||
<div
|
||||
v-if="item.tags?.length"
|
||||
class="flex flex-wrap gap-1"
|
||||
>
|
||||
<TagBadge
|
||||
v-for="tag in item.tags"
|
||||
:key="tag.id"
|
||||
:tag="tag"
|
||||
/>
|
||||
</div>
|
||||
<TagBadgeList :tags="item.tags" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-around border-t border-default px-2 py-2">
|
||||
|
||||
@@ -633,20 +633,11 @@ defineExpose({ refresh })
|
||||
{{ uploadedLabel(item) }}
|
||||
</td>
|
||||
<td class="hidden px-3 py-2 md:table-cell">
|
||||
<div
|
||||
v-if="item.tags?.length"
|
||||
class="flex flex-wrap gap-1"
|
||||
>
|
||||
<TagBadge
|
||||
v-for="tag in item.tags"
|
||||
:key="tag.id"
|
||||
:tag="tag"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
v-else
|
||||
class="text-muted"
|
||||
>{{ t('tags.unlabeled') }}</span>
|
||||
<TagBadgeList
|
||||
:tags="item.tags"
|
||||
compact
|
||||
:empty-label="t('tags.unlabeled')"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
|
||||
@@ -32,24 +32,28 @@ onMounted(() => {
|
||||
const text = computed(() => {
|
||||
const allowedVideos = uploadPolicy.value?.allowedVideoExtensions ?? []
|
||||
const forbiddenFiles = uploadPolicy.value?.forbiddenFileExtensions ?? []
|
||||
const videoMaxFileSizeMb = uploadPolicy.value?.videoMaxFileSizeMb ?? 500
|
||||
const fileMaxFileSizeMb = uploadPolicy.value?.fileMaxFileSizeMb ?? 100
|
||||
return props.kind === 'video'
|
||||
? {
|
||||
uploadTitle: t('library.video.uploadTitle'),
|
||||
uploadHint: t('library.video.uploadHint'),
|
||||
uploadFormats: allowedVideos.length
|
||||
? t('library.video.uploadFormatsAllowed', {
|
||||
formats: allowedVideos.map(item => item.toUpperCase()).join(' / ')
|
||||
formats: allowedVideos.map(item => item.toUpperCase()).join(' / '),
|
||||
size: videoMaxFileSizeMb
|
||||
})
|
||||
: t('library.video.uploadFormats')
|
||||
: t('library.video.uploadFormats', { size: videoMaxFileSizeMb })
|
||||
}
|
||||
: {
|
||||
uploadTitle: t('library.file.uploadTitle'),
|
||||
uploadHint: t('library.file.uploadHint'),
|
||||
uploadFormats: forbiddenFiles.length
|
||||
? 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 })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ const emit = defineEmits<{
|
||||
'created': [tag: ImageTag]
|
||||
}>()
|
||||
|
||||
const COMMON_LIMIT = 12
|
||||
const COMMON_LIMIT = 10
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const { createTag } = useTags()
|
||||
|
||||
@@ -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>
|
||||
@@ -43,7 +43,7 @@ function onBack() {
|
||||
<template>
|
||||
<div
|
||||
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-face upload-card-face--front">
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { UploadMediaKind } from '~/composables/useUploadPreferences'
|
||||
import {
|
||||
normalizeUploadTagNames,
|
||||
type UploadMediaKind
|
||||
} from '~/composables/useUploadPreferences'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** 每个上传页各自一份偏好 */
|
||||
@@ -17,20 +20,36 @@ const {
|
||||
autoCopyMarkdown,
|
||||
copyFormat,
|
||||
clientWebpQuality,
|
||||
lastTagIds,
|
||||
uploadTagNames,
|
||||
transcodeToHls,
|
||||
loadPreferences
|
||||
} = useUploadPreferences(props.kind)
|
||||
const { items: tagItems, fetchTags } = useTags()
|
||||
const uploadTagDraft = ref('')
|
||||
|
||||
/** 视频床 / 文件床没有图片压缩相关选项 */
|
||||
const isImageKind = computed(() => props.kind === 'image')
|
||||
const isVideoKind = computed(() => props.kind === 'video')
|
||||
const gridClass = computed(() => {
|
||||
if (isImageKind.value) return 'sm:grid-cols-3 sm:gap-6'
|
||||
if (isVideoKind.value) return 'sm:grid-cols-2 sm:gap-6'
|
||||
return 'mx-auto w-full max-w-md'
|
||||
const processingGridClass = computed(() => {
|
||||
if (isImageKind.value || isVideoKind.value) return 'md:col-start-1 md:row-start-1'
|
||||
return ''
|
||||
})
|
||||
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 {
|
||||
@@ -74,11 +93,39 @@ function onClientQualityInput(event: Event) {
|
||||
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(() => {
|
||||
loadPreferences()
|
||||
void loadUserSettings()
|
||||
void loadTranscodeSettings()
|
||||
void fetchTags()
|
||||
})
|
||||
</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="grid gap-5"
|
||||
:class="gridClass"
|
||||
class="grid gap-5 md:grid-cols-2 md:gap-6"
|
||||
>
|
||||
<div
|
||||
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">
|
||||
{{ t('preferences.processing') }}
|
||||
@@ -179,7 +226,8 @@ onMounted(() => {
|
||||
|
||||
<div
|
||||
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">
|
||||
{{ t('preferences.videoProcessing') }}
|
||||
@@ -217,7 +265,8 @@ onMounted(() => {
|
||||
|
||||
<div
|
||||
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">
|
||||
{{ t('preferences.webpQuality') }}
|
||||
@@ -239,7 +288,10 @@ onMounted(() => {
|
||||
>
|
||||
</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">
|
||||
{{ t('preferences.convenience') }}
|
||||
</h3>
|
||||
@@ -271,19 +323,57 @@ onMounted(() => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 space-y-3 border-t border-default pt-5">
|
||||
<h3 class="text-sm font-semibold">
|
||||
{{ t('preferences.uploadTags') }}
|
||||
</h3>
|
||||
<p class="text-xs leading-relaxed text-muted">
|
||||
{{ t('preferences.uploadTagsHint') }}
|
||||
</p>
|
||||
<TagAssignPanel
|
||||
v-model="lastTagIds"
|
||||
:tags="tagItems"
|
||||
/>
|
||||
<div
|
||||
class="space-y-3 rounded-xl border border-default/70 bg-muted/10 p-4"
|
||||
:class="tagsLayoutClass"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-semibold">
|
||||
{{ t('preferences.uploadTags') }}
|
||||
</h3>
|
||||
<p class="text-xs leading-relaxed text-muted">
|
||||
{{ 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>
|
||||
|
||||
@@ -109,16 +109,7 @@ const uploadedLabel = computed(() => {
|
||||
<p class="truncate text-xs text-muted">
|
||||
{{ uploadedLabel }} · {{ formatFileSize(item.size) }}
|
||||
</p>
|
||||
<div
|
||||
v-if="item.tags?.length"
|
||||
class="flex flex-wrap gap-1"
|
||||
>
|
||||
<TagBadge
|
||||
v-for="tag in item.tags"
|
||||
:key="tag.id"
|
||||
:tag="tag"
|
||||
/>
|
||||
</div>
|
||||
<TagBadgeList :tags="item.tags" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-around border-t border-default px-2 py-2">
|
||||
|
||||
@@ -659,20 +659,11 @@ defineExpose({ refresh })
|
||||
{{ uploadedLabel(item) }}
|
||||
</td>
|
||||
<td class="hidden px-3 py-2 md:table-cell">
|
||||
<div
|
||||
v-if="item.tags?.length"
|
||||
class="flex flex-wrap gap-1"
|
||||
>
|
||||
<TagBadge
|
||||
v-for="tag in item.tags"
|
||||
:key="tag.id"
|
||||
:tag="tag"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
v-else
|
||||
class="text-muted"
|
||||
>{{ t('tags.unlabeled') }}</span>
|
||||
<TagBadgeList
|
||||
:tags="item.tags"
|
||||
compact
|
||||
:empty-label="t('tags.unlabeled')"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<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<{
|
||||
part: 'upload-preference' | 'image-processing' | 'login-rate'
|
||||
uploadMaxFileSizeMbSource?: PolicySource
|
||||
uploadImageMaxFileSizeMbSource?: PolicySource
|
||||
uploadVideoMaxFileSizeMbSource?: PolicySource
|
||||
uploadFileMaxFileSizeMbSource?: PolicySource
|
||||
allowedMimeTypesSource?: PolicySource
|
||||
allowedVideoExtensionsSource?: PolicySource
|
||||
forbiddenFileExtensionsSource?: PolicySource
|
||||
@@ -20,7 +22,9 @@ const props = defineProps<{
|
||||
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 allowedVideoExtensionsDraft = defineModel<VideoExtension[]>('allowedVideoExtensions')
|
||||
const forbiddenFileExtensionsDraft = defineModel<string[]>('forbiddenFileExtensions')
|
||||
@@ -125,67 +129,109 @@ function addCustomForbiddenExtension() {
|
||||
customForbiddenExtension.value = ''
|
||||
}
|
||||
|
||||
function clampUploadMaxFileSizeMb(value: unknown): number {
|
||||
const num = Number(value)
|
||||
if (!Number.isFinite(num)) return uploadMaxFileSizeMbDraft.value ?? 10
|
||||
return Math.min(100, Math.max(1, Math.round(num)))
|
||||
type UploadSizeKind = 'image' | 'video' | 'file'
|
||||
|
||||
const uploadSizeFields = computed(() => [
|
||||
{
|
||||
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) {
|
||||
uploadMaxFileSizeMbDraft.value = clampUploadMaxFileSizeMb(
|
||||
function clampUploadMaxFileSizeMb(kind: UploadSizeKind, value: unknown): number {
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
function onUploadMaxFileSizeNumberInput(value: string | number) {
|
||||
uploadMaxFileSizeMbDraft.value = clampUploadMaxFileSizeMb(value)
|
||||
function onUploadMaxFileSizeNumberInput(kind: UploadSizeKind, value: string | number) {
|
||||
uploadSizeModel(kind).value = clampUploadMaxFileSizeMb(kind, value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="part === 'upload-preference'">
|
||||
<div class="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 class="space-y-5 py-4">
|
||||
<div
|
||||
class="mt-3 flex items-center gap-3"
|
||||
:class="uploadMaxFileSizeMbSource === 'env' ? 'pointer-events-none opacity-60' : ''"
|
||||
v-for="field in uploadSizeFields"
|
||||
:key="field.kind"
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="100"
|
||||
:value="uploadMaxFileSizeMbDraft"
|
||||
class="h-1.5 min-w-0 flex-1 cursor-pointer appearance-none rounded-full bg-muted accent-primary"
|
||||
:disabled="uploadMaxFileSizeMbSource === 'env'"
|
||||
@input="onUploadMaxFileSizeInput"
|
||||
>
|
||||
<div class="flex shrink-0 items-center gap-1.5">
|
||||
<UInput
|
||||
:model-value="uploadMaxFileSizeMbDraft"
|
||||
type="number"
|
||||
:min="1"
|
||||
:max="100"
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-sm font-medium text-highlighted">
|
||||
{{ field.title }}
|
||||
</p>
|
||||
<UBadge
|
||||
v-if="field.source"
|
||||
:color="sourceBadge(field.source).color"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
class="w-14 tabular-nums"
|
||||
:disabled="uploadMaxFileSizeMbSource === 'env'"
|
||||
@update:model-value="onUploadMaxFileSizeNumberInput"
|
||||
/>
|
||||
<span class="text-xs text-muted">MB</span>
|
||||
>
|
||||
{{ sourceBadge(field.source).label }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -4,21 +4,22 @@ import type {
|
||||
} from '~/types/image'
|
||||
import { isUnauthorizedError } from './useAuth'
|
||||
import { prepareFilesForUpload } from './useClipboardImage'
|
||||
import { normalizeUploadTagNames } from './useUploadPreferences'
|
||||
|
||||
const MAX_FILES = 10
|
||||
|
||||
export function useImageUpload() {
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
const { compressEnabled, clientWebpQuality, lastTagIds } = useUploadPreferences()
|
||||
const { items: tagItems } = useTags()
|
||||
const { compressEnabled, clientWebpQuality, uploadTagNames } = useUploadPreferences()
|
||||
const { load: loadUploadPolicy } = useUploadPolicy()
|
||||
const uploading = ref(false)
|
||||
const progressItems = ref<UploadProgressItem[]>([])
|
||||
const lastUploadResult = ref<UploadResponse | null>(null)
|
||||
|
||||
async function uploadFiles(
|
||||
files: File[],
|
||||
options?: { notify?: boolean, tagIds?: number[] }
|
||||
options?: { notify?: boolean }
|
||||
) {
|
||||
if (uploading.value) return
|
||||
|
||||
@@ -43,10 +44,27 @@ export function useImageUpload() {
|
||||
}))
|
||||
|
||||
try {
|
||||
const prepared = await prepareFilesForUpload(files, {
|
||||
const preparedFiles = await prepareFilesForUpload(files, {
|
||||
enabled: compressEnabled.value,
|
||||
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) => ({
|
||||
name: file.name,
|
||||
@@ -59,15 +77,9 @@ export function useImageUpload() {
|
||||
formData.append('files', file, file.name)
|
||||
}
|
||||
|
||||
const tagIds = options?.tagIds ?? lastTagIds.value
|
||||
if (tagIds.length) {
|
||||
const names = tagIds
|
||||
.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 tagNames = normalizeUploadTagNames(uploadTagNames.value).join(',')
|
||||
if (tagNames) {
|
||||
formData.append('tagNames', tagNames)
|
||||
}
|
||||
|
||||
const response = await $fetch<UploadResponse>('/api/images/upload', {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { UploadProgressItem } from '~/types/image'
|
||||
import type { FileItem, FileListResponse, FileUploadResponse } from '~/types/file'
|
||||
import type { CopyFormat, UploadMediaKind } from './useUploadPreferences'
|
||||
import { isUnauthorizedError } from './useAuth'
|
||||
import { normalizeUploadTagNames } from './useUploadPreferences'
|
||||
|
||||
const MAX_FILES = 10
|
||||
|
||||
@@ -54,10 +55,9 @@ export function useMediaUpload(kind: UploadMediaKind) {
|
||||
autoCopyMarkdown,
|
||||
copyFormat,
|
||||
transcodeToHls,
|
||||
lastTagIds,
|
||||
uploadTagNames,
|
||||
loadPreferences
|
||||
} = useUploadPreferences(kind)
|
||||
const { items: tagItems, fetchTags } = useTags()
|
||||
|
||||
const uploading = ref(false)
|
||||
const progressItems = ref<UploadProgressItem[]>([])
|
||||
@@ -209,6 +209,19 @@ export function useMediaUpload(kind: UploadMediaKind) {
|
||||
})
|
||||
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
|
||||
})
|
||||
if (!acceptedFiles.length) return
|
||||
@@ -234,14 +247,9 @@ export function useMediaUpload(kind: UploadMediaKind) {
|
||||
if (kind === 'video' && transcodeToHls.value) {
|
||||
formData.append('transcode', '1')
|
||||
}
|
||||
if (lastTagIds.value.length) {
|
||||
await fetchTags()
|
||||
const names = lastTagIds.value
|
||||
.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))
|
||||
}
|
||||
const tagNames = normalizeUploadTagNames(uploadTagNames.value).join(',')
|
||||
if (tagNames) {
|
||||
formData.append('tagNames', tagNames)
|
||||
}
|
||||
|
||||
progressItems.value = progressItems.value.map(item => ({
|
||||
|
||||
@@ -2,6 +2,9 @@ interface UploadPolicy {
|
||||
allowedMimeTypes: string[]
|
||||
allowedVideoExtensions: string[]
|
||||
forbiddenFileExtensions: string[]
|
||||
imageMaxFileSizeMb: number
|
||||
videoMaxFileSizeMb: number
|
||||
fileMaxFileSizeMb: number
|
||||
}
|
||||
|
||||
const policy = ref<UploadPolicy | null>(null)
|
||||
@@ -21,7 +24,10 @@ export function useUploadPolicy() {
|
||||
policy.value = {
|
||||
allowedMimeTypes: [...data.allowedMimeTypes],
|
||||
allowedVideoExtensions: [...data.allowedVideoExtensions],
|
||||
forbiddenFileExtensions: [...data.forbiddenFileExtensions]
|
||||
forbiddenFileExtensions: [...data.forbiddenFileExtensions],
|
||||
imageMaxFileSizeMb: data.imageMaxFileSizeMb,
|
||||
videoMaxFileSizeMb: data.videoMaxFileSizeMb,
|
||||
fileMaxFileSizeMb: data.fileMaxFileSizeMb
|
||||
}
|
||||
return policy.value
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface UploadPreferences {
|
||||
autoCopyMarkdown: boolean
|
||||
copyFormat: CopyFormat
|
||||
clientWebpQuality: number
|
||||
lastTagIds: number[]
|
||||
uploadTagNames: string[]
|
||||
/** 视频床专属:上传后转码为 HLS(m3u8 + 分片) */
|
||||
transcodeToHls: boolean
|
||||
}
|
||||
@@ -23,10 +23,21 @@ const DEFAULTS: UploadPreferences = {
|
||||
autoCopyMarkdown: false,
|
||||
copyFormat: 'markdown',
|
||||
clientWebpQuality: 80,
|
||||
lastTagIds: [],
|
||||
uploadTagNames: [],
|
||||
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 {
|
||||
const num = Number(value)
|
||||
if (!Number.isFinite(num)) return DEFAULTS.clientWebpQuality
|
||||
@@ -49,9 +60,8 @@ function readStored(kind: UploadMediaKind): UploadPreferences {
|
||||
autoCopyMarkdown: parsed.autoCopyMarkdown === true,
|
||||
copyFormat: isCopyFormat(parsed.copyFormat) ? parsed.copyFormat : DEFAULTS.copyFormat,
|
||||
clientWebpQuality: clampQuality(parsed.clientWebpQuality),
|
||||
lastTagIds: Array.isArray(parsed.lastTagIds)
|
||||
? parsed.lastTagIds.filter(id => Number.isInteger(id) && id > 0)
|
||||
: DEFAULTS.lastTagIds,
|
||||
/** 标签输入只在当前页面会话内生效,刷新后默认为空。 */
|
||||
uploadTagNames: [...DEFAULTS.uploadTagNames],
|
||||
transcodeToHls: parsed.transcodeToHls === true
|
||||
}
|
||||
} catch {
|
||||
@@ -69,17 +79,23 @@ export function useUploadPreferences(kind: UploadMediaKind = 'image') {
|
||||
const autoCopyMarkdown = useState(`upload-pref-auto-md-${kind}`, () => DEFAULTS.autoCopyMarkdown)
|
||||
const copyFormat = useState<CopyFormat>(`upload-pref-copy-format-${kind}`, () => DEFAULTS.copyFormat)
|
||||
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 loaded = useState(`upload-pref-loaded-${kind}`, () => false)
|
||||
|
||||
function loadPreferences() {
|
||||
const firstLoad = !loaded.value
|
||||
const stored = readStored(kind)
|
||||
compressEnabled.value = stored.compressEnabled
|
||||
autoCopyMarkdown.value = stored.autoCopyMarkdown
|
||||
copyFormat.value = stored.copyFormat
|
||||
clientWebpQuality.value = stored.clientWebpQuality
|
||||
lastTagIds.value = stored.lastTagIds
|
||||
if (firstLoad) {
|
||||
uploadTagNames.value = [...stored.uploadTagNames]
|
||||
}
|
||||
transcodeToHls.value = stored.transcodeToHls
|
||||
loaded.value = true
|
||||
}
|
||||
@@ -94,7 +110,7 @@ export function useUploadPreferences(kind: UploadMediaKind = 'image') {
|
||||
autoCopyMarkdown: autoCopyMarkdown.value,
|
||||
copyFormat: copyFormat.value,
|
||||
clientWebpQuality: clampQuality(clientWebpQuality.value),
|
||||
lastTagIds: lastTagIds.value,
|
||||
uploadTagNames: [...DEFAULTS.uploadTagNames],
|
||||
transcodeToHls: transcodeToHls.value
|
||||
})
|
||||
}
|
||||
@@ -104,12 +120,13 @@ export function useUploadPreferences(kind: UploadMediaKind = 'image') {
|
||||
autoCopyMarkdown.value = DEFAULTS.autoCopyMarkdown
|
||||
copyFormat.value = DEFAULTS.copyFormat
|
||||
clientWebpQuality.value = DEFAULTS.clientWebpQuality
|
||||
uploadTagNames.value = [...DEFAULTS.uploadTagNames]
|
||||
transcodeToHls.value = DEFAULTS.transcodeToHls
|
||||
savePreferences()
|
||||
}
|
||||
|
||||
watch(
|
||||
[compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality, lastTagIds, transcodeToHls],
|
||||
[compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality, transcodeToHls],
|
||||
() => {
|
||||
if (loaded.value) {
|
||||
savePreferences()
|
||||
@@ -123,7 +140,7 @@ export function useUploadPreferences(kind: UploadMediaKind = 'image') {
|
||||
autoCopyMarkdown,
|
||||
copyFormat,
|
||||
clientWebpQuality,
|
||||
lastTagIds,
|
||||
uploadTagNames,
|
||||
transcodeToHls,
|
||||
loaded,
|
||||
loadPreferences,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ImageItem, ImageTag } from '~/types/image'
|
||||
import { buildImageBbcode } from '~/utils/image-display'
|
||||
|
||||
const { uploading, progressItems, uploadFiles } = useImageUpload()
|
||||
const { policy: uploadPolicy, load: loadUploadPolicy } = useUploadPolicy()
|
||||
|
||||
const {
|
||||
autoCopyMarkdown,
|
||||
@@ -16,6 +17,9 @@ const { recordRecent } = useRecentTags()
|
||||
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus } = useAuth()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
const imageFormats = computed(() =>
|
||||
t('upload.formats', { size: uploadPolicy.value?.imageMaxFileSizeMb ?? 10 })
|
||||
)
|
||||
const sessionItems = ref<ImageItem[]>([])
|
||||
const deletingKeys = ref<Set<string>>(new Set())
|
||||
const emptySelection = ref(new Set<string>())
|
||||
@@ -39,6 +43,7 @@ const showProgress = computed(
|
||||
async function loadPage() {
|
||||
try {
|
||||
loadPreferences()
|
||||
await loadUploadPolicy().catch(() => {})
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(error)
|
||||
}
|
||||
@@ -227,6 +232,7 @@ async function confirmBatchTags(tagIds: number[]) {
|
||||
<AppShell v-else>
|
||||
<UploadFlipCard
|
||||
:disabled="uploading"
|
||||
:formats="imageFormats"
|
||||
@upload="handleUpload"
|
||||
/>
|
||||
|
||||
|
||||
@@ -55,8 +55,12 @@ interface SettingsResponse {
|
||||
capApiEndpoint: string
|
||||
capSecret: string
|
||||
appVersion: string
|
||||
uploadMaxFileSizeMb: number
|
||||
uploadMaxFileSizeMbSource: PolicySource
|
||||
uploadImageMaxFileSizeMb: number
|
||||
uploadImageMaxFileSizeMbSource: PolicySource
|
||||
uploadVideoMaxFileSizeMb: number
|
||||
uploadVideoMaxFileSizeMbSource: PolicySource
|
||||
uploadFileMaxFileSizeMb: number
|
||||
uploadFileMaxFileSizeMbSource: PolicySource
|
||||
allowedMimeTypes: MimeType[]
|
||||
allowedMimeTypesSource: PolicySource
|
||||
allowedVideoExtensions: VideoExtension[]
|
||||
@@ -185,7 +189,9 @@ const capApiEndpointDraft = ref('')
|
||||
const capSecretDraft = ref('')
|
||||
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 allowedVideoExtensionsDraft = ref<VideoExtension[]>([])
|
||||
const forbiddenFileExtensionsDraft = ref<string[]>([])
|
||||
@@ -242,7 +248,9 @@ const hasServerChanges = computed(() => {
|
||||
|| turnstileSecretKeyDraft.value !== settings.value.turnstileSecretKey
|
||||
|| capApiEndpointDraft.value !== settings.value.capApiEndpoint
|
||||
|| 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(allowedVideoExtensionsDraft.value, settings.value.allowedVideoExtensions)
|
||||
|| !mimeListsEqual(forbiddenFileExtensionsDraft.value, settings.value.forbiddenFileExtensions)
|
||||
@@ -320,7 +328,9 @@ function applySettings(data: SettingsResponse) {
|
||||
turnstileSecretKeyDraft.value = data.turnstileSecretKey
|
||||
capApiEndpointDraft.value = data.capApiEndpoint
|
||||
capSecretDraft.value = data.capSecret
|
||||
uploadMaxFileSizeMbDraft.value = data.uploadMaxFileSizeMb
|
||||
uploadImageMaxFileSizeMbDraft.value = data.uploadImageMaxFileSizeMb
|
||||
uploadVideoMaxFileSizeMbDraft.value = data.uploadVideoMaxFileSizeMb
|
||||
uploadFileMaxFileSizeMbDraft.value = data.uploadFileMaxFileSizeMb
|
||||
allowedMimeTypesDraft.value = [...data.allowedMimeTypes]
|
||||
allowedVideoExtensionsDraft.value = [...data.allowedVideoExtensions]
|
||||
forbiddenFileExtensionsDraft.value = [...data.forbiddenFileExtensions]
|
||||
@@ -423,7 +433,9 @@ async function performSaveServerSettings() {
|
||||
turnstileSecretKey: turnstileSecretKeyDraft.value,
|
||||
capApiEndpoint: capApiEndpointDraft.value,
|
||||
capSecret: capSecretDraft.value,
|
||||
uploadMaxFileSizeMb: uploadMaxFileSizeMbDraft.value,
|
||||
uploadImageMaxFileSizeMb: uploadImageMaxFileSizeMbDraft.value,
|
||||
uploadVideoMaxFileSizeMb: uploadVideoMaxFileSizeMbDraft.value,
|
||||
uploadFileMaxFileSizeMb: uploadFileMaxFileSizeMbDraft.value,
|
||||
allowedMimeTypes: allowedMimeTypesDraft.value,
|
||||
allowedVideoExtensions: allowedVideoExtensionsDraft.value,
|
||||
forbiddenFileExtensions: forbiddenFileExtensionsDraft.value,
|
||||
@@ -626,7 +638,9 @@ watch(isAdmin, () => {
|
||||
>
|
||||
<SettingsGroup>
|
||||
<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-video-extensions="allowedVideoExtensionsDraft"
|
||||
v-model:forbidden-file-extensions="forbiddenFileExtensionsDraft"
|
||||
@@ -634,7 +648,9 @@ watch(isAdmin, () => {
|
||||
v-model:upload-rate-token-max="uploadRateTokenMaxDraft"
|
||||
v-model:upload-rate-window-minutes="uploadRateWindowMinutesDraft"
|
||||
part="upload-preference"
|
||||
:upload-max-file-size-mb-source="settings.uploadMaxFileSizeMbSource"
|
||||
: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-video-extensions-source="settings.allowedVideoExtensionsSource"
|
||||
:forbidden-file-extensions-source="settings.forbiddenFileExtensionsSource"
|
||||
|
||||
+17
-7
@@ -158,7 +158,7 @@
|
||||
"upload": {
|
||||
"title": "Drop images here to upload",
|
||||
"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",
|
||||
"settings": "Upload settings",
|
||||
"progress": "Upload progress",
|
||||
@@ -329,7 +329,10 @@
|
||||
"autoCopy": "Copy link after upload",
|
||||
"autoCopyHint": "Copy to clipboard in the selected format",
|
||||
"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": {
|
||||
"title": "System settings",
|
||||
@@ -475,6 +478,12 @@
|
||||
"minutesUnit": "min",
|
||||
"uploadMaxFileSize": "Max file size",
|
||||
"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",
|
||||
"allowedMimeTypesHint": "Keep at least one format enabled. The server validates file signatures, not just extensions.",
|
||||
"allowedVideoExtensions": "Allowed video formats",
|
||||
@@ -1169,6 +1178,7 @@
|
||||
"maxFiles": "Up to {n} files per upload",
|
||||
"uploadSuccess": "Uploaded {n} file(s)",
|
||||
"uploadFailed": "Upload failed, please try again",
|
||||
"fileTooLarge": "{name}: exceeds the {limit} MB per-file limit",
|
||||
"loadFailed": "Failed to load files",
|
||||
"copyLink": "Copy link",
|
||||
"download": "Download",
|
||||
@@ -1218,9 +1228,9 @@
|
||||
"subtitle": "Manage uploaded files, copy links or download",
|
||||
"uploadTitle": "Drop files here to upload",
|
||||
"uploadHint": "or click to choose files, up to 10 at a time",
|
||||
"uploadFormats": "Any type allowed (HTML / HTM / SVG blocked), up to 100MB per file",
|
||||
"uploadFormatsBlocked": "{formats} blocked; other file types allowed, up to 100MB per file",
|
||||
"uploadFormatsUnrestricted": "Any file type allowed, up to 100MB per file",
|
||||
"uploadFormats": "Any type allowed (HTML / HTM / SVG blocked), up to {size} MB per file",
|
||||
"uploadFormatsBlocked": "{formats} blocked; other file types allowed, up to {size} MB per file",
|
||||
"uploadFormatsUnrestricted": "Any file type allowed, up to {size} MB per file",
|
||||
"empty": "No files yet — upload one to get started"
|
||||
},
|
||||
"video": {
|
||||
@@ -1243,8 +1253,8 @@
|
||||
"downloadMp4Failed": "Video merge failed. Please try again later.",
|
||||
"uploadTitle": "Drop videos here to upload",
|
||||
"uploadHint": "or click to choose video files, up to 10 at a time",
|
||||
"uploadFormats": "MP4 / WebM / MOV / MKV and more, up to 100MB per file",
|
||||
"uploadFormatsAllowed": "{formats} supported, up to 100MB per file",
|
||||
"uploadFormats": "MP4 / WebM / MOV / MKV and more, up to {size} MB per file",
|
||||
"uploadFormatsAllowed": "{formats} supported, up to {size} MB per file",
|
||||
"empty": "No videos yet — upload one to get started"
|
||||
}
|
||||
},
|
||||
|
||||
+17
-7
@@ -158,7 +158,7 @@
|
||||
"upload": {
|
||||
"title": "拖拽图片到此处上传",
|
||||
"hint": "或点击选择文件,按 {ctrl} + {v} 粘贴图片",
|
||||
"formats": "支持 JPEG / PNG / WebP / GIF / SVG / ICO,单张最大 10 MB,每次最多 10 张",
|
||||
"formats": "支持 JPEG / PNG / WebP / GIF / SVG / ICO,单张最大 {size} MB,每次最多 10 张",
|
||||
"selectFiles": "选择图片",
|
||||
"settings": "上传设置",
|
||||
"progress": "上传进度",
|
||||
@@ -329,7 +329,10 @@
|
||||
"autoCopy": "上传成功自动复制链接",
|
||||
"autoCopyHint": "上传成功后,自动复制链接到剪贴板。",
|
||||
"uploadTags": "上传标签",
|
||||
"uploadTagsHint": "图片、视频和文件共用当前上传页选择的标签;不存在的标签会自动创建。"
|
||||
"uploadTagsHint": "输入后按 Enter、逗号或移开焦点加入待添加列表;留空则不添加标签。",
|
||||
"uploadTagsPlaceholder": "输入标签",
|
||||
"uploadTagsPending": "待添加标签({n})",
|
||||
"uploadTagsRemove": "删除标签 {name}"
|
||||
},
|
||||
"settings": {
|
||||
"title": "系统设置",
|
||||
@@ -475,6 +478,12 @@
|
||||
"minutesUnit": "分钟",
|
||||
"uploadMaxFileSize": "单文件大小上限",
|
||||
"uploadMaxFileSizeHint": "超过此大小的图片将被拒绝上传(1–100 MB)。",
|
||||
"uploadImageMaxFileSize": "图片大小上限",
|
||||
"uploadImageMaxFileSizeHint": "超过此大小的图片将被拒绝上传,最大 100 MB。0 MB 表示禁止上传。",
|
||||
"uploadVideoMaxFileSize": "视频大小上限",
|
||||
"uploadVideoMaxFileSizeHint": "超过此大小的视频将被拒绝上传,最大 2048 MB。0 MB 表示禁止上传。",
|
||||
"uploadFileMaxFileSize": "文件大小上限",
|
||||
"uploadFileMaxFileSizeHint": "超过此大小的普通文件将被拒绝上传,最大 1024 MB。0 MB 表示禁止上传。",
|
||||
"allowedMimeTypes": "允许的图片格式",
|
||||
"allowedMimeTypesHint": "至少保留一种格式;服务端会校验文件签名,不仅看扩展名。",
|
||||
"allowedVideoExtensions": "允许的视频格式",
|
||||
@@ -1172,6 +1181,7 @@
|
||||
"maxFiles": "每次最多上传 {n} 个文件",
|
||||
"uploadSuccess": "已上传 {n} 个文件",
|
||||
"uploadFailed": "上传失败,请稍后重试",
|
||||
"fileTooLarge": "{name}:超过单文件上限 {limit} MB",
|
||||
"loadFailed": "读取文件列表失败",
|
||||
"copyLink": "复制直链",
|
||||
"download": "下载",
|
||||
@@ -1221,9 +1231,9 @@
|
||||
"subtitle": "管理已上传的文件,复制直链或下载",
|
||||
"uploadTitle": "拖拽文件到此处上传",
|
||||
"uploadHint": "或点击选择文件,单次最多 10 个",
|
||||
"uploadFormats": "支持任意类型(禁止 HTML / HTM / SVG),单个文件最大 100MB",
|
||||
"uploadFormatsBlocked": "禁止 {formats};其他文件类型可上传,单个文件最大 100MB",
|
||||
"uploadFormatsUnrestricted": "支持任意类型,单个文件最大 100MB",
|
||||
"uploadFormats": "支持任意类型(禁止 HTML / HTM / SVG),单个文件最大 {size} MB",
|
||||
"uploadFormatsBlocked": "禁止 {formats};其他文件类型可上传,单个文件最大 {size} MB",
|
||||
"uploadFormatsUnrestricted": "支持任意类型,单个文件最大 {size} MB",
|
||||
"empty": "还没有文件,上传一个试试"
|
||||
},
|
||||
"video": {
|
||||
@@ -1246,8 +1256,8 @@
|
||||
"downloadMp4Failed": "视频合并失败,请稍后重试",
|
||||
"uploadTitle": "拖拽视频到此处上传",
|
||||
"uploadHint": "或点击选择视频文件,单次最多 10 个",
|
||||
"uploadFormats": "支持 MP4 / WebM / MOV / MKV 等,单个文件最大 100MB",
|
||||
"uploadFormatsAllowed": "支持 {formats},单个文件最大 100MB",
|
||||
"uploadFormats": "支持 MP4 / WebM / MOV / MKV 等,单个文件最大 {size} MB",
|
||||
"uploadFormatsAllowed": "支持 {formats},单个文件最大 {size} MB",
|
||||
"empty": "还没有视频,上传一个试试"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
parseLoginRateMax,
|
||||
parseLoginRateWindowMinutes,
|
||||
parseUploadMaxFileSizeMb,
|
||||
parseUploadMaxFileSizeMbForKind,
|
||||
parseUploadRateIpMax,
|
||||
parseUploadRateTokenMax,
|
||||
parseUploadRateWindowMinutes,
|
||||
@@ -78,6 +79,9 @@ interface SettingsPatchBody {
|
||||
capApiEndpoint?: unknown
|
||||
capSecret?: unknown
|
||||
uploadMaxFileSizeMb?: unknown
|
||||
uploadImageMaxFileSizeMb?: unknown
|
||||
uploadVideoMaxFileSizeMb?: unknown
|
||||
uploadFileMaxFileSizeMb?: unknown
|
||||
allowedMimeTypes?: unknown
|
||||
allowedVideoExtensions?: unknown
|
||||
forbiddenFileExtensions?: unknown
|
||||
@@ -360,11 +364,44 @@ export default defineEventHandler(async (event) => {
|
||||
if (body.uploadMaxFileSizeMb !== undefined) {
|
||||
const mb = parseUploadMaxFileSizeMb(String(body.uploadMaxFileSizeMb))
|
||||
if (mb === null) {
|
||||
createApiError(event, 'INVALID_REQUEST', '单文件大小需为 1–100 MB 的整数', 400)
|
||||
createApiError(event, 'INVALID_REQUEST', '图片大小需为 0–100 MB 的整数', 400)
|
||||
}
|
||||
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) {
|
||||
const raw = Array.isArray(body.allowedMimeTypes)
|
||||
? body.allowedMimeTypes.map(item => String(item))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
getAllowedMimeTypesList,
|
||||
getAllowedVideoExtensions,
|
||||
getEffectiveUploadMaxFileSizeMb,
|
||||
getForbiddenFileExtensions
|
||||
} from '../utils/upload-policy'
|
||||
|
||||
@@ -9,6 +10,9 @@ export default defineEventHandler((event) => {
|
||||
return {
|
||||
allowedMimeTypes: getAllowedMimeTypesList(),
|
||||
allowedVideoExtensions: getAllowedVideoExtensions(),
|
||||
forbiddenFileExtensions: getForbiddenFileExtensions()
|
||||
forbiddenFileExtensions: getForbiddenFileExtensions(),
|
||||
imageMaxFileSizeMb: getEffectiveUploadMaxFileSizeMb('image'),
|
||||
videoMaxFileSizeMb: getEffectiveUploadMaxFileSizeMb('video'),
|
||||
fileMaxFileSizeMb: getEffectiveUploadMaxFileSizeMb('file')
|
||||
}
|
||||
})
|
||||
@@ -1,10 +1,6 @@
|
||||
import type { H3Event } from 'h3'
|
||||
import type { UploadErrorItem } from '~/types/image'
|
||||
import type { FileItem } from '~/types/file'
|
||||
import {
|
||||
MAX_FILE_BED_FILE_SIZE,
|
||||
MAX_VIDEO_FILE_SIZE
|
||||
} from './constants'
|
||||
import { contentTypeFromKey } from './content-type'
|
||||
import {
|
||||
extensionFromFilename,
|
||||
@@ -21,7 +17,9 @@ import { putImage } from './storage'
|
||||
import { generateVideoThumbnail } from './transcode'
|
||||
import { updateImageThumb } from './image-index'
|
||||
import {
|
||||
formatEffectiveMaxFileSizeMessage,
|
||||
getAllowedVideoExtensions,
|
||||
getEffectiveMaxFileSizeBytes,
|
||||
isForbiddenFileExtension,
|
||||
isVideoExtensionAllowed
|
||||
} 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 {
|
||||
error: {
|
||||
name: originalName,
|
||||
error: {
|
||||
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 {
|
||||
error: {
|
||||
name: originalName,
|
||||
error: {
|
||||
code: 'FILE_TOO_LARGE',
|
||||
message: `单个视频不能超过 ${Math.round(MAX_VIDEO_FILE_SIZE / 1024 / 1024)}MB`
|
||||
message: formatEffectiveMaxFileSizeMessage('video')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { findUserByUsername } from '../db'
|
||||
import {
|
||||
findUserByUsername,
|
||||
getUploadSourcesForKeys,
|
||||
insertActivityLog
|
||||
} from '../db'
|
||||
import {
|
||||
getImageIndexRow,
|
||||
insertImageIndex,
|
||||
@@ -52,6 +56,24 @@ export async function applySiteSyncMedia(
|
||||
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.mediaType === 'video') {
|
||||
updateImageTranscode(media.key, {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ImageIndexRow } from '../image-index'
|
||||
import { getUploadSourcesForKeys } from '../db'
|
||||
import { getTagsForImageKeys } from '../tags'
|
||||
import type {
|
||||
SiteSyncMediaTransfer,
|
||||
@@ -10,6 +11,8 @@ export function toSiteSyncMediaTransfer(
|
||||
ownerUsername: string | null,
|
||||
tags: SiteSyncTag[]
|
||||
): SiteSyncMediaTransfer {
|
||||
const source = getUploadSourcesForKeys([row.key]).get(row.key)
|
||||
const uploadSource = source === 'web' || source === 'api' ? source : undefined
|
||||
return {
|
||||
key: row.key,
|
||||
ownerUsername,
|
||||
@@ -18,6 +21,7 @@ export function toSiteSyncMediaTransfer(
|
||||
size: row.size,
|
||||
uploadedAt: row.uploaded_at,
|
||||
mediaType: row.media_type,
|
||||
...(uploadSource ? { uploadSource } : {}),
|
||||
thumbKey: row.thumb_key,
|
||||
hlsKey: row.hls_key,
|
||||
transcodeStatus: row.transcode_status as SiteSyncMediaTransfer['transcodeStatus'],
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface SiteSyncMediaTransfer {
|
||||
size: number
|
||||
uploadedAt: string
|
||||
mediaType: MediaType
|
||||
uploadSource?: 'web' | 'api'
|
||||
thumbKey: string | null
|
||||
hlsKey: string | null
|
||||
transcodeStatus: TranscodeStatus
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
parseLoginRateMax,
|
||||
parsePreserveOriginalUpload,
|
||||
parseUploadMaxFileSizeMb,
|
||||
parseUploadMaxFileSizeMbForKind,
|
||||
parseUploadRateIpMax,
|
||||
serializeAllowedMimeTypes,
|
||||
serializeAllowedVideoExtensions,
|
||||
@@ -18,10 +19,19 @@ import {
|
||||
describe('upload-policy parsers', () => {
|
||||
it('parses upload max file size mb', () => {
|
||||
expect(parseUploadMaxFileSizeMb('10')).toBe(10)
|
||||
expect(parseUploadMaxFileSizeMb('0')).toBeNull()
|
||||
expect(parseUploadMaxFileSizeMb('0')).toBe(0)
|
||||
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', () => {
|
||||
expect(normalizeAllowedMimeTypes('image/png, image/jpeg, image/png')).toEqual([
|
||||
'image/png',
|
||||
|
||||
+146
-28
@@ -10,6 +10,9 @@ import { getActiveBackendRow } from './storage/resolver'
|
||||
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_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_VIDEO_EXTENSIONS = 'allowed_video_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 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 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_TOKEN_MAX = 120
|
||||
@@ -78,6 +87,41 @@ export const FORBIDDEN_FILE_EXTENSION_OPTIONS = [
|
||||
}))
|
||||
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
return parsePositiveInt(raw, MIN_UPLOAD_RATE_MAX, MAX_UPLOAD_RATE_IP_MAX)
|
||||
}
|
||||
@@ -208,9 +263,36 @@ export function serializeForbiddenFileExtensions(extensions: readonly string[]):
|
||||
.join(',')
|
||||
}
|
||||
|
||||
function getUploadMaxFileSizeMbSource(): PolicySource {
|
||||
if (getSetting(SETTINGS_UPLOAD_MAX_FILE_SIZE_MB) !== null) return 'db'
|
||||
if (parseUploadMaxFileSizeMb(readEnv('UPLOAD_MAX_FILE_SIZE_MB')) !== null) return 'env'
|
||||
function getConfiguredUploadMaxFileSizeMbFromDb(kind: UploadMaxFileSizeKind): number | null {
|
||||
const config = uploadMaxFileSizeConfig[kind]
|
||||
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'
|
||||
}
|
||||
|
||||
@@ -270,23 +352,18 @@ function getPreserveOriginalUploadSource(): PolicySource {
|
||||
return 'default'
|
||||
}
|
||||
|
||||
export function getUploadMaxFileSizeMb(): number {
|
||||
const dbRaw = getSetting(SETTINGS_UPLOAD_MAX_FILE_SIZE_MB)
|
||||
if (dbRaw !== null) {
|
||||
const parsed = parseUploadMaxFileSizeMb(dbRaw)
|
||||
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 getUploadMaxFileSizeMb(kind: UploadMaxFileSizeKind = 'image'): number {
|
||||
return getConfiguredUploadMaxFileSizeMbFromDb(kind)
|
||||
?? getConfiguredUploadMaxFileSizeMbFromEnv(kind)
|
||||
?? uploadMaxFileSizeConfig[kind].defaultMb
|
||||
}
|
||||
|
||||
export function getMaxFileSizeBytes(): number {
|
||||
return getUploadMaxFileSizeMb() * 1024 * 1024
|
||||
export function getMaxFileSizeBytes(kind: UploadMaxFileSizeKind = 'image'): number {
|
||||
return getUploadMaxFileSizeMb(kind) * 1024 * 1024
|
||||
}
|
||||
|
||||
export function getEffectiveMaxFileSizeBytes(): number {
|
||||
const configured = getMaxFileSizeBytes()
|
||||
export function getEffectiveMaxFileSizeBytes(kind: UploadMaxFileSizeKind = 'image'): number {
|
||||
const configured = getMaxFileSizeBytes(kind)
|
||||
const row = getActiveBackendRow()
|
||||
if (row?.type === 'telegram') {
|
||||
return Math.min(configured, TELEGRAM_MAX_FILE_BYTES)
|
||||
@@ -294,6 +371,10 @@ export function getEffectiveMaxFileSizeBytes(): number {
|
||||
return configured
|
||||
}
|
||||
|
||||
export function getEffectiveUploadMaxFileSizeMb(kind: UploadMaxFileSizeKind): number {
|
||||
return Math.floor(getEffectiveMaxFileSizeBytes(kind) / (1024 * 1024))
|
||||
}
|
||||
|
||||
export function getAllowedMimeTypesList(): AllowedMimeType[] {
|
||||
const dbRaw = getSetting(SETTINGS_ALLOWED_MIME_TYPES)
|
||||
if (dbRaw !== null) {
|
||||
@@ -418,27 +499,48 @@ export function getLoginRateLimitSettings(): {
|
||||
}
|
||||
}
|
||||
|
||||
export function formatMaxFileSizeMessage(): string {
|
||||
return `图片大小不能超过 ${getUploadMaxFileSizeMb()} MB`
|
||||
export function formatMaxFileSizeMessage(kind: UploadMaxFileSizeKind = 'image'): string {
|
||||
return `${uploadMaxFileSizeConfig[kind].label}大小不能超过 ${getUploadMaxFileSizeMb(kind)} MB`
|
||||
}
|
||||
|
||||
export function formatEffectiveMaxFileSizeMessage(): string {
|
||||
export function formatEffectiveMaxFileSizeMessage(
|
||||
kind: UploadMaxFileSizeKind = 'image'
|
||||
): string {
|
||||
const row = getActiveBackendRow()
|
||||
if (row?.type === 'telegram') {
|
||||
const mb = TELEGRAM_MAX_FILE_BYTES / (1024 * 1024)
|
||||
const siteMb = getUploadMaxFileSizeMb()
|
||||
const siteMb = getUploadMaxFileSizeMb(kind)
|
||||
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() {
|
||||
return {
|
||||
uploadMaxFileSizeMb: getUploadMaxFileSizeMb(),
|
||||
uploadMaxFileSizeMbSource: getUploadMaxFileSizeMbSource(),
|
||||
uploadImageMaxFileSizeMb: getUploadMaxFileSizeMb('image'),
|
||||
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'))
|
||||
?? DEFAULT_UPLOAD_MAX_FILE_SIZE_MB,
|
||||
allowedMimeTypes: getAllowedMimeTypesList(),
|
||||
@@ -477,6 +579,9 @@ export function getUploadPolicySettingsPayload() {
|
||||
|
||||
export function setUploadPolicySettings(input: {
|
||||
uploadMaxFileSizeMb?: number
|
||||
uploadImageMaxFileSizeMb?: number
|
||||
uploadVideoMaxFileSizeMb?: number
|
||||
uploadFileMaxFileSizeMb?: number
|
||||
allowedMimeTypes?: readonly string[]
|
||||
allowedVideoExtensions?: readonly string[]
|
||||
forbiddenFileExtensions?: readonly string[]
|
||||
@@ -487,8 +592,21 @@ export function setUploadPolicySettings(input: {
|
||||
loginRateWindowMinutes?: number
|
||||
preserveOriginalUpload?: boolean
|
||||
}): void {
|
||||
if (input.uploadMaxFileSizeMb !== undefined) {
|
||||
setSetting(SETTINGS_UPLOAD_MAX_FILE_SIZE_MB, String(input.uploadMaxFileSizeMb))
|
||||
const imageMaxFileSizeMb = input.uploadImageMaxFileSizeMb ?? 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) {
|
||||
setSetting(SETTINGS_ALLOWED_MIME_TYPES, serializeAllowedMimeTypes(input.allowedMimeTypes))
|
||||
|
||||
Reference in new issue
Block a user