4e789663a1
Memind CI / Test, build, and release guards (push) Failing after 5m19s
Drop the user_confirmed_at gate so public, online, unexpired pages can enter sitemap/llms and receive SEO/GEO tags. Defaults now enable all discovery switches; stored all-off config is still preserved until admin saves. Co-authored-by: Cursor <cursoragent@cursor.com>
264 lines
9.9 KiB
JavaScript
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: 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,
|
|
};
|