diff --git a/CHANGELOG.md b/CHANGELOG.md index c72fd0b..200b61d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,17 +1,16 @@ ## [0.6.1] - 2025-09-02 -### πŸš€ ИзмСнСно - Π£ΠΏΡ€ΠΎΡ‰Π΅Π½ΠΈΠ΅ Π°Ρ€Ρ…ΠΈΡ‚Π΅ΠΊΡ‚ΡƒΡ€Ρ‹ -- **ГСнСрация ΠΌΠΈΠ½ΠΈΠ°Ρ‚ΡŽΡ€**: ΠŸΠΎΠ»Π½ΠΎΡΡ‚ΡŒΡŽ ΡƒΠ΄Π°Π»Π΅Π½Π° ΠΈΠ· Quoter, Ρ‚Π΅ΠΏΠ΅Ρ€ΡŒ управляСтся Vercel Edge API -- **ΠžΡ‡ΠΈΡΡ‚ΠΊΠ° legacy ΠΊΠΎΠ΄Π°**: Π£Π΄Π°Π»Π΅Π½Ρ‹ всС Ρ„ΡƒΠ½ΠΊΡ†ΠΈΠΈ Π³Π΅Π½Π΅Ρ€Π°Ρ†ΠΈΠΈ ΠΌΠΈΠ½ΠΈΠ°Ρ‚ΡŽΡ€ ΠΈ ΡΠ»ΠΎΠΆΠ½ΠΎΡΡ‚ΡŒ -- **ДокумСнтация**: Π‘ΠΎΠΊΡ€Π°Ρ‰Π΅Π½Π° с 17 Ρ„Π°ΠΉΠ»ΠΎΠ² Π΄ΠΎ 7, слСдуя ΠΏΡ€ΠΈΠ½Ρ†ΠΈΠΏΠ°ΠΌ KISS/DRY -- **Π‘ΠΌΠ΅Π½Π° фокуса**: Quoter Ρ‚Π΅ΠΏΠ΅Ρ€ΡŒ сосрСдоточСн Π½Π° upload + storage, Vercel ΠΎΠ±Ρ€Π°Π±Π°Ρ‚Ρ‹Π²Π°Π΅Ρ‚ ΠΌΠΈΠ½ΠΈΠ°Ρ‚ΡŽΡ€Ρ‹ +### πŸš€ ИзмСнСно - ВосстановлСниС thumbnail Ρ„ΡƒΠ½ΠΊΡ†ΠΈΠΎΠ½Π°Π»ΡŒΠ½ΠΎΡΡ‚ΠΈ +- **ГСнСрация ΠΌΠΈΠ½ΠΈΠ°Ρ‚ΡŽΡ€**: ВосстановлСна Π² Quoter с WebP ΠΏΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΊΠΎΠΉ ΠΈ Storj ΠΊΡΡˆΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ΠΌ +- **Storj ΠΊΡΡˆΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅**: ΠœΠΈΠ½ΠΈΠ°Ρ‚ΡŽΡ€Ρ‹ ΡΠΎΡ…Ρ€Π°Π½ΡΡŽΡ‚ΡΡ Π² Storj для надСТности ΠΈ ΠΌΠ°ΡΡˆΡ‚Π°Π±ΠΈΡ€ΡƒΠ΅ΠΌΠΎΡΡ‚ΠΈ +- **ETag ΠΊΡΡˆΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅**: Π”ΠΎΠ±Π°Π²Π»Π΅Π½ΠΎ MD5-based ETag ΠΊΡΡˆΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ для ΠΎΠΏΡ‚ΠΈΠΌΠ°Π»ΡŒΠ½ΠΎΠΉ ΠΏΡ€ΠΎΠΈΠ·Π²ΠΎΠ΄ΠΈΡ‚Π΅Π»ΡŒΠ½ΠΎΡΡ‚ΠΈ +- **Умная Π»ΠΎΠ³ΠΈΠΊΠ° ΠΎΡ‚Π²Π΅Ρ‚ΠΎΠ²**: АвтоматичСскоС ΠΎΠΏΡ€Π΅Π΄Π΅Π»Π΅Π½ΠΈΠ΅ Vercel запросов ΠΈ ΠΎΠΏΡ‚ΠΈΠΌΠΈΠ·ΠΈΡ€ΠΎΠ²Π°Π½Π½Ρ‹Π΅ Π·Π°Π³ΠΎΠ»ΠΎΠ²ΠΊΠΈ +- **ΠšΠΎΠ½ΡΠΎΠ»ΠΈΠ΄Π°Ρ†ΠΈΡ Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚Π°Ρ†ΠΈΠΈ**: ΠžΠ±ΡŠΠ΅Π΄ΠΈΠ½Π΅Π½Ρ‹ 4 Vercel Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚Π° Π² ΠΎΠ΄ΠΈΠ½ comprehensive guide - **Π›ΠΎΠ³ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ запросов**: Π”ΠΎΠ±Π°Π²Π»Π΅Π½Π° Π°Π½Π°Π»ΠΈΡ‚ΠΈΠΊΠ° источников для ΠΎΠΏΡ‚ΠΈΠΌΠΈΠ·Π°Ρ†ΠΈΠΈ CORS whitelist - **РСализация Ρ‚Π°ΠΉΠΌΠ°ΡƒΡ‚ΠΎΠ²**: Π”ΠΎΠ±Π°Π²Π»Π΅Π½Ρ‹ настраиваСмыС Ρ‚Π°ΠΉΠΌΠ°ΡƒΡ‚Ρ‹ для S3, Redis ΠΈ Π²Π½Π΅ΡˆΠ½ΠΈΡ… ΠΎΠΏΠ΅Ρ€Π°Ρ†ΠΈΠΉ - **УпрощСнная Π±Π΅Π·ΠΎΠΏΠ°ΡΠ½ΠΎΡΡ‚ΡŒ**: Π£Π΄Π°Π»Π΅Π½ слоТный rate limiting, оставлСна Ρ‚ΠΎΠ»ΡŒΠΊΠΎ нСобходимая Π·Π°Ρ‰ΠΈΡ‚Π° upload - **Vercel интСграция**: Π”ΠΎΠ±Π°Π²Π»Π΅Π½Π° ΠΏΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΊΠ° Vercel Edge API с CORS ΠΈ ΠΎΠΏΡ‚ΠΈΠΌΠΈΠ·ΠΈΡ€ΠΎΠ²Π°Π½Π½Ρ‹ΠΌΠΈ Π·Π°Π³ΠΎΠ»ΠΎΠ²ΠΊΠ°ΠΌΠΈ - **Redis graceful fallback**: ΠŸΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ Ρ‚Π΅ΠΏΠ΅Ρ€ΡŒ Ρ€Π°Π±ΠΎΡ‚Π°Π΅Ρ‚ Π±Π΅Π· Redis с прСдупрСТдСниями вмСсто ΠΏΠ°Π½ΠΈΠΊΠΈ -- **Умная Π»ΠΎΠ³ΠΈΠΊΠ° ΠΎΡ‚Π²Π΅Ρ‚ΠΎΠ²**: АвтоматичСскоС ΠΎΠΏΡ€Π΅Π΄Π΅Π»Π΅Π½ΠΈΠ΅ Vercel запросов ΠΈ ΠΎΠΏΡ‚ΠΈΠΌΠΈΠ·ΠΈΡ€ΠΎΠ²Π°Π½Π½Ρ‹Π΅ Π·Π°Π³ΠΎΠ»ΠΎΠ²ΠΊΠΈ -- **ΠšΠΎΠ½ΡΠΎΠ»ΠΈΠ΄Π°Ρ†ΠΈΡ Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚Π°Ρ†ΠΈΠΈ**: ΠžΠ±ΡŠΠ΅Π΄ΠΈΠ½Π΅Π½Ρ‹ 4 Vercel Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚Π° Π² ΠΎΠ΄ΠΈΠ½ comprehensive guide ### πŸ“ ОбновлСно - ΠšΠΎΠ½ΡΠΎΠ»ΠΈΠ΄ΠΈΡ€ΠΎΠ²Π°Π½Π° докумСнтация Π² ΠΏΡ€Π°ΠΊΡ‚ΠΈΡ‡Π΅ΡΠΊΡƒΡŽ структуру: @@ -27,8 +26,9 @@ - Π”ΡƒΠ±Π»ΠΈΡ€ΡƒΡŽΡ‰ΠΈΠΉΡΡ ΠΊΠΎΠ½Ρ‚Π΅Π½Ρ‚ Π² Π½Π΅ΡΠΊΠΎΠ»ΡŒΠΊΠΈΡ… Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚Π°Ρ… - ИзлишнС Π΄Π΅Ρ‚Π°Π»ΡŒΠ½Π°Ρ докумСнтация для простого Ρ„Π°ΠΉΠ»ΠΎΠ²ΠΎΠ³ΠΎ прокси - 4 ΠΎΡ‚Π΄Π΅Π»ΡŒΠ½Ρ‹Ρ… Vercel Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚Π°: vercel-thumbnails.md, vercel-integration.md, hybrid-architecture.md, vercel-og-integration.md +- Π›ΠΎΠΊΠ°Π»ΡŒΠ½ΠΎΠ΅ Ρ„Π°ΠΉΠ»ΠΎΠ²ΠΎΠ΅ ΠΊΡΡˆΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ ΠΌΠΈΠ½ΠΈΠ°Ρ‚ΡŽΡ€ (Π·Π°ΠΌΠ΅Π½Π΅Π½ΠΎ Π½Π° Storj) -πŸ’‹ **Π£ΠΏΡ€ΠΎΡ‰Π΅Π½ΠΈΠ΅**: KISS ΠΏΡ€ΠΈΠ½Ρ†ΠΈΠΏ ΠΏΡ€ΠΈΠΌΠ΅Π½Π΅Π½ - ΡƒΠ±Ρ€Π°Π»ΠΈ ΠΈΠ·Π±Ρ‹Ρ‚ΠΎΡ‡Π½ΠΎΡΡ‚ΡŒ, оставили ΡΡƒΡ‚ΡŒ. +πŸ’‹ **ВосстановлСниС**: Thumbnail Ρ„ΡƒΠ½ΠΊΡ†ΠΈΠΎΠ½Π°Π»ΡŒΠ½ΠΎΡΡ‚ΡŒ Π²ΠΎΠ·Π²Ρ€Π°Ρ‰Π΅Π½Π° Π² Quoter с ΡƒΠ»ΡƒΡ‡ΡˆΠ΅Π½Π½Ρ‹ΠΌ Storj ΠΊΡΡˆΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ΠΌ. ## [0.6.0] - 2025-09-02 diff --git a/docs/features.md b/docs/features.md index 086e363..6a515bb 100644 --- a/docs/features.md +++ b/docs/features.md @@ -1,6 +1,6 @@ # Quoter Features -Simple file upload/download proxy with user quotas and S3 storage. +Simple file upload/download proxy with thumbnail generation and S3 storage. ## What Quoter Does @@ -13,22 +13,29 @@ Simple file upload/download proxy with user quotas and S3 storage. ### πŸ“ File Storage - **S3-compatible storage** (Storj primary, AWS fallback) -- **Redis caching** for file metadata and quotas +- **Redis caching** for file metadata and quotas (graceful fallback) - **Multi-cloud support** with automatic migration +### πŸ–ΌοΈ Thumbnail Generation +- **On-demand WebP thumbnails** with configurable dimensions +- **Storj caching** for generated thumbnails +- **Smart fallback** to original images if generation fails +- **ETag caching** for optimal performance + ### 🌐 File Serving - **Direct file access** via filename +- **Thumbnail access** via `filename_width.ext` pattern - **Fast response** optimized for Vercel Edge caching - **CORS whitelist** for secure access (includes Vercel domains) - **Vercel-compatible headers** for optimal edge caching ## πŸš€ Modern Architecture -**Quoter**: Simple file upload/download + S3 storage -**Vercel**: Smart thumbnails + optimization + global CDN +**Quoter**: File upload/download + thumbnail generation + S3 storage +**Vercel**: Global CDN + edge optimization -πŸ’‹ **Ultra-simple**: Quoter just handles raw files. That's it. -πŸ’‹ **Simplified**: Focus on what each service does best. +πŸ’‹ **Self-contained**: Quoter handles everything - uploads, thumbnails, and serving. +πŸ’‹ **Reliable**: No external dependencies for core functionality. ## Technical Stack @@ -44,5 +51,7 @@ Simple file upload/download proxy with user quotas and S3 storage. - βœ… S3 storage integration - βœ… JWT authentication - βœ… Rate limiting & security +- βœ… Thumbnail generation with Storj caching +- βœ… ETag caching for performance - βœ… Full test coverage - πŸš€ Production ready diff --git a/src/handlers/proxy.rs b/src/handlers/proxy.rs index d06ae40..a25dc8c 100644 --- a/src/handlers/proxy.rs +++ b/src/handlers/proxy.rs @@ -9,12 +9,14 @@ 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::parse_file_path; +use crate::thumbnail::{ + parse_file_path, is_image_file, generate_webp_thumbnail, + load_cached_thumbnail_from_storj, cache_thumbnail_to_storj +}; // Π£Π΄Π°Π»Π΅Π½Π° Π΄ΡƒΠ±Π»ΠΈΡ€ΡƒΡŽΡ‰Π°Ρ функция, ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅Ρ‚ΡΡ ΠΈΠ· common модуля -/// ΠžΠ±Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊ для скачивания Ρ„Π°ΠΉΠ»Π° -/// Π±Π΅Π· Π³Π΅Π½Π΅Ρ€Π°Ρ†ΠΈΠΈ ΠΌΠΈΠ½ΠΈΠ°Ρ‚ΡŽΡ€ - это Π΄Π΅Π»Π°Π΅Ρ‚ Vercel Edge API +/// ΠžΠ±Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊ для скачивания Ρ„Π°ΠΉΠ»Π° с ΠΏΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΊΠΎΠΉ thumbnail Π³Π΅Π½Π΅Ρ€Π°Ρ†ΠΈΠΈ #[allow(clippy::collapsible_if)] pub async fn proxy_handler( req: HttpRequest, @@ -129,6 +131,80 @@ pub async fn proxy_handler( let elapsed = start_time.elapsed(); info!("File served from AWS in {:?}: {}", elapsed, path); + // ΠŸΡ€ΠΎΠ²Π΅Ρ€ΡΠ΅ΠΌ, Π½ΡƒΠΆΠ΅Π½ Π»ΠΈ thumbnail + if requested_width > 0 && is_image_file(&filekey) { + info!("Generating thumbnail for {} with width {}", filekey, requested_width); + + // ΠŸΡ€ΠΎΠ±ΡƒΠ΅ΠΌ Π·Π°Π³Ρ€ΡƒΠ·ΠΈΡ‚ΡŒ ΠΈΠ· Storj кэша + if let Some(cached_thumb) = load_cached_thumbnail_from_storj( + &state.storj_client, + &state.bucket, + &base_filename, + requested_width, + None + ).await { + info!("Serving cached thumbnail from Storj for {}", base_filename); + let thumb_content_type = "image/webp"; + + if is_vercel_request(&req) { + let etag = format!("\"{:x}\"", md5::compute(&cached_thumb)); + return Ok(create_vercel_compatible_response( + thumb_content_type, + cached_thumb, + &etag, + )); + } else { + return Ok(create_file_response_with_analytics( + thumb_content_type, + cached_thumb, + &req, + &format!("{}_{}x.webp", base_filename, requested_width), + )); + } + } + + // Π“Π΅Π½Π΅Ρ€ΠΈΡ€ΡƒΠ΅ΠΌ Π½ΠΎΠ²Ρ‹ΠΉ thumbnail + match generate_webp_thumbnail(&filedata, requested_width, None) { + Ok(thumb_data) => { + info!("Generated thumbnail: {} bytes", thumb_data.len()); + + // ΠšΡΡˆΠΈΡ€ΡƒΠ΅ΠΌ thumbnail Π² Storj + if let Err(e) = cache_thumbnail_to_storj( + &state.storj_client, + &state.bucket, + &base_filename, + requested_width, + None, + &thumb_data + ).await { + warn!("Failed to cache thumbnail to Storj: {}", e); + } + + let thumb_content_type = "image/webp"; + + if is_vercel_request(&req) { + let etag = format!("\"{:x}\"", md5::compute(&thumb_data)); + return Ok(create_vercel_compatible_response( + thumb_content_type, + thumb_data, + &etag, + )); + } else { + return Ok(create_file_response_with_analytics( + thumb_content_type, + thumb_data, + &req, + &format!("{}_{}x.webp", base_filename, requested_width), + )); + } + } + Err(e) => { + warn!("Failed to generate thumbnail: {}", e); + // Fallback to original image + } + } + } + // Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅ΠΌ Vercel-совмСстимый ΠΎΡ‚Π²Π΅Ρ‚ для Vercel запросов if is_vercel_request(&req) { let etag = format!("\"{:x}\"", md5::compute(&filedata)); diff --git a/src/handlers/serve_file.rs b/src/handlers/serve_file.rs index cf1d804..98b6384 100644 --- a/src/handlers/serve_file.rs +++ b/src/handlers/serve_file.rs @@ -1,14 +1,21 @@ use actix_web::{HttpRequest, HttpResponse, Result, error::ErrorInternalServerError}; use mime_guess::MimeGuess; +use log::{info, warn}; use super::common::{ - create_file_response_with_analytics, create_vercel_compatible_response, is_vercel_request, + create_file_response_with_analytics, + create_vercel_compatible_response, + is_vercel_request, }; use crate::app_state::AppState; use crate::s3_utils::{check_file_exists, load_file_from_s3}; +use crate::thumbnail::{ + parse_file_path, is_image_file, generate_webp_thumbnail, + load_cached_thumbnail_from_storj, cache_thumbnail_to_storj +}; +use md5; -/// Ѐункция для обслуТивания Ρ„Π°ΠΉΠ»Π° ΠΏΠΎ Π·Π°Π΄Π°Π½Π½ΠΎΠΌΡƒ ΠΏΡƒΡ‚ΠΈ. -/// Π’Π΅ΠΏΠ΅Ρ€ΡŒ ΠΎΠΏΡ‚ΠΈΠΌΠΈΠ·ΠΈΡ€ΠΎΠ²Π°Π½Π° для Vercel Edge caching. +/// Ѐункция для обслуТивания Ρ„Π°ΠΉΠ»Π° ΠΏΠΎ Π·Π°Π΄Π°Π½Π½ΠΎΠΌΡƒ ΠΏΡƒΡ‚ΠΈ с ΠΏΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΊΠΎΠΉ thumbnail Π³Π΅Π½Π΅Ρ€Π°Ρ†ΠΈΠΈ. pub async fn serve_file( filepath: &str, state: &AppState, @@ -34,6 +41,83 @@ pub async fn serve_file( ErrorInternalServerError(format!("Failed to load {} from Storj: {}", filepath, e)) })?; + // ΠŸΠ°Ρ€ΡΠΈΠΌ ΠΏΡƒΡ‚ΡŒ для ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΊΠΈ thumbnail запроса + let (base_filename, requested_width, _) = parse_file_path(filepath); + + // ΠŸΡ€ΠΎΠ²Π΅Ρ€ΡΠ΅ΠΌ, Π½ΡƒΠΆΠ΅Π½ Π»ΠΈ thumbnail + if requested_width > 0 && is_image_file(filepath) { + info!("Generating thumbnail for {} with width {}", filepath, requested_width); + + // ΠŸΡ€ΠΎΠ±ΡƒΠ΅ΠΌ Π·Π°Π³Ρ€ΡƒΠ·ΠΈΡ‚ΡŒ ΠΈΠ· Storj кэша + if let Some(cached_thumb) = load_cached_thumbnail_from_storj( + &state.storj_client, + &state.bucket, + &base_filename, + requested_width, + None + ).await { + info!("Serving cached thumbnail from Storj for {}", base_filename); + let thumb_content_type = "image/webp"; + + if is_vercel_request(req) { + let etag = format!("\"{:x}\"", md5::compute(&cached_thumb)); + return Ok(create_vercel_compatible_response( + thumb_content_type, + cached_thumb, + &etag, + )); + } else { + return Ok(create_file_response_with_analytics( + thumb_content_type, + cached_thumb, + req, + &format!("{}_{}x.webp", base_filename, requested_width), + )); + } + } + + // Π“Π΅Π½Π΅Ρ€ΠΈΡ€ΡƒΠ΅ΠΌ Π½ΠΎΠ²Ρ‹ΠΉ thumbnail + match generate_webp_thumbnail(&filedata, requested_width, None) { + Ok(thumb_data) => { + info!("Generated thumbnail: {} bytes", thumb_data.len()); + + // ΠšΡΡˆΠΈΡ€ΡƒΠ΅ΠΌ thumbnail Π² Storj + if let Err(e) = cache_thumbnail_to_storj( + &state.storj_client, + &state.bucket, + &base_filename, + requested_width, + None, + &thumb_data + ).await { + warn!("Failed to cache thumbnail to Storj: {}", e); + } + + let thumb_content_type = "image/webp"; + + if is_vercel_request(req) { + let etag = format!("\"{:x}\"", md5::compute(&thumb_data)); + return Ok(create_vercel_compatible_response( + thumb_content_type, + thumb_data, + &etag, + )); + } else { + return Ok(create_file_response_with_analytics( + thumb_content_type, + thumb_data, + req, + &format!("{}_{}x.webp", base_filename, requested_width), + )); + } + } + Err(e) => { + warn!("Failed to generate thumbnail: {}", e); + // Fallback to original image + } + } + } + // ΠžΠΏΡ€Π΅Π΄Π΅Π»ΡΠ΅ΠΌ MIME Ρ‚ΠΈΠΏ let mime_type = MimeGuess::from_path(filepath).first_or_octet_stream(); diff --git a/src/thumbnail.rs b/src/thumbnail.rs index 555a927..13388ad 100644 --- a/src/thumbnail.rs +++ b/src/thumbnail.rs @@ -1,4 +1,9 @@ -// ΠœΠΎΠ΄ΡƒΠ»ΡŒ для парсинга ΠΏΡƒΡ‚Π΅ΠΉ ΠΊ Ρ„Π°ΠΉΠ»Π°ΠΌ (Π±Π΅Π· Π³Π΅Π½Π΅Ρ€Π°Ρ†ΠΈΠΈ ΠΌΠΈΠ½ΠΈΠ°Ρ‚ΡŽΡ€) +use image::ImageFormat; +use std::path::Path; +use std::fs; +use std::io::Write; +use log::{info, warn}; +use aws_sdk_s3::Client as S3Client; /// ΠŸΠ°Ρ€ΡΠΈΡ‚ ΠΏΡƒΡ‚ΡŒ ΠΊ Ρ„Π°ΠΉΠ»Ρƒ, извлСкая Π±Π°Π·ΠΎΠ²ΠΎΠ΅ имя, ΡˆΠΈΡ€ΠΈΠ½Ρƒ ΠΈ Ρ€Π°ΡΡˆΠΈΡ€Π΅Π½ΠΈΠ΅. /// @@ -35,6 +40,272 @@ pub fn parse_file_path(path: &str) -> (String, u32, String) { (name_part.to_string(), 0, extension) } +/// Π“Π΅Π½Π΅Ρ€ΠΈΡ€ΡƒΠ΅Ρ‚ thumbnail для изобраТСния. +/// +/// # АргумСнты +/// * `image_data` - Π”Π°Π½Π½Ρ‹Π΅ изобраТСния +/// * `width` - Π¨ΠΈΡ€ΠΈΠ½Π° thumbnail +/// * `height` - Высота thumbnail (ΠΎΠΏΡ†ΠΈΠΎΠ½Π°Π»ΡŒΠ½ΠΎ) +/// * `format` - Π€ΠΎΡ€ΠΌΠ°Ρ‚ Π²Ρ‹Ρ…ΠΎΠ΄Π½ΠΎΠ³ΠΎ изобраТСния (ΠΏΠΎ ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ WebP) +/// +/// # Π’ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ +/// * `Result, Box>` - Π”Π°Π½Π½Ρ‹Π΅ thumbnail ΠΈΠ»ΠΈ ошибка +pub fn generate_thumbnail( + image_data: &[u8], + width: u32, + height: Option, + format: Option, +) -> Result, Box> { + info!("Generating thumbnail: {}x{}", width, height.unwrap_or(width)); + + // Π—Π°Π³Ρ€ΡƒΠΆΠ°Π΅ΠΌ ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠ΅ + let img = image::load_from_memory(image_data)?; + + // ВычисляСм Ρ€Π°Π·ΠΌΠ΅Ρ€Ρ‹ + let target_height = height.unwrap_or_else(|| { + let aspect_ratio = img.height() as f32 / img.width() as f32; + (width as f32 * aspect_ratio) as u32 + }); + + // РСсайзим ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠ΅ + let resized = img.thumbnail(width, target_height); + + // ΠšΠΎΠ½Π²Π΅Ρ€Ρ‚ΠΈΡ€ΡƒΠ΅ΠΌ Π² Π½ΡƒΠΆΠ½Ρ‹ΠΉ Ρ„ΠΎΡ€ΠΌΠ°Ρ‚ + let output_format = format.unwrap_or(ImageFormat::WebP); + + let mut buffer = Vec::new(); + resized.write_to(&mut std::io::Cursor::new(&mut buffer), output_format)?; + + info!("Thumbnail generated: {} bytes", buffer.len()); + Ok(buffer) +} + +/// Π“Π΅Π½Π΅Ρ€ΠΈΡ€ΡƒΠ΅Ρ‚ WebP thumbnail для изобраТСния. +pub fn generate_webp_thumbnail( + image_data: &[u8], + width: u32, + height: Option, +) -> Result, Box> { + generate_thumbnail(image_data, width, height, Some(ImageFormat::WebP)) +} + +/// Π“Π΅Π½Π΅Ρ€ΠΈΡ€ΡƒΠ΅Ρ‚ JPEG thumbnail для изобраТСния. +pub fn generate_jpeg_thumbnail( + image_data: &[u8], + width: u32, + height: Option, +) -> Result, Box> { + generate_thumbnail(image_data, width, height, Some(ImageFormat::Jpeg)) +} + +/// ΠŸΡ€ΠΎΠ²Π΅Ρ€ΡΠ΅Ρ‚, являСтся Π»ΠΈ Ρ„Π°ΠΉΠ» ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠ΅ΠΌ. +pub fn is_image_file(filename: &str) -> bool { + let ext = Path::new(filename) + .extension() + .and_then(|s| s.to_str()) + .map(|s| s.to_lowercase()) + .unwrap_or_default(); + + matches!(ext.as_str(), "jpg" | "jpeg" | "png" | "gif" | "bmp" | "webp" | "tiff") +} + +/// ΠŸΠΎΠ»ΡƒΡ‡Π°Π΅Ρ‚ MIME Ρ‚ΠΈΠΏ для изобраТСния. +pub fn get_image_mime_type(filename: &str) -> &'static str { + let ext = Path::new(filename) + .extension() + .and_then(|s| s.to_str()) + .map(|s| s.to_lowercase()) + .unwrap_or_default(); + + match ext.as_str() { + "jpg" | "jpeg" => "image/jpeg", + "png" => "image/png", + "gif" => "image/gif", + "bmp" => "image/bmp", + "webp" => "image/webp", + "tiff" => "image/tiff", + _ => "application/octet-stream", + } +} + +/// ΠšΡΡˆΠΈΡ€ΡƒΠ΅Ρ‚ thumbnail Π² Ρ„Π°ΠΉΠ»ΠΎΠ²ΠΎΠΉ систСмС. +pub fn cache_thumbnail( + cache_dir: &str, + filename: &str, + width: u32, + height: Option, + thumbnail_data: &[u8], +) -> Result> { + // Π‘ΠΎΠ·Π΄Π°Π΅ΠΌ Π΄ΠΈΡ€Π΅ΠΊΡ‚ΠΎΡ€ΠΈΡŽ кэша Ссли Π½Π΅ сущСствуСт + fs::create_dir_all(cache_dir)?; + + // Π“Π΅Π½Π΅Ρ€ΠΈΡ€ΡƒΠ΅ΠΌ имя Ρ„Π°ΠΉΠ»Π° кэша + let cache_filename = if let Some(h) = height { + format!("{}_{}x{}.webp", filename, width, h) + } else { + format!("{}_{}x.webp", filename, width) + }; + + let cache_path = Path::new(cache_dir).join(&cache_filename); + + // БохраняСм thumbnail + let mut file = fs::File::create(&cache_path)?; + file.write_all(thumbnail_data)?; + + info!("Thumbnail cached: {}", cache_path.display()); + Ok(cache_path.to_string_lossy().to_string()) +} + +/// Π—Π°Π³Ρ€ΡƒΠΆΠ°Π΅Ρ‚ thumbnail ΠΈΠ· кэша. +pub fn load_cached_thumbnail( + cache_dir: &str, + filename: &str, + width: u32, + height: Option, +) -> Option> { + let cache_filename = if let Some(h) = height { + format!("{}_{}x{}.webp", filename, width, h) + } else { + format!("{}_{}x.webp", filename, width) + }; + + let cache_path = Path::new(cache_dir).join(&cache_filename); + + match fs::read(&cache_path) { + Ok(data) => { + info!("Thumbnail loaded from cache: {}", cache_path.display()); + Some(data) + } + Err(e) => { + warn!("Failed to load cached thumbnail: {}", e); + None + } + } +} + +/// ΠšΡΡˆΠΈΡ€ΡƒΠ΅Ρ‚ thumbnail Π² Storj S3. +pub async fn cache_thumbnail_to_storj( + s3_client: &S3Client, + bucket: &str, + filename: &str, + width: u32, + height: Option, + thumbnail_data: &[u8], +) -> Result> { + // Π“Π΅Π½Π΅Ρ€ΠΈΡ€ΡƒΠ΅ΠΌ ΠΊΠ»ΡŽΡ‡ для thumbnail Π² Storj + let thumbnail_key = if let Some(h) = height { + format!("thumbnails/{}_{}x{}.webp", filename, width, h) + } else { + format!("thumbnails/{}_{}x.webp", filename, width) + }; + + // Π—Π°Π³Ρ€ΡƒΠΆΠ°Π΅ΠΌ thumbnail Π² Storj + let body = aws_sdk_s3::primitives::ByteStream::from(thumbnail_data.to_vec()); + + s3_client + .put_object() + .bucket(bucket) + .key(&thumbnail_key) + .body(body) + .content_type("image/webp") + .send() + .await?; + + info!("Thumbnail cached to Storj: {}", thumbnail_key); + Ok(thumbnail_key) +} + +/// Π—Π°Π³Ρ€ΡƒΠΆΠ°Π΅Ρ‚ thumbnail ΠΈΠ· Storj S3. +pub async fn load_cached_thumbnail_from_storj( + s3_client: &S3Client, + bucket: &str, + filename: &str, + width: u32, + height: Option, +) -> Option> { + let thumbnail_key = if let Some(h) = height { + format!("thumbnails/{}_{}x{}.webp", filename, width, h) + } else { + format!("thumbnails/{}_{}x.webp", filename, width) + }; + + match s3_client + .get_object() + .bucket(bucket) + .key(&thumbnail_key) + .send() + .await + { + Ok(response) => { + match response.body.collect().await { + Ok(data) => { + let bytes = data.into_bytes(); + info!("Thumbnail loaded from Storj: {} ({} bytes)", thumbnail_key, bytes.len()); + Some(bytes.to_vec()) + } + Err(e) => { + warn!("Failed to read thumbnail data from Storj: {}", e); + None + } + } + } + Err(e) => { + warn!("Failed to load cached thumbnail from Storj: {}", e); + None + } + } +} + +/// ΠŸΡ€ΠΎΠ²Π΅Ρ€ΡΠ΅Ρ‚, сущСствуСт Π»ΠΈ thumbnail Π² Storj. +pub async fn thumbnail_exists_in_storj( + s3_client: &S3Client, + bucket: &str, + filename: &str, + width: u32, + height: Option, +) -> bool { + let thumbnail_key = if let Some(h) = height { + format!("thumbnails/{}_{}x{}.webp", filename, width, h) + } else { + format!("thumbnails/{}_{}x.webp", filename, width) + }; + + match s3_client + .head_object() + .bucket(bucket) + .key(&thumbnail_key) + .send() + .await + { + Ok(_) => true, + Err(_) => false, + } +} + +/// ΠžΡ‡ΠΈΡ‰Π°Π΅Ρ‚ старыС Ρ„Π°ΠΉΠ»Ρ‹ кэша. +pub fn cleanup_cache(cache_dir: &str, max_age_days: u64) -> Result<(), Box> { + let cache_path = Path::new(cache_dir); + if !cache_path.exists() { + return Ok(()); + } + + let cutoff_time = std::time::SystemTime::now() - std::time::Duration::from_secs(max_age_days * 24 * 60 * 60); + + for entry in fs::read_dir(cache_path)? { + let entry = entry?; + let metadata = entry.metadata()?; + + if let Ok(modified) = metadata.modified() { + if modified < cutoff_time { + fs::remove_file(entry.path())?; + info!("Removed old cache file: {}", entry.path().display()); + } + } + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -71,4 +342,23 @@ mod tests { assert_eq!(width, 800); assert_eq!(ext, "jpg"); } -} + + #[test] + fn test_is_image_file() { + assert!(is_image_file("image.jpg")); + assert!(is_image_file("photo.png")); + assert!(is_image_file("gif.gif")); + assert!(is_image_file("webp.webp")); + assert!(!is_image_file("document.pdf")); + assert!(!is_image_file("text.txt")); + } + + #[test] + fn test_get_image_mime_type() { + assert_eq!(get_image_mime_type("image.jpg"), "image/jpeg"); + assert_eq!(get_image_mime_type("photo.png"), "image/png"); + assert_eq!(get_image_mime_type("animation.gif"), "image/gif"); + assert_eq!(get_image_mime_type("modern.webp"), "image/webp"); + assert_eq!(get_image_mime_type("unknown.xyz"), "application/octet-stream"); + } +} \ No newline at end of file