[0.6.1] - 2025-09-02
Some checks failed
Deploy / deploy (push) Has been skipped
CI / lint (push) Failing after 8s
CI / test (push) Failing after 10m26s

### 🚀 Изменено - Упрощение архитектуры
- **Генерация миниатюр**: Полностью удалена из Quoter, теперь управляется Vercel Edge API
- **Очистка legacy кода**: Удалены все функции генерации миниатюр и сложность
- **Документация**: Сокращена с 17 файлов до 7, следуя принципам KISS/DRY
- **Смена фокуса**: Quoter теперь сосредоточен на upload + storage, Vercel обрабатывает миниатюры
- **Логирование запросов**: Добавлена аналитика источников для оптимизации CORS whitelist
- **Реализация таймаутов**: Добавлены настраиваемые таймауты для S3, Redis и внешних операций
- **Упрощенная безопасность**: Удален сложный rate limiting, оставлена только необходимая защита upload

### 📝 Обновлено
- Консолидирована документация в практическую структуру:
  - Основной README.md с быстрым стартом
  - docs/SETUP.md для конфигурации и развертывания
  - Упрощенный features.md с фокусом на основную функциональность
- Добавлен акцент на Vercel по всему коду и документации

### 🗑️ Удалено
- Избыточные файлы документации (api-reference, deployment, development, и т.д.)
- Дублирующийся контент в нескольких документах
- Излишне детальная документация для простого файлового прокси

💋 **Упрощение**: KISS принцип применен - убрали избыточность, оставили суть.
This commit is contained in:
2025-09-02 14:00:54 +03:00
parent b876564f4a
commit 7973ba0027
32 changed files with 1168 additions and 3855 deletions

View File

@@ -1,42 +1,22 @@
use actix_web::{HttpRequest, dev::ServiceRequest, middleware::Next, dev::ServiceResponse, error::ErrorTooManyRequests};
use log::{warn, error, info};
use redis::{AsyncCommands, aio::MultiplexedConnection};
use std::time::{SystemTime, UNIX_EPOCH};
use actix_web::HttpRequest;
use log::warn;
use std::collections::HashMap;
use tokio::sync::RwLock;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::RwLock;
/// Конфигурация лимитов запросов
/// Простая защита от злоупотреблений для upload endpoint
#[derive(Debug, Clone)]
pub struct RateLimitConfig {
/// Максимальное количество запросов в окне времени
pub max_requests: u32,
/// Окно времени в секундах
pub window_seconds: u64,
/// Блокировка на количество секунд при превышении лимита
pub block_duration_seconds: u64,
pub struct UploadProtection {
/// Максимальное количество загрузок в минуту с одного IP
pub max_uploads_per_minute: u32,
/// Локальный кэш для подсчета загрузок
pub upload_counts: Arc<RwLock<HashMap<String, (u32, u64)>>>,
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
max_requests: 100, // 100 запросов
window_seconds: 60, // в минуту
block_duration_seconds: 300, // блокировка на 5 минут
}
}
}
/// Конфигурация для разных типов запросов
/// Конфигурация безопасности для простого storage proxy
#[derive(Debug, Clone)]
pub struct SecurityConfig {
/// Общий лимит по IP
pub general_rate_limit: RateLimitConfig,
/// Лимит для загрузки файлов
pub upload_rate_limit: RateLimitConfig,
/// Лимит для аутентификации
pub auth_rate_limit: RateLimitConfig,
/// Максимальный размер тела запроса (байты)
pub max_payload_size: usize,
/// Таймаут запроса (секунды)
@@ -47,217 +27,52 @@ pub struct SecurityConfig {
pub max_headers_count: usize,
/// Максимальная длина значения заголовка
pub max_header_value_length: usize,
/// Защита от злоупотреблений upload
pub upload_protection: UploadProtection,
}
impl Default for SecurityConfig {
fn default() -> Self {
Self {
general_rate_limit: RateLimitConfig::default(),
upload_rate_limit: RateLimitConfig {
max_requests: 10, // 10 загрузок
window_seconds: 300, // в 5 минут
block_duration_seconds: 600, // блокировка на 10 минут
},
auth_rate_limit: RateLimitConfig {
max_requests: 20, // 20 попыток аутентификации
window_seconds: 900, // в 15 минут
block_duration_seconds: 1800, // блокировка на 30 минут
},
max_payload_size: 4000 * 1024 * 1024, // 4000 МБ
max_payload_size: 500 * 1024 * 1024, // 500MB
request_timeout_seconds: 300, // 5 минут
max_path_length: 1000,
max_headers_count: 50,
max_header_value_length: 8192,
upload_protection: UploadProtection {
max_uploads_per_minute: 10, // 10 загрузок в минуту
upload_counts: Arc::new(RwLock::new(HashMap::new())),
},
}
}
}
/// Структура для хранения информации о запросах
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RequestInfo {
pub count: u32,
pub first_request_time: u64,
pub blocked_until: Option<u64>,
}
/// Менеджер безопасности
pub struct SecurityManager {
pub config: SecurityConfig,
redis: MultiplexedConnection,
// Локальный кэш для быстрых проверок
local_cache: Arc<RwLock<HashMap<String, RequestInfo>>>,
}
impl SecurityManager {
pub fn new(config: SecurityConfig, redis: MultiplexedConnection) -> Self {
Self {
config,
redis,
local_cache: Arc::new(RwLock::new(HashMap::new())),
}
}
/// Получает IP адрес из запроса, учитывая прокси
pub fn extract_client_ip(req: &HttpRequest) -> String {
// Проверяем заголовки прокси
if let Some(forwarded_for) = req.headers().get("x-forwarded-for") {
if let Ok(forwarded_str) = forwarded_for.to_str() {
if let Some(first_ip) = forwarded_str.split(',').next() {
return first_ip.trim().to_string();
}
}
}
if let Some(real_ip) = req.headers().get("x-real-ip") {
if let Ok(ip_str) = real_ip.to_str() {
return ip_str.to_string();
}
}
// Fallback к connection info
req.connection_info()
.realip_remote_addr()
.unwrap_or("unknown")
.to_string()
}
/// Проверяет лимиты запросов для IP
pub async fn check_rate_limit(&mut self, ip: &str, endpoint_type: &str) -> Result<(), actix_web::Error> {
let config = match endpoint_type {
"upload" => &self.config.upload_rate_limit,
"auth" => &self.config.auth_rate_limit,
_ => &self.config.general_rate_limit,
};
let current_time = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let redis_key = format!("rate_limit:{}:{}", endpoint_type, ip);
// Проверяем локальный кэш
{
let cache = self.local_cache.read().await;
if let Some(info) = cache.get(&redis_key) {
if let Some(blocked_until) = info.blocked_until {
if current_time < blocked_until {
warn!("IP {} blocked until {}", ip, blocked_until);
return Err(ErrorTooManyRequests("Rate limit exceeded, IP temporarily blocked"));
}
}
}
}
// Проверяем в Redis
let info_str: Option<String> = self.redis.get(&redis_key).await
.map_err(|e| {
error!("Redis error in rate limit check: {}", e);
actix_web::error::ErrorInternalServerError("Service temporarily unavailable")
})?;
let mut request_info = if let Some(info_str) = info_str {
serde_json::from_str::<RequestInfo>(&info_str)
.unwrap_or_else(|_| RequestInfo {
count: 0,
first_request_time: current_time,
blocked_until: None,
})
} else {
RequestInfo {
count: 0,
first_request_time: current_time,
blocked_until: None,
}
};
// Проверяем блокировку
if let Some(blocked_until) = request_info.blocked_until {
if current_time < blocked_until {
warn!("IP {} is blocked until {}", ip, blocked_until);
return Err(ErrorTooManyRequests("Rate limit exceeded, IP temporarily blocked"));
} else {
// Блокировка истекла, сбрасываем
request_info.blocked_until = None;
request_info.count = 0;
request_info.first_request_time = current_time;
}
}
// Проверяем окно времени
if current_time - request_info.first_request_time > config.window_seconds {
// Новое окно времени, сбрасываем счетчик
request_info.count = 0;
request_info.first_request_time = current_time;
}
// Увеличиваем счетчик
request_info.count += 1;
// Проверяем лимит
if request_info.count > config.max_requests {
warn!("Rate limit exceeded for IP {}: {} requests in window", ip, request_info.count);
// Устанавливаем блокировку
request_info.blocked_until = Some(current_time + config.block_duration_seconds);
// Сохраняем в Redis
let info_str = serde_json::to_string(&request_info).unwrap();
let _: () = self.redis.set_ex(&redis_key, info_str, config.block_duration_seconds).await
.map_err(|e| {
error!("Redis error saving rate limit: {}", e);
actix_web::error::ErrorInternalServerError("Service temporarily unavailable")
})?;
// Обновляем локальный кэш
{
let mut cache = self.local_cache.write().await;
cache.insert(redis_key, request_info);
}
return Err(ErrorTooManyRequests("Rate limit exceeded, IP temporarily blocked"));
}
// Сохраняем обновленную информацию
let info_str = serde_json::to_string(&request_info).unwrap();
let _: () = self.redis.set_ex(&redis_key, info_str, config.window_seconds * 2).await
.map_err(|e| {
error!("Redis error updating rate limit: {}", e);
actix_web::error::ErrorInternalServerError("Service temporarily unavailable")
})?;
let count = request_info.count;
// Обновляем локальный кэш
{
let mut cache = self.local_cache.write().await;
cache.insert(redis_key, request_info);
}
info!("Rate limit check passed for IP {}: {}/{} requests", ip, count, config.max_requests);
Ok(())
}
/// Проверяет безопасность запроса (размер, заголовки, путь)
pub fn validate_request_security(&self, req: &HttpRequest) -> Result<(), actix_web::Error> {
// Проверка длины пути
impl SecurityConfig {
/// Валидирует запрос на базовые параметры безопасности
pub fn validate_request(&self, req: &HttpRequest) -> Result<(), actix_web::Error> {
let path = req.path();
if path.len() > self.config.max_path_length {
warn!("Request path too long: {} chars", path.len());
return Err(actix_web::error::ErrorBadRequest("Request path too long"));
// Проверка длины пути
if path.len() > self.max_path_length {
warn!("Path too long: {} chars", path.len());
return Err(actix_web::error::ErrorBadRequest("Path too long"));
}
// Проверка количества заголовков
if req.headers().len() > self.config.max_headers_count {
if req.headers().len() > self.max_headers_count {
warn!("Too many headers: {}", req.headers().len());
return Err(actix_web::error::ErrorBadRequest("Too many headers"));
}
// Проверка длины значений заголовков
for (name, value) in req.headers().iter() {
for (name, value) in req.headers() {
if let Ok(value_str) = value.to_str() {
if value_str.len() > self.config.max_header_value_length {
warn!("Header value too long: {} = {} chars", name, value_str.len());
if value_str.len() > self.max_header_value_length {
warn!(
"Header value too long: {} = {} chars",
name,
value_str.len()
);
return Err(actix_web::error::ErrorBadRequest("Header value too long"));
}
}
@@ -266,81 +81,104 @@ impl SecurityManager {
// Проверка на подозрительные символы в пути
if path.contains("..") || path.contains('\0') || path.contains('\r') || path.contains('\n') {
warn!("Suspicious characters in path: {}", path);
return Err(actix_web::error::ErrorBadRequest("Invalid characters in path"));
return Err(actix_web::error::ErrorBadRequest(
"Invalid characters in path",
));
}
// Проверка на подозрительные паттерны
if self.check_suspicious_patterns(path) {
return Err(actix_web::error::ErrorBadRequest("Suspicious path pattern"));
}
Ok(())
}
/// Проверяет подозрительные паттерны в пути
/// Проверяет путь на подозрительные паттерны
pub fn check_suspicious_patterns(&self, path: &str) -> bool {
let suspicious_patterns = [
"/admin", "/wp-admin", "/phpmyadmin", "/.env", "/config",
"/.git", "/backup", "/db", "/sql", "/.well-known/acme-challenge",
"/xmlrpc.php", "/wp-login.php", "/wp-config.php",
"script>", "<iframe", "javascript:", "data:",
"/admin",
"/wp-admin",
"/phpmyadmin",
"/.env",
"/config",
"/.git",
"/backup",
"/db",
"/sql",
"/xmlrpc.php",
"/wp-login.php",
"/wp-config.php",
"script>",
"<iframe",
"javascript:",
"data:",
];
let path_lower = path.to_lowercase();
for pattern in &suspicious_patterns {
if path_lower.contains(pattern) {
warn!("Suspicious pattern detected in path: {} (pattern: {})", path, pattern);
warn!(
"Suspicious pattern detected in path: {} (pattern: {})",
path, pattern
);
return true;
}
}
false
}
/// Очистка старых записей из локального кэша
pub async fn cleanup_cache(&mut self) {
/// Проверяет лимит загрузок для IP (только для upload endpoint)
pub async fn check_upload_limit(&self, ip: &str) -> Result<(), actix_web::Error> {
let current_time = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let mut cache = self.local_cache.write().await;
let mut to_remove = Vec::new();
let mut counts = self.upload_protection.upload_counts.write().await;
// Очищаем старые записи (старше минуты)
counts.retain(|_, (_, timestamp)| current_time - *timestamp < 60);
// Проверяем текущий IP
let current_count = counts.get(ip).map(|(count, _)| *count).unwrap_or(0);
let first_upload_time = counts.get(ip).map(|(_, time)| *time).unwrap_or(current_time);
if current_time - first_upload_time < 60 {
// В пределах минуты
if current_count >= self.upload_protection.max_uploads_per_minute {
warn!("Upload limit exceeded for IP {}: {} uploads in minute", ip, current_count);
return Err(actix_web::error::ErrorTooManyRequests("Upload limit exceeded"));
}
counts.insert(ip.to_string(), (current_count + 1, first_upload_time));
} else {
// Новая минута, сбрасываем счетчик
counts.insert(ip.to_string(), (1, current_time));
}
Ok(())
}
for (key, info) in cache.iter() {
// Удаляем записи старше 1 часа
if current_time - info.first_request_time > 3600 {
to_remove.push(key.clone());
/// Извлекает IP адрес клиента
pub fn extract_client_ip(req: &HttpRequest) -> String {
// Проверяем X-Forwarded-For (для прокси)
if let Some(forwarded) = req.headers().get("x-forwarded-for") {
if let Ok(forwarded_str) = forwarded.to_str() {
if let Some(first_ip) = forwarded_str.split(',').next() {
return first_ip.trim().to_string();
}
}
}
for key in to_remove {
cache.remove(&key);
// Проверяем X-Real-IP
if let Some(real_ip) = req.headers().get("x-real-ip") {
if let Ok(real_ip_str) = real_ip.to_str() {
return real_ip_str.to_string();
}
}
info!("Cleaned {} old entries from security cache", cache.len());
// Fallback на connection info
req.connection_info().peer_addr().unwrap_or("unknown").to_string()
}
}
/// Middleware для проверки безопасности
pub async fn security_middleware(
req: ServiceRequest,
next: Next<impl actix_web::body::MessageBody>,
) -> Result<ServiceResponse<impl actix_web::body::MessageBody>, actix_web::Error> {
let path = req.path().to_string();
let method = req.method().to_string();
// Быстрая проверка на известные атаки
if path.contains("..") || path.contains('\0') || path.len() > 1000 {
warn!("Blocked suspicious request: {} {}", method, path);
return Err(actix_web::error::ErrorBadRequest("Invalid request"));
}
// Проверка на bot patterns
if let Some(user_agent) = req.headers().get("user-agent") {
if let Ok(ua_str) = user_agent.to_str() {
let ua_lower = ua_str.to_lowercase();
if ua_lower.contains("bot") || ua_lower.contains("crawler") || ua_lower.contains("spider") {
// Для ботов применяем более строгие лимиты
info!("Bot detected: {}", ua_str);
}
}
}
let res = next.call(req).await?;
Ok(res)
}