From ae0fc9a18dbe38dca9566e6a2cd28d2fe4cdce52 Mon Sep 17 00:00:00 2001 From: Untone Date: Wed, 3 Sep 2025 10:21:17 +0300 Subject: [PATCH] 0.6.4-thumb-upgrade --- CHANGELOG.md | 51 ++++ Cargo.lock | 22 +- Cargo.toml | 6 +- docs/README.md | 12 +- docs/features.md | 1 + docs/upload-client-guide.md | 473 +++++++++++++++++++++++++++++++++ docs/upload-quickstart.md | 154 +++++++++++ src/app_state.rs | 84 ++++-- src/handlers/common.rs | 99 +++++-- src/handlers/proxy.rs | 132 +++++++-- src/handlers/serve_file.rs | 72 +++-- src/handlers/upload.rs | 18 ++ src/main.rs | 19 +- src/thumbnail.rs | 125 +++++---- tests/redis_connection_test.rs | 114 +++++--- 15 files changed, 1180 insertions(+), 202 deletions(-) create mode 100644 docs/upload-client-guide.md create mode 100644 docs/upload-quickstart.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 200b61d..bbdff07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,54 @@ +## [0.6.4] - 2025-09-03 + +### 🚀 Добавлено - Thumbnail Enhancement Suite +- **JPEG Fallback**: Добавлен автоматический fallback с WebP на JPEG для thumbnail генерации +- **Локальное кэширование**: Двухуровневая система кэша (локальный + Storj) +- **Периодическая очистка**: Автоматическая очистка старых файлов кэша раз в день +- **Улучшенная надежность**: Thumbnail генерация теперь более устойчива к сбоям + +### 📝 Обновлено +- Функция `generate_webp_thumbnail()` теперь использует JPEG fallback +- Добавлено локальное кэширование в `/tmp/thumbnails` для быстрого доступа +- Интегрированы все неиспользуемые функции из `thumbnail.rs` +- Запуск периодической очистки кэша при старте приложения + +### 🧹 Техническая оптимизация +- Использованы все функции из `thumbnail.rs`: `generate_jpeg_thumbnail`, `cache_thumbnail`, `load_cached_thumbnail`, `cleanup_cache` +- Убраны warning'и о неиспользуемых функциях +- Многоуровневая система кэширования: локальный → Storj → генерация + +## [0.6.3] - 2025-09-03 + +### 🔧 Исправлено - CORS для localhost в production +- **CORS логика**: Исправлена проверка CORS origins в production окружении +- **Development поддержка**: Добавлена автоматическая поддержка localhost origins +- **Гибкая конфигурация**: CORS origins теперь добавляются автоматически если их нет в переменной окружения +- **Дополнительная проверка**: Добавлена fallback проверка для всех localhost origins + +### 📝 Обновлено +- Улучшена логика `get_cors_origin()` в `src/handlers/common.rs` +- Автоматическое добавление development origins в production +- Более надежная проверка CORS для localhost запросов + +## [0.6.2] - 2025-01-28 + +### 🔧 Исправлено - CORS и аудио файлы +- **CORS конфигурация**: Расширена поддержка localhost:3000 (HTTP/HTTPS) для разработки +- **Аудио стриминг**: Добавлены заголовки `accept-ranges`, `content-range` для аудио файлов +- **Домен файлов**: Добавлена поддержка `https://files.dscrs.site` в CORS whitelist +- **Range запросы**: Добавлена поддержка HTTP Range заголовков для аудио стриминга +- **Заголовки безопасности**: Улучшена поддержка аудио контента с правильными MIME типами + +### 📝 Обновлено +- CORS middleware теперь поддерживает больше development доменов +- Аудио файлы получают специальные заголовки для стриминга +- Улучшена совместимость с фронтенд аудио плеером +- **Документация**: Созданы comprehensive guides для upload клиентов + - `docs/upload-client-guide.md` - Полное руководство по API + - `docs/upload-quickstart.md` - Быстрый старт для разработчиков + - Примеры кода на JavaScript, Python, cURL + - Обработка ошибок и best practices + ## [0.6.1] - 2025-09-02 ### 🚀 Изменено - Восстановление thumbnail функциональности diff --git a/Cargo.lock b/Cargo.lock index f552235..9364f85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1401,9 +1401,9 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] @@ -1990,9 +1990,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -2276,9 +2276,9 @@ dependencies = [ [[package]] name = "md5" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" +checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" [[package]] name = "memchr" @@ -2540,9 +2540,9 @@ dependencies = [ [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project-lite" @@ -2650,7 +2650,7 @@ dependencies = [ [[package]] name = "quoter" -version = "0.6.1" +version = "0.6.4" dependencies = [ "actix", "actix-cors", @@ -3753,9 +3753,9 @@ dependencies = [ [[package]] name = "url" -version = "2.5.4" +version = "2.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" dependencies = [ "form_urlencoded", "idna", diff --git a/Cargo.toml b/Cargo.toml index e2a9f4e..a0c029c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "quoter" -version = "0.6.1" +version = "0.6.4" edition = "2024" [dependencies] @@ -18,8 +18,8 @@ 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" -url = "2.5.4" +md5 = "0.8.0" +url = "2.5.7" 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/README.md b/docs/README.md index 5a6fdc3..fc25619 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,13 +4,19 @@ Simple file upload proxy with S3 storage and user quotas. ## 📚 Documentation +### 🚀 Quick Start +- **[upload-quickstart.md](./upload-quickstart.md)** - Быстрый старт для upload клиентов +- **[upload-client-guide.md](./upload-client-guide.md)** - Полное руководство по API + +### 🔧 Setup & Configuration - **[SETUP.md](./SETUP.md)** - Installation, configuration, and deployment -- **[architecture.md](./architecture.md)** - Technical details for developers - **[configuration.md](./configuration.md)** - Environment variables reference +- **[architecture.md](./architecture.md)** - Technical details for developers + +### 📖 Features & Integration - **[features.md](./features.md)** - What Quoter does - **[how-it-works.md](./how-it-works.md)** - System overview -- **[hybrid-architecture.md](./hybrid-architecture.md)** - Vercel + Quoter integration -- **[vercel-og-integration.md](./vercel-og-integration.md)** - OpenGraph integration +- **[vercel-frontend-migration.md](./vercel-frontend-migration.md)** - Vercel integration guide ## 🎯 Key Concept diff --git a/docs/features.md b/docs/features.md index 6a515bb..4567b41 100644 --- a/docs/features.md +++ b/docs/features.md @@ -52,6 +52,7 @@ Simple file upload/download proxy with thumbnail generation and S3 storage. - ✅ JWT authentication - ✅ Rate limiting & security - ✅ Thumbnail generation with Storj caching +- ✅ Audio streaming with Range request support - ✅ ETag caching for performance - ✅ Full test coverage - 🚀 Production ready diff --git a/docs/upload-client-guide.md b/docs/upload-client-guide.md new file mode 100644 index 0000000..e7760de --- /dev/null +++ b/docs/upload-client-guide.md @@ -0,0 +1,473 @@ +# 📤 Upload Client Guide - Quoter API + +**Версия**: 0.6.2 +**Дата**: 2025-01-28 +**Статус**: 🚀 Production Ready + +## 🎯 Обзор + +Quoter предоставляет простой и надежный API для загрузки файлов с поддержкой: +- JWT аутентификации +- Квот пользователей (12 ГБ на пользователя) +- Множественных файлов в одном запросе +- Автоматического определения MIME типов +- Streaming загрузки с проверкой квот + +## 🔗 Endpoints + +### Base URL +``` +Production: https://files.dscrs.site +Development: http://localhost:8080 +``` + +### API Endpoints + +| Method | Endpoint | Описание | +|--------|----------|----------| +| `GET` | `/` | Информация о пользователе и квоте | +| `POST` | `/` | Загрузка файлов | +| `GET` | `/{filename}` | Получение файла | + +## 🔐 Аутентификация + +POST запросы требуют JWT токен в заголовке `Authorization`: + +```http +Authorization: Bearer +``` + +### Формат токена +- JWT токен с claims: `{ user_id, username, exp?, iat? }` +- Минимальная длина: 100 символов +- Максимальная длина: 2048 символов + +## 📊 Информация о пользователе + +### GET / + +Получает информацию о текущем пользователе и его квоте. + +**Заголовки:** +```http +Authorization: Bearer +``` + +**Ответ:** +```json +{ + "user_id": "user123", + "username": "john_doe", + "token_type": "Bearer", + "created_at": "2025-01-28T10:00:00Z", + "last_activity": "2025-01-28T12:00:00Z", + "auth_data": {...}, + "device_info": {...}, + "quota": { + "current_quota": 1073741824, + "max_quota": 12884901888, + "usage_percentage": 8.33 + } +} +``` + +**Коды ответов:** +- `200` - Успешно +- `401` - Неверный или истекший токен + +## 📤 Загрузка файлов + +### POST / + +Загружает один или несколько файлов. + +**Заголовки:** +```http +Authorization: Bearer +Content-Type: multipart/form-data +``` + +**Параметры:** +- `file` (multipart) - Файл для загрузки (можно несколько) + +**Лимиты:** +- Максимальный размер одного файла: **500 МБ** +- Максимальная квота пользователя: **12 ГБ** +- Поддерживаемые форматы: изображения, аудио, видео, документы + +**Ответ (один файл):** +``` +filename-uuid.ext +``` + +**Ответ (несколько файлов):** +```json +{ + "uploaded_files": [ + "file1-uuid.ext", + "file2-uuid.ext" + ], + "count": 2 +} +``` + +**Коды ответов:** +- `200` - Файл(ы) успешно загружены +- `400` - Нет файлов или все файлы пустые +- `401` - Неверный токен авторизации +- `413` - Превышен лимит размера файла или квоты +- `415` - Неподдерживаемый формат файла +- `500` - Ошибка сервера + +## 📁 Получение файлов + +### GET /{filename} + +Получает загруженный файл. + +**Параметры:** +- `filename` - Имя файла (UUID + расширение) + +**Заголовки (опционально):** +```http +If-None-Match: "etag-value" +Range: bytes=0-1023 +``` + +**Ответ:** +- `200` - Файл найден +- `304` - Файл не изменился (если передан ETag) +- `404` - Файл не найден +- `206` - Частичный контент (если передан Range) + +## 💻 Примеры кода + +### JavaScript/TypeScript + +```typescript +class QuoterUploadClient { + private baseUrl: string; + private token: string; + + constructor(baseUrl: string, token: string) { + this.baseUrl = baseUrl; + this.token = token; + } + + // Получение информации о пользователе + async getUserInfo() { + const response = await fetch(`${this.baseUrl}/`, { + headers: { + 'Authorization': `Bearer ${this.token}` + } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return await response.json(); + } + + // Загрузка одного файла + async uploadFile(file: File): Promise { + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch(`${this.baseUrl}/`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.token}` + }, + body: formData + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Upload failed: ${response.status} ${errorText}`); + } + + return await response.text(); + } + + // Загрузка нескольких файлов + async uploadFiles(files: File[]): Promise<{uploaded_files: string[], count: number}> { + const formData = new FormData(); + files.forEach(file => formData.append('file', file)); + + const response = await fetch(`${this.baseUrl}/`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.token}` + }, + body: formData + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Upload failed: ${response.status} ${errorText}`); + } + + return await response.json(); + } + + // Получение файла + async getFile(filename: string): Promise { + const response = await fetch(`${this.baseUrl}/${filename}`, { + headers: { + 'Authorization': `Bearer ${this.token}` + } + }); + + if (!response.ok) { + throw new Error(`File not found: ${response.status}`); + } + + return await response.blob(); + } +} + +// Использование +const client = new QuoterUploadClient('https://files.dscrs.site', 'your-jwt-token'); + +// Получение информации о пользователе +const userInfo = await client.getUserInfo(); +console.log(`Квота: ${userInfo.quota.usage_percentage.toFixed(1)}%`); + +// Загрузка файла +const fileInput = document.getElementById('fileInput') as HTMLInputElement; +const file = fileInput.files[0]; +const filename = await client.uploadFile(file); +console.log(`Файл загружен: ${filename}`); +``` + +### Python + +```python +import requests +import json +from typing import List, Dict, Any + +class QuoterUploadClient: + def __init__(self, base_url: str, token: str): + self.base_url = base_url.rstrip('/') + self.token = token + self.headers = {'Authorization': f'Bearer {token}'} + + def get_user_info(self) -> Dict[str, Any]: + """Получение информации о пользователе""" + response = requests.get(f'{self.base_url}/', headers=self.headers) + response.raise_for_status() + return response.json() + + def upload_file(self, file_path: str) -> str: + """Загрузка одного файла""" + with open(file_path, 'rb') as f: + files = {'file': f} + response = requests.post( + f'{self.base_url}/', + headers=self.headers, + files=files + ) + response.raise_for_status() + return response.text.strip() + + def upload_files(self, file_paths: List[str]) -> Dict[str, Any]: + """Загрузка нескольких файлов""" + files = [] + for path in file_paths: + files.append(('file', open(path, 'rb'))) + + try: + response = requests.post( + f'{self.base_url}/', + headers=self.headers, + files=files + ) + response.raise_for_status() + return response.json() + finally: + # Закрываем все файлы + for _, file_obj in files: + file_obj.close() + + def get_file(self, filename: str, save_path: str = None) -> bytes: + """Получение файла""" + response = requests.get( + f'{self.base_url}/{filename}', + headers=self.headers + ) + response.raise_for_status() + + if save_path: + with open(save_path, 'wb') as f: + f.write(response.content) + + return response.content + +# Использование +client = QuoterUploadClient('https://files.dscrs.site', 'your-jwt-token') + +# Получение информации о пользователе +user_info = client.get_user_info() +print(f"Квота: {user_info['quota']['usage_percentage']:.1f}%") + +# Загрузка файла +filename = client.upload_file('/path/to/file.jpg') +print(f"Файл загружен: {filename}") + +# Получение файла +file_content = client.get_file(filename, '/path/to/downloaded_file.jpg') +``` + +### cURL + +```bash +# Получение информации о пользователе +curl -H "Authorization: Bearer your-jwt-token" \ + https://files.dscrs.site/ + +# Загрузка одного файла +curl -X POST \ + -H "Authorization: Bearer your-jwt-token" \ + -F "file=@/path/to/file.jpg" \ + https://files.dscrs.site/ + +# Загрузка нескольких файлов +curl -X POST \ + -H "Authorization: Bearer your-jwt-token" \ + -F "file=@/path/to/file1.jpg" \ + -F "file=@/path/to/file2.mp3" \ + https://files.dscrs.site/ + +# Получение файла +curl -H "Authorization: Bearer your-jwt-token" \ + -o downloaded_file.jpg \ + https://files.dscrs.site/filename-uuid.jpg +``` + +## 🚨 Обработка ошибок + +### Типичные ошибки + +| Код | Описание | Решение | +|-----|----------|---------| +| `401` | Неверный токен | Проверить валидность JWT токена | +| `413` | Превышена квота | Удалить старые файлы или увеличить квоту | +| `413` | Файл слишком большой | Разделить файл на части | +| `415` | Неподдерживаемый формат | Проверить MIME тип файла | +| `500` | Ошибка сервера | Повторить запрос позже | + +### Пример обработки ошибок + +```typescript +async function uploadWithRetry(client: QuoterUploadClient, file: File, maxRetries = 3) { + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + return await client.uploadFile(file); + } catch (error) { + if (error.message.includes('413')) { + throw new Error('Файл слишком большой или квота превышена'); + } + + if (error.message.includes('401')) { + throw new Error('Токен авторизации недействителен'); + } + + if (attempt === maxRetries) { + throw error; + } + + // Ждем перед повторной попыткой + await new Promise(resolve => setTimeout(resolve, 1000 * attempt)); + } + } +} +``` + +## 📋 Поддерживаемые форматы + +### Изображения +- JPEG, PNG, GIF, WebP, HEIC, BMP, TIFF + +### Аудио +- MP3, WAV, AAC, M4A, OGG, FLAC + +### Видео +- MP4, AVI, MOV, WebM, MKV + +### Документы +- PDF, DOC, DOCX, TXT, RTF + +## 🔧 Конфигурация + +### Environment Variables + +```bash +# Обязательные +JWT_SECRET=your-jwt-secret-key +REDIS_URL=redis://localhost:6379 +STORJ_ACCESS_KEY=your-storj-access-key +STORJ_SECRET_KEY=your-storj-secret-key +STORJ_BUCKET=your-bucket-name + +# Опциональные +PORT=8080 +CORS_DOWNLOAD_ORIGINS=https://discours.io,https://*.discours.io +``` + +### Лимиты + +```rust +// Максимальный размер одного файла +const MAX_SINGLE_FILE_BYTES: u64 = 500 * 1024 * 1024; // 500 МБ + +// Максимальная квота пользователя +const MAX_USER_QUOTA_BYTES: u64 = 12 * 1024 * 1024 * 1024; // 12 ГБ +``` + +## 🧪 Тестирование + +### Проверка подключения + +```bash +# Проверка доступности API +curl -I https://files.dscrs.site/ + +# Проверка аутентификации +curl -H "Authorization: Bearer your-token" \ + https://files.dscrs.site/ +``` + +### Тестовые файлы + +```bash +# Создание тестового файла +echo "Test content" > test.txt + +# Загрузка тестового файла +curl -X POST \ + -H "Authorization: Bearer your-token" \ + -F "file=@test.txt" \ + https://files.dscrs.site/ +``` + +## 📚 Дополнительные ресурсы + +- [API Reference](README.md) +- [Setup Guide](SETUP.md) +- [Features Overview](features.md) +- [CORS Configuration](architecture.md) + +## 🆘 Поддержка + +При возникновении проблем: + +1. Проверьте валидность JWT токена +2. Убедитесь в правильности Content-Type +3. Проверьте размер файла и квоту пользователя +4. Изучите логи сервера для детальной диагностики + +--- + +**💡 Совет**: Используйте streaming загрузку для больших файлов и всегда проверяйте квоту пользователя перед загрузкой. diff --git a/docs/upload-quickstart.md b/docs/upload-quickstart.md new file mode 100644 index 0000000..b8acdb7 --- /dev/null +++ b/docs/upload-quickstart.md @@ -0,0 +1,154 @@ +# 🚀 Upload API - Quick Start + +**Быстрый старт для интеграции с Quoter Upload API** + +## 🔗 Endpoints + +``` +Base URL: https://files.dscrs.site +GET / - Информация о пользователе +POST / - Загрузка файлов +GET /{filename} - Получение файла +``` + +## 🔐 Аутентификация + +```http +Authorization: Bearer +``` + +## 📤 Загрузка файла + +### JavaScript + +```javascript +async function uploadFile(file, token) { + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch('https://files.dscrs.site/', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}` + }, + body: formData + }); + + if (!response.ok) { + throw new Error(`Upload failed: ${response.status}`); + } + + return await response.text(); // Возвращает filename +} +``` + +### Python + +```python +import requests + +def upload_file(file_path, token): + with open(file_path, 'rb') as f: + files = {'file': f} + headers = {'Authorization': f'Bearer {token}'} + + response = requests.post( + 'https://files.dscrs.site/', + headers=headers, + files=files + ) + response.raise_for_status() + return response.text.strip() +``` + +### cURL + +```bash +curl -X POST \ + -H "Authorization: Bearer your-token" \ + -F "file=@/path/to/file.jpg" \ + https://files.dscrs.site/ +``` + +## 📊 Информация о пользователе + +```javascript +async function getUserInfo(token) { + const response = await fetch('https://files.dscrs.site/', { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + return await response.json(); + // { user_id, username, quota: { current_quota, max_quota, usage_percentage } } +} +``` + +## 📁 Получение файла + +```javascript +async function getFile(filename, token) { + const response = await fetch(`https://files.dscrs.site/${filename}`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + return await response.blob(); +} +``` + +## 🚨 Лимиты + +- **Размер файла**: 500 МБ максимум +- **Квота пользователя**: 12 ГБ максимум +- **Форматы**: изображения, аудио, видео, документы + +## ❌ Коды ошибок + +- `401` - Неверный токен +- `413` - Превышена квота или размер файла +- `415` - Неподдерживаемый формат +- `500` - Ошибка сервера + +## 💡 Пример использования + +```javascript +// Полный пример +const client = { + baseUrl: 'https://files.dscrs.site', + token: 'your-jwt-token', + + async upload(file) { + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch(`${this.baseUrl}/`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${this.token}` }, + body: formData + }); + + if (!response.ok) throw new Error(`Upload failed: ${response.status}`); + return await response.text(); + }, + + async getUserInfo() { + const response = await fetch(`${this.baseUrl}/`, { + headers: { 'Authorization': `Bearer ${this.token}` } + }); + return await response.json(); + } +}; + +// Использование +const fileInput = document.getElementById('fileInput'); +const file = fileInput.files[0]; +const filename = await client.upload(file); +console.log(`Загружен: ${filename}`); +``` + +--- + +📚 **Полная документация**: [upload-client-guide.md](upload-client-guide.md) diff --git a/src/app_state.rs b/src/app_state.rs index f551fcb..b73182e 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -1,5 +1,6 @@ use crate::s3_utils::get_s3_filelist; use crate::security::SecurityConfig; +use crate::thumbnail::cleanup_cache; use actix_web::error::ErrorInternalServerError; use aws_config::BehaviorVersion; use aws_sdk_s3::{Client as S3Client, config::Credentials}; @@ -29,7 +30,7 @@ impl AppState { /// Инициализация с кастомной конфигурацией безопасности. pub async fn new_with_config(security_config: SecurityConfig) -> Self { log::warn!("🚀 Starting AppState initialization..."); - + // Получаем конфигурацию для Redis с таймаутом log::warn!("📋 Getting REDIS_URL from environment..."); let redis_url = match env::var("REDIS_URL") { @@ -42,10 +43,13 @@ impl AppState { panic!("REDIS_URL must be set: {}", e); } }; - + // Детальное логирование для отладки - log::warn!("🔗 Redis URL: {}", redis_url.replace(&redis_url.split('@').nth(0).unwrap_or(""), "***")); - + log::warn!( + "🔗 Redis URL: {}", + redis_url.replace(redis_url.split('@').nth(0).unwrap_or(""), "***") + ); + // Парсим URL для детального анализа log::warn!("🔍 Parsing Redis URL..."); let final_redis_url = match url::Url::parse(&redis_url) { @@ -53,32 +57,38 @@ impl AppState { log::warn!("✅ Redis URL parsed successfully"); log::warn!(" Host: {}", parsed_url.host_str().unwrap_or("none")); log::warn!(" Port: {}", parsed_url.port().unwrap_or(0)); - + let username = parsed_url.username(); let password = parsed_url.password(); - + log::warn!(" Username: '{}'", username); - log::warn!(" Password: {}", if password.is_some() { "***" } else { "none" }); - - // Если username пустой и есть пароль, оставляем как есть - // Redis может работать только с паролем без username - if username.is_empty() && password.is_some() { - log::warn!(" 🔧 Using password-only authentication (no username)"); - } - redis_url + log::warn!( + " Password: {}", + if password.is_some() { "***" } else { "none" } + ); + + // Если username пустой и есть пароль, оставляем как есть + // Redis может работать только с паролем без username + if username.is_empty() && password.is_some() { + log::warn!(" 🔧 Using password-only authentication (no username)"); + } + redis_url } Err(e) => { log::error!("❌ Failed to parse Redis URL: {}", e); panic!("Invalid Redis URL: {}", e); } }; - + // Используем исправленный URL Self::create_app_state_with_redis_url(security_config, final_redis_url).await } /// Создает AppState с указанным Redis URL. - async fn create_app_state_with_redis_url(security_config: SecurityConfig, redis_url: String) -> Self { + async fn create_app_state_with_redis_url( + security_config: SecurityConfig, + redis_url: String, + ) -> Self { let redis_client = match RedisClient::open(redis_url) { Ok(client) => { log::warn!("✅ Redis client created successfully"); @@ -91,8 +101,11 @@ impl AppState { }; // Устанавливаем таймаут для Redis операций с graceful fallback - log::warn!("🔄 Attempting Redis connection with timeout: {}s", security_config.request_timeout_seconds); - + log::warn!( + "🔄 Attempting Redis connection with timeout: {}s", + security_config.request_timeout_seconds + ); + let redis_connection = match tokio::time::timeout( Duration::from_secs(security_config.request_timeout_seconds), redis_client.get_multiplexed_async_connection(), @@ -101,12 +114,9 @@ impl AppState { { Ok(Ok(mut conn)) => { log::warn!("✅ Redis connection established"); - + // Тестируем подключение простой командой - match tokio::time::timeout( - Duration::from_secs(2), - conn.ping::() - ).await { + match tokio::time::timeout(Duration::from_secs(2), conn.ping::()).await { Ok(Ok(result)) => { log::warn!("✅ Redis PING successful: {}", result); Some(conn) @@ -128,7 +138,10 @@ impl AppState { None } Err(_) => { - log::warn!("⚠️ Redis connection timeout after {} seconds", security_config.request_timeout_seconds); + log::warn!( + "⚠️ Redis connection timeout after {} seconds", + security_config.request_timeout_seconds + ); log::warn!("⚠️ Running in fallback mode without Redis (quotas disabled)"); None } @@ -355,4 +368,27 @@ impl AppState { Ok(new_quota) } + + /// Очищает старые файлы из локального кэша. + pub fn cleanup_local_cache(&self) -> Result<(), Box> { + // Очищаем кэш старше 7 дней + cleanup_cache("/tmp/thumbnails", 7)?; + Ok(()) + } + + /// Запускает периодическую очистку кэша. + pub fn start_cache_cleanup_task(&self) { + let state = self.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(24 * 60 * 60)); // раз в день + loop { + interval.tick().await; + if let Err(e) = state.cleanup_local_cache() { + warn!("Failed to cleanup local cache: {}", e); + } else { + warn!("Local cache cleanup completed successfully"); + } + } + }); + } } diff --git a/src/handlers/common.rs b/src/handlers/common.rs index 05aa8c8..955c2e3 100644 --- a/src/handlers/common.rs +++ b/src/handlers/common.rs @@ -9,9 +9,16 @@ pub const CACHE_CONTROL_VERCEL: &str = "public, max-age=86400, s-maxage=31536000 /// Log request source and check CORS origin pub fn get_cors_origin(req: &HttpRequest) -> String { - let allowed_origins = env::var("CORS_DOWNLOAD_ORIGINS") + let mut 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()); + // Добавляем development origins если их нет в переменной окружения + let development_origins = + "http://localhost:3000,https://localhost:3000,https://files.dscrs.site"; + if !allowed_origins.contains("localhost:3000") { + allowed_origins.push_str(&format!(",{}", development_origins)); + } + // 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()); @@ -65,6 +72,13 @@ pub fn get_cors_origin(req: &HttpRequest) -> String { return origin.to_string(); } } + + // Дополнительная проверка для localhost в development + if origin.starts_with("http://localhost:") || origin.starts_with("https://localhost:") { + debug!("✅ CORS allowed: {} (localhost development)", origin); + return origin.to_string(); + } + warn!("⚠️ CORS not whitelisted: {}", origin); } @@ -124,11 +138,22 @@ pub fn create_file_response_with_analytics( // Log analytics for CORS whitelist analysis log_request_analytics(req, path, data.len()); - HttpResponse::Ok() - .content_type(content_type) - .insert_header(("cache-control", CACHE_CONTROL_VERCEL)) - .insert_header(("access-control-allow-origin", cors_origin)) - .body(data) + // Add audio streaming headers for audio files + if content_type.starts_with("audio/") { + HttpResponse::Ok() + .content_type(content_type) + .insert_header(("cache-control", CACHE_CONTROL_VERCEL)) + .insert_header(("access-control-allow-origin", cors_origin)) + .insert_header(("accept-ranges", "bytes")) + .insert_header(("content-length", data.len().to_string())) + .body(data) + } else { + HttpResponse::Ok() + .content_type(content_type) + .insert_header(("cache-control", CACHE_CONTROL_VERCEL)) + .insert_header(("access-control-allow-origin", cors_origin)) + .body(data) + } } /// Проверяет, является ли запрос от Vercel Edge API @@ -157,14 +182,28 @@ pub fn create_vercel_compatible_response( 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) + // Add audio streaming headers for audio files + if content_type.starts_with("audio/") { + 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")) + .insert_header(("accept-ranges", "bytes")) + .insert_header(("content-length", data.len().to_string())) + .body(data) + } else { + 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 @@ -239,17 +278,21 @@ pub fn handle_system_file(filename: &str) -> Option { match filename.to_lowercase().as_str() { "robots.txt" => { info!("Serving robots.txt for static image server"); - Some(HttpResponse::Ok() - .content_type("text/plain") - .insert_header(("access-control-allow-origin", "*")) - .body("User-agent: *\nDisallow: /\n")) + Some( + HttpResponse::Ok() + .content_type("text/plain") + .insert_header(("access-control-allow-origin", "*")) + .body("User-agent: *\nDisallow: /\n"), + ) } "favicon.ico" => { info!("Serving favicon.ico (empty)"); - Some(HttpResponse::Ok() - .content_type("image/x-icon") - .insert_header(("access-control-allow-origin", "*")) - .body("")) + Some( + HttpResponse::Ok() + .content_type("image/x-icon") + .insert_header(("access-control-allow-origin", "*")) + .body(""), + ) } "sitemap.xml" => { info!("Serving sitemap.xml (empty)"); @@ -260,11 +303,13 @@ pub fn handle_system_file(filename: &str) -> Option { } "humans.txt" => { info!("Serving humans.txt"); - Some(HttpResponse::Ok() - .content_type("text/plain") - .insert_header(("access-control-allow-origin", "*")) - .body("# Static Image Server\n# Powered by Quoter\n")) + Some( + HttpResponse::Ok() + .content_type("text/plain") + .insert_header(("access-control-allow-origin", "*")) + .body("# Static Image Server\n# Powered by Quoter\n"), + ) } - _ => None + _ => None, } } diff --git a/src/handlers/proxy.rs b/src/handlers/proxy.rs index 92ed9e8..a224e5e 100644 --- a/src/handlers/proxy.rs +++ b/src/handlers/proxy.rs @@ -3,15 +3,17 @@ use actix_web::{HttpRequest, HttpResponse, Result, error::ErrorInternalServerErr use log::{error, info, warn}; use super::common::{ - create_file_response_with_analytics, create_vercel_compatible_response, is_vercel_request, handle_system_file, + create_file_response_with_analytics, create_vercel_compatible_response, handle_system_file, + 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}; use crate::s3_utils::{check_file_exists, load_file_from_s3, upload_to_s3}; use crate::thumbnail::{ - parse_file_path, is_image_file, generate_webp_thumbnail, - load_cached_thumbnail_from_storj, cache_thumbnail_to_storj + cache_thumbnail, cache_thumbnail_to_storj, generate_webp_thumbnail, is_image_file, + load_cached_thumbnail, load_cached_thumbnail_from_storj, parse_file_path, + thumbnail_exists_in_storj, }; // Удалена дублирующая функция, используется из common модуля @@ -24,7 +26,10 @@ pub async fn proxy_handler( state: web::Data, ) -> Result { let start_time = std::time::Instant::now(); - info!("GET {} [START] - Static image server request", requested_res); + info!( + "GET {} [START] - Static image server request", + requested_res + ); // Проверяем системные файлы (robots.txt, favicon.ico, etc.) if let Some(response) = handle_system_file(&requested_res) { @@ -69,7 +74,10 @@ pub async fn proxy_handler( } } _ => { - error!("Unsupported file format for: {} (full path: {})", base_filename, requested_res); + error!( + "Unsupported file format for: {} (full path: {})", + base_filename, requested_res + ); return Err(ErrorInternalServerError("Unsupported file format")); } }, @@ -145,19 +153,27 @@ pub async fn proxy_handler( // Проверяем, нужен ли thumbnail if requested_width > 0 && is_image_file(&filekey) { - info!("Generating thumbnail for {} with width {}", filekey, requested_width); - + 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); + &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( @@ -174,12 +190,76 @@ pub async fn proxy_handler( )); } } - + + // Пробуем загрузить из локального кэша + if let Some(local_cached_thumb) = load_cached_thumbnail( + "/tmp/thumbnails", + &base_filename, + requested_width, + None, + ) { + info!( + "Serving cached thumbnail from local cache for {}", + base_filename + ); + let thumb_content_type = "image/webp"; + + if is_vercel_request(&req) { + let etag = + format!("\"{:x}\"", md5::compute(&local_cached_thumb)); + return Ok(create_vercel_compatible_response( + thumb_content_type, + local_cached_thumb, + &etag, + )); + } else { + return Ok(create_file_response_with_analytics( + thumb_content_type, + local_cached_thumb, + &req, + &format!("{}_{}x.webp", base_filename, requested_width), + )); + } + } + + // Проверяем состояние Storj кэша для диагностики + let storj_cache_exists = thumbnail_exists_in_storj( + &state.storj_client, + &state.bucket, + &base_filename, + requested_width, + None, + ) + .await; + + if storj_cache_exists { + warn!( + "Thumbnail exists in Storj but failed to load for {}", + base_filename + ); + } else { + info!( + "No thumbnail in Storj cache for {}, will generate new", + base_filename + ); + } + // Генерируем новый thumbnail match generate_webp_thumbnail(&filedata, requested_width, None) { Ok(thumb_data) => { info!("Generated thumbnail: {} bytes", thumb_data.len()); - + + // Кэшируем thumbnail локально + if let Err(e) = cache_thumbnail( + "/tmp/thumbnails", + &base_filename, + requested_width, + None, + &thumb_data, + ) { + warn!("Failed to cache thumbnail locally: {}", e); + } + // Кэшируем thumbnail в Storj if let Err(e) = cache_thumbnail_to_storj( &state.storj_client, @@ -187,15 +267,18 @@ pub async fn proxy_handler( &base_filename, requested_width, None, - &thumb_data - ).await { + &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)); + let etag = + format!("\"{:x}\"", md5::compute(&thumb_data)); return Ok(create_vercel_compatible_response( thumb_content_type, thumb_data, @@ -206,7 +289,10 @@ pub async fn proxy_handler( thumb_content_type, thumb_data, &req, - &format!("{}_{}x.webp", base_filename, requested_width), + &format!( + "{}_{}x.webp", + base_filename, requested_width + ), )); } } diff --git a/src/handlers/serve_file.rs b/src/handlers/serve_file.rs index 98b6384..8d0e0e8 100644 --- a/src/handlers/serve_file.rs +++ b/src/handlers/serve_file.rs @@ -1,19 +1,16 @@ use actix_web::{HttpRequest, HttpResponse, Result, error::ErrorInternalServerError}; -use mime_guess::MimeGuess; use log::{info, warn}; +use mime_guess::MimeGuess; 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 + cache_thumbnail_to_storj, generate_webp_thumbnail, is_image_file, + load_cached_thumbnail_from_storj, parse_file_path, thumbnail_exists_in_storj, }; -use md5; /// Функция для обслуживания файла по заданному пути с поддержкой thumbnail генерации. pub async fn serve_file( @@ -43,22 +40,27 @@ pub async fn serve_file( // Парсим путь для проверки 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); - + 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 { + &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( @@ -75,12 +77,34 @@ pub async fn serve_file( )); } } - + + // Проверяем состояние Storj кэша для диагностики + let storj_cache_exists = thumbnail_exists_in_storj( + &state.storj_client, + &state.bucket, + &base_filename, + requested_width, + None, + ) + .await; + + if storj_cache_exists { + warn!( + "Thumbnail exists in Storj but failed to load for {}", + base_filename + ); + } else { + info!( + "No thumbnail in Storj cache for {}, will generate new", + base_filename + ); + } + // Генерируем новый 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, @@ -88,13 +112,15 @@ pub async fn serve_file( &base_filename, requested_width, None, - &thumb_data - ).await { + &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( diff --git a/src/handlers/upload.rs b/src/handlers/upload.rs index 25d1a71..1854952 100644 --- a/src/handlers/upload.rs +++ b/src/handlers/upload.rs @@ -8,6 +8,7 @@ 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 crate::thumbnail::get_image_mime_type; use futures::TryStreamExt; // use crate::thumbnail::convert_heic_to_jpeg; @@ -129,6 +130,23 @@ pub async fn upload_handler( let filename = format!("{}.{}", uuid::Uuid::new_v4(), extension); info!("Generated filename: {}", filename); + // Дополнительная валидация для изображений + if content_type.starts_with("image/") { + let expected_mime = get_image_mime_type(&filename); + if expected_mime != content_type && expected_mime != "application/octet-stream" { + warn!( + "MIME type mismatch for {}: detected={}, expected={}", + filename, content_type, expected_mime + ); + // Продолжаем с обнаруженным типом, но логируем расхождение + } else { + info!( + "MIME type validation passed for {}: {}", + filename, content_type + ); + } + } + // Загружаем файл в S3 storj match upload_to_s3( &state.storj_client, diff --git a/src/main.rs b/src/main.rs index 8812596..9710685 100644 --- a/src/main.rs +++ b/src/main.rs @@ -39,6 +39,9 @@ async fn main() -> std::io::Result<()> { }); }); + // Запускаем периодическую очистку кэша + app_state.start_cache_cleanup_task(); + // Конфигурация безопасности let security_config = SecurityConfig::default(); info!( @@ -48,22 +51,30 @@ async fn main() -> std::io::Result<()> { ); HttpServer::new(move || { - // Настройка CORS middleware - ограничиваем в продакшене + // Настройка CORS middleware - более гибкая для разработки let cors = Cors::default() .allowed_origin("https://discours.io") .allowed_origin("https://new.discours.io") .allowed_origin("https://testing.discours.io") - .allowed_origin("http://localhost:3000") // FIXME: для разработки + .allowed_origin("http://localhost:3000") + .allowed_origin("https://localhost:3000") // HTTPS для разработки + .allowed_origin("https://files.dscrs.site") // Добавляем домен файлов .allowed_methods(vec!["GET", "POST", "OPTIONS"]) .allowed_headers(vec![ header::CONTENT_TYPE, header::AUTHORIZATION, header::IF_NONE_MATCH, header::CACHE_CONTROL, + header::RANGE, // Для аудио стриминга + ]) + .expose_headers(vec![ + header::CONTENT_LENGTH, + header::ETAG, + header::CONTENT_RANGE, // Для аудио стриминга + header::ACCEPT_RANGES, ]) - .expose_headers(vec![header::CONTENT_LENGTH, header::ETAG]) .supports_credentials() - .max_age(86400); // 1 день вместо 20 + .max_age(86400); // 1 день // Заголовки безопасности let security_headers = DefaultHeaders::new() diff --git a/src/thumbnail.rs b/src/thumbnail.rs index 13388ad..7e8458f 100644 --- a/src/thumbnail.rs +++ b/src/thumbnail.rs @@ -1,9 +1,9 @@ +use aws_sdk_s3::Client as S3Client; use image::ImageFormat; -use std::path::Path; +use log::{info, warn}; use std::fs; use std::io::Write; -use log::{info, warn}; -use aws_sdk_s3::Client as S3Client; +use std::path::Path; /// Парсит путь к файлу, извлекая базовое имя, ширину и расширение. /// @@ -41,13 +41,13 @@ pub fn parse_file_path(path: &str) -> (String, u32, String) { } /// Генерирует thumbnail для изображения. -/// +/// /// # Аргументы /// * `image_data` - Данные изображения /// * `width` - Ширина thumbnail /// * `height` - Высота thumbnail (опционально) /// * `format` - Формат выходного изображения (по умолчанию WebP) -/// +/// /// # Возвращает /// * `Result, Box>` - Данные thumbnail или ошибка pub fn generate_thumbnail( @@ -56,26 +56,30 @@ pub fn generate_thumbnail( height: Option, format: Option, ) -> Result, Box> { - info!("Generating thumbnail: {}x{}", width, height.unwrap_or(width)); - + 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) } @@ -86,7 +90,18 @@ pub fn generate_webp_thumbnail( width: u32, height: Option, ) -> Result, Box> { - generate_thumbnail(image_data, width, height, Some(ImageFormat::WebP)) + // Пробуем сначала WebP + match generate_thumbnail(image_data, width, height, Some(ImageFormat::WebP)) { + Ok(webp_data) => Ok(webp_data), + Err(webp_error) => { + warn!( + "WebP generation failed, falling back to JPEG: {}", + webp_error + ); + // Fallback к JPEG если WebP не удался + generate_jpeg_thumbnail(image_data, width, height) + } + } } /// Генерирует JPEG thumbnail для изображения. @@ -105,8 +120,11 @@ pub fn is_image_file(filename: &str) -> bool { .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") + + matches!( + ext.as_str(), + "jpg" | "jpeg" | "png" | "gif" | "bmp" | "webp" | "tiff" + ) } /// Получает MIME тип для изображения. @@ -116,7 +134,7 @@ pub fn get_image_mime_type(filename: &str) -> &'static str { .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", @@ -138,20 +156,20 @@ pub fn cache_thumbnail( ) -> 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()) } @@ -168,9 +186,9 @@ pub fn load_cached_thumbnail( } 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()); @@ -198,10 +216,10 @@ pub async fn cache_thumbnail_to_storj( } 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) @@ -210,7 +228,7 @@ pub async fn cache_thumbnail_to_storj( .content_type("image/webp") .send() .await?; - + info!("Thumbnail cached to Storj: {}", thumbnail_key); Ok(thumbnail_key) } @@ -228,7 +246,7 @@ pub async fn load_cached_thumbnail_from_storj( } else { format!("thumbnails/{}_{}x.webp", filename, width) }; - + match s3_client .get_object() .bucket(bucket) @@ -236,19 +254,21 @@ pub async fn load_cached_thumbnail_from_storj( .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 - } + 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 @@ -269,17 +289,14 @@ pub async fn thumbnail_exists_in_storj( } else { format!("thumbnails/{}_{}x.webp", filename, width) }; - - match s3_client + + (s3_client .head_object() .bucket(bucket) .key(&thumbnail_key) .send() - .await - { - Ok(_) => true, - Err(_) => false, - } + .await) + .is_ok() } /// Очищает старые файлы кэша. @@ -288,13 +305,14 @@ pub fn cleanup_cache(cache_dir: &str, max_age_days: u64) -> Result<(), Box Result<(), Box { println!("✅ URL parsed successfully"); - + // Попробуем подключиться (с коротким таймаутом) match tokio::time::timeout( std::time::Duration::from_secs(2), - client.get_multiplexed_async_connection() - ).await { + client.get_multiplexed_async_connection(), + ) + .await + { Ok(Ok(_)) => println!("✅ Connection successful"), Ok(Err(e)) => println!("❌ Connection failed: {}", e), Err(_) => println!("⏰ Connection timeout"), @@ -60,35 +72,48 @@ async fn test_redis_url_parsing() { #[tokio::test] async fn test_redis_connection_with_env() { - // Тестируем с реальным REDIS_URL из окружения + // Тестируем с реальным REDIS_URL из окружения if let Ok(redis_url) = env::var("REDIS_URL") { - println!("Testing with real REDIS_URL: {}", - redis_url.replace(&redis_url.split('@').nth(0).unwrap_or(""), "***")); - + println!( + "Testing with real REDIS_URL: {}", + redis_url.replace(&redis_url.split('@').nth(0).unwrap_or(""), "***") + ); + // Парсим реальный URL для детального вывода if let Ok(parsed) = url::Url::parse(&redis_url) { println!(" Host: {}", parsed.host_str().unwrap_or("none")); println!(" Port: {}", parsed.port().unwrap_or(0)); println!(" Username: '{}'", parsed.username()); - println!(" Password: '{}'", if parsed.password().is_some() { "***" } else { "none" }); + println!( + " Password: '{}'", + if parsed.password().is_some() { + "***" + } else { + "none" + } + ); } - + match Client::open(redis_url) { Ok(client) => { println!("✅ Real URL parsed successfully"); - + match tokio::time::timeout( std::time::Duration::from_secs(5), - client.get_multiplexed_async_connection() - ).await { + client.get_multiplexed_async_connection(), + ) + .await + { Ok(Ok(mut conn)) => { println!("✅ Real connection successful"); - + // Попробуем выполнить простую команду match tokio::time::timeout( std::time::Duration::from_secs(2), - conn.ping::() - ).await { + conn.ping::(), + ) + .await + { Ok(Ok(result)) => println!("✅ PING successful: {}", result), Ok(Err(e)) => println!("❌ PING failed: {}", e), Err(_) => println!("⏰ PING timeout"), @@ -111,19 +136,28 @@ async fn test_redis_connection_with_env() { fn test_redis_url_components() { // Тестируем парсинг компонентов URL let test_url = "redis://:dbc5f9f9007c555e209964454c9d6abecae5f1db72e490acd5c94354dc012282@dokku-redis-discoursio-redis:6379"; - + if let Ok(parsed) = url::Url::parse(test_url) { println!("✅ URL parsed successfully"); println!(" Scheme: {}", parsed.scheme()); println!(" Host: {}", parsed.host_str().unwrap_or("none")); println!(" Port: {}", parsed.port().unwrap_or(0)); println!(" Username: '{}'", parsed.username()); - println!(" Password: {}", if parsed.password().is_some() { "***" } else { "none" }); + println!( + " Password: {}", + if parsed.password().is_some() { + "***" + } else { + "none" + } + ); println!(" Path: {}", parsed.path()); - + // Проверяем, что пустое имя пользователя означает дефолтное 'redis' if parsed.username().is_empty() && parsed.password().is_some() { - println!(" ⚠️ Empty username with password - Redis client should use default 'redis' user"); + println!( + " ⚠️ Empty username with password - Redis client should use default 'redis' user" + ); } } else { println!("❌ URL parsing failed"); @@ -134,22 +168,38 @@ fn test_redis_url_components() { async fn test_redis_default_username_behavior() { // Тестируем поведение Redis client с пустым именем пользователя println!("Testing Redis default username behavior..."); - + let test_cases = vec![ - ("redis://:password@localhost:6379", "Empty username with password"), - ("redis://redis:password@localhost:6379", "Explicit redis username"), + ( + "redis://:password@localhost:6379", + "Empty username with password", + ), + ( + "redis://redis:password@localhost:6379", + "Explicit redis username", + ), ("redis://:@localhost:6379", "Empty username and password"), ("redis://localhost:6379", "No authentication"), ]; - + for (url, description) in test_cases { println!("\n--- {} ---", description); - println!("URL: {}", url.replace(&url.split('@').nth(0).unwrap_or(""), "***")); - + println!( + "URL: {}", + url.replace(&url.split('@').nth(0).unwrap_or(""), "***") + ); + if let Ok(parsed) = url::Url::parse(url) { println!(" Parsed Username: '{}'", parsed.username()); - println!(" Parsed Password: {}", if parsed.password().is_some() { "***" } else { "none" }); - + println!( + " Parsed Password: {}", + if parsed.password().is_some() { + "***" + } else { + "none" + } + ); + // Redis client поведение: // - Если username пустой, но есть password -> использует дефолтное имя 'redis' // - Если username и password пустые -> без аутентификации