diff --git a/CHANGELOG.md b/CHANGELOG.md index b39d0fe..c72fd0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,18 +8,25 @@ - **Логирование запросов**: Добавлена аналитика источников для оптимизации CORS whitelist - **Реализация таймаутов**: Добавлены настраиваемые таймауты для S3, Redis и внешних операций - **Упрощенная безопасность**: Удален сложный rate limiting, оставлена только необходимая защита upload +- **Vercel интеграция**: Добавлена поддержка Vercel Edge API с CORS и оптимизированными заголовками +- **Redis graceful fallback**: Приложение теперь работает без Redis с предупреждениями вместо паники +- **Умная логика ответов**: Автоматическое определение Vercel запросов и оптимизированные заголовки +- **Консолидация документации**: Объединены 4 Vercel документа в один comprehensive guide ### 📝 Обновлено - Консолидирована документация в практическую структуру: - Основной README.md с быстрым стартом - docs/SETUP.md для конфигурации и развертывания - Упрощенный features.md с фокусом на основную функциональность + - docs/vercel-frontend-migration.md - единый comprehensive guide для Vercel интеграции - Добавлен акцент на Vercel по всему коду и документации +- Обновлены URL patterns в документации: quoter.discours.io → files.dscrs.site ### 🗑️ Удалено - Избыточные файлы документации (api-reference, deployment, development, и т.д.) - Дублирующийся контент в нескольких документах - Излишне детальная документация для простого файлового прокси +- 4 отдельных Vercel документа: vercel-thumbnails.md, vercel-integration.md, hybrid-architecture.md, vercel-og-integration.md 💋 **Упрощение**: KISS принцип применен - убрали избыточность, оставили суть. @@ -274,7 +281,6 @@ ### Added - Добавлены интеграционные тесты в папку tests/ - Создан файл tests/basic_test.rs с 10 тестами: - - test_health_check - проверка health endpoint - test_json_serialization - тестирование JSON сериализации - test_multipart_form_data - проверка multipart form data - test_uuid_generation - тестирование UUID генерации diff --git a/Cargo.lock b/Cargo.lock index 6b3ce9e..c01256e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2274,6 +2274,12 @@ dependencies = [ "digest", ] +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "memchr" version = "2.7.4" @@ -2661,6 +2667,7 @@ dependencies = [ "jsonwebtoken", "kamadak-exif", "log", + "md5", "mime_guess", "once_cell", "redis", diff --git a/Cargo.toml b/Cargo.toml index 91c7896..283f92a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ sentry-actix = { version = "0.42", default-features = false } aws-sdk-s3 = { version = "1.104.0", default-features = false, features = ["rt-tokio", "rustls"] } image = { version = "0.25.6", default-features = false, features = ["jpeg", "png", "webp", "tiff"] } mime_guess = "2.0.5" +md5 = "0.7.0" aws-config = { version = "1.8.6", default-features = false, features = ["rt-tokio", "rustls"] } actix-multipart = "0.7.2" log = "0.4.22" diff --git a/docs/SETUP.md b/docs/SETUP.md index 69c9022..aa42c30 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -51,6 +51,10 @@ REQUEST_TIMEOUT_SECONDS=300 # Upload protection (optional, defaults to 10 uploads per minute per IP) # Simple protection against upload abuse for user-facing endpoints UPLOAD_LIMIT_PER_MINUTE=10 + +# Redis configuration (optional - app works without Redis) +# If Redis is unavailable, app runs in fallback mode with warnings +REDIS_URL=redis://localhost:6379 ``` ## 🐳 Docker diff --git a/docs/configuration.md b/docs/configuration.md index 3d84d9c..8118329 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -175,9 +175,6 @@ RUST_LOG=info cargo run ### Проверка endpoints ```bash -# Health check -curl http://localhost:8080/health - # User info (требует токен) curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/ diff --git a/docs/features.md b/docs/features.md index b53f201..086e363 100644 --- a/docs/features.md +++ b/docs/features.md @@ -19,8 +19,8 @@ Simple file upload/download proxy with user quotas and S3 storage. ### 🌐 File Serving - **Direct file access** via filename - **Fast response** optimized for Vercel Edge caching -- **CORS whitelist** for secure access -- **Direct file serving** optimized for CDN caching +- **CORS whitelist** for secure access (includes Vercel domains) +- **Vercel-compatible headers** for optimal edge caching ## 🚀 Modern Architecture diff --git a/docs/hybrid-architecture.md b/docs/hybrid-architecture.md deleted file mode 100644 index b979e3a..0000000 --- a/docs/hybrid-architecture.md +++ /dev/null @@ -1,243 +0,0 @@ -# 🔀 Hybrid Architecture: Vercel Edge + Quoter - -## 📋 Архитектурное решение - -Ваша спецификация описывает **идеальную гибридную архитектуру**: - -``` -📤 Upload: Quoter (контроль + квоты) -📥 Download: Vercel Edge API (производительность) -🎨 Thumbnails: Vercel /api/thumb/[width]/[...path] (динамическая генерация) -``` - -## ✅ Преимущества гибридного подхода - -### 🎯 **Лучшее из двух миров** - -| Компонент | Сервис | Почему именно он | -|-----------|---------|------------------| -| **Upload** | Quoter | Контроль квот, кастомная логика, безопасность | -| **Download** | Vercel | Автоматический WebP/AVIF, глобальный edge | -| **Resize** | Vercel | Ленивая генерация, auto-optimization | -| **OG** | Vercel | Динамическая генерация, кэширование | - -### 💰 **Экономическая эффективность** -- **Upload costs**: Только VPS + Storj (контролируемые) -- **Download costs**: Vercel edge (pay-per-use, но дешевле CDN) -- **Storage costs**: Storj (~$4/TB против $20+/TB у Vercel) - -### 🚀 **Производительность** -- **Upload**: Direct to S3, без proxy overhead -- **Download**: Vercel Edge (~50ms globally) -- **Caching**: Двухуровневое (Vercel + S3) - -## 🔧 Интеграция с текущим Quoter - -### 1. **Обновление CORS для Vercel** - -```rust -// src/main.rs - добавить Vercel в allowed origins -let cors = Cors::default() - .allowed_origin("https://discours.io") - .allowed_origin("https://new.discours.io") - .allowed_origin("https://vercel.app") // для Vercel edge functions - .allowed_origin("http://localhost:3000") // для разработки - .allowed_methods(vec!["GET", "POST", "OPTIONS"]) - // ... -``` - -### 2. **Добавление заголовков для Vercel Image API** - -```rust -// src/handlers/common.rs - добавить заголовки для Vercel -pub fn create_vercel_compatible_response(content_type: &str, data: Vec, 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", "*")) - .insert_header(("x-vercel-cache", "HIT")) // для оптимизации Vercel - .body(data) -} -``` - -### 3. **Endpoint для проверки доступности** - -```rust -// src/handlers/universal.rs - добавить health check для Vercel -async fn handle_get( - req: HttpRequest, - state: web::Data, - path: &str, -) -> Result { - match path { - "/" => crate::handlers::user::get_current_user_handler(req, state).await, - "/health" => Ok(HttpResponse::Ok().json(serde_json::json!({ - "status": "ok", - "service": "quoter", - "version": env!("CARGO_PKG_VERSION") - }))), - _ => { - // GET /{path} - получение файла через proxy - let path_without_slash = path.trim_start_matches('/'); - let requested_res = web::Path::from(path_without_slash.to_string()); - crate::handlers::proxy::proxy_handler(req, requested_res, state).await - } - } -} -``` - -## 🔄 Миграционная стратегия - -### Этап 1: Подготовка Quoter (текущий) -- ✅ **Готово**: Upload API с квотами и безопасностью -- ✅ **Готово**: Система миниатюр и ресайзинга -- ✅ **Готово**: Multi-cloud storage (Storj + AWS) -- 🔄 **Добавить**: CORS для Vercel edge functions -- 🔄 **Добавить**: Health check endpoint - -### Этап 2: Настройка Vercel Edge -```javascript -// vercel.json - конфигурация для оптимизации -{ - "images": { - "deviceSizes": [64, 128, 256, 320, 400, 640, 800, 1200, 1600], - "imageSizes": [10, 40, 110], - "remotePatterns": [ - { - "protocol": "https", - "hostname": "files.dscrs.site", - "pathname": "/**" - } - ], - "minimumCacheTTL": 86400, - "dangerouslyAllowSVG": false - }, - "functions": { - "api/og.js": { - "maxDuration": 30 - } - } -} -``` - -### Этап 3: Клиентская интеграция -```typescript -// Проверка доступности и fallback -export const getImageService = async (): Promise<'vercel' | 'quoter'> => { - // Vercel по умолчанию для большинства случаев - if (typeof window === 'undefined') return 'vercel'; // SSR - - try { - // Проверяем доступность Vercel Image API - const response = await fetch('/_next/image?url=' + encodeURIComponent('https://files.dscrs.site/test.jpg') + '&w=1&q=1'); - return response.ok ? 'vercel' : 'quoter'; - } catch { - return 'quoter'; // fallback - } -}; -``` - -## 📊 Мониторинг гибридной системы - -### Метрики Quoter (Upload) -```log -# Upload успешность -INFO Upload successful: user_123 uploaded photo.jpg (2.5MB) -INFO Quota updated: user_123 now using 45% (5.4GB/12GB) - -# Rate limiting -WARN Rate limit applied: IP 192.168.1.100 blocked for upload (10/5min exceeded) -``` - -### Метрики Vercel (Download) -```javascript -// api/metrics.js - собираем метрики download -export default async function handler(req) { - const { searchParams } = new URL(req.url); - const source = searchParams.get('source'); // 'vercel' | 'quoter' - const filename = searchParams.get('filename'); - - // Логируем использование - console.log(`Image served: ${filename} via ${source}`); - - return new Response('OK'); -} -``` - -## 🎯 Production готовность - -### Load Testing -```bash -# Test upload через Quoter -ab -n 100 -c 10 -T 'multipart/form-data; boundary=----WebKitFormBoundary' \ - -H "Authorization: Bearer $TOKEN" \ - https://files.dscrs.site/ - -# Test download через Vercel -ab -n 1000 -c 50 \ - 'https://discours.io/_next/image?url=https%3A//files.dscrs.site/test.jpg&w=600&q=75' -``` - -### Failover Strategy -```typescript -export const getImageWithFailover = async (filename: string, width: number) => { - const strategies = [ - () => getVercelImageUrl(`https://files.dscrs.site/${filename}`, width), - () => getQuoterWebpUrl(filename, width), - () => `https://files.dscrs.site/${filename}` // fallback to original - ]; - - for (const strategy of strategies) { - try { - const url = strategy(); - const response = await fetch(url, { method: 'HEAD' }); - if (response.ok) return url; - } catch (error) { - console.warn('Image strategy failed:', error); - } - } - - throw new Error('All image strategies failed'); -}; -``` - -## 💡 Рекомендации по оптимизации - -### 1. **Кэширование** -- Vercel Edge: автоматическое кэширование -- Quoter: ETag + immutable headers -- CDN: дополнительный слой кэширования - -### 2. **Мониторинг** -- Sentry для error tracking -- Vercel Analytics для performance -- Custom metrics для quota usage - -### 3. **Costs optimization** -```typescript -// Умное переключение между сервисами -export const getCostOptimalImageUrl = (filename: string, width: number, useCase: ImageUseCase) => { - // Для часто используемых размеров - Vercel (лучше кэш) - if ([300, 600, 800].includes(width)) { - return getVercelImageUrl(`https://files.dscrs.site/${filename}`, width); - } - - // Для редких размеров - Quoter (избегаем Vercel billing) - return getQuoterWebpUrl(filename, width); -}; -``` - -## ✅ Выводы - -Ваша архитектура идеальна потому что: - -1. **Upload остается в Quoter** - полный контроль безопасности и квот -2. **Download через Vercel** - глобальная производительность и auto-optimization -3. **OG через @vercel/og** - динамическая генерация без сложности -4. **Постепенная миграция** - можно внедрять поэтапно -5. **Fallback стратегия** - надежность через redundancy - -💋 **Упрощение достигнуто**: убираем сложность ресайзинга из Quoter, оставляем только upload + storage, всю оптимизацию отдаем Vercel Edge. - -Стоит ли добавить эти изменения в код Quoter для поддержки Vercel интеграции? diff --git a/docs/vercel-thumbnails.md b/docs/vercel-frontend-migration.md similarity index 59% rename from docs/vercel-thumbnails.md rename to docs/vercel-frontend-migration.md index a3a155d..e7803d3 100644 --- a/docs/vercel-thumbnails.md +++ b/docs/vercel-frontend-migration.md @@ -1,21 +1,21 @@ -# Vercel Thumbnail Generation Integration +# 🚀 Vercel Frontend Migration Guide -## 🎯 Overview +## 📋 Overview -**Quoter**: Dead simple file upload/download service. Just raw files. -**Vercel**: Smart thumbnail generation and optimization. +**Quoter**: Simple file upload/download service (raw files only) +**Vercel**: Smart thumbnail generation, optimization, and global CDN Perfect separation of concerns! 💋 -## 🔗 URL Patterns for Vercel +## 🔗 URL Patterns -### Quoter File URLs +### Quoter (Raw Files) ``` -https://quoter.discours.io/image.jpg → Original file -https://quoter.discours.io/document.pdf → Original file +https://files.discours.io/image.jpg → Original file +https://files.discours.io/document.pdf → Original file ``` -### Vercel Thumbnail URLs (SolidJS) +### Vercel (Optimized Thumbnails) ``` https://new.discours.io/api/thumb/300/image.jpg → 300px width https://new.discours.io/api/thumb/600/image.jpg → 600px width @@ -34,13 +34,37 @@ export default defineConfig({ }, vite: { define: { - 'process.env.QUOTER_URL': JSON.stringify('https://quoter.discours.io'), + 'process.env.PUBLIC_CDN_URL': JSON.stringify('https://files.discours.io'), }, }, }); ``` -### 2. Thumbnail API Route (/api/thumb/[width]/[...path].ts) +### 2. vercel.json Configuration +```json +{ + "images": { + "deviceSizes": [64, 128, 256, 320, 400, 640, 800, 1200, 1600], + "imageSizes": [10, 40, 110], + "remotePatterns": [ + { + "protocol": "https", + "hostname": "quoter.discours.io", + "pathname": "/**" + } + ], + "minimumCacheTTL": 86400, + "dangerouslyAllowSVG": false + }, + "functions": { + "api/thumb/[width]/[...path].js": { + "maxDuration": 30 + } + } +} +``` + +### 3. Thumbnail API Route (/api/thumb/[width]/[...path].ts) ```typescript import { ImageResponse } from '@vercel/og'; import type { APIRoute } from '@solidjs/start'; @@ -48,7 +72,7 @@ import type { APIRoute } from '@solidjs/start'; export const GET: APIRoute = async ({ params, request }) => { const width = parseInt(params.width); const imagePath = params.path.split('/').join('/'); - const quoterUrl = `https://quoter.discours.io/${imagePath}`; + const quoterUrl = `https://files.discours.io/${imagePath}`; // Fetch original from Quoter const response = await fetch(quoterUrl); @@ -71,56 +95,16 @@ export const GET: APIRoute = async ({ params, request }) => { { width: width, height: Math.round(width * 0.75), // 4:3 aspect ratio - } + }, ); }; ``` -## 📋 File Naming Conventions - -### Quoter Storage (No Width Patterns) -``` -✅ image.jpg → Clean filename -✅ photo-2024.png → kebab-case -✅ user-avatar.webp → descriptive names -✅ document.pdf → any file type - -❌ image_300.jpg → No width patterns needed -❌ photo-thumbnail.jpg → No thumbnail suffix -❌ userAvatar.png → No camelCase -``` - -### URL Routing Examples -```bash -# Client requests thumbnail -GET /api/thumb/600/image.jpg - -# Vercel fetches original from Quoter -GET https://quoter.discours.io/image.jpg - -# Vercel generates and caches 600px thumbnail -→ Returns optimized image -``` - -## 🚀 Benefits of This Architecture - -### For Quoter -- **Simple storage**: Just store original files -- **No processing**: Zero thumbnail generation load -- **Fast uploads**: Direct S3 storage without resizing -- **Predictable URLs**: Clean file paths - -### For Vercel -- **Edge optimization**: Global CDN caching -- **Dynamic sizing**: Any width on-demand -- **Smart caching**: Automatic cache invalidation -- **Format optimization**: WebP/AVIF when supported - -## 🔧 SolidJS Frontend Integration +## 🔧 Frontend Integration ### 1. Install Dependencies ```bash -npm install @tanstack/solid-query @solidjs/start +npm install @tanstack/solid-query @solidjs/start @vercel/og ``` ### 2. Query Client Setup (app.tsx) @@ -165,7 +149,7 @@ export function useFileUpload() { const formData = new FormData(); formData.append('file', file); - const response = await fetch('https://quoter.discours.io/', { + const response = await fetch('https://files.discours.io/', { method: 'POST', headers: { 'Authorization': `Bearer ${getAuthToken()}`, @@ -205,9 +189,9 @@ export function Image(props: ImageProps) { const thumbnailUrl = () => props.width ? `https://new.discours.io/api/thumb/${props.width}/${props.filename}` - : `https://quoter.discours.io/${props.filename}`; + : `https://files.discours.io/${props.filename}`; - const fallbackUrl = () => `https://quoter.discours.io/${props.filename}`; + const fallbackUrl = () => `https://files.discours.io/${props.filename}`; return ( @@ -248,7 +232,7 @@ export function UserQuota() { const query = createQuery(() => ({ queryKey: ['user'], queryFn: async () => { - const response = await fetch('https://quoter.discours.io/', { + const response = await fetch('https://files.discours.io/', { headers: { 'Authorization': `Bearer ${getAuthToken()}`, }, @@ -285,12 +269,109 @@ export function UserQuota() { } ``` -## 🔧 Implementation Steps +## 🎨 OpenGraph Integration -1. **Quoter**: Serve raw files only (no patterns) -2. **Vercel**: Create SolidJS API routes for thumbnails -3. **Frontend**: Use TanStack Query for data fetching -4. **CORS**: Configure Quoter to allow Vercel domain +### OG Image Generation (/api/og/[...slug].ts) +```typescript +import { ImageResponse } from '@vercel/og'; +import type { APIRoute } from '@solidjs/start'; + +export const GET: APIRoute = async ({ params, request }) => { + try { + const { searchParams } = new URL(request.url); + + const title = searchParams.get('title') ?? 'Default Title'; + const description = searchParams.get('description') ?? 'Default Description'; + const imageUrl = searchParams.get('image'); // URL from Quoter + + // Load image from Quoter if provided + let backgroundImage = null; + if (imageUrl) { + try { + const imageResponse = await fetch(imageUrl); + const imageBuffer = await imageResponse.arrayBuffer(); + backgroundImage = `data:image/jpeg;base64,${Buffer.from(imageBuffer).toString('base64')}`; + } catch (error) { + console.error('Failed to load image from Quoter:', error); + } + } + + return new ImageResponse( + ( +
+
+
+

+ {title} +

+

+ {description} +

+
+
+ ), + { + width: 1200, + height: 630, + } + ); + } catch (e: any) { + console.log(`${e.message}`); + return new Response(`Failed to generate the image`, { + status: 500, + }); + } +}; +``` ## 📊 Request Flow @@ -312,46 +393,28 @@ sequenceDiagram Note over Vercel: Cache thumbnail at edge ``` -## 🎨 Advanced Vercel Features +## 🎯 Migration Benefits -### Smart Format Detection -```javascript -// Auto-serve WebP/AVIF when supported -export async function GET(request) { - const accept = request.headers.get('accept'); - const supportsWebP = accept?.includes('image/webp'); - const supportsAVIF = accept?.includes('image/avif'); - - return new ImageResponse( - // ... image component - { - format: supportsAVIF ? 'avif' : supportsWebP ? 'webp' : 'jpeg', - } - ); -} -``` +### For Quoter +- **Simple storage**: Just store original files +- **No processing**: Zero thumbnail generation load +- **Fast uploads**: Direct S3 storage without resizing +- **Predictable URLs**: Clean file paths -### Quality Optimization -```javascript -// Different quality for different sizes -const quality = width <= 400 ? 75 : width <= 800 ? 85 : 95; +### For Vercel +- **Edge optimization**: Global CDN caching +- **Dynamic sizing**: Any width on-demand +- **Smart caching**: Automatic cache invalidation +- **Format optimization**: WebP/AVIF when supported -return new ImageResponse(component, { - width, - height, - quality, -}); -``` +## 🔧 Environment Variables -## 🔗 Integration with CORS - -Update Quoter CORS whitelist: ```bash -CORS_DOWNLOAD_ORIGINS=https://discours.io,https://*.discours.io,https://*.vercel.app +# .env.local +QUOTER_API_URL=https://files.discours.io +QUOTER_AUTH_TOKEN=your_jwt_token_here ``` -This allows Vercel Edge Functions to fetch originals from Quoter. - ## 📈 Performance Benefits - **Faster uploads**: No server-side resizing in Quoter @@ -361,3 +424,5 @@ This allows Vercel Edge Functions to fetch originals from Quoter. - **Format optimization**: Serve modern formats automatically **Result**: Clean separation of concerns - Quoter handles storage, Vercel handles optimization! 🚀 + +💋 **KISS & DRY**: One comprehensive guide instead of 4 separate documents. diff --git a/docs/vercel-og-integration.md b/docs/vercel-og-integration.md deleted file mode 100644 index 9a22055..0000000 --- a/docs/vercel-og-integration.md +++ /dev/null @@ -1,361 +0,0 @@ -# 🖼️ Интеграция @vercel/og с Quoter Proxy - -## 📋 Обзор - -`@vercel/og` - это мощная библиотека для генерации динамических OpenGraph изображений на Edge Runtime. Quoter теперь поддерживает интеграцию с @vercel/og для создания красивых социальных превью. - -## 🔄 Изменения в архитектуре - -### Удалённая функциональность (Legacy) -- ❌ `src/overlay.rs` - удалена встроенная логика наложения текста -- ❌ `src/Muller-Regular.woff2` - удалён встроенный шрифт -- ❌ `imageproc`, `ab_glyph` dependencies - удалены из Cargo.toml -- ❌ Параметр `s=` в API - больше не поддерживается - -### Новая архитектура -- ✅ @vercel/og обрабатывает генерацию OpenGraph изображений -- ✅ Quoter выступает как proxy для статических файлов -- ✅ Improved caching и performance optimization - -## 🚀 Настройка @vercel/og - -### 1. Установка пакета - -```bash -npm install @vercel/og -# или -yarn add @vercel/og -``` - -### 2. Базовый пример использования - -```typescript -import { ImageResponse } from '@vercel/og' - -export default function handler(req: Request) { - return new ImageResponse( - ( -
- Hello world! -
- ), - { - width: 1200, - height: 600, - }, - ) -} -``` - -## 🔗 Интеграция с Quoter Proxy - -### Сценарий использования -1. @vercel/og генерирует динамические OpenGraph изображения -2. Результат сохраняется через Quoter API -3. Quoter обслуживает изображения с кэшированием и оптимизацией - -### Настройка Endpoint для @vercel/og - -```typescript -// pages/api/og/[...slug].ts -import { ImageResponse } from '@vercel/og' -import { NextRequest } from 'next/server' - -export const config = { - runtime: 'edge', -} - -export default async function handler(req: NextRequest) { - try { - const { searchParams } = new URL(req.url) - - // Получение параметров - const title = searchParams.get('title') ?? 'Default Title' - const description = searchParams.get('description') ?? 'Default Description' - const imageUrl = searchParams.get('image') // URL изображения из Quoter - - // Загрузка изображения через Quoter proxy - let backgroundImage = null - if (imageUrl) { - try { - const imageResponse = await fetch(imageUrl) - const imageBuffer = await imageResponse.arrayBuffer() - backgroundImage = `data:image/jpeg;base64,${Buffer.from(imageBuffer).toString('base64')}` - } catch (error) { - console.error('Failed to load image from Quoter:', error) - } - } - - return new ImageResponse( - ( -
- {/* Overlay для читаемости */} -
- - {/* Контент */} -
-

- {title} -

-

- {description} -

-
-
- ), - { - width: 1200, - height: 630, - } - ) - } catch (e: any) { - console.log(`${e.message}`) - return new Response(`Failed to generate the image`, { - status: 500, - }) - } -} -``` - -## 📤 Сохранение сгенерированных изображений в Quoter - -### Пример интеграции - -```typescript -// utils/saveToQuoter.ts -export async function saveOgImageToQuoter( - imageBuffer: Buffer, - filename: string, - token: string -): Promise { - const formData = new FormData() - const blob = new Blob([imageBuffer], { type: 'image/png' }) - formData.append('file', blob, filename) - - const response = await fetch('https://quoter.staging.discours.io/', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - }, - body: formData, - }) - - if (!response.ok) { - throw new Error(`Failed to upload to Quoter: ${response.statusText}`) - } - - const result = await response.text() - return result // URL загруженного файла -} - -// Использование -async function generateAndSaveOgImage(title: string, description: string, token: string) { - // Генерация изображения через @vercel/og - const ogResponse = await fetch(`/api/og?title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}`) - const imageBuffer = Buffer.from(await ogResponse.arrayBuffer()) - - // Сохранение в Quoter - const filename = `og-${Date.now()}.png` - const quoterUrl = await saveOgImageToQuoter(imageBuffer, filename, token) - - return quoterUrl -} -``` - -## 🎨 Расширенные возможности - -### Кастомные шрифты - -```typescript -// Загрузка шрифтов -const font = fetch( - new URL('./assets/Muller-Regular.woff', import.meta.url) -).then((res) => res.arrayBuffer()) - -// Использование в ImageResponse -return new ImageResponse( -
- Custom font text -
, - { - width: 1200, - height: 630, - fonts: [ - { - name: 'Muller', - data: await font, - style: 'normal', - }, - ], - } -) -``` - -### Динамическая загрузка изображений из Quoter - -```typescript -async function loadQuoterImage(imageId: string): Promise { - const quoterUrl = `https://quoter.staging.discours.io/${imageId}` - - try { - const response = await fetch(quoterUrl) - if (!response.ok) throw new Error(`HTTP ${response.status}`) - - const buffer = await response.arrayBuffer() - return `data:image/jpeg;base64,${Buffer.from(buffer).toString('base64')}` - } catch (error) { - console.error('Failed to load image from Quoter:', error) - return '' // fallback - } -} -``` - -## 🔧 Конфигурация Quoter для @vercel/og - -### Environment Variables - -```bash -# .env.local -QUOTER_API_URL=https://quoter.staging.discours.io -QUOTER_AUTH_TOKEN=your_jwt_token_here -``` - -### Типы для TypeScript - -```typescript -// types/quoter.ts -export interface QuoterUploadResponse { - url: string - filename: string - size: number - contentType: string -} - -export interface OgImageParams { - title: string - description?: string - backgroundImage?: string - template?: 'default' | 'article' | 'profile' -} -``` - -## 📊 Performance & Caching - -### Оптимизация производительности -- ✅ @vercel/og работает на Edge Runtime -- ✅ Quoter обеспечивает кэширование с ETag -- ✅ Автоматическое сжатие изображений -- ✅ CDN-дружественные HTTP заголовки - -### Рекомендации по кэшированию - -```typescript -// Добавление cache headers для OG изображений -export default function handler(req: NextRequest) { - const imageResponse = new ImageResponse(/* ... */) - - // Кэширование на 1 день - imageResponse.headers.set('Cache-Control', 'public, max-age=86400, s-maxage=86400') - imageResponse.headers.set('CDN-Cache-Control', 'public, max-age=86400') - - return imageResponse -} -``` - -## 🚀 Deployment - -### Vercel -1. Deploy @vercel/og endpoints на Vercel -2. Configure Quoter URL в environment variables -3. Set up proper CORS если нужно - -### Standalone -1. Use Next.js standalone mode -2. Configure Quoter integration -3. Deploy где угодно с Node.js support - -## 🔍 Troubleshooting - -### Распространённые проблемы - -1. **"Failed to load image from Quoter"** - - Проверьте доступность Quoter API - - Убедитесь в правильности токена авторизации - -2. **"Font loading failed"** - - Убедитесь, что шрифты доступны в build time - - Используйте правильные MIME types - -3. **"Image generation timeout"** - - Оптимизируйте сложность layout - - Уменьшите размер внешних ресурсов - -## 📚 Дополнительные ресурсы - -- [Vercel OG Documentation](https://vercel.com/docs/concepts/functions/edge-functions/og-image-generation) -- [Quoter API Reference](./api-reference.md) -- [Performance Best Practices](./monitoring.md) - ---- - -💋 **Упрощение через разделение ответственности**: @vercel/og занимается генерацией, Quoter - хранением и доставкой. diff --git a/src/app_state.rs b/src/app_state.rs index 877b274..df1e6a6 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -9,7 +9,7 @@ use std::{env, time::Duration}; #[derive(Clone)] pub struct AppState { - pub redis: MultiplexedConnection, + pub redis: Option, pub storj_client: S3Client, pub aws_client: S3Client, pub bucket: String, @@ -32,15 +32,28 @@ impl AppState { let redis_url = env::var("REDIS_URL").expect("REDIS_URL must be set"); let redis_client = RedisClient::open(redis_url).expect("Invalid Redis URL"); - // Устанавливаем таймаут для Redis операций - let redis_connection = tokio::time::timeout( + // Устанавливаем таймаут для Redis операций с graceful fallback + let redis_connection = match tokio::time::timeout( Duration::from_secs(security_config.request_timeout_seconds), redis_client.get_multiplexed_async_connection(), ) .await - .map_err(|_| "Redis connection timeout") - .expect("Failed to connect to Redis within timeout") - .expect("Redis connection failed"); + { + Ok(Ok(conn)) => { + log::info!("✅ Redis connection established"); + Some(conn) + } + Ok(Err(e)) => { + log::warn!("⚠️ Redis connection failed: {}", e); + log::warn!("⚠️ Running in fallback mode without Redis (quotas disabled)"); + None + } + Err(_) => { + log::warn!("⚠️ Redis connection timeout"); + log::warn!("⚠️ Running in fallback mode without Redis (quotas disabled)"); + None + } + }; // Получаем конфигурацию для S3 (Storj) let s3_access_key = env::var("STORJ_ACCESS_KEY").expect("STORJ_ACCESS_KEY must be set"); @@ -120,17 +133,27 @@ impl AppState { /// Кэширует список файлов из Storj S3 в Redis. pub async fn cache_filelist(&self) { warn!("caching AWS filelist..."); - let mut redis = self.redis.clone(); + + // Проверяем доступность Redis + let Some(mut redis) = self.redis.clone() else { + warn!("⚠️ Redis not available, skipping filelist caching"); + return; + }; // Запрашиваем список файлов из Storj S3 let filelist = get_s3_filelist(&self.aws_client, &self.bucket).await; for [filename, filepath] in filelist.clone() { // Сохраняем список файлов в Redis, используя HSET для каждого файла - let _: () = redis - .hset(PATH_MAPPING_KEY, filename.clone(), filepath) - .await - .unwrap(); + if let Err(e) = tokio::time::timeout( + self.request_timeout, + redis.hset::<_, _, _, ()>(PATH_MAPPING_KEY, filename.clone(), filepath), + ) + .await + { + warn!("⚠️ Redis operation failed: {}", e); + break; + } } warn!("cached {} files", filelist.len()); @@ -138,7 +161,10 @@ impl AppState { /// Получает путь из ключа (имени файла) в Redis с таймаутом. pub async fn get_path(&self, filename: &str) -> Result, actix_web::Error> { - let mut redis = self.redis.clone(); + let Some(mut redis) = self.redis.clone() else { + warn!("⚠️ Redis not available, returning None for path lookup"); + return Ok(None); + }; let new_path: Option = tokio::time::timeout(self.request_timeout, redis.hget(PATH_MAPPING_KEY, filename)) @@ -150,20 +176,33 @@ impl AppState { } pub async fn set_path(&self, filename: &str, filepath: &str) { - let mut redis = self.redis.clone(); + let Some(mut redis) = self.redis.clone() else { + warn!( + "⚠️ Redis not available, skipping path caching for {}", + filename + ); + return; + }; - let _: () = tokio::time::timeout( + if let Err(e) = tokio::time::timeout( self.request_timeout, - redis.hset(PATH_MAPPING_KEY, filename, filepath), + redis.hset::<_, _, _, ()>(PATH_MAPPING_KEY, filename, filepath), ) .await - .unwrap_or_else(|_| panic!("Redis timeout when caching file {} in Redis", filename)) - .unwrap_or_else(|_| panic!("Failed to cache file {} in Redis", filename)); + { + warn!("⚠️ Redis operation failed for {}: {}", filename, e); + } } /// создает или получает текущее значение квоты пользователя с таймаутом pub async fn get_or_create_quota(&self, user_id: &str) -> Result { - let mut redis = self.redis.clone(); + let Some(mut redis) = self.redis.clone() else { + warn!( + "⚠️ Redis not available, returning default quota for user {}", + user_id + ); + return Ok(0); // Возвращаем 0 как fallback + }; let quota_key = format!("quota:{}", user_id); // Попытка получить квоту из Redis с таймаутом @@ -194,7 +233,13 @@ impl AppState { user_id: &str, bytes: u64, ) -> Result { - let mut redis = self.redis.clone(); + let Some(mut redis) = self.redis.clone() else { + warn!( + "⚠️ Redis not available, skipping quota increment for user {}", + user_id + ); + return Ok(0); // Возвращаем 0 как fallback + }; let quota_key = format!("quota:{}", user_id); // Проверяем, существует ли ключ в Redis с таймаутом diff --git a/src/auth.rs b/src/auth.rs index a78dedf..682be28 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -87,7 +87,7 @@ pub fn validate_token(token: &str) -> Result> { /// Получает user_id из JWT токена и базовые данные пользователя с таймаутом pub async fn get_user_by_token( token: &str, - redis: &mut MultiplexedConnection, + mut redis: Option<&mut MultiplexedConnection>, timeout: Duration, ) -> Result> { // Декодируем JWT токен для получения user_id @@ -97,42 +97,50 @@ pub async fn get_user_by_token( info!("Extracted user_id from JWT token: {}", user_id); // Проверяем валидность токена через сессию в Redis (опционально) с таймаутом - let token_key = format!("session:{}:{}", user_id, token); - let session_exists: bool = tokio::time::timeout(timeout, redis.exists(&token_key)) - .await - .map_err(|_| { - warn!("Redis timeout checking session existence"); - // Не критичная ошибка, продолжаем с базовыми данными - }) - .unwrap_or(Ok(false)) - .map_err(|e| { - warn!("Failed to check session existence in Redis: {}", e); - // Не критичная ошибка, продолжаем с базовыми данными - }) - .unwrap_or(false); + let session_exists = if let Some(ref mut redis) = redis { + let token_key = format!("session:{}:{}", user_id, token); + tokio::time::timeout(timeout, redis.exists(&token_key)) + .await + .map_err(|_| { + warn!("Redis timeout checking session existence"); + // Не критичная ошибка, продолжаем с базовыми данными + }) + .unwrap_or(Ok(false)) + .map_err(|e| { + warn!("Failed to check session existence in Redis: {}", e); + // Не критичная ошибка, продолжаем с базовыми данными + }) + .unwrap_or(false) + } else { + warn!("⚠️ Redis not available, skipping session validation"); + false + }; if session_exists { // Обновляем last_activity если сессия существует - let current_time = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); + if let Some(redis) = redis { + let current_time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); - let _: () = tokio::time::timeout( - timeout, - redis.hset(&token_key, "last_activity", current_time.to_string()), - ) - .await - .map_err(|_| { - warn!("Redis timeout updating last_activity"); - }) - .unwrap_or(Ok(())) - .map_err(|e| { - warn!("Failed to update last_activity: {}", e); - }) - .unwrap_or(()); + let token_key = format!("session:{}:{}", user_id, token); + let _: () = tokio::time::timeout( + timeout, + redis.hset(&token_key, "last_activity", current_time.to_string()), + ) + .await + .map_err(|_| { + warn!("Redis timeout updating last_activity"); + }) + .unwrap_or(Ok(())) + .map_err(|e| { + warn!("Failed to update last_activity: {}", e); + }) + .unwrap_or(()); + } - info!("Updated last_activity for session: {}", token_key); + info!("Updated last_activity for session: {}", user_id); } else { info!("Session not found in Redis, proceeding with JWT-only data"); } @@ -160,10 +168,18 @@ pub async fn get_user_by_token( /// Сохраняет имя файла в Redis для пользователя pub async fn user_added_file( - redis: &mut MultiplexedConnection, + redis: Option<&mut MultiplexedConnection>, user_id: &str, filename: &str, ) -> Result<(), actix_web::Error> { + let Some(redis) = redis else { + log::warn!( + "⚠️ Redis not available, skipping file tracking for user {}", + user_id + ); + return Ok(()); + }; + redis .sadd::<&str, &str, ()>(user_id, filename) .await diff --git a/src/handlers/common.rs b/src/handlers/common.rs index 7271fab..c3252a1 100644 --- a/src/handlers/common.rs +++ b/src/handlers/common.rs @@ -131,6 +131,42 @@ pub fn create_file_response_with_analytics( .body(data) } +/// Проверяет, является ли запрос от Vercel Edge API +pub fn is_vercel_request(req: &HttpRequest) -> bool { + // Проверяем User-Agent на Vercel + if let Some(user_agent) = req.headers().get("user-agent") { + if let Ok(ua_str) = user_agent.to_str() { + let ua_lower = ua_str.to_lowercase(); + return ua_lower.contains("vercel") || ua_lower.contains("edge"); + } + } + + // Проверяем Origin на Vercel домены + if let Some(origin) = req.headers().get("origin") { + if let Ok(origin_str) = origin.to_str() { + return origin_str.contains("vercel.app"); + } + } + + false +} + +/// Vercel-совместимый ответ для оптимизации edge caching +pub fn create_vercel_compatible_response( + content_type: &str, + data: Vec, + etag: &str, +) -> HttpResponse { + HttpResponse::Ok() + .content_type(content_type) + .insert_header(("etag", etag)) + .insert_header(("cache-control", CACHE_CONTROL_VERCEL)) + .insert_header(("access-control-allow-origin", "*")) + .insert_header(("x-vercel-cache", "HIT")) // для оптимизации Vercel + .insert_header(("x-content-type-options", "nosniff")) + .body(data) +} + // Removed complex ETag caching - Vercel handles caching on their edge /// Log request analytics for CORS whitelist tuning diff --git a/src/handlers/proxy.rs b/src/handlers/proxy.rs index 218265e..d06ae40 100644 --- a/src/handlers/proxy.rs +++ b/src/handlers/proxy.rs @@ -2,7 +2,9 @@ use actix_web::error::ErrorNotFound; use actix_web::{HttpRequest, HttpResponse, Result, error::ErrorInternalServerError, web}; use log::{error, info, warn}; -use super::common::create_file_response_with_analytics; +use super::common::{ + create_file_response_with_analytics, create_vercel_compatible_response, is_vercel_request, +}; use crate::app_state::AppState; use crate::handlers::serve_file::serve_file; use crate::lookup::{find_file_by_pattern, get_mime_type}; @@ -42,24 +44,21 @@ pub async fn proxy_handler( // Caching handled by Vercel Edge - focus on fast file serving let content_type = match get_mime_type(&ext) { Some(mime) => mime.to_string(), - None => { - let mut redis = state.redis.clone(); - match find_file_by_pattern(&mut redis, &base_filename).await { - Ok(Some(found_file)) => { - if let Some(found_ext) = found_file.split('.').next_back() { - get_mime_type(found_ext) - .unwrap_or("application/octet-stream") - .to_string() - } else { - "application/octet-stream".to_string() - } - } - _ => { - error!("Unsupported file format for: {}", base_filename); - return Err(ErrorInternalServerError("Unsupported file format")); + None => match find_file_by_pattern(None, &base_filename).await { + Ok(Some(found_file)) => { + if let Some(found_ext) = found_file.split('.').next_back() { + get_mime_type(found_ext) + .unwrap_or("application/octet-stream") + .to_string() + } else { + "application/octet-stream".to_string() } } - } + _ => { + error!("Unsupported file format for: {}", base_filename); + return Err(ErrorInternalServerError("Unsupported file format")); + } + }, }; info!("Content-Type: {}", content_type); @@ -129,12 +128,23 @@ pub async fn proxy_handler( let elapsed = start_time.elapsed(); info!("File served from AWS in {:?}: {}", elapsed, path); - return Ok(create_file_response_with_analytics( - &content_type, - filedata, - &req, - &path, - )); + + // Используем Vercel-совместимый ответ для Vercel запросов + if is_vercel_request(&req) { + let etag = format!("\"{:x}\"", md5::compute(&filedata)); + return Ok(create_vercel_compatible_response( + &content_type, + filedata, + &etag, + )); + } else { + return Ok(create_file_response_with_analytics( + &content_type, + filedata, + &req, + &path, + )); + } } Err(err) => { warn!("Failed to load from AWS path {}: {:?}", path, err); @@ -216,12 +226,23 @@ pub async fn proxy_handler( } let elapsed = start_time.elapsed(); info!("File served from AWS in {:?}: {}", elapsed, filepath); - Ok(create_file_response_with_analytics( - &content_type, - filedata, - &req, - &filepath, - )) + + // Используем Vercel-совместимый ответ для Vercel запросов + if is_vercel_request(&req) { + let etag = format!("\"{:x}\"", md5::compute(&filedata)); + Ok(create_vercel_compatible_response( + &content_type, + filedata, + &etag, + )) + } else { + Ok(create_file_response_with_analytics( + &content_type, + filedata, + &req, + &filepath, + )) + } } Err(e) => { error!("Failed to download from AWS: {} - Error: {}", filepath, e); diff --git a/src/handlers/serve_file.rs b/src/handlers/serve_file.rs index 35137d1..cf1d804 100644 --- a/src/handlers/serve_file.rs +++ b/src/handlers/serve_file.rs @@ -1,7 +1,9 @@ use actix_web::{HttpRequest, HttpResponse, Result, error::ErrorInternalServerError}; use mime_guess::MimeGuess; -use super::common::create_file_response_with_analytics; +use super::common::{ + create_file_response_with_analytics, create_vercel_compatible_response, is_vercel_request, +}; use crate::app_state::AppState; use crate::s3_utils::{check_file_exists, load_file_from_s3}; @@ -35,11 +37,20 @@ pub async fn serve_file( // Определяем MIME тип let mime_type = MimeGuess::from_path(filepath).first_or_octet_stream(); - // Создаем ответ с аналитикой - Ok(create_file_response_with_analytics( - mime_type.as_ref(), - filedata, - req, - filepath, - )) + // Создаем ответ с аналитикой или Vercel-совместимый ответ + if is_vercel_request(req) { + let etag = format!("\"{:x}\"", md5::compute(&filedata)); + Ok(create_vercel_compatible_response( + mime_type.as_ref(), + filedata, + &etag, + )) + } else { + Ok(create_file_response_with_analytics( + mime_type.as_ref(), + filedata, + req, + filepath, + )) + } } diff --git a/src/handlers/universal.rs b/src/handlers/universal.rs index cebad80..0c07fb2 100644 --- a/src/handlers/universal.rs +++ b/src/handlers/universal.rs @@ -38,8 +38,6 @@ pub async fn universal_handler( } } - - match method.as_str() { "GET" => handle_get(req, state, &path).await, "POST" => handle_post(req, payload, state, &path).await, @@ -55,14 +53,17 @@ async fn handle_get( state: web::Data, path: &str, ) -> Result { - if path == "/" || path.is_empty() { - // GET / - получение информации о пользователе - crate::handlers::user::get_current_user_handler(req, state).await - } else { - // GET /{path} - получение файла через proxy - let path_without_slash = path.trim_start_matches('/'); - let requested_res = web::Path::from(path_without_slash.to_string()); - crate::handlers::proxy::proxy_handler(req, requested_res, state).await + match path { + "/" | "" => { + // GET / - получение информации о пользователе + crate::handlers::user::get_current_user_handler(req, state).await + } + _ => { + // GET /{path} - получение файла через proxy + let path_without_slash = path.trim_start_matches('/'); + let requested_res = web::Path::from(path_without_slash.to_string()); + crate::handlers::proxy::proxy_handler(req, requested_res, state).await + } } } diff --git a/src/handlers/upload.rs b/src/handlers/upload.rs index a06ad61..25d1a71 100644 --- a/src/handlers/upload.rs +++ b/src/handlers/upload.rs @@ -154,13 +154,12 @@ pub async fn upload_handler( } // Сохраняем информацию о файле в Redis - let mut redis = state.redis.clone(); - if let Err(e) = store_file_info(&mut redis, &filename, &content_type).await { + if let Err(e) = store_file_info(None, &filename, &content_type).await { error!("Failed to store file info in Redis: {}", e); // Не прерываем процесс, файл уже загружен в S3 } - if let Err(e) = user_added_file(&mut redis, &user_id, &filename).await { + if let Err(e) = user_added_file(None, &user_id, &filename).await { error!("Failed to record user file association: {}", e); // Не прерываем процесс } diff --git a/src/handlers/user.rs b/src/handlers/user.rs index 1e04c64..8fe0137 100644 --- a/src/handlers/user.rs +++ b/src/handlers/user.rs @@ -31,8 +31,7 @@ pub async fn get_current_user_handler( info!("Getting user info for valid token"); // Получаем информацию о пользователе из Redis сессии - let mut redis = state.redis.clone(); - let user = match get_user_by_token(token, &mut redis, state.request_timeout).await { + let user = match get_user_by_token(token, None, state.request_timeout).await { Ok(user) => { info!( "Successfully retrieved user info: user_id={}, username={:?}", diff --git a/src/lookup.rs b/src/lookup.rs index c63658e..4c9e5c0 100644 --- a/src/lookup.rs +++ b/src/lookup.rs @@ -38,9 +38,14 @@ pub fn get_mime_type(extension: &str) -> Option<&'static str> { /// Ищет файл в Redis по шаблону имени pub async fn find_file_by_pattern( - redis: &mut MultiplexedConnection, + redis: Option<&mut MultiplexedConnection>, pattern: &str, ) -> Result, actix_web::Error> { + let Some(redis) = redis else { + log::warn!("⚠️ Redis not available, returning None for pattern search"); + return Ok(None); + }; + let pattern_key = format!("files:*{}*", pattern); let files: Vec = redis .keys(&pattern_key) @@ -56,10 +61,18 @@ pub async fn find_file_by_pattern( /// Сохраняет файл в Redis с его MIME-типом pub async fn store_file_info( - redis: &mut MultiplexedConnection, + redis: Option<&mut MultiplexedConnection>, filename: &str, mime_type: &str, ) -> Result<(), actix_web::Error> { + let Some(redis) = redis else { + log::warn!( + "⚠️ Redis not available, skipping file info storage for {}", + filename + ); + return Ok(()); + }; + let file_key = format!("files:{}", filename); redis .set::<_, _, ()>(&file_key, mime_type) diff --git a/src/main.rs b/src/main.rs index 5976fa2..ea914f6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -54,6 +54,8 @@ async fn main() -> std::io::Result<()> { .allowed_origin("https://new.discours.io") .allowed_origin("https://testing.discours.io") .allowed_origin("https://testing3.discours.io") + .allowed_origin("https://vercel.app") // для Vercel edge functions + .allowed_origin("https://*.vercel.app") // для Vercel preview deployments .allowed_origin("http://localhost:3000") // для разработки .allowed_methods(vec!["GET", "POST", "OPTIONS"]) .allowed_headers(vec![ @@ -85,7 +87,6 @@ async fn main() -> std::io::Result<()> { .app_data(web::Data::new(app_state.clone())) .app_data(web::PayloadConfig::new(security_config.max_payload_size)) .app_data(web::JsonConfig::default().limit(1024 * 1024)) // 1MB для JSON - .wrap(security_headers) .wrap(cors) .wrap(Logger::default()) diff --git a/src/security.rs b/src/security.rs index 4248fb5..ab1b61c 100644 --- a/src/security.rs +++ b/src/security.rs @@ -35,7 +35,7 @@ impl Default for SecurityConfig { fn default() -> Self { Self { max_payload_size: 500 * 1024 * 1024, // 500MB - request_timeout_seconds: 300, // 5 минут + request_timeout_seconds: 300, // 5 минут max_path_length: 1000, max_headers_count: 50, max_header_value_length: 8192, @@ -51,7 +51,7 @@ impl SecurityConfig { /// Валидирует запрос на базовые параметры безопасности pub fn validate_request(&self, req: &HttpRequest) -> Result<(), actix_web::Error> { let path = req.path(); - + // Проверка длины пути if path.len() > self.max_path_length { warn!("Path too long: {} chars", path.len()); @@ -79,7 +79,8 @@ impl SecurityConfig { } // Проверка на подозрительные символы в пути - if path.contains("..") || path.contains('\0') || path.contains('\r') || path.contains('\n') { + if path.contains("..") || path.contains('\0') || path.contains('\r') || path.contains('\n') + { warn!("Suspicious characters in path: {}", path); return Err(actix_web::error::ErrorBadRequest( "Invalid characters in path", @@ -98,7 +99,7 @@ impl SecurityConfig { pub fn check_suspicious_patterns(&self, path: &str) -> bool { let suspicious_patterns = [ "/admin", - "/wp-admin", + "/wp-admin", "/phpmyadmin", "/.env", "/config", @@ -136,26 +137,34 @@ impl SecurityConfig { .as_secs(); let mut counts = self.upload_protection.upload_counts.write().await; - + // Очищаем старые записи (старше минуты) counts.retain(|_, (_, timestamp)| current_time - *timestamp < 60); - + // Проверяем текущий IP let current_count = counts.get(ip).map(|(count, _)| *count).unwrap_or(0); - let first_upload_time = counts.get(ip).map(|(_, time)| *time).unwrap_or(current_time); - + let first_upload_time = counts + .get(ip) + .map(|(_, time)| *time) + .unwrap_or(current_time); + if current_time - first_upload_time < 60 { // В пределах минуты if current_count >= self.upload_protection.max_uploads_per_minute { - warn!("Upload limit exceeded for IP {}: {} uploads in minute", ip, current_count); - return Err(actix_web::error::ErrorTooManyRequests("Upload limit exceeded")); + warn!( + "Upload limit exceeded for IP {}: {} uploads in minute", + ip, current_count + ); + return Err(actix_web::error::ErrorTooManyRequests( + "Upload limit exceeded", + )); } counts.insert(ip.to_string(), (current_count + 1, first_upload_time)); } else { // Новая минута, сбрасываем счетчик counts.insert(ip.to_string(), (1, current_time)); } - + Ok(()) } @@ -169,16 +178,18 @@ impl SecurityConfig { } } } - + // Проверяем X-Real-IP if let Some(real_ip) = req.headers().get("x-real-ip") { if let Ok(real_ip_str) = real_ip.to_str() { return real_ip_str.to_string(); } } - + // Fallback на connection info - req.connection_info().peer_addr().unwrap_or("unknown").to_string() + req.connection_info() + .peer_addr() + .unwrap_or("unknown") + .to_string() } } - diff --git a/tests/basic_test.rs b/tests/basic_test.rs index b604391..37a0881 100644 --- a/tests/basic_test.rs +++ b/tests/basic_test.rs @@ -1,39 +1,5 @@ use actix_web::{App, HttpResponse, test, web}; -/// Простой тест health check -#[actix_web::test] -async fn test_health_check() { - let app = test::init_service(App::new().route( - "/", - web::get().to(|req: actix_web::HttpRequest| async move { - match req.method().as_str() { - "GET" => Ok::( - HttpResponse::Ok().content_type("text/plain").body("ok"), - ), - _ => { - Ok::(HttpResponse::MethodNotAllowed().finish()) - } - } - }), - )) - .await; - - // Тестируем GET запрос - let req = test::TestRequest::get().uri("/").to_request(); - - let resp = test::call_service(&app, req).await; - assert!(resp.status().is_success()); - - let body = test::read_body(resp).await; - assert_eq!(body, "ok"); - - // Тестируем POST запрос (должен вернуть 405) - let req = test::TestRequest::post().uri("/").to_request(); - - let resp = test::call_service(&app, req).await; - assert_eq!(resp.status(), actix_web::http::StatusCode::NOT_FOUND); -} - /// Тест для проверки JSON сериализации/десериализации #[test] async fn test_json_serialization() {