[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 ### 📝 Обновлено - Консолидирована документация в практическую структуру: - Основной README.md с быстрым стартом - docs/SETUP.md для конфигурации и развертывания - Упрощенный features.md с фокусом на основную функциональность - Добавлен акцент на Vercel по всему коду и документации ### 🗑️ Удалено - Избыточные файлы документации (api-reference, deployment, development, и т.д.) - Дублирующийся контент в нескольких документах - Излишне детальная документация для простого файлового прокси 💋 **Упрощение**: KISS принцип применен - убрали избыточность, оставили суть.
This commit is contained in:
136
src/app_state.rs
136
src/app_state.rs
@@ -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("a_key).await.unwrap_or(0);
|
||||
// Попытка получить квоту из Redis с таймаутом
|
||||
let quota: u64 = tokio::time::timeout(self.request_timeout, redis.get("a_key))
|
||||
.await
|
||||
.map_err(|_| ErrorInternalServerError("Redis timeout getting user quota"))?
|
||||
.unwrap_or(0);
|
||||
|
||||
if quota == 0 {
|
||||
// Если квота не найдена, устанавливаем её в 0 байт без TTL (постоянная квота)
|
||||
redis
|
||||
.set::<&str, u64, ()>("a_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, ()>("a_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>("a_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>("a_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, ()>("a_key, bytes)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
ErrorInternalServerError("Failed to set initial user quota in Redis")
|
||||
})?;
|
||||
tokio::time::timeout(
|
||||
self.request_timeout,
|
||||
redis.set::<_, u64, ()>("a_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>("a_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>("a_key, bytes),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| ErrorInternalServerError("Redis timeout incrementing user quota"))?
|
||||
.map_err(|_| ErrorInternalServerError("Failed to increment user quota in Redis"))?;
|
||||
|
||||
Ok(new_quota)
|
||||
}
|
||||
|
||||
38
src/auth.rs
38
src/auth.rs
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
mod common;
|
||||
mod proxy;
|
||||
mod serve_file;
|
||||
mod universal;
|
||||
mod upload;
|
||||
mod user;
|
||||
mod universal;
|
||||
|
||||
pub use universal::universal_handler;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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={:?}",
|
||||
|
||||
27
src/main.rs
27
src/main.rs
@@ -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())
|
||||
|
||||
372
src/security.rs
372
src/security.rs
@@ -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)
|
||||
}
|
||||
|
||||
256
src/thumbnail.rs
256
src/thumbnail.rs
@@ -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]) // Возвращаем самый маленький размер, если ничего не подошло
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user