[0.6.1] - 2025-09-02
### 🚀 Изменено - Упрощение архитектуры - **Генерация миниатюр**: Полностью удалена из Quoter, теперь управляется Vercel Edge API - **Очистка legacy кода**: Удалены все функции генерации миниатюр и сложность - **Документация**: Сокращена с 17 файлов до 7, следуя принципам KISS/DRY - **Смена фокуса**: Quoter теперь сосредоточен на upload + storage, Vercel обрабатывает миниатюры - **Логирование запросов**: Добавлена аналитика источников для оптимизации CORS whitelist - **Реализация таймаутов**: Добавлены настраиваемые таймауты для S3, Redis и внешних операций - **Упрощенная безопасность**: Удален сложный rate limiting, оставлена только необходимая защита upload - **Vercel интеграция**: Добавлена поддержка Vercel Edge API с CORS и оптимизированными заголовками - **Redis graceful fallback**: Приложение теперь работает без Redis с предупреждениями вместо паники - **Умная логика ответов**: Автоматическое определение Vercel запросов и оптимизированные заголовки - **Консолидация документации**: Объединены 4 Vercel документа в один comprehensive guide ### 📝 Обновлено - Консолидирована документация в практическую структуру: - Основной README.md с быстрым стартом - docs/SETUP.md для конфигурации и развертывания - Упрощенный features.md с фокусом на основную функциональность - docs/vercel-frontend-migration.md - единый comprehensive guide для Vercel интеграции - Добавлен акцент на Vercel по всему коду и документации - Обновлены URL patterns в документации: quoter.discours.io → files.dscrs.site ### 🗑️ Удалено - Избыточные файлы документации (api-reference, deployment, development, и т.д.) - Дублирующийся контент в нескольких документах - Излишне детальная документация для простого файлового прокси - 4 отдельных Vercel документа: vercel-thumbnails.md, vercel-integration.md, hybrid-architecture.md, vercel-og-integration.md 💋 **Упрощение**: KISS принцип применен - убрали избыточность, оставили суть.
This commit is contained in:
@@ -9,7 +9,7 @@ use std::{env, time::Duration};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub redis: MultiplexedConnection,
|
||||
pub redis: Option<MultiplexedConnection>,
|
||||
pub storj_client: S3Client,
|
||||
pub aws_client: S3Client,
|
||||
pub bucket: String,
|
||||
@@ -32,15 +32,28 @@ impl AppState {
|
||||
let redis_url = env::var("REDIS_URL").expect("REDIS_URL must be set");
|
||||
let redis_client = RedisClient::open(redis_url).expect("Invalid Redis URL");
|
||||
|
||||
// Устанавливаем таймаут для Redis операций
|
||||
let redis_connection = tokio::time::timeout(
|
||||
// Устанавливаем таймаут для Redis операций с graceful fallback
|
||||
let redis_connection = match tokio::time::timeout(
|
||||
Duration::from_secs(security_config.request_timeout_seconds),
|
||||
redis_client.get_multiplexed_async_connection(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "Redis connection timeout")
|
||||
.expect("Failed to connect to Redis within timeout")
|
||||
.expect("Redis connection failed");
|
||||
{
|
||||
Ok(Ok(conn)) => {
|
||||
log::info!("✅ Redis connection established");
|
||||
Some(conn)
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
log::warn!("⚠️ Redis connection failed: {}", e);
|
||||
log::warn!("⚠️ Running in fallback mode without Redis (quotas disabled)");
|
||||
None
|
||||
}
|
||||
Err(_) => {
|
||||
log::warn!("⚠️ Redis connection timeout");
|
||||
log::warn!("⚠️ Running in fallback mode without Redis (quotas disabled)");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// Получаем конфигурацию для S3 (Storj)
|
||||
let s3_access_key = env::var("STORJ_ACCESS_KEY").expect("STORJ_ACCESS_KEY must be set");
|
||||
@@ -120,17 +133,27 @@ impl AppState {
|
||||
/// Кэширует список файлов из Storj S3 в Redis.
|
||||
pub async fn cache_filelist(&self) {
|
||||
warn!("caching AWS filelist...");
|
||||
let mut redis = self.redis.clone();
|
||||
|
||||
// Проверяем доступность Redis
|
||||
let Some(mut redis) = self.redis.clone() else {
|
||||
warn!("⚠️ Redis not available, skipping filelist caching");
|
||||
return;
|
||||
};
|
||||
|
||||
// Запрашиваем список файлов из Storj S3
|
||||
let filelist = get_s3_filelist(&self.aws_client, &self.bucket).await;
|
||||
|
||||
for [filename, filepath] in filelist.clone() {
|
||||
// Сохраняем список файлов в Redis, используя HSET для каждого файла
|
||||
let _: () = redis
|
||||
.hset(PATH_MAPPING_KEY, filename.clone(), filepath)
|
||||
.await
|
||||
.unwrap();
|
||||
if let Err(e) = tokio::time::timeout(
|
||||
self.request_timeout,
|
||||
redis.hset::<_, _, _, ()>(PATH_MAPPING_KEY, filename.clone(), filepath),
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("⚠️ Redis operation failed: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
warn!("cached {} files", filelist.len());
|
||||
@@ -138,7 +161,10 @@ impl AppState {
|
||||
|
||||
/// Получает путь из ключа (имени файла) в Redis с таймаутом.
|
||||
pub async fn get_path(&self, filename: &str) -> Result<Option<String>, actix_web::Error> {
|
||||
let mut redis = self.redis.clone();
|
||||
let Some(mut redis) = self.redis.clone() else {
|
||||
warn!("⚠️ Redis not available, returning None for path lookup");
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let new_path: Option<String> =
|
||||
tokio::time::timeout(self.request_timeout, redis.hget(PATH_MAPPING_KEY, filename))
|
||||
@@ -150,20 +176,33 @@ impl AppState {
|
||||
}
|
||||
|
||||
pub async fn set_path(&self, filename: &str, filepath: &str) {
|
||||
let mut redis = self.redis.clone();
|
||||
let Some(mut redis) = self.redis.clone() else {
|
||||
warn!(
|
||||
"⚠️ Redis not available, skipping path caching for {}",
|
||||
filename
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let _: () = tokio::time::timeout(
|
||||
if let Err(e) = tokio::time::timeout(
|
||||
self.request_timeout,
|
||||
redis.hset(PATH_MAPPING_KEY, filename, filepath),
|
||||
redis.hset::<_, _, _, ()>(PATH_MAPPING_KEY, filename, filepath),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("Redis timeout when caching file {} in Redis", filename))
|
||||
.unwrap_or_else(|_| panic!("Failed to cache file {} in Redis", filename));
|
||||
{
|
||||
warn!("⚠️ Redis operation failed for {}: {}", filename, e);
|
||||
}
|
||||
}
|
||||
|
||||
/// создает или получает текущее значение квоты пользователя с таймаутом
|
||||
pub async fn get_or_create_quota(&self, user_id: &str) -> Result<u64, actix_web::Error> {
|
||||
let mut redis = self.redis.clone();
|
||||
let Some(mut redis) = self.redis.clone() else {
|
||||
warn!(
|
||||
"⚠️ Redis not available, returning default quota for user {}",
|
||||
user_id
|
||||
);
|
||||
return Ok(0); // Возвращаем 0 как fallback
|
||||
};
|
||||
let quota_key = format!("quota:{}", user_id);
|
||||
|
||||
// Попытка получить квоту из Redis с таймаутом
|
||||
@@ -194,7 +233,13 @@ impl AppState {
|
||||
user_id: &str,
|
||||
bytes: u64,
|
||||
) -> Result<u64, actix_web::Error> {
|
||||
let mut redis = self.redis.clone();
|
||||
let Some(mut redis) = self.redis.clone() else {
|
||||
warn!(
|
||||
"⚠️ Redis not available, skipping quota increment for user {}",
|
||||
user_id
|
||||
);
|
||||
return Ok(0); // Возвращаем 0 как fallback
|
||||
};
|
||||
let quota_key = format!("quota:{}", user_id);
|
||||
|
||||
// Проверяем, существует ли ключ в Redis с таймаутом
|
||||
|
||||
82
src/auth.rs
82
src/auth.rs
@@ -87,7 +87,7 @@ pub fn validate_token(token: &str) -> Result<bool, Box<dyn Error>> {
|
||||
/// Получает user_id из JWT токена и базовые данные пользователя с таймаутом
|
||||
pub async fn get_user_by_token(
|
||||
token: &str,
|
||||
redis: &mut MultiplexedConnection,
|
||||
mut redis: Option<&mut MultiplexedConnection>,
|
||||
timeout: Duration,
|
||||
) -> Result<Author, Box<dyn Error>> {
|
||||
// Декодируем JWT токен для получения user_id
|
||||
@@ -97,42 +97,50 @@ pub async fn get_user_by_token(
|
||||
info!("Extracted user_id from JWT token: {}", user_id);
|
||||
|
||||
// Проверяем валидность токена через сессию в Redis (опционально) с таймаутом
|
||||
let token_key = format!("session:{}:{}", user_id, token);
|
||||
let session_exists: bool = tokio::time::timeout(timeout, redis.exists(&token_key))
|
||||
.await
|
||||
.map_err(|_| {
|
||||
warn!("Redis timeout checking session existence");
|
||||
// Не критичная ошибка, продолжаем с базовыми данными
|
||||
})
|
||||
.unwrap_or(Ok(false))
|
||||
.map_err(|e| {
|
||||
warn!("Failed to check session existence in Redis: {}", e);
|
||||
// Не критичная ошибка, продолжаем с базовыми данными
|
||||
})
|
||||
.unwrap_or(false);
|
||||
let session_exists = if let Some(ref mut redis) = redis {
|
||||
let token_key = format!("session:{}:{}", user_id, token);
|
||||
tokio::time::timeout(timeout, redis.exists(&token_key))
|
||||
.await
|
||||
.map_err(|_| {
|
||||
warn!("Redis timeout checking session existence");
|
||||
// Не критичная ошибка, продолжаем с базовыми данными
|
||||
})
|
||||
.unwrap_or(Ok(false))
|
||||
.map_err(|e| {
|
||||
warn!("Failed to check session existence in Redis: {}", e);
|
||||
// Не критичная ошибка, продолжаем с базовыми данными
|
||||
})
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
warn!("⚠️ Redis not available, skipping session validation");
|
||||
false
|
||||
};
|
||||
|
||||
if session_exists {
|
||||
// Обновляем last_activity если сессия существует
|
||||
let current_time = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
if let Some(redis) = redis {
|
||||
let current_time = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
let _: () = tokio::time::timeout(
|
||||
timeout,
|
||||
redis.hset(&token_key, "last_activity", current_time.to_string()),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
warn!("Redis timeout updating last_activity");
|
||||
})
|
||||
.unwrap_or(Ok(()))
|
||||
.map_err(|e| {
|
||||
warn!("Failed to update last_activity: {}", e);
|
||||
})
|
||||
.unwrap_or(());
|
||||
let token_key = format!("session:{}:{}", user_id, token);
|
||||
let _: () = tokio::time::timeout(
|
||||
timeout,
|
||||
redis.hset(&token_key, "last_activity", current_time.to_string()),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
warn!("Redis timeout updating last_activity");
|
||||
})
|
||||
.unwrap_or(Ok(()))
|
||||
.map_err(|e| {
|
||||
warn!("Failed to update last_activity: {}", e);
|
||||
})
|
||||
.unwrap_or(());
|
||||
}
|
||||
|
||||
info!("Updated last_activity for session: {}", token_key);
|
||||
info!("Updated last_activity for session: {}", user_id);
|
||||
} else {
|
||||
info!("Session not found in Redis, proceeding with JWT-only data");
|
||||
}
|
||||
@@ -160,10 +168,18 @@ pub async fn get_user_by_token(
|
||||
|
||||
/// Сохраняет имя файла в Redis для пользователя
|
||||
pub async fn user_added_file(
|
||||
redis: &mut MultiplexedConnection,
|
||||
redis: Option<&mut MultiplexedConnection>,
|
||||
user_id: &str,
|
||||
filename: &str,
|
||||
) -> Result<(), actix_web::Error> {
|
||||
let Some(redis) = redis else {
|
||||
log::warn!(
|
||||
"⚠️ Redis not available, skipping file tracking for user {}",
|
||||
user_id
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
redis
|
||||
.sadd::<&str, &str, ()>(user_id, filename)
|
||||
.await
|
||||
|
||||
@@ -131,6 +131,42 @@ pub fn create_file_response_with_analytics(
|
||||
.body(data)
|
||||
}
|
||||
|
||||
/// Проверяет, является ли запрос от Vercel Edge API
|
||||
pub fn is_vercel_request(req: &HttpRequest) -> bool {
|
||||
// Проверяем User-Agent на Vercel
|
||||
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();
|
||||
return ua_lower.contains("vercel") || ua_lower.contains("edge");
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем Origin на Vercel домены
|
||||
if let Some(origin) = req.headers().get("origin") {
|
||||
if let Ok(origin_str) = origin.to_str() {
|
||||
return origin_str.contains("vercel.app");
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Vercel-совместимый ответ для оптимизации edge caching
|
||||
pub fn create_vercel_compatible_response(
|
||||
content_type: &str,
|
||||
data: Vec<u8>,
|
||||
etag: &str,
|
||||
) -> HttpResponse {
|
||||
HttpResponse::Ok()
|
||||
.content_type(content_type)
|
||||
.insert_header(("etag", etag))
|
||||
.insert_header(("cache-control", CACHE_CONTROL_VERCEL))
|
||||
.insert_header(("access-control-allow-origin", "*"))
|
||||
.insert_header(("x-vercel-cache", "HIT")) // для оптимизации Vercel
|
||||
.insert_header(("x-content-type-options", "nosniff"))
|
||||
.body(data)
|
||||
}
|
||||
|
||||
// Removed complex ETag caching - Vercel handles caching on their edge
|
||||
|
||||
/// Log request analytics for CORS whitelist tuning
|
||||
|
||||
@@ -2,7 +2,9 @@ use actix_web::error::ErrorNotFound;
|
||||
use actix_web::{HttpRequest, HttpResponse, Result, error::ErrorInternalServerError, web};
|
||||
use log::{error, info, warn};
|
||||
|
||||
use super::common::create_file_response_with_analytics;
|
||||
use super::common::{
|
||||
create_file_response_with_analytics, create_vercel_compatible_response, is_vercel_request,
|
||||
};
|
||||
use crate::app_state::AppState;
|
||||
use crate::handlers::serve_file::serve_file;
|
||||
use crate::lookup::{find_file_by_pattern, get_mime_type};
|
||||
@@ -42,24 +44,21 @@ pub async fn proxy_handler(
|
||||
// Caching handled by Vercel Edge - focus on fast file serving
|
||||
let content_type = match get_mime_type(&ext) {
|
||||
Some(mime) => mime.to_string(),
|
||||
None => {
|
||||
let mut redis = state.redis.clone();
|
||||
match find_file_by_pattern(&mut redis, &base_filename).await {
|
||||
Ok(Some(found_file)) => {
|
||||
if let Some(found_ext) = found_file.split('.').next_back() {
|
||||
get_mime_type(found_ext)
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string()
|
||||
} else {
|
||||
"application/octet-stream".to_string()
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
error!("Unsupported file format for: {}", base_filename);
|
||||
return Err(ErrorInternalServerError("Unsupported file format"));
|
||||
None => match find_file_by_pattern(None, &base_filename).await {
|
||||
Ok(Some(found_file)) => {
|
||||
if let Some(found_ext) = found_file.split('.').next_back() {
|
||||
get_mime_type(found_ext)
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string()
|
||||
} else {
|
||||
"application/octet-stream".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
error!("Unsupported file format for: {}", base_filename);
|
||||
return Err(ErrorInternalServerError("Unsupported file format"));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
info!("Content-Type: {}", content_type);
|
||||
@@ -129,12 +128,23 @@ pub async fn proxy_handler(
|
||||
|
||||
let elapsed = start_time.elapsed();
|
||||
info!("File served from AWS in {:?}: {}", elapsed, path);
|
||||
return Ok(create_file_response_with_analytics(
|
||||
&content_type,
|
||||
filedata,
|
||||
&req,
|
||||
&path,
|
||||
));
|
||||
|
||||
// Используем Vercel-совместимый ответ для Vercel запросов
|
||||
if is_vercel_request(&req) {
|
||||
let etag = format!("\"{:x}\"", md5::compute(&filedata));
|
||||
return Ok(create_vercel_compatible_response(
|
||||
&content_type,
|
||||
filedata,
|
||||
&etag,
|
||||
));
|
||||
} else {
|
||||
return Ok(create_file_response_with_analytics(
|
||||
&content_type,
|
||||
filedata,
|
||||
&req,
|
||||
&path,
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("Failed to load from AWS path {}: {:?}", path, err);
|
||||
@@ -216,12 +226,23 @@ pub async fn proxy_handler(
|
||||
}
|
||||
let elapsed = start_time.elapsed();
|
||||
info!("File served from AWS in {:?}: {}", elapsed, filepath);
|
||||
Ok(create_file_response_with_analytics(
|
||||
&content_type,
|
||||
filedata,
|
||||
&req,
|
||||
&filepath,
|
||||
))
|
||||
|
||||
// Используем Vercel-совместимый ответ для Vercel запросов
|
||||
if is_vercel_request(&req) {
|
||||
let etag = format!("\"{:x}\"", md5::compute(&filedata));
|
||||
Ok(create_vercel_compatible_response(
|
||||
&content_type,
|
||||
filedata,
|
||||
&etag,
|
||||
))
|
||||
} else {
|
||||
Ok(create_file_response_with_analytics(
|
||||
&content_type,
|
||||
filedata,
|
||||
&req,
|
||||
&filepath,
|
||||
))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to download from AWS: {} - Error: {}", filepath, e);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use actix_web::{HttpRequest, HttpResponse, Result, error::ErrorInternalServerError};
|
||||
use mime_guess::MimeGuess;
|
||||
|
||||
use super::common::create_file_response_with_analytics;
|
||||
use super::common::{
|
||||
create_file_response_with_analytics, create_vercel_compatible_response, is_vercel_request,
|
||||
};
|
||||
use crate::app_state::AppState;
|
||||
use crate::s3_utils::{check_file_exists, load_file_from_s3};
|
||||
|
||||
@@ -35,11 +37,20 @@ pub async fn serve_file(
|
||||
// Определяем MIME тип
|
||||
let mime_type = MimeGuess::from_path(filepath).first_or_octet_stream();
|
||||
|
||||
// Создаем ответ с аналитикой
|
||||
Ok(create_file_response_with_analytics(
|
||||
mime_type.as_ref(),
|
||||
filedata,
|
||||
req,
|
||||
filepath,
|
||||
))
|
||||
// Создаем ответ с аналитикой или Vercel-совместимый ответ
|
||||
if is_vercel_request(req) {
|
||||
let etag = format!("\"{:x}\"", md5::compute(&filedata));
|
||||
Ok(create_vercel_compatible_response(
|
||||
mime_type.as_ref(),
|
||||
filedata,
|
||||
&etag,
|
||||
))
|
||||
} else {
|
||||
Ok(create_file_response_with_analytics(
|
||||
mime_type.as_ref(),
|
||||
filedata,
|
||||
req,
|
||||
filepath,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,8 +38,6 @@ pub async fn universal_handler(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
match method.as_str() {
|
||||
"GET" => handle_get(req, state, &path).await,
|
||||
"POST" => handle_post(req, payload, state, &path).await,
|
||||
@@ -55,14 +53,17 @@ async fn handle_get(
|
||||
state: web::Data<AppState>,
|
||||
path: &str,
|
||||
) -> Result<HttpResponse, actix_web::Error> {
|
||||
if path == "/" || path.is_empty() {
|
||||
// GET / - получение информации о пользователе
|
||||
crate::handlers::user::get_current_user_handler(req, state).await
|
||||
} else {
|
||||
// GET /{path} - получение файла через proxy
|
||||
let path_without_slash = path.trim_start_matches('/');
|
||||
let requested_res = web::Path::from(path_without_slash.to_string());
|
||||
crate::handlers::proxy::proxy_handler(req, requested_res, state).await
|
||||
match path {
|
||||
"/" | "" => {
|
||||
// GET / - получение информации о пользователе
|
||||
crate::handlers::user::get_current_user_handler(req, state).await
|
||||
}
|
||||
_ => {
|
||||
// GET /{path} - получение файла через proxy
|
||||
let path_without_slash = path.trim_start_matches('/');
|
||||
let requested_res = web::Path::from(path_without_slash.to_string());
|
||||
crate::handlers::proxy::proxy_handler(req, requested_res, state).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -154,13 +154,12 @@ pub async fn upload_handler(
|
||||
}
|
||||
|
||||
// Сохраняем информацию о файле в Redis
|
||||
let mut redis = state.redis.clone();
|
||||
if let Err(e) = store_file_info(&mut redis, &filename, &content_type).await {
|
||||
if let Err(e) = store_file_info(None, &filename, &content_type).await {
|
||||
error!("Failed to store file info in Redis: {}", e);
|
||||
// Не прерываем процесс, файл уже загружен в S3
|
||||
}
|
||||
|
||||
if let Err(e) = user_added_file(&mut redis, &user_id, &filename).await {
|
||||
if let Err(e) = user_added_file(None, &user_id, &filename).await {
|
||||
error!("Failed to record user file association: {}", e);
|
||||
// Не прерываем процесс
|
||||
}
|
||||
|
||||
@@ -31,8 +31,7 @@ pub async fn get_current_user_handler(
|
||||
info!("Getting user info for valid token");
|
||||
|
||||
// Получаем информацию о пользователе из Redis сессии
|
||||
let mut redis = state.redis.clone();
|
||||
let user = match get_user_by_token(token, &mut redis, state.request_timeout).await {
|
||||
let user = match get_user_by_token(token, None, state.request_timeout).await {
|
||||
Ok(user) => {
|
||||
info!(
|
||||
"Successfully retrieved user info: user_id={}, username={:?}",
|
||||
|
||||
@@ -38,9 +38,14 @@ pub fn get_mime_type(extension: &str) -> Option<&'static str> {
|
||||
|
||||
/// Ищет файл в Redis по шаблону имени
|
||||
pub async fn find_file_by_pattern(
|
||||
redis: &mut MultiplexedConnection,
|
||||
redis: Option<&mut MultiplexedConnection>,
|
||||
pattern: &str,
|
||||
) -> Result<Option<String>, actix_web::Error> {
|
||||
let Some(redis) = redis else {
|
||||
log::warn!("⚠️ Redis not available, returning None for pattern search");
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let pattern_key = format!("files:*{}*", pattern);
|
||||
let files: Vec<String> = redis
|
||||
.keys(&pattern_key)
|
||||
@@ -56,10 +61,18 @@ pub async fn find_file_by_pattern(
|
||||
|
||||
/// Сохраняет файл в Redis с его MIME-типом
|
||||
pub async fn store_file_info(
|
||||
redis: &mut MultiplexedConnection,
|
||||
redis: Option<&mut MultiplexedConnection>,
|
||||
filename: &str,
|
||||
mime_type: &str,
|
||||
) -> Result<(), actix_web::Error> {
|
||||
let Some(redis) = redis else {
|
||||
log::warn!(
|
||||
"⚠️ Redis not available, skipping file info storage for {}",
|
||||
filename
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let file_key = format!("files:{}", filename);
|
||||
redis
|
||||
.set::<_, _, ()>(&file_key, mime_type)
|
||||
|
||||
@@ -54,6 +54,8 @@ async fn main() -> std::io::Result<()> {
|
||||
.allowed_origin("https://new.discours.io")
|
||||
.allowed_origin("https://testing.discours.io")
|
||||
.allowed_origin("https://testing3.discours.io")
|
||||
.allowed_origin("https://vercel.app") // для Vercel edge functions
|
||||
.allowed_origin("https://*.vercel.app") // для Vercel preview deployments
|
||||
.allowed_origin("http://localhost:3000") // для разработки
|
||||
.allowed_methods(vec!["GET", "POST", "OPTIONS"])
|
||||
.allowed_headers(vec![
|
||||
@@ -85,7 +87,6 @@ async fn main() -> std::io::Result<()> {
|
||||
.app_data(web::Data::new(app_state.clone()))
|
||||
.app_data(web::PayloadConfig::new(security_config.max_payload_size))
|
||||
.app_data(web::JsonConfig::default().limit(1024 * 1024)) // 1MB для JSON
|
||||
|
||||
.wrap(security_headers)
|
||||
.wrap(cors)
|
||||
.wrap(Logger::default())
|
||||
|
||||
@@ -35,7 +35,7 @@ impl Default for SecurityConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_payload_size: 500 * 1024 * 1024, // 500MB
|
||||
request_timeout_seconds: 300, // 5 минут
|
||||
request_timeout_seconds: 300, // 5 минут
|
||||
max_path_length: 1000,
|
||||
max_headers_count: 50,
|
||||
max_header_value_length: 8192,
|
||||
@@ -51,7 +51,7 @@ impl SecurityConfig {
|
||||
/// Валидирует запрос на базовые параметры безопасности
|
||||
pub fn validate_request(&self, req: &HttpRequest) -> Result<(), actix_web::Error> {
|
||||
let path = req.path();
|
||||
|
||||
|
||||
// Проверка длины пути
|
||||
if path.len() > self.max_path_length {
|
||||
warn!("Path too long: {} chars", path.len());
|
||||
@@ -79,7 +79,8 @@ impl SecurityConfig {
|
||||
}
|
||||
|
||||
// Проверка на подозрительные символы в пути
|
||||
if path.contains("..") || path.contains('\0') || path.contains('\r') || path.contains('\n') {
|
||||
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",
|
||||
@@ -98,7 +99,7 @@ impl SecurityConfig {
|
||||
pub fn check_suspicious_patterns(&self, path: &str) -> bool {
|
||||
let suspicious_patterns = [
|
||||
"/admin",
|
||||
"/wp-admin",
|
||||
"/wp-admin",
|
||||
"/phpmyadmin",
|
||||
"/.env",
|
||||
"/config",
|
||||
@@ -136,26 +137,34 @@ impl SecurityConfig {
|
||||
.as_secs();
|
||||
|
||||
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);
|
||||
|
||||
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"));
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -169,16 +178,18 @@ impl SecurityConfig {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Проверяем 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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Fallback на connection info
|
||||
req.connection_info().peer_addr().unwrap_or("unknown").to_string()
|
||||
req.connection_info()
|
||||
.peer_addr()
|
||||
.unwrap_or("unknown")
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user