[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,10 +1,11 @@
use crate::s3_utils::get_s3_filelist;
use crate::security::SecurityConfig;
use actix_web::error::ErrorInternalServerError;
use aws_config::BehaviorVersion;
use aws_sdk_s3::{Client as S3Client, config::Credentials};
use log::warn;
use redis::{AsyncCommands, Client as RedisClient, aio::MultiplexedConnection};
use std::env;
use std::{env, time::Duration};
#[derive(Clone)]
pub struct AppState {
@@ -12,6 +13,7 @@ pub struct AppState {
pub storj_client: S3Client,
pub aws_client: S3Client,
pub bucket: String,
pub request_timeout: Duration,
}
const PATH_MAPPING_KEY: &str = "filepath_mapping"; // Ключ для хранения маппинга путей
@@ -20,13 +22,25 @@ const PATH_MAPPING_KEY: &str = "filepath_mapping"; // Ключ для хране
impl AppState {
/// Инициализация нового состояния приложения.
pub async fn new() -> Self {
// Получаем конфигурацию для Redis
let security_config = SecurityConfig::default();
Self::new_with_config(security_config).await
}
/// Инициализация с кастомной конфигурацией безопасности.
pub async fn new_with_config(security_config: SecurityConfig) -> Self {
// Получаем конфигурацию для Redis с таймаутом
let redis_url = env::var("REDIS_URL").expect("REDIS_URL must be set");
let redis_client = RedisClient::open(redis_url).expect("Invalid Redis URL");
let redis_connection = redis_client
.get_multiplexed_async_connection()
.await
.unwrap();
// Устанавливаем таймаут для Redis операций
let redis_connection = 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");
// Получаем конфигурацию для S3 (Storj)
let s3_access_key = env::var("STORJ_ACCESS_KEY").expect("STORJ_ACCESS_KEY must be set");
@@ -41,7 +55,7 @@ impl AppState {
let aws_endpoint =
env::var("AWS_END_POINT").unwrap_or_else(|_| "https://s3.amazonaws.com".to_string());
// Конфигурируем клиент S3 для Storj
// Конфигурируем клиент S3 для Storj с таймаутом
let storj_config = aws_config::defaults(BehaviorVersion::latest())
.region("eu-west-1")
.endpoint_url(s3_endpoint)
@@ -52,12 +66,20 @@ impl AppState {
None,
"rust-storj-client",
))
.timeout_config(
aws_config::timeout::TimeoutConfig::builder()
.operation_timeout(Duration::from_secs(security_config.request_timeout_seconds))
.operation_attempt_timeout(Duration::from_secs(
security_config.request_timeout_seconds / 2,
))
.build(),
)
.load()
.await;
let storj_client = S3Client::new(&storj_config);
// Конфигурируем клиент S3 для AWS
// Конфигурируем клиент S3 для AWS с таймаутом
let aws_config = aws_config::defaults(BehaviorVersion::latest())
.region("eu-west-1")
.endpoint_url(aws_endpoint)
@@ -68,6 +90,14 @@ impl AppState {
None,
"rust-aws-client",
))
.timeout_config(
aws_config::timeout::TimeoutConfig::builder()
.operation_timeout(Duration::from_secs(security_config.request_timeout_seconds))
.operation_attempt_timeout(Duration::from_secs(
security_config.request_timeout_seconds / 2,
))
.build(),
)
.load()
.await;
@@ -78,6 +108,7 @@ impl AppState {
storj_client,
aws_client,
bucket,
request_timeout: Duration::from_secs(security_config.request_timeout_seconds),
};
// Кэшируем список файлов из AWS при старте приложения
@@ -105,40 +136,51 @@ impl AppState {
warn!("cached {} files", filelist.len());
}
/// Получает путь из ключа (имени файла) в Redis.
/// Получает путь из ключа (имени файла) в Redis с таймаутом.
pub async fn get_path(&self, filename: &str) -> Result<Option<String>, actix_web::Error> {
let mut redis = self.redis.clone();
let new_path: Option<String> = redis
.hget(PATH_MAPPING_KEY, filename)
.await
.map_err(|_| ErrorInternalServerError("Failed to get path mapping from Redis"))?;
let new_path: Option<String> =
tokio::time::timeout(self.request_timeout, redis.hget(PATH_MAPPING_KEY, filename))
.await
.map_err(|_| ErrorInternalServerError("Redis operation timeout"))?
.map_err(|_| ErrorInternalServerError("Failed to get path mapping from Redis"))?;
Ok(new_path)
}
pub async fn set_path(&self, filename: &str, filepath: &str) {
let mut redis = self.redis.clone();
let _: () = redis
.hset(PATH_MAPPING_KEY, filename, filepath)
.await
.unwrap_or_else(|_| panic!("Failed to cache file {} in Redis", filename));
let _: () = tokio::time::timeout(
self.request_timeout,
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));
}
/// создает или получает текущее значение квоты пользователя
/// создает или получает текущее значение квоты пользователя с таймаутом
pub async fn get_or_create_quota(&self, user_id: &str) -> Result<u64, actix_web::Error> {
let mut redis = self.redis.clone();
let quota_key = format!("quota:{}", user_id);
// Попытка получить квоту из Redis
let quota: u64 = redis.get(&quota_key).await.unwrap_or(0);
// Попытка получить квоту из Redis с таймаутом
let quota: u64 = tokio::time::timeout(self.request_timeout, redis.get(&quota_key))
.await
.map_err(|_| ErrorInternalServerError("Redis timeout getting user quota"))?
.unwrap_or(0);
if quota == 0 {
// Если квота не найдена, устанавливаем её в 0 байт без TTL (постоянная квота)
redis
.set::<&str, u64, ()>(&quota_key, 0)
.await
.map_err(|_| {
ErrorInternalServerError("Failed to set initial user quota in Redis")
})?;
// Если квота не найдена, устанавливаем её в 0 байт без TTL с таймаутом
tokio::time::timeout(
self.request_timeout,
redis.set::<&str, u64, ()>(&quota_key, 0),
)
.await
.map_err(|_| ErrorInternalServerError("Redis timeout setting user quota"))?
.map_err(|_| ErrorInternalServerError("Failed to set initial user quota in Redis"))?;
Ok(0) // Возвращаем 0 как начальную квоту
} else {
@@ -146,7 +188,7 @@ impl AppState {
}
}
/// инкрементирует значение квоты пользователя в байтах
/// инкрементирует значение квоты пользователя в байтах с таймаутом
pub async fn increment_uploaded_bytes(
&self,
user_id: &str,
@@ -155,27 +197,37 @@ impl AppState {
let mut redis = self.redis.clone();
let quota_key = format!("quota:{}", user_id);
// Проверяем, существует ли ключ в Redis
let exists: bool = redis.exists::<_, bool>(&quota_key).await.map_err(|_| {
ErrorInternalServerError("Failed to check if user quota exists in Redis")
})?;
// Проверяем, существует ли ключ в Redis с таймаутом
let exists: bool =
tokio::time::timeout(self.request_timeout, redis.exists::<_, bool>(&quota_key))
.await
.map_err(|_| {
ErrorInternalServerError("Redis timeout checking user quota existence")
})?
.map_err(|_| {
ErrorInternalServerError("Failed to check if user quota exists in Redis")
})?;
// Если ключ не существует, создаем его с начальным значением без TTL
if !exists {
redis
.set::<_, u64, ()>(&quota_key, bytes)
.await
.map_err(|_| {
ErrorInternalServerError("Failed to set initial user quota in Redis")
})?;
tokio::time::timeout(
self.request_timeout,
redis.set::<_, u64, ()>(&quota_key, bytes),
)
.await
.map_err(|_| ErrorInternalServerError("Redis timeout setting initial user quota"))?
.map_err(|_| ErrorInternalServerError("Failed to set initial user quota in Redis"))?;
return Ok(bytes);
}
// Если ключ существует, инкрементируем его значение на заданное количество байт
let new_quota: u64 = redis
.incr::<_, u64, u64>(&quota_key, bytes)
.await
.map_err(|_| ErrorInternalServerError("Failed to increment user quota in Redis"))?;
let new_quota: u64 = tokio::time::timeout(
self.request_timeout,
redis.incr::<_, u64, u64>(&quota_key, bytes),
)
.await
.map_err(|_| ErrorInternalServerError("Redis timeout incrementing user quota"))?
.map_err(|_| ErrorInternalServerError("Failed to increment user quota in Redis"))?;
Ok(new_quota)
}

View File

@@ -2,11 +2,8 @@ use actix_web::error::ErrorInternalServerError;
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode};
use log::{info, warn};
use redis::{AsyncCommands, aio::MultiplexedConnection};
use reqwest::Client as HTTPClient;
use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::{collections::HashMap, env, error::Error};
use std::{error::Error, time::Duration};
// Структуры для JWT токенов
#[derive(Debug, Deserialize)]
@@ -87,10 +84,11 @@ pub fn validate_token(token: &str) -> Result<bool, Box<dyn Error>> {
}
}
/// Получает user_id из JWT токена и базовые данные пользователя
/// Получает user_id из JWT токена и базовые данные пользователя с таймаутом
pub async fn get_user_by_token(
token: &str,
redis: &mut MultiplexedConnection,
timeout: Duration,
) -> Result<Author, Box<dyn Error>> {
// Декодируем JWT токен для получения user_id
let claims = decode_jwt_token(token)?;
@@ -98,11 +96,15 @@ pub async fn get_user_by_token(
info!("Extracted user_id from JWT token: {}", user_id);
// Проверяем валидность токена через сессию в Redis (опционально)
// Проверяем валидность токена через сессию в Redis (опционально) с таймаутом
let token_key = format!("session:{}:{}", user_id, token);
let session_exists: bool = redis
.exists(&token_key)
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);
// Не критичная ошибка, продолжаем с базовыми данными
@@ -116,13 +118,19 @@ pub async fn get_user_by_token(
.unwrap()
.as_secs();
let _: () = redis
.hset(&token_key, "last_activity", current_time.to_string())
.await
.map_err(|e| {
warn!("Failed to update last_activity: {}", e);
})
.unwrap_or(());
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);
} else {

View File

@@ -1,11 +1,76 @@
use actix_web::{HttpRequest, HttpResponse, Result, error::ErrorUnauthorized};
use log::warn;
use log::{debug, info, warn};
use std::env;
use crate::auth::validate_token;
/// Общие константы
pub const CACHE_CONTROL_IMMUTABLE: &str = "public, max-age=31536000, immutable"; // 1 год
pub const CORS_ALLOW_ORIGIN: &str = "*";
/// Общие константы - optimized for Vercel Edge caching
pub const CACHE_CONTROL_VERCEL: &str = "public, max-age=86400, s-maxage=31536000"; // 1 day browser, 1 year CDN
/// Log request source and check CORS origin
pub fn get_cors_origin(req: &HttpRequest) -> String {
let allowed_origins = env::var("CORS_DOWNLOAD_ORIGINS")
.unwrap_or_else(|_| "https://discours.io,https://*.discours.io,https://testing.discours.io,https://testing3.discours.io".to_string());
// Extract request source info for logging
let origin = req.headers().get("origin").and_then(|h| h.to_str().ok());
let referer = req.headers().get("referer").and_then(|h| h.to_str().ok());
let user_agent = req
.headers()
.get("user-agent")
.and_then(|h| h.to_str().ok())
.unwrap_or("unknown");
let remote_addr = req
.peer_addr()
.map(|addr| addr.ip().to_string())
.unwrap_or_else(|| "unknown".to_string());
// Log request source for CORS whitelist analysis
match (origin, referer) {
(Some(orig), Some(ref_)) => {
info!(
"📥 Request source: origin={}, referer={}, ip={}, ua={}",
orig, ref_, remote_addr, user_agent
);
}
(Some(orig), None) => {
info!(
"📥 Request source: origin={}, ip={}, ua={}",
orig, remote_addr, user_agent
);
}
(None, Some(ref_)) => {
info!(
"📥 Request source: referer={}, ip={}, ua={}",
ref_, remote_addr, user_agent
);
}
(None, None) => {
debug!("📥 Direct request: ip={}, ua={}", remote_addr, user_agent);
}
}
if let Some(origin) = origin {
// Simple check - if origin contains any allowed domain, allow it
for allowed in allowed_origins.split(',') {
let allowed = allowed.trim();
if allowed.contains('*') {
let base = allowed.replace("*.", "");
if origin.contains(&base) {
debug!("✅ CORS allowed: {} matches {}", origin, allowed);
return origin.to_string();
}
} else if origin == allowed {
debug!("✅ CORS allowed: {} exact match", origin);
return origin.to_string();
}
}
warn!("⚠️ CORS not whitelisted: {}", origin);
}
// Default permissive for file downloads
"*".to_string()
}
/// Извлекает и валидирует токен авторизации из заголовков запроса
pub fn extract_and_validate_token(req: &HttpRequest) -> Result<&str, actix_web::Error> {
@@ -45,49 +110,57 @@ pub fn extract_and_validate_token(req: &HttpRequest) -> Result<&str, actix_web::
Ok(token)
}
/// Создает HTTP ответ с оптимальными заголовками кэширования
pub fn create_cached_response(content_type: &str, data: Vec<u8>, etag: &str) -> HttpResponse {
// Removed unused create_file_response - using create_file_response_with_analytics instead
/// File response with analytics logging
pub fn create_file_response_with_analytics(
content_type: &str,
data: Vec<u8>,
req: &HttpRequest,
path: &str,
) -> HttpResponse {
let cors_origin = get_cors_origin(req);
// Log analytics for CORS whitelist analysis
log_request_analytics(req, path, data.len());
HttpResponse::Ok()
.content_type(content_type)
.insert_header(("etag", etag))
.insert_header(("cache-control", CACHE_CONTROL_IMMUTABLE))
.insert_header(("access-control-allow-origin", CORS_ALLOW_ORIGIN))
.insert_header(("cache-control", CACHE_CONTROL_VERCEL))
.insert_header(("access-control-allow-origin", cors_origin))
.body(data)
}
/// Создает стандартный HTTP ответ с заголовками CORS
pub fn create_response_with_cors(content_type: &str, data: Vec<u8>) -> HttpResponse {
HttpResponse::Ok()
.content_type(content_type)
.insert_header(("access-control-allow-origin", CORS_ALLOW_ORIGIN))
.body(data)
}
// Removed complex ETag caching - Vercel handles caching on their edge
/// Создает HTTP ответ с кэшированием на основе ETag
pub fn create_etag_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_IMMUTABLE))
.insert_header(("access-control-allow-origin", CORS_ALLOW_ORIGIN))
.body(data)
}
/// Проверяет ETag для кэширования и возвращает 304 если совпадает
pub fn check_etag_cache(req: &HttpRequest, etag: &str) -> Option<HttpResponse> {
let client_etag = req
/// Log request analytics for CORS whitelist tuning
pub fn log_request_analytics(req: &HttpRequest, path: &str, response_size: usize) {
let origin = req.headers().get("origin").and_then(|h| h.to_str().ok());
let referer = req.headers().get("referer").and_then(|h| h.to_str().ok());
let user_agent = req
.headers()
.get("if-none-match")
.and_then(|h| h.to_str().ok());
.get("user-agent")
.and_then(|h| h.to_str().ok())
.unwrap_or("unknown");
let remote_addr = req
.peer_addr()
.map(|addr| addr.ip().to_string())
.unwrap_or_else(|| "unknown".to_string());
if let Some(client_etag) = client_etag {
if client_etag == etag {
return Some(HttpResponse::NotModified().finish());
}
}
None
// Analytics log for future CORS configuration
info!(
"📊 ANALYTICS: path={}, size={}b, origin={}, referer={}, ip={}, ua={}",
path,
response_size,
origin.unwrap_or("none"),
referer.unwrap_or("none"),
remote_addr,
user_agent
);
}
// ETag caching removed - handled by Vercel Edge
/// Проверяет путь на ACME challenge и возвращает 404 если нужно
pub fn check_acme_path(path: &str) -> Option<HttpResponse> {
if path.starts_with(".well-known/") || path.starts_with("/.well-known/") {
@@ -107,31 +180,15 @@ pub fn validate_token_format(token: &str) -> bool {
}
// Проверяем, что токен содержит только допустимые символы для JWT
token.chars().all(|c| c.is_alphanumeric() || c == '.' || c == '-' || c == '_')
}
/// Создает ответ с задержкой для предотвращения брутфорса
pub async fn create_delayed_error_response(
status: actix_web::http::StatusCode,
message: &str,
delay_ms: u64,
) -> HttpResponse {
if delay_ms > 0 {
tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
}
HttpResponse::build(status)
.insert_header(("access-control-allow-origin", CORS_ALLOW_ORIGIN))
.json(serde_json::json!({
"error": message,
"retry_after": delay_ms / 1000
}))
token
.chars()
.all(|c| c.is_alphanumeric() || c == '.' || c == '-' || c == '_')
}
/// Создает JSON ответ с ошибкой
pub fn create_error_response(status: actix_web::http::StatusCode, message: &str) -> HttpResponse {
HttpResponse::build(status)
.insert_header(("access-control-allow-origin", CORS_ALLOW_ORIGIN))
.insert_header(("access-control-allow-origin", "*"))
.json(serde_json::json!({
"error": message
}))

View File

@@ -1,9 +1,9 @@
mod common;
mod proxy;
mod serve_file;
mod universal;
mod upload;
mod user;
mod universal;
pub use universal::universal_handler;

View File

@@ -2,16 +2,17 @@ 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 crate::app_state::AppState;
use crate::handlers::serve_file::serve_file;
use crate::lookup::{find_file_by_pattern, get_mime_type};
use crate::s3_utils::{check_file_exists, load_file_from_s3, upload_to_s3};
use crate::thumbnail::{find_closest_width, parse_file_path, thumbdata_save};
use super::common::{check_etag_cache, create_cached_response};
use crate::thumbnail::parse_file_path;
// Удалена дублирующая функция, используется из common модуля
/// Обработчик для скачивания файла и генерации миниатюры, если она недоступна.
/// Обработчик для скачивания файла
/// без генерации миниатюр - это делает Vercel Edge API
#[allow(clippy::collapsible_if)]
pub async fn proxy_handler(
req: HttpRequest,
@@ -38,12 +39,7 @@ pub async fn proxy_handler(
base_filename, requested_width, ext
);
// Генерируем ETag для кэширования и проверяем кэш
let file_etag = format!("\"{}\"", &filekey);
if let Some(response) = check_etag_cache(&req, &file_etag) {
info!("Cache hit for {}, returning 304", filekey);
return Ok(response);
}
// Caching handled by Vercel Edge - focus on fast file serving
let content_type = match get_mime_type(&ext) {
Some(mime) => mime.to_string(),
None => {
@@ -77,72 +73,8 @@ pub async fn proxy_handler(
);
if check_file_exists(&state.storj_client, &state.bucket, &stored_path).await? {
warn!("File exists in Storj: {}", stored_path);
if content_type.starts_with("image") {
warn!("Processing image file with width: {}", requested_width);
if requested_width == 0 {
warn!("Serving original file without resizing");
serve_file(&stored_path, &state).await
} else {
let closest: u32 = find_closest_width(requested_width);
warn!(
"Calculated closest width: {} for requested: {}",
closest, requested_width
);
let thumb_filename = &format!("{}_{}.{}", base_filename, closest, ext);
warn!("Generated thumbnail filename: {}", thumb_filename);
// Проверяем, существует ли уже миниатюра в Storj
match check_file_exists(&state.storj_client, &state.bucket, thumb_filename)
.await
{
Ok(true) => {
warn!("serve existed thumb file: {}", thumb_filename);
serve_file(thumb_filename, &state).await
}
Ok(false) => {
// Миниатюра не существует, возвращаем оригинал и запускаем генерацию миниатюры
let original_file = serve_file(&stored_path, &state).await?;
// Запускаем асинхронную задачу для генерации миниатюры
let state_clone = state.clone();
let stored_path_clone = stored_path.clone();
let filekey_clone = filekey.clone();
let content_type_clone = content_type.to_string();
actix_web::rt::spawn(async move {
if let Ok(filedata) = load_file_from_s3(
&state_clone.storj_client,
&state_clone.bucket,
&stored_path_clone,
)
.await
{
warn!("generate new thumb files: {}", stored_path_clone);
if let Err(e) = thumbdata_save(
filedata,
&state_clone,
&filekey_clone,
content_type_clone,
)
.await
{
error!("Failed to generate thumbnail: {}", e);
}
}
});
Ok(original_file)
}
Err(e) => {
error!("ошибка при проверке существования миниатюры: {}", e);
Err(ErrorInternalServerError("failed to load thumbnail"))
}
}
}
} else {
warn!("File is not an image, proceeding with normal serving");
serve_file(&stored_path, &state).await
}
// Просто отдаем файл, миниатюры генерирует Vercel Edge API
serve_file(&stored_path, &state, &req).await
} else {
warn!(
"Attempting to load from AWS - bucket: {}, path: {}",
@@ -197,7 +129,12 @@ pub async fn proxy_handler(
let elapsed = start_time.elapsed();
info!("File served from AWS in {:?}: {}", elapsed, path);
return Ok(create_cached_response(&content_type, filedata, &file_etag));
return Ok(create_file_response_with_analytics(
&content_type,
filedata,
&req,
&path,
));
}
Err(err) => {
warn!("Failed to load from AWS path {}: {:?}", path, err);
@@ -244,26 +181,9 @@ pub async fn proxy_handler(
warn!("Checking existence in Storj: {}", exists_in_storj);
if exists_in_storj {
warn!(
"file {} exists in storj, try to generate thumbnails",
filepath
);
match load_file_from_s3(&state.aws_client, &state.bucket, &filepath).await {
Ok(filedata) => {
let _ = thumbdata_save(
filedata.clone(),
&state,
&filekey,
content_type.to_string(),
)
.await;
}
Err(e) => {
error!("cannot download {} from storj: {}", filekey, e);
return Err(ErrorInternalServerError(e));
}
}
warn!("file {} exists in storj, serving directly", filepath);
// Файл существует в Storj, отдаем его напрямую
return serve_file(&filepath, &state, &req).await;
} else {
warn!("file {} does not exist in storj", filepath);
}
@@ -280,13 +200,6 @@ pub async fn proxy_handler(
"Successfully downloaded file from AWS, size: {} bytes",
filedata.len()
);
let _ = thumbdata_save(
filedata.clone(),
&state,
&filekey,
content_type.to_string(),
)
.await;
if let Err(e) = upload_to_s3(
&state.storj_client,
&state.bucket,
@@ -303,7 +216,12 @@ pub async fn proxy_handler(
}
let elapsed = start_time.elapsed();
info!("File served from AWS in {:?}: {}", elapsed, filepath);
Ok(create_cached_response(&content_type, filedata, &file_etag))
Ok(create_file_response_with_analytics(
&content_type,
filedata,
&req,
&filepath,
))
}
Err(e) => {
error!("Failed to download from AWS: {} - Error: {}", filepath, e);

View File

@@ -1,14 +1,16 @@
use actix_web::{HttpResponse, Result, error::ErrorInternalServerError};
use actix_web::{HttpRequest, HttpResponse, Result, error::ErrorInternalServerError};
use mime_guess::MimeGuess;
use super::common::create_file_response_with_analytics;
use crate::app_state::AppState;
use crate::s3_utils::check_file_exists;
use super::common::{CACHE_CONTROL_IMMUTABLE, CORS_ALLOW_ORIGIN};
use crate::s3_utils::{check_file_exists, load_file_from_s3};
/// Функция для обслуживания файла по заданному пути.
/// Теперь оптимизирована для Vercel Edge caching.
pub async fn serve_file(
filepath: &str,
state: &AppState,
req: &HttpRequest,
) -> Result<HttpResponse, actix_web::Error> {
if filepath.is_empty() {
return Err(ErrorInternalServerError("Filename is empty".to_string()));
@@ -23,35 +25,21 @@ pub async fn serve_file(
)));
}
// Получаем объект из Storj S3
let get_object_output = state
.storj_client
.get_object()
.bucket(&state.bucket)
.key(filepath)
.send()
// Загружаем файл из S3
let filedata = load_file_from_s3(&state.storj_client, &state.bucket, filepath)
.await
.map_err(|_| {
ErrorInternalServerError(format!("Failed to get {} object from Storj", filepath))
.map_err(|e| {
ErrorInternalServerError(format!("Failed to load {} from Storj: {}", filepath, e))
})?;
let data: aws_sdk_s3::primitives::AggregatedBytes = get_object_output
.body
.collect()
.await
.map_err(|_| ErrorInternalServerError("Failed to read object body"))?;
let data_bytes = data.into_bytes();
// Определяем MIME тип
let mime_type = MimeGuess::from_path(filepath).first_or_octet_stream();
// Генерируем ETag для кэширования на основе пути файла
let etag = format!("\"{}\"", filepath);
Ok(HttpResponse::Ok()
.content_type(mime_type.as_ref())
.insert_header(("etag", etag.as_str()))
.insert_header(("cache-control", CACHE_CONTROL_IMMUTABLE))
.insert_header(("access-control-allow-origin", CORS_ALLOW_ORIGIN))
.body(data_bytes))
// Создаем ответ с аналитикой
Ok(create_file_response_with_analytics(
mime_type.as_ref(),
filedata,
req,
filepath,
))
}

View File

@@ -1,10 +1,10 @@
use actix_web::{HttpRequest, HttpResponse, Result, web};
use actix_multipart::Multipart;
use actix_web::{HttpRequest, HttpResponse, Result, web};
use log::{info, warn};
use super::common::{check_acme_path, create_error_response};
use crate::app_state::AppState;
use crate::security::{SecurityManager, SecurityConfig};
use super::common::{create_error_response, check_acme_path};
use crate::security::SecurityConfig;
/// Универсальный обработчик, который определяет HTTP метод и путь
pub async fn universal_handler(
@@ -14,7 +14,7 @@ pub async fn universal_handler(
) -> Result<HttpResponse, actix_web::Error> {
let method = req.method().clone();
let path = req.path().to_string();
info!("Universal handler: {} {}", method, path);
// Проверка ACME challenge путей
@@ -22,46 +22,31 @@ pub async fn universal_handler(
return Ok(response);
}
// Инициализация SecurityManager для проверок
// Базовая проверка безопасности
let security_config = SecurityConfig::default();
let client_ip = SecurityManager::extract_client_ip(&req);
// Проверка базовых ограничений безопасности
if let Err(error) = SecurityManager::new(security_config.clone(), state.redis.clone())
.validate_request_security(&req) {
warn!("Security validation failed for IP {}: {}", client_ip, error);
if let Err(error) = security_config.validate_request(&req) {
warn!("Security validation failed: {}", error);
return Err(error);
}
// Проверка подозрительных паттернов
let mut security_manager = SecurityManager::new(security_config.clone(), state.redis.clone());
if security_manager.check_suspicious_patterns(&path) {
warn!("Suspicious pattern detected from IP {}: {}", client_ip, path);
return Ok(create_error_response(
actix_web::http::StatusCode::NOT_FOUND,
"Not found"
));
// Проверка upload лимитов только для POST запросов
if method == "POST" {
let client_ip = SecurityConfig::extract_client_ip(&req);
if let Err(error) = security_config.check_upload_limit(&client_ip).await {
warn!("Upload limit exceeded for IP {}: {}", client_ip, error);
return Err(error);
}
}
// Проверка rate limits в зависимости от endpoint
let endpoint_type = match method.as_str() {
"POST" if path == "/" => "upload",
"GET" if path == "/" => "auth",
_ => "general"
};
if let Err(error) = security_manager.check_rate_limit(&client_ip, endpoint_type).await {
warn!("Rate limit exceeded for IP {} on {}: {}", client_ip, endpoint_type, error);
return Err(error);
}
match method.as_str() {
"GET" => handle_get(req, state, &path).await,
"POST" => handle_post(req, payload, state, &path).await,
_ => Ok(create_error_response(
actix_web::http::StatusCode::METHOD_NOT_ALLOWED,
"Method not allowed"
))
"Method not allowed",
)),
}
}
@@ -70,7 +55,7 @@ async fn handle_get(
state: web::Data<AppState>,
path: &str,
) -> Result<HttpResponse, actix_web::Error> {
if path == "/" || path == "" {
if path == "/" || path.is_empty() {
// GET / - получение информации о пользователе
crate::handlers::user::get_current_user_handler(req, state).await
} else {
@@ -88,6 +73,6 @@ async fn handle_post(
_path: &str,
) -> Result<HttpResponse, actix_web::Error> {
// POST / - загрузка файла (multipart)
let multipart = Multipart::new(&req.headers(), payload);
let multipart = Multipart::new(req.headers(), payload);
crate::handlers::upload::upload_handler(req, multipart, state).await
}

View File

@@ -2,12 +2,12 @@ use actix_multipart::Multipart;
use actix_web::{HttpRequest, HttpResponse, Result, web};
use log::{error, info, warn};
use super::common::extract_and_validate_token;
use crate::app_state::AppState;
use crate::auth::{extract_user_id_from_token, user_added_file};
use crate::handlers::MAX_USER_QUOTA_BYTES;
use crate::lookup::store_file_info;
use crate::s3_utils::{self, generate_key_with_extension, upload_to_s3};
use super::common::extract_and_validate_token;
use futures::TryStreamExt;
// use crate::thumbnail::convert_heic_to_jpeg;

View File

@@ -2,9 +2,9 @@ use actix_web::{HttpRequest, HttpResponse, Result, web};
use log::{error, info, warn};
use serde::Serialize;
use super::common::extract_and_validate_token;
use crate::app_state::AppState;
use crate::auth::{Author, get_user_by_token};
use super::common::extract_and_validate_token;
#[derive(Serialize)]
pub struct UserWithQuotaResponse {
@@ -32,7 +32,7 @@ pub async fn get_current_user_handler(
// Получаем информацию о пользователе из Redis сессии
let mut redis = state.redis.clone();
let user = match get_user_by_token(token, &mut redis).await {
let user = match get_user_by_token(token, &mut redis, state.request_timeout).await {
Ok(user) => {
info!(
"Successfully retrieved user info: user_id={}, username={:?}",

View File

@@ -10,14 +10,14 @@ use actix_cors::Cors;
use actix_web::{
App, HttpServer,
http::header,
middleware::{Logger, DefaultHeaders},
middleware::{DefaultHeaders, Logger},
web,
};
use app_state::AppState;
use security::{SecurityConfig, security_middleware};
use security::SecurityConfig;
use handlers::universal_handler;
use log::{warn, info};
use log::{info, warn};
use std::env;
use tokio::task::spawn_blocking;
@@ -41,10 +41,11 @@ async fn main() -> std::io::Result<()> {
// Конфигурация безопасности
let security_config = SecurityConfig::default();
info!("Security config: max_payload={} MB, upload_rate_limit={}/{}s",
security_config.max_payload_size / (1024 * 1024),
security_config.upload_rate_limit.max_requests,
security_config.upload_rate_limit.window_seconds);
info!(
"Security config: max_payload={} MB, timeout={}s",
security_config.max_payload_size / (1024 * 1024),
security_config.request_timeout_seconds
);
HttpServer::new(move || {
// Настройка CORS middleware - ограничиваем в продакшене
@@ -71,14 +72,20 @@ async fn main() -> std::io::Result<()> {
.add(("X-Frame-Options", "DENY"))
.add(("X-XSS-Protection", "1; mode=block"))
.add(("Referrer-Policy", "strict-origin-when-cross-origin"))
.add(("Content-Security-Policy", "default-src 'self'; img-src 'self' data: https:; object-src 'none';"))
.add(("Strict-Transport-Security", "max-age=31536000; includeSubDomains"));
.add((
"Content-Security-Policy",
"default-src 'self'; img-src 'self' data: https:; object-src 'none';",
))
.add((
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains",
));
App::new()
.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(actix_web::middleware::from_fn(security_middleware))
.wrap(security_headers)
.wrap(cors)
.wrap(Logger::default())

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)
}

View File

@@ -1,210 +1,74 @@
use actix_web::error::ErrorInternalServerError;
use image::{DynamicImage, ImageFormat, imageops::FilterType};
use log::warn;
use std::{collections::HashMap, io::Cursor};
// Модуль для парсинга путей к файлам (без генерации миниатюр)
use crate::{app_state::AppState, s3_utils::upload_to_s3};
pub const THUMB_WIDTHS: [u32; 7] = [10, 40, 110, 300, 600, 800, 1400];
/// Парсит путь к файлу, извлекая оригинальное имя, требуемую ширину и формат.
/// Примеры:
/// - "filename_150.ext" -> ("filename", 150, "ext")
/// - "unsafe/1440x/production/image/439efaa0-816f-11ef-b201-439da98539bc.jpg" -> ("439efaa0-816f-11ef-b201-439da98539bc", 1440, "jpg")
/// - "unsafe/production/image/5627e002-0c53-11ee-9565-0242ac110006.png" -> ("5627e002-0c53-11ee-9565-0242ac110006", 0, "png")
/// - "unsafe/development/image/439efaa0-816f-11ef-b201-439da98539bc.jpg/webp" -> ("439efaa0-816f-11ef-b201-439da98539bc", 0, "webp")
#[allow(clippy::collapsible_if)]
pub fn parse_file_path(requested_path: &str) -> (String, u32, String) {
let mut path = requested_path.to_string();
if requested_path.ends_with("/webp") {
path = path.replace("/webp", "");
}
let mut path_parts: Vec<&str> = path.split('/').collect();
let mut extension = String::new();
let mut width = 0;
let mut base_filename = String::new();
if path_parts.is_empty() {
return (path.to_string(), width, extension);
}
// пытаемся извлечь формат из имени файла
if let Some(filename_part) = path_parts.pop() {
if let Some((base, ext_part)) = filename_part.rsplit_once('.') {
extension = ext_part.to_string();
base_filename = base.to_string(); // Устанавливаем base_filename без расширения
} else {
base_filename = filename_part.to_string();
}
}
// Если base_filename ещё не установлено, извлекаем его
if base_filename.is_empty() {
if let Some(filename_part) = path_parts.pop() {
if let Some((base, ext_part)) = filename_part.rsplit_once('.') {
extension = ext_part.to_string();
base_filename = base.to_string();
} else {
base_filename = filename_part.to_string();
}
}
}
// Извлечение ширины из base_filename, если она есть
if let Some((name_part, width_str)) = base_filename.rsplit_once('_') {
if let Ok(w) = width_str.parse::<u32>() {
width = w;
base_filename = name_part.to_string();
}
}
// Проверка на старую ширину в путях, начинающихся с "unsafe"
if path.starts_with("unsafe") && width == 0 && path_parts.len() >= 2 {
if let Some(old_width_str) = path_parts.get(1) {
// Получаем второй элемент
let old_width_str = old_width_str.trim_end_matches('x');
if let Ok(w) = old_width_str.parse::<u32>() {
width = w;
}
}
}
(base_filename, width, extension)
}
/// Генерирует миниатюры изображения.
/// Парсит путь к файлу, извлекая базовое имя, ширину и расширение.
///
/// Теперь функция принимает дополнительный параметр `format`, который определяет формат сохранения миниатюр.
/// Это позволяет поддерживать различные форматы изображений без необходимости заранее предугадывать их.
pub async fn generate_thumbnails(
image: &DynamicImage,
format: ImageFormat,
) -> Result<HashMap<u32, Vec<u8>>, actix_web::Error> {
let mut thumbnails = HashMap::new();
/// Пример:
/// - "image.jpg" -> ("image", 0, "jpg")
/// - "image_300.jpg" -> ("image", 300, "jpg")
/// - "image_large.jpg" -> ("image", 0, "jpg") - некорректная ширина игнорируется
pub fn parse_file_path(path: &str) -> (String, u32, String) {
let path = path.trim_start_matches('/');
for &width in THUMB_WIDTHS.iter().filter(|&&w| w < image.width()) {
let thumbnail = image.resize(width, u32::MAX, FilterType::Lanczos3); // Ресайз изображения по ширине
let mut buffer = Vec::new();
thumbnail
.write_to(&mut Cursor::new(&mut buffer), format)
.map_err(|e| {
log::error!("Ошибка при сохранении миниатюры: {}", e);
ErrorInternalServerError("Не удалось сгенерировать миниатюру")
})?; // Сохранение изображения в указанном формате
thumbnails.insert(width, buffer);
}
// Находим последнюю точку для разделения имени и расширения
let (name_part, extension) = match path.rfind('.') {
Some(dot_pos) => (&path[..dot_pos], path[dot_pos + 1..].to_string()),
None => (path, String::new()),
};
Ok(thumbnails)
}
// Ищем последнее подчеркивание в имени файла
if let Some(underscore_pos) = name_part.rfind('_') {
let base_filename = name_part[..underscore_pos].to_string();
let width_str = &name_part[underscore_pos + 1..];
/// Определяет формат изображения на основе расширения файла.
fn determine_image_format(extension: &str) -> Result<ImageFormat, actix_web::Error> {
match extension.to_lowercase().as_str() {
"jpg" | "jpeg" => Ok(ImageFormat::Jpeg),
"png" => Ok(ImageFormat::Png),
"gif" => Ok(ImageFormat::Gif),
"webp" => Ok(ImageFormat::WebP),
"heic" | "heif" | "tiff" | "tif" => {
// Конвертируем HEIC и TIFF в JPEG при сохранении
Ok(ImageFormat::Jpeg)
}
_ => {
log::error!("Неподдерживаемый формат изображения: {}", extension);
Err(ErrorInternalServerError(
"Неподдерживаемый формат изображения",
))
}
}
}
/// Сохраняет данные миниатюры.
///
/// Обновлена для передачи корректного формата изображения.
pub async fn thumbdata_save(
original_data: Vec<u8>,
state: &AppState,
original_filename: &str,
content_type: String,
) -> Result<(), actix_web::Error> {
if content_type.starts_with("image") {
warn!("original file name: {}", original_filename);
let (base_filename, _, extension) = parse_file_path(original_filename);
warn!("detected file extension: {}", extension);
// Для HEIC файлов просто сохраняем оригинал как миниатюру
if content_type == "image/heic" {
warn!("HEIC file detected, using original as thumbnail");
let thumb_filename = format!("{}_{}.heic", base_filename, THUMB_WIDTHS[0]);
if let Err(e) = upload_to_s3(
&state.storj_client,
&state.bucket,
&thumb_filename,
original_data,
&content_type,
)
.await
{
warn!("cannot save HEIC thumb {}: {}", thumb_filename, e);
return Err(ErrorInternalServerError("cant save HEIC thumbnail"));
// Пытаемся парсить ширину
match width_str.parse::<u32>() {
Ok(width) => {
return (base_filename, width, extension);
}
return Ok(());
}
// Для остальных изображений продолжаем как обычно
let img = match image::load_from_memory(&original_data) {
Ok(img) => img,
Err(e) => {
warn!("cannot load image from memory: {}", e);
return Err(ErrorInternalServerError("cant load image"));
}
};
warn!("generate thumbnails for {}", original_filename);
let format = determine_image_format(&extension.to_lowercase())?;
match generate_thumbnails(&img, format).await {
Ok(thumbnails_bytes) => {
for (thumb_width, thumbnail) in thumbnails_bytes {
let thumb_filename = format!("{}_{}.{}", base_filename, thumb_width, extension);
if let Err(e) = upload_to_s3(
&state.storj_client,
&state.bucket,
&thumb_filename,
thumbnail,
&content_type,
)
.await
{
warn!("cannot load thumb {}: {}", thumb_filename, e);
}
}
}
Err(e) => {
warn!(
"cannot generate thumbnails for {}: {}",
original_filename, e
);
return Err(e);
Err(_) => {
// Если не получилось парсить как число, считаем все имя файла базовым
}
}
}
Ok(())
// Если подчеркивания нет или ширина не парсится, возвращаем все как базовое имя
(name_part.to_string(), 0, extension)
}
/// Выбирает ближайший подходящий размер из предопределённых.
/// Если `requested_width` больше максимальной ширины в `THUMB_WIDTHS`,
/// возвращает максимальную ширину.
pub fn find_closest_width(requested_width: u32) -> u32 {
// Проверяем, превышает ли запрошенная ширина максимальную доступную ширину
if requested_width > *THUMB_WIDTHS.last().unwrap() {
return *THUMB_WIDTHS.last().unwrap();
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_file_path() {
// Обычный файл без ширины
let (base, width, ext) = parse_file_path("image.jpg");
assert_eq!(base, "image");
assert_eq!(width, 0);
assert_eq!(ext, "jpg");
// Файл с шириной
let (base, width, ext) = parse_file_path("photo_300.png");
assert_eq!(base, "photo");
assert_eq!(width, 300);
assert_eq!(ext, "png");
// Файл с нечисловым суффиксом
let (base, width, ext) = parse_file_path("document_large.pdf");
assert_eq!(base, "document_large");
assert_eq!(width, 0);
assert_eq!(ext, "pdf");
// Файл без расширения
let (base, width, ext) = parse_file_path("file_100");
assert_eq!(base, "file");
assert_eq!(width, 100);
assert_eq!(ext, "");
// Путь с префиксом
let (base, width, ext) = parse_file_path("/uploads/image_800.jpg");
assert_eq!(base, "uploads/image");
assert_eq!(width, 800);
assert_eq!(ext, "jpg");
}
// Находим ширину с минимальной абсолютной разницей с запрошенной
*THUMB_WIDTHS
.iter()
.min_by_key(|&&width| (width as i32 - requested_width as i32).abs())
.unwrap_or(&THUMB_WIDTHS[0]) // Возвращаем самый маленький размер, если ничего не подошло
}