mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-03 15:43:11 +08:00
- Updated phase references in SKILL.md and 01-lite-plan.md to use "LP-Phase" prefix for consistency. - Added critical context isolation note in 01-lite-plan.md to clarify phase invocation rules. - Enhanced execution process descriptions to reflect updated phase naming conventions. Improve error handling in frontend routing - Introduced ChunkErrorBoundary component to handle lazy-loaded chunk load failures. - Wrapped lazy-loaded routes with error boundary and suspense for better user experience. - Created PageSkeleton component for loading states in lazy-loaded routes. Sanitize header values in notification routes - Added regex validation for header values to prevent XSS attacks by allowing only printable ASCII characters. Enhance mobile responsiveness in documentation styles - Updated CSS breakpoints to use custom properties for better maintainability. - Improved layout styles across various components to ensure consistent behavior on mobile devices.
593 lines
20 KiB
TypeScript
593 lines
20 KiB
TypeScript
// ========================================
|
|
// Remote Notification Routes
|
|
// ========================================
|
|
// API endpoints for remote notification configuration
|
|
|
|
import type { IncomingMessage, ServerResponse } from 'http';
|
|
import { URL } from 'url';
|
|
import {
|
|
loadConfig,
|
|
saveConfig,
|
|
resetConfig,
|
|
} from '../../config/remote-notification-config.js';
|
|
import {
|
|
remoteNotificationService,
|
|
} from '../services/remote-notification-service.js';
|
|
import {
|
|
maskSensitiveConfig,
|
|
type RemoteNotificationConfig,
|
|
type TestNotificationRequest,
|
|
type NotificationPlatform,
|
|
type DiscordConfig,
|
|
type TelegramConfig,
|
|
type WebhookConfig,
|
|
type FeishuConfig,
|
|
type DingTalkConfig,
|
|
type WeComConfig,
|
|
type EmailConfig,
|
|
} from '../../types/remote-notification.js';
|
|
import { deepMerge } from '../../types/util.js';
|
|
|
|
// ========== Input Validation ==========
|
|
|
|
// Compiled regex patterns for performance (compiled once at module load)
|
|
const TELEGRAM_BOT_TOKEN_REGEX = /^\d{8,15}:[A-Za-z0-9_-]{30,50}$/;
|
|
const TELEGRAM_CHAT_ID_REGEX = /^-?\d{1,20}$/;
|
|
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
const DISCORD_HOSTNAMES = ['discord.com', 'discordapp.com'];
|
|
const FEISHU_HOSTNAMES = ['feishu.cn', 'larksuite.com', 'lark.com'];
|
|
const DINGTALK_HOSTNAMES = ['dingtalk.com', 'oapi.dingtalk.com'];
|
|
const WECOM_HOSTNAMES = ['qyapi.weixin.qq.com', 'work.weixin.qq.com'];
|
|
|
|
// Pattern for safe header values - only printable ASCII characters (no control chars)
|
|
// This prevents XSS by ensuring header values don't contain HTML/JS metacharacters
|
|
// Space (0x20) through tilde (0x7E) are printable ASCII characters
|
|
const SAFE_HEADER_VALUE_REGEX = /^[\x20-\x7E]+$/;
|
|
|
|
/**
|
|
* Validate URL format (must be http or https)
|
|
*/
|
|
function isValidUrl(url: string): boolean {
|
|
try {
|
|
const parsed = new URL(url);
|
|
return ['http:', 'https:'].includes(parsed.protocol);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate Discord webhook URL format
|
|
*/
|
|
function isValidDiscordWebhookUrl(url: string): boolean {
|
|
if (!isValidUrl(url)) return false;
|
|
try {
|
|
const parsed = new URL(url);
|
|
// Discord webhooks are typically: discord.com/api/webhooks/{id}/{token}
|
|
return (
|
|
DISCORD_HOSTNAMES.includes(parsed.hostname) &&
|
|
parsed.pathname.startsWith('/api/webhooks/')
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate Telegram bot token format (typically: 123456789:ABCdef...)
|
|
*/
|
|
function isValidTelegramBotToken(token: string): boolean {
|
|
// Telegram bot tokens are in format: {bot_id}:{token}
|
|
// Bot ID is a number, token is alphanumeric with underscores and hyphens
|
|
return TELEGRAM_BOT_TOKEN_REGEX.test(token);
|
|
}
|
|
|
|
/**
|
|
* Validate Telegram chat ID format
|
|
*/
|
|
function isValidTelegramChatId(chatId: string): boolean {
|
|
// Chat IDs are numeric, optionally negative (for groups)
|
|
return TELEGRAM_CHAT_ID_REGEX.test(chatId);
|
|
}
|
|
|
|
/**
|
|
* Validate webhook headers (must be valid JSON object)
|
|
*/
|
|
function isValidHeaders(headers: unknown): { valid: boolean; error?: string } {
|
|
if (headers === undefined || headers === null) {
|
|
return { valid: true }; // Optional field
|
|
}
|
|
|
|
if (typeof headers !== 'object' || Array.isArray(headers)) {
|
|
return { valid: false, error: 'Headers must be an object' };
|
|
}
|
|
|
|
const headerObj = headers as Record<string, unknown>;
|
|
|
|
// Check for reasonable size limit (10KB)
|
|
const serialized = JSON.stringify(headers);
|
|
if (serialized.length > 10240) {
|
|
return { valid: false, error: 'Headers too large (max 10KB)' };
|
|
}
|
|
|
|
// Validate each header key and value
|
|
for (const [key, value] of Object.entries(headerObj)) {
|
|
if (typeof key !== 'string' || key.length === 0) {
|
|
return { valid: false, error: 'Header keys must be non-empty strings' };
|
|
}
|
|
if (typeof value !== 'string') {
|
|
return { valid: false, error: `Header '${key}' value must be a string` };
|
|
}
|
|
// Sanitize header value - only allow printable ASCII characters
|
|
// This prevents XSS by blocking HTML/JS metacharacters and control characters
|
|
if (!SAFE_HEADER_VALUE_REGEX.test(value)) {
|
|
return { valid: false, error: `Header '${key}' contains invalid characters. Only printable ASCII characters (space through tilde) are allowed.` };
|
|
}
|
|
// Block potentially dangerous headers
|
|
const lowerKey = key.toLowerCase();
|
|
if (['host', 'content-length', 'connection'].includes(lowerKey)) {
|
|
return { valid: false, error: `Header '${key}' is not allowed` };
|
|
}
|
|
}
|
|
|
|
return { valid: true };
|
|
}
|
|
|
|
/**
|
|
* Validate Feishu webhook URL format
|
|
*/
|
|
function isValidFeishuWebhookUrl(url: string): boolean {
|
|
if (!isValidUrl(url)) return false;
|
|
try {
|
|
const parsed = new URL(url);
|
|
// Feishu webhooks are typically: open.feishu.cn/open-apis/bot/v2/hook/{token}
|
|
// or: open.larksuite.com/open-apis/bot/v2/hook/{token}
|
|
const validHosts = ['open.feishu.cn', 'open.larksuite.com'];
|
|
return validHosts.includes(parsed.hostname) && parsed.pathname.includes('/bot/');
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate DingTalk webhook URL format
|
|
*/
|
|
function isValidDingTalkWebhookUrl(url: string): boolean {
|
|
if (!isValidUrl(url)) return false;
|
|
try {
|
|
const parsed = new URL(url);
|
|
// DingTalk webhooks are typically: oapi.dingtalk.com/robot/send?access_token=xxx
|
|
return DINGTALK_HOSTNAMES.some(h => parsed.hostname.includes(h)) && parsed.pathname.includes('robot');
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate WeCom webhook URL format
|
|
*/
|
|
function isValidWeComWebhookUrl(url: string): boolean {
|
|
if (!isValidUrl(url)) return false;
|
|
try {
|
|
const parsed = new URL(url);
|
|
// WeCom webhooks are typically: qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx
|
|
return WECOM_HOSTNAMES.includes(parsed.hostname) && parsed.pathname.includes('webhook');
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate email address format
|
|
*/
|
|
function isValidEmail(email: string): boolean {
|
|
// Basic email validation regex (using compiled constant)
|
|
return EMAIL_REGEX.test(email);
|
|
}
|
|
|
|
/**
|
|
* Validate SMTP port number
|
|
*/
|
|
function isValidSmtpPort(port: number): boolean {
|
|
return Number.isInteger(port) && port > 0 && port <= 65535;
|
|
}
|
|
|
|
/**
|
|
* Validate configuration updates
|
|
*/
|
|
function validateConfigUpdates(updates: Partial<RemoteNotificationConfig>): { valid: boolean; error?: string } {
|
|
// Validate platforms if present
|
|
if (updates.platforms) {
|
|
const { discord, telegram, webhook, feishu, dingtalk, wecom, email } = updates.platforms;
|
|
|
|
// Validate Discord config
|
|
if (discord) {
|
|
if (discord.webhookUrl !== undefined && discord.webhookUrl !== '') {
|
|
if (!isValidUrl(discord.webhookUrl)) {
|
|
return { valid: false, error: 'Invalid Discord webhook URL format' };
|
|
}
|
|
// Warning: we allow non-Discord URLs for flexibility, but log it
|
|
if (!isValidDiscordWebhookUrl(discord.webhookUrl)) {
|
|
console.warn('[RemoteNotification] Webhook URL does not match Discord format');
|
|
}
|
|
}
|
|
if (discord.username !== undefined && discord.username.length > 80) {
|
|
return { valid: false, error: 'Discord username too long (max 80 chars)' };
|
|
}
|
|
}
|
|
|
|
// Validate Telegram config
|
|
if (telegram) {
|
|
if (telegram.botToken !== undefined && telegram.botToken !== '') {
|
|
if (!isValidTelegramBotToken(telegram.botToken)) {
|
|
return { valid: false, error: 'Invalid Telegram bot token format' };
|
|
}
|
|
}
|
|
if (telegram.chatId !== undefined && telegram.chatId !== '') {
|
|
if (!isValidTelegramChatId(telegram.chatId)) {
|
|
return { valid: false, error: 'Invalid Telegram chat ID format' };
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate Webhook config
|
|
if (webhook) {
|
|
if (webhook.url !== undefined && webhook.url !== '') {
|
|
if (!isValidUrl(webhook.url)) {
|
|
return { valid: false, error: 'Invalid webhook URL format' };
|
|
}
|
|
}
|
|
if (webhook.headers !== undefined) {
|
|
const headerValidation = isValidHeaders(webhook.headers);
|
|
if (!headerValidation.valid) {
|
|
return { valid: false, error: headerValidation.error };
|
|
}
|
|
}
|
|
if (webhook.timeout !== undefined && (webhook.timeout < 1000 || webhook.timeout > 60000)) {
|
|
return { valid: false, error: 'Webhook timeout must be between 1000ms and 60000ms' };
|
|
}
|
|
}
|
|
|
|
// Validate Feishu config
|
|
if (feishu) {
|
|
if (feishu.webhookUrl !== undefined && feishu.webhookUrl !== '') {
|
|
if (!isValidUrl(feishu.webhookUrl)) {
|
|
return { valid: false, error: 'Invalid Feishu webhook URL format' };
|
|
}
|
|
if (!isValidFeishuWebhookUrl(feishu.webhookUrl)) {
|
|
console.warn('[RemoteNotification] Webhook URL does not match Feishu format');
|
|
}
|
|
}
|
|
if (feishu.title !== undefined && feishu.title.length > 100) {
|
|
return { valid: false, error: 'Feishu title too long (max 100 chars)' };
|
|
}
|
|
}
|
|
|
|
// Validate DingTalk config
|
|
if (dingtalk) {
|
|
if (dingtalk.webhookUrl !== undefined && dingtalk.webhookUrl !== '') {
|
|
if (!isValidUrl(dingtalk.webhookUrl)) {
|
|
return { valid: false, error: 'Invalid DingTalk webhook URL format' };
|
|
}
|
|
if (!isValidDingTalkWebhookUrl(dingtalk.webhookUrl)) {
|
|
console.warn('[RemoteNotification] Webhook URL does not match DingTalk format');
|
|
}
|
|
}
|
|
if (dingtalk.keywords !== undefined) {
|
|
if (!Array.isArray(dingtalk.keywords)) {
|
|
return { valid: false, error: 'DingTalk keywords must be an array' };
|
|
}
|
|
if (dingtalk.keywords.length > 10) {
|
|
return { valid: false, error: 'Too many DingTalk keywords (max 10)' };
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate WeCom config
|
|
if (wecom) {
|
|
if (wecom.webhookUrl !== undefined && wecom.webhookUrl !== '') {
|
|
if (!isValidUrl(wecom.webhookUrl)) {
|
|
return { valid: false, error: 'Invalid WeCom webhook URL format' };
|
|
}
|
|
if (!isValidWeComWebhookUrl(wecom.webhookUrl)) {
|
|
console.warn('[RemoteNotification] Webhook URL does not match WeCom format');
|
|
}
|
|
}
|
|
if (wecom.mentionedList !== undefined) {
|
|
if (!Array.isArray(wecom.mentionedList)) {
|
|
return { valid: false, error: 'WeCom mentionedList must be an array' };
|
|
}
|
|
if (wecom.mentionedList.length > 100) {
|
|
return { valid: false, error: 'Too many mentioned users (max 100)' };
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate Email config
|
|
if (email) {
|
|
if (email.host !== undefined && email.host !== '') {
|
|
if (email.host.length > 255) {
|
|
return { valid: false, error: 'Email host too long (max 255 chars)' };
|
|
}
|
|
}
|
|
if (email.port !== undefined) {
|
|
if (!isValidSmtpPort(email.port)) {
|
|
return { valid: false, error: 'Invalid SMTP port (must be 1-65535)' };
|
|
}
|
|
}
|
|
if (email.username !== undefined && email.username.length > 255) {
|
|
return { valid: false, error: 'Email username too long (max 255 chars)' };
|
|
}
|
|
if (email.from !== undefined && email.from !== '') {
|
|
if (!isValidEmail(email.from)) {
|
|
return { valid: false, error: 'Invalid sender email address' };
|
|
}
|
|
}
|
|
if (email.to !== undefined) {
|
|
if (!Array.isArray(email.to)) {
|
|
return { valid: false, error: 'Email recipients must be an array' };
|
|
}
|
|
if (email.to.length === 0) {
|
|
return { valid: false, error: 'At least one email recipient is required' };
|
|
}
|
|
if (email.to.length > 50) {
|
|
return { valid: false, error: 'Too many email recipients (max 50)' };
|
|
}
|
|
for (const addr of email.to) {
|
|
if (!isValidEmail(addr)) {
|
|
return { valid: false, error: `Invalid email address: ${addr}` };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate timeout
|
|
if (updates.timeout !== undefined && (updates.timeout < 1000 || updates.timeout > 60000)) {
|
|
return { valid: false, error: 'Timeout must be between 1000ms and 60000ms' };
|
|
}
|
|
|
|
return { valid: true };
|
|
}
|
|
|
|
/**
|
|
* Validate test notification request
|
|
*/
|
|
function validateTestRequest(request: TestNotificationRequest): { valid: boolean; error?: string } {
|
|
if (!request.platform) {
|
|
return { valid: false, error: 'Missing platform' };
|
|
}
|
|
|
|
const validPlatforms: NotificationPlatform[] = ['discord', 'telegram', 'webhook', 'feishu', 'dingtalk', 'wecom', 'email'];
|
|
if (!validPlatforms.includes(request.platform as NotificationPlatform)) {
|
|
return { valid: false, error: `Invalid platform: ${request.platform}` };
|
|
}
|
|
|
|
if (!request.config) {
|
|
return { valid: false, error: 'Missing config' };
|
|
}
|
|
|
|
// Platform-specific validation
|
|
switch (request.platform) {
|
|
case 'discord': {
|
|
const config = request.config as Partial<DiscordConfig>;
|
|
if (!config.webhookUrl) {
|
|
return { valid: false, error: 'Discord webhook URL is required' };
|
|
}
|
|
if (!isValidUrl(config.webhookUrl)) {
|
|
return { valid: false, error: 'Invalid Discord webhook URL format' };
|
|
}
|
|
break;
|
|
}
|
|
case 'telegram': {
|
|
const config = request.config as Partial<TelegramConfig>;
|
|
if (!config.botToken) {
|
|
return { valid: false, error: 'Telegram bot token is required' };
|
|
}
|
|
if (!config.chatId) {
|
|
return { valid: false, error: 'Telegram chat ID is required' };
|
|
}
|
|
if (!isValidTelegramBotToken(config.botToken)) {
|
|
return { valid: false, error: 'Invalid Telegram bot token format' };
|
|
}
|
|
if (!isValidTelegramChatId(config.chatId)) {
|
|
return { valid: false, error: 'Invalid Telegram chat ID format' };
|
|
}
|
|
break;
|
|
}
|
|
case 'webhook': {
|
|
const config = request.config as Partial<WebhookConfig>;
|
|
if (!config.url) {
|
|
return { valid: false, error: 'Webhook URL is required' };
|
|
}
|
|
if (!isValidUrl(config.url)) {
|
|
return { valid: false, error: 'Invalid webhook URL format' };
|
|
}
|
|
if (config.headers) {
|
|
const headerValidation = isValidHeaders(config.headers);
|
|
if (!headerValidation.valid) {
|
|
return { valid: false, error: headerValidation.error };
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case 'feishu': {
|
|
const config = request.config as Partial<FeishuConfig>;
|
|
if (!config.webhookUrl) {
|
|
return { valid: false, error: 'Feishu webhook URL is required' };
|
|
}
|
|
if (!isValidUrl(config.webhookUrl)) {
|
|
return { valid: false, error: 'Invalid Feishu webhook URL format' };
|
|
}
|
|
break;
|
|
}
|
|
case 'dingtalk': {
|
|
const config = request.config as Partial<DingTalkConfig>;
|
|
if (!config.webhookUrl) {
|
|
return { valid: false, error: 'DingTalk webhook URL is required' };
|
|
}
|
|
if (!isValidUrl(config.webhookUrl)) {
|
|
return { valid: false, error: 'Invalid DingTalk webhook URL format' };
|
|
}
|
|
break;
|
|
}
|
|
case 'wecom': {
|
|
const config = request.config as Partial<WeComConfig>;
|
|
if (!config.webhookUrl) {
|
|
return { valid: false, error: 'WeCom webhook URL is required' };
|
|
}
|
|
if (!isValidUrl(config.webhookUrl)) {
|
|
return { valid: false, error: 'Invalid WeCom webhook URL format' };
|
|
}
|
|
break;
|
|
}
|
|
case 'email': {
|
|
const config = request.config as Partial<EmailConfig>;
|
|
if (!config.host) {
|
|
return { valid: false, error: 'SMTP host is required' };
|
|
}
|
|
if (!config.username) {
|
|
return { valid: false, error: 'SMTP username is required' };
|
|
}
|
|
if (!config.password) {
|
|
return { valid: false, error: 'SMTP password is required' };
|
|
}
|
|
if (!config.from) {
|
|
return { valid: false, error: 'Sender email address is required' };
|
|
}
|
|
if (!isValidEmail(config.from)) {
|
|
return { valid: false, error: 'Invalid sender email address' };
|
|
}
|
|
if (!config.to || config.to.length === 0) {
|
|
return { valid: false, error: 'At least one recipient email is required' };
|
|
}
|
|
for (const addr of config.to) {
|
|
if (!isValidEmail(addr)) {
|
|
return { valid: false, error: `Invalid recipient email: ${addr}` };
|
|
}
|
|
}
|
|
if (config.port !== undefined && !isValidSmtpPort(config.port)) {
|
|
return { valid: false, error: 'Invalid SMTP port' };
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
return { valid: true };
|
|
}
|
|
|
|
/**
|
|
* Handle remote notification routes
|
|
* GET /api/notifications/remote/config - Get current config
|
|
* POST /api/notifications/remote/config - Update config
|
|
* POST /api/notifications/remote/test - Test notification
|
|
* POST /api/notifications/remote/reset - Reset to defaults
|
|
*/
|
|
export async function handleNotificationRoutes(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
pathname: string
|
|
): Promise<boolean> {
|
|
// GET /api/notifications/remote/config
|
|
if (pathname === '/api/notifications/remote/config' && req.method === 'GET') {
|
|
const config = loadConfig();
|
|
const masked = maskSensitiveConfig(config);
|
|
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(masked));
|
|
return true;
|
|
}
|
|
|
|
// POST /api/notifications/remote/config
|
|
if (pathname === '/api/notifications/remote/config' && req.method === 'POST') {
|
|
const body = await readBody(req);
|
|
|
|
try {
|
|
const updates = JSON.parse(body) as Partial<RemoteNotificationConfig>;
|
|
|
|
// Validate input
|
|
const validation = validateConfigUpdates(updates);
|
|
if (!validation.valid) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: validation.error }));
|
|
return true;
|
|
}
|
|
|
|
const current = loadConfig();
|
|
const updated = deepMerge(current, updates);
|
|
|
|
saveConfig(updated);
|
|
|
|
// Reload service config
|
|
remoteNotificationService.reloadConfig();
|
|
|
|
const masked = maskSensitiveConfig(updated);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ success: true, config: masked }));
|
|
} catch (error) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
error: error instanceof Error ? error.message : 'Invalid configuration',
|
|
}));
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// POST /api/notifications/remote/test
|
|
if (pathname === '/api/notifications/remote/test' && req.method === 'POST') {
|
|
const body = await readBody(req);
|
|
|
|
try {
|
|
const request = JSON.parse(body) as TestNotificationRequest;
|
|
|
|
// Validate input
|
|
const validation = validateTestRequest(request);
|
|
if (!validation.valid) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ success: false, error: validation.error }));
|
|
return true;
|
|
}
|
|
|
|
const result = await remoteNotificationService.testPlatform(
|
|
request.platform as NotificationPlatform,
|
|
request.config
|
|
);
|
|
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(result));
|
|
} catch (error) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Invalid request',
|
|
}));
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// POST /api/notifications/remote/reset
|
|
if (pathname === '/api/notifications/remote/reset' && req.method === 'POST') {
|
|
const config = resetConfig();
|
|
remoteNotificationService.reloadConfig();
|
|
|
|
const masked = maskSensitiveConfig(config);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ success: true, config: masked }));
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Read request body as string
|
|
*/
|
|
async function readBody(req: IncomingMessage): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
let body = '';
|
|
req.on('data', (chunk) => { body += chunk; });
|
|
req.on('end', () => resolve(body));
|
|
req.on('error', reject);
|
|
});
|
|
}
|