s
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
Deploy Telegram Bot / Deploy Telegram Bot (push) Waiting to run

This commit is contained in:
Dmitry 2025-12-11 23:21:38 +03:00
parent aad1a51028
commit 0df722d3bd
5 changed files with 218 additions and 18 deletions

View file

@ -60,7 +60,7 @@ EXPOSE 3000
# Используем 127.0.0.1 для healthcheck (внутри контейнера работает даже если сервер слушает на 0.0.0.0)
# Сервер работает только по HTTP (HTTPS обрабатывается на уровне reverse proxy в Coolify)
# Пробуем curl, если не работает - используем wget как fallback
HEALTHCHECK --interval=15s --timeout=10s --start-period=45s --retries=3 \
HEALTHCHECK --interval=15s --timeout=10s --start-period=30s --retries=3 \
CMD curl -f -sS --max-time 8 --connect-timeout 3 http://127.0.0.1:${PORT:-3000}/health > /dev/null 2>&1 || \
wget --quiet --tries=1 --timeout=8 --spider http://127.0.0.1:${PORT:-3000}/health || exit 1

View file

@ -0,0 +1,194 @@
import 'dart:collection';
import 'package:shelf/shelf.dart';
/// Simple in-memory rate limiter for admin auth endpoints
/// In production, consider using Redis or similar distributed cache
class AdminAuthRateLimiter {
// Track request counts per IP
final Map<String, _RateLimitData> _requestCounts = {};
// Limits
static const int maxCodeGenerationsPerHour = 100; // Max 10 codes per hour per IP
static const int maxVerifyAttemptsPerHour = 100; // Max 20 verify attempts per hour per IP
static const int maxStatusChecksPerMinute = 60; // Max 30 status checks per minute per IP
static const Duration codeGenerationWindow = Duration(hours: 1);
static const Duration verifyWindow = Duration(hours: 1);
static const Duration statusCheckWindow = Duration(minutes: 1);
/// Check if IP can generate a new code
bool canGenerateCode(String ip) {
final now = DateTime.now();
final data = _requestCounts.putIfAbsent(
ip,
() => _RateLimitData(),
);
// Clean old code generation requests
data.codeGenerations.removeWhere(
(timestamp) => now.difference(timestamp) > codeGenerationWindow,
);
if (data.codeGenerations.length >= maxCodeGenerationsPerHour) {
return false;
}
data.codeGenerations.add(now);
return true;
}
/// Check if IP can attempt verification
bool canVerify(String ip) {
final now = DateTime.now();
final data = _requestCounts.putIfAbsent(
ip,
() => _RateLimitData(),
);
// Clean old verify attempts
data.verifyAttempts.removeWhere(
(timestamp) => now.difference(timestamp) > verifyWindow,
);
if (data.verifyAttempts.length >= maxVerifyAttemptsPerHour) {
return false;
}
data.verifyAttempts.add(now);
return true;
}
/// Check if IP can check code status
bool canCheckStatus(String ip) {
final now = DateTime.now();
final data = _requestCounts.putIfAbsent(
ip,
() => _RateLimitData(),
);
// Clean old status checks
data.statusChecks.removeWhere(
(timestamp) => now.difference(timestamp) > statusCheckWindow,
);
if (data.statusChecks.length >= maxStatusChecksPerMinute) {
return false;
}
data.statusChecks.add(now);
return true;
}
/// Get remaining attempts for code generation
int getRemainingCodeGenerations(String ip) {
final data = _requestCounts[ip];
if (data == null) return maxCodeGenerationsPerHour;
final now = DateTime.now();
data.codeGenerations.removeWhere(
(timestamp) => now.difference(timestamp) > codeGenerationWindow,
);
return maxCodeGenerationsPerHour - data.codeGenerations.length;
}
/// Get remaining attempts for verification
int getRemainingVerifyAttempts(String ip) {
final data = _requestCounts[ip];
if (data == null) return maxVerifyAttemptsPerHour;
final now = DateTime.now();
data.verifyAttempts.removeWhere(
(timestamp) => now.difference(timestamp) > verifyWindow,
);
return maxVerifyAttemptsPerHour - data.verifyAttempts.length;
}
/// Cleanup old data periodically
void cleanup() {
final now = DateTime.now();
_requestCounts.removeWhere((ip, data) {
final hasCodeGens = data.codeGenerations.any(
(timestamp) => now.difference(timestamp) <= codeGenerationWindow,
);
final hasVerifyAttempts = data.verifyAttempts.any(
(timestamp) => now.difference(timestamp) <= verifyWindow,
);
final hasStatusChecks = data.statusChecks.any(
(timestamp) => now.difference(timestamp) <= statusCheckWindow,
);
return !hasCodeGens && !hasVerifyAttempts && !hasStatusChecks;
});
}
}
class _RateLimitData {
final List<DateTime> codeGenerations = [];
final List<DateTime> verifyAttempts = [];
final List<DateTime> statusChecks = [];
}
/// Get client IP from request
String _getClientIp(Request request) {
// Check X-Forwarded-For header (for proxies/load balancers)
final forwardedFor = request.headers['x-forwarded-for'];
if (forwardedFor != null && forwardedFor.isNotEmpty) {
// X-Forwarded-For can contain multiple IPs, take the first one
return forwardedFor.split(',').first.trim();
}
// Check X-Real-IP header
final realIp = request.headers['x-real-ip'];
if (realIp != null && realIp.isNotEmpty) {
return realIp;
}
// Fallback to request IP
return request.headers['remote-addr'] ?? 'unknown';
}
/// Rate limiting middleware for admin auth endpoints
Middleware adminAuthRateLimit(AdminAuthRateLimiter rateLimiter) {
return (Handler innerHandler) {
return (Request request) async {
final path = request.requestedUri.path;
// Only apply rate limiting to admin auth endpoints
if (!path.contains('/admin/auth/')) {
return await innerHandler(request);
}
final ip = _getClientIp(request);
// Apply rate limiting based on endpoint
if (path.contains('/admin/auth/request-code')) {
if (!rateLimiter.canGenerateCode(ip)) {
return Response(
429,
headers: {'Content-Type': 'application/json'},
body: '{"error":"TooManyRequests","message":"Too many code generation requests. Please try again later."}',
);
}
} else if (path.contains('/admin/auth/verify-code')) {
if (!rateLimiter.canVerify(ip)) {
return Response(
429,
headers: {'Content-Type': 'application/json'},
body: '{"error":"TooManyRequests","message":"Too many verification attempts. Please try again later."}',
);
}
} else if (path.contains('/admin/auth/code-status')) {
if (!rateLimiter.canCheckStatus(ip)) {
return Response(
429,
headers: {'Content-Type': 'application/json'},
body: '{"error":"TooManyRequests","message":"Too many status check requests. Please try again later."}',
);
}
}
return await innerHandler(request);
};
};
}

View file

@ -11,6 +11,7 @@ const _publicAuthPaths = {
'/auth/refresh',
'/admin/auth/request-code', // Admin login code request (public)
'/admin/auth/verify-code', // Admin login code verification (public)
'/admin/auth/code-status', // Admin login code status (public)
'/tests',
'/test',
};

View file

@ -160,13 +160,8 @@ Future<void> _run() async {
try {
final config = BotConfig.fromEnvironment();
final lines = File('admins').readAsLinesSync();
final adminIds = lines.map((e) => e.trim()).toList();
if (adminIds.isEmpty) {
print('[BOT] ERROR: No admins found in admins file');
return;
}
final adminIds = config.adminIds;
print('[BOT] Admin IDs: $adminIds');
final username = (await Telegram(config.botToken).getMe()).username;
teledart = TeleDart(config.botToken, Event(username!));
@ -174,16 +169,6 @@ Future<void> _run() async {
// Store globally for shutdown handler
_globalTeledart = teledart;
Future<void> notifyAdmins(String message) async {
for (final admin in adminIds) {
try {
await teledart!.sendMessage(admin, message);
} catch (e) {
print('[BOT] ERROR: Failed to notify admin $admin: $e');
}
}
}
print('[BOT] Using backend API at ${config.backendUrl}');
backendClient = BackendClient(
@ -671,6 +656,7 @@ Future<void> _run() async {
print('Required environment variables:');
print(' - TELEGRAM_BOT_TOKEN (required)');
print(' - TELEGRAM_BOT_API_KEY (required)');
print(' - ADMIN_IDS (required, comma or space separated)');
print(' - BACKEND_URL or MNEMO_BACKEND_URL (optional, default: https://api.mnemo-cards.online)');
print(' - BOT_SHARE_DAILY_LIMIT (optional, default: 1)');

View file

@ -6,12 +6,14 @@ class BotConfig {
required this.shareDailyLimit,
required this.apiKey,
required this.botToken,
required this.adminIds,
});
final String backendUrl;
final int shareDailyLimit;
final String apiKey;
final String botToken;
final List<String> adminIds;
static const String defaultBackendUrl = 'https://api.mnemo-cards.online';
static const int defaultShareDailyLimit = 1;
@ -49,11 +51,28 @@ class BotConfig {
throw Exception('TELEGRAM_BOT_TOKEN environment variable is required');
}
// Admin IDs: required, comma or space separated
final adminIdsStr = env['ADMIN_IDS'];
if (adminIdsStr == null || adminIdsStr.trim().isEmpty) {
throw Exception(
'ADMIN_IDS environment variable is required');
}
final adminIds = adminIdsStr
.split(RegExp(r'[,;\s]+'))
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toList();
if (adminIds.isEmpty) {
throw Exception(
'ADMIN_IDS must contain at least one admin ID');
}
return BotConfig(
backendUrl: backendUrl.trim(),
shareDailyLimit: shareDailyLimit,
apiKey: apiKey,
botToken: botToken,
adminIds: adminIds,
);
}
}