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
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:
parent
aad1a51028
commit
0df722d3bd
5 changed files with 218 additions and 18 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
194
mnemo_cards_backend/lib/api/v2/admin_auth_rate_limiter.dart
Normal file
194
mnemo_cards_backend/lib/api/v2/admin_auth_rate_limiter.dart
Normal 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);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)');
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue