Files

318 lines
12 KiB
JavaScript

const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { URL } = require('url');
const { Pool } = require('pg');
const PORT = Number(process.env.APP_PORT || 3000);
const HOST = process.env.APP_HOST || '0.0.0.0';
const PUBLIC_DIR = path.join(__dirname, 'public');
const ADMIN_TOKEN = process.env.APP_ADMIN_TOKEN || '';
const pool = new Pool({
host: process.env.PGHOST || '127.0.0.1',
port: Number(process.env.PGPORT || 5432),
database: process.env.PGDATABASE,
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
ssl: process.env.PGSSLMODE === 'require' ? { rejectUnauthorized: false } : undefined,
max: 5,
idleTimeoutMillis: 30000,
});
const MIME_TYPES = {
'.css': 'text/css; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
};
function sendJson(res, status, payload) {
const body = JSON.stringify(payload);
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
});
res.end(body);
}
function sendError(res, status, message) {
sendJson(res, status, { error: message });
}
function requireAdmin(req, res) {
if (!ADMIN_TOKEN) {
sendError(res, 503, 'APP_ADMIN_TOKEN 尚未配置');
return false;
}
const supplied = req.headers['x-admin-token'] || '';
const expected = Buffer.from(ADMIN_TOKEN);
const actual = Buffer.from(String(supplied));
if (expected.length !== actual.length || !crypto.timingSafeEqual(expected, actual)) {
sendError(res, 401, '管理令牌无效');
return false;
}
return true;
}
function readBody(req) {
return new Promise((resolve, reject) => {
let data = '';
req.on('data', (chunk) => {
data += chunk;
if (data.length > 1024 * 1024) reject(new Error('请求内容过大'));
});
req.on('end', () => {
if (!data) return resolve({});
try {
resolve(JSON.parse(data));
} catch {
reject(new Error('请求必须是 JSON'));
}
});
req.on('error', reject);
});
}
function validateEmail(value) {
return typeof value === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
}
function normalizeModules(modules) {
if (!Array.isArray(modules)) return [];
return [...new Set(modules.filter((item) => typeof item === 'string').map((item) => item.trim()).filter(Boolean))];
}
async function listModules() {
const result = await pool.query(
'SELECT module_key, module_name, description, sort_order FROM newsletter_modules WHERE enabled = TRUE ORDER BY sort_order, module_key',
);
return result.rows;
}
async function listRecipients() {
const result = await pool.query(`
SELECT r.id, r.email, r.display_name, r.enabled, r.send_time, r.timezone,
r.send_all_modules,
COALESCE(array_agg(rm.module_key ORDER BY rm.module_key)
FILTER (WHERE rm.module_key IS NOT NULL), '{}') AS modules
FROM newsletter_recipients r
LEFT JOIN recipient_modules rm ON rm.recipient_id = r.id
GROUP BY r.id
ORDER BY r.enabled DESC, r.email
`);
return result.rows;
}
async function createRecipient(body) {
const email = String(body.email || '').trim().toLowerCase();
if (!validateEmail(email)) throw new Error('请输入有效邮箱');
const modules = normalizeModules(body.modules);
if (!body.send_all_modules && modules.length === 0) throw new Error('至少选择一个推送模块');
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await client.query(`
INSERT INTO newsletter_recipients
(email, display_name, send_time, timezone, send_all_modules)
VALUES ($1, NULLIF($2, ''), COALESCE(NULLIF($3, '')::time, TIME '07:30'),
COALESCE(NULLIF($4, ''), 'Asia/Shanghai'), $5)
RETURNING id
`, [email, String(body.display_name || '').trim(), String(body.send_time || ''), String(body.timezone || ''), Boolean(body.send_all_modules)]);
const recipientId = result.rows[0].id;
if (modules.length) {
await client.query(
'INSERT INTO recipient_modules (recipient_id, module_key) SELECT $1, unnest($2::text[]) ON CONFLICT DO NOTHING',
[recipientId, modules],
);
}
await client.query('COMMIT');
return recipientId;
} catch (error) {
await client.query('ROLLBACK');
if (error.code === '23505') throw new Error('这个邮箱已经存在');
if (error.code === '23503') throw new Error('选择了不存在的模块');
throw error;
} finally {
client.release();
}
}
async function updateRecipient(id, body) {
const modules = normalizeModules(body.modules);
if (!body.send_all_modules && modules.length === 0) throw new Error('至少选择一个推送模块');
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await client.query(`
UPDATE newsletter_recipients
SET display_name = NULLIF($2, ''),
enabled = COALESCE($3, enabled),
send_time = COALESCE(NULLIF($4, '')::time, send_time),
timezone = COALESCE(NULLIF($5, ''), timezone),
send_all_modules = $6,
updated_at = NOW()
WHERE id = $1
RETURNING id
`, [id, String(body.display_name || '').trim(), body.enabled === undefined ? null : Boolean(body.enabled), String(body.send_time || ''), String(body.timezone || ''), Boolean(body.send_all_modules)]);
if (!result.rowCount) throw new Error('收件邮箱不存在');
await client.query('DELETE FROM recipient_modules WHERE recipient_id = $1', [id]);
if (modules.length) {
await client.query(
'INSERT INTO recipient_modules (recipient_id, module_key) SELECT $1, unnest($2::text[]) ON CONFLICT DO NOTHING',
[id, modules],
);
}
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async function deleteRecipient(id) {
const result = await pool.query('DELETE FROM newsletter_recipients WHERE id = $1 RETURNING id', [id]);
if (!result.rowCount) throw new Error('收件邮箱不存在');
}
async function listReports(query) {
const page = Math.max(1, Number(query.get('page') || 1));
const pageSize = Math.min(30, Math.max(1, Number(query.get('page_size') || 10)));
const params = [];
const conditions = [];
const date = query.get('date');
const q = query.get('q');
const category = query.get('category');
if (date) {
params.push(date);
conditions.push(`r.report_date = $${params.length}::date`);
}
if (q) {
params.push(`%${q}%`);
conditions.push(`EXISTS (
SELECT 1 FROM report_articles rqa
JOIN news_articles nqa ON nqa.id = rqa.article_id
WHERE rqa.report_id = r.id
AND (nqa.title ILIKE $${params.length}
OR COALESCE(nqa.summary, '') ILIKE $${params.length}
OR COALESCE(array_to_string(nqa.keywords, ' '), '') ILIKE $${params.length})
)`);
}
if (category) {
params.push(category);
conditions.push(`EXISTS (
SELECT 1 FROM report_articles rca
JOIN news_articles nca ON nca.id = rca.article_id
WHERE rca.report_id = r.id
AND (rca.section = $${params.length} OR nca.category = $${params.length})
)`);
}
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const countResult = await pool.query(`SELECT COUNT(*)::int AS count FROM daily_reports r ${where}`, params);
const total = countResult.rows[0].count;
params.push(pageSize, (page - 1) * pageSize);
const result = await pool.query(`
SELECT r.id, r.report_date::text AS report_date, r.title, r.introduction, r.item_count,
r.status, r.email_status, r.created_at,
COALESCE(json_agg(json_build_object(
'id', a.id, 'title', a.title, 'translated_title', a.translated_title,
'summary', a.summary, 'translated_summary', a.translated_summary,
'url', a.url, 'source_name', s.name, 'category', a.category,
'topics', a.topics, 'region', a.region, 'published_at', a.published_at,
'section', ra.section, 'display_order', ra.display_order,
'is_highlight', ra.is_highlight
) ORDER BY ra.section, ra.display_order)
FILTER (WHERE a.id IS NOT NULL), '[]') AS articles
FROM daily_reports r
LEFT JOIN report_articles ra ON ra.report_id = r.id
LEFT JOIN news_articles a ON a.id = ra.article_id
LEFT JOIN news_sources s ON s.id = a.source_id
${where}
GROUP BY r.id
ORDER BY r.report_date DESC
LIMIT $${params.length - 1} OFFSET $${params.length}
`, params);
return { page, page_size: pageSize, total, reports: result.rows };
}
async function routeApi(req, res, requestUrl) {
const pathname = requestUrl.pathname;
if (req.method === 'GET' && pathname === '/api/health') {
try {
await pool.query('SELECT 1');
return sendJson(res, 200, { ok: true });
} catch (error) {
return sendError(res, 503, `数据库连接失败: ${error.message}`);
}
}
if (req.method === 'GET' && pathname === '/api/modules') {
try { return sendJson(res, 200, { modules: await listModules() }); }
catch (error) { return sendError(res, 500, error.message); }
}
if (req.method === 'GET' && pathname === '/api/reports') {
try { return sendJson(res, 200, await listReports(requestUrl.searchParams)); }
catch (error) { return sendError(res, 500, error.message); }
}
if (pathname.startsWith('/api/recipients')) {
if (!requireAdmin(req, res)) return;
try {
if (req.method === 'GET' && pathname === '/api/recipients') return sendJson(res, 200, { recipients: await listRecipients() });
if (req.method === 'POST' && pathname === '/api/recipients') {
const body = await readBody(req);
const id = await createRecipient(body);
return sendJson(res, 201, { id });
}
const id = Number(pathname.split('/').pop());
if (!Number.isInteger(id)) return sendError(res, 400, '无效的收件邮箱 ID');
if (req.method === 'PUT') {
await updateRecipient(id, await readBody(req));
return sendJson(res, 200, { ok: true });
}
if (req.method === 'DELETE') {
await deleteRecipient(id);
return sendJson(res, 200, { ok: true });
}
} catch (error) {
return sendError(res, error.message.includes('不存在') || error.message.includes('有效邮箱') || error.message.includes('选择') || error.message.includes('已经存在') ? 400 : 500, error.message);
}
}
return sendError(res, 404, '接口不存在');
}
function serveStatic(req, res, pathname) {
let relative = pathname === '/' ? 'index.html' : pathname.slice(1);
if (relative === 'settings') relative = 'settings.html';
if (relative.includes('..')) return sendError(res, 400, '非法路径');
const filePath = path.join(PUBLIC_DIR, relative);
fs.readFile(filePath, (error, content) => {
if (error) return sendError(res, 404, '页面不存在');
const ext = path.extname(filePath).toLowerCase();
res.writeHead(200, { 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream' });
res.end(content);
});
}
const server = http.createServer(async (req, res) => {
try {
const requestUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
if (requestUrl.pathname.startsWith('/api/')) return await routeApi(req, res, requestUrl);
if (req.method !== 'GET') return sendError(res, 405, '只支持 GET');
return serveStatic(req, res, requestUrl.pathname);
} catch (error) {
sendError(res, 500, error.message || '服务器错误');
}
});
server.listen(PORT, HOST, () => {
console.log(`Daily news digest web listening on ${PORT}`);
});
process.on('SIGTERM', async () => {
await pool.end();
server.close(() => process.exit(0));
});