0.6.4-thumb-upgrade
Some checks failed
Deploy / deploy (push) Has been skipped
CI / lint (push) Successful in 1m53s
CI / test (push) Failing after 9m28s

This commit is contained in:
2025-09-03 10:21:17 +03:00
parent f0b327a99a
commit ae0fc9a18d
15 changed files with 1180 additions and 202 deletions

View File

@@ -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 ## [0.6.1] - 2025-09-02
### 🚀 Изменено - Восстановление thumbnail функциональности ### 🚀 Изменено - Восстановление thumbnail функциональности

22
Cargo.lock generated
View File

@@ -1401,9 +1401,9 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.2.1" version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [ dependencies = [
"percent-encoding", "percent-encoding",
] ]
@@ -1990,9 +1990,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]] [[package]]
name = "idna" name = "idna"
version = "1.0.3" version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [ dependencies = [
"idna_adapter", "idna_adapter",
"smallvec", "smallvec",
@@ -2276,9 +2276,9 @@ dependencies = [
[[package]] [[package]]
name = "md5" name = "md5"
version = "0.7.0" version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0"
[[package]] [[package]]
name = "memchr" name = "memchr"
@@ -2540,9 +2540,9 @@ dependencies = [
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.1" version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
@@ -2650,7 +2650,7 @@ dependencies = [
[[package]] [[package]]
name = "quoter" name = "quoter"
version = "0.6.1" version = "0.6.4"
dependencies = [ dependencies = [
"actix", "actix",
"actix-cors", "actix-cors",
@@ -3753,9 +3753,9 @@ dependencies = [
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.4" version = "2.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b"
dependencies = [ dependencies = [
"form_urlencoded", "form_urlencoded",
"idna", "idna",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "quoter" name = "quoter"
version = "0.6.1" version = "0.6.4"
edition = "2024" edition = "2024"
[dependencies] [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"] } 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"] } image = { version = "0.25.6", default-features = false, features = ["jpeg", "png", "webp", "tiff"] }
mime_guess = "2.0.5" mime_guess = "2.0.5"
md5 = "0.7.0" md5 = "0.8.0"
url = "2.5.4" url = "2.5.7"
aws-config = { version = "1.8.6", default-features = false, features = ["rt-tokio", "rustls"] } aws-config = { version = "1.8.6", default-features = false, features = ["rt-tokio", "rustls"] }
actix-multipart = "0.7.2" actix-multipart = "0.7.2"
log = "0.4.22" log = "0.4.22"

View File

@@ -4,13 +4,19 @@ Simple file upload proxy with S3 storage and user quotas.
## 📚 Documentation ## 📚 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 - **[SETUP.md](./SETUP.md)** - Installation, configuration, and deployment
- **[architecture.md](./architecture.md)** - Technical details for developers
- **[configuration.md](./configuration.md)** - Environment variables reference - **[configuration.md](./configuration.md)** - Environment variables reference
- **[architecture.md](./architecture.md)** - Technical details for developers
### 📖 Features & Integration
- **[features.md](./features.md)** - What Quoter does - **[features.md](./features.md)** - What Quoter does
- **[how-it-works.md](./how-it-works.md)** - System overview - **[how-it-works.md](./how-it-works.md)** - System overview
- **[hybrid-architecture.md](./hybrid-architecture.md)** - Vercel + Quoter integration - **[vercel-frontend-migration.md](./vercel-frontend-migration.md)** - Vercel integration guide
- **[vercel-og-integration.md](./vercel-og-integration.md)** - OpenGraph integration
## 🎯 Key Concept ## 🎯 Key Concept

View File

@@ -52,6 +52,7 @@ Simple file upload/download proxy with thumbnail generation and S3 storage.
- ✅ JWT authentication - ✅ JWT authentication
- ✅ Rate limiting & security - ✅ Rate limiting & security
- ✅ Thumbnail generation with Storj caching - ✅ Thumbnail generation with Storj caching
- ✅ Audio streaming with Range request support
- ✅ ETag caching for performance - ✅ ETag caching for performance
- ✅ Full test coverage - ✅ Full test coverage
- 🚀 Production ready - 🚀 Production ready

473
docs/upload-client-guide.md Normal file
View File

@@ -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 <your-jwt-token>
```
### Формат токена
- JWT токен с claims: `{ user_id, username, exp?, iat? }`
- Минимальная длина: 100 символов
- Максимальная длина: 2048 символов
## 📊 Информация о пользователе
### GET /
Получает информацию о текущем пользователе и его квоте.
**Заголовки:**
```http
Authorization: Bearer <jwt-token>
```
**Ответ:**
```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 <jwt-token>
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<string> {
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<Blob> {
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 загрузку для больших файлов и всегда проверяйте квоту пользователя перед загрузкой.

154
docs/upload-quickstart.md Normal file
View File

@@ -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 <jwt-token>
```
## 📤 Загрузка файла
### 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)

View File

@@ -1,5 +1,6 @@
use crate::s3_utils::get_s3_filelist; use crate::s3_utils::get_s3_filelist;
use crate::security::SecurityConfig; use crate::security::SecurityConfig;
use crate::thumbnail::cleanup_cache;
use actix_web::error::ErrorInternalServerError; use actix_web::error::ErrorInternalServerError;
use aws_config::BehaviorVersion; use aws_config::BehaviorVersion;
use aws_sdk_s3::{Client as S3Client, config::Credentials}; use aws_sdk_s3::{Client as S3Client, config::Credentials};
@@ -44,7 +45,10 @@ impl AppState {
}; };
// Детальное логирование для отладки // Детальное логирование для отладки
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 для детального анализа // Парсим URL для детального анализа
log::warn!("🔍 Parsing Redis URL..."); log::warn!("🔍 Parsing Redis URL...");
@@ -58,7 +62,10 @@ impl AppState {
let password = parsed_url.password(); let password = parsed_url.password();
log::warn!(" Username: '{}'", username); log::warn!(" Username: '{}'", username);
log::warn!(" Password: {}", if password.is_some() { "***" } else { "none" }); log::warn!(
" Password: {}",
if password.is_some() { "***" } else { "none" }
);
// Если username пустой и есть пароль, оставляем как есть // Если username пустой и есть пароль, оставляем как есть
// Redis может работать только с паролем без username // Redis может работать только с паролем без username
@@ -78,7 +85,10 @@ impl AppState {
} }
/// Создает AppState с указанным Redis URL. /// Создает 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) { let redis_client = match RedisClient::open(redis_url) {
Ok(client) => { Ok(client) => {
log::warn!("✅ Redis client created successfully"); log::warn!("✅ Redis client created successfully");
@@ -91,7 +101,10 @@ impl AppState {
}; };
// Устанавливаем таймаут для Redis операций с graceful fallback // Устанавливаем таймаут для 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( let redis_connection = match tokio::time::timeout(
Duration::from_secs(security_config.request_timeout_seconds), Duration::from_secs(security_config.request_timeout_seconds),
@@ -103,10 +116,7 @@ impl AppState {
log::warn!("✅ Redis connection established"); log::warn!("✅ Redis connection established");
// Тестируем подключение простой командой // Тестируем подключение простой командой
match tokio::time::timeout( match tokio::time::timeout(Duration::from_secs(2), conn.ping::<String>()).await {
Duration::from_secs(2),
conn.ping::<String>()
).await {
Ok(Ok(result)) => { Ok(Ok(result)) => {
log::warn!("✅ Redis PING successful: {}", result); log::warn!("✅ Redis PING successful: {}", result);
Some(conn) Some(conn)
@@ -128,7 +138,10 @@ impl AppState {
None None
} }
Err(_) => { 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)"); log::warn!("⚠️ Running in fallback mode without Redis (quotas disabled)");
None None
} }
@@ -355,4 +368,27 @@ impl AppState {
Ok(new_quota) Ok(new_quota)
} }
/// Очищает старые файлы из локального кэша.
pub fn cleanup_local_cache(&self) -> Result<(), Box<dyn std::error::Error>> {
// Очищаем кэш старше 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");
}
}
});
}
} }

View File

@@ -9,9 +9,16 @@ pub const CACHE_CONTROL_VERCEL: &str = "public, max-age=86400, s-maxage=31536000
/// Log request source and check CORS origin /// Log request source and check CORS origin
pub fn get_cors_origin(req: &HttpRequest) -> String { 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()); .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 // Extract request source info for logging
let origin = req.headers().get("origin").and_then(|h| h.to_str().ok()); let origin = req.headers().get("origin").and_then(|h| h.to_str().ok());
let referer = req.headers().get("referer").and_then(|h| h.to_str().ok()); let 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(); 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); warn!("⚠️ CORS not whitelisted: {}", origin);
} }
@@ -124,11 +138,22 @@ pub fn create_file_response_with_analytics(
// Log analytics for CORS whitelist analysis // Log analytics for CORS whitelist analysis
log_request_analytics(req, path, data.len()); log_request_analytics(req, path, data.len());
// 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() HttpResponse::Ok()
.content_type(content_type) .content_type(content_type)
.insert_header(("cache-control", CACHE_CONTROL_VERCEL)) .insert_header(("cache-control", CACHE_CONTROL_VERCEL))
.insert_header(("access-control-allow-origin", cors_origin)) .insert_header(("access-control-allow-origin", cors_origin))
.body(data) .body(data)
}
} }
/// Проверяет, является ли запрос от Vercel Edge API /// Проверяет, является ли запрос от Vercel Edge API
@@ -157,6 +182,19 @@ pub fn create_vercel_compatible_response(
data: Vec<u8>, data: Vec<u8>,
etag: &str, etag: &str,
) -> HttpResponse { ) -> HttpResponse {
// 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() HttpResponse::Ok()
.content_type(content_type) .content_type(content_type)
.insert_header(("etag", etag)) .insert_header(("etag", etag))
@@ -165,6 +203,7 @@ pub fn create_vercel_compatible_response(
.insert_header(("x-vercel-cache", "HIT")) // для оптимизации Vercel .insert_header(("x-vercel-cache", "HIT")) // для оптимизации Vercel
.insert_header(("x-content-type-options", "nosniff")) .insert_header(("x-content-type-options", "nosniff"))
.body(data) .body(data)
}
} }
// Removed complex ETag caching - Vercel handles caching on their edge // Removed complex ETag caching - Vercel handles caching on their edge
@@ -239,17 +278,21 @@ pub fn handle_system_file(filename: &str) -> Option<HttpResponse> {
match filename.to_lowercase().as_str() { match filename.to_lowercase().as_str() {
"robots.txt" => { "robots.txt" => {
info!("Serving robots.txt for static image server"); info!("Serving robots.txt for static image server");
Some(HttpResponse::Ok() Some(
HttpResponse::Ok()
.content_type("text/plain") .content_type("text/plain")
.insert_header(("access-control-allow-origin", "*")) .insert_header(("access-control-allow-origin", "*"))
.body("User-agent: *\nDisallow: /\n")) .body("User-agent: *\nDisallow: /\n"),
)
} }
"favicon.ico" => { "favicon.ico" => {
info!("Serving favicon.ico (empty)"); info!("Serving favicon.ico (empty)");
Some(HttpResponse::Ok() Some(
HttpResponse::Ok()
.content_type("image/x-icon") .content_type("image/x-icon")
.insert_header(("access-control-allow-origin", "*")) .insert_header(("access-control-allow-origin", "*"))
.body("")) .body(""),
)
} }
"sitemap.xml" => { "sitemap.xml" => {
info!("Serving sitemap.xml (empty)"); info!("Serving sitemap.xml (empty)");
@@ -260,11 +303,13 @@ pub fn handle_system_file(filename: &str) -> Option<HttpResponse> {
} }
"humans.txt" => { "humans.txt" => {
info!("Serving humans.txt"); info!("Serving humans.txt");
Some(HttpResponse::Ok() Some(
HttpResponse::Ok()
.content_type("text/plain") .content_type("text/plain")
.insert_header(("access-control-allow-origin", "*")) .insert_header(("access-control-allow-origin", "*"))
.body("# Static Image Server\n# Powered by Quoter\n")) .body("# Static Image Server\n# Powered by Quoter\n"),
)
} }
_ => None _ => None,
} }
} }

View File

@@ -3,15 +3,17 @@ use actix_web::{HttpRequest, HttpResponse, Result, error::ErrorInternalServerErr
use log::{error, info, warn}; use log::{error, info, warn};
use super::common::{ 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::app_state::AppState;
use crate::handlers::serve_file::serve_file; use crate::handlers::serve_file::serve_file;
use crate::lookup::{find_file_by_pattern, get_mime_type}; 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::s3_utils::{check_file_exists, load_file_from_s3, upload_to_s3};
use crate::thumbnail::{ use crate::thumbnail::{
parse_file_path, is_image_file, generate_webp_thumbnail, cache_thumbnail, cache_thumbnail_to_storj, generate_webp_thumbnail, is_image_file,
load_cached_thumbnail_from_storj, cache_thumbnail_to_storj load_cached_thumbnail, load_cached_thumbnail_from_storj, parse_file_path,
thumbnail_exists_in_storj,
}; };
// Удалена дублирующая функция, используется из common модуля // Удалена дублирующая функция, используется из common модуля
@@ -24,7 +26,10 @@ pub async fn proxy_handler(
state: web::Data<AppState>, state: web::Data<AppState>,
) -> Result<HttpResponse, actix_web::Error> { ) -> Result<HttpResponse, actix_web::Error> {
let start_time = std::time::Instant::now(); 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.) // Проверяем системные файлы (robots.txt, favicon.ico, etc.)
if let Some(response) = handle_system_file(&requested_res) { 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")); return Err(ErrorInternalServerError("Unsupported file format"));
} }
}, },
@@ -145,7 +153,10 @@ pub async fn proxy_handler(
// Проверяем, нужен ли thumbnail // Проверяем, нужен ли thumbnail
if requested_width > 0 && is_image_file(&filekey) { 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 кэша // Пробуем загрузить из Storj кэша
if let Some(cached_thumb) = load_cached_thumbnail_from_storj( if let Some(cached_thumb) = load_cached_thumbnail_from_storj(
@@ -153,9 +164,14 @@ pub async fn proxy_handler(
&state.bucket, &state.bucket,
&base_filename, &base_filename,
requested_width, requested_width,
None None,
).await { )
info!("Serving cached thumbnail from Storj for {}", base_filename); .await
{
info!(
"Serving cached thumbnail from Storj for {}",
base_filename
);
let thumb_content_type = "image/webp"; let thumb_content_type = "image/webp";
if is_vercel_request(&req) { if is_vercel_request(&req) {
@@ -175,11 +191,75 @@ 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 // Генерируем новый thumbnail
match generate_webp_thumbnail(&filedata, requested_width, None) { match generate_webp_thumbnail(&filedata, requested_width, None) {
Ok(thumb_data) => { Ok(thumb_data) => {
info!("Generated thumbnail: {} bytes", thumb_data.len()); 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 // Кэшируем thumbnail в Storj
if let Err(e) = cache_thumbnail_to_storj( if let Err(e) = cache_thumbnail_to_storj(
&state.storj_client, &state.storj_client,
@@ -187,15 +267,18 @@ pub async fn proxy_handler(
&base_filename, &base_filename,
requested_width, requested_width,
None, None,
&thumb_data &thumb_data,
).await { )
.await
{
warn!("Failed to cache thumbnail to Storj: {}", e); warn!("Failed to cache thumbnail to Storj: {}", e);
} }
let thumb_content_type = "image/webp"; let thumb_content_type = "image/webp";
if is_vercel_request(&req) { 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( return Ok(create_vercel_compatible_response(
thumb_content_type, thumb_content_type,
thumb_data, thumb_data,
@@ -206,7 +289,10 @@ pub async fn proxy_handler(
thumb_content_type, thumb_content_type,
thumb_data, thumb_data,
&req, &req,
&format!("{}_{}x.webp", base_filename, requested_width), &format!(
"{}_{}x.webp",
base_filename, requested_width
),
)); ));
} }
} }

View File

@@ -1,19 +1,16 @@
use actix_web::{HttpRequest, HttpResponse, Result, error::ErrorInternalServerError}; use actix_web::{HttpRequest, HttpResponse, Result, error::ErrorInternalServerError};
use mime_guess::MimeGuess;
use log::{info, warn}; use log::{info, warn};
use mime_guess::MimeGuess;
use super::common::{ use super::common::{
create_file_response_with_analytics, create_file_response_with_analytics, create_vercel_compatible_response, is_vercel_request,
create_vercel_compatible_response,
is_vercel_request,
}; };
use crate::app_state::AppState; use crate::app_state::AppState;
use crate::s3_utils::{check_file_exists, load_file_from_s3}; use crate::s3_utils::{check_file_exists, load_file_from_s3};
use crate::thumbnail::{ use crate::thumbnail::{
parse_file_path, is_image_file, generate_webp_thumbnail, cache_thumbnail_to_storj, generate_webp_thumbnail, is_image_file,
load_cached_thumbnail_from_storj, cache_thumbnail_to_storj load_cached_thumbnail_from_storj, parse_file_path, thumbnail_exists_in_storj,
}; };
use md5;
/// Функция для обслуживания файла по заданному пути с поддержкой thumbnail генерации. /// Функция для обслуживания файла по заданному пути с поддержкой thumbnail генерации.
pub async fn serve_file( pub async fn serve_file(
@@ -46,7 +43,10 @@ pub async fn serve_file(
// Проверяем, нужен ли thumbnail // Проверяем, нужен ли thumbnail
if requested_width > 0 && is_image_file(filepath) { 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 кэша // Пробуем загрузить из Storj кэша
if let Some(cached_thumb) = load_cached_thumbnail_from_storj( if let Some(cached_thumb) = load_cached_thumbnail_from_storj(
@@ -54,8 +54,10 @@ pub async fn serve_file(
&state.bucket, &state.bucket,
&base_filename, &base_filename,
requested_width, requested_width,
None None,
).await { )
.await
{
info!("Serving cached thumbnail from Storj for {}", base_filename); info!("Serving cached thumbnail from Storj for {}", base_filename);
let thumb_content_type = "image/webp"; let thumb_content_type = "image/webp";
@@ -76,6 +78,28 @@ 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 // Генерируем новый thumbnail
match generate_webp_thumbnail(&filedata, requested_width, None) { match generate_webp_thumbnail(&filedata, requested_width, None) {
Ok(thumb_data) => { Ok(thumb_data) => {
@@ -88,8 +112,10 @@ pub async fn serve_file(
&base_filename, &base_filename,
requested_width, requested_width,
None, None,
&thumb_data &thumb_data,
).await { )
.await
{
warn!("Failed to cache thumbnail to Storj: {}", e); warn!("Failed to cache thumbnail to Storj: {}", e);
} }

View File

@@ -8,6 +8,7 @@ use crate::auth::{extract_user_id_from_token, user_added_file};
use crate::handlers::MAX_USER_QUOTA_BYTES; use crate::handlers::MAX_USER_QUOTA_BYTES;
use crate::lookup::store_file_info; use crate::lookup::store_file_info;
use crate::s3_utils::{self, generate_key_with_extension, upload_to_s3}; use crate::s3_utils::{self, generate_key_with_extension, upload_to_s3};
use crate::thumbnail::get_image_mime_type;
use futures::TryStreamExt; use futures::TryStreamExt;
// use crate::thumbnail::convert_heic_to_jpeg; // use crate::thumbnail::convert_heic_to_jpeg;
@@ -129,6 +130,23 @@ pub async fn upload_handler(
let filename = format!("{}.{}", uuid::Uuid::new_v4(), extension); let filename = format!("{}.{}", uuid::Uuid::new_v4(), extension);
info!("Generated filename: {}", filename); 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 // Загружаем файл в S3 storj
match upload_to_s3( match upload_to_s3(
&state.storj_client, &state.storj_client,

View File

@@ -39,6 +39,9 @@ async fn main() -> std::io::Result<()> {
}); });
}); });
// Запускаем периодическую очистку кэша
app_state.start_cache_cleanup_task();
// Конфигурация безопасности // Конфигурация безопасности
let security_config = SecurityConfig::default(); let security_config = SecurityConfig::default();
info!( info!(
@@ -48,22 +51,30 @@ async fn main() -> std::io::Result<()> {
); );
HttpServer::new(move || { HttpServer::new(move || {
// Настройка CORS middleware - ограничиваем в продакшене // Настройка CORS middleware - более гибкая для разработки
let cors = Cors::default() let cors = Cors::default()
.allowed_origin("https://discours.io") .allowed_origin("https://discours.io")
.allowed_origin("https://new.discours.io") .allowed_origin("https://new.discours.io")
.allowed_origin("https://testing.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_methods(vec!["GET", "POST", "OPTIONS"])
.allowed_headers(vec![ .allowed_headers(vec![
header::CONTENT_TYPE, header::CONTENT_TYPE,
header::AUTHORIZATION, header::AUTHORIZATION,
header::IF_NONE_MATCH, header::IF_NONE_MATCH,
header::CACHE_CONTROL, 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() .supports_credentials()
.max_age(86400); // 1 день вместо 20 .max_age(86400); // 1 день
// Заголовки безопасности // Заголовки безопасности
let security_headers = DefaultHeaders::new() let security_headers = DefaultHeaders::new()

View File

@@ -1,9 +1,9 @@
use aws_sdk_s3::Client as S3Client;
use image::ImageFormat; use image::ImageFormat;
use std::path::Path; use log::{info, warn};
use std::fs; use std::fs;
use std::io::Write; use std::io::Write;
use log::{info, warn}; use std::path::Path;
use aws_sdk_s3::Client as S3Client;
/// Парсит путь к файлу, извлекая базовое имя, ширину и расширение. /// Парсит путь к файлу, извлекая базовое имя, ширину и расширение.
/// ///
@@ -56,7 +56,11 @@ pub fn generate_thumbnail(
height: Option<u32>, height: Option<u32>,
format: Option<ImageFormat>, format: Option<ImageFormat>,
) -> Result<Vec<u8>, Box<dyn std::error::Error>> { ) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
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 img = image::load_from_memory(image_data)?;
@@ -86,7 +90,18 @@ pub fn generate_webp_thumbnail(
width: u32, width: u32,
height: Option<u32>, height: Option<u32>,
) -> Result<Vec<u8>, Box<dyn std::error::Error>> { ) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
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 для изображения. /// Генерирует JPEG thumbnail для изображения.
@@ -106,7 +121,10 @@ pub fn is_image_file(filename: &str) -> bool {
.map(|s| s.to_lowercase()) .map(|s| s.to_lowercase())
.unwrap_or_default(); .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 тип для изображения. /// Получает MIME тип для изображения.
@@ -236,19 +254,21 @@ pub async fn load_cached_thumbnail_from_storj(
.send() .send()
.await .await
{ {
Ok(response) => { Ok(response) => match response.body.collect().await {
match response.body.collect().await {
Ok(data) => { Ok(data) => {
let bytes = data.into_bytes(); let bytes = data.into_bytes();
info!("Thumbnail loaded from Storj: {} ({} bytes)", thumbnail_key, bytes.len()); info!(
"Thumbnail loaded from Storj: {} ({} bytes)",
thumbnail_key,
bytes.len()
);
Some(bytes.to_vec()) Some(bytes.to_vec())
} }
Err(e) => { Err(e) => {
warn!("Failed to read thumbnail data from Storj: {}", e); warn!("Failed to read thumbnail data from Storj: {}", e);
None None
} }
} },
}
Err(e) => { Err(e) => {
warn!("Failed to load cached thumbnail from Storj: {}", e); warn!("Failed to load cached thumbnail from Storj: {}", e);
None None
@@ -270,16 +290,13 @@ pub async fn thumbnail_exists_in_storj(
format!("thumbnails/{}_{}x.webp", filename, width) format!("thumbnails/{}_{}x.webp", filename, width)
}; };
match s3_client (s3_client
.head_object() .head_object()
.bucket(bucket) .bucket(bucket)
.key(&thumbnail_key) .key(&thumbnail_key)
.send() .send()
.await .await)
{ .is_ok()
Ok(_) => true,
Err(_) => false,
}
} }
/// Очищает старые файлы кэша. /// Очищает старые файлы кэша.
@@ -289,7 +306,8 @@ pub fn cleanup_cache(cache_dir: &str, max_age_days: u64) -> Result<(), Box<dyn s
return Ok(()); return Ok(());
} }
let cutoff_time = std::time::SystemTime::now() - std::time::Duration::from_secs(max_age_days * 24 * 60 * 60); let cutoff_time =
std::time::SystemTime::now() - std::time::Duration::from_secs(max_age_days * 24 * 60 * 60);
for entry in fs::read_dir(cache_path)? { for entry in fs::read_dir(cache_path)? {
let entry = entry?; let entry = entry?;
@@ -359,6 +377,9 @@ mod tests {
assert_eq!(get_image_mime_type("photo.png"), "image/png"); assert_eq!(get_image_mime_type("photo.png"), "image/png");
assert_eq!(get_image_mime_type("animation.gif"), "image/gif"); assert_eq!(get_image_mime_type("animation.gif"), "image/gif");
assert_eq!(get_image_mime_type("modern.webp"), "image/webp"); assert_eq!(get_image_mime_type("modern.webp"), "image/webp");
assert_eq!(get_image_mime_type("unknown.xyz"), "application/octet-stream"); assert_eq!(
get_image_mime_type("unknown.xyz"),
"application/octet-stream"
);
} }
} }

View File

@@ -1,4 +1,4 @@
use redis::{Client, AsyncCommands}; use redis::{AsyncCommands, Client};
use std::env; use std::env;
#[tokio::test] #[tokio::test]
@@ -27,11 +27,21 @@ async fn test_redis_url_parsing() {
for url in test_urls { for url in test_urls {
// Парсим URL для детального вывода // Парсим URL для детального вывода
if let Ok(parsed) = url::Url::parse(url) { if let Ok(parsed) = url::Url::parse(url) {
println!("Testing Redis URL: {}", url.replace(&url.split('@').nth(0).unwrap_or(""), "***")); println!(
"Testing Redis URL: {}",
url.replace(&url.split('@').nth(0).unwrap_or(""), "***")
);
println!(" Host: {}", parsed.host_str().unwrap_or("none")); println!(" Host: {}", parsed.host_str().unwrap_or("none"));
println!(" Port: {}", parsed.port().unwrap_or(0)); println!(" Port: {}", parsed.port().unwrap_or(0));
println!(" Username: '{}'", parsed.username()); println!(" Username: '{}'", parsed.username());
println!(" Password: '{}'", if parsed.password().is_some() { "***" } else { "none" }); println!(
" Password: '{}'",
if parsed.password().is_some() {
"***"
} else {
"none"
}
);
} else { } else {
println!("Testing Redis URL: {}", url); println!("Testing Redis URL: {}", url);
} }
@@ -43,8 +53,10 @@ async fn test_redis_url_parsing() {
// Попробуем подключиться (с коротким таймаутом) // Попробуем подключиться (с коротким таймаутом)
match tokio::time::timeout( match tokio::time::timeout(
std::time::Duration::from_secs(2), std::time::Duration::from_secs(2),
client.get_multiplexed_async_connection() client.get_multiplexed_async_connection(),
).await { )
.await
{
Ok(Ok(_)) => println!("✅ Connection successful"), Ok(Ok(_)) => println!("✅ Connection successful"),
Ok(Err(e)) => println!("❌ Connection failed: {}", e), Ok(Err(e)) => println!("❌ Connection failed: {}", e),
Err(_) => println!("⏰ Connection timeout"), Err(_) => println!("⏰ Connection timeout"),
@@ -62,15 +74,24 @@ async fn test_redis_url_parsing() {
async fn test_redis_connection_with_env() { async fn test_redis_connection_with_env() {
// Тестируем с реальным REDIS_URL из окружения // Тестируем с реальным REDIS_URL из окружения
if let Ok(redis_url) = env::var("REDIS_URL") { if let Ok(redis_url) = env::var("REDIS_URL") {
println!("Testing with real REDIS_URL: {}", println!(
redis_url.replace(&redis_url.split('@').nth(0).unwrap_or(""), "***")); "Testing with real REDIS_URL: {}",
redis_url.replace(&redis_url.split('@').nth(0).unwrap_or(""), "***")
);
// Парсим реальный URL для детального вывода // Парсим реальный URL для детального вывода
if let Ok(parsed) = url::Url::parse(&redis_url) { if let Ok(parsed) = url::Url::parse(&redis_url) {
println!(" Host: {}", parsed.host_str().unwrap_or("none")); println!(" Host: {}", parsed.host_str().unwrap_or("none"));
println!(" Port: {}", parsed.port().unwrap_or(0)); println!(" Port: {}", parsed.port().unwrap_or(0));
println!(" Username: '{}'", parsed.username()); 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) { match Client::open(redis_url) {
@@ -79,16 +100,20 @@ async fn test_redis_connection_with_env() {
match tokio::time::timeout( match tokio::time::timeout(
std::time::Duration::from_secs(5), std::time::Duration::from_secs(5),
client.get_multiplexed_async_connection() client.get_multiplexed_async_connection(),
).await { )
.await
{
Ok(Ok(mut conn)) => { Ok(Ok(mut conn)) => {
println!("✅ Real connection successful"); println!("✅ Real connection successful");
// Попробуем выполнить простую команду // Попробуем выполнить простую команду
match tokio::time::timeout( match tokio::time::timeout(
std::time::Duration::from_secs(2), std::time::Duration::from_secs(2),
conn.ping::<String>() conn.ping::<String>(),
).await { )
.await
{
Ok(Ok(result)) => println!("✅ PING successful: {}", result), Ok(Ok(result)) => println!("✅ PING successful: {}", result),
Ok(Err(e)) => println!("❌ PING failed: {}", e), Ok(Err(e)) => println!("❌ PING failed: {}", e),
Err(_) => println!("⏰ PING timeout"), Err(_) => println!("⏰ PING timeout"),
@@ -118,12 +143,21 @@ fn test_redis_url_components() {
println!(" Host: {}", parsed.host_str().unwrap_or("none")); println!(" Host: {}", parsed.host_str().unwrap_or("none"));
println!(" Port: {}", parsed.port().unwrap_or(0)); println!(" Port: {}", parsed.port().unwrap_or(0));
println!(" Username: '{}'", parsed.username()); 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()); println!(" Path: {}", parsed.path());
// Проверяем, что пустое имя пользователя означает дефолтное 'redis' // Проверяем, что пустое имя пользователя означает дефолтное 'redis'
if parsed.username().is_empty() && parsed.password().is_some() { 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 { } else {
println!("❌ URL parsing failed"); println!("❌ URL parsing failed");
@@ -136,19 +170,35 @@ async fn test_redis_default_username_behavior() {
println!("Testing Redis default username behavior..."); println!("Testing Redis default username behavior...");
let test_cases = vec![ 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", "Empty username and password"),
("redis://localhost:6379", "No authentication"), ("redis://localhost:6379", "No authentication"),
]; ];
for (url, description) in test_cases { for (url, description) in test_cases {
println!("\n--- {} ---", description); 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) { if let Ok(parsed) = url::Url::parse(url) {
println!(" Parsed Username: '{}'", parsed.username()); 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 поведение: // Redis client поведение:
// - Если username пустой, но есть password -> использует дефолтное имя 'redis' // - Если username пустой, но есть password -> использует дефолтное имя 'redis'