'use strict'; const crypto = require('crypto'); const net = require('net'); const database = require('../config/database'); const API_KEY_TABLE = 'external_api_keys'; const API_KEY_META_TABLE = 'external_api_key_meta'; const SELECTION_FISSION_SCOPE = 'selection_fission'; const LEGACY_MIGRATION_KEY = 'selection_fission_env_keys_migrated_v1'; const MIN_API_KEY_LENGTH = 16; const MAX_API_KEY_LENGTH = 500; let schemaReady = false; let schemaPromise = null; function normalizeText(value, maxLength = 255) { return String(value ?? '').trim().slice(0, maxLength); } function getLegacyEnvironmentKeys() { return [ process.env.SELECTION_FISSION_PUBLIC_API_KEY, process.env.SELECTION_FISSION_PUBLIC_API_KEYS, process.env.EXTERNAL_SELECTION_FISSION_API_KEY, process.env.EXTERNAL_SELECTION_FISSION_API_KEYS, ] .filter(Boolean) .flatMap((value) => String(value).split(',')) .map((value) => value.trim()) .filter(Boolean); } function hashApiKey(apiKey) { return crypto.createHash('sha256').update(String(apiKey || ''), 'utf8').digest('hex'); } function generateApiKey() { return `sf_live_${crypto.randomBytes(32).toString('base64url')}`; } function buildKeyPreview(apiKey) { const normalized = normalizeText(apiKey, MAX_API_KEY_LENGTH); return { keyPrefix: normalized.slice(0, 10), keySuffix: normalized.slice(-4), }; } function maskApiKey(keyPrefix, keySuffix) { const prefix = normalizeText(keyPrefix, 20); const suffix = normalizeText(keySuffix, 8); return prefix && suffix ? `${prefix}••••••••${suffix}` : '••••••••••••'; } function normalizeApiKeyInput(value, { required = false } = {}) { const apiKey = normalizeText(value, MAX_API_KEY_LENGTH); if (!apiKey && !required) return ''; if (!apiKey) { const error = new Error('API Key 不能为空'); error.status = 400; throw error; } if (apiKey.length < MIN_API_KEY_LENGTH) { const error = new Error(`API Key 长度不能少于 ${MIN_API_KEY_LENGTH} 位`); error.status = 400; throw error; } return apiKey; } function normalizeExpiresAt(value) { if (value === undefined) return undefined; if (value === null || value === '') return null; const date = new Date(value); if (Number.isNaN(date.getTime())) { const error = new Error('过期时间格式不正确'); error.status = 400; throw error; } return date; } function getActor(user = {}) { return { id: normalizeText(user.sub || user.id || user.userId, 120) || null, name: normalizeText(user.name || user.username || user.userName || user.email, 120) || null, }; } function normalizeAllowedIps(value) { const entries = Array.isArray(value) ? value : String(value || '').split(/[\s,;]+/); const normalized = entries .map((entry) => normalizeText(entry, 80).replace(/^::ffff:/i, '')) .filter(Boolean); for (const ip of normalized) { if (!net.isIP(ip)) { const error = new Error(`IP 白名单包含无效地址:${ip}`); error.status = 400; throw error; } } return Array.from(new Set(normalized)); } function mapApiKeyRow(row = {}) { const expiresAt = row.expires_at || null; const allowedIps = normalizeAllowedIps(row.allowed_ips || ''); return { id: Number(row.id), name: row.name || '', serviceScope: row.service_scope || SELECTION_FISSION_SCOPE, serviceName: '自动上架与预售对外 API', keyMasked: maskApiKey(row.key_prefix, row.key_suffix), keyPrefix: row.key_prefix || '', status: row.status || 'disabled', enabled: row.status === 'active', description: row.description || '', allowedIps, hmacRequired: Number(row.hmac_required || 0) === 1, expiresAt, expired: Boolean(expiresAt && new Date(expiresAt).getTime() <= Date.now()), lastUsedAt: row.last_used_at || null, createdBy: row.created_by_name || row.created_by_user_id || '', updatedBy: row.updated_by_name || row.updated_by_user_id || '', createdAt: row.created_at || null, updatedAt: row.updated_at || null, }; } async function migrateLegacyEnvironmentKeys() { const markers = await database.local.query( `SELECT setting_value FROM ${API_KEY_META_TABLE} WHERE setting_key = ? LIMIT 1`, [LEGACY_MIGRATION_KEY] ); if (markers[0]) return; const legacyKeys = Array.from(new Set(getLegacyEnvironmentKeys())); for (let index = 0; index < legacyKeys.length; index += 1) { const apiKey = normalizeText(legacyKeys[index], MAX_API_KEY_LENGTH); if (!apiKey) continue; const preview = buildKeyPreview(apiKey); await database.local.query( `INSERT IGNORE INTO ${API_KEY_TABLE} ( service_scope, name, key_hash, key_prefix, key_suffix, status, description, created_by_name, updated_by_name ) VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?)`, [ SELECTION_FISSION_SCOPE, legacyKeys.length > 1 ? `原统一 Key ${index + 1}` : '原统一 Key', hashApiKey(apiKey), preview.keyPrefix, preview.keySuffix, '由服务器环境变量自动迁移', '系统迁移', '系统迁移', ] ); } await database.local.query( `INSERT INTO ${API_KEY_META_TABLE} (setting_key, setting_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_at = CURRENT_TIMESTAMP`, [LEGACY_MIGRATION_KEY, new Date().toISOString()] ); } async function ensureApiKeySchema() { if (schemaReady) return; if (schemaPromise) { await schemaPromise; return; } schemaPromise = (async () => { await database.local.query( `CREATE TABLE IF NOT EXISTS ${API_KEY_TABLE} ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', service_scope VARCHAR(80) NOT NULL DEFAULT 'selection_fission' COMMENT 'API用途', name VARCHAR(120) NOT NULL COMMENT 'Key名称', key_hash CHAR(64) NOT NULL COMMENT 'SHA-256摘要', key_prefix VARCHAR(20) NOT NULL COMMENT '掩码前缀', key_suffix VARCHAR(8) NOT NULL COMMENT '掩码后缀', status ENUM('active', 'disabled') NOT NULL DEFAULT 'active' COMMENT '状态', description VARCHAR(500) NULL COMMENT '备注', expires_at DATETIME NULL COMMENT '过期时间', last_used_at DATETIME NULL COMMENT '最近使用时间', created_by_user_id VARCHAR(120) NULL COMMENT '创建人ID', created_by_name VARCHAR(120) NULL COMMENT '创建人', updated_by_user_id VARCHAR(120) NULL COMMENT '更新人ID', updated_by_name VARCHAR(120) NULL COMMENT '更新人', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', UNIQUE KEY uk_external_api_key_hash (key_hash), KEY idx_external_api_key_scope_status (service_scope, status), KEY idx_external_api_key_updated (updated_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='第三方 API Key'` ); const apiKeyColumns = await database.local.query(`SHOW COLUMNS FROM ${API_KEY_TABLE}`); const existingColumns = new Set(apiKeyColumns.map((column) => column.Field)); if (!existingColumns.has('allowed_ips')) { await database.local.query( `ALTER TABLE ${API_KEY_TABLE} ADD COLUMN allowed_ips VARCHAR(2000) NULL COMMENT '允许访问的IP白名单' AFTER description` ); } if (!existingColumns.has('hmac_required')) { await database.local.query( `ALTER TABLE ${API_KEY_TABLE} ADD COLUMN hmac_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否强制HMAC签名' AFTER allowed_ips` ); } await database.local.query( `CREATE TABLE IF NOT EXISTS ${API_KEY_META_TABLE} ( setting_key VARCHAR(120) PRIMARY KEY COMMENT '配置键', setting_value VARCHAR(500) NULL COMMENT '配置值', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='第三方 API Key 元数据'` ); await migrateLegacyEnvironmentKeys(); schemaReady = true; })(); try { await schemaPromise; } finally { schemaPromise = null; } } async function getApiKeyById(id) { const normalizedId = Number.parseInt(id, 10); if (!Number.isFinite(normalizedId) || normalizedId <= 0) { const error = new Error('API Key ID 不正确'); error.status = 400; throw error; } const rows = await database.local.query( `SELECT * FROM ${API_KEY_TABLE} WHERE id = ? AND service_scope = ? LIMIT 1`, [normalizedId, SELECTION_FISSION_SCOPE] ); if (!rows[0]) { const error = new Error('API Key 不存在'); error.status = 404; throw error; } return rows[0]; } async function listSelectionFissionApiKeys() { await ensureApiKeySchema(); const rows = await database.local.query( `SELECT * FROM ${API_KEY_TABLE} WHERE service_scope = ? ORDER BY status = 'active' DESC, updated_at DESC, id DESC`, [SELECTION_FISSION_SCOPE] ); return rows.map(mapApiKeyRow); } async function createSelectionFissionApiKey(input = {}, user = {}) { await ensureApiKeySchema(); const name = normalizeText(input.name, 120); if (!name) { const error = new Error('请输入 API Key 名称'); error.status = 400; throw error; } const plainApiKey = input.apiKey ? normalizeApiKeyInput(input.apiKey, { required: true }) : generateApiKey(); const preview = buildKeyPreview(plainApiKey); const actor = getActor(user); const status = input.enabled === false || input.status === 'disabled' ? 'disabled' : 'active'; const expiresAt = normalizeExpiresAt(input.expiresAt); try { const result = await database.local.insert( `INSERT INTO ${API_KEY_TABLE} ( service_scope, name, key_hash, key_prefix, key_suffix, status, description, allowed_ips, hmac_required, expires_at, created_by_user_id, created_by_name, updated_by_user_id, updated_by_name ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ SELECTION_FISSION_SCOPE, name, hashApiKey(plainApiKey), preview.keyPrefix, preview.keySuffix, status, normalizeText(input.description, 500) || null, normalizeAllowedIps(input.allowedIps || input.allowed_ips).join(',') || null, input.hmacRequired === true || input.hmac_required === true ? 1 : 0, expiresAt ?? null, actor.id, actor.name, actor.id, actor.name, ] ); const row = await getApiKeyById(result.insertId); return { ...mapApiKeyRow(row), plainApiKey, }; } catch (error) { if (error?.code === 'ER_DUP_ENTRY') { const conflict = new Error('该 API Key 已存在'); conflict.status = 409; throw conflict; } throw error; } } async function updateSelectionFissionApiKey(id, input = {}, user = {}) { await ensureApiKeySchema(); const existing = await getApiKeyById(id); const actor = getActor(user); const updates = []; const params = []; let plainApiKey = null; if (Object.prototype.hasOwnProperty.call(input, 'name')) { const name = normalizeText(input.name, 120); if (!name) { const error = new Error('请输入 API Key 名称'); error.status = 400; throw error; } updates.push('name = ?'); params.push(name); } if (Object.prototype.hasOwnProperty.call(input, 'description')) { updates.push('description = ?'); params.push(normalizeText(input.description, 500) || null); } if ( Object.prototype.hasOwnProperty.call(input, 'allowedIps') || Object.prototype.hasOwnProperty.call(input, 'allowed_ips') ) { updates.push('allowed_ips = ?'); params.push(normalizeAllowedIps(input.allowedIps || input.allowed_ips).join(',') || null); } if ( Object.prototype.hasOwnProperty.call(input, 'hmacRequired') || Object.prototype.hasOwnProperty.call(input, 'hmac_required') ) { updates.push('hmac_required = ?'); params.push(input.hmacRequired === true || input.hmac_required === true ? 1 : 0); } if ( Object.prototype.hasOwnProperty.call(input, 'enabled') || Object.prototype.hasOwnProperty.call(input, 'status') ) { const status = input.enabled === false || input.status === 'disabled' ? 'disabled' : 'active'; updates.push('status = ?'); params.push(status); } if (Object.prototype.hasOwnProperty.call(input, 'expiresAt')) { updates.push('expires_at = ?'); params.push(normalizeExpiresAt(input.expiresAt) ?? null); } if (normalizeText(input.apiKey, MAX_API_KEY_LENGTH)) { plainApiKey = normalizeApiKeyInput(input.apiKey, { required: true }); const preview = buildKeyPreview(plainApiKey); updates.push('key_hash = ?', 'key_prefix = ?', 'key_suffix = ?'); params.push(hashApiKey(plainApiKey), preview.keyPrefix, preview.keySuffix); } updates.push('updated_by_user_id = ?', 'updated_by_name = ?'); params.push(actor.id, actor.name); try { await database.local.query( `UPDATE ${API_KEY_TABLE} SET ${updates.join(', ')} WHERE id = ? AND service_scope = ?`, [...params, existing.id, SELECTION_FISSION_SCOPE] ); } catch (error) { if (error?.code === 'ER_DUP_ENTRY') { const conflict = new Error('该 API Key 已存在'); conflict.status = 409; throw conflict; } throw error; } const row = await getApiKeyById(existing.id); return { ...mapApiKeyRow(row), plainApiKey, }; } async function deleteSelectionFissionApiKey(id) { await ensureApiKeySchema(); const existing = await getApiKeyById(id); await database.local.query( `DELETE FROM ${API_KEY_TABLE} WHERE id = ? AND service_scope = ?`, [existing.id, SELECTION_FISSION_SCOPE] ); return mapApiKeyRow(existing); } async function authenticateSelectionFissionApiKey(apiKey) { await ensureApiKeySchema(); const activeCountRows = await database.local.query( `SELECT COUNT(*) AS total FROM ${API_KEY_TABLE} WHERE service_scope = ? AND status = 'active' AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)`, [SELECTION_FISSION_SCOPE] ); const configured = Number(activeCountRows[0]?.total || 0) > 0; const normalizedApiKey = normalizeText(apiKey, MAX_API_KEY_LENGTH); if (!normalizedApiKey) { return { configured, valid: false }; } const rows = await database.local.query( `SELECT id, name, allowed_ips, hmac_required FROM ${API_KEY_TABLE} WHERE service_scope = ? AND key_hash = ? AND status = 'active' AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP) LIMIT 1`, [SELECTION_FISSION_SCOPE, hashApiKey(normalizedApiKey)] ); if (!rows[0]) { return { configured, valid: false }; } await database.local.query( `UPDATE ${API_KEY_TABLE} SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?`, [rows[0].id] ); return { configured: true, valid: true, keyId: Number(rows[0].id), keyName: rows[0].name || '', allowedIps: normalizeAllowedIps(rows[0].allowed_ips || ''), hmacRequired: Number(rows[0].hmac_required || 0) === 1, }; } module.exports = { authenticateSelectionFissionApiKey, createSelectionFissionApiKey, deleteSelectionFissionApiKey, ensureApiKeySchema, listSelectionFissionApiKeys, updateSelectionFissionApiKey, _test: { buildKeyPreview, generateApiKey, hashApiKey, maskApiKey, normalizeApiKeyInput, normalizeAllowedIps, }, };