0.6.4-thumb-upgrade
This commit is contained in:
51
CHANGELOG.md
51
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
|
## [0.6.1] - 2025-09-02
|
||||||
|
|
||||||
### 🚀 Изменено - Восстановление thumbnail функциональности
|
### 🚀 Изменено - Восстановление thumbnail функциональности
|
||||||
|
|||||||
22
Cargo.lock
generated
22
Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
473
docs/upload-client-guide.md
Normal 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
154
docs/upload-quickstart.md
Normal 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)
|
||||||
@@ -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,14 +62,17 @@ 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
|
||||||
if username.is_empty() && password.is_some() {
|
if username.is_empty() && password.is_some() {
|
||||||
log::warn!(" 🔧 Using password-only authentication (no username)");
|
log::warn!(" 🔧 Using password-only authentication (no username)");
|
||||||
}
|
}
|
||||||
redis_url
|
redis_url
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("❌ Failed to parse Redis URL: {}", e);
|
log::error!("❌ Failed to parse Redis URL: {}", e);
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|
||||||
HttpResponse::Ok()
|
// Add audio streaming headers for audio files
|
||||||
.content_type(content_type)
|
if content_type.starts_with("audio/") {
|
||||||
.insert_header(("cache-control", CACHE_CONTROL_VERCEL))
|
HttpResponse::Ok()
|
||||||
.insert_header(("access-control-allow-origin", cors_origin))
|
.content_type(content_type)
|
||||||
.body(data)
|
.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
|
/// Проверяет, является ли запрос от Vercel Edge API
|
||||||
@@ -157,14 +182,28 @@ pub fn create_vercel_compatible_response(
|
|||||||
data: Vec<u8>,
|
data: Vec<u8>,
|
||||||
etag: &str,
|
etag: &str,
|
||||||
) -> HttpResponse {
|
) -> HttpResponse {
|
||||||
HttpResponse::Ok()
|
// Add audio streaming headers for audio files
|
||||||
.content_type(content_type)
|
if content_type.starts_with("audio/") {
|
||||||
.insert_header(("etag", etag))
|
HttpResponse::Ok()
|
||||||
.insert_header(("cache-control", CACHE_CONTROL_VERCEL))
|
.content_type(content_type)
|
||||||
.insert_header(("access-control-allow-origin", "*"))
|
.insert_header(("etag", etag))
|
||||||
.insert_header(("x-vercel-cache", "HIT")) // для оптимизации Vercel
|
.insert_header(("cache-control", CACHE_CONTROL_VERCEL))
|
||||||
.insert_header(("x-content-type-options", "nosniff"))
|
.insert_header(("access-control-allow-origin", "*"))
|
||||||
.body(data)
|
.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
|
// 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(
|
||||||
.content_type("text/plain")
|
HttpResponse::Ok()
|
||||||
.insert_header(("access-control-allow-origin", "*"))
|
.content_type("text/plain")
|
||||||
.body("User-agent: *\nDisallow: /\n"))
|
.insert_header(("access-control-allow-origin", "*"))
|
||||||
|
.body("User-agent: *\nDisallow: /\n"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
"favicon.ico" => {
|
"favicon.ico" => {
|
||||||
info!("Serving favicon.ico (empty)");
|
info!("Serving favicon.ico (empty)");
|
||||||
Some(HttpResponse::Ok()
|
Some(
|
||||||
.content_type("image/x-icon")
|
HttpResponse::Ok()
|
||||||
.insert_header(("access-control-allow-origin", "*"))
|
.content_type("image/x-icon")
|
||||||
.body(""))
|
.insert_header(("access-control-allow-origin", "*"))
|
||||||
|
.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(
|
||||||
.content_type("text/plain")
|
HttpResponse::Ok()
|
||||||
.insert_header(("access-control-allow-origin", "*"))
|
.content_type("text/plain")
|
||||||
.body("# Static Image Server\n# Powered by Quoter\n"))
|
.insert_header(("access-control-allow-origin", "*"))
|
||||||
|
.body("# Static Image Server\n# Powered by Quoter\n"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
_ => None
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
19
src/main.rs
19
src/main.rs
@@ -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()
|
||||||
|
|||||||
@@ -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!(
|
||||||
info!("Thumbnail loaded from Storj: {} ({} bytes)", thumbnail_key, bytes.len());
|
"Thumbnail loaded from Storj: {} ({} bytes)",
|
||||||
Some(bytes.to_vec())
|
thumbnail_key,
|
||||||
}
|
bytes.len()
|
||||||
Err(e) => {
|
);
|
||||||
warn!("Failed to read thumbnail data from Storj: {}", e);
|
Some(bytes.to_vec())
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
Err(e) => {
|
||||||
|
warn!("Failed to read thumbnail data from Storj: {}", e);
|
||||||
|
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"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"),
|
||||||
@@ -60,17 +72,26 @@ async fn test_redis_url_parsing() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
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'
|
||||||
|
|||||||
Reference in New Issue
Block a user