release: v1.4.1 — 上传策略设置、保留原图与上传 tagNames
GitHub Release / release (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s

管理员可在设置页配置上传大小、格式、限流与保留原图;上传 API 支持按标签名称打标。
This commit is contained in:
O96u committed 2026-09-16 10:03:05 +08:00
1 parent d0eb364645
commit 6cc4f27f7d
25 files changed
+1354 -172

No files matched your search

+2 -21
View File
@@ -14,6 +14,7 @@ import { getAdminSecret } from '../../utils/env'
import { verifyLoginVerification, type VerificationBody } from '../../utils/login-verification'
import { logActivity } from '../../utils/activity-log'
import { clientIp, logInfo, logWarn } from '../../utils/logger'
import { checkLoginRateLimit } from '../../utils/rate-limit'
interface LoginBody extends VerificationBody {
username?: string
@@ -21,29 +22,9 @@ interface LoginBody extends VerificationBody {
secret?: string
}
const loginAttempts = new Map<string, { count: number, resetAt: number }>()
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<LoginBody>(event)
await verifyLoginVerification(event, body ?? {})
+14 -18
View File
@@ -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(
+84
View File
@@ -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',
+2
View File
@@ -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,
+2 -2
View File
@@ -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
}
+30
View File
@@ -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)
}
+12 -8
View File
@@ -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)
+5 -2
View File
@@ -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()
+25 -8
View File
@@ -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<string, Map<string, { count: number, resetAt: number }>>()
@@ -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<string, { count: number, resetAt: number }>()
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: '登录尝试过于频繁,请稍后再试'
})
}
}
+30 -1
View File
@@ -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<string | number> = []
+88
View File
@@ -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
}
}
+49
View File
@@ -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')
})
})
+347
View File
@@ -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<string>(ALLOWED_MIME_TYPES)
const seen = new Set<string>()
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)
)
}
}