Files
memind/mindspace-config.mjs
T
john fde6503bdf
Memind CI / Test, build, and release guards (push) Has been cancelled
feat(mindspace): add SEO/GEO delivery, page template catalog, and admin hooks
Enable optional SEO/GEO injection and discovery routes for confirmed public pages while keeping private pages noindex. Add premium page template skills, portal catalog API, template shop UI, and Baidu push gated by memind_adm config.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 08:06:12 +08:00

264 lines
9.9 KiB
JavaScript

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: false,
seo: {
enabled: false,
canonical: true,
sitemap: false,
robotsTxt: false,
baiduPush: false,
},
geo: {
enabled: false,
jsonLd: false,
llmsTxt: false,
},
};
}
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,
};