diff --git a/.env.example b/.env.example
index a3a47bc..5f00fc5 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/app/assets/css/main.css b/app/assets/css/main.css
index eea00f0..519e45a 100644
--- a/app/assets/css/main.css
+++ b/app/assets/css/main.css
@@ -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 {
diff --git a/app/components/FileCard.vue b/app/components/FileCard.vue
index 8546c78..992adcb 100644
--- a/app/components/FileCard.vue
+++ b/app/components/FileCard.vue
@@ -74,16 +74,7 @@ function fileIcon(item: FileItem): string {
diff --git a/app/components/FileLibraryPanel.vue b/app/components/FileLibraryPanel.vue
index eabc5f5..ecc7fdf 100644
--- a/app/components/FileLibraryPanel.vue
+++ b/app/components/FileLibraryPanel.vue
@@ -633,20 +633,11 @@ defineExpose({ refresh })
{{ uploadedLabel(item) }}
-
-
-
- {{ t('tags.unlabeled') }}
+
|
diff --git a/app/components/MediaUploadPanel.vue b/app/components/MediaUploadPanel.vue
index 1359bc2..d4195df 100644
--- a/app/components/MediaUploadPanel.vue
+++ b/app/components/MediaUploadPanel.vue
@@ -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 })
}
})
diff --git a/app/components/TagAssignPanel.vue b/app/components/TagAssignPanel.vue
index c997972..acc6c91 100644
--- a/app/components/TagAssignPanel.vue
+++ b/app/components/TagAssignPanel.vue
@@ -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()
diff --git a/app/components/TagBadgeList.vue b/app/components/TagBadgeList.vue
new file mode 100644
index 0000000..1a2ff67
--- /dev/null
+++ b/app/components/TagBadgeList.vue
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ emptyLabel }}
+
+
diff --git a/app/components/UploadFlipCard.vue b/app/components/UploadFlipCard.vue
index 9bb0074..3799fd3 100644
--- a/app/components/UploadFlipCard.vue
+++ b/app/components/UploadFlipCard.vue
@@ -43,7 +43,7 @@ function onBack() {
diff --git a/app/components/UploadPreferencesPanel.vue b/app/components/UploadPreferencesPanel.vue
index df9eab0..b25abc3 100644
--- a/app/components/UploadPreferencesPanel.vue
+++ b/app/components/UploadPreferencesPanel.vue
@@ -1,5 +1,8 @@
@@ -113,12 +160,12 @@ onMounted(() => {
{{ t('preferences.processing') }}
@@ -179,7 +226,8 @@ onMounted(() => {
{{ t('preferences.videoProcessing') }}
@@ -217,7 +265,8 @@ onMounted(() => {
{{ t('preferences.webpQuality') }}
@@ -239,7 +288,10 @@ onMounted(() => {
>
-
+
{{ t('preferences.convenience') }}
@@ -271,19 +323,57 @@ onMounted(() => {
/>
-
-
-
- {{ t('preferences.uploadTags') }}
-
-
- {{ t('preferences.uploadTagsHint') }}
-
-
+
+
+
+ {{ t('preferences.uploadTags') }}
+
+
+ {{ t('preferences.uploadTagsHint') }}
+
+
+
+
+
+ {{ t('preferences.uploadTagsPending', { n: uploadTagNames.length }) }}
+
+
+
+ {{ name }}
+
+
+
+
+
diff --git a/app/components/VideoCard.vue b/app/components/VideoCard.vue
index f7730bc..fc11d8c 100644
--- a/app/components/VideoCard.vue
+++ b/app/components/VideoCard.vue
@@ -109,16 +109,7 @@ const uploadedLabel = computed(() => {
{{ uploadedLabel }} · {{ formatFileSize(item.size) }}
-
-
-
+
diff --git a/app/components/VideoLibraryPanel.vue b/app/components/VideoLibraryPanel.vue
index e6c9e49..0ff0cdb 100644
--- a/app/components/VideoLibraryPanel.vue
+++ b/app/components/VideoLibraryPanel.vue
@@ -659,20 +659,11 @@ defineExpose({ refresh })
{{ uploadedLabel(item) }}
|
-
-
-
- {{ t('tags.unlabeled') }}
+
|
diff --git a/app/components/settings/SettingsUploadPolicyFields.vue b/app/components/settings/SettingsUploadPolicyFields.vue
index 34ffa87..eff7eee 100644
--- a/app/components/settings/SettingsUploadPolicyFields.vue
+++ b/app/components/settings/SettingsUploadPolicyFields.vue
@@ -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 ('uploadMaxFileSizeMb')
+const uploadImageMaxFileSizeMbDraft = defineModel('uploadImageMaxFileSizeMb')
+const uploadVideoMaxFileSizeMbDraft = defineModel('uploadVideoMaxFileSizeMb')
+const uploadFileMaxFileSizeMbDraft = defineModel('uploadFileMaxFileSizeMb')
const allowedMimeTypesDraft = defineModel('allowedMimeTypes')
const allowedVideoExtensionsDraft = defineModel('allowedVideoExtensions')
const forbiddenFileExtensionsDraft = defineModel('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)
}
-
-
-
- {{ t('settings.uploadMaxFileSize') }}
-
-
- {{ sourceBadge(uploadMaxFileSizeMbSource).label }}
-
-
-
- {{ t('settings.uploadMaxFileSizeHint') }}
-
+
-
-
-
+
+ {{ field.title }}
+
+
- MB
+ >
+ {{ sourceBadge(field.source).label }}
+
+
+
+ {{ field.hint }}
+
+
diff --git a/app/composables/useImageUpload.ts b/app/composables/useImageUpload.ts
index 681422d..caf43ab 100644
--- a/app/composables/useImageUpload.ts
+++ b/app/composables/useImageUpload.ts
@@ -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 ([])
const lastUploadResult = ref(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('/api/images/upload', {
diff --git a/app/composables/useMediaUpload.ts b/app/composables/useMediaUpload.ts
index 45cf31c..03bc5ed 100644
--- a/app/composables/useMediaUpload.ts
+++ b/app/composables/useMediaUpload.ts
@@ -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([])
@@ -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 => ({
diff --git a/app/composables/useUploadPolicy.ts b/app/composables/useUploadPolicy.ts
index 3cb054b..1f5b571 100644
--- a/app/composables/useUploadPolicy.ts
+++ b/app/composables/useUploadPolicy.ts
@@ -2,6 +2,9 @@ interface UploadPolicy {
allowedMimeTypes: string[]
allowedVideoExtensions: string[]
forbiddenFileExtensions: string[]
+ imageMaxFileSizeMb: number
+ videoMaxFileSizeMb: number
+ fileMaxFileSizeMb: number
}
const policy = ref(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
})
diff --git a/app/composables/useUploadPreferences.ts b/app/composables/useUploadPreferences.ts
index ab66516..d69117c 100644
--- a/app/composables/useUploadPreferences.ts
+++ b/app/composables/useUploadPreferences.ts
@@ -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(`upload-pref-copy-format-${kind}`, () => DEFAULTS.copyFormat)
const clientWebpQuality = useState(`upload-pref-webp-quality-${kind}`, () => DEFAULTS.clientWebpQuality)
- const lastTagIds = useState(`upload-pref-last-tag-ids-${kind}`, () => DEFAULTS.lastTagIds)
+ const uploadTagNames = useState(
+ `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,
diff --git a/app/pages/index.vue b/app/pages/index.vue
index 217f383..ff0eedf 100644
--- a/app/pages/index.vue
+++ b/app/pages/index.vue
@@ -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([])
const deletingKeys = ref>(new Set())
const emptySelection = ref(new Set())
@@ -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[]) {
diff --git a/app/pages/settings/index.vue b/app/pages/settings/index.vue
index 2937b82..be66981 100644
--- a/app/pages/settings/index.vue
+++ b/app/pages/settings/index.vue
@@ -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([])
const allowedVideoExtensionsDraft = ref([])
const forbiddenFileExtensionsDraft = ref([])
@@ -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, () => {
>
{
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"
diff --git a/i18n/locales/en.json b/i18n/locales/en.json
index d41623c..55beb1f 100644
--- a/i18n/locales/en.json
+++ b/i18n/locales/en.json
@@ -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"
}
},
diff --git a/i18n/locales/zh-CN.json b/i18n/locales/zh-CN.json
index 17841b3..2636752 100644
--- a/i18n/locales/zh-CN.json
+++ b/i18n/locales/zh-CN.json
@@ -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": "还没有视频,上传一个试试"
}
},
diff --git a/server/api/settings/index.patch.ts b/server/api/settings/index.patch.ts
index 071faf1..244ed25 100644
--- a/server/api/settings/index.patch.ts
+++ b/server/api/settings/index.patch.ts
@@ -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))
diff --git a/server/api/upload-policy.get.ts b/server/api/upload-policy.get.ts
index d8015ad..9eee728 100644
--- a/server/api/upload-policy.get.ts
+++ b/server/api/upload-policy.get.ts
@@ -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')
}
})
diff --git a/server/utils/file-bed.ts b/server/utils/file-bed.ts
index f3280b8..5a82c93 100644
--- a/server/utils/file-bed.ts
+++ b/server/utils/file-bed.ts
@@ -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')
}
}
}
diff --git a/server/utils/site-sync/apply.ts b/server/utils/site-sync/apply.ts
index d9a811d..5dd5b00 100644
--- a/server/utils/site-sync/apply.ts
+++ b/server/utils/site-sync/apply.ts
@@ -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, {
diff --git a/server/utils/site-sync/manifest.ts b/server/utils/site-sync/manifest.ts
index 9972c91..64aac55 100644
--- a/server/utils/site-sync/manifest.ts
+++ b/server/utils/site-sync/manifest.ts
@@ -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'],
diff --git a/server/utils/site-sync/protocol.ts b/server/utils/site-sync/protocol.ts
index ef65656..2322907 100644
--- a/server/utils/site-sync/protocol.ts
+++ b/server/utils/site-sync/protocol.ts
@@ -13,6 +13,7 @@ export interface SiteSyncMediaTransfer {
size: number
uploadedAt: string
mediaType: MediaType
+ uploadSource?: 'web' | 'api'
thumbKey: string | null
hlsKey: string | null
transcodeStatus: TranscodeStatus
diff --git a/server/utils/upload-policy.test.ts b/server/utils/upload-policy.test.ts
index 5f33877..e42be51 100644
--- a/server/utils/upload-policy.test.ts
+++ b/server/utils/upload-policy.test.ts
@@ -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',
diff --git a/server/utils/upload-policy.ts b/server/utils/upload-policy.ts
index ac0df48..474a76b 100644
--- a/server/utils/upload-policy.ts
+++ b/server/utils/upload-policy.ts
@@ -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 = {
+ 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))
|