diff --git a/.env.example b/.env.example index bb8e38b..a3a47bc 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,16 @@ # 服务端 WebP 压缩质量 1-100(默认 80;也可在设置页配置) # WEBP_QUALITY=80 +# 上传限制(也可在设置 → 访问控制配置;环境变量优先级更高) +# UPLOAD_MAX_FILE_SIZE_MB=10 +# 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 +# UPLOAD_RATE_WINDOW_MINUTES=15 +# LOGIN_RATE_MAX=10 +# LOGIN_RATE_WINDOW_MINUTES=15 +# PRESERVE_ORIGINAL_UPLOAD=false + # 自动删除超过 N 天的图片(0 表示关闭;也可在设置页配置) # AUTO_DELETE_DAYS=0 diff --git a/CHANGELOG.md b/CHANGELOG.md index de77244..a30c72e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ ## [Unreleased] +## [1.4.1] - 2026-09-16 + +### 新增 + +- **上传策略(设置 → 基础)**:单文件大小、允许格式、上传/登录限流、保留原图(关闭服务端 WebP 转换);支持 `.env` 覆盖 +- **上传 API**:可选 `tagNames`(名称不存在则创建,存在则复用);与 `tagIds` 可并用 + +### 改进 + +- 登录接口接入可配置的登录限流 +- 设置页将全站上传策略并入「上传偏好 / 图片处理」;访问控制保留登录限流 +- API 文档补充 `tagIds` / `tagNames` 参数说明 + ## [1.4.0] - 2026-09-09 ### 新增 diff --git a/app/components/settings/SettingsUploadPolicyFields.vue b/app/components/settings/SettingsUploadPolicyFields.vue new file mode 100644 index 0000000..8736344 --- /dev/null +++ b/app/components/settings/SettingsUploadPolicyFields.vue @@ -0,0 +1,329 @@ + + + diff --git a/app/components/settings/SettingsUserPreferencesFields.vue b/app/components/settings/SettingsUserPreferencesFields.vue index 76f7d6e..c1d2e62 100644 --- a/app/components/settings/SettingsUserPreferencesFields.vue +++ b/app/components/settings/SettingsUserPreferencesFields.vue @@ -1,4 +1,8 @@ diff --git a/app/composables/useApiDocs.ts b/app/composables/useApiDocs.ts index 7fb30d1..535d860 100644 --- a/app/composables/useApiDocs.ts +++ b/app/composables/useApiDocs.ts @@ -73,11 +73,26 @@ export function buildApiDocs(ctx: ApiDocsContext): ApiDocEndpoint[] { location: 'form-data', required: false, descriptionKey: 'api.params.upload.token' + }, + { + name: 'tagIds', + type: 'string', + location: 'form-data', + required: false, + descriptionKey: 'api.params.upload.tagIds' + }, + { + name: 'tagNames', + type: 'string', + location: 'form-data', + required: false, + descriptionKey: 'api.params.upload.tagNames' } ], curl: `curl -X POST "${baseUrl}/api/images/upload" \\ ${auth} \\ - -F "image=@./demo.png"`, + -F "image=@./demo.png" \\ + -F 'tagNames=["博客","截图"]'`, responseExample: `{ "success": true, "items": [ diff --git a/app/pages/settings/index.vue b/app/pages/settings/index.vue index ee7a43f..2e44899 100644 --- a/app/pages/settings/index.vue +++ b/app/pages/settings/index.vue @@ -3,10 +3,13 @@ import type { SettingsTab } from '~/types/settings' import { ADMIN_SETTINGS_TABS, USER_SETTINGS_TABS } from '~/types/settings' import { willActivateDomainSeparation } from '~/utils/domain-separation' import SettingsUserPreferencesFields from '~/components/settings/SettingsUserPreferencesFields.vue' +import SettingsUploadPolicyFields from '~/components/settings/SettingsUploadPolicyFields.vue' type SettingSource = 'env' | 'db' | 'none' type WebpQualitySource = 'env' | 'db' | 'default' +type PolicySource = 'env' | 'db' | 'default' type LoginVerificationMethod = 'slider' | 'turnstile' | 'cap' +type MimeType = 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif' | 'image/svg+xml' | 'image/x-icon' interface SettingsResponse { apiUploadToken: string @@ -42,6 +45,23 @@ interface SettingsResponse { capApiEndpoint: string capSecret: string appVersion: string + uploadMaxFileSizeMb: number + uploadMaxFileSizeMbSource: PolicySource + allowedMimeTypes: MimeType[] + allowedMimeTypesSource: PolicySource + uploadRateIpMax: number + uploadRateIpMaxSource: PolicySource + uploadRateTokenMax: number + uploadRateTokenMaxSource: PolicySource + uploadRateWindowMinutes: number + uploadRateWindowMinutesSource: PolicySource + loginRateMax: number + loginRateMaxSource: PolicySource + loginRateWindowMinutes: number + loginRateWindowMinutesSource: PolicySource + preserveOriginalUpload: boolean + preserveOriginalUploadSource: PolicySource + mimeTypeOptions: ReadonlyArray<{ mime: MimeType, label: string }> } const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth() @@ -143,6 +163,15 @@ const disableDomainSeparationOpen = ref(false) const pendingDomainSeparationDisable = ref(false) const domainSeparationSaveConfirmOpen = ref(false) +const uploadMaxFileSizeMbDraft = ref(10) +const allowedMimeTypesDraft = ref([]) +const uploadRateIpMaxDraft = ref(60) +const uploadRateTokenMaxDraft = ref(120) +const uploadRateWindowMinutesDraft = ref(15) +const loginRateMaxDraft = ref(10) +const loginRateWindowMinutesDraft = ref(15) +const preserveOriginalUploadDraft = ref(false) + interface ReleaseCheckResponse { currentVersion: string latestVersion: string | null @@ -152,6 +181,10 @@ interface ReleaseCheckResponse { const releaseCheck = ref(null) +function mimeListsEqual(left: readonly string[], right: readonly string[]): boolean { + return left.slice().sort().join(',') === right.slice().sort().join(',') +} + const hasServerChanges = computed(() => { if (!settings.value) return false return refererDraft.value !== settings.value.allowedRefererHosts @@ -166,6 +199,14 @@ const hasServerChanges = computed(() => { || turnstileSecretKeyDraft.value !== settings.value.turnstileSecretKey || capApiEndpointDraft.value !== settings.value.capApiEndpoint || capSecretDraft.value !== settings.value.capSecret + || uploadMaxFileSizeMbDraft.value !== settings.value.uploadMaxFileSizeMb + || !mimeListsEqual(allowedMimeTypesDraft.value, settings.value.allowedMimeTypes) + || uploadRateIpMaxDraft.value !== settings.value.uploadRateIpMax + || uploadRateTokenMaxDraft.value !== settings.value.uploadRateTokenMax + || uploadRateWindowMinutesDraft.value !== settings.value.uploadRateWindowMinutes + || loginRateMaxDraft.value !== settings.value.loginRateMax + || loginRateWindowMinutesDraft.value !== settings.value.loginRateWindowMinutes + || preserveOriginalUploadDraft.value !== settings.value.preserveOriginalUpload }) const domainSeparationWouldActivate = computed(() => @@ -221,6 +262,14 @@ function applySettings(data: SettingsResponse) { capApiEndpointDraft.value = data.capApiEndpoint capSecretDraft.value = data.capSecret domainSeparationDraft.value = data.domainSeparation + uploadMaxFileSizeMbDraft.value = data.uploadMaxFileSizeMb + allowedMimeTypesDraft.value = [...data.allowedMimeTypes] + uploadRateIpMaxDraft.value = data.uploadRateIpMax + uploadRateTokenMaxDraft.value = data.uploadRateTokenMax + uploadRateWindowMinutesDraft.value = data.uploadRateWindowMinutes + loginRateMaxDraft.value = data.loginRateMax + loginRateWindowMinutesDraft.value = data.loginRateWindowMinutes + preserveOriginalUploadDraft.value = data.preserveOriginalUpload } async function patchSettings(body: Record) { @@ -296,6 +345,11 @@ async function performSaveServerSettings() { savingServer.value = true try { + if (allowedMimeTypesDraft.value.length === 0) { + toast.add({ title: t('settings.allowedMimeTypesHint'), color: 'error' }) + return + } + await patchSettings({ allowedRefererHosts: refererDraft.value, domainSeparation: domainSeparationDraft.value, @@ -308,7 +362,15 @@ async function performSaveServerSettings() { turnstileSiteKey: turnstileSiteKeyDraft.value, turnstileSecretKey: turnstileSecretKeyDraft.value, capApiEndpoint: capApiEndpointDraft.value, - capSecret: capSecretDraft.value + capSecret: capSecretDraft.value, + uploadMaxFileSizeMb: uploadMaxFileSizeMbDraft.value, + allowedMimeTypes: allowedMimeTypesDraft.value, + uploadRateIpMax: uploadRateIpMaxDraft.value, + uploadRateTokenMax: uploadRateTokenMaxDraft.value, + uploadRateWindowMinutes: uploadRateWindowMinutesDraft.value, + loginRateMax: loginRateMaxDraft.value, + loginRateWindowMinutes: loginRateWindowMinutesDraft.value, + preserveOriginalUpload: preserveOriginalUploadDraft.value }) toast.add({ title: t('settings.saved'), color: 'success' }) } catch (error: unknown) { @@ -492,7 +554,42 @@ watch(isAdmin, () => { - + + + + + + + + + + + + + @@ -897,6 +994,21 @@ watch(isAdmin, () => { + + + + + +
() -const MAX_ATTEMPTS = 10 -const WINDOW_MS = 15 * 60 * 1000 - -function checkRateLimit(ip: string): void { - const now = Date.now() - const entry = loginAttempts.get(ip) - if (!entry || now > entry.resetAt) { - loginAttempts.set(ip, { count: 1, resetAt: now + WINDOW_MS }) - return - } - entry.count += 1 - if (entry.count > MAX_ATTEMPTS) { - throw createError({ - statusCode: 429, - statusMessage: '登录尝试过于频繁,请稍后再试' - }) - } -} - export default defineEventHandler(async (event) => { const ip = clientIp(event) - checkRateLimit(ip) + checkLoginRateLimit(ip) const body = await readBody(event) await verifyLoginVerification(event, body ?? {}) diff --git a/server/api/images/upload.post.ts b/server/api/images/upload.post.ts index 20277e9..84ab447 100644 --- a/server/api/images/upload.post.ts +++ b/server/api/images/upload.post.ts @@ -12,7 +12,12 @@ import { } from '../../utils/process-image-upload' import { createApiError } from '../../utils/api-error' import { checkUploadRateLimit } from '../../utils/rate-limit' -import { parseTagIdsParam } from '../../utils/tags' +import { readMultipartFormFieldValues } from '../../utils/multipart-form' +import { + parseTagIdsParam, + parseTagNamesParam, + resolveUploadTagIds +} from '../../utils/tags' export default defineEventHandler(async (event) => { await requireUploadAuth(event) @@ -31,24 +36,15 @@ export default defineEventHandler(async (event) => { const source = verifyApiUploadToken(event) ? 'api' : 'web' const uploadUserId = await getUploadUserId(event) - const tagIdsField = formData.find(part => part.name === 'tagIds') - let uploadTagIds: number[] = [] - if (tagIdsField?.data?.length) { - const raw = new TextDecoder().decode(tagIdsField.data).trim() - try { - const parsed = JSON.parse(raw) as unknown - uploadTagIds = parseTagIdsParam(parsed) - } catch { - uploadTagIds = parseTagIdsParam(raw) - } - } else { - const repeatFields = formData.filter(part => part.name === 'tagIds' && part.data?.length) - if (repeatFields.length) { - uploadTagIds = parseTagIdsParam( - repeatFields.map(part => new TextDecoder().decode(part.data!).trim()) - ) - } + const tagIdsRaw = readMultipartFormFieldValues(formData, 'tagIds') + const tagNamesRaw = readMultipartFormFieldValues(formData, 'tagNames') + const uploadTagIdsInput = parseTagIdsParam(tagIdsRaw.length === 1 ? tagIdsRaw[0] : tagIdsRaw) + const uploadTagNames = parseTagNamesParam(tagNamesRaw.length === 1 ? tagNamesRaw[0] : tagNamesRaw) + const resolvedTags = resolveUploadTagIds(uploadUserId, uploadTagIdsInput, uploadTagNames) + if ('message' in resolvedTags) { + createApiError(event, 'INVALID_REQUEST', resolvedTags.message, 400) } + const uploadTagIds = resolvedTags.tagIds // 兼容 image / file / files 字段名(图床脚本 / 通用客户端 / 网页多图) const fileParts = formData.filter( diff --git a/server/api/settings/index.patch.ts b/server/api/settings/index.patch.ts index c501cb9..cc275ed 100644 --- a/server/api/settings/index.patch.ts +++ b/server/api/settings/index.patch.ts @@ -34,6 +34,16 @@ import { validateLoginVerificationSettings, validateSettingsDomainPatch } from '../../utils/env' +import { + normalizeAllowedMimeTypes, + parseLoginRateMax, + parseLoginRateWindowMinutes, + parseUploadMaxFileSizeMb, + parseUploadRateIpMax, + parseUploadRateTokenMax, + parseUploadRateWindowMinutes, + setUploadPolicySettings +} from '../../utils/upload-policy' interface SettingsPatchBody { webpQuality?: unknown @@ -50,6 +60,14 @@ interface SettingsPatchBody { turnstileSecretKey?: unknown capApiEndpoint?: unknown capSecret?: unknown + uploadMaxFileSizeMb?: unknown + allowedMimeTypes?: unknown + uploadRateIpMax?: unknown + uploadRateTokenMax?: unknown + uploadRateWindowMinutes?: unknown + loginRateMax?: unknown + loginRateWindowMinutes?: unknown + preserveOriginalUpload?: unknown } export default defineEventHandler(async (event) => { @@ -249,6 +267,72 @@ export default defineEventHandler(async (event) => { setSetting(SETTINGS_CAP_SECRET, nextCapSecret) } + if (body.uploadMaxFileSizeMb !== undefined) { + const mb = parseUploadMaxFileSizeMb(String(body.uploadMaxFileSizeMb)) + if (mb === null) { + createApiError(event, 'INVALID_REQUEST', '单文件大小需为 1–100 MB 的整数', 400) + } + setUploadPolicySettings({ uploadMaxFileSizeMb: mb }) + } + + if (body.allowedMimeTypes !== undefined) { + const raw = Array.isArray(body.allowedMimeTypes) + ? body.allowedMimeTypes.map(item => String(item)) + : String(body.allowedMimeTypes).split(',') + const normalized = normalizeAllowedMimeTypes(raw.join(',')) + if (normalized.length === 0) { + createApiError(event, 'INVALID_REQUEST', '至少选择一种允许上传的图片格式', 400) + } + setUploadPolicySettings({ allowedMimeTypes: normalized }) + } + + if (body.uploadRateIpMax !== undefined) { + const value = parseUploadRateIpMax(String(body.uploadRateIpMax)) + if (value === null) { + createApiError(event, 'INVALID_REQUEST', '上传 IP 限流次数无效', 400) + } + setUploadPolicySettings({ uploadRateIpMax: value }) + } + + if (body.uploadRateTokenMax !== undefined) { + const value = parseUploadRateTokenMax(String(body.uploadRateTokenMax)) + if (value === null) { + createApiError(event, 'INVALID_REQUEST', '上传 Token 限流次数无效', 400) + } + setUploadPolicySettings({ uploadRateTokenMax: value }) + } + + if (body.uploadRateWindowMinutes !== undefined) { + const value = parseUploadRateWindowMinutes(String(body.uploadRateWindowMinutes)) + if (value === null) { + createApiError(event, 'INVALID_REQUEST', '上传限流窗口无效', 400) + } + setUploadPolicySettings({ uploadRateWindowMinutes: value }) + } + + if (body.loginRateMax !== undefined) { + const value = parseLoginRateMax(String(body.loginRateMax)) + if (value === null) { + createApiError(event, 'INVALID_REQUEST', '登录限流次数无效', 400) + } + setUploadPolicySettings({ loginRateMax: value }) + } + + if (body.loginRateWindowMinutes !== undefined) { + const value = parseLoginRateWindowMinutes(String(body.loginRateWindowMinutes)) + if (value === null) { + createApiError(event, 'INVALID_REQUEST', '登录限流窗口无效', 400) + } + setUploadPolicySettings({ loginRateWindowMinutes: value }) + } + + if (body.preserveOriginalUpload !== undefined) { + if (typeof body.preserveOriginalUpload !== 'boolean') { + createApiError(event, 'INVALID_REQUEST', '保留原图设置无效', 400) + } + setUploadPolicySettings({ preserveOriginalUpload: body.preserveOriginalUpload }) + } + logActivity(event, { action: 'settings', key: 'settings/system', diff --git a/server/utils/env.ts b/server/utils/env.ts index 883af01..6335549 100644 --- a/server/utils/env.ts +++ b/server/utils/env.ts @@ -18,6 +18,7 @@ import { SETTINGS_CAP_SECRET, isAllowRegistration } from './db' +import { getUploadPolicySettingsPayload } from './upload-policy' export function getRuntimeEnv( event: H3Event, @@ -694,6 +695,7 @@ export function getSettingsPayload(event: H3Event) { capApiEndpoint: getCapApiEndpointConfigured(event), capSecret: getCapSecret(event), appVersion, + ...getUploadPolicySettingsPayload(), env: { webpQuality, refererConfigured: allowedRefererHosts.length > 0, diff --git a/server/utils/image-query.ts b/server/utils/image-query.ts index 35c5428..d230c8c 100644 --- a/server/utils/image-query.ts +++ b/server/utils/image-query.ts @@ -1,5 +1,5 @@ import { getStorageBackendRow } from './storage-backends' -import { ALLOWED_MIME_TYPES } from './constants' +import { getAllowedMimeTypesList } from './upload-policy' import { parseTagIdsParam, type TagFilterInput } from './tags' export function readBackendIdQuery( @@ -15,7 +15,7 @@ export function readContentTypeQuery( ): string | undefined | null { const raw = typeof query.contentType === 'string' ? query.contentType.trim() : '' if (!raw || raw === 'all') return undefined - if (!(ALLOWED_MIME_TYPES as readonly string[]).includes(raw)) return null + if (!(getAllowedMimeTypesList() as readonly string[]).includes(raw)) return null return raw } diff --git a/server/utils/multipart-form.ts b/server/utils/multipart-form.ts new file mode 100644 index 0000000..79a5757 --- /dev/null +++ b/server/utils/multipart-form.ts @@ -0,0 +1,30 @@ +import type { MultiPartData } from 'h3' + +export function readMultipartFormFieldValues( + formData: MultiPartData[], + fieldName: string +): string[] { + const field = formData.find(part => part.name === fieldName) + if (field?.data?.length) { + const raw = new TextDecoder().decode(field.data).trim() + if (!raw) return [] + try { + const parsed = JSON.parse(raw) as unknown + if (Array.isArray(parsed)) { + return parsed.map(item => String(item).trim()).filter(Boolean) + } + } catch { + // fall through to comma-separated parsing + } + return raw.split(',').map(item => item.trim()).filter(Boolean) + } + + const repeatFields = formData.filter( + part => part.name === fieldName && part.data?.length + ) + if (!repeatFields.length) return [] + + return repeatFields + .map(part => new TextDecoder().decode(part.data!).trim()) + .filter(Boolean) +} diff --git a/server/utils/process-image-upload.ts b/server/utils/process-image-upload.ts index fef0e13..c3c50b5 100644 --- a/server/utils/process-image-upload.ts +++ b/server/utils/process-image-upload.ts @@ -2,10 +2,6 @@ import sharp from 'sharp' import type { H3Event } from 'h3' import type { ImageItem, UploadErrorItem } from '~/types/image' import type { AllowedMimeType } from './constants' -import { - ALLOWED_MIME_TYPES, - MAX_FILE_SIZE -} from './constants' import { detectMimeFromSignature, isAllowedMimeType, @@ -15,6 +11,12 @@ import type { LogSource } from './db' import { generateImageKey } from './image-key' import { buildImageItem, sanitizeOriginalName } from './image-response' import { getStorageLayout, getWebpQuality } from './env' +import { + formatMaxFileSizeMessage, + getMaxFileSizeBytes, + isMimeTypeAllowed, + isPreserveOriginalUpload +} from './upload-policy' import { logActivity } from './activity-log' import { attachTagsAfterUpload, getTagsForImageKeys } from './tags' import { putImage } from './storage' @@ -71,13 +73,13 @@ export async function processSingleImageUpload( const originalName = sanitizeOriginalName(input.filename ?? 'image') const bytes = input.bytes - if (bytes.byteLength > MAX_FILE_SIZE) { + if (bytes.byteLength > getMaxFileSizeBytes()) { return { error: { name: originalName, error: { code: 'FILE_TOO_LARGE', - message: '图片大小不能超过 10 MB' + message: formatMaxFileSizeMessage() } } } @@ -96,7 +98,7 @@ export async function processSingleImageUpload( } } - if (!ALLOWED_MIME_TYPES.includes(detectedMime)) { + if (!isMimeTypeAllowed(detectedMime)) { return { error: { name: originalName, @@ -120,7 +122,9 @@ export async function processSingleImageUpload( } } - const compressed = await compressToWebp(bytes, detectedMime, getWebpQuality(event)) + const compressed = isPreserveOriginalUpload() + ? { bytes, mime: detectedMime } + : await compressToWebp(bytes, detectedMime, getWebpQuality(event)) const uploadedAt = new Date().toISOString() const layout = getStorageLayout(event) diff --git a/server/utils/rate-limit.test.ts b/server/utils/rate-limit.test.ts index be06817..aeacf6b 100644 --- a/server/utils/rate-limit.test.ts +++ b/server/utils/rate-limit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { checkUploadRateLimit } from './rate-limit' +import { getUploadRateLimitSettings } from './upload-policy' vi.mock('./logger', () => ({ clientIp: (event: { __ip?: string }) => event.__ip ?? '127.0.0.1' @@ -27,7 +28,8 @@ describe('checkUploadRateLimit', () => { it('limits repeated uploads by IP', () => { const event = mockEvent('10.0.0.3') - for (let i = 0; i < 60; i++) { + const { ipMax } = getUploadRateLimitSettings() + for (let i = 0; i < ipMax; i++) { checkUploadRateLimit(event) } expect(() => checkUploadRateLimit(event)).toThrow() @@ -35,7 +37,8 @@ describe('checkUploadRateLimit', () => { it('limits repeated API token uploads', () => { const token = 'token-a' - for (let i = 0; i < 120; i++) { + const { tokenMax } = getUploadRateLimitSettings() + for (let i = 0; i < tokenMax; i++) { checkUploadRateLimit(mockEvent(`10.0.${i % 200}`), token) } expect(() => checkUploadRateLimit(mockEvent('10.0.250'), token)).toThrow() diff --git a/server/utils/rate-limit.ts b/server/utils/rate-limit.ts index db14e66..8305dcc 100644 --- a/server/utils/rate-limit.ts +++ b/server/utils/rate-limit.ts @@ -1,10 +1,7 @@ import { createHash } from 'node:crypto' import { createError, getHeader, type H3Event } from 'h3' import { clientIp } from './logger' - -const UPLOAD_IP_MAX = 60 -const UPLOAD_TOKEN_MAX = 120 -const UPLOAD_WINDOW_MS = 15 * 60 * 1000 +import { getUploadRateLimitSettings, getLoginRateLimitSettings } from './upload-policy' const stores = new Map>() @@ -43,12 +40,13 @@ function assertRateLimit( } export function checkUploadRateLimit(event: H3Event, apiToken?: string): void { + const { ipMax, tokenMax, windowMs } = getUploadRateLimitSettings() const ip = clientIp(event) assertRateLimit( 'upload-ip', ip, - UPLOAD_IP_MAX, - UPLOAD_WINDOW_MS, + ipMax, + windowMs, '上传过于频繁,请稍后再试' ) @@ -60,8 +58,27 @@ export function checkUploadRateLimit(event: H3Event, apiToken?: string): void { assertRateLimit( 'upload-token', hash, - UPLOAD_TOKEN_MAX, - UPLOAD_WINDOW_MS, + tokenMax, + windowMs, '上传过于频繁,请稍后再试' ) } + +const loginAttempts = new Map() + +export function checkLoginRateLimit(ip: string): void { + const { max, windowMs } = getLoginRateLimitSettings() + const now = Date.now() + const entry = loginAttempts.get(ip) + if (!entry || now > entry.resetAt) { + loginAttempts.set(ip, { count: 1, resetAt: now + windowMs }) + return + } + entry.count += 1 + if (entry.count > max) { + throw createError({ + statusCode: 429, + statusMessage: '登录尝试过于频繁,请稍后再试' + }) + } +} diff --git a/server/utils/tags.test.ts b/server/utils/tags.test.ts index 9e52fe9..06c8466 100644 --- a/server/utils/tags.test.ts +++ b/server/utils/tags.test.ts @@ -4,7 +4,9 @@ import { isValidTagColor, isValidTagName, normalizeTagName, - parseTagIdsParam + parseTagIdsParam, + parseTagNamesParam, + resolveUploadTagIds } from './tags' describe('normalizeTagName', () => { @@ -51,6 +53,33 @@ describe('parseTagIdsParam', () => { }) }) +describe('parseTagNamesParam', () => { + it('parses comma-separated and json array values', () => { + expect(parseTagNamesParam('博客, 截图')).toEqual(['博客', '截图']) + expect(parseTagNamesParam('["工作","截图"]')).toEqual(['工作', '截图']) + expect(parseTagNamesParam(['A', 'B'])).toEqual(['A', 'B']) + }) + + it('deduplicates normalized names', () => { + expect(parseTagNamesParam('博客, 博客 ,截图')).toEqual(['博客', '截图']) + }) +}) + +describe('resolveUploadTagIds', () => { + it('returns empty when no tags requested', () => { + expect(resolveUploadTagIds(1, [], [])).toEqual({ tagIds: [] }) + }) + + it('requires user when tags are requested', () => { + expect(resolveUploadTagIds(null, [1], [])).toMatchObject({ + message: expect.stringContaining('API Token') + }) + expect(resolveUploadTagIds(null, [], ['博客'])).toMatchObject({ + message: expect.stringContaining('API Token') + }) + }) +}) + describe('buildTagFilterSql', () => { it('returns empty sql without filter', () => { const params: Array = [] diff --git a/server/utils/tags.ts b/server/utils/tags.ts index 9877214..f7ef3de 100644 --- a/server/utils/tags.ts +++ b/server/utils/tags.ts @@ -471,3 +471,91 @@ export function parseTagIdsParam(raw: unknown): number[] { } return [...new Set(ids)] } + +export function parseTagNamesParam(raw: unknown): string[] { + if (raw == null || raw === '') return [] + + let parts: string[] + if (Array.isArray(raw)) { + parts = raw.flatMap(item => String(item).split(',')) + } else { + const str = String(raw).trim() + if (str.startsWith('[')) { + try { + const parsed = JSON.parse(str) as unknown + parts = Array.isArray(parsed) + ? parsed.map(item => String(item)) + : str.split(',') + } catch { + parts = str.split(',') + } + } else { + parts = str.split(',') + } + } + + const names: string[] = [] + for (const part of parts) { + const normalized = normalizeTagName(part) + if (normalized) names.push(normalized) + } + return [...new Set(names)] +} + +export function findOrCreateTagByName(userId: number, name: string): TagRow { + const normalized = normalizeTagName(name) + if (!isValidTagName(normalized)) { + throw new Error('INVALID_TAG_NAME') + } + + const existing = findTagByName(userId, normalized) + if (existing) return existing + + const created = createTag(userId, normalized) + const row = getTagById(created.id) + if (!row) { + throw new Error('TAG_CREATE_FAILED') + } + return row +} + +export function resolveTagIdsFromNames(userId: number, names: readonly string[]): number[] { + const ids: number[] = [] + for (const name of names) { + ids.push(findOrCreateTagByName(userId, name).id) + } + return [...new Set(ids)] +} + +export function resolveUploadTagIds( + userId: number | null, + tagIds: number[], + tagNames: string[] +): { tagIds: number[] } | { message: string } { + if (!tagIds.length && !tagNames.length) { + return { tagIds: [] } + } + + if (userId == null) { + return { + message: '打标签需要登录或使用绑定用户的 API Token' + } + } + + try { + const fromNames = tagNames.length ? resolveTagIdsFromNames(userId, tagNames) : [] + return { tagIds: [...new Set([...tagIds, ...fromNames])] } + } catch (error) { + if (error instanceof Error) { + switch (error.message) { + case 'INVALID_TAG_NAME': + return { message: '标签名称无效' } + case 'TAG_LIMIT_REACHED': + return { message: '标签数量已达上限' } + default: + break + } + } + throw error + } +} diff --git a/server/utils/upload-policy.test.ts b/server/utils/upload-policy.test.ts new file mode 100644 index 0000000..689f842 --- /dev/null +++ b/server/utils/upload-policy.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' + +import { + DEFAULT_UPLOAD_MAX_FILE_SIZE_MB, + normalizeAllowedMimeTypes, + parseLoginRateMax, + parsePreserveOriginalUpload, + parseUploadMaxFileSizeMb, + parseUploadRateIpMax, + serializeAllowedMimeTypes, + serializePreserveOriginalUpload +} from './upload-policy' + +describe('upload-policy parsers', () => { + it('parses upload max file size mb', () => { + expect(parseUploadMaxFileSizeMb('10')).toBe(10) + expect(parseUploadMaxFileSizeMb('0')).toBeNull() + expect(parseUploadMaxFileSizeMb('101')).toBeNull() + }) + + it('normalizes allowed mime types', () => { + expect(normalizeAllowedMimeTypes('image/png, image/jpeg, image/png')).toEqual([ + 'image/png', + 'image/jpeg' + ]) + expect(normalizeAllowedMimeTypes('image/bmp')).toEqual([]) + }) + + it('serializes allowed mime types in canonical order', () => { + expect(serializeAllowedMimeTypes(['image/webp', 'image/jpeg'])).toBe('image/jpeg,image/webp') + }) + + it('parses rate limit values', () => { + expect(parseUploadRateIpMax('60')).toBe(60) + expect(parseLoginRateMax('10')).toBe(10) + expect(parseUploadRateIpMax('0')).toBeNull() + }) + + it('defaults max file size mb constant matches legacy 10MB', () => { + expect(DEFAULT_UPLOAD_MAX_FILE_SIZE_MB).toBe(10) + }) + + it('parses preserve original upload flag', () => { + expect(parsePreserveOriginalUpload('true')).toBe(true) + expect(parsePreserveOriginalUpload('0')).toBe(false) + expect(parsePreserveOriginalUpload('')).toBeNull() + expect(serializePreserveOriginalUpload(true)).toBe('true') + }) +}) diff --git a/server/utils/upload-policy.ts b/server/utils/upload-policy.ts new file mode 100644 index 0000000..0dabd82 --- /dev/null +++ b/server/utils/upload-policy.ts @@ -0,0 +1,347 @@ +import { + ALLOWED_MIME_TYPES, + type AllowedMimeType +} from './constants' +import { getSetting, setSetting } from './db' + +export const SETTINGS_UPLOAD_MAX_FILE_SIZE_MB = 'upload_max_file_size_mb' +export const SETTINGS_ALLOWED_MIME_TYPES = 'allowed_mime_types' +export const SETTINGS_UPLOAD_RATE_IP_MAX = 'upload_rate_ip_max' +export const SETTINGS_UPLOAD_RATE_TOKEN_MAX = 'upload_rate_token_max' +export const SETTINGS_UPLOAD_RATE_WINDOW_MINUTES = 'upload_rate_window_minutes' +export const SETTINGS_LOGIN_RATE_MAX = 'login_rate_max' +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 MAX_UPLOAD_MAX_FILE_SIZE_MB = 100 + +export const DEFAULT_UPLOAD_RATE_IP_MAX = 60 +export const DEFAULT_UPLOAD_RATE_TOKEN_MAX = 120 +export const DEFAULT_UPLOAD_RATE_WINDOW_MINUTES = 15 +export const MIN_UPLOAD_RATE_MAX = 1 +export const MAX_UPLOAD_RATE_IP_MAX = 1000 +export const MAX_UPLOAD_RATE_TOKEN_MAX = 5000 +export const MIN_RATE_WINDOW_MINUTES = 1 +export const MAX_RATE_WINDOW_MINUTES = 1440 + +export const DEFAULT_LOGIN_RATE_MAX = 10 +export const DEFAULT_LOGIN_RATE_WINDOW_MINUTES = 15 +export const MAX_LOGIN_RATE_MAX = 100 + +export const ALL_MIME_TYPE_OPTIONS = [ + { mime: 'image/jpeg', label: 'JPEG' }, + { mime: 'image/png', label: 'PNG' }, + { mime: 'image/webp', label: 'WebP' }, + { mime: 'image/gif', label: 'GIF' }, + { mime: 'image/svg+xml', label: 'SVG' }, + { mime: 'image/x-icon', label: 'ICO' } +] as const satisfies ReadonlyArray<{ mime: AllowedMimeType, label: string }> + +export type PolicySource = 'env' | 'db' | 'default' + +function readEnv(name: string): string { + try { + return process.env[name]?.trim() ?? '' + } catch { + return '' + } +} + +function parsePositiveInt(raw: string, min: number, max: number): number | null { + const trimmed = raw.trim() + if (!trimmed) return null + const value = Number(trimmed) + if (!Number.isFinite(value) || !Number.isInteger(value)) return null + if (value < min || value > max) return null + return value +} + +export function parseUploadMaxFileSizeMb(raw: string): number | null { + return parsePositiveInt(raw, MIN_UPLOAD_MAX_FILE_SIZE_MB, MAX_UPLOAD_MAX_FILE_SIZE_MB) +} + +export function parseUploadRateIpMax(raw: string): number | null { + return parsePositiveInt(raw, MIN_UPLOAD_RATE_MAX, MAX_UPLOAD_RATE_IP_MAX) +} + +export function parseUploadRateTokenMax(raw: string): number | null { + return parsePositiveInt(raw, MIN_UPLOAD_RATE_MAX, MAX_UPLOAD_RATE_TOKEN_MAX) +} + +export function parseUploadRateWindowMinutes(raw: string): number | null { + return parsePositiveInt(raw, MIN_RATE_WINDOW_MINUTES, MAX_RATE_WINDOW_MINUTES) +} + +export function parseLoginRateMax(raw: string): number | null { + return parsePositiveInt(raw, MIN_UPLOAD_RATE_MAX, MAX_LOGIN_RATE_MAX) +} + +export function parseLoginRateWindowMinutes(raw: string): number | null { + return parsePositiveInt(raw, MIN_RATE_WINDOW_MINUTES, MAX_RATE_WINDOW_MINUTES) +} + +export function parsePreserveOriginalUpload(raw: string): boolean | null { + const trimmed = raw.trim().toLowerCase() + if (!trimmed) return null + if (trimmed === 'true' || trimmed === '1' || trimmed === 'yes') return true + if (trimmed === 'false' || trimmed === '0' || trimmed === 'no') return false + return null +} + +export function serializePreserveOriginalUpload(value: boolean): string { + return value ? 'true' : 'false' +} + +export function normalizeAllowedMimeTypes(raw: string): AllowedMimeType[] { + const allowedSet = new Set(ALLOWED_MIME_TYPES) + const seen = new Set() + const result: AllowedMimeType[] = [] + + for (const part of raw.split(',')) { + const mime = part.trim().toLowerCase() + if (!mime || seen.has(mime) || !allowedSet.has(mime)) continue + seen.add(mime) + result.push(mime as AllowedMimeType) + } + + return result +} + +export function serializeAllowedMimeTypes(mimes: readonly string[]): string { + const normalized = new Set(normalizeAllowedMimeTypes(mimes.join(','))) + return ALL_MIME_TYPE_OPTIONS + .map(option => option.mime) + .filter(mime => normalized.has(mime)) + .join(',') +} + +function getUploadMaxFileSizeMbSource(): PolicySource { + if (getSetting(SETTINGS_UPLOAD_MAX_FILE_SIZE_MB) !== null) return 'db' + if (parseUploadMaxFileSizeMb(readEnv('UPLOAD_MAX_FILE_SIZE_MB')) !== null) return 'env' + return 'default' +} + +function getAllowedMimeTypesSource(): PolicySource { + if (getSetting(SETTINGS_ALLOWED_MIME_TYPES) !== null) return 'db' + if (normalizeAllowedMimeTypes(readEnv('ALLOWED_MIME_TYPES')).length > 0) return 'env' + return 'default' +} + +function getUploadRateIpMaxSource(): PolicySource { + if (getSetting(SETTINGS_UPLOAD_RATE_IP_MAX) !== null) return 'db' + if (parseUploadRateIpMax(readEnv('UPLOAD_RATE_IP_MAX')) !== null) return 'env' + return 'default' +} + +function getUploadRateTokenMaxSource(): PolicySource { + if (getSetting(SETTINGS_UPLOAD_RATE_TOKEN_MAX) !== null) return 'db' + if (parseUploadRateTokenMax(readEnv('UPLOAD_RATE_TOKEN_MAX')) !== null) return 'env' + return 'default' +} + +function getUploadRateWindowMinutesSource(): PolicySource { + if (getSetting(SETTINGS_UPLOAD_RATE_WINDOW_MINUTES) !== null) return 'db' + if (parseUploadRateWindowMinutes(readEnv('UPLOAD_RATE_WINDOW_MINUTES')) !== null) return 'env' + return 'default' +} + +function getLoginRateMaxSource(): PolicySource { + if (getSetting(SETTINGS_LOGIN_RATE_MAX) !== null) return 'db' + if (parseLoginRateMax(readEnv('LOGIN_RATE_MAX')) !== null) return 'env' + return 'default' +} + +function getLoginRateWindowMinutesSource(): PolicySource { + if (getSetting(SETTINGS_LOGIN_RATE_WINDOW_MINUTES) !== null) return 'db' + if (parseLoginRateWindowMinutes(readEnv('LOGIN_RATE_WINDOW_MINUTES')) !== null) return 'env' + return 'default' +} + +function getPreserveOriginalUploadSource(): PolicySource { + if (getSetting(SETTINGS_PRESERVE_ORIGINAL_UPLOAD) !== null) return 'db' + if (parsePreserveOriginalUpload(readEnv('PRESERVE_ORIGINAL_UPLOAD')) !== null) return 'env' + 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 getMaxFileSizeBytes(): number { + return getUploadMaxFileSizeMb() * 1024 * 1024 +} + +export function getAllowedMimeTypesList(): AllowedMimeType[] { + const dbRaw = getSetting(SETTINGS_ALLOWED_MIME_TYPES) + if (dbRaw !== null) { + const parsed = normalizeAllowedMimeTypes(dbRaw) + if (parsed.length > 0) return parsed + } + const fromEnv = normalizeAllowedMimeTypes(readEnv('ALLOWED_MIME_TYPES')) + if (fromEnv.length > 0) return fromEnv + return [...ALLOWED_MIME_TYPES] +} + +export function isMimeTypeAllowed(mime: string): mime is AllowedMimeType { + return getAllowedMimeTypesList().includes(mime as AllowedMimeType) +} + +export function isPreserveOriginalUpload(): boolean { + const dbRaw = getSetting(SETTINGS_PRESERVE_ORIGINAL_UPLOAD) + if (dbRaw !== null) { + const parsed = parsePreserveOriginalUpload(dbRaw) + if (parsed !== null) return parsed + } + const fromEnv = parsePreserveOriginalUpload(readEnv('PRESERVE_ORIGINAL_UPLOAD')) + if (fromEnv !== null) return fromEnv + return false +} + +export function getUploadRateLimitSettings(): { + ipMax: number + tokenMax: number + windowMs: number +} { + const ipMax = (() => { + const dbRaw = getSetting(SETTINGS_UPLOAD_RATE_IP_MAX) + if (dbRaw !== null) { + const parsed = parseUploadRateIpMax(dbRaw) + if (parsed !== null) return parsed + } + return parseUploadRateIpMax(readEnv('UPLOAD_RATE_IP_MAX')) ?? DEFAULT_UPLOAD_RATE_IP_MAX + })() + + const tokenMax = (() => { + const dbRaw = getSetting(SETTINGS_UPLOAD_RATE_TOKEN_MAX) + if (dbRaw !== null) { + const parsed = parseUploadRateTokenMax(dbRaw) + if (parsed !== null) return parsed + } + return parseUploadRateTokenMax(readEnv('UPLOAD_RATE_TOKEN_MAX')) ?? DEFAULT_UPLOAD_RATE_TOKEN_MAX + })() + + const windowMinutes = (() => { + const dbRaw = getSetting(SETTINGS_UPLOAD_RATE_WINDOW_MINUTES) + if (dbRaw !== null) { + const parsed = parseUploadRateWindowMinutes(dbRaw) + if (parsed !== null) return parsed + } + return parseUploadRateWindowMinutes(readEnv('UPLOAD_RATE_WINDOW_MINUTES')) + ?? DEFAULT_UPLOAD_RATE_WINDOW_MINUTES + })() + + return { + ipMax, + tokenMax, + windowMs: windowMinutes * 60 * 1000 + } +} + +export function getLoginRateLimitSettings(): { + max: number + windowMs: number +} { + const max = (() => { + const dbRaw = getSetting(SETTINGS_LOGIN_RATE_MAX) + if (dbRaw !== null) { + const parsed = parseLoginRateMax(dbRaw) + if (parsed !== null) return parsed + } + return parseLoginRateMax(readEnv('LOGIN_RATE_MAX')) ?? DEFAULT_LOGIN_RATE_MAX + })() + + const windowMinutes = (() => { + const dbRaw = getSetting(SETTINGS_LOGIN_RATE_WINDOW_MINUTES) + if (dbRaw !== null) { + const parsed = parseLoginRateWindowMinutes(dbRaw) + if (parsed !== null) return parsed + } + return parseLoginRateWindowMinutes(readEnv('LOGIN_RATE_WINDOW_MINUTES')) + ?? DEFAULT_LOGIN_RATE_WINDOW_MINUTES + })() + + return { + max, + windowMs: windowMinutes * 60 * 1000 + } +} + +export function formatMaxFileSizeMessage(): string { + return `图片大小不能超过 ${getUploadMaxFileSizeMb()} MB` +} + +export function getUploadPolicySettingsPayload() { + return { + uploadMaxFileSizeMb: getUploadMaxFileSizeMb(), + uploadMaxFileSizeMbSource: getUploadMaxFileSizeMbSource(), + uploadMaxFileSizeMbEnvFallback: parseUploadMaxFileSizeMb(readEnv('UPLOAD_MAX_FILE_SIZE_MB')) + ?? DEFAULT_UPLOAD_MAX_FILE_SIZE_MB, + allowedMimeTypes: getAllowedMimeTypesList(), + allowedMimeTypesSource: getAllowedMimeTypesSource(), + allowedMimeTypesEnvFallback: normalizeAllowedMimeTypes(readEnv('ALLOWED_MIME_TYPES')), + uploadRateIpMax: getUploadRateLimitSettings().ipMax, + uploadRateIpMaxSource: getUploadRateIpMaxSource(), + uploadRateTokenMax: getUploadRateLimitSettings().tokenMax, + uploadRateTokenMaxSource: getUploadRateTokenMaxSource(), + uploadRateWindowMinutes: getUploadRateLimitSettings().windowMs / (60 * 1000), + uploadRateWindowMinutesSource: getUploadRateWindowMinutesSource(), + loginRateMax: getLoginRateLimitSettings().max, + loginRateMaxSource: getLoginRateMaxSource(), + loginRateWindowMinutes: getLoginRateLimitSettings().windowMs / (60 * 1000), + loginRateWindowMinutesSource: getLoginRateWindowMinutesSource(), + preserveOriginalUpload: isPreserveOriginalUpload(), + preserveOriginalUploadSource: getPreserveOriginalUploadSource(), + mimeTypeOptions: ALL_MIME_TYPE_OPTIONS.map(option => ({ + mime: option.mime, + label: option.label + })) + } +} + +export function setUploadPolicySettings(input: { + uploadMaxFileSizeMb?: number + allowedMimeTypes?: readonly string[] + uploadRateIpMax?: number + uploadRateTokenMax?: number + uploadRateWindowMinutes?: number + loginRateMax?: number + loginRateWindowMinutes?: number + preserveOriginalUpload?: boolean +}): void { + if (input.uploadMaxFileSizeMb !== undefined) { + setSetting(SETTINGS_UPLOAD_MAX_FILE_SIZE_MB, String(input.uploadMaxFileSizeMb)) + } + if (input.allowedMimeTypes !== undefined) { + setSetting(SETTINGS_ALLOWED_MIME_TYPES, serializeAllowedMimeTypes(input.allowedMimeTypes)) + } + if (input.uploadRateIpMax !== undefined) { + setSetting(SETTINGS_UPLOAD_RATE_IP_MAX, String(input.uploadRateIpMax)) + } + if (input.uploadRateTokenMax !== undefined) { + setSetting(SETTINGS_UPLOAD_RATE_TOKEN_MAX, String(input.uploadRateTokenMax)) + } + if (input.uploadRateWindowMinutes !== undefined) { + setSetting(SETTINGS_UPLOAD_RATE_WINDOW_MINUTES, String(input.uploadRateWindowMinutes)) + } + if (input.loginRateMax !== undefined) { + setSetting(SETTINGS_LOGIN_RATE_MAX, String(input.loginRateMax)) + } + if (input.loginRateWindowMinutes !== undefined) { + setSetting(SETTINGS_LOGIN_RATE_WINDOW_MINUTES, String(input.loginRateWindowMinutes)) + } + if (input.preserveOriginalUpload !== undefined) { + setSetting( + SETTINGS_PRESERVE_ORIGINAL_UPLOAD, + serializePreserveOriginalUpload(input.preserveOriginalUpload) + ) + } +}