import crypto from 'node:crypto'; const PUBLIC_PAGE_LIMIT_KEY = 'public_page_limit'; const SEO_GEO_CONFIG_KEY = 'seo_geo_config'; const ANALYTICS_ENABLED_KEY = 'analytics_enabled'; const CONFIG_CACHE_TTL_MS = 5_000; let cachedMindSpaceConfig = null; let cachedMindSpaceConfigAt = 0; const ANALYTICS_WEBSITE_ID_KEY = 'analytics_website_id'; const ANALYTICS_URL_KEY = 'analytics_url'; const ANALYTICS_DOMAINS_KEY = 'analytics_domains'; const ANALYTICS_ID_SECRET_KEY = 'analytics_id_secret'; function secretKey(env = process.env) { return crypto.createHash('sha256').update(String(env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret')).digest(); } function encryptSecret(value, env = process.env) { const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv('aes-256-gcm', secretKey(env), iv); const ciphertext = Buffer.concat([cipher.update(String(value), 'utf8'), cipher.final()]); return JSON.stringify({ v: 1, iv: iv.toString('base64'), tag: cipher.getAuthTag().toString('base64'), data: ciphertext.toString('base64') }); } function decryptSecret(value, env = process.env) { try { const payload = JSON.parse(String(value)); const decipher = crypto.createDecipheriv('aes-256-gcm', secretKey(env), Buffer.from(payload.iv, 'base64')); decipher.setAuthTag(Buffer.from(payload.tag, 'base64')); return Buffer.concat([decipher.update(Buffer.from(payload.data, 'base64')), decipher.final()]).toString('utf8'); } catch { return ''; } } function asPositiveInteger(value, fallback) { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed < 1) return fallback; return Math.floor(parsed); } function normalizeBoolean(value, fallback = false) { if (value == null || value === '') return fallback; if (typeof value === 'boolean') return value; return /^(1|true|yes|on)$/i.test(String(value)); } function parseJsonObject(value, fallback = {}) { if (value == null || value === '') return structuredClone?.(fallback) ?? JSON.parse(JSON.stringify(fallback)); if (typeof value === 'object') return structuredClone?.(value) ?? JSON.parse(JSON.stringify(value)); try { const parsed = JSON.parse(String(value)); return typeof parsed === 'object' && parsed ? parsed : structuredClone?.(fallback) ?? JSON.parse(JSON.stringify(fallback)); } catch { return structuredClone?.(fallback) ?? JSON.parse(JSON.stringify(fallback)); } } export function defaultSeoGeoConfig() { return { enabled: true, seo: { enabled: true, canonical: true, sitemap: true, robotsTxt: true, baiduPush: true, }, geo: { enabled: true, jsonLd: true, llmsTxt: true, }, }; } export function normalizeSeoGeoConfig(input = null) { const defaults = defaultSeoGeoConfig(); const source = parseJsonObject(input, defaults); return { enabled: normalizeBoolean(source.enabled, defaults.enabled), seo: { enabled: normalizeBoolean(source.seo?.enabled, defaults.seo.enabled), canonical: normalizeBoolean(source.seo?.canonical, defaults.seo.canonical), sitemap: normalizeBoolean(source.seo?.sitemap, defaults.seo.sitemap), robotsTxt: normalizeBoolean(source.seo?.robotsTxt, defaults.seo.robotsTxt), baiduPush: normalizeBoolean(source.seo?.baiduPush, defaults.seo.baiduPush), }, geo: { enabled: normalizeBoolean(source.geo?.enabled, defaults.geo.enabled), jsonLd: normalizeBoolean(source.geo?.jsonLd, defaults.geo.jsonLd), llmsTxt: normalizeBoolean(source.geo?.llmsTxt, defaults.geo.llmsTxt), }, }; } export function invalidateMindSpaceConfigCache() { cachedMindSpaceConfig = null; cachedMindSpaceConfigAt = 0; } async function ensureConfigTable(pool) { await pool.query(` CREATE TABLE IF NOT EXISTS mindspace_config ( \`key\` VARCHAR(64) PRIMARY KEY, value TEXT NOT NULL, description VARCHAR(255) NULL, updated_at BIGINT NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); } export function defaultMindSpaceConfig(env = process.env) { return { publicPageLimit: asPositiveInteger(env.MINDSPACE_FREE_PUBLIC_PAGE_LIMIT ?? 5, 5), seoGeo: normalizeSeoGeoConfig({ enabled: env.MINDSPACE_SEO_GEO_ENABLED, seo: { enabled: env.MINDSPACE_SEO_ENABLED, canonical: env.MINDSPACE_SEO_CANONICAL, sitemap: env.MINDSPACE_SEO_SITEMAP, robotsTxt: env.MINDSPACE_SEO_ROBOTS_TXT, baiduPush: env.MINDSPACE_SEO_BAIDU_PUSH, }, geo: { enabled: env.MINDSPACE_GEO_ENABLED, jsonLd: env.MINDSPACE_GEO_JSONLD, llmsTxt: env.MINDSPACE_GEO_LLMSTXT, }, }), analytics: { enabled: String(env.MEMIND_ANALYTICS_ENABLED ?? '').toLowerCase() === 'true', websiteId: String(env.MEMIND_ANALYTICS_WEBSITE_ID ?? '').trim(), analyticsUrl: String(env.MEMIND_ANALYTICS_URL ?? 'http://127.0.0.1:3100').trim(), domains: String(env.MEMIND_ANALYTICS_DOMAINS ?? '127.0.0.1,localhost').trim(), idSecretConfigured: Boolean(String(env.MEMIND_ANALYTICS_ID_SECRET ?? '').trim()), }, }; } async function readConfigRows(pool) { const [rows] = await pool.query( 'SELECT `key`, value FROM mindspace_config', ); return rows; } export async function ensureMindSpaceConfig(pool, { env = process.env, seedDefault = true } = {}) { await ensureConfigTable(pool); if (!seedDefault) return; const [rows] = await pool.query('SELECT COUNT(*) AS count FROM mindspace_config'); if (Number(rows[0]?.count ?? 0) > 0) return; const now = Date.now(); const defaults = defaultMindSpaceConfig(env); await pool.query( `INSERT INTO mindspace_config (\`key\`, value, description, updated_at) VALUES (?, ?, ?, ?)`, [PUBLIC_PAGE_LIMIT_KEY, String(defaults.publicPageLimit), '公开页面数量上限', now], ); } export async function loadMindSpaceConfig(pool, { env = process.env, includeAnalyticsSecret = false } = {}) { const config = defaultMindSpaceConfig(env); try { const rows = await readConfigRows(pool); for (const row of rows) { if (row.key === PUBLIC_PAGE_LIMIT_KEY) { config.publicPageLimit = asPositiveInteger(row.value, config.publicPageLimit); } if (row.key === SEO_GEO_CONFIG_KEY) { config.seoGeo = normalizeSeoGeoConfig(row.value); } if (row.key === ANALYTICS_ENABLED_KEY) config.analytics.enabled = row.value === 'true'; if (row.key === ANALYTICS_WEBSITE_ID_KEY) config.analytics.websiteId = String(row.value ?? ''); if (row.key === ANALYTICS_URL_KEY) config.analytics.analyticsUrl = String(row.value ?? ''); if (row.key === ANALYTICS_DOMAINS_KEY) config.analytics.domains = String(row.value ?? ''); if (row.key === ANALYTICS_ID_SECRET_KEY) { const secret = decryptSecret(row.value, env); config.analytics.idSecretConfigured = Boolean(secret); if (includeAnalyticsSecret) config.analytics.idSecret = secret; } } } catch (error) { if (error?.code === 'ER_NO_SUCH_TABLE') return config; throw error; } return config; } export async function loadMindSpaceConfigCached(pool, options = {}) { const ttlMs = Number(options.cacheTtlMs ?? CONFIG_CACHE_TTL_MS); const now = Date.now(); if ( cachedMindSpaceConfig && now - cachedMindSpaceConfigAt < ttlMs && options.includeAnalyticsSecret === cachedMindSpaceConfig.__includeAnalyticsSecret ) { return cachedMindSpaceConfig; } const config = await loadMindSpaceConfig(pool, options); cachedMindSpaceConfig = config; cachedMindSpaceConfigAt = now; cachedMindSpaceConfig.__includeAnalyticsSecret = Boolean(options.includeAnalyticsSecret); return config; } export async function updateMindSpaceConfig(pool, patch, { env = process.env } = {}) { await ensureConfigTable(pool); const updates = []; if (patch?.publicPageLimit !== undefined) { const publicPageLimit = asPositiveInteger(patch.publicPageLimit, null); if (!publicPageLimit) { throw Object.assign(new Error('公开页面数量上限必须是大于 0 的整数'), { code: 'invalid_mindspace_config', }); } updates.push([PUBLIC_PAGE_LIMIT_KEY, String(publicPageLimit), '公开页面数量上限']); } if (patch?.seoGeo !== undefined) { updates.push([ SEO_GEO_CONFIG_KEY, JSON.stringify(normalizeSeoGeoConfig(patch.seoGeo)), 'MindSpace SEO/GEO 配置', ]); } if (patch?.analytics) { const analytics = patch.analytics; if (analytics.enabled !== undefined) updates.push([ANALYTICS_ENABLED_KEY, String(Boolean(analytics.enabled)), '本地 Umami 分析开关']); if (analytics.websiteId !== undefined) updates.push([ANALYTICS_WEBSITE_ID_KEY, String(analytics.websiteId ?? '').trim(), '本地 Umami Website ID']); if (analytics.analyticsUrl !== undefined) updates.push([ANALYTICS_URL_KEY, String(analytics.analyticsUrl || 'http://127.0.0.1:3100').trim(), '本地 Umami 地址']); if (analytics.domains !== undefined) updates.push([ANALYTICS_DOMAINS_KEY, String(analytics.domains ?? '').trim(), '本地统计域名']); if (analytics.idSecret !== undefined && String(analytics.idSecret).trim()) updates.push([ANALYTICS_ID_SECRET_KEY, encryptSecret(String(analytics.idSecret).trim(), env), '本地分析匿名化密钥']); } if (updates.length === 0) return loadMindSpaceConfig(pool, { env }); invalidateMindSpaceConfigCache(); const now = Date.now(); for (const [key, value, description] of updates) { await pool.query( `INSERT INTO mindspace_config (\`key\`, value, description, updated_at) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE value = VALUES(value), description = VALUES(description), updated_at = VALUES(updated_at)`, [key, value, description, now], ); } return loadMindSpaceConfig(pool, { env }); } export const mindspaceConfigInternals = { PUBLIC_PAGE_LIMIT_KEY, SEO_GEO_CONFIG_KEY, ANALYTICS_ENABLED_KEY, ANALYTICS_WEBSITE_ID_KEY, ANALYTICS_URL_KEY, ANALYTICS_DOMAINS_KEY, ANALYTICS_ID_SECRET_KEY, };