Files
2026-06-03 14:15:55 +08:00

142 lines
4.0 KiB
JavaScript

const crypto = require('crypto')
const KEYWORD_MAX = 200
const URL_MAX = 2048
const BASE64_MAX = 5 * 1024 * 1024
const BANNED_PATTERNS = [
/ignore\s+(all\s+)?(previous|prior|above)\s+(instructions?|prompts?)/i,
/system\s*(prompt|message|role)/i,
/disregard/i,
/forget\s+(everything|all)/i,
/act\s+as\s+(a\s+)?(developer|admin|root)/i,
/reveal\s+(your|the)\s+(prompt|system|secret|key)/i,
/api[_-]?key/i,
/password|secret/i
]
function sanitizeKeyword(input) {
if (input === undefined || input === null) return ''
const text = String(input).trim()
if (text.length === 0) return ''
if (text.length > KEYWORD_MAX) {
return { error: `关键词不能超过 ${KEYWORD_MAX} 个字符` }
}
for (const re of BANNED_PATTERNS) {
if (re.test(text)) return { error: '关键词包含非法内容' }
}
return { value: text }
}
function sanitizeImageUrl(input) {
if (!input) return { value: '' }
const text = String(input).trim()
if (text.length === 0) return { value: '' }
if (text.length > URL_MAX) return { error: '图片 URL 过长' }
if (/^https?:\/\//i.test(text)) {
let parsed
try {
parsed = new URL(text)
} catch (e) {
return { error: '图片 URL 格式不合法' }
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return { error: '图片 URL 协议必须是 http/https' }
}
const host = parsed.hostname
if (!host || host.length === 0) return { error: '图片 URL 缺少主机' }
if (host.includes('\u0000') || /[\s<>"'`\\]/.test(host)) {
return { error: '图片 URL 主机名非法' }
}
const labels = host.split('.')
for (const label of labels) {
if (label.length === 0 || label.length > 63) return { error: '图片 URL 主机名非法' }
if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(label)) {
return { error: '图片 URL 主机名非法' }
}
}
if (text.includes('..') || text.includes('\u0000')) return { error: '图片 URL 格式不合法' }
} else if (!text.startsWith('/uploads/')) {
return { error: '图片 URL 必须是 https/http 链接或 /uploads/ 相对路径' }
}
return { value: text }
}
function sanitizeImageBase64(input) {
if (!input) return { error: '缺少图片数据' }
const text = String(input)
if (text.length > BASE64_MAX) return { error: `图片不能超过 ${(BASE64_MAX / 1024 / 1024).toFixed(1)}MB` }
if (!/^[A-Za-z0-9+/=\r\n]+$/.test(text)) return { error: '图片 base64 格式不合法' }
return { value: text }
}
function makeCacheKey(prefix, payload) {
const json = JSON.stringify(payload, Object.keys(payload).sort())
return prefix + ':' + crypto.createHash('sha1').update(json).digest('hex')
}
class LRU {
constructor(max = 100, ttlMs = 5 * 60 * 1000) {
this.max = max
this.ttlMs = ttlMs
this.map = new Map()
}
get(key) {
const item = this.map.get(key)
if (!item) return undefined
if (Date.now() > item.expire) {
this.map.delete(key)
return undefined
}
this.map.delete(key)
this.map.set(key, item)
return item.value
}
set(key, value) {
if (this.map.has(key)) this.map.delete(key)
this.map.set(key, { value, expire: Date.now() + this.ttlMs })
if (this.map.size > this.max) {
const first = this.map.keys().next().value
this.map.delete(first)
}
}
}
class TokenBucket {
constructor(capacity, refillPerSec) {
this.capacity = capacity
this.tokens = capacity
this.refillPerSec = refillPerSec
this.lastRefill = Date.now()
this.queue = []
}
take() {
this._refill()
if (this.tokens >= 1) {
this.tokens -= 1
return true
}
return false
}
_refill() {
const now = Date.now()
const elapsed = (now - this.lastRefill) / 1000
if (elapsed > 0) {
this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillPerSec)
this.lastRefill = now
}
}
}
module.exports = {
KEYWORD_MAX,
URL_MAX,
BASE64_MAX,
sanitizeKeyword,
sanitizeImageUrl,
sanitizeImageBase64,
makeCacheKey,
LRU,
TokenBucket
}