134 lines
6.6 KiB
TypeScript
134 lines
6.6 KiB
TypeScript
import express from 'express';
|
||
import helmet from 'helmet';
|
||
import cors from 'cors';
|
||
import cookieParser from 'cookie-parser';
|
||
import swaggerUi from 'swagger-ui-express';
|
||
import { env } from './config/env';
|
||
import { swaggerSpec } from './config/swagger';
|
||
import { traceMiddleware } from './middleware/trace';
|
||
import { authMiddleware } from './middleware/auth';
|
||
import { csrfMiddleware } from './middleware/csrf';
|
||
import { globalLimiter, mutateLimiter, sensitiveLimiter, mnemonicRevealLimiter } from './middleware/rate-limit';
|
||
import { errorHandler } from './middleware/error-handler';
|
||
import walletRoutes from './routes/wallet.routes';
|
||
import relayProxyRoutes from './routes/relay-proxy.routes';
|
||
import jumperProxyRoutes from './routes/jumper-proxy.routes';
|
||
import tronProxyRoutes from './routes/tron-proxy.routes';
|
||
import btcProxyRoutes from './routes/btc-proxy.routes';
|
||
import pricesRoutes from './routes/prices.routes';
|
||
import tokensRoutes from './routes/tokens.routes';
|
||
import bridgeRoutes from './routes/bridge.routes';
|
||
|
||
const app = express();
|
||
|
||
// Trust proxy для корректного req.ip за reverse proxy / load balancer
|
||
app.set('trust proxy', 1);
|
||
|
||
app.use(helmet());
|
||
|
||
// CORS — поддерживаем 3 режима:
|
||
// 1. wildcard ['*'] — любой origin (для dev/staging); credentials force=false (browser spec)
|
||
// 2. whitelist [a, b, c] — только эти origins
|
||
// 3. пустой массив — все cross-origin blocked (fail-secure default)
|
||
const corsOrigins = env.cors.origins;
|
||
const corsIsWildcard = corsOrigins.length === 1 && corsOrigins[0] === '*';
|
||
if (corsIsWildcard) {
|
||
// eslint-disable-next-line no-console
|
||
console.warn('[CORS] WILDCARD enabled (CORS_ORIGINS=*) — any origin can call API. Use only for dev/staging. Production: use explicit whitelist.');
|
||
}
|
||
app.use(
|
||
cors({
|
||
origin: corsIsWildcard ? '*' : (corsOrigins.length > 0 ? corsOrigins : false),
|
||
// Wildcard incompatible с credentials per browser spec — force false при wildcard.
|
||
credentials: corsIsWildcard ? false : env.cors.allowCredentials,
|
||
}),
|
||
);
|
||
app.use(express.json({ limit: '64kb' })); // защита от больших payload-DoS
|
||
app.use(cookieParser());
|
||
app.use(traceMiddleware);
|
||
|
||
// ── PUBLIC endpoints ─────────────────────────────────────────────────────────
|
||
// H11 — /api/health with DB probe (не возвращает OK если DB down)
|
||
import { db } from './config/database';
|
||
app.get('/api/health', async (_req, res) => {
|
||
try {
|
||
await Promise.race([
|
||
db.raw('select 1'),
|
||
new Promise((_, reject) => setTimeout(() => reject(new Error('db-timeout')), 1000)),
|
||
]);
|
||
res.json({ success: true, data: { status: 'ok' } });
|
||
} catch (err: any) {
|
||
res.status(503).json({ success: false, error: 'db_unavailable' });
|
||
}
|
||
});
|
||
|
||
// ── Глобальный rate limit на /api/* — ДО docs чтобы не было unauthenticated DoS на swagger.json
|
||
app.use('/api', globalLimiter);
|
||
|
||
// H1 — Swagger gated. В production требуется basic-auth ИЛИ NODE_ENV != production.
|
||
// JSON endpoint ОБЯЗАТЕЛЬНО до app.use('/api/docs', ...) — иначе swagger-ui-express
|
||
// перехватывает все /api/docs/* и возвращает HTML вместо JSON.
|
||
const docsGate = (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||
if (process.env.NODE_ENV !== 'production' || process.env.SWAGGER_PUBLIC === 'true') {
|
||
return next();
|
||
}
|
||
// Production без SWAGGER_PUBLIC=true → require basic auth (operator credentials)
|
||
const auth = req.headers.authorization || '';
|
||
const expected = process.env.SWAGGER_BASIC_AUTH; // "user:pass"
|
||
if (!expected || !auth.startsWith('Basic ')) {
|
||
res.set('WWW-Authenticate', 'Basic realm="docs"');
|
||
res.status(401).json({ success: false, error: 'Docs auth required' });
|
||
return;
|
||
}
|
||
const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8');
|
||
if (decoded !== expected) {
|
||
res.set('WWW-Authenticate', 'Basic realm="docs"');
|
||
res.status(401).json({ success: false, error: 'Invalid docs credentials' });
|
||
return;
|
||
}
|
||
return next();
|
||
};
|
||
app.get('/api/docs/swagger.json', docsGate, (_req, res) => {
|
||
res.json(swaggerSpec);
|
||
});
|
||
app.use('/api/docs', docsGate, swaggerUi.serve, swaggerUi.setup(swaggerSpec));
|
||
|
||
// ── PROTECTED endpoints (JWT + CSRF) ─────────────────────────────────────────
|
||
const protect = [authMiddleware, csrfMiddleware];
|
||
|
||
// Sensitive — самый строгий лимит. Каждый POST защищён JWT + CSRF.
|
||
app.use('/api/wallets/create', ...protect, sensitiveLimiter);
|
||
app.use('/api/wallets/mnemonic/reveal', ...protect, mnemonicRevealLimiter);
|
||
app.use('/api/wallets/:chain/send', ...protect, sensitiveLimiter);
|
||
|
||
// Mutating (proxy + read endpoints) — повышенный лимит
|
||
app.use('/api/wallets', ...protect, mutateLimiter, walletRoutes);
|
||
app.use('/api/relay', ...protect, mutateLimiter, relayProxyRoutes);
|
||
// Jumper.xyz — LiFi-backed bridge aggregator (50+ chains: ETH/BSC/SOL/TRX/BTC + others).
|
||
// Используется когда Relay не поддерживает направление (TRX/BTC bridges).
|
||
app.use('/api/jumper', ...protect, mutateLimiter, jumperProxyRoutes);
|
||
app.use('/api/tron', ...protect, mutateLimiter, tronProxyRoutes);
|
||
app.use('/api/btc', ...protect, mutateLimiter, btcProxyRoutes);
|
||
// Legacy non-custodial swap proxies (/api/bsc/swap, /api/sol/swap, /api/tron/swap)
|
||
// УДАЛЕНЫ. Custodial 2-step swap живёт под /api/wallets/{chain}/swap{,/quote}.
|
||
|
||
// USD-цены (CoinGecko + KeyDB cache). GET-only, auth required, max 50 symbols.
|
||
app.use('/api/prices', ...protect, mutateLimiter, pricesRoutes);
|
||
|
||
// Token registry — всех известных contracts/mints по всем chain'ам. GET-only, auth required.
|
||
app.use('/api/tokens', ...protect, mutateLimiter, tokensRoutes);
|
||
|
||
// Bridge execute — one-click "Подтвердить" для bridge через Jumper (LiFi) / Relay.
|
||
// Dispatcher по source chain: EVM (approve+fee+bridge) / SOL (versioned tx) / TRX (TRC20 approve+bridge) / BTC (PSBT deposit).
|
||
// Sign + broadcast custodial через server (mnemonic не покидает API).
|
||
app.use('/api/bridge', ...protect, mutateLimiter, bridgeRoutes);
|
||
|
||
// 404 для всего что не сматчилось выше — единый JSON-ответ, не express default text
|
||
app.use((_req, res) => {
|
||
res.status(404).json({ success: false, error: 'Not found' });
|
||
});
|
||
|
||
app.use(errorHandler);
|
||
|
||
export default app;
|