Files
FileHost/server/utils/upload-policy.ts
T
2026-10-17 16:09:31 +08:00

530 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
ALLOWED_MIME_TYPES,
FORBIDDEN_FILE_EXTENSIONS,
VIDEO_EXTENSIONS,
type VideoExtension,
type AllowedMimeType
} from './constants'
import { getSetting, setSetting } from './db'
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_ALLOWED_MIME_TYPES = 'allowed_mime_types'
export const SETTINGS_ALLOWED_VIDEO_EXTENSIONS = 'allowed_video_extensions'
export const SETTINGS_FORBIDDEN_FILE_EXTENSIONS = 'forbidden_file_extensions'
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 const ALL_VIDEO_EXTENSION_OPTIONS = VIDEO_EXTENSIONS.map(extension => ({
extension,
label: extension.toUpperCase()
}))
export const FORBIDDEN_FILE_EXTENSION_OPTIONS = [
'html',
'htm',
'xhtml',
'svg',
'xml',
'js',
'mjs',
'cjs',
'php',
'phtml',
'sh',
'bash',
'zsh',
'bat',
'cmd',
'ps1',
'exe',
'dll',
'msi',
'scr'
].map(extension => ({
extension,
label: extension.toUpperCase()
}))
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(',')
}
export function normalizeAllowedVideoExtensions(raw: string): VideoExtension[] {
const allowedSet = new Set<string>(VIDEO_EXTENSIONS)
const seen = new Set<string>()
const result: VideoExtension[] = []
for (const part of raw.split(',')) {
const extension = part.trim().toLowerCase().replace(/^\.+/, '')
if (!extension || seen.has(extension) || !allowedSet.has(extension)) continue
seen.add(extension)
result.push(extension as VideoExtension)
}
return result
}
export function serializeAllowedVideoExtensions(extensions: readonly string[]): string {
const normalized = new Set(normalizeAllowedVideoExtensions(extensions.join(',')))
return VIDEO_EXTENSIONS
.filter(extension => normalized.has(extension))
.join(',')
}
export function normalizeForbiddenFileExtensions(raw: string): string[] {
const seen = new Set<string>()
const result: string[] = []
for (const part of raw.split(',')) {
const extension = part
.trim()
.toLowerCase()
.replace(/^\.+/, '')
if (!extension || seen.has(extension)) continue
if (!/^[a-z0-9][a-z0-9_-]{0,15}$/.test(extension)) continue
seen.add(extension)
result.push(extension)
}
return result
}
export function serializeForbiddenFileExtensions(extensions: readonly string[]): string {
const configuredOrder = new Map(
FORBIDDEN_FILE_EXTENSION_OPTIONS.map((option, index) => [option.extension, index])
)
return normalizeForbiddenFileExtensions(extensions.join(','))
.sort((left, right) => {
const leftOrder = configuredOrder.get(left)
const rightOrder = configuredOrder.get(right)
if (leftOrder !== undefined || rightOrder !== undefined) {
return (leftOrder ?? Number.MAX_SAFE_INTEGER)
- (rightOrder ?? Number.MAX_SAFE_INTEGER)
}
return left.localeCompare(right)
})
.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 getAllowedVideoExtensionsSource(): PolicySource {
if (getSetting(SETTINGS_ALLOWED_VIDEO_EXTENSIONS) !== null) return 'db'
if (normalizeAllowedVideoExtensions(readEnv('ALLOWED_VIDEO_EXTENSIONS')).length > 0) {
return 'env'
}
return 'default'
}
function getForbiddenFileExtensionsSource(): PolicySource {
if (getSetting(SETTINGS_FORBIDDEN_FILE_EXTENSIONS) !== null) return 'db'
if (readEnv('FORBIDDEN_FILE_EXTENSIONS')) 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 getEffectiveMaxFileSizeBytes(): number {
const configured = getMaxFileSizeBytes()
const row = getActiveBackendRow()
if (row?.type === 'telegram') {
return Math.min(configured, TELEGRAM_MAX_FILE_BYTES)
}
return configured
}
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 getAllowedVideoExtensions(): VideoExtension[] {
const dbRaw = getSetting(SETTINGS_ALLOWED_VIDEO_EXTENSIONS)
if (dbRaw !== null) {
const parsed = normalizeAllowedVideoExtensions(dbRaw)
if (parsed.length > 0) return parsed
}
const fromEnv = normalizeAllowedVideoExtensions(readEnv('ALLOWED_VIDEO_EXTENSIONS'))
if (fromEnv.length > 0) return fromEnv
return [...VIDEO_EXTENSIONS]
}
export function getForbiddenFileExtensions(): string[] {
const dbRaw = getSetting(SETTINGS_FORBIDDEN_FILE_EXTENSIONS)
if (dbRaw !== null) return normalizeForbiddenFileExtensions(dbRaw)
const envRaw = readEnv('FORBIDDEN_FILE_EXTENSIONS')
if (envRaw) return normalizeForbiddenFileExtensions(envRaw)
return [...FORBIDDEN_FILE_EXTENSIONS]
}
export function isVideoExtensionAllowed(extension: string): boolean {
const normalized = extension.trim().toLowerCase().replace(/^\.+/, '')
return getAllowedVideoExtensions().includes(normalized as VideoExtension)
}
export function isForbiddenFileExtension(extension: string): boolean {
const normalized = extension.trim().toLowerCase().replace(/^\.+/, '')
return Boolean(normalized) && getForbiddenFileExtensions().includes(normalized)
}
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 formatEffectiveMaxFileSizeMessage(): string {
const row = getActiveBackendRow()
if (row?.type === 'telegram') {
const mb = TELEGRAM_MAX_FILE_BYTES / (1024 * 1024)
const siteMb = getUploadMaxFileSizeMb()
if (siteMb * 1024 * 1024 <= TELEGRAM_MAX_FILE_BYTES) {
return formatMaxFileSizeMessage()
}
return `图片大小不能超过 ${mb} MB(Telegram 存储上限)`
}
return formatMaxFileSizeMessage()
}
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')),
allowedVideoExtensions: getAllowedVideoExtensions(),
allowedVideoExtensionsSource: getAllowedVideoExtensionsSource(),
allowedVideoExtensionsEnvFallback: normalizeAllowedVideoExtensions(
readEnv('ALLOWED_VIDEO_EXTENSIONS')
),
forbiddenFileExtensions: getForbiddenFileExtensions(),
forbiddenFileExtensionsSource: getForbiddenFileExtensionsSource(),
forbiddenFileExtensionsEnvFallback: normalizeForbiddenFileExtensions(
readEnv('FORBIDDEN_FILE_EXTENSIONS')
),
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
})),
videoExtensionOptions: ALL_VIDEO_EXTENSION_OPTIONS,
forbiddenFileExtensionOptions: FORBIDDEN_FILE_EXTENSION_OPTIONS
}
}
export function setUploadPolicySettings(input: {
uploadMaxFileSizeMb?: number
allowedMimeTypes?: readonly string[]
allowedVideoExtensions?: readonly string[]
forbiddenFileExtensions?: 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.allowedVideoExtensions !== undefined) {
setSetting(
SETTINGS_ALLOWED_VIDEO_EXTENSIONS,
serializeAllowedVideoExtensions(input.allowedVideoExtensions)
)
}
if (input.forbiddenFileExtensions !== undefined) {
setSetting(
SETTINGS_FORBIDDEN_FILE_EXTENSIONS,
serializeForbiddenFileExtensions(input.forbiddenFileExtensions)
)
}
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)
)
}
}