Files
memind/admin-server.mjs
T
john 4b15610aa8
Memind CI / Test, build, and release guards (pull_request) Successful in 2m47s
feat(image): complete reviewed generation delivery
2026-07-20 20:04:53 +08:00

209 lines
7.6 KiB
JavaScript

// admin-server.mjs
//
// Standalone memind_adm service — the back-office process.
//
// Serves the platform super-admin API (/admin-api/*) and the plaza operations
// console API (/api/ops/v1/*) on its own port, isolated from the public
// user-facing server (server.mjs). In production this is fronted by
// gadm.tkmind.cn; in dev the ops SPA (:3002) proxies to it directly.
//
// It trusts the shared session cookie (H5_COOKIE_DOMAIN=.tkmind.cn), so login
// continues to happen on the main app domain — this process only reads the
// session and authorizes admin/ops roles. No daemons, no public traffic.
import express from 'express';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseCookies } from './auth.mjs';
import { USER_COOKIE } from './user-auth.mjs';
import { createAdminServices } from './admin-bootstrap.mjs';
import { createAdminApi, createOpsApi } from './admin-routes.mjs';
import { buildNetworkGuard, parseList } from './admin-guard.mjs';
import { sendError } from './api-response.mjs';
import { isLocalDevHostname } from './scripts/local-test-config.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Mirror server.mjs env loading so the admin service can run standalone.
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return;
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq < 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1).trim();
if (!process.env[key]) process.env[key] = value;
}
}
loadEnvFile(path.join(__dirname, '../../.env.local'));
loadEnvFile(path.join(__dirname, '.env'));
const PORT = Number(process.env.ADMIN_PORT ?? 8082);
const HOST = process.env.ADMIN_HOST ?? '127.0.0.1';
const PUBLIC_HOST = process.env.ADMIN_PUBLIC_HOST ?? 'gadm.tkmind.cn';
const getToken = (req) => parseCookies(req.get('cookie'))[USER_COOKIE];
// Resolve the shared session cookie into req.currentUser, else 401.
function resolveSessionUser(userAuth) {
return async (req, res, next) => {
const token = getToken(req);
const me = token ? await userAuth.getMe(token) : null;
if (!me) return res.status(401).json({ message: '未授权,请重新登录' });
req.currentUser = me;
next();
};
}
// ---- Console registry ----------------------------------------------------
// Each back-office console is an independently mountable unit. A process serves
// whichever subset ADMIN_CONSOLES names (default: all). Splitting into separate
// processes later is purely a config change — run one instance with
// ADMIN_CONSOLES=admin and another with ADMIN_CONSOLES=ops; no code changes.
const CONSOLES = {
admin: {
label: 'platform super-admin',
mountPath: '/admin-api',
routeHint: '/admin-api/*',
envPrefix: 'ADMIN_API', // ADMIN_API_ALLOWED_HOSTS / ADMIN_API_IP_ALLOWLIST
build: ({ jsonBody, services }) =>
// The admin router gates currentUser itself, then requireAdmin per route.
createAdminApi({
jsonBody,
getToken,
userAuth: services.userAuth,
llmProviderService: services.llmProviderService,
assetGatewayConfigService: services.assetGatewayConfigService,
imageMakeAdminConfigService: services.imageMakeAdminConfigService,
memoryV2ConfigService: services.memoryV2ConfigService,
mindSearchConfigService: services.mindSearchConfigService,
adminSystemTestService: services.adminSystemTestService,
plazaPosts: services.plazaPosts,
plazaOps: services.plazaOps,
wechatAdmin: services.wechatAdmin,
subscriptionService: services.subscriptionService,
}),
},
ops: {
label: 'plaza operations console',
mountPath: '/api',
routeHint: '/api/ops/v1/*',
envPrefix: 'OPS_API', // OPS_API_ALLOWED_HOSTS / OPS_API_IP_ALLOWLIST
build: ({ jsonBody, services }) => {
const router = express.Router();
router.use(resolveSessionUser(services.userAuth));
router.use('/ops/v1', createOpsApi({ jsonBody, plazaOps: services.plazaOps }));
return router;
},
},
};
function csrfOriginCheck(req, res, next) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
const host = req.get('host');
const origin = req.get('origin');
const referer = req.get('referer');
if (!origin && !referer) return next();
const requestHostname = (host ?? '').split(':')[0];
const allowed = [origin, referer].some((value) => {
if (!value) return false;
try {
const source = new URL(value);
if (source.host === host) return true;
if (source.hostname === PUBLIC_HOST) return true;
return isLocalDevHostname(requestHostname) && isLocalDevHostname(source.hostname);
} catch {
return false;
}
});
if (!allowed) {
return sendError(res, req, 403, 'csrf_failed', '来源校验失败');
}
return next();
}
function resolveEnabledConsoles() {
const enabled = parseList(process.env.ADMIN_CONSOLES || 'admin,ops');
if (!enabled.length) throw new Error('ADMIN_CONSOLES resolved to an empty set');
const unknown = enabled.filter((key) => !CONSOLES[key]);
if (unknown.length) {
throw new Error(
`unknown ADMIN_CONSOLES: ${unknown.join(', ')} (valid: ${Object.keys(CONSOLES).join(', ')})`,
);
}
return enabled;
}
async function main() {
const enabled = resolveEnabledConsoles();
const services = await createAdminServices({ h5Root: __dirname });
const app = express();
app.set('trust proxy', true);
app.disable('x-powered-by');
const jsonBody = express.json({ limit: '1mb' });
const ctx = { jsonBody, services };
app.get('/healthz', (_req, res) =>
res.json({ ok: true, service: 'memind_adm', consoles: enabled }),
);
const summary = [];
for (const key of enabled) {
const console_ = CONSOLES[key];
const allowedHosts = parseList(process.env[`${console_.envPrefix}_ALLOWED_HOSTS`]);
const ipAllowlist = parseList(process.env[`${console_.envPrefix}_IP_ALLOWLIST`]);
const guard = buildNetworkGuard({
allowedHosts,
ipAllowlist,
onDeny: (res, req, reason) =>
sendError(
res,
req,
403,
'forbidden_network',
reason === 'host' ? '主机不在白名单' : 'IP 不在白名单',
),
});
app.use(console_.mountPath, csrfOriginCheck);
if (guard) app.use(console_.mountPath, guard);
app.use(console_.mountPath, console_.build(ctx));
summary.push({ key, console_, allowedHosts, ipAllowlist, guarded: Boolean(guard) });
}
app.use((req, res) => res.status(404).json({ message: 'not found' }));
if (services.subscriptionService) {
const subExpiryTimer = setInterval(async () => {
try {
const n = await services.subscriptionService.expireStaleSubscriptions();
if (n > 0) console.log(`Expired ${n} stale subscription(s)`);
} catch (err) {
console.warn('Subscription expiry check failed:', err);
}
}, 60 * 60 * 1000);
subExpiryTimer.unref?.();
}
app.listen(PORT, HOST, () => {
console.log(`memind_adm @ http://${HOST}:${PORT} (public host: ${PUBLIC_HOST})`);
for (const { console_, allowedHosts, ipAllowlist, guarded } of summary) {
const fence = guarded
? ` [hosts: ${allowedHosts.join(',') || '*'} | ip: ${ipAllowlist.join(',') || '*'}]`
: '';
console.log(` ${console_.routeHint.padEnd(16)} ${console_.label}${fence}`);
}
});
}
main().catch((err) => {
console.error('admin-server failed to start:', err);
process.exit(1);
});