Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ npm install
```env
# Telegram
TELEGRAM_BOT_TOKEN=your_bot_token_here
# BOT_TOKEN is deprecated (legacy). Use TELEGRAM_BOT_TOKEN instead.
BOT_TOKEN=your_bot_token_here

# Database
Expand Down Expand Up @@ -320,7 +321,7 @@ docker-compose up -d

See [SECURITY.md](./SECURITY.md) for details on validation, authentication, encryption, audit logging, and key rotation.

Required security env vars: `ENCRYPTION_KEY`, `BOT_TOKEN`.
Required security env vars: `ENCRYPTION_KEY`, `TELEGRAM_BOT_TOKEN` (legacy `BOT_TOKEN` is deprecated).

## 🔐 Безопасность

Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
## Переменные окружения
- ENCRYPTION_KEY (обязательная, минимум 32 символа)
- ENCRYPTION_KEY_VERSION (отслеживает версию ключа)
- BOT_TOKEN (токен Telegram-бота для валидации initData)
- TELEGRAM_BOT_TOKEN (токен Telegram-бота для валидации initData; legacy BOT_TOKEN deprecated)

## Ротация ключей
1. Сгенерируйте новый ENCRYPTION_KEY
Expand Down
18 changes: 16 additions & 2 deletions packages/core/src/__tests__/api.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ const request = supertest.agent(app);
let pool: ReturnType<typeof createTestPostgresPool>;
let postgresPool: any;
const encryptionKey = process.env.ENCRYPTION_KEY as string;
const botToken = process.env.BOT_TOKEN || 'test-bot-token';
const botToken = process.env.TELEGRAM_BOT_TOKEN || 'test-bot-token';
let previousTelegramBotToken: string | undefined;
let previousBotToken: string | undefined;

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

Expand Down Expand Up @@ -60,6 +62,7 @@ async function deleteBotApi(userId: number, botId: string) {
}

beforeEach(async () => {
process.env.TELEGRAM_BOT_TOKEN = botToken;
const redisClient = await redisModule.getRedisClientOptional();
await cleanupAllTestState(pool, redisClient);

Expand All @@ -69,13 +72,24 @@ beforeEach(async () => {
});

afterEach(() => {
if (previousTelegramBotToken === undefined) {
delete process.env.TELEGRAM_BOT_TOKEN;
} else {
process.env.TELEGRAM_BOT_TOKEN = previousTelegramBotToken;
}
if (previousBotToken === undefined) {
delete process.env.BOT_TOKEN;
} else {
process.env.BOT_TOKEN = previousBotToken;
}
// Defensive reset in case a test fails before its own `finally` cleanup
setRedisUnavailableForTests(false);
setRedisAvailableForTests(true);
});

beforeAll(async () => {
process.env.BOT_TOKEN = botToken;
previousTelegramBotToken = process.env.TELEGRAM_BOT_TOKEN;
previousBotToken = process.env.BOT_TOKEN;
pool = createTestPostgresPool();

// Initialize databases explicitly to ensure dbInitialized flag is set before any /health request
Expand Down
23 changes: 20 additions & 3 deletions packages/core/src/__tests__/broadcasts-api.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import supertest from 'supertest';
import crypto from 'crypto';
import { createApp } from '../index';
Expand All @@ -11,7 +11,9 @@ const app = createApp();
const request = supertest.agent(app);
let pool: ReturnType<typeof createTestPostgresPool>;
const encryptionKey = process.env.ENCRYPTION_KEY as string;
const botToken = process.env.BOT_TOKEN || 'test-bot-token';
const botToken = process.env.TELEGRAM_BOT_TOKEN || 'test-bot-token';
let previousTelegramBotToken: string | undefined;
let previousBotToken: string | undefined;
const rateLimitCooldownMs = 1500;

async function seedBotUsers(botId: string, telegramIds: string[]) {
Expand All @@ -38,13 +40,15 @@ async function seedBotUsers(botId: string, telegramIds: string[]) {
}

beforeEach(async () => {
process.env.TELEGRAM_BOT_TOKEN = botToken;
const redisClient = await getRedisClientOptional();
await cleanupAllTestState(pool, redisClient);
await new Promise((resolve) => setTimeout(resolve, rateLimitCooldownMs));
});

beforeAll(async () => {
process.env.BOT_TOKEN = botToken;
previousTelegramBotToken = process.env.TELEGRAM_BOT_TOKEN;
previousBotToken = process.env.BOT_TOKEN;
pool = createTestPostgresPool();

const { initializeRateLimiters } = await import('../index');
Expand All @@ -57,6 +61,19 @@ afterAll(async () => {
}
});

afterEach(() => {
if (previousTelegramBotToken === undefined) {
delete process.env.TELEGRAM_BOT_TOKEN;
} else {
process.env.TELEGRAM_BOT_TOKEN = previousTelegramBotToken;
}
if (previousBotToken === undefined) {
delete process.env.BOT_TOKEN;
} else {
process.env.BOT_TOKEN = previousBotToken;
}
});

describe('Broadcasts API', () => {
it('creates broadcast and returns total recipients', async () => {
const botId = crypto.randomUUID();
Expand Down
42 changes: 40 additions & 2 deletions packages/core/src/db/postgres.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,26 @@ type PostgresConnectionInfo = {
user: string;
};

function getPostgresConnectionInfo(connectionString: string): PostgresConnectionInfo | null {
const LOCALHOST_HOSTS = new Set([
'localhost',
'localhost.localdomain',
'127.0.0.1',
'127.0.1.1',
'::1',
'0.0.0.0',
]);

function normalizeHost(host?: string): string {
if (!host) return '';
return host.trim().toLowerCase().replace(/^\[/, '').replace(/\]$/, '');
}

function isLocalhostHost(host?: string): boolean {
const normalized = normalizeHost(host);
return LOCALHOST_HOSTS.has(normalized);
}

export function getPostgresConnectionInfo(connectionString: string): PostgresConnectionInfo | null {
const activePool = pool;

try {
Expand Down Expand Up @@ -223,11 +242,15 @@ async function connectWithRetry(
urlValid,
diagnostics,
}, 'PostgreSQL connection state: error');
const localhostHint =
isVercel && connectionInfo && isLocalhostHost(connectionInfo.host)
? ' Check DATABASE_URL: localhost is unreachable on Vercel.'
: '';
throw new Error(
`PostgreSQL connection failed after ${attempt} attempts ` +
`(${formatPostgresConnectionInfo(connectionInfo)}). ` +
`URL format: ${urlValid ? 'valid' : 'invalid'}. ` +
`Likely cause: ${diagnostics.category} (${diagnostics.hint})`
`Likely cause: ${diagnostics.category} (${diagnostics.hint}).${localhostHint}`
);
}
logger?.warn({
Expand Down Expand Up @@ -272,6 +295,21 @@ export async function initPostgres(loggerInstance: Logger): Promise<Pool> {
const poolConfig = getPostgresPoolConfig();
const connectionInfo = getPostgresConnectionInfo(connectionString);

if (isVercel && connectionInfo && isLocalhostHost(connectionInfo.host)) {
logger?.error({
service: 'postgres',
environment: 'Vercel serverless',
detectedHost: connectionInfo.host,
vercelEnv: process.env.VERCEL_ENV,
hint: 'Use Neon (neon.tech) or Supabase (supabase.com) for serverless PostgreSQL',
}, '❌ Invalid DATABASE_URL: localhost detected on Vercel');
throw new Error(
`DATABASE_URL points to localhost (${connectionInfo.host}) on Vercel. ` +
'Use a production PostgreSQL service (Neon, Supabase, AWS RDS) with public endpoint. ' +
'Update DATABASE_URL in Vercel Dashboard → Settings → Environment Variables.'
);
}

const { max, idleTimeoutMillis, connectionTimeoutMillis } = poolConfig;
logger?.info({
service: 'postgres',
Expand Down
34 changes: 30 additions & 4 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import { Telegraf, session } from 'telegraf';
import { Scenes } from 'telegraf';
import pinoHttp from 'pino-http';
import { z } from 'zod';
import { BOT_LIMITS, RATE_LIMITS, WEBHOOK_INTEGRATION_LIMITS, BotIdSchema, BroadcastIdSchema, CreateBotSchema, CreateBroadcastSchema, PaginationSchema, UpdateBotSchemaSchema, createLogger, createRateLimiter, errorMetricsMiddleware, getErrorMetrics, logBroadcastCreated, logRateLimitMetrics, metricsMiddleware, requestContextMiddleware, requestIdMiddleware, requireBotOwnership, validateBody, validateParams, validateQuery, validateTelegramWebAppData } from '@dialogue-constructor/shared';
import { BOT_LIMITS, RATE_LIMITS, WEBHOOK_INTEGRATION_LIMITS, BotIdSchema, BroadcastIdSchema, CreateBotSchema, CreateBroadcastSchema, PaginationSchema, UpdateBotSchemaSchema, createLogger, createRateLimiter, errorMetricsMiddleware, getErrorMetrics, getTelegramBotToken, logBroadcastCreated, logRateLimitMetrics, metricsMiddleware, requestContextMiddleware, requestIdMiddleware, requireBotOwnership, validateBody, validateParams, validateQuery, validateTelegramWebAppData } from '@dialogue-constructor/shared';
import { getRequestId, validateBotSchema } from '@dialogue-constructor/shared/server';
import { initPostgres, closePostgres, getPoolStats, getPostgresCircuitBreakerStats, getPostgresConnectRetryBudgetMs, getPostgresRetryStats, POSTGRES_RETRY_CONFIG, getPostgresClient, getPostgresPoolConfig } from './db/postgres';
import { initPostgres, closePostgres, getPoolStats, getPostgresCircuitBreakerStats, getPostgresConnectRetryBudgetMs, getPostgresRetryStats, POSTGRES_RETRY_CONFIG, getPostgresClient, getPostgresPoolConfig, getPostgresConnectionInfo } from './db/postgres';
import { initRedis, closeRedis, getRedisCircuitBreakerStats, getRedisClientOptional, getRedisRetryStats } from './db/redis';
import { initializeBotsTable, getBotsByUserId, getBotsByUserIdPaginated, getBotById, updateBotSchema, createBot, deleteBot } from './db/bots';
import { exportBotUsersToCSV, getBotTelegramUserIds, getBotUsers, getBotUserStats } from './db/bot-users';
Expand Down Expand Up @@ -987,12 +987,26 @@ app.get('/health', async (req: Request, res: Response) => {

const statusCode = status === 'error' ? 503 : 200;
const timestamp = new Date().toISOString();
const minimalConnectionInfo = (() => {
if (process.env.VERCEL !== '1') return null;
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) return null;
const info = getPostgresConnectionInfo(dbUrl);
if (!info) return null;
const host = info.host?.trim().toLowerCase().replace(/^\[/, '').replace(/\]$/, '');
return {
host,
port: info.port,
database: info.database,
};
})();
const minimalHealth = {
status,
timestamp,
databases: {
postgres: {
status: postgresState,
...(minimalConnectionInfo ? { connectionInfo: minimalConnectionInfo } : {}),
},
redis: {
status: redisState,
Expand Down Expand Up @@ -1067,6 +1081,18 @@ app.get('/health', async (req: Request, res: Response) => {
status: postgresState,
pool: poolInfo,
poolConfig: postgresPoolConfig,
connectionInfo: (() => {
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) return null;
const info = getPostgresConnectionInfo(dbUrl);
return info
? {
host: info.host,
port: info.port,
database: info.database,
}
: null;
})(),
},
redis: {
status: redisState,
Expand Down Expand Up @@ -1109,9 +1135,9 @@ async function requireUserId(req: Request, res: Response, next: Function) {
return res.status(401).json({ error: 'Unauthorized' });
}

const botToken = process.env.BOT_TOKEN;
const botToken = getTelegramBotToken();
if (!botToken) {
return res.status(500).json({ error: 'BOT_TOKEN is not set' });
return res.status(500).json({ error: 'TELEGRAM_BOT_TOKEN is not set' });
}

const validation = validateTelegramWebAppData(initData, botToken);
Expand Down
33 changes: 27 additions & 6 deletions packages/mini-app/src/pages/BotList.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../utils/api';
import { api, formatApiError } from '../utils/api';
import { BotSummary } from '../types';

const WebApp = window.Telegram?.WebApp;
Expand Down Expand Up @@ -34,8 +34,13 @@ export default function BotList() {
setBots(data.bots);
setPagination(data.pagination);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Ошибка загрузки ботов';
console.error('❌ Error loading bots:', err);
const status = (err as any).status;
const errorMessage = formatApiError(err);
console.error('❌ Error loading bots:', {
error: err,
status,
message: errorMessage,
});
setError(errorMessage);
WebApp?.showAlert(errorMessage);
} finally {
Expand All @@ -55,9 +60,15 @@ export default function BotList() {
setBots((prev) => [...prev, ...data.bots]);
setPagination(data.pagination);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Ошибка загрузки ботов';
console.error('❌ Error loading more bots:', err);
WebApp?.showAlert(errorMessage);
const status = (err as any).status;
const errorMessage = formatApiError(err);
console.error('❌ Error loading more bots:', {
error: err,
status,
message: errorMessage,
});

// Не спамим модальным алертом при пагинации (loadMore); при желании — мягкий toast/индикатор
} finally {
setLoadingMore(false);
}
Expand Down Expand Up @@ -88,6 +99,16 @@ export default function BotList() {
<div className="empty-state">
<div className="empty-state-icon">❌</div>
<div className="empty-state-text">{error}</div>
<div
className="empty-state-hint"
style={{
marginTop: '8px',
fontSize: '12px',
color: 'var(--tg-theme-hint-color)',
}}
>
💡 Если открыто в браузере: откройте F12 → Network и проверьте запросы к API
</div>
<button className="btn btn-primary" onClick={loadBots} style={{ marginTop: '16px' }}>
Повторить
</button>
Expand Down
Loading
Loading