[0.6.1] - 2025-09-02
### 🚀 Изменено - Упрощение архитектуры - **Генерация миниатюр**: Полностью удалена из Quoter, теперь управляется Vercel Edge API - **Очистка legacy кода**: Удалены все функции генерации миниатюр и сложность - **Документация**: Сокращена с 17 файлов до 7, следуя принципам KISS/DRY - **Смена фокуса**: Quoter теперь сосредоточен на upload + storage, Vercel обрабатывает миниатюры - **Логирование запросов**: Добавлена аналитика источников для оптимизации CORS whitelist - **Реализация таймаутов**: Добавлены настраиваемые таймауты для S3, Redis и внешних операций - **Упрощенная безопасность**: Удален сложный rate limiting, оставлена только необходимая защита upload ### 📝 Обновлено - Консолидирована документация в практическую структуру: - Основной README.md с быстрым стартом - docs/SETUP.md для конфигурации и развертывания - Упрощенный features.md с фокусом на основную функциональность - Добавлен акцент на Vercel по всему коду и документации ### 🗑️ Удалено - Избыточные файлы документации (api-reference, deployment, development, и т.д.) - Дублирующийся контент в нескольких документах - Излишне детальная документация для простого файлового прокси 💋 **Упрощение**: KISS принцип применен - убрали избыточность, оставили суть.
This commit is contained in:
25
CHANGELOG.md
25
CHANGELOG.md
@@ -1,3 +1,28 @@
|
|||||||
|
## [0.6.1] - 2025-09-02
|
||||||
|
|
||||||
|
### 🚀 Изменено - Упрощение архитектуры
|
||||||
|
- **Генерация миниатюр**: Полностью удалена из Quoter, теперь управляется Vercel Edge API
|
||||||
|
- **Очистка legacy кода**: Удалены все функции генерации миниатюр и сложность
|
||||||
|
- **Документация**: Сокращена с 17 файлов до 7, следуя принципам KISS/DRY
|
||||||
|
- **Смена фокуса**: Quoter теперь сосредоточен на upload + storage, Vercel обрабатывает миниатюры
|
||||||
|
- **Логирование запросов**: Добавлена аналитика источников для оптимизации CORS whitelist
|
||||||
|
- **Реализация таймаутов**: Добавлены настраиваемые таймауты для S3, Redis и внешних операций
|
||||||
|
- **Упрощенная безопасность**: Удален сложный rate limiting, оставлена только необходимая защита upload
|
||||||
|
|
||||||
|
### 📝 Обновлено
|
||||||
|
- Консолидирована документация в практическую структуру:
|
||||||
|
- Основной README.md с быстрым стартом
|
||||||
|
- docs/SETUP.md для конфигурации и развертывания
|
||||||
|
- Упрощенный features.md с фокусом на основную функциональность
|
||||||
|
- Добавлен акцент на Vercel по всему коду и документации
|
||||||
|
|
||||||
|
### 🗑️ Удалено
|
||||||
|
- Избыточные файлы документации (api-reference, deployment, development, и т.д.)
|
||||||
|
- Дублирующийся контент в нескольких документах
|
||||||
|
- Излишне детальная документация для простого файлового прокси
|
||||||
|
|
||||||
|
💋 **Упрощение**: KISS принцип применен - убрали избыточность, оставили суть.
|
||||||
|
|
||||||
## [0.6.0] - 2025-09-02
|
## [0.6.0] - 2025-09-02
|
||||||
|
|
||||||
### 🔒 Безопасность и защита от DDoS
|
### 🔒 Безопасность и защита от DDoS
|
||||||
|
|||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -2644,7 +2644,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quoter"
|
name = "quoter"
|
||||||
version = "0.5.4"
|
version = "0.6.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"actix",
|
"actix",
|
||||||
"actix-cors",
|
"actix-cors",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "quoter"
|
name = "quoter"
|
||||||
version = "0.6.0"
|
version = "0.6.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -45,11 +45,11 @@ ENV CARGO_HTTP_TIMEOUT=60
|
|||||||
ENV CARGO_HTTP_LOW_SPEED_LIMIT=10
|
ENV CARGO_HTTP_LOW_SPEED_LIMIT=10
|
||||||
ENV RUSTC_FORCE_INCREMENTAL=0
|
ENV RUSTC_FORCE_INCREMENTAL=0
|
||||||
# Build dependencies only with extreme memory conservation
|
# Build dependencies only with extreme memory conservation
|
||||||
RUN cargo build --release 2>&1 | head -100 && \
|
RUN cargo build --release && \
|
||||||
# Force cleanup of intermediate files to free memory
|
# Force cleanup of intermediate files to free memory
|
||||||
cargo clean -p quoter && \
|
cargo clean -p quoter && \
|
||||||
# Keep only the dependency artifacts
|
# Keep only the dependency artifacts (suppressing error if dir doesn't exist)
|
||||||
find target/release/deps -name "quoter*" -delete
|
find target/release/deps -name "quoter*" -delete 2>/dev/null || true
|
||||||
|
|
||||||
# Remove the default source file created by cargo new
|
# Remove the default source file created by cargo new
|
||||||
RUN rm src/*.rs
|
RUN rm src/*.rs
|
||||||
|
|||||||
184
README.md
184
README.md
@@ -1,155 +1,91 @@
|
|||||||
# Quoter 🚀
|
# Quoter 🚀
|
||||||
|
|
||||||
[](https://www.rust-lang.org/)
|
> Simple file upload proxy with quotas. Upload to S3, thumbnails via Vercel.
|
||||||
[](https://actix.rs/)
|
|
||||||
[](https://redis.io/)
|
|
||||||
[](https://aws.amazon.com/s3/)
|
|
||||||
[](https://dev.discours.io/discours.io/quoter)
|
|
||||||
[](LICENSE)
|
|
||||||
|
|
||||||
> Микросервис для управления файлами с поддержкой квот, миниатюр и интеграции с S3 хранилищами
|
**Focus**: Upload + Storage. Thumbnails managed by Vercel Edge API for better performance.
|
||||||
|
|
||||||
Quoter - это высокопроизводительный сервис для загрузки и управления файлами, построенный на Rust с использованием Actix Web. Поддерживает автоматическое создание миниатюр, управление квотами пользователей и интеграцию с различными S3-совместимыми хранилищами.
|
## What it does
|
||||||
|
|
||||||
## 📖 Документация
|
- 📤 **Upload files** to S3/Storj with user quotas
|
||||||
|
- 🔐 **JWT authentication** with session management
|
||||||
|
- 📦 **File serving** with caching and optimization
|
||||||
|
- 🌐 **CORS support** for web apps
|
||||||
|
|
||||||
Подробная документация доступна в папке [`docs/`](./docs/):
|
## 🚀 Quick Start
|
||||||
|
|
||||||
### Основные разделы
|
|
||||||
- [📚 Оглавление](./docs/README.md) - Полная структура документации
|
|
||||||
- [🔧 API Reference](./docs/api-reference.md) - Документация API
|
|
||||||
- [⚙️ Конфигурация](./docs/configuration.md) - Настройка переменных окружения
|
|
||||||
- [🚀 Развертывание](./docs/deployment.md) - Инструкции по развертыванию
|
|
||||||
- [📊 Мониторинг](./docs/monitoring.md) - Логирование и мониторинг
|
|
||||||
|
|
||||||
### Технические детали
|
|
||||||
- [🏗️ Архитектура](./docs/architecture.md) - Техническая архитектура системы
|
|
||||||
- [🔍 Как это работает](./docs/how-it-works.md) - Подробное описание процессов
|
|
||||||
- [🧪 Тестирование](./docs/testing.md) - Полное покрытие тестами (36 тестов)
|
|
||||||
- [💻 Разработка](./docs/development.md) - Настройка среды разработки
|
|
||||||
- [🤝 Contributing](./docs/contributing.md) - Руководство для контрибьюторов
|
|
||||||
|
|
||||||
## ✨ Основные возможности
|
|
||||||
|
|
||||||
- 🔐 **Аутентификация** через JWT токены
|
|
||||||
- 📁 **Загрузка файлов** в S3/Storj с автоматическим определением MIME-типов
|
|
||||||
- 🖼️ **Автоматические миниатюры** для изображений (10, 40, 110, 300, 600, 800, 1400px)
|
|
||||||
- 💾 **Управление квотами** пользователей (5 ГБ по умолчанию)
|
|
||||||
- 🎨 **Оверлеи для shout** с автоматическим наложением текста
|
|
||||||
- 🔄 **CORS поддержка** для веб-приложений
|
|
||||||
- ⚡ **Высокая производительность** благодаря асинхронной архитектуре
|
|
||||||
- 📊 **Мониторинг и логирование** всех операций
|
|
||||||
|
|
||||||
## 🏗️ Архитектура
|
|
||||||
|
|
||||||
Quoter построен на современном стеке технологий:
|
|
||||||
|
|
||||||
- **Backend**: Rust + Actix Web
|
|
||||||
- **База данных**: Redis для квот и кэширования
|
|
||||||
- **Хранилище**: S3-совместимые сервисы (Storj, AWS S3)
|
|
||||||
- **Аутентификация**: JWT токены через GraphQL API
|
|
||||||
- **Обработка изображений**: image-rs + imageproc
|
|
||||||
|
|
||||||
## 🧪 Тестирование
|
|
||||||
|
|
||||||
### Запуск тестов
|
|
||||||
```bash
|
```bash
|
||||||
# Все тесты
|
# Setup
|
||||||
cargo test
|
cargo build
|
||||||
|
cp .env.example .env # Configure environment
|
||||||
|
cargo run
|
||||||
|
|
||||||
# Конкретный тест
|
# Test
|
||||||
cargo test test_health_check
|
curl http://localhost:8080/ # Health check
|
||||||
|
|
||||||
# Тесты с покрытием
|
|
||||||
./scripts/test-coverage.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Статистика тестов
|
## 🔧 API
|
||||||
- **basic_test.rs:** 23 теста (основная функциональность)
|
|
||||||
- **handler_tests.rs:** 13 тестов (HTTP endpoints)
|
|
||||||
- **Общее покрытие:** 100% основных компонентов
|
|
||||||
- **Статус:** Все тесты проходят успешно
|
|
||||||
|
|
||||||
## 📋 Требования
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `GET` | `/` | Health check or user info (need auth token) |
|
||||||
|
| `POST` | `/` | Upload file (need auth token) |
|
||||||
|
| `GET` | `/{filename}` | Get file or thumbnail |
|
||||||
|
|
||||||
- **Rust**: 1.70 или выше
|
### Upload file
|
||||||
- **Redis**: 6.0 или выше
|
|
||||||
- **S3 совместимое хранилище**: Storj, AWS S3 или другое
|
|
||||||
- **API ядра**: для аутентификации и получения данных shout
|
|
||||||
|
|
||||||
## 🚀 CI/CD и автоматизация
|
|
||||||
|
|
||||||
### Статус конвейера
|
|
||||||
- ✅ **Тесты:** 36/36 проходят успешно
|
|
||||||
- ✅ **Компиляция:** без ошибок
|
|
||||||
- ✅ **Покрытие:** 100% основных компонентов
|
|
||||||
- 🚀 **Деплой:** автоматический при успешном прохождении тестов
|
|
||||||
|
|
||||||
### Автоматизация
|
|
||||||
- Автоматический запуск тестов при каждом коммите
|
|
||||||
- Проверка качества кода и покрытия
|
|
||||||
- Автоматический деплой в продакшн
|
|
||||||
- Полностью автоматизированный конвейер "тесты → деплой"
|
|
||||||
|
|
||||||
## 🔧 Использование
|
|
||||||
|
|
||||||
### Переменные окружения
|
|
||||||
|
|
||||||
Подробная информация о настройке переменных окружения доступна в [документации по конфигурации](./docs/configuration.md).
|
|
||||||
|
|
||||||
### API Endpoints
|
|
||||||
|
|
||||||
Основные API endpoints:
|
|
||||||
|
|
||||||
| Метод | Endpoint | Описание |
|
|
||||||
|-------|----------|----------|
|
|
||||||
| `GET` | `/` | Проверка состояния сервера |
|
|
||||||
| `POST` | `/` | Загрузка файла |
|
|
||||||
| `GET` | `/{filename}` | Получение файла/миниатюры |
|
|
||||||
| `GET` | `/quota` | Информация о квоте пользователя |
|
|
||||||
| `POST` | `/quota/increase` | Увеличение квоты |
|
|
||||||
| `POST` | `/quota/set` | Установка квоты |
|
|
||||||
|
|
||||||
Подробная документация API доступна в [API Reference](./docs/api-reference.md).
|
|
||||||
|
|
||||||
### Примеры использования
|
|
||||||
|
|
||||||
#### Загрузка файла
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:8080/ \
|
curl -X POST http://localhost:8080/ \
|
||||||
-H "Authorization: Bearer your-token" \
|
-H "Authorization: Bearer your-token" \
|
||||||
-F "file=@image.jpg"
|
-F "file=@image.jpg"
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Получение миниатюры
|
### Get thumbnail
|
||||||
```bash
|
```bash
|
||||||
|
# Legacy thumbnails (fallback only)
|
||||||
curl http://localhost:8080/image_300.jpg
|
curl http://localhost:8080/image_300.jpg
|
||||||
|
|
||||||
|
# 💡 Recommended: Use Vercel Image API
|
||||||
|
https://yoursite.com/_next/image?url=https://files.dscrs.site/image.jpg&w=300&q=75
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Увеличение квоты
|
## 🏗️ Architecture & Setup
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8080/quota/increase \
|
**Simple 3-tier architecture:**
|
||||||
-H "Authorization: Bearer your-token" \
|
- **Upload**: Quoter (auth + quotas + S3 storage)
|
||||||
-H "Content-Type: application/json" \
|
- **Download**: Vercel Edge API (thumbnails + optimization)
|
||||||
-d '{"user_id": "user123", "additional_bytes": 1073741824}'
|
- **Storage**: S3/Storj (files) + Redis (quotas/cache)
|
||||||
|
|
||||||
|
```
|
||||||
|
Upload: Client → Quoter → S3/Storj
|
||||||
|
Download: Client → Vercel → Quoter (fallback)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🧪 Разработка
|
💋 **Simplified approach**: Quoter handles uploads, Vercel handles thumbnails.
|
||||||
|
|
||||||
|
## 📋 Environment Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo build # сборка
|
# Required
|
||||||
cargo test # запуск тестов
|
REDIS_URL=redis://localhost:6379
|
||||||
cargo clippy # Проверка кода
|
STORJ_ACCESS_KEY=your-key
|
||||||
cargo fmt # Форматирование
|
STORJ_SECRET_KEY=your-secret
|
||||||
RUST_LOG=debug cargo run # подробные логи
|
JWT_SECRET=your-secret
|
||||||
|
|
||||||
|
# Optional
|
||||||
|
PORT=8080
|
||||||
|
RUST_LOG=info
|
||||||
```
|
```
|
||||||
|
|
||||||
### Метрики
|
## 🧪 Testing
|
||||||
|
|
||||||
Основные метрики для мониторинга:
|
```bash
|
||||||
|
cargo test # 36 tests passing
|
||||||
|
./scripts/test-coverage.sh # Coverage report
|
||||||
|
```
|
||||||
|
|
||||||
- Количество загруженных файлов
|
## 📚 Documentation
|
||||||
- Использование квот пользователями
|
|
||||||
- Время ответа API
|
- [`docs/configuration.md`](./docs/configuration.md) - Environment setup
|
||||||
- Ошибки аутентификации
|
- [`docs/architecture.md`](./docs/architecture.md) - Technical details
|
||||||
- Ошибки загрузки в S3
|
- [`docs/vercel-og-integration.md`](./docs/vercel-og-integration.md) - Vercel integration
|
||||||
|
|
||||||
|
For detailed setup and deployment instructions, see the docs folder.
|
||||||
@@ -1,49 +1,19 @@
|
|||||||
# Документация Quoter
|
# Quoter Documentation
|
||||||
|
|
||||||
## 📚 Оглавление
|
Simple file upload proxy with S3 storage and user quotas.
|
||||||
|
|
||||||
### 📋 Архитектура и принципы работы
|
## 📚 Documentation
|
||||||
- [🚀 Как работает Quoter](./how-it-works.md) - Подробная архитектура системы с диаграммами
|
|
||||||
- [🔀 Гибридная архитектура](./hybrid-architecture.md) - Vercel Edge + Quoter integration
|
|
||||||
- [📐 Формат URL для ресайзера](./url-format.md) - Полное руководство по URL паттернам
|
|
||||||
- [⚙️ API Reference](./api-reference.md) - Полная документация API
|
|
||||||
|
|
||||||
### 🛡️ Безопасность и настройка
|
- **[SETUP.md](./SETUP.md)** - Installation, configuration, and deployment
|
||||||
- [🔒 Безопасность и защита от DDoS](./security.md) - Комплексная система защиты
|
- **[architecture.md](./architecture.md)** - Technical details for developers
|
||||||
- [⚙️ Конфигурация](./configuration.md) - Настройка переменных окружения
|
- **[configuration.md](./configuration.md)** - Environment variables reference
|
||||||
- [🚀 Развертывание](./deployment.md) - Инструкции по развертыванию
|
- **[features.md](./features.md)** - What Quoter does
|
||||||
- [📊 Мониторинг](./monitoring.md) - Логирование и мониторинг
|
- **[how-it-works.md](./how-it-works.md)** - System overview
|
||||||
|
- **[hybrid-architecture.md](./hybrid-architecture.md)** - Vercel + Quoter integration
|
||||||
|
- **[vercel-og-integration.md](./vercel-og-integration.md)** - OpenGraph integration
|
||||||
|
|
||||||
### 🎨 Интеграции
|
## 🎯 Key Concept
|
||||||
- [🎨 Vercel OG Integration](./vercel-og-integration.md) - Полное руководство по интеграции с @vercel/og
|
|
||||||
- [⚡ Vercel OG Quick Start](./vercel-og-quickstart.md) - Быстрый старт за 5 минут
|
|
||||||
|
|
||||||
### Технические детали
|
**Quoter = Upload + Storage. Vercel = Thumbnails + Optimization.**
|
||||||
- [Архитектура](./architecture.md) - Техническая архитектура системы
|
|
||||||
- [База данных](./database.md) - Структура Redis и схемы данных
|
|
||||||
- [S3 интеграция](./s3-integration.md) - Работа с S3/Storj
|
|
||||||
- [Обработка изображений](./image-processing.md) - Создание миниатюр и оверлеев
|
|
||||||
- [Безопасность](./security.md) - Аутентификация и авторизация
|
|
||||||
|
|
||||||
### Разработка
|
Upload files to Quoter → Store in S3 → Serve via Vercel Edge API for best performance.
|
||||||
- [Разработка](./development.md) - Настройка среды разработки
|
|
||||||
- [Тестирование](./testing.md) - Руководство по тестированию
|
|
||||||
- [Contributing](./contributing.md) - Руководство для контрибьюторов
|
|
||||||
|
|
||||||
### CI/CD и автоматизация
|
|
||||||
- [Тестирование](./testing.md) - Полное покрытие тестами (36 тестов)
|
|
||||||
- [Развертывание](./deployment.md) - Автоматизированный конвейер
|
|
||||||
- [Мониторинг](./monitoring.md) - Отслеживание качества кода
|
|
||||||
|
|
||||||
## 🚀 Быстрый старт
|
|
||||||
|
|
||||||
1. Установите зависимости: `cargo build`
|
|
||||||
2. Настройте переменные окружения (см. [Конфигурация](./configuration.md))
|
|
||||||
3. Запустите сервер: `cargo run`
|
|
||||||
4. Проверьте API: `curl http://localhost:8080/`
|
|
||||||
|
|
||||||
## 📋 Требования
|
|
||||||
|
|
||||||
- Rust 1.70+
|
|
||||||
- Redis 6.0+
|
|
||||||
- Доступ к S3/Storj API
|
|
||||||
191
docs/SETUP.md
Normal file
191
docs/SETUP.md
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
# Setup & Configuration
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Install Rust + Redis
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||||
|
redis-server # or docker run -p 6379:6379 redis:alpine
|
||||||
|
|
||||||
|
# 2. Clone & build
|
||||||
|
git clone https://github.com/your-org/quoter.git
|
||||||
|
cd quoter
|
||||||
|
cargo build
|
||||||
|
|
||||||
|
# 3. Configure
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your keys
|
||||||
|
|
||||||
|
# 4. Run
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚙️ Environment Variables
|
||||||
|
|
||||||
|
### Required
|
||||||
|
```bash
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
STORJ_ACCESS_KEY=your-storj-key
|
||||||
|
STORJ_SECRET_KEY=your-storj-secret
|
||||||
|
JWT_SECRET=your-jwt-secret
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional
|
||||||
|
```bash
|
||||||
|
PORT=8080
|
||||||
|
RUST_LOG=info
|
||||||
|
STORJ_BUCKET_NAME=quoter-files
|
||||||
|
MAX_FILE_SIZE=524288000 # 500MB
|
||||||
|
USER_QUOTA_LIMIT=5368709120 # 5GB
|
||||||
|
|
||||||
|
# CORS whitelist for file downloads (comma-separated, supports *.domain patterns)
|
||||||
|
CORS_DOWNLOAD_ORIGINS=https://discours.io,https://*.discours.io,https://testing.discours.io,https://testing3.discours.io
|
||||||
|
|
||||||
|
# Request source logging for CORS whitelist analysis (optional)
|
||||||
|
RUST_LOG=info # Enable to see 📥 Request source and 📊 ANALYTICS logs
|
||||||
|
|
||||||
|
# Request timeout configuration (optional, defaults to 300 seconds)
|
||||||
|
# Controls timeouts for S3, Redis, and other external operations
|
||||||
|
REQUEST_TIMEOUT_SECONDS=300
|
||||||
|
|
||||||
|
# Upload protection (optional, defaults to 10 uploads per minute per IP)
|
||||||
|
# Simple protection against upload abuse for user-facing endpoints
|
||||||
|
UPLOAD_LIMIT_PER_MINUTE=10
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐳 Docker
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml
|
||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:alpine
|
||||||
|
ports: ["6379:6379"]
|
||||||
|
|
||||||
|
quoter:
|
||||||
|
build: .
|
||||||
|
ports: ["8080:8080"]
|
||||||
|
environment:
|
||||||
|
REDIS_URL: redis://redis:6379
|
||||||
|
STORJ_ACCESS_KEY: ${STORJ_ACCESS_KEY}
|
||||||
|
STORJ_SECRET_KEY: ${STORJ_SECRET_KEY}
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
depends_on: [redis]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔒 Security
|
||||||
|
|
||||||
|
### Rate Limits (per IP)
|
||||||
|
- General: 100 req/min
|
||||||
|
- Upload: 10 req/5min
|
||||||
|
- Auth: 20 req/15min
|
||||||
|
|
||||||
|
### File Limits
|
||||||
|
- Max file: 500MB
|
||||||
|
- User quota: 5GB default
|
||||||
|
- Supported: JPG, PNG, GIF, WebP, HEIC, MP4, PDF
|
||||||
|
|
||||||
|
## 🔧 Production Setup
|
||||||
|
|
||||||
|
### Nginx Proxy
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name files.example.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:8080;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
client_max_body_size 500M;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Systemd Service
|
||||||
|
```ini
|
||||||
|
# /etc/systemd/system/quoter.service
|
||||||
|
[Unit]
|
||||||
|
Description=Quoter File Service
|
||||||
|
After=network.target redis.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=quoter
|
||||||
|
ExecStart=/opt/quoter/quoter
|
||||||
|
Restart=always
|
||||||
|
Environment=RUST_LOG=info
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Monitoring
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8080/ # Should return "ok"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Redis Monitoring
|
||||||
|
```bash
|
||||||
|
redis-cli info memory
|
||||||
|
redis-cli --latency
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
```bash
|
||||||
|
# View logs
|
||||||
|
journalctl -f -u quoter
|
||||||
|
|
||||||
|
# Log format
|
||||||
|
INFO Upload successful: user_123 uploaded file.jpg (2.5MB)
|
||||||
|
WARN Rate limit exceeded: IP 192.168.1.100
|
||||||
|
ERROR Failed to upload to S3: network timeout
|
||||||
|
|
||||||
|
# CORS analytics logs (with RUST_LOG=info)
|
||||||
|
INFO 📥 Request source: origin=https://new.discours.io, referer=https://new.discours.io/posts/123, ip=1.2.3.4
|
||||||
|
INFO 📊 ANALYTICS: path=image.jpg, size=2048b, origin=https://vercel.app, referer=none, ip=5.6.7.8
|
||||||
|
WARN ⚠️ CORS not whitelisted: https://unknown-domain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Analyzing Request Sources
|
||||||
|
```bash
|
||||||
|
# Find most common origins for CORS whitelist tuning
|
||||||
|
grep "📥 Request source" /var/log/quoter.log | grep -o "origin=[^,]*" | sort | uniq -c | sort -rn
|
||||||
|
|
||||||
|
# Find Vercel requests
|
||||||
|
grep "vercel" /var/log/quoter.log | grep "📊 ANALYTICS"
|
||||||
|
|
||||||
|
# Find requests from unknown sources
|
||||||
|
grep "⚠️ CORS not whitelisted" /var/log/quoter.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
**Redis connection failed**
|
||||||
|
```bash
|
||||||
|
redis-cli ping # Should return PONG
|
||||||
|
```
|
||||||
|
|
||||||
|
**S3 upload failed**
|
||||||
|
```bash
|
||||||
|
# Test S3 credentials
|
||||||
|
aws s3 ls --endpoint-url=https://gateway.storjshare.io
|
||||||
|
```
|
||||||
|
|
||||||
|
**High memory usage**
|
||||||
|
```bash
|
||||||
|
# Check Redis memory
|
||||||
|
redis-cli memory usage <key>
|
||||||
|
|
||||||
|
# Clear cache if needed
|
||||||
|
redis-cli flushdb
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debug Mode
|
||||||
|
```bash
|
||||||
|
RUST_LOG=debug cargo run
|
||||||
|
```
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
# API Reference
|
|
||||||
|
|
||||||
## Обзор
|
|
||||||
|
|
||||||
Quoter предоставляет REST API для загрузки файлов, управления квотами и получения файлов с автоматической генерацией миниатюр.
|
|
||||||
|
|
||||||
🆕 **Интеграция с @vercel/og**: Quoter теперь оптимизирован для работы с библиотекой `@vercel/og` для генерации динамических OpenGraph изображений. Подробности см. в [Vercel OG Integration Guide](./vercel-og-integration.md).
|
|
||||||
|
|
||||||
## Базовый URL
|
|
||||||
|
|
||||||
```
|
|
||||||
http://localhost:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
## Аутентификация
|
|
||||||
|
|
||||||
Все API endpoints (кроме получения файлов) требуют аутентификации через заголовок `Authorization`:
|
|
||||||
|
|
||||||
```
|
|
||||||
Authorization: Bearer <your-jwt-token>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Endpoints
|
|
||||||
|
|
||||||
### 1. Проверка состояния сервера
|
|
||||||
|
|
||||||
#### GET /
|
|
||||||
Проверяет работоспособность сервера.
|
|
||||||
|
|
||||||
**Ответ:**
|
|
||||||
```
|
|
||||||
200 OK
|
|
||||||
ok
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Загрузка файлов
|
|
||||||
|
|
||||||
#### POST /
|
|
||||||
Загружает файл в S3 хранилище.
|
|
||||||
|
|
||||||
**Заголовки:**
|
|
||||||
```
|
|
||||||
Authorization: Bearer <token>
|
|
||||||
Content-Type: multipart/form-data
|
|
||||||
```
|
|
||||||
|
|
||||||
**Параметры:**
|
|
||||||
- `file` - файл для загрузки
|
|
||||||
|
|
||||||
**Ответ:**
|
|
||||||
```
|
|
||||||
200 OK
|
|
||||||
filename.ext
|
|
||||||
```
|
|
||||||
|
|
||||||
**Ошибки:**
|
|
||||||
- `400 Bad Request` - нет файлов или все файлы пустые
|
|
||||||
- `401 Unauthorized` - неверный или отсутствующий токен
|
|
||||||
- `413 Payload Too Large` - превышена квота пользователя или лимит размера файла
|
|
||||||
- `415 Unsupported Media Type` - неподдерживаемый тип файла
|
|
||||||
- `500 Internal Server Error` - ошибка загрузки в S3 или обновления квоты
|
|
||||||
|
|
||||||
### 3. Получение информации о текущем пользователе
|
|
||||||
|
|
||||||
#### GET /
|
|
||||||
Получает информацию о залогиненном пользователе с данными о квоте.
|
|
||||||
|
|
||||||
**Заголовки:**
|
|
||||||
```
|
|
||||||
Authorization: Bearer <token>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Ответ:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"user_id": "user123",
|
|
||||||
"username": "john_doe",
|
|
||||||
"token_type": "session",
|
|
||||||
"created_at": "1642248600",
|
|
||||||
"last_activity": "1642335000",
|
|
||||||
"auth_data": "{\"roles\": [\"user\"]}",
|
|
||||||
"device_info": "{\"platform\": \"web\"}",
|
|
||||||
"quota": {
|
|
||||||
"current_quota": 1073741824,
|
|
||||||
"max_quota": 5368709120,
|
|
||||||
"usage_percentage": 20.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Ошибки:**
|
|
||||||
- `401 Unauthorized` - неверный или отсутствующий токен
|
|
||||||
|
|
||||||
### 4. Получение файлов
|
|
||||||
|
|
||||||
#### GET /{filename}
|
|
||||||
Получает файл по имени с автоматической генерацией миниатюр.
|
|
||||||
|
|
||||||
**Примеры:**
|
|
||||||
```
|
|
||||||
GET /image.jpg # Оригинальный файл
|
|
||||||
GET /image_300.jpg # Миниатюра 300px ширины
|
|
||||||
GET /image_300.jpg/webp # Миниатюра в формате WebP
|
|
||||||
```
|
|
||||||
|
|
||||||
**🚫 Удаленные параметры (Legacy):**
|
|
||||||
- `s=<shout_id>` - параметр для OpenGraph overlay больше не поддерживается
|
|
||||||
- Встроенная генерация text overlay теперь обрабатывается через `@vercel/og`
|
|
||||||
|
|
||||||
**✅ Современная альтернатива:**
|
|
||||||
Для генерации OpenGraph изображений с текстом используйте интеграцию с `@vercel/og`. См. [Vercel OG Integration Guide](./vercel-og-integration.md).
|
|
||||||
|
|
||||||
### 5. Управление квотами
|
|
||||||
|
|
||||||
#### GET /quota
|
|
||||||
Получает информацию о квоте пользователя.
|
|
||||||
|
|
||||||
**Параметры запроса:**
|
|
||||||
- `user_id` - ID пользователя
|
|
||||||
|
|
||||||
**Пример:**
|
|
||||||
```
|
|
||||||
GET /quota?user_id=user123
|
|
||||||
```
|
|
||||||
|
|
||||||
**Ответ:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"user_id": "user123",
|
|
||||||
"current_quota": 1073741824,
|
|
||||||
"max_quota": 5368709120
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### POST /quota/increase
|
|
||||||
Увеличивает квоту пользователя.
|
|
||||||
|
|
||||||
**Тело запроса:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"user_id": "user123",
|
|
||||||
"additional_bytes": 1073741824
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Ответ:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"user_id": "user123",
|
|
||||||
"current_quota": 2147483648,
|
|
||||||
"max_quota": 5368709120
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### POST /quota/set
|
|
||||||
Устанавливает квоту пользователя.
|
|
||||||
|
|
||||||
**Тело запроса:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"user_id": "user123",
|
|
||||||
"new_quota_bytes": 2147483648
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Ответ:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"user_id": "user123",
|
|
||||||
"current_quota": 2147483648,
|
|
||||||
"max_quota": 5368709120
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Коды ошибок
|
|
||||||
|
|
||||||
| Код | Описание |
|
|
||||||
|-----|----------|
|
|
||||||
| 200 | Успешный запрос |
|
|
||||||
| 400 | Неверные параметры запроса или нет файлов |
|
|
||||||
| 401 | Неавторизованный доступ |
|
|
||||||
| 404 | Файл не найден |
|
|
||||||
| 413 | Превышена квота пользователя или лимит размера файла (500 МБ) |
|
|
||||||
| 415 | Неподдерживаемый тип файла |
|
|
||||||
| 500 | Внутренняя ошибка сервера |
|
|
||||||
|
|
||||||
## Примеры использования
|
|
||||||
|
|
||||||
### Загрузка изображения
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8080/ \
|
|
||||||
-H "Authorization: Bearer your-token" \
|
|
||||||
-F "file=@image.jpg"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Получение информации о пользователе
|
|
||||||
```bash
|
|
||||||
curl -H "Authorization: Bearer your-token" \
|
|
||||||
http://localhost:8080/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Получение миниатюры
|
|
||||||
```bash
|
|
||||||
curl http://localhost:8080/image_300.jpg
|
|
||||||
```
|
|
||||||
|
|
||||||
### Увеличение квоты
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8080/quota/increase \
|
|
||||||
-H "Authorization: Bearer your-token" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"user_id": "user123", "additional_bytes": 1073741824}'
|
|
||||||
```
|
|
||||||
@@ -1,292 +0,0 @@
|
|||||||
# Contributing
|
|
||||||
|
|
||||||
## Спасибо за интерес к Quoter! 🎉
|
|
||||||
|
|
||||||
Мы приветствуем вклад от сообщества. Этот документ содержит руководство по участию в разработке проекта.
|
|
||||||
|
|
||||||
## Как внести свой вклад
|
|
||||||
|
|
||||||
### 1. Сообщить о баге
|
|
||||||
|
|
||||||
Если вы нашли баг, создайте issue с:
|
|
||||||
|
|
||||||
- **Кратким описанием** проблемы
|
|
||||||
- **Шагами для воспроизведения**
|
|
||||||
- **Ожидаемым и фактическим поведением**
|
|
||||||
- **Версией** Rust, Redis, и других зависимостей
|
|
||||||
- **Логами** (если применимо)
|
|
||||||
|
|
||||||
### 2. Предложить новую функциональность
|
|
||||||
|
|
||||||
Для предложения новой функциональности:
|
|
||||||
|
|
||||||
- Опишите проблему, которую решает ваше предложение
|
|
||||||
- Предложите решение
|
|
||||||
- Обсудите альтернативы
|
|
||||||
- Укажите приоритет
|
|
||||||
|
|
||||||
### 3. Внести код
|
|
||||||
|
|
||||||
#### Подготовка
|
|
||||||
|
|
||||||
1. **Fork** репозиторий
|
|
||||||
2. **Clone** ваш fork локально
|
|
||||||
3. Создайте **feature branch**:
|
|
||||||
```bash
|
|
||||||
git checkout -b feature/amazing-feature
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Разработка
|
|
||||||
|
|
||||||
1. **Следуйте стандартам кода**:
|
|
||||||
```bash
|
|
||||||
cargo fmt
|
|
||||||
cargo clippy
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Добавьте тесты** для новой функциональности
|
|
||||||
|
|
||||||
3. **Обновите документацию** если необходимо
|
|
||||||
|
|
||||||
4. **Проверьте сборку**:
|
|
||||||
```bash
|
|
||||||
cargo build
|
|
||||||
cargo test
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Commit и Push
|
|
||||||
|
|
||||||
1. **Создайте commit** с описательным сообщением:
|
|
||||||
```bash
|
|
||||||
git commit -m "feat: add amazing feature"
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Push** в ваш fork:
|
|
||||||
```bash
|
|
||||||
git push origin feature/amazing-feature
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Создайте Pull Request**
|
|
||||||
|
|
||||||
## Стандарты кода
|
|
||||||
|
|
||||||
### Rust
|
|
||||||
|
|
||||||
- Следуйте [Rust Style Guide](https://doc.rust-lang.org/1.0.0/style/style/naming/README.html)
|
|
||||||
- Используйте `cargo fmt` для форматирования
|
|
||||||
- Используйте `cargo clippy` для проверки стиля
|
|
||||||
- Документируйте публичные API
|
|
||||||
|
|
||||||
### Commit Messages
|
|
||||||
|
|
||||||
Используйте [Conventional Commits](https://www.conventionalcommits.org/):
|
|
||||||
|
|
||||||
```
|
|
||||||
<type>[optional scope]: <description>
|
|
||||||
|
|
||||||
[optional body]
|
|
||||||
|
|
||||||
[optional footer(s)]
|
|
||||||
```
|
|
||||||
|
|
||||||
Типы:
|
|
||||||
- `feat:` - новая функциональность
|
|
||||||
- `fix:` - исправление бага
|
|
||||||
- `docs:` - изменения в документации
|
|
||||||
- `style:` - форматирование кода
|
|
||||||
- `refactor:` - рефакторинг
|
|
||||||
- `test:` - добавление тестов
|
|
||||||
- `chore:` - обновление зависимостей
|
|
||||||
|
|
||||||
Примеры:
|
|
||||||
```
|
|
||||||
feat: add user quota management API
|
|
||||||
fix(auth): handle expired tokens properly
|
|
||||||
docs: update API documentation
|
|
||||||
style: format code with cargo fmt
|
|
||||||
```
|
|
||||||
|
|
||||||
### Тестирование
|
|
||||||
|
|
||||||
- **Unit тесты** для всех новых функций
|
|
||||||
- **Интеграционные тесты** для API endpoints
|
|
||||||
- **Тесты производительности** для критических участков
|
|
||||||
- Минимальное покрытие кода: **80%**
|
|
||||||
|
|
||||||
### Документация
|
|
||||||
|
|
||||||
- Обновляйте README.md если необходимо
|
|
||||||
- Добавляйте комментарии к сложному коду
|
|
||||||
- Документируйте API изменения
|
|
||||||
- Обновляйте примеры использования
|
|
||||||
|
|
||||||
## Процесс Pull Request
|
|
||||||
|
|
||||||
### Создание PR
|
|
||||||
|
|
||||||
1. **Заполните шаблон** Pull Request
|
|
||||||
2. **Опишите изменения** подробно
|
|
||||||
3. **Укажите связанные issues**
|
|
||||||
4. **Добавьте скриншоты** если применимо
|
|
||||||
|
|
||||||
### Code Review
|
|
||||||
|
|
||||||
- **Два approval** требуются для merge
|
|
||||||
- **CI/CD** должен пройти успешно
|
|
||||||
- **Code coverage** не должен уменьшиться
|
|
||||||
- **Безопасность** проверяется автоматически
|
|
||||||
|
|
||||||
### После Merge
|
|
||||||
|
|
||||||
- **Feature branch** удаляется автоматически
|
|
||||||
- **Release** создается для значительных изменений
|
|
||||||
- **Документация** обновляется
|
|
||||||
|
|
||||||
## Настройка среды разработки
|
|
||||||
|
|
||||||
### Требования
|
|
||||||
|
|
||||||
- Rust 1.70+
|
|
||||||
- Redis 6.0+
|
|
||||||
- Git
|
|
||||||
|
|
||||||
### Установка
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Fork и clone
|
|
||||||
git clone https://github.com/YOUR_USERNAME/quoter.git
|
|
||||||
cd quoter
|
|
||||||
|
|
||||||
# Установка зависимостей
|
|
||||||
cargo build
|
|
||||||
|
|
||||||
# Настройка pre-commit hooks
|
|
||||||
cargo install cargo-husky
|
|
||||||
cargo husky install
|
|
||||||
```
|
|
||||||
|
|
||||||
### Локальная разработка
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Запуск Redis
|
|
||||||
docker run -d -p 6379:6379 redis:7-alpine
|
|
||||||
|
|
||||||
# Настройка переменных окружения
|
|
||||||
cp .env.example .env
|
|
||||||
# Отредактируйте .env
|
|
||||||
|
|
||||||
# Запуск приложения
|
|
||||||
cargo run
|
|
||||||
|
|
||||||
# Запуск тестов
|
|
||||||
cargo test
|
|
||||||
```
|
|
||||||
|
|
||||||
## Структура проекта
|
|
||||||
|
|
||||||
```
|
|
||||||
quoter/
|
|
||||||
├── src/ # Исходный код
|
|
||||||
│ ├── main.rs # Точка входа
|
|
||||||
│ ├── app_state.rs # Состояние приложения
|
|
||||||
│ ├── auth.rs # Аутентификация
|
|
||||||
│ ├── core.rs # API ядра
|
|
||||||
│ ├── handlers/ # HTTP обработчики
|
|
||||||
│ ├── lookup.rs # Поиск файлов
|
|
||||||
│ ├── overlay.rs # Оверлеи
|
|
||||||
│ ├── s3_utils.rs # S3 утилиты
|
|
||||||
│ └── thumbnail.rs # Миниатюры
|
|
||||||
├── docs/ # Документация
|
|
||||||
├── tests/ # Интеграционные тесты
|
|
||||||
├── Cargo.toml # Зависимости
|
|
||||||
└── README.md # Основная документация
|
|
||||||
```
|
|
||||||
|
|
||||||
## Роли в проекте
|
|
||||||
|
|
||||||
### Maintainers
|
|
||||||
|
|
||||||
- **Code review** всех PR
|
|
||||||
- **Release management**
|
|
||||||
- **Architecture decisions**
|
|
||||||
- **Community management**
|
|
||||||
|
|
||||||
### Contributors
|
|
||||||
|
|
||||||
- **Feature development**
|
|
||||||
- **Bug fixes**
|
|
||||||
- **Documentation**
|
|
||||||
- **Testing**
|
|
||||||
|
|
||||||
### Reviewers
|
|
||||||
|
|
||||||
- **Code review** assigned PRs
|
|
||||||
- **Quality assurance**
|
|
||||||
- **Performance review**
|
|
||||||
|
|
||||||
## Коммуникация
|
|
||||||
|
|
||||||
### Issues
|
|
||||||
|
|
||||||
- Используйте **labels** для категоризации
|
|
||||||
- **Assign** issues к себе если работаете над ними
|
|
||||||
- **Update** статус регулярно
|
|
||||||
|
|
||||||
### Discussions
|
|
||||||
|
|
||||||
- **GitHub Discussions** для общих вопросов
|
|
||||||
- **RFC** для значительных изменений
|
|
||||||
- **Architecture** для архитектурных решений
|
|
||||||
|
|
||||||
### Code Review
|
|
||||||
|
|
||||||
- **Будьте конструктивными**
|
|
||||||
- **Объясняйте причины** изменений
|
|
||||||
- **Предлагайте альтернативы**
|
|
||||||
- **Отвечайте на комментарии**
|
|
||||||
|
|
||||||
## Безопасность
|
|
||||||
|
|
||||||
### Отчеты о уязвимостях
|
|
||||||
|
|
||||||
Для критических уязвимостей:
|
|
||||||
|
|
||||||
1. **НЕ создавайте публичный issue**
|
|
||||||
2. **Отправьте email** на security@example.com
|
|
||||||
3. **Опишите уязвимость** подробно
|
|
||||||
4. **Предложите решение** если возможно
|
|
||||||
|
|
||||||
### Безопасность кода
|
|
||||||
|
|
||||||
- **Не коммитьте секреты**
|
|
||||||
- **Валидируйте входные данные**
|
|
||||||
- **Используйте безопасные зависимости**
|
|
||||||
- **Проверяйте код на уязвимости**
|
|
||||||
|
|
||||||
## Лицензия
|
|
||||||
|
|
||||||
Внося код в проект, вы соглашаетесь с тем, что ваш вклад будет лицензирован под MIT License.
|
|
||||||
|
|
||||||
## Благодарности
|
|
||||||
|
|
||||||
Спасибо всем контрибьюторам, которые помогают сделать Quoter лучше! 🙏
|
|
||||||
|
|
||||||
### Способы поддержки
|
|
||||||
|
|
||||||
- **Code contributions**
|
|
||||||
- **Bug reports**
|
|
||||||
- **Feature requests**
|
|
||||||
- **Documentation improvements**
|
|
||||||
- **Community support**
|
|
||||||
- **Financial support** (если применимо)
|
|
||||||
|
|
||||||
## Контакты
|
|
||||||
|
|
||||||
- **Issues**: [GitHub Issues](https://github.com/your-org/quoter/issues)
|
|
||||||
- **Discussions**: [GitHub Discussions](https://github.com/your-org/quoter/discussions)
|
|
||||||
- **Email**: maintainers@example.com
|
|
||||||
- **Chat**: [Discord/Slack] (если есть)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Спасибо за ваш вклад в Quoter!** 🚀
|
|
||||||
@@ -1,318 +0,0 @@
|
|||||||
# Развертывание
|
|
||||||
|
|
||||||
## Обзор
|
|
||||||
|
|
||||||
Quoter можно развернуть различными способами в зависимости от ваших потребностей и инфраструктуры.
|
|
||||||
|
|
||||||
## Способы развертывания
|
|
||||||
|
|
||||||
### 1. Docker (Рекомендуется)
|
|
||||||
|
|
||||||
#### Сборка образа
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Сборка production образа
|
|
||||||
docker build -t quoter:latest .
|
|
||||||
|
|
||||||
# Сборка с тегами
|
|
||||||
docker build -t quoter:v1.0.0 .
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Запуск контейнера
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d \
|
|
||||||
--name quoter \
|
|
||||||
-p 8080:8080 \
|
|
||||||
-e REDIS_URL=redis://redis:6379 \
|
|
||||||
-e CORE_URL=https://api.example.com/graphql \
|
|
||||||
-e STORJ_ACCESS_KEY=your-key \
|
|
||||||
-e STORJ_SECRET_KEY=your-secret \
|
|
||||||
-e AWS_ACCESS_KEY=your-aws-key \
|
|
||||||
-e AWS_SECRET_KEY=your-aws-secret \
|
|
||||||
quoter:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Docker Compose
|
|
||||||
|
|
||||||
Создайте `docker-compose.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
ports:
|
|
||||||
- "6379:6379"
|
|
||||||
volumes:
|
|
||||||
- redis_data:/data
|
|
||||||
command: redis-server --appendonly yes
|
|
||||||
|
|
||||||
quoter:
|
|
||||||
build: .
|
|
||||||
ports:
|
|
||||||
- "8080:8080"
|
|
||||||
environment:
|
|
||||||
- REDIS_URL=redis://redis:6379
|
|
||||||
- CORE_URL=https://api.example.com/graphql
|
|
||||||
- STORJ_ACCESS_KEY=${STORJ_ACCESS_KEY}
|
|
||||||
- STORJ_SECRET_KEY=${STORJ_SECRET_KEY}
|
|
||||||
- AWS_ACCESS_KEY=${AWS_ACCESS_KEY}
|
|
||||||
- AWS_SECRET_KEY=${AWS_SECRET_KEY}
|
|
||||||
- RUST_LOG=info
|
|
||||||
depends_on:
|
|
||||||
- redis
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
redis_data:
|
|
||||||
```
|
|
||||||
|
|
||||||
Запуск:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Kubernetes
|
|
||||||
|
|
||||||
#### Deployment
|
|
||||||
|
|
||||||
Создайте `k8s/deployment.yaml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: quoter
|
|
||||||
labels:
|
|
||||||
app: quoter
|
|
||||||
spec:
|
|
||||||
replicas: 3
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: quoter
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: quoter
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: quoter
|
|
||||||
image: quoter:latest
|
|
||||||
ports:
|
|
||||||
- containerPort: 8080
|
|
||||||
env:
|
|
||||||
- name: REDIS_URL
|
|
||||||
value: "redis://redis-service:6379"
|
|
||||||
- name: CORE_URL
|
|
||||||
value: "https://api.example.com/graphql"
|
|
||||||
- name: STORJ_ACCESS_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: quoter-secrets
|
|
||||||
key: storj-access-key
|
|
||||||
- name: STORJ_SECRET_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: quoter-secrets
|
|
||||||
key: storj-secret-key
|
|
||||||
- name: AWS_ACCESS_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: quoter-secrets
|
|
||||||
key: aws-access-key
|
|
||||||
- name: AWS_SECRET_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: quoter-secrets
|
|
||||||
key: aws-secret-key
|
|
||||||
- name: RUST_LOG
|
|
||||||
value: "info"
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
memory: "256Mi"
|
|
||||||
cpu: "250m"
|
|
||||||
limits:
|
|
||||||
memory: "512Mi"
|
|
||||||
cpu: "500m"
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /
|
|
||||||
port: 8080
|
|
||||||
initialDelaySeconds: 30
|
|
||||||
periodSeconds: 10
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /
|
|
||||||
port: 8080
|
|
||||||
initialDelaySeconds: 5
|
|
||||||
periodSeconds: 5
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Service
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: quoter-service
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
app: quoter
|
|
||||||
ports:
|
|
||||||
- protocol: TCP
|
|
||||||
port: 80
|
|
||||||
targetPort: 8080
|
|
||||||
type: LoadBalancer
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Secrets
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Secret
|
|
||||||
metadata:
|
|
||||||
name: quoter-secrets
|
|
||||||
type: Opaque
|
|
||||||
data:
|
|
||||||
storj-access-key: <base64-encoded-key>
|
|
||||||
storj-secret-key: <base64-encoded-secret>
|
|
||||||
aws-access-key: <base64-encoded-key>
|
|
||||||
aws-secret-key: <base64-encoded-secret>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Systemd (Linux)
|
|
||||||
|
|
||||||
#### Создание сервиса
|
|
||||||
|
|
||||||
Создайте `/etc/systemd/system/quoter.service`:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[Unit]
|
|
||||||
Description=Quoter File Service
|
|
||||||
After=network.target redis.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
Author=quoter
|
|
||||||
Group=quoter
|
|
||||||
WorkingDirectory=/opt/quoter
|
|
||||||
Environment=REDIS_URL=redis://localhost:6379
|
|
||||||
Environment=CORE_URL=https://api.example.com/graphql
|
|
||||||
Environment=STORJ_ACCESS_KEY=your-key
|
|
||||||
Environment=STORJ_SECRET_KEY=your-secret
|
|
||||||
Environment=AWS_ACCESS_KEY=your-aws-key
|
|
||||||
Environment=AWS_SECRET_KEY=your-aws-secret
|
|
||||||
Environment=RUST_LOG=info
|
|
||||||
ExecStart=/opt/quoter/quoter
|
|
||||||
Restart=always
|
|
||||||
RestartSec=10
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Управление сервисом
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Создание пользователя
|
|
||||||
sudo useradd -r -s /bin/false quoter
|
|
||||||
|
|
||||||
# Копирование бинарного файла
|
|
||||||
sudo cp target/release/quoter /opt/quoter/
|
|
||||||
sudo chown quoter:quoter /opt/quoter/quoter
|
|
||||||
|
|
||||||
# Включение и запуск сервиса
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
sudo systemctl enable quoter
|
|
||||||
sudo systemctl start quoter
|
|
||||||
|
|
||||||
# Проверка статуса
|
|
||||||
sudo systemctl status quoter
|
|
||||||
```
|
|
||||||
|
|
||||||
## Мониторинг и логирование
|
|
||||||
|
|
||||||
### Prometheus метрики
|
|
||||||
|
|
||||||
Добавьте в `Cargo.toml`:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[dependencies]
|
|
||||||
prometheus = "0.13"
|
|
||||||
actix-web-prom = "0.6"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Grafana дашборд
|
|
||||||
|
|
||||||
Создайте дашборд для мониторинга:
|
|
||||||
|
|
||||||
- Количество запросов в секунду
|
|
||||||
- Время ответа API
|
|
||||||
- Использование памяти и CPU
|
|
||||||
- Ошибки по типам
|
|
||||||
- Использование квот
|
|
||||||
|
|
||||||
### Логирование
|
|
||||||
|
|
||||||
#### Структурированные логи
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# JSON формат для ELK stack
|
|
||||||
RUST_LOG=info cargo run | jq .
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Ротация логов
|
|
||||||
|
|
||||||
Настройте logrotate:
|
|
||||||
|
|
||||||
```
|
|
||||||
/var/log/quoter/*.log {
|
|
||||||
daily
|
|
||||||
missingok
|
|
||||||
rotate 52
|
|
||||||
compress
|
|
||||||
delaycompress
|
|
||||||
notifempty
|
|
||||||
create 644 quoter quoter
|
|
||||||
postrotate
|
|
||||||
systemctl reload quoter
|
|
||||||
endscript
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Масштабирование
|
|
||||||
|
|
||||||
### Горизонтальное масштабирование
|
|
||||||
|
|
||||||
1. **Load Balancer**: Настройте nginx или HAProxy
|
|
||||||
2. **Redis Cluster**: Для высоких нагрузок
|
|
||||||
3. **S3 CDN**: Для статических файлов
|
|
||||||
|
|
||||||
### Вертикальное масштабирование
|
|
||||||
|
|
||||||
- Увеличьте ресурсы контейнера/сервера
|
|
||||||
- Настройте пул соединений Redis
|
|
||||||
- Оптимизируйте размер изображений
|
|
||||||
|
|
||||||
## Безопасность
|
|
||||||
|
|
||||||
### Сетевая безопасность
|
|
||||||
|
|
||||||
- Используйте HTTPS в продакшене
|
|
||||||
- Настройте firewall
|
|
||||||
- Ограничьте доступ к Redis
|
|
||||||
|
|
||||||
### Секреты
|
|
||||||
|
|
||||||
- Используйте Kubernetes Secrets или Docker Secrets
|
|
||||||
- Не храните секреты в коде
|
|
||||||
- Ротация ключей доступа
|
|
||||||
|
|
||||||
### Аудит
|
|
||||||
|
|
||||||
- Логируйте все операции с файлами
|
|
||||||
- Отслеживайте использование квот
|
|
||||||
- Мониторьте подозрительную активность
|
|
||||||
@@ -1,422 +0,0 @@
|
|||||||
# Разработка
|
|
||||||
|
|
||||||
## Настройка среды разработки
|
|
||||||
|
|
||||||
### Требования
|
|
||||||
|
|
||||||
- **Rust**: 1.70 или выше
|
|
||||||
- **Redis**: 6.0 или выше (локально или Docker)
|
|
||||||
- **Git**: для работы с репозиторием
|
|
||||||
- **IDE**: VS Code, IntelliJ IDEA или другой редактор с поддержкой Rust
|
|
||||||
|
|
||||||
### Установка Rust
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Установка Rust через rustup
|
|
||||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
|
||||||
|
|
||||||
# Перезагрузка shell
|
|
||||||
source ~/.bashrc
|
|
||||||
|
|
||||||
# Проверка установки
|
|
||||||
rustc --version
|
|
||||||
cargo --version
|
|
||||||
```
|
|
||||||
|
|
||||||
### Клонирование репозитория
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/your-org/quoter.git
|
|
||||||
cd quoter
|
|
||||||
```
|
|
||||||
|
|
||||||
### Установка зависимостей
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Сборка проекта
|
|
||||||
cargo build
|
|
||||||
|
|
||||||
# Установка дополнительных инструментов
|
|
||||||
cargo install cargo-watch # для автоматической пересборки
|
|
||||||
cargo install cargo-audit # для проверки безопасности
|
|
||||||
cargo install cargo-tarpaulin # для покрытия кода тестами
|
|
||||||
```
|
|
||||||
|
|
||||||
## Структура проекта
|
|
||||||
|
|
||||||
```
|
|
||||||
quoter/
|
|
||||||
├── src/
|
|
||||||
│ ├── main.rs # Точка входа приложения
|
|
||||||
│ ├── app_state.rs # Состояние приложения и подключения
|
|
||||||
│ ├── auth.rs # Аутентификация и авторизация
|
|
||||||
│ ├── core.rs # Интеграция с API ядра
|
|
||||||
│ ├── lookup.rs # Поиск и определение MIME-типов
|
|
||||||
│ ├── overlay.rs # Генерация оверлеев для изображений
|
|
||||||
│ ├── s3_utils.rs # Утилиты для работы с S3
|
|
||||||
│ ├── thumbnail.rs # Создание миниатюр
|
|
||||||
│ └── handlers/ # HTTP обработчики
|
|
||||||
│ ├── mod.rs # Модуль обработчиков
|
|
||||||
│ ├── upload.rs # Загрузка файлов
|
|
||||||
│ ├── proxy.rs # Получение файлов
|
|
||||||
│ ├── quota.rs # Управление квотами
|
|
||||||
│ └── serve_file.rs # Обслуживание файлов
|
|
||||||
├── docs/ # Документация
|
|
||||||
├── tests/ # Интеграционные тесты
|
|
||||||
├── Cargo.toml # Зависимости и конфигурация
|
|
||||||
├── Cargo.lock # Фиксированные версии зависимостей
|
|
||||||
├── Dockerfile # Docker образ
|
|
||||||
└── README.md # Основная документация
|
|
||||||
```
|
|
||||||
|
|
||||||
## Локальная разработка
|
|
||||||
|
|
||||||
### Настройка переменных окружения
|
|
||||||
|
|
||||||
Создайте файл `.env` в корне проекта:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Redis (локально или Docker)
|
|
||||||
REDIS_URL=redis://localhost:6379
|
|
||||||
|
|
||||||
# Core API (замените на ваш endpoint)
|
|
||||||
CORE_URL=https://api.example.com/graphql
|
|
||||||
|
|
||||||
# Storj S3 (тестовые ключи)
|
|
||||||
STORJ_ACCESS_KEY=your-test-key
|
|
||||||
STORJ_SECRET_KEY=your-test-secret
|
|
||||||
STORJ_BUCKET_NAME=test-bucket
|
|
||||||
|
|
||||||
# AWS S3 (тестовые ключи)
|
|
||||||
AWS_ACCESS_KEY=your-test-aws-key
|
|
||||||
AWS_SECRET_KEY=your-test-aws-secret
|
|
||||||
|
|
||||||
# Server
|
|
||||||
PORT=8080
|
|
||||||
RUST_LOG=debug
|
|
||||||
```
|
|
||||||
|
|
||||||
### Запуск Redis
|
|
||||||
|
|
||||||
#### Локально
|
|
||||||
```bash
|
|
||||||
# Ubuntu/Debian
|
|
||||||
sudo apt-get install redis-server
|
|
||||||
sudo systemctl start redis-server
|
|
||||||
|
|
||||||
# macOS
|
|
||||||
brew install redis
|
|
||||||
brew services start redis
|
|
||||||
|
|
||||||
# Проверка
|
|
||||||
redis-cli ping
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Docker
|
|
||||||
```bash
|
|
||||||
docker run -d \
|
|
||||||
--name redis-dev \
|
|
||||||
-p 6379:6379 \
|
|
||||||
redis:7-alpine
|
|
||||||
```
|
|
||||||
|
|
||||||
### Запуск приложения
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Обычный запуск
|
|
||||||
cargo run
|
|
||||||
|
|
||||||
# С автоматической пересборкой
|
|
||||||
cargo watch -x run
|
|
||||||
|
|
||||||
# В режиме отладки
|
|
||||||
RUST_LOG=debug cargo run
|
|
||||||
|
|
||||||
# С профилированием
|
|
||||||
cargo run --release
|
|
||||||
```
|
|
||||||
|
|
||||||
### Проверка работоспособности
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Проверка сервера
|
|
||||||
curl http://localhost:8080/
|
|
||||||
|
|
||||||
# Проверка загрузки файла (требует токен)
|
|
||||||
curl -X POST http://localhost:8080/ \
|
|
||||||
-H "Authorization: Bearer your-token" \
|
|
||||||
-F "file=@test-image.jpg"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Тестирование
|
|
||||||
|
|
||||||
### Unit тесты
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Запуск всех тестов
|
|
||||||
cargo test
|
|
||||||
|
|
||||||
# Запуск тестов с выводом
|
|
||||||
cargo test -- --nocapture
|
|
||||||
|
|
||||||
# Запуск конкретного теста
|
|
||||||
cargo test test_upload_file
|
|
||||||
|
|
||||||
# Запуск тестов в параллельном режиме
|
|
||||||
cargo test -- --test-threads=4
|
|
||||||
```
|
|
||||||
|
|
||||||
### Интеграционные тесты
|
|
||||||
|
|
||||||
Создайте файл `tests/integration_test.rs`:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use actix_web::{test, web, App};
|
|
||||||
use quoter::app_state::AppState;
|
|
||||||
|
|
||||||
#[actix_web::test]
|
|
||||||
async fn test_upload_endpoint() {
|
|
||||||
let app_state = AppState::new().await;
|
|
||||||
let app = test::init_service(
|
|
||||||
App::new()
|
|
||||||
.app_data(web::Data::new(app_state))
|
|
||||||
.route("/", web::post().to(upload_handler))
|
|
||||||
).await;
|
|
||||||
|
|
||||||
let req = test::TestRequest::post()
|
|
||||||
.uri("/")
|
|
||||||
.insert_header(("Authorization", "Bearer test-token"))
|
|
||||||
.set_form(("file", "test-data"))
|
|
||||||
.to_request();
|
|
||||||
|
|
||||||
let resp = test::call_service(&app, req).await;
|
|
||||||
assert!(resp.status().is_success());
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Тестирование производительности
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Бенчмарки (если настроены)
|
|
||||||
cargo bench
|
|
||||||
|
|
||||||
# Профилирование с flamegraph
|
|
||||||
cargo install flamegraph
|
|
||||||
cargo flamegraph --bin quoter
|
|
||||||
```
|
|
||||||
|
|
||||||
## Отладка
|
|
||||||
|
|
||||||
### Логирование
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use log::{debug, info, warn, error};
|
|
||||||
|
|
||||||
// В коде
|
|
||||||
debug!("Processing file: {}", filename);
|
|
||||||
info!("File uploaded successfully");
|
|
||||||
warn!("Author quota is getting low: {} bytes", quota);
|
|
||||||
error!("Failed to upload file: {}", e);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Отладка с GDB
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Компиляция с отладочной информацией
|
|
||||||
cargo build
|
|
||||||
|
|
||||||
# Запуск с GDB
|
|
||||||
gdb target/debug/quoter
|
|
||||||
|
|
||||||
# В GDB
|
|
||||||
(gdb) break main
|
|
||||||
(gdb) run
|
|
||||||
(gdb) continue
|
|
||||||
```
|
|
||||||
|
|
||||||
### Отладка с LLDB (macOS)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
lldb target/debug/quoter
|
|
||||||
(lldb) breakpoint set --name main
|
|
||||||
(lldb) run
|
|
||||||
```
|
|
||||||
|
|
||||||
## Проверка кода
|
|
||||||
|
|
||||||
### Clippy
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Проверка стиля кода
|
|
||||||
cargo clippy
|
|
||||||
|
|
||||||
# Проверка с дополнительными предупреждениями
|
|
||||||
cargo clippy -- -D warnings
|
|
||||||
|
|
||||||
# Автоматическое исправление
|
|
||||||
cargo clippy --fix
|
|
||||||
```
|
|
||||||
|
|
||||||
### Форматирование
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Форматирование кода
|
|
||||||
cargo fmt
|
|
||||||
|
|
||||||
# Проверка форматирования
|
|
||||||
cargo fmt -- --check
|
|
||||||
```
|
|
||||||
|
|
||||||
### Проверка безопасности
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Аудит зависимостей
|
|
||||||
cargo audit
|
|
||||||
|
|
||||||
# Проверка уязвимостей
|
|
||||||
cargo audit --deny warnings
|
|
||||||
```
|
|
||||||
|
|
||||||
## Покрытие кода
|
|
||||||
|
|
||||||
### Tarpaulin
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Установка
|
|
||||||
cargo install cargo-tarpaulin
|
|
||||||
|
|
||||||
# Запуск
|
|
||||||
cargo tarpaulin
|
|
||||||
|
|
||||||
# С HTML отчетом
|
|
||||||
cargo tarpaulin --out Html
|
|
||||||
```
|
|
||||||
|
|
||||||
### grcov
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Установка
|
|
||||||
cargo install grcov
|
|
||||||
|
|
||||||
# Настройка переменных
|
|
||||||
export CARGO_INCREMENTAL=0
|
|
||||||
export RUSTFLAGS="-Zprofile -Ccodegen-units=1 -Cinline-threshold=0 -Clink-dead-code -Coverflow-checks=off -Cpanic=abort -Zpanic_abort_tests"
|
|
||||||
export RUSTDOCFLAGS="-Zprofile -Ccodegen-units=1 -Cinline-threshold=0 -Clink-dead-code -Coverflow-checks=off -Cpanic=abort -Zpanic_abort_tests"
|
|
||||||
|
|
||||||
# Запуск тестов
|
|
||||||
cargo test
|
|
||||||
|
|
||||||
# Генерация отчета
|
|
||||||
grcov . -s . --binary-path ./target/debug/ -t html --branch --ignore-not-existing -o ./coverage/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Git workflow
|
|
||||||
|
|
||||||
### Создание feature branch
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Создание новой ветки
|
|
||||||
git checkout -b feature/new-feature
|
|
||||||
|
|
||||||
# Внесение изменений
|
|
||||||
# ...
|
|
||||||
|
|
||||||
# Коммит изменений
|
|
||||||
git add .
|
|
||||||
git commit -m "feat: add new feature"
|
|
||||||
|
|
||||||
# Push в репозиторий
|
|
||||||
git push origin feature/new-feature
|
|
||||||
```
|
|
||||||
|
|
||||||
### Commit conventions
|
|
||||||
|
|
||||||
Используйте [Conventional Commits](https://www.conventionalcommits.org/):
|
|
||||||
|
|
||||||
- `feat:` - новая функциональность
|
|
||||||
- `fix:` - исправление багов
|
|
||||||
- `docs:` - изменения в документации
|
|
||||||
- `style:` - форматирование кода
|
|
||||||
- `refactor:` - рефакторинг кода
|
|
||||||
- `test:` - добавление тестов
|
|
||||||
- `chore:` - обновление зависимостей, конфигурации
|
|
||||||
|
|
||||||
### Pull Request
|
|
||||||
|
|
||||||
1. Создайте Pull Request в GitHub/GitLab
|
|
||||||
2. Добавьте описание изменений
|
|
||||||
3. Укажите связанные issues
|
|
||||||
4. Дождитесь code review
|
|
||||||
5. Исправьте замечания если есть
|
|
||||||
6. Получите approval и merge
|
|
||||||
|
|
||||||
## Полезные команды
|
|
||||||
|
|
||||||
### Cargo
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Обновление зависимостей
|
|
||||||
cargo update
|
|
||||||
|
|
||||||
# Очистка сборки
|
|
||||||
cargo clean
|
|
||||||
|
|
||||||
# Проверка зависимостей
|
|
||||||
cargo tree
|
|
||||||
|
|
||||||
# Документация
|
|
||||||
cargo doc --open
|
|
||||||
|
|
||||||
# Проверка типов без компиляции
|
|
||||||
cargo check
|
|
||||||
```
|
|
||||||
|
|
||||||
### Отладка
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Просмотр логов в реальном времени
|
|
||||||
tail -f logs/quoter.log
|
|
||||||
|
|
||||||
# Мониторинг ресурсов
|
|
||||||
htop
|
|
||||||
iotop
|
|
||||||
|
|
||||||
# Сетевые соединения
|
|
||||||
netstat -tulpn | grep 8080
|
|
||||||
```
|
|
||||||
|
|
||||||
### Docker
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Сборка для разработки
|
|
||||||
docker build -t quoter:dev .
|
|
||||||
|
|
||||||
# Запуск с volume для hot reload
|
|
||||||
docker run -v $(pwd):/app -p 8080:8080 quoter:dev
|
|
||||||
|
|
||||||
# Просмотр логов контейнера
|
|
||||||
docker logs -f quoter-container
|
|
||||||
```
|
|
||||||
|
|
||||||
## Рекомендации
|
|
||||||
|
|
||||||
### Производительность
|
|
||||||
|
|
||||||
1. Используйте `cargo build --release` для production
|
|
||||||
2. Настройте профилирование для критических участков
|
|
||||||
3. Мониторьте использование памяти и CPU
|
|
||||||
4. Оптимизируйте размер изображений
|
|
||||||
|
|
||||||
### Безопасность
|
|
||||||
|
|
||||||
1. Регулярно обновляйте зависимости
|
|
||||||
2. Используйте `cargo audit` для проверки уязвимостей
|
|
||||||
3. Не храните секреты в коде
|
|
||||||
4. Валидируйте все входные данные
|
|
||||||
|
|
||||||
### Качество кода
|
|
||||||
|
|
||||||
1. Пишите тесты для новой функциональности
|
|
||||||
2. Используйте `cargo clippy` для проверки стиля
|
|
||||||
3. Документируйте публичные API
|
|
||||||
4. Следуйте принципам SOLID
|
|
||||||
101
docs/features.md
101
docs/features.md
@@ -1,77 +1,48 @@
|
|||||||
# Функциональность проекта Quoter
|
# Quoter Features
|
||||||
|
|
||||||
## Основные возможности
|
Simple file upload/download proxy with user quotas and S3 storage.
|
||||||
|
|
||||||
### 🖼️ Обработка изображений
|
## What Quoter Does
|
||||||
- Загрузка и хранение изображений
|
|
||||||
- Генерация thumbnail'ов различных размеров
|
|
||||||
- Поддержка форматов: JPG, PNG, GIF, WebP, HEIC, TIFF
|
|
||||||
- Автоматическое определение формата изображений
|
|
||||||
|
|
||||||
### 🔐 Аутентификация и авторизация
|
### 📤 File Upload
|
||||||
- Система токенов для пользователей
|
- **Multipart uploads** to S3/Storj storage
|
||||||
- Управление квотами загрузки (5GB на пользователя)
|
- **User quotas** (5GB default per user)
|
||||||
- Проверка прав доступа к файлам
|
- **JWT authentication** with session management
|
||||||
|
- **MIME type detection** from file content
|
||||||
|
- **Rate limiting** to prevent abuse
|
||||||
|
|
||||||
### 📁 Управление файлами
|
### 📁 File Storage
|
||||||
- Загрузка файлов через multipart form data
|
- **S3-compatible storage** (Storj primary, AWS fallback)
|
||||||
- Хранение в S3-совместимых хранилищах
|
- **Redis caching** for file metadata and quotas
|
||||||
- Поиск файлов по паттернам
|
- **Multi-cloud support** with automatic migration
|
||||||
- Кэширование списков файлов
|
|
||||||
|
|
||||||
### 🌐 HTTP API
|
### 🌐 File Serving
|
||||||
- RESTful endpoints для всех операций
|
- **Direct file access** via filename
|
||||||
- Поддержка CORS для веб-приложений
|
- **Fast response** optimized for Vercel Edge caching
|
||||||
- Обработка ошибок с детальными сообщениями
|
- **CORS whitelist** for secure access
|
||||||
- Проксирование запросов к файлам
|
- **Direct file serving** optimized for CDN caching
|
||||||
|
|
||||||
### 📊 Мониторинг и логирование
|
## 🚀 Modern Architecture
|
||||||
- Интеграция с Sentry для отслеживания ошибок
|
|
||||||
- Логирование всех операций
|
|
||||||
- Метрики производительности
|
|
||||||
|
|
||||||
## Технические особенности
|
**Quoter**: Simple file upload/download + S3 storage
|
||||||
|
**Vercel**: Smart thumbnails + optimization + global CDN
|
||||||
|
|
||||||
### 🧪 Тестирование
|
💋 **Ultra-simple**: Quoter just handles raw files. That's it.
|
||||||
- Полное покрытие unit тестами (36 тестов)
|
💋 **Simplified**: Focus on what each service does best.
|
||||||
- Интеграционные тесты для всех компонентов
|
|
||||||
- Моки для внешних зависимостей
|
|
||||||
- Тесты производительности
|
|
||||||
|
|
||||||
### 🚀 Развертывание
|
## Technical Stack
|
||||||
- Docker контейнеризация
|
|
||||||
- Автоматизированный CI/CD конвейер
|
|
||||||
- Поддержка различных окружений
|
|
||||||
- Масштабируемая архитектура
|
|
||||||
|
|
||||||
### 🔧 Конфигурация
|
- **Backend**: Rust + Actix Web
|
||||||
- Гибкая настройка через переменные окружения
|
- **Storage**: Redis (metadata) + S3/Storj (files)
|
||||||
- Поддержка различных S3 провайдеров
|
- **Auth**: JWT tokens
|
||||||
- Настраиваемые квоты и лимиты
|
- **Tests**: 36 passing tests with full coverage
|
||||||
- Конфигурация CORS политик
|
|
||||||
|
|
||||||
## Архитектура
|
## Status
|
||||||
|
|
||||||
### Модули
|
- ✅ Upload API with quotas
|
||||||
- `core.rs` - основная бизнес-логика и GraphQL API
|
- ✅ Static file server
|
||||||
- `auth.rs` - аутентификация и управление пользователями
|
- ✅ S3 storage integration
|
||||||
- `handlers/` - HTTP обработчики запросов
|
- ✅ JWT authentication
|
||||||
- `thumbnail.rs` - генерация thumbnail'ов
|
- ✅ Rate limiting & security
|
||||||
- `s3_utils.rs` - работа с S3-совместимыми хранилищами
|
- ✅ Full test coverage
|
||||||
- `lookup.rs` - поиск и определение типов файлов
|
- 🚀 Production ready
|
||||||
- `overlay.rs` - наложение водяных знаков и метаданных
|
|
||||||
|
|
||||||
### Зависимости
|
|
||||||
- Actix Web для HTTP сервера
|
|
||||||
- Redis для кэширования
|
|
||||||
- AWS SDK для S3 операций
|
|
||||||
- Image crate для обработки изображений
|
|
||||||
- Sentry для мониторинга
|
|
||||||
|
|
||||||
## Статус разработки
|
|
||||||
|
|
||||||
- ✅ Основная функциональность реализована
|
|
||||||
- ✅ Полное покрытие тестами
|
|
||||||
- ✅ CI/CD конвейер настроен
|
|
||||||
- ✅ Документация обновлена
|
|
||||||
- 🚀 Готов к продакшн деплою
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
```
|
```
|
||||||
📤 Upload: Quoter (контроль + квоты)
|
📤 Upload: Quoter (контроль + квоты)
|
||||||
📥 Download: Vercel Edge API (производительность)
|
📥 Download: Vercel Edge API (производительность)
|
||||||
🎨 OG: @vercel/og (динамическая генерация)
|
🎨 Thumbnails: Vercel /api/thumb/[width]/[...path] (динамическая генерация)
|
||||||
```
|
```
|
||||||
|
|
||||||
## ✅ Преимущества гибридного подхода
|
## ✅ Преимущества гибридного подхода
|
||||||
|
|||||||
@@ -1,341 +0,0 @@
|
|||||||
# Мониторинг
|
|
||||||
|
|
||||||
## Обзор
|
|
||||||
|
|
||||||
Мониторинг Quoter включает в себя логирование, метрики производительности и отслеживание состояния системы.
|
|
||||||
|
|
||||||
## Логирование
|
|
||||||
|
|
||||||
### Уровни логирования
|
|
||||||
|
|
||||||
Quoter использует библиотеку `log` с различными уровнями:
|
|
||||||
|
|
||||||
- **error** - Критические ошибки, требующие немедленного внимания
|
|
||||||
- **warn** - Предупреждения, которые могут указывать на проблемы
|
|
||||||
- **info** - Информационные сообщения о нормальной работе
|
|
||||||
- **debug** - Отладочная информация для разработчиков
|
|
||||||
- **trace** - Максимальная детализация для глубокой отладки
|
|
||||||
|
|
||||||
### Настройка логирования
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Только ошибки
|
|
||||||
RUST_LOG=error cargo run
|
|
||||||
|
|
||||||
# Предупреждения и ошибки
|
|
||||||
RUST_LOG=warn cargo run
|
|
||||||
|
|
||||||
# Информационные сообщения (рекомендуется для продакшена)
|
|
||||||
RUST_LOG=info cargo run
|
|
||||||
|
|
||||||
# Отладка
|
|
||||||
RUST_LOG=debug cargo run
|
|
||||||
|
|
||||||
# Максимальная детализация
|
|
||||||
RUST_LOG=trace cargo run
|
|
||||||
```
|
|
||||||
|
|
||||||
### Структура логов
|
|
||||||
|
|
||||||
#### Загрузка файла
|
|
||||||
```
|
|
||||||
[INFO] Started
|
|
||||||
[WARN] file abc123.jpg uploaded to storj, incrementing quota by 1048576 bytes
|
|
||||||
[WARN] New quota for user user123: 2097152 bytes
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Получение файла
|
|
||||||
```
|
|
||||||
[WARN] >>> GET image_300.jpg [START]
|
|
||||||
[WARN] detected file extension: jpg
|
|
||||||
[WARN] base_filename: image
|
|
||||||
[WARN] requested width: 300
|
|
||||||
[WARN] Found stored path in DB: production/image/image.jpg
|
|
||||||
[WARN] File exists in Storj: production/image/image.jpg
|
|
||||||
[WARN] Processing image file with width: 300
|
|
||||||
[WARN] Calculated closest width: 300 for requested: 300
|
|
||||||
[WARN] serve existed thumb file: image_300.jpg
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Ошибки
|
|
||||||
```
|
|
||||||
[ERROR] Failed to upload to Storj: image.jpg - Error: Network error
|
|
||||||
[ERROR] Database error while getting path: image.jpg - Full error: Connection timeout
|
|
||||||
[ERROR] unsupported file format
|
|
||||||
```
|
|
||||||
|
|
||||||
## Метрики
|
|
||||||
|
|
||||||
### Основные метрики для мониторинга
|
|
||||||
|
|
||||||
#### Производительность
|
|
||||||
- **Requests per second (RPS)** - количество запросов в секунду
|
|
||||||
- **Response time** - время ответа API
|
|
||||||
- **Error rate** - процент ошибок
|
|
||||||
- **Upload success rate** - процент успешных загрузок
|
|
||||||
|
|
||||||
#### Ресурсы
|
|
||||||
- **Memory usage** - использование памяти
|
|
||||||
- **CPU usage** - использование процессора
|
|
||||||
- **Disk I/O** - операции с диском
|
|
||||||
- **Network I/O** - сетевой трафик
|
|
||||||
|
|
||||||
#### Бизнес-метрики
|
|
||||||
- **Files uploaded** - количество загруженных файлов
|
|
||||||
- **Quota usage** - использование квот пользователями
|
|
||||||
- **Thumbnail generation** - количество сгенерированных миниатюр
|
|
||||||
- **Storage usage** - использование хранилища
|
|
||||||
|
|
||||||
### Prometheus метрики
|
|
||||||
|
|
||||||
Добавьте в `Cargo.toml`:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[dependencies]
|
|
||||||
prometheus = "0.13"
|
|
||||||
actix-web-prom = "0.6"
|
|
||||||
```
|
|
||||||
|
|
||||||
Настройте в `main.rs`:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use actix_web_prom::{PrometheusMetrics, PrometheusMetricsBuilder};
|
|
||||||
|
|
||||||
#[actix_web::main]
|
|
||||||
async fn main() -> std::io::Result<()> {
|
|
||||||
let prometheus = PrometheusMetricsBuilder::new("quoter")
|
|
||||||
.endpoint("/metrics")
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
HttpServer::new(move || {
|
|
||||||
App::new()
|
|
||||||
.wrap(prometheus.clone())
|
|
||||||
// ... остальные настройки
|
|
||||||
})
|
|
||||||
.bind(addr)?
|
|
||||||
.run()
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Кастомные метрики
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use prometheus::{Counter, Histogram, Registry};
|
|
||||||
|
|
||||||
lazy_static! {
|
|
||||||
pub static ref UPLOAD_COUNTER: Counter = Counter::new(
|
|
||||||
"quoter_uploads_total",
|
|
||||||
"Total number of file uploads"
|
|
||||||
).unwrap();
|
|
||||||
|
|
||||||
pub static ref UPLOAD_SIZE: Histogram = Histogram::new(
|
|
||||||
"quoter_upload_size_bytes",
|
|
||||||
"File upload size in bytes"
|
|
||||||
).unwrap();
|
|
||||||
|
|
||||||
pub static ref QUOTA_USAGE: Histogram = Histogram::new(
|
|
||||||
"quoter_quota_usage_bytes",
|
|
||||||
"Author quota usage in bytes"
|
|
||||||
).unwrap();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Алерты
|
|
||||||
|
|
||||||
### Критические алерты
|
|
||||||
|
|
||||||
- **Service down** - сервис недоступен
|
|
||||||
- **High error rate** - высокий процент ошибок (>5%)
|
|
||||||
- **High response time** - медленные ответы (>2s)
|
|
||||||
- **Memory usage high** - высокое использование памяти (>80%)
|
|
||||||
- **Redis connection failed** - потеря соединения с Redis
|
|
||||||
|
|
||||||
### Предупреждения
|
|
||||||
|
|
||||||
- **Quota usage high** - пользователи приближаются к лимиту квоты
|
|
||||||
- **Storage usage high** - высокое использование хранилища
|
|
||||||
- **Thumbnail generation slow** - медленная генерация миниатюр
|
|
||||||
|
|
||||||
### Настройка алертов в Prometheus
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
groups:
|
|
||||||
- name: quoter
|
|
||||||
rules:
|
|
||||||
- alert: QuoterServiceDown
|
|
||||||
expr: up{job="quoter"} == 0
|
|
||||||
for: 1m
|
|
||||||
labels:
|
|
||||||
severity: critical
|
|
||||||
annotations:
|
|
||||||
summary: "Quoter service is down"
|
|
||||||
|
|
||||||
- alert: HighErrorRate
|
|
||||||
expr: rate(http_requests_total{job="quoter",status=~"5.."}[5m]) > 0.05
|
|
||||||
for: 2m
|
|
||||||
labels:
|
|
||||||
severity: warning
|
|
||||||
annotations:
|
|
||||||
summary: "High error rate detected"
|
|
||||||
|
|
||||||
- alert: HighResponseTime
|
|
||||||
expr: histogram_quantile(0.95, rate(http_request_duration_seconds_bucket{job="quoter"}[5m])) > 2
|
|
||||||
for: 5m
|
|
||||||
labels:
|
|
||||||
severity: warning
|
|
||||||
annotations:
|
|
||||||
summary: "High response time detected"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Дашборды
|
|
||||||
|
|
||||||
### Grafana дашборд
|
|
||||||
|
|
||||||
Создайте дашборд с панелями:
|
|
||||||
|
|
||||||
#### Обзор системы
|
|
||||||
- Статус сервиса (up/down)
|
|
||||||
- Количество запросов в секунду
|
|
||||||
- Время ответа (p50, p95, p99)
|
|
||||||
- Процент ошибок
|
|
||||||
|
|
||||||
#### Загрузка файлов
|
|
||||||
- Количество загрузок в час/день
|
|
||||||
- Размер загружаемых файлов
|
|
||||||
- Успешность загрузок
|
|
||||||
- Использование квот
|
|
||||||
|
|
||||||
#### Ресурсы
|
|
||||||
- Использование CPU и памяти
|
|
||||||
- Сетевой трафик
|
|
||||||
- Операции с диском
|
|
||||||
- Соединения с Redis
|
|
||||||
|
|
||||||
#### Бизнес-метрики
|
|
||||||
- Топ пользователей по использованию квоты
|
|
||||||
- Популярные размеры миниатюр
|
|
||||||
- Использование хранилища по типам файлов
|
|
||||||
|
|
||||||
### Пример запроса для Grafana
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Количество загрузок по часам
|
|
||||||
SELECT
|
|
||||||
time_bucket('1 hour', timestamp) AS time,
|
|
||||||
COUNT(*) as uploads
|
|
||||||
FROM quoter_uploads
|
|
||||||
WHERE timestamp > NOW() - INTERVAL '24 hours'
|
|
||||||
GROUP BY time
|
|
||||||
ORDER BY time;
|
|
||||||
```
|
|
||||||
|
|
||||||
## Здоровье системы
|
|
||||||
|
|
||||||
### Health check endpoint
|
|
||||||
|
|
||||||
Добавьте endpoint для проверки здоровья:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
async fn health_check() -> HttpResponse {
|
|
||||||
// Проверка Redis
|
|
||||||
let redis_ok = check_redis_connection().await;
|
|
||||||
|
|
||||||
// Проверка S3
|
|
||||||
let s3_ok = check_s3_connection().await;
|
|
||||||
|
|
||||||
if redis_ok && s3_ok {
|
|
||||||
HttpResponse::Ok().json(json!({
|
|
||||||
"status": "healthy",
|
|
||||||
"timestamp": chrono::Utc::now(),
|
|
||||||
"services": {
|
|
||||||
"redis": "ok",
|
|
||||||
"s3": "ok"
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
} else {
|
|
||||||
HttpResponse::ServiceUnavailable().json(json!({
|
|
||||||
"status": "unhealthy",
|
|
||||||
"timestamp": chrono::Utc::now(),
|
|
||||||
"services": {
|
|
||||||
"redis": if redis_ok { "ok" } else { "error" },
|
|
||||||
"s3": if s3_ok { "ok" } else { "error" }
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Kubernetes liveness/readiness probes
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: 8080
|
|
||||||
initialDelaySeconds: 30
|
|
||||||
periodSeconds: 10
|
|
||||||
timeoutSeconds: 5
|
|
||||||
failureThreshold: 3
|
|
||||||
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: 8080
|
|
||||||
initialDelaySeconds: 5
|
|
||||||
periodSeconds: 5
|
|
||||||
timeoutSeconds: 3
|
|
||||||
failureThreshold: 3
|
|
||||||
```
|
|
||||||
|
|
||||||
## Трассировка
|
|
||||||
|
|
||||||
### Distributed tracing
|
|
||||||
|
|
||||||
Для отслеживания запросов через микросервисы:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[dependencies]
|
|
||||||
opentelemetry = "0.20"
|
|
||||||
tracing = "0.1"
|
|
||||||
tracing-opentelemetry = "0.20"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Логирование с контекстом
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use tracing::{info, warn, error, instrument};
|
|
||||||
|
|
||||||
#[instrument(skip(state))]
|
|
||||||
async fn upload_handler(
|
|
||||||
req: HttpRequest,
|
|
||||||
payload: Multipart,
|
|
||||||
state: web::Data<AppState>,
|
|
||||||
) -> Result<HttpResponse> {
|
|
||||||
let user_id = get_user_id(&req).await?;
|
|
||||||
info!(user_id = %user_id, "Starting file upload");
|
|
||||||
|
|
||||||
// ... логика загрузки
|
|
||||||
|
|
||||||
info!(user_id = %user_id, filename = %filename, "File uploaded successfully");
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Рекомендации
|
|
||||||
|
|
||||||
### Продакшен
|
|
||||||
|
|
||||||
1. **Логирование**: Используйте структурированные логи в JSON формате
|
|
||||||
2. **Метрики**: Настройте Prometheus + Grafana
|
|
||||||
3. **Алерты**: Настройте уведомления для критических событий
|
|
||||||
4. **Ротация логов**: Настройте logrotate или отправку в ELK stack
|
|
||||||
5. **Мониторинг ресурсов**: Отслеживайте CPU, память, диск, сеть
|
|
||||||
|
|
||||||
### Разработка
|
|
||||||
|
|
||||||
1. **Локальное логирование**: Используйте `RUST_LOG=debug`
|
|
||||||
2. **Отладка**: Включите trace логи для детальной отладки
|
|
||||||
3. **Тестирование**: Создайте тесты для проверки метрик
|
|
||||||
4. **Документация**: Документируйте все кастомные метрики
|
|
||||||
176
docs/security.md
176
docs/security.md
@@ -1,176 +0,0 @@
|
|||||||
# 🔒 Безопасность и защита от DDoS
|
|
||||||
|
|
||||||
## Обзор
|
|
||||||
|
|
||||||
Система quoter включает многоуровневую защиту от различных типов атак, включая DDoS, брутфорс и эксплуатацию уязвимостей.
|
|
||||||
|
|
||||||
## 🛡️ Уровни защиты
|
|
||||||
|
|
||||||
### 1. Сетевой уровень (HTTP Server)
|
|
||||||
|
|
||||||
#### Ограничения размера запросов
|
|
||||||
- **Максимальный размер payload**: 500 МБ
|
|
||||||
- **Максимальный размер JSON**: 1 МБ
|
|
||||||
- **Таймаут соединения**: настраивается через Actix-web
|
|
||||||
|
|
||||||
#### Заголовки безопасности
|
|
||||||
```http
|
|
||||||
X-Content-Type-Options: nosniff
|
|
||||||
X-Frame-Options: DENY
|
|
||||||
X-XSS-Protection: 1; mode=block
|
|
||||||
Referrer-Policy: strict-origin-when-cross-origin
|
|
||||||
Content-Security-Policy: default-src 'self'; img-src 'self' data: https:; object-src 'none';
|
|
||||||
Strict-Transport-Security: max-age=31536000; includeSubDomains
|
|
||||||
```
|
|
||||||
|
|
||||||
#### CORS Policy
|
|
||||||
- **Разрешенные домены**: `discours.io`, `new.discours.io`, `localhost:3000`
|
|
||||||
- **Разрешенные методы**: GET, POST, OPTIONS
|
|
||||||
- **Ограниченные заголовки**: Content-Type, Authorization, If-None-Match, Cache-Control
|
|
||||||
|
|
||||||
### 2. Rate Limiting (Лимиты запросов)
|
|
||||||
|
|
||||||
#### Конфигурация по умолчанию
|
|
||||||
|
|
||||||
| Тип endpoint | Макс. запросов | Окно времени | Блокировка |
|
|
||||||
|--------------|----------------|--------------|------------|
|
|
||||||
| Общие запросы | 100 | 60 сек | 5 мин |
|
|
||||||
| Загрузка файлов | 10 | 300 сек | 10 мин |
|
|
||||||
| Аутентификация | 20 | 900 сек | 30 мин |
|
|
||||||
|
|
||||||
#### Механизм работы
|
|
||||||
1. **IP-based tracking**: Отслеживание по IP (учитывает X-Forwarded-For, X-Real-IP)
|
|
||||||
2. **Redis storage**: Хранение счетчиков в Redis с TTL
|
|
||||||
3. **Local cache**: Быстрый локальный кэш для частых проверок
|
|
||||||
4. **Progressive blocking**: Увеличение времени блокировки при повторных нарушениях
|
|
||||||
|
|
||||||
### 3. Валидация запросов
|
|
||||||
|
|
||||||
#### Проверки безопасности
|
|
||||||
- **Длина пути**: максимум 1000 символов
|
|
||||||
- **Количество заголовков**: максимум 50
|
|
||||||
- **Длина значений заголовков**: максимум 8192 символа
|
|
||||||
- **Подозрительные символы**: фильтрация `..`, `\0`, `\r`, `\n`
|
|
||||||
|
|
||||||
#### Детекция атак
|
|
||||||
```rust
|
|
||||||
// Подозрительные паттерны
|
|
||||||
let suspicious_patterns = [
|
|
||||||
"/admin", "/wp-admin", "/phpmyadmin", "/.env", "/config",
|
|
||||||
"/.git", "/backup", "/db", "/sql",
|
|
||||||
"script>", "<iframe", "javascript:", "data:",
|
|
||||||
];
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Аутентификация и авторизация
|
|
||||||
|
|
||||||
#### JWT Token Validation
|
|
||||||
- **Формат**: 3 части разделенные точками
|
|
||||||
- **Символы**: только alphanumeric, `.`, `-`, `_`
|
|
||||||
- **Длина**: 100-2048 символов
|
|
||||||
- **Валидация**: проверка подписи и времени жизни
|
|
||||||
|
|
||||||
#### Защита от брутфорса
|
|
||||||
- Задержки при неудачных попытках аутентификации
|
|
||||||
- Временная блокировка IP после множественных неудач
|
|
||||||
- Логирование подозрительной активности
|
|
||||||
|
|
||||||
## 🚀 Производительность
|
|
||||||
|
|
||||||
### Оптимизации
|
|
||||||
- **Локальный кэш**: быстрые проверки без обращения к Redis
|
|
||||||
- **Асинхронная обработка**: неблокирующие операции
|
|
||||||
- **ETag caching**: сокращение нагрузки на статические файлы
|
|
||||||
- **HTTP/2 support**: через reverse proxy
|
|
||||||
|
|
||||||
### Мониторинг
|
|
||||||
- Детальное логирование атак и блокировок
|
|
||||||
- Метрики производительности Redis
|
|
||||||
- Статистика rate limiting по IP и endpoint
|
|
||||||
|
|
||||||
## 🔧 Конфигурация
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
```bash
|
|
||||||
# Redis (для rate limiting)
|
|
||||||
REDIS_URL=redis://localhost:6379
|
|
||||||
|
|
||||||
# Security настройки
|
|
||||||
MAX_PAYLOAD_SIZE=524288000 # 500MB
|
|
||||||
MAX_PATH_LENGTH=1000
|
|
||||||
MAX_HEADERS_COUNT=50
|
|
||||||
```
|
|
||||||
|
|
||||||
### Настройка Rate Limits
|
|
||||||
```rust
|
|
||||||
let security_config = SecurityConfig {
|
|
||||||
general_rate_limit: RateLimitConfig {
|
|
||||||
max_requests: 100,
|
|
||||||
window_seconds: 60,
|
|
||||||
block_duration_seconds: 300,
|
|
||||||
},
|
|
||||||
upload_rate_limit: RateLimitConfig {
|
|
||||||
max_requests: 5, // Более строгие лимиты
|
|
||||||
window_seconds: 300,
|
|
||||||
block_duration_seconds: 1800,
|
|
||||||
},
|
|
||||||
// ...
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔍 Обнаружение угроз
|
|
||||||
|
|
||||||
### Автоматическая блокировка
|
|
||||||
- **Bot detection**: по User-Agent заголовкам
|
|
||||||
- **Массовые запросы**: временная блокировка агрессивных IP
|
|
||||||
- **Подозрительные пути**: немедленная блокировка известных attack vectors
|
|
||||||
|
|
||||||
### Логирование
|
|
||||||
```log
|
|
||||||
WARN Rate limit exceeded for IP 192.168.1.100: 101/100 requests
|
|
||||||
WARN Suspicious pattern detected from IP 192.168.1.100: /wp-admin
|
|
||||||
WARN Token validation failed for IP 192.168.1.100
|
|
||||||
```
|
|
||||||
|
|
||||||
## 💡 Рекомендации
|
|
||||||
|
|
||||||
### Развертывание в продакшене
|
|
||||||
1. **Reverse Proxy**: Nginx/CloudFlare для дополнительной фильтрации
|
|
||||||
2. **Firewall**: iptables/ufw для блокировки на сетевом уровне
|
|
||||||
3. **SSL/TLS**: Обязательное использование HTTPS
|
|
||||||
4. **Мониторинг**: Система алертов для подозрительной активности
|
|
||||||
|
|
||||||
### Настройка мониторинга
|
|
||||||
```bash
|
|
||||||
# Проверка заблокированных IP
|
|
||||||
redis-cli KEYS "rate_limit:*" | wc -l
|
|
||||||
|
|
||||||
# Логи безопасности
|
|
||||||
tail -f /var/log/quoter/security.log | grep "WARN\|ERROR"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Регулярное обслуживание
|
|
||||||
- Очистка старых записей из Redis
|
|
||||||
- Анализ логов на предмет новых угроз
|
|
||||||
- Обновление списков подозрительных паттернов
|
|
||||||
- Тестирование защиты от DDoS
|
|
||||||
|
|
||||||
## 🚨 Реагирование на инциденты
|
|
||||||
|
|
||||||
### При обнаружении атаки
|
|
||||||
1. **Анализ логов**: определение источника и типа атаки
|
|
||||||
2. **Блокировка IP**: добавление в firewall rules
|
|
||||||
3. **Масштабирование**: увеличение ресурсов при необходимости
|
|
||||||
4. **Документирование**: запись деталей для улучшения защиты
|
|
||||||
|
|
||||||
### Команды для экстренного реагирования
|
|
||||||
```bash
|
|
||||||
# Блокировка IP через Redis
|
|
||||||
redis-cli SET "rate_limit:general:192.168.1.100" '{"blocked_until":9999999999}'
|
|
||||||
|
|
||||||
# Проверка активных атак
|
|
||||||
grep "Rate limit exceeded" /var/log/quoter.log | tail -20
|
|
||||||
|
|
||||||
# Очистка всех rate limits (экстренная мера)
|
|
||||||
redis-cli FLUSHDB
|
|
||||||
```
|
|
||||||
363
docs/testing.md
363
docs/testing.md
@@ -1,363 +0,0 @@
|
|||||||
# Тестирование
|
|
||||||
|
|
||||||
Этот документ описывает подход к тестированию проекта Quoter.
|
|
||||||
|
|
||||||
## Обзор
|
|
||||||
|
|
||||||
Проект использует комплексное тестирование для проверки функциональности без внешних зависимостей. Тесты написаны на Rust с использованием фреймворка Actix Web для тестирования HTTP endpoints.
|
|
||||||
|
|
||||||
### Статистика тестов
|
|
||||||
- **Всего тестов:** 36
|
|
||||||
- **basic_test.rs:** 23 теста
|
|
||||||
- **handler_tests.rs:** 13 тестов
|
|
||||||
- **Покрытие:** 100% основных компонентов
|
|
||||||
- **Статус:** ✅ Все тесты проходят успешно
|
|
||||||
|
|
||||||
## Запуск тестов
|
|
||||||
|
|
||||||
### Локально
|
|
||||||
```bash
|
|
||||||
# Все тесты
|
|
||||||
cargo test
|
|
||||||
|
|
||||||
# Конкретный тест
|
|
||||||
cargo test test_health_check
|
|
||||||
|
|
||||||
# Тесты с выводом
|
|
||||||
cargo test -- --nocapture
|
|
||||||
|
|
||||||
# Тесты конкретного файла
|
|
||||||
cargo test --test basic_test
|
|
||||||
cargo test --test handler_tests
|
|
||||||
```
|
|
||||||
|
|
||||||
### В CI/CD конвейере
|
|
||||||
Тесты запускаются автоматически при каждом коммите:
|
|
||||||
1. Компиляция проекта
|
|
||||||
2. Запуск всех unit тестов
|
|
||||||
3. Проверка покрытия кода
|
|
||||||
4. Генерация отчётов
|
|
||||||
5. Автоматический деплой при успехе
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Тесты с покрытием кода
|
|
||||||
```bash
|
|
||||||
# Использование скрипта
|
|
||||||
./scripts/test-coverage.sh
|
|
||||||
|
|
||||||
# Или вручную
|
|
||||||
cargo install cargo-llvm-cov
|
|
||||||
cargo test --tests
|
|
||||||
cargo llvm-cov --lcov --output-path lcov.info
|
|
||||||
cargo llvm-cov --html
|
|
||||||
cargo llvm-cov --summary
|
|
||||||
```
|
|
||||||
|
|
||||||
## Описание тестов
|
|
||||||
|
|
||||||
### 1. Health Check (`test_health_check`)
|
|
||||||
Проверяет работу основного endpoint `/`:
|
|
||||||
- GET запрос возвращает статус 200 и тело "ok"
|
|
||||||
- POST запрос возвращает статус 404 (не найден)
|
|
||||||
|
|
||||||
### 2. JSON Сериализация (`test_json_serialization`)
|
|
||||||
Проверяет корректность сериализации и десериализации JSON:
|
|
||||||
- Создание структуры с данными квоты
|
|
||||||
- Сериализация в JSON строку
|
|
||||||
- Десериализация обратно в структуру
|
|
||||||
- Проверка соответствия данных
|
|
||||||
|
|
||||||
### 3. Multipart Form Data (`test_multipart_form_data`)
|
|
||||||
Проверяет создание multipart form data для загрузки файлов:
|
|
||||||
- Формирование правильного boundary
|
|
||||||
- Добавление заголовков Content-Disposition
|
|
||||||
- Добавление содержимого файла
|
|
||||||
- Проверка корректности структуры
|
|
||||||
|
|
||||||
### 4. UUID Генерация (`test_uuid_generation`)
|
|
||||||
Проверяет работу с UUID:
|
|
||||||
- Генерация уникальных UUID
|
|
||||||
- Проверка формата (36 символов с дефисами)
|
|
||||||
- Парсинг UUID обратно
|
|
||||||
|
|
||||||
### 5. MIME Типы (`test_mime_type_detection`)
|
|
||||||
Проверяет определение MIME типов по расширениям файлов:
|
|
||||||
- Поддерживаемые форматы (jpg, png, gif, webp, mp3, wav, mp4)
|
|
||||||
- Неподдерживаемые форматы (pdf, txt)
|
|
||||||
- Регистронезависимость
|
|
||||||
|
|
||||||
### 6. Парсинг путей файлов (`test_file_path_parsing`)
|
|
||||||
Проверяет парсинг путей файлов с размерами:
|
|
||||||
- Извлечение базового имени, ширины и расширения
|
|
||||||
- Обработка путей без размеров
|
|
||||||
- Обработка путей с подчеркиваниями
|
|
||||||
|
|
||||||
### 7. Расчеты квот (`test_quota_calculations`)
|
|
||||||
Проверяет логику расчета квот:
|
|
||||||
- Различные сценарии использования квоты
|
|
||||||
- Проверка превышения лимитов
|
|
||||||
- Корректность математических операций
|
|
||||||
|
|
||||||
### 8. Форматирование размеров (`test_file_size_formatting`)
|
|
||||||
Проверяет форматирование размеров файлов:
|
|
||||||
- Байты, килобайты, мегабайты, гигабайты
|
|
||||||
- Правильное округление
|
|
||||||
- Корректные единицы измерения
|
|
||||||
|
|
||||||
### 9. Обработка ошибок (`test_error_handling`)
|
|
||||||
Проверяет обработку некорректных данных:
|
|
||||||
- Неверный JSON
|
|
||||||
- Неполный JSON
|
|
||||||
- Неверные UUID
|
|
||||||
- Пустые значения
|
|
||||||
|
|
||||||
### 10. Производительность (`test_performance`)
|
|
||||||
Проверяет производительность критических операций:
|
|
||||||
- Генерация UUID (должна быть < 1μs)
|
|
||||||
- JSON сериализация (должна быть < 100μs)
|
|
||||||
- Вывод статистики производительности
|
|
||||||
|
|
||||||
### 11. Thumbnail функции (`test_thumbnail_path_parsing`)
|
|
||||||
Проверяет парсинг путей для thumbnail'ов:
|
|
||||||
- Извлечение размера из имени файла
|
|
||||||
- Обработка различных форматов имен
|
|
||||||
- Корректность разбора компонентов
|
|
||||||
|
|
||||||
### 12. Определение форматов изображений (`test_image_format_detection`)
|
|
||||||
Проверяет определение форматов изображений:
|
|
||||||
- Поддержка JPG, PNG, GIF, WebP
|
|
||||||
- Конвертация HEIC, TIFF в JPEG
|
|
||||||
- Обработка неподдерживаемых форматов
|
|
||||||
|
|
||||||
### 13. Поиск ближайшей ширины (`test_find_closest_width`)
|
|
||||||
Проверяет алгоритм поиска оптимального размера:
|
|
||||||
- Точные совпадения
|
|
||||||
- Поиск ближайшего размера
|
|
||||||
- Обработка граничных случаев
|
|
||||||
|
|
||||||
### 14. Lookup функции (`test_lookup_functions`)
|
|
||||||
Проверяет функции поиска и определения типов:
|
|
||||||
- Определение MIME типов
|
|
||||||
- Поиск файлов по паттернам
|
|
||||||
- Обработка различных расширений
|
|
||||||
|
|
||||||
### 15. S3 утилиты (`test_s3_utils_functions`)
|
|
||||||
Проверяет функции работы с S3:
|
|
||||||
- Получение списка файлов
|
|
||||||
- Проверка существования файлов
|
|
||||||
- Загрузка файлов из S3
|
|
||||||
|
|
||||||
### 16. Overlay функции (`test_overlay_functions`)
|
|
||||||
Проверяет генерацию оверлеев:
|
|
||||||
- Обработка пустых данных
|
|
||||||
- Обработка некорректных ID
|
|
||||||
- Возврат оригинальных данных при ошибках
|
|
||||||
|
|
||||||
### 17. Core функции (`test_core_functions`)
|
|
||||||
Проверяет основную бизнес-логику:
|
|
||||||
- Получение shout по ID
|
|
||||||
- Обработка некорректных ID
|
|
||||||
- Обработка граничных случаев
|
|
||||||
|
|
||||||
### 18. Auth функции (`test_auth_functions`)
|
|
||||||
Проверяет функции аутентификации:
|
|
||||||
- Проверка токенов
|
|
||||||
- Управление файлами пользователей
|
|
||||||
- Обработка неверных данных
|
|
||||||
|
|
||||||
### 19. App State функции (`test_app_state_functions`)
|
|
||||||
Проверяет управление состоянием приложения:
|
|
||||||
- Структура AppState
|
|
||||||
- Моки для Redis и S3 клиентов
|
|
||||||
- Корректность инициализации
|
|
||||||
|
|
||||||
### 20. Handlers функции (`test_handlers_functions`)
|
|
||||||
Проверяет HTTP обработчики:
|
|
||||||
- Все основные endpoints
|
|
||||||
- Корректность ответов
|
|
||||||
- Обработка запросов
|
|
||||||
|
|
||||||
### 21. Интеграционные тесты (`test_integration`)
|
|
||||||
Проверяет взаимодействие компонентов:
|
|
||||||
- Работа thumbnail и lookup функций
|
|
||||||
- Корректность парсинга путей
|
|
||||||
- Определение MIME типов
|
|
||||||
|
|
||||||
### 22. Граничные случаи (`test_edge_cases`)
|
|
||||||
Проверяет обработку особых ситуаций:
|
|
||||||
- Пустые строки и пути
|
|
||||||
- Очень длинные имена файлов
|
|
||||||
- Специальные символы
|
|
||||||
|
|
||||||
### 23. Производительность парсинга (`test_parsing_performance`)
|
|
||||||
Проверяет скорость парсинга путей:
|
|
||||||
- 10,000 итераций для каждого пути
|
|
||||||
- Порог производительности: < 2,000 нс
|
|
||||||
- Статистика по времени выполнения
|
|
||||||
|
|
||||||
### 24. HTTP Handler тесты (`handler_tests.rs`)
|
|
||||||
Проверяет все HTTP endpoints:
|
|
||||||
- Тесты квот (get, increase, set)
|
|
||||||
- Тесты загрузки файлов
|
|
||||||
- Тесты прокси и serve_file
|
|
||||||
- Тесты CORS и заголовков
|
|
||||||
- Тесты различных HTTP методов
|
|
||||||
- Тесты обработки ошибок
|
|
||||||
|
|
||||||
## CI/CD интеграция
|
|
||||||
|
|
||||||
### Автоматизация тестов
|
|
||||||
- Все тесты запускаются автоматически в CI конвейере
|
|
||||||
- Проверка компиляции и выполнения тестов
|
|
||||||
- Генерация отчётов о покрытии кода
|
|
||||||
- Автоматический деплой при успешном прохождении
|
|
||||||
|
|
||||||
### Статус конвейера
|
|
||||||
- ✅ Тесты компилируются без ошибок
|
|
||||||
- ✅ Все 36 тестов проходят успешно
|
|
||||||
- ✅ Покрытие кода 100% основных компонентов
|
|
||||||
- 🚀 Готов к автоматическому деплою
|
|
||||||
|
|
||||||
## Принципы тестирования
|
|
||||||
|
|
||||||
### 1. Изоляция
|
|
||||||
- Тесты не зависят от внешних сервисов (Redis, S3)
|
|
||||||
- Каждый тест независим от других
|
|
||||||
- Используются моки и заглушки
|
|
||||||
|
|
||||||
### 2. Моки и заглушки
|
|
||||||
- Локальные моки для всех внешних функций
|
|
||||||
- Моки для Redis соединений и S3 клиентов
|
|
||||||
- Моки для HTTP handlers и бизнес-логики
|
|
||||||
- Заглушки для сложных операций
|
|
||||||
|
|
||||||
### 3. Совместимость с Actix Web
|
|
||||||
- Использование актуального API тестов
|
|
||||||
- Правильная обработка async/await
|
|
||||||
- Корректная работа с lifetime
|
|
||||||
- Тестирование всех HTTP методов
|
|
||||||
|
|
||||||
### 4. Покрытие
|
|
||||||
- Тестируются основные функции
|
|
||||||
- Проверяются граничные случаи
|
|
||||||
- Тестируется обработка ошибок
|
|
||||||
|
|
||||||
### 3. Производительность
|
|
||||||
- Тесты должны выполняться быстро
|
|
||||||
- Проверяется производительность критических операций
|
|
||||||
- Устанавливаются временные лимиты
|
|
||||||
|
|
||||||
### 4. Читаемость
|
|
||||||
- Понятные названия тестов
|
|
||||||
- Описательные сообщения об ошибках
|
|
||||||
- Комментарии к сложной логике
|
|
||||||
|
|
||||||
## Добавление новых тестов
|
|
||||||
|
|
||||||
### 1. Создание теста
|
|
||||||
```rust
|
|
||||||
#[test]
|
|
||||||
async fn test_new_feature() {
|
|
||||||
// Подготовка
|
|
||||||
let test_data = create_test_data();
|
|
||||||
|
|
||||||
// Выполнение
|
|
||||||
let result = process_data(test_data);
|
|
||||||
|
|
||||||
// Проверка
|
|
||||||
assert!(result.is_ok());
|
|
||||||
assert_eq!(result.unwrap(), expected_value);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Тестирование HTTP endpoints
|
|
||||||
```rust
|
|
||||||
#[actix_web::test]
|
|
||||||
async fn test_http_endpoint() {
|
|
||||||
let app = test::init_service(
|
|
||||||
App::new()
|
|
||||||
.route("/test", web::get().to(test_handler))
|
|
||||||
).await;
|
|
||||||
|
|
||||||
let req = test::TestRequest::get()
|
|
||||||
.uri("/test")
|
|
||||||
.to_request();
|
|
||||||
|
|
||||||
let resp = test::call_service(&app, req).await;
|
|
||||||
assert!(resp.status().is_success());
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Тестирование производительности
|
|
||||||
```rust
|
|
||||||
#[test]
|
|
||||||
async fn test_performance() {
|
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
let start = Instant::now();
|
|
||||||
let iterations = 10000;
|
|
||||||
|
|
||||||
for _ in 0..iterations {
|
|
||||||
// Тестируемая операция
|
|
||||||
}
|
|
||||||
|
|
||||||
let duration = start.elapsed();
|
|
||||||
let avg_time = duration.as_micros() as f64 / iterations as f64;
|
|
||||||
|
|
||||||
assert!(avg_time < 100.0, "Operation too slow: {:.2} μs", avg_time);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Лучшие практики
|
|
||||||
|
|
||||||
### 1. Именование
|
|
||||||
- Используйте описательные имена тестов
|
|
||||||
- Группируйте связанные тесты
|
|
||||||
- Используйте префиксы для типов тестов
|
|
||||||
|
|
||||||
### 2. Организация
|
|
||||||
- Разделяйте тесты на логические группы
|
|
||||||
- Используйте модули для организации
|
|
||||||
- Документируйте сложные тесты
|
|
||||||
|
|
||||||
### 3. Надежность
|
|
||||||
- Избегайте хрупких тестов
|
|
||||||
- Не полагайтесь на порядок выполнения
|
|
||||||
- Очищайте состояние после тестов
|
|
||||||
|
|
||||||
### 4. Производительность
|
|
||||||
- Минимизируйте время выполнения
|
|
||||||
- Используйте параллельное выполнение
|
|
||||||
- Оптимизируйте медленные тесты
|
|
||||||
|
|
||||||
## Отладка тестов
|
|
||||||
|
|
||||||
```bash
|
|
||||||
RUST_LOG=debug cargo test --tests -- --nocapture # Вывод отладочной информации
|
|
||||||
cargo test --tests -- --nocapture --test-threads=1 # Продолжение после ошибки
|
|
||||||
```
|
|
||||||
|
|
||||||
## Покрытие кода
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo install cargo-tarpaulin # Установка cargo-tarpaulin
|
|
||||||
cargo tarpaulin --tests # Запуск анализа покрытия
|
|
||||||
```
|
|
||||||
|
|
||||||
## CI/CD интеграция
|
|
||||||
|
|
||||||
Тесты автоматически запускаются в Gitea Actions:
|
|
||||||
|
|
||||||
### Workflows
|
|
||||||
- **CI** (`.gitea/workflows/main.yml`) - основной CI pipeline
|
|
||||||
- Тестирование с покрытием кода
|
|
||||||
- Линтинг (rustfmt, clippy)
|
|
||||||
- Генерация артефактов покрытия
|
|
||||||
- **Release** (`.gitea/workflows/release.yml`) - создание релизов
|
|
||||||
- Сборка release версии
|
|
||||||
- Создание GitHub release
|
|
||||||
- Загрузка бинарных файлов
|
|
||||||
- **Deploy** (`.gitea/workflows/deploy.yml`) - деплой
|
|
||||||
- Автоматический деплой на staging
|
|
||||||
- Запускается после успешного CI
|
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
# 📤 API загрузки файлов с квотами - Точная документация
|
|
||||||
|
|
||||||
## Обзор
|
|
||||||
|
|
||||||
Quoter предоставляет API для загрузки файлов с системой квот и автоматической обработкой различных типов медиа.
|
|
||||||
|
|
||||||
## Базовый URL
|
|
||||||
```
|
|
||||||
http://localhost:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
## Аутентификация
|
|
||||||
Все эндпоинты загрузки требуют JWT токен в заголовке:
|
|
||||||
```
|
|
||||||
Authorization: Bearer <jwt-token>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📤 Загрузка файлов (УЛУЧШЕННАЯ ВЕРСИЯ)
|
|
||||||
|
|
||||||
### POST /
|
|
||||||
|
|
||||||
Загружает файл(ы) в S3-совместимое хранилище (Storj) с улучшенной проверкой квот и валидацией.
|
|
||||||
|
|
||||||
#### Заголовки запроса
|
|
||||||
```http
|
|
||||||
Authorization: Bearer <jwt-token>
|
|
||||||
Content-Type: multipart/form-data
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Параметры
|
|
||||||
- **file** (required) - файл(ы) для загрузки в multipart/form-data
|
|
||||||
|
|
||||||
#### Поддерживаемые форматы
|
|
||||||
Автоматическое определение MIME-типа из содержимого файла:
|
|
||||||
- **Изображения**: JPEG, PNG, GIF, WebP, HEIC
|
|
||||||
- **Видео**: MP4, WebM, AVI
|
|
||||||
- **Аудио**: MP3, WAV, OGG
|
|
||||||
- **Документы**: PDF
|
|
||||||
|
|
||||||
#### 🔄 Улучшенная логика обработки
|
|
||||||
|
|
||||||
1. **Проверка авторизации** - извлечение и валидация JWT токена
|
|
||||||
2. **Получение текущей квоты** пользователя из Redis
|
|
||||||
3. **Предварительная проверка квоты** - пользователь не достиг лимита
|
|
||||||
4. **Streaming чтение файла** с проверками на каждом chunk:
|
|
||||||
- Проверка лимита одного файла (500 МБ)
|
|
||||||
- Проверка общей квоты пользователя
|
|
||||||
5. **Пропуск пустых файлов**
|
|
||||||
6. **Определение MIME-типа** из содержимого (не из расширения!)
|
|
||||||
7. **Генерация UUID имени** файла с правильным расширением
|
|
||||||
8. **Загрузка в Storj S3**
|
|
||||||
9. **Обновление квоты** пользователя
|
|
||||||
10. **Сохранение метаданных** в Redis (с обработкой ошибок)
|
|
||||||
|
|
||||||
#### Ограничения
|
|
||||||
|
|
||||||
- **Максимальная квота на пользователя**: 5 ГБ (5,368,709,120 байт)
|
|
||||||
- **Максимальный размер одного файла**: 500 МБ (524,288,000 байт)
|
|
||||||
- **Проверка квоты происходит во время чтения** (streaming)
|
|
||||||
- **Поддержка множественных файлов** в одном запросе
|
|
||||||
|
|
||||||
#### Успешные ответы
|
|
||||||
|
|
||||||
**Один файл:**
|
|
||||||
```http
|
|
||||||
HTTP/1.1 200 OK
|
|
||||||
Content-Type: text/plain
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
**Несколько файлов:**
|
|
||||||
```http
|
|
||||||
HTTP/1.1 200 OK
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Коды ошибок (ИСПРАВЛЕННЫЕ)
|
|
||||||
|
|
||||||
| Код | Условие | Описание |
|
|
||||||
|-----|---------|----------|
|
|
||||||
| **400 Bad Request** | Нет файлов | `"No files provided or all files were empty"` |
|
|
||||||
| **401 Unauthorized** | Отсутствует токен | `"Authorization token required"` |
|
|
||||||
| **401 Unauthorized** | Неверный токен | `"Invalid authorization token"` |
|
|
||||||
| **413 Payload Too Large** | 🎯 Превышена квота | `"Author quota limit exceeded"` |
|
|
||||||
| **413 Payload Too Large** | 🎯 Большой файл | `"Single file size limit exceeded"` |
|
|
||||||
| **413 Payload Too Large** | 🎯 Превышение при загрузке | `"Author quota limit would be exceeded"` |
|
|
||||||
| **415 Unsupported Media Type** | Неподдерживаемый MIME | `"Unsupported file format"` |
|
|
||||||
| **415 Unsupported Media Type** | Нет расширения для MIME | `"Unsupported content type"` |
|
|
||||||
| **500 Internal Server Error** | Ошибка S3 | `"File upload failed"` |
|
|
||||||
| **500 Internal Server Error** | Ошибка квоты | `"Failed to update user quota"` |
|
|
||||||
|
|
||||||
#### ✅ Исправленные проблемы
|
|
||||||
|
|
||||||
1. **Правильный код ошибки для квоты**: 413 Payload Too Large
|
|
||||||
2. **Efficient memory usage**: streaming с проверками на каждом chunk
|
|
||||||
3. **Предварительная проверка квоты** перед началом загрузки
|
|
||||||
4. **Лимит размера одного файла**: 500 МБ
|
|
||||||
5. **Улучшенная обработка ошибок** с детальными сообщениями
|
|
||||||
6. **Поддержка множественных файлов** в одном запросе
|
|
||||||
7. **Детальное логирование** с процентом использования квоты
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 🔄 Как это работает
|
|
||||||
|
|
||||||
1. **JWT декодирование** - извлекается `user_id` из токена
|
|
||||||
2. **Redis lookup** - опциональный поиск сессии по ключу `session:{user_id}:{token}`
|
|
||||||
3. **Quota lookup** - получение квоты по ключу `quota:{user_id}` из Redis
|
|
||||||
4. **Activity update** - обновление `last_activity` timestamp (если сессия найдена)
|
|
||||||
5. **Response building** - объединение данных пользователя и квоты
|
|
||||||
|
|
||||||
#### Заголовки запроса
|
|
||||||
```http
|
|
||||||
Authorization: Bearer <jwt-token>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Успешный ответ
|
|
||||||
```http
|
|
||||||
HTTP/1.1 200 OK
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Поля ответа
|
|
||||||
|
|
||||||
| Поле | Тип | Описание |
|
|
||||||
|------|-----|----------|
|
|
||||||
| `user_id` | string | Уникальный ID пользователя |
|
|
||||||
| `username` | string \| null | Имя пользователя |
|
|
||||||
| `token_type` | string \| null | Тип токена (обычно "session") |
|
|
||||||
| `created_at` | string \| null | Unix timestamp создания сессии |
|
|
||||||
| `last_activity` | string \| null | Unix timestamp последней активности |
|
|
||||||
| `auth_data` | string \| null | JSON-строка с данными авторизации |
|
|
||||||
| `device_info` | string \| null | JSON-строка с информацией об устройстве |
|
|
||||||
| `quota.current_quota` | number | Текущее использование квоты в байтах |
|
|
||||||
| `quota.max_quota` | number | Максимальная квота в байтах |
|
|
||||||
| `quota.usage_percentage` | number | Процент использования квоты |
|
|
||||||
|
|
||||||
#### Коды ошибок
|
|
||||||
|
|
||||||
| Код | Условие | Описание |
|
|
||||||
|-----|---------|----------|
|
|
||||||
| **401 Unauthorized** | Отсутствует токен | `"Authorization token required"` |
|
|
||||||
| **401 Unauthorized** | Неверный JWT | `"Invalid or expired session token"` |
|
|
||||||
| **401 Unauthorized** | Сессия не найдена | `"Session not found or expired"` |
|
|
||||||
|
|
||||||
#### Примеры использования
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Получение информации о текущем пользователе
|
|
||||||
curl -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGc..." \
|
|
||||||
http://localhost:8080/
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Управление квотами
|
|
||||||
|
|
||||||
### GET /quota
|
|
||||||
|
|
||||||
Получает информацию о квоте пользователя.
|
|
||||||
|
|
||||||
#### Параметры запроса
|
|
||||||
```
|
|
||||||
GET /quota?user_id=<user_id>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Ответ
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"user_id": "user123",
|
|
||||||
"current_quota": 1073741824,
|
|
||||||
"max_quota": 5368709120
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### POST /quota/increase
|
|
||||||
|
|
||||||
Увеличивает квоту пользователя (admin-only).
|
|
||||||
|
|
||||||
#### Тело запроса
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"user_id": "user123",
|
|
||||||
"additional_bytes": 1073741824
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Валидация
|
|
||||||
- `additional_bytes` должно быть > 0
|
|
||||||
- Требуется админский токен
|
|
||||||
|
|
||||||
### POST /quota/set
|
|
||||||
|
|
||||||
Устанавливает абсолютное значение квоты (admin-only).
|
|
||||||
|
|
||||||
#### Тело запроса
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"user_id": "user123",
|
|
||||||
"new_quota_bytes": 2147483648
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 Получение файлов
|
|
||||||
|
|
||||||
### GET /{filename}
|
|
||||||
|
|
||||||
Возвращает файл по имени с возможными трансформациями.
|
|
||||||
|
|
||||||
#### Параметры URL
|
|
||||||
- `filename` - имя файла или имя_размер.расширение для миниатюр
|
|
||||||
|
|
||||||
#### Query параметры
|
|
||||||
- `s=<shout_id>` - добавляет оверлей с данными shout (только изображения)
|
|
||||||
|
|
||||||
#### Примеры
|
|
||||||
```bash
|
|
||||||
GET /uuid-file.jpg # Оригинальный файл
|
|
||||||
GET /uuid-file_300.jpg # Миниатюра 300px
|
|
||||||
GET /uuid-file_300.jpg/webp # Миниатюра в WebP
|
|
||||||
GET /uuid-file.jpg?s=123 # С оверлеем shout
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 Примеры использования
|
|
||||||
|
|
||||||
### Загрузка файла
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8080/ \
|
|
||||||
-H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGc..." \
|
|
||||||
-F "file=@photo.jpg"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Ответ при успехе:**
|
|
||||||
```
|
|
||||||
c4ca4238-a0b9-23f1-8429-81dc9bdb9a1f.jpg
|
|
||||||
```
|
|
||||||
|
|
||||||
**Ответ при превышении квоты:**
|
|
||||||
```
|
|
||||||
HTTP/1.1 401 Unauthorized
|
|
||||||
Quota exceeded
|
|
||||||
```
|
|
||||||
|
|
||||||
### Проверка квоты
|
|
||||||
```bash
|
|
||||||
curl "http://localhost:8080/quota?user_id=user123" \
|
|
||||||
-H "Authorization: Bearer admin-token"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Увеличение квоты (admin)
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8080/quota/increase \
|
|
||||||
-H "Authorization: Bearer admin-token" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"user_id": "user123", "additional_bytes": 1073741824}'
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Рекомендации по улучшению
|
|
||||||
|
|
||||||
1. **Исправить код ошибки квоты**: 401 → 413
|
|
||||||
2. **Добавить предварительную проверку размера** из Content-Length
|
|
||||||
3. **Streaming загрузка** вместо полного чтения в память
|
|
||||||
4. **Лимит размера одного файла**
|
|
||||||
5. **Детальная валидация MIME-типов**
|
|
||||||
6. **Метрики использования квот**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Документация актуальна для версии кода на момент создания. Для изменений см. CHANGELOG.md.*
|
|
||||||
-H "Authorization: Bearer admin-token" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"user_id": "user123", "additional_bytes": 1073741824}'
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Рекомендации по улучшению
|
|
||||||
|
|
||||||
1. **Исправить код ошибки квоты**: 401 → 413
|
|
||||||
2. **Добавить предварительную проверку размера** из Content-Length
|
|
||||||
3. **Streaming загрузка** вместо полного чтения в память
|
|
||||||
4. **Лимит размера одного файла**
|
|
||||||
5. **Детальная валидация MIME-типов**
|
|
||||||
6. **Метрики использования квот**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Документация актуальна для версии кода на момент создания. Для изменений см. CHANGELOG.md.*
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
# 📐 Формат URL для ресайзера изображений
|
|
||||||
|
|
||||||
## Обзор
|
|
||||||
|
|
||||||
Quoter поддерживает автоматическое изменение размера изображений через URL параметры. Система автоматически генерирует миниатюры в предопределенных размерах и возвращает ближайший подходящий размер.
|
|
||||||
|
|
||||||
## 🎯 Поддерживаемые размеры
|
|
||||||
|
|
||||||
### Предопределенные ширины
|
|
||||||
```rust
|
|
||||||
[10, 40, 110, 300, 600, 800, 1400] // пикселей по ширине
|
|
||||||
```
|
|
||||||
|
|
||||||
- **10px** - микро-превью
|
|
||||||
- **40px** - аватары, иконки
|
|
||||||
- **110px** - маленькие превью
|
|
||||||
- **300px** - средние превью
|
|
||||||
- **600px** - стандартные изображения
|
|
||||||
- **800px** - большие изображения
|
|
||||||
- **1400px** - максимальный размер
|
|
||||||
|
|
||||||
## 📝 Синтаксис URL
|
|
||||||
|
|
||||||
### 1. Современный формат (рекомендуется)
|
|
||||||
```
|
|
||||||
GET /{filename}_{width}.{extension}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Примеры:**
|
|
||||||
```bash
|
|
||||||
# Запрос изображения шириной 300px
|
|
||||||
GET /439efaa0-816f-11ef-b201-439da98539bc_300.jpg
|
|
||||||
|
|
||||||
# Запрос изображения шириной 600px
|
|
||||||
GET /5627e002-0c53-11ee-9565-0242ac110006_600.png
|
|
||||||
|
|
||||||
# Запрос оригинального размера (без ресайза)
|
|
||||||
GET /439efaa0-816f-11ef-b201-439da98539bc.jpg
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Legacy формат (поддерживается)
|
|
||||||
```
|
|
||||||
GET /unsafe/{width}x/production/image/{filename}.{extension}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Примеры:**
|
|
||||||
```bash
|
|
||||||
# Legacy формат с указанием ширины
|
|
||||||
GET /unsafe/1440x/production/image/439efaa0-816f-11ef-b201-439da98539bc.jpg
|
|
||||||
|
|
||||||
# Legacy формат без ресайза
|
|
||||||
GET /unsafe/production/image/5627e002-0c53-11ee-9565-0242ac110006.png
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Конверсия формата
|
|
||||||
```
|
|
||||||
GET /{filename}.{extension}/webp
|
|
||||||
```
|
|
||||||
|
|
||||||
**Примеры:**
|
|
||||||
```bash
|
|
||||||
# Конверсия в WebP
|
|
||||||
GET /439efaa0-816f-11ef-b201-439da98539bc.jpg/webp
|
|
||||||
|
|
||||||
# Конверсия с ресайзом
|
|
||||||
GET /439efaa0-816f-11ef-b201-439da98539bc_600.jpg/webp
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔧 Логика обработки
|
|
||||||
|
|
||||||
### Алгоритм выбора размера
|
|
||||||
1. **Точное совпадение**: Если запрошенная ширина есть в предопределенных размерах
|
|
||||||
2. **Ближайший размер**: Выбирается размер с минимальной разностью
|
|
||||||
3. **Максимальный лимит**: Если запрошенная ширина > 1400px, возвращается 1400px
|
|
||||||
4. **Оригинал**: Если ширина не указана (0), возвращается оригинальное изображение
|
|
||||||
|
|
||||||
### Примеры выбора размера
|
|
||||||
```bash
|
|
||||||
# Запрос 150px → вернет 110px (ближайший меньший)
|
|
||||||
# Запрос 250px → вернет 300px (ближайший больший)
|
|
||||||
# Запрос 2000px → вернет 1400px (максимальный)
|
|
||||||
# Запрос 299px → вернет 300px (ближайший)
|
|
||||||
# Запрос 301px → вернет 300px (ближайший)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Генерация миниатюр
|
|
||||||
- **Lazy generation**: Миниатюры создаются по первому запросу
|
|
||||||
- **Асинхронная обработка**: Генерация происходит в фоне
|
|
||||||
- **Кэширование**: Созданные миниатюры сохраняются в S3
|
|
||||||
- **Fallback**: При отсутствии миниатюры возвращается оригинал
|
|
||||||
|
|
||||||
## 🎨 Поддерживаемые форматы
|
|
||||||
|
|
||||||
### Входные форматы
|
|
||||||
- **JPEG** (`.jpg`, `.jpeg`)
|
|
||||||
- **PNG** (`.png`)
|
|
||||||
- **GIF** (`.gif`)
|
|
||||||
- **WebP** (`.webp`)
|
|
||||||
- **HEIC** (`.heic`, `.heif`) - конвертируется в JPEG
|
|
||||||
- **TIFF** (`.tiff`, `.tif`) - конвертируется в JPEG
|
|
||||||
|
|
||||||
### Выходные форматы
|
|
||||||
- **Сохраняется исходный формат** (кроме HEIC/TIFF → JPEG)
|
|
||||||
- **WebP конверсия** через `/webp` суффикс
|
|
||||||
- **Автоматическая оптимизация** для web
|
|
||||||
|
|
||||||
## 🚀 HTTP заголовки
|
|
||||||
|
|
||||||
### Кэширование
|
|
||||||
```http
|
|
||||||
ETag: "filename.ext"
|
|
||||||
Cache-Control: public, max-age=31536000, immutable
|
|
||||||
Access-Control-Allow-Origin: *
|
|
||||||
```
|
|
||||||
|
|
||||||
### Условные запросы
|
|
||||||
```http
|
|
||||||
# Клиент отправляет
|
|
||||||
If-None-Match: "filename.ext"
|
|
||||||
|
|
||||||
# Сервер отвечает (если не изменено)
|
|
||||||
HTTP/1.1 304 Not Modified
|
|
||||||
```
|
|
||||||
|
|
||||||
## 💡 Оптимизация производительности
|
|
||||||
|
|
||||||
### Клиентская оптимизация
|
|
||||||
```html
|
|
||||||
<!-- Используйте srcset для разных размеров -->
|
|
||||||
<img src="/image_600.jpg"
|
|
||||||
srcset="/image_300.jpg 300w,
|
|
||||||
/image_600.jpg 600w,
|
|
||||||
/image_800.jpg 800w"
|
|
||||||
sizes="(max-width: 600px) 300px, 600px"
|
|
||||||
alt="Описание">
|
|
||||||
|
|
||||||
<!-- WebP с fallback -->
|
|
||||||
<picture>
|
|
||||||
<source srcset="/image_600.jpg/webp" type="image/webp">
|
|
||||||
<img src="/image_600.jpg" alt="Описание">
|
|
||||||
</picture>
|
|
||||||
```
|
|
||||||
|
|
||||||
### API использование
|
|
||||||
```javascript
|
|
||||||
// Функция для получения оптимального URL
|
|
||||||
function getImageUrl(filename, maxWidth) {
|
|
||||||
const sizes = [10, 40, 110, 300, 600, 800, 1400];
|
|
||||||
const optimalSize = sizes.find(size => size >= maxWidth) || 1400;
|
|
||||||
|
|
||||||
const [name, ext] = filename.split('.');
|
|
||||||
return `https://files.dscrs.site/${name}_${optimalSize}.${ext}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Примеры использования
|
|
||||||
const thumbUrl = getImageUrl('image.jpg', 300); // image_300.jpg
|
|
||||||
const fullUrl = getImageUrl('image.jpg', 1200); // image_1400.jpg
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔍 Мониторинг и отладка
|
|
||||||
|
|
||||||
### Логи сервера
|
|
||||||
```log
|
|
||||||
# Успешная обработка
|
|
||||||
INFO GET image_300.jpg [START]
|
|
||||||
INFO Parsed request - base: image, width: 300, ext: jpg
|
|
||||||
INFO Cache hit for image.jpg, returning 304
|
|
||||||
|
|
||||||
# Генерация миниатюры
|
|
||||||
WARN Thumbnail not found, generating: image_300.jpg
|
|
||||||
WARN generate new thumb files: image.jpg
|
|
||||||
INFO Generated thumbnail: image_300.jpg
|
|
||||||
```
|
|
||||||
|
|
||||||
### Проверка через API
|
|
||||||
```bash
|
|
||||||
# Проверка существования файла
|
|
||||||
curl -I https://files.dscrs.site/image_300.jpg
|
|
||||||
|
|
||||||
# Проверка с условным запросом
|
|
||||||
curl -H "If-None-Match: \"image.jpg\"" https://files.dscrs.site/image_300.jpg
|
|
||||||
```
|
|
||||||
|
|
||||||
## ⚠️ Ограничения и рекомендации
|
|
||||||
|
|
||||||
### Лимиты
|
|
||||||
- **Максимальная ширина**: 1400px
|
|
||||||
- **Поддерживаемые форматы**: см. список выше
|
|
||||||
- **Размер файла**: до 500MB для загрузки
|
|
||||||
|
|
||||||
### Рекомендации
|
|
||||||
1. **Используйте WebP** для лучшего сжатия
|
|
||||||
2. **Кэшируйте на CDN** для лучшей производительности
|
|
||||||
3. **Указывайте размеры заранее** для избежания layout shift
|
|
||||||
4. **Используйте lazy loading** для изображений вне viewport
|
|
||||||
|
|
||||||
### Troubleshooting
|
|
||||||
```bash
|
|
||||||
# Если изображение не отображается
|
|
||||||
1. Проверьте формат файла (поддерживается ли)
|
|
||||||
2. Проверьте размер запроса (не превышает ли лимиты)
|
|
||||||
3. Проверьте логи сервера на ошибки генерации
|
|
||||||
4. Убедитесь в корректности URL формата
|
|
||||||
```
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
# 🚀 Quick Start: @vercel/og + Quoter
|
|
||||||
|
|
||||||
## ⚡ 5-минутная настройка
|
|
||||||
|
|
||||||
### 1. Установка зависимостей
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install @vercel/og
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Создание API endpoint
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// pages/api/og.tsx (Next.js)
|
|
||||||
import { ImageResponse } from '@vercel/og'
|
|
||||||
|
|
||||||
export const config = { runtime: 'edge' }
|
|
||||||
|
|
||||||
export default function handler(req) {
|
|
||||||
const { searchParams } = new URL(req.url)
|
|
||||||
const title = searchParams.get('title') ?? 'Hello World'
|
|
||||||
|
|
||||||
return new ImageResponse(
|
|
||||||
(
|
|
||||||
<div style={{
|
|
||||||
background: 'white',
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontSize: 60,
|
|
||||||
fontWeight: 700,
|
|
||||||
}}>
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
{ width: 1200, height: 630 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Интеграция с Quoter
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// utils/quoter.ts
|
|
||||||
export async function uploadToQuoter(imageBuffer: Buffer, filename: string, token: string) {
|
|
||||||
const formData = new FormData()
|
|
||||||
const blob = new Blob([imageBuffer], { type: 'image/png' })
|
|
||||||
formData.append('file', blob, filename)
|
|
||||||
|
|
||||||
const response = await fetch('https://quoter.staging.discours.io/', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Authorization': `Bearer ${token}` },
|
|
||||||
body: formData,
|
|
||||||
})
|
|
||||||
|
|
||||||
return response.text() // URL файла
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Использование
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Генерация OG изображения
|
|
||||||
const ogResponse = await fetch('/api/og?title=My%20Amazing%20Post')
|
|
||||||
const imageBuffer = Buffer.from(await ogResponse.arrayBuffer())
|
|
||||||
|
|
||||||
// Загрузка в Quoter
|
|
||||||
const quoterUrl = await uploadToQuoter(imageBuffer, 'og-image.png', userToken)
|
|
||||||
|
|
||||||
// Использование в meta tags
|
|
||||||
<meta property="og:image" content={quoterUrl} />
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎨 Расширенный пример с изображением фона
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// pages/api/og-advanced.tsx
|
|
||||||
import { ImageResponse } from '@vercel/og'
|
|
||||||
|
|
||||||
export default async function handler(req) {
|
|
||||||
const { searchParams } = new URL(req.url)
|
|
||||||
const title = searchParams.get('title')
|
|
||||||
const imageUrl = searchParams.get('image') // Quoter URL
|
|
||||||
|
|
||||||
// Загружаем изображение из Quoter
|
|
||||||
let backgroundImage = null
|
|
||||||
if (imageUrl) {
|
|
||||||
const imageResponse = await fetch(imageUrl)
|
|
||||||
const buffer = await imageResponse.arrayBuffer()
|
|
||||||
backgroundImage = `data:image/jpeg;base64,${Buffer.from(buffer).toString('base64')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ImageResponse(
|
|
||||||
(
|
|
||||||
<div style={{
|
|
||||||
background: backgroundImage ? `url(${backgroundImage})` : '#667eea',
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}>
|
|
||||||
{/* Затемнение для читаемости */}
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
background: 'rgba(0,0,0,0.4)',
|
|
||||||
}} />
|
|
||||||
|
|
||||||
{/* Заголовок */}
|
|
||||||
<h1 style={{
|
|
||||||
fontSize: 72,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color: 'white',
|
|
||||||
textAlign: 'center',
|
|
||||||
textShadow: '2px 2px 4px rgba(0,0,0,0.8)',
|
|
||||||
zIndex: 1,
|
|
||||||
}}>
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
{ width: 1200, height: 630 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📱 React Hook для удобства
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// hooks/useOgImage.ts
|
|
||||||
import { useState } from 'react'
|
|
||||||
|
|
||||||
export function useOgImage() {
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
|
|
||||||
const generateAndUpload = async (title: string, backgroundImage?: string) => {
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
// Генерируем OG изображение
|
|
||||||
const ogUrl = `/api/og?title=${encodeURIComponent(title)}${
|
|
||||||
backgroundImage ? `&image=${encodeURIComponent(backgroundImage)}` : ''
|
|
||||||
}`
|
|
||||||
|
|
||||||
const response = await fetch(ogUrl)
|
|
||||||
const buffer = await response.arrayBuffer()
|
|
||||||
|
|
||||||
// Загружаем в Quoter
|
|
||||||
const formData = new FormData()
|
|
||||||
const blob = new Blob([buffer], { type: 'image/png' })
|
|
||||||
formData.append('file', blob, `og-${Date.now()}.png`)
|
|
||||||
|
|
||||||
const uploadResponse = await fetch('/api/upload-to-quoter', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
})
|
|
||||||
|
|
||||||
return await uploadResponse.text()
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { generateAndUpload, loading }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Использование в компоненте
|
|
||||||
function MyComponent() {
|
|
||||||
const { generateAndUpload, loading } = useOgImage()
|
|
||||||
|
|
||||||
const handleCreateOg = async () => {
|
|
||||||
const quoterUrl = await generateAndUpload('My Post Title', '/existing-image.jpg')
|
|
||||||
console.log('OG image uploaded to:', quoterUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button onClick={handleCreateOg} disabled={loading}>
|
|
||||||
{loading ? 'Generating...' : 'Create OG Image'}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## ⚡ Production Tips
|
|
||||||
|
|
||||||
### Кэширование
|
|
||||||
```typescript
|
|
||||||
// Cache OG images for 24 hours
|
|
||||||
export default function handler(req) {
|
|
||||||
const response = new ImageResponse(/* ... */)
|
|
||||||
|
|
||||||
response.headers.set('Cache-Control', 'public, max-age=86400')
|
|
||||||
response.headers.set('CDN-Cache-Control', 'public, max-age=86400')
|
|
||||||
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
```typescript
|
|
||||||
export default async function handler(req) {
|
|
||||||
try {
|
|
||||||
return new ImageResponse(/* ... */)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('OG generation failed:', error)
|
|
||||||
|
|
||||||
// Fallback изображение
|
|
||||||
return new Response('Failed to generate image', { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
```bash
|
|
||||||
# .env.local
|
|
||||||
QUOTER_API_URL=https://quoter.staging.discours.io
|
|
||||||
QUOTER_AUTH_TOKEN=your_jwt_token
|
|
||||||
NEXT_PUBLIC_OG_BASE_URL=https://yoursite.com/api/og
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔗 Полезные ссылки
|
|
||||||
|
|
||||||
- [Полная документация по интеграции](./vercel-og-integration.md)
|
|
||||||
- [Quoter API Reference](./api-reference.md)
|
|
||||||
- [Vercel OG Official Docs](https://vercel.com/docs/concepts/functions/edge-functions/og-image-generation)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
💋 **Упрощение**: Один endpoint для генерации, один для загрузки - минимум кода, максимум результата!
|
|
||||||
363
docs/vercel-thumbnails.md
Normal file
363
docs/vercel-thumbnails.md
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
# Vercel Thumbnail Generation Integration
|
||||||
|
|
||||||
|
## 🎯 Overview
|
||||||
|
|
||||||
|
**Quoter**: Dead simple file upload/download service. Just raw files.
|
||||||
|
**Vercel**: Smart thumbnail generation and optimization.
|
||||||
|
|
||||||
|
Perfect separation of concerns! 💋
|
||||||
|
|
||||||
|
## 🔗 URL Patterns for Vercel
|
||||||
|
|
||||||
|
### Quoter File URLs
|
||||||
|
```
|
||||||
|
https://quoter.discours.io/image.jpg → Original file
|
||||||
|
https://quoter.discours.io/document.pdf → Original file
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vercel Thumbnail URLs (SolidJS)
|
||||||
|
```
|
||||||
|
https://new.discours.io/api/thumb/300/image.jpg → 300px width
|
||||||
|
https://new.discours.io/api/thumb/600/image.jpg → 600px width
|
||||||
|
https://new.discours.io/api/thumb/1200/image.jpg → 1200px width
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛠️ Vercel Configuration
|
||||||
|
|
||||||
|
### 1. SolidJS Start Config (app.config.ts)
|
||||||
|
```typescript
|
||||||
|
import { defineConfig } from '@solidjs/start/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
preset: 'vercel',
|
||||||
|
},
|
||||||
|
vite: {
|
||||||
|
define: {
|
||||||
|
'process.env.QUOTER_URL': JSON.stringify('https://quoter.discours.io'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Thumbnail API Route (/api/thumb/[width]/[...path].ts)
|
||||||
|
```typescript
|
||||||
|
import { ImageResponse } from '@vercel/og';
|
||||||
|
import type { APIRoute } from '@solidjs/start';
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ params, request }) => {
|
||||||
|
const width = parseInt(params.width);
|
||||||
|
const imagePath = params.path.split('/').join('/');
|
||||||
|
const quoterUrl = `https://quoter.discours.io/${imagePath}`;
|
||||||
|
|
||||||
|
// Fetch original from Quoter
|
||||||
|
const response = await fetch(quoterUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
return new Response('Image not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate optimized thumbnail using @vercel/og
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<img
|
||||||
|
src={quoterUrl}
|
||||||
|
style={{
|
||||||
|
width: width,
|
||||||
|
height: 'auto',
|
||||||
|
objectFit: 'contain',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
{
|
||||||
|
width: width,
|
||||||
|
height: Math.round(width * 0.75), // 4:3 aspect ratio
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 File Naming Conventions
|
||||||
|
|
||||||
|
### Quoter Storage (No Width Patterns)
|
||||||
|
```
|
||||||
|
✅ image.jpg → Clean filename
|
||||||
|
✅ photo-2024.png → kebab-case
|
||||||
|
✅ user-avatar.webp → descriptive names
|
||||||
|
✅ document.pdf → any file type
|
||||||
|
|
||||||
|
❌ image_300.jpg → No width patterns needed
|
||||||
|
❌ photo-thumbnail.jpg → No thumbnail suffix
|
||||||
|
❌ userAvatar.png → No camelCase
|
||||||
|
```
|
||||||
|
|
||||||
|
### URL Routing Examples
|
||||||
|
```bash
|
||||||
|
# Client requests thumbnail
|
||||||
|
GET /api/thumb/600/image.jpg
|
||||||
|
|
||||||
|
# Vercel fetches original from Quoter
|
||||||
|
GET https://quoter.discours.io/image.jpg
|
||||||
|
|
||||||
|
# Vercel generates and caches 600px thumbnail
|
||||||
|
→ Returns optimized image
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Benefits of This Architecture
|
||||||
|
|
||||||
|
### For Quoter
|
||||||
|
- **Simple storage**: Just store original files
|
||||||
|
- **No processing**: Zero thumbnail generation load
|
||||||
|
- **Fast uploads**: Direct S3 storage without resizing
|
||||||
|
- **Predictable URLs**: Clean file paths
|
||||||
|
|
||||||
|
### For Vercel
|
||||||
|
- **Edge optimization**: Global CDN caching
|
||||||
|
- **Dynamic sizing**: Any width on-demand
|
||||||
|
- **Smart caching**: Automatic cache invalidation
|
||||||
|
- **Format optimization**: WebP/AVIF when supported
|
||||||
|
|
||||||
|
## 🔧 SolidJS Frontend Integration
|
||||||
|
|
||||||
|
### 1. Install Dependencies
|
||||||
|
```bash
|
||||||
|
npm install @tanstack/solid-query @solidjs/start
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Query Client Setup (app.tsx)
|
||||||
|
```tsx
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/solid-query';
|
||||||
|
import { Router } from '@solidjs/router';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Router>
|
||||||
|
{/* Your app components */}
|
||||||
|
</Router>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. File Upload Hook (hooks/useFileUpload.ts)
|
||||||
|
```tsx
|
||||||
|
import { createMutation, useQueryClient } from '@tanstack/solid-query';
|
||||||
|
|
||||||
|
interface UploadResponse {
|
||||||
|
url: string;
|
||||||
|
filename: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFileUpload() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return createMutation(() => ({
|
||||||
|
mutationFn: async (file: File): Promise<UploadResponse> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const response = await fetch('https://quoter.discours.io/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${getAuthToken()}`,
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Upload failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate user quota query
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['user'] });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Image Component with Thumbnails (components/Image.tsx)
|
||||||
|
```tsx
|
||||||
|
import { createSignal, Show, Switch, Match } from 'solid-js';
|
||||||
|
|
||||||
|
interface ImageProps {
|
||||||
|
filename: string;
|
||||||
|
width?: number;
|
||||||
|
alt: string;
|
||||||
|
fallback?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Image(props: ImageProps) {
|
||||||
|
const [loadError, setLoadError] = createSignal(false);
|
||||||
|
const [loading, setLoading] = createSignal(true);
|
||||||
|
|
||||||
|
const thumbnailUrl = () =>
|
||||||
|
props.width
|
||||||
|
? `https://new.discours.io/api/thumb/${props.width}/${props.filename}`
|
||||||
|
: `https://quoter.discours.io/${props.filename}`;
|
||||||
|
|
||||||
|
const fallbackUrl = () => `https://quoter.discours.io/${props.filename}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Switch>
|
||||||
|
<Match when={loading()}>
|
||||||
|
<div class="bg-gray-200 animate-pulse" style={{ width: `${props.width}px`, height: '200px' }} />
|
||||||
|
</Match>
|
||||||
|
<Match when={!loadError()}>
|
||||||
|
<img
|
||||||
|
src={thumbnailUrl()}
|
||||||
|
alt={props.alt}
|
||||||
|
loading="lazy"
|
||||||
|
onLoad={() => setLoading(false)}
|
||||||
|
onError={() => {
|
||||||
|
setLoading(false);
|
||||||
|
setLoadError(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
<Match when={loadError() && props.fallback !== false}>
|
||||||
|
<img
|
||||||
|
src={fallbackUrl()}
|
||||||
|
alt={props.alt}
|
||||||
|
loading="lazy"
|
||||||
|
onLoad={() => setLoading(false)}
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. User Quota Component (components/UserQuota.tsx)
|
||||||
|
```tsx
|
||||||
|
import { createQuery } from '@tanstack/solid-query';
|
||||||
|
import { Show, Switch, Match } from 'solid-js';
|
||||||
|
|
||||||
|
export function UserQuota() {
|
||||||
|
const query = createQuery(() => ({
|
||||||
|
queryKey: ['user'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch('https://quoter.discours.io/', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${getAuthToken()}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Switch>
|
||||||
|
<Match when={query.isLoading}>
|
||||||
|
<div>Loading quota...</div>
|
||||||
|
</Match>
|
||||||
|
<Match when={query.isError}>
|
||||||
|
<div>Error loading quota</div>
|
||||||
|
</Match>
|
||||||
|
<Match when={query.isSuccess}>
|
||||||
|
<Show when={query.data}>
|
||||||
|
{(data) => (
|
||||||
|
<div>
|
||||||
|
<p>Storage: {data().storage_used_mb}MB / {data().storage_limit_mb}MB</p>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
class="bg-blue-600 h-2 rounded-full"
|
||||||
|
style={{ width: `${(data().storage_used_mb / data().storage_limit_mb) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Implementation Steps
|
||||||
|
|
||||||
|
1. **Quoter**: Serve raw files only (no patterns)
|
||||||
|
2. **Vercel**: Create SolidJS API routes for thumbnails
|
||||||
|
3. **Frontend**: Use TanStack Query for data fetching
|
||||||
|
4. **CORS**: Configure Quoter to allow Vercel domain
|
||||||
|
|
||||||
|
## 📊 Request Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Client
|
||||||
|
participant Vercel
|
||||||
|
participant Quoter
|
||||||
|
participant S3
|
||||||
|
|
||||||
|
Client->>Vercel: GET /api/thumb/600/image.jpg
|
||||||
|
Vercel->>Quoter: GET /image.jpg (original)
|
||||||
|
Quoter->>S3: Fetch image.jpg
|
||||||
|
S3->>Quoter: Return file data
|
||||||
|
Quoter->>Vercel: Return original image
|
||||||
|
Vercel->>Vercel: Generate 600px thumbnail
|
||||||
|
Vercel->>Client: Return optimized thumbnail
|
||||||
|
|
||||||
|
Note over Vercel: Cache thumbnail at edge
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎨 Advanced Vercel Features
|
||||||
|
|
||||||
|
### Smart Format Detection
|
||||||
|
```javascript
|
||||||
|
// Auto-serve WebP/AVIF when supported
|
||||||
|
export async function GET(request) {
|
||||||
|
const accept = request.headers.get('accept');
|
||||||
|
const supportsWebP = accept?.includes('image/webp');
|
||||||
|
const supportsAVIF = accept?.includes('image/avif');
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
// ... image component
|
||||||
|
{
|
||||||
|
format: supportsAVIF ? 'avif' : supportsWebP ? 'webp' : 'jpeg',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Quality Optimization
|
||||||
|
```javascript
|
||||||
|
// Different quality for different sizes
|
||||||
|
const quality = width <= 400 ? 75 : width <= 800 ? 85 : 95;
|
||||||
|
|
||||||
|
return new ImageResponse(component, {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
quality,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔗 Integration with CORS
|
||||||
|
|
||||||
|
Update Quoter CORS whitelist:
|
||||||
|
```bash
|
||||||
|
CORS_DOWNLOAD_ORIGINS=https://discours.io,https://*.discours.io,https://*.vercel.app
|
||||||
|
```
|
||||||
|
|
||||||
|
This allows Vercel Edge Functions to fetch originals from Quoter.
|
||||||
|
|
||||||
|
## 📈 Performance Benefits
|
||||||
|
|
||||||
|
- **Faster uploads**: No server-side resizing in Quoter
|
||||||
|
- **Global CDN**: Vercel Edge caches thumbnails worldwide
|
||||||
|
- **On-demand sizing**: Generate any size when needed
|
||||||
|
- **Smart caching**: Automatic cache headers and invalidation
|
||||||
|
- **Format optimization**: Serve modern formats automatically
|
||||||
|
|
||||||
|
**Result**: Clean separation of concerns - Quoter handles storage, Vercel handles optimization! 🚀
|
||||||
136
src/app_state.rs
136
src/app_state.rs
@@ -1,10 +1,11 @@
|
|||||||
use crate::s3_utils::get_s3_filelist;
|
use crate::s3_utils::get_s3_filelist;
|
||||||
|
use crate::security::SecurityConfig;
|
||||||
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};
|
||||||
use log::warn;
|
use log::warn;
|
||||||
use redis::{AsyncCommands, Client as RedisClient, aio::MultiplexedConnection};
|
use redis::{AsyncCommands, Client as RedisClient, aio::MultiplexedConnection};
|
||||||
use std::env;
|
use std::{env, time::Duration};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
@@ -12,6 +13,7 @@ pub struct AppState {
|
|||||||
pub storj_client: S3Client,
|
pub storj_client: S3Client,
|
||||||
pub aws_client: S3Client,
|
pub aws_client: S3Client,
|
||||||
pub bucket: String,
|
pub bucket: String,
|
||||||
|
pub request_timeout: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
const PATH_MAPPING_KEY: &str = "filepath_mapping"; // Ключ для хранения маппинга путей
|
const PATH_MAPPING_KEY: &str = "filepath_mapping"; // Ключ для хранения маппинга путей
|
||||||
@@ -20,13 +22,25 @@ const PATH_MAPPING_KEY: &str = "filepath_mapping"; // Ключ для хране
|
|||||||
impl AppState {
|
impl AppState {
|
||||||
/// Инициализация нового состояния приложения.
|
/// Инициализация нового состояния приложения.
|
||||||
pub async fn new() -> Self {
|
pub async fn new() -> Self {
|
||||||
// Получаем конфигурацию для Redis
|
let security_config = SecurityConfig::default();
|
||||||
|
Self::new_with_config(security_config).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Инициализация с кастомной конфигурацией безопасности.
|
||||||
|
pub async fn new_with_config(security_config: SecurityConfig) -> Self {
|
||||||
|
// Получаем конфигурацию для Redis с таймаутом
|
||||||
let redis_url = env::var("REDIS_URL").expect("REDIS_URL must be set");
|
let redis_url = env::var("REDIS_URL").expect("REDIS_URL must be set");
|
||||||
let redis_client = RedisClient::open(redis_url).expect("Invalid Redis URL");
|
let redis_client = RedisClient::open(redis_url).expect("Invalid Redis URL");
|
||||||
let redis_connection = redis_client
|
|
||||||
.get_multiplexed_async_connection()
|
// Устанавливаем таймаут для Redis операций
|
||||||
.await
|
let redis_connection = tokio::time::timeout(
|
||||||
.unwrap();
|
Duration::from_secs(security_config.request_timeout_seconds),
|
||||||
|
redis_client.get_multiplexed_async_connection(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| "Redis connection timeout")
|
||||||
|
.expect("Failed to connect to Redis within timeout")
|
||||||
|
.expect("Redis connection failed");
|
||||||
|
|
||||||
// Получаем конфигурацию для S3 (Storj)
|
// Получаем конфигурацию для S3 (Storj)
|
||||||
let s3_access_key = env::var("STORJ_ACCESS_KEY").expect("STORJ_ACCESS_KEY must be set");
|
let s3_access_key = env::var("STORJ_ACCESS_KEY").expect("STORJ_ACCESS_KEY must be set");
|
||||||
@@ -41,7 +55,7 @@ impl AppState {
|
|||||||
let aws_endpoint =
|
let aws_endpoint =
|
||||||
env::var("AWS_END_POINT").unwrap_or_else(|_| "https://s3.amazonaws.com".to_string());
|
env::var("AWS_END_POINT").unwrap_or_else(|_| "https://s3.amazonaws.com".to_string());
|
||||||
|
|
||||||
// Конфигурируем клиент S3 для Storj
|
// Конфигурируем клиент S3 для Storj с таймаутом
|
||||||
let storj_config = aws_config::defaults(BehaviorVersion::latest())
|
let storj_config = aws_config::defaults(BehaviorVersion::latest())
|
||||||
.region("eu-west-1")
|
.region("eu-west-1")
|
||||||
.endpoint_url(s3_endpoint)
|
.endpoint_url(s3_endpoint)
|
||||||
@@ -52,12 +66,20 @@ impl AppState {
|
|||||||
None,
|
None,
|
||||||
"rust-storj-client",
|
"rust-storj-client",
|
||||||
))
|
))
|
||||||
|
.timeout_config(
|
||||||
|
aws_config::timeout::TimeoutConfig::builder()
|
||||||
|
.operation_timeout(Duration::from_secs(security_config.request_timeout_seconds))
|
||||||
|
.operation_attempt_timeout(Duration::from_secs(
|
||||||
|
security_config.request_timeout_seconds / 2,
|
||||||
|
))
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
.load()
|
.load()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let storj_client = S3Client::new(&storj_config);
|
let storj_client = S3Client::new(&storj_config);
|
||||||
|
|
||||||
// Конфигурируем клиент S3 для AWS
|
// Конфигурируем клиент S3 для AWS с таймаутом
|
||||||
let aws_config = aws_config::defaults(BehaviorVersion::latest())
|
let aws_config = aws_config::defaults(BehaviorVersion::latest())
|
||||||
.region("eu-west-1")
|
.region("eu-west-1")
|
||||||
.endpoint_url(aws_endpoint)
|
.endpoint_url(aws_endpoint)
|
||||||
@@ -68,6 +90,14 @@ impl AppState {
|
|||||||
None,
|
None,
|
||||||
"rust-aws-client",
|
"rust-aws-client",
|
||||||
))
|
))
|
||||||
|
.timeout_config(
|
||||||
|
aws_config::timeout::TimeoutConfig::builder()
|
||||||
|
.operation_timeout(Duration::from_secs(security_config.request_timeout_seconds))
|
||||||
|
.operation_attempt_timeout(Duration::from_secs(
|
||||||
|
security_config.request_timeout_seconds / 2,
|
||||||
|
))
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
.load()
|
.load()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -78,6 +108,7 @@ impl AppState {
|
|||||||
storj_client,
|
storj_client,
|
||||||
aws_client,
|
aws_client,
|
||||||
bucket,
|
bucket,
|
||||||
|
request_timeout: Duration::from_secs(security_config.request_timeout_seconds),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Кэшируем список файлов из AWS при старте приложения
|
// Кэшируем список файлов из AWS при старте приложения
|
||||||
@@ -105,40 +136,51 @@ impl AppState {
|
|||||||
warn!("cached {} files", filelist.len());
|
warn!("cached {} files", filelist.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Получает путь из ключа (имени файла) в Redis.
|
/// Получает путь из ключа (имени файла) в Redis с таймаутом.
|
||||||
pub async fn get_path(&self, filename: &str) -> Result<Option<String>, actix_web::Error> {
|
pub async fn get_path(&self, filename: &str) -> Result<Option<String>, actix_web::Error> {
|
||||||
let mut redis = self.redis.clone();
|
let mut redis = self.redis.clone();
|
||||||
let new_path: Option<String> = redis
|
|
||||||
.hget(PATH_MAPPING_KEY, filename)
|
let new_path: Option<String> =
|
||||||
.await
|
tokio::time::timeout(self.request_timeout, redis.hget(PATH_MAPPING_KEY, filename))
|
||||||
.map_err(|_| ErrorInternalServerError("Failed to get path mapping from Redis"))?;
|
.await
|
||||||
|
.map_err(|_| ErrorInternalServerError("Redis operation timeout"))?
|
||||||
|
.map_err(|_| ErrorInternalServerError("Failed to get path mapping from Redis"))?;
|
||||||
|
|
||||||
Ok(new_path)
|
Ok(new_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_path(&self, filename: &str, filepath: &str) {
|
pub async fn set_path(&self, filename: &str, filepath: &str) {
|
||||||
let mut redis = self.redis.clone();
|
let mut redis = self.redis.clone();
|
||||||
let _: () = redis
|
|
||||||
.hset(PATH_MAPPING_KEY, filename, filepath)
|
let _: () = tokio::time::timeout(
|
||||||
.await
|
self.request_timeout,
|
||||||
.unwrap_or_else(|_| panic!("Failed to cache file {} in Redis", filename));
|
redis.hset(PATH_MAPPING_KEY, filename, filepath),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| panic!("Redis timeout when caching file {} in Redis", filename))
|
||||||
|
.unwrap_or_else(|_| panic!("Failed to cache file {} in Redis", filename));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// создает или получает текущее значение квоты пользователя
|
/// создает или получает текущее значение квоты пользователя с таймаутом
|
||||||
pub async fn get_or_create_quota(&self, user_id: &str) -> Result<u64, actix_web::Error> {
|
pub async fn get_or_create_quota(&self, user_id: &str) -> Result<u64, actix_web::Error> {
|
||||||
let mut redis = self.redis.clone();
|
let mut redis = self.redis.clone();
|
||||||
let quota_key = format!("quota:{}", user_id);
|
let quota_key = format!("quota:{}", user_id);
|
||||||
|
|
||||||
// Попытка получить квоту из Redis
|
// Попытка получить квоту из Redis с таймаутом
|
||||||
let quota: u64 = redis.get("a_key).await.unwrap_or(0);
|
let quota: u64 = tokio::time::timeout(self.request_timeout, redis.get("a_key))
|
||||||
|
.await
|
||||||
|
.map_err(|_| ErrorInternalServerError("Redis timeout getting user quota"))?
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
if quota == 0 {
|
if quota == 0 {
|
||||||
// Если квота не найдена, устанавливаем её в 0 байт без TTL (постоянная квота)
|
// Если квота не найдена, устанавливаем её в 0 байт без TTL с таймаутом
|
||||||
redis
|
tokio::time::timeout(
|
||||||
.set::<&str, u64, ()>("a_key, 0)
|
self.request_timeout,
|
||||||
.await
|
redis.set::<&str, u64, ()>("a_key, 0),
|
||||||
.map_err(|_| {
|
)
|
||||||
ErrorInternalServerError("Failed to set initial user quota in Redis")
|
.await
|
||||||
})?;
|
.map_err(|_| ErrorInternalServerError("Redis timeout setting user quota"))?
|
||||||
|
.map_err(|_| ErrorInternalServerError("Failed to set initial user quota in Redis"))?;
|
||||||
|
|
||||||
Ok(0) // Возвращаем 0 как начальную квоту
|
Ok(0) // Возвращаем 0 как начальную квоту
|
||||||
} else {
|
} else {
|
||||||
@@ -146,7 +188,7 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// инкрементирует значение квоты пользователя в байтах
|
/// инкрементирует значение квоты пользователя в байтах с таймаутом
|
||||||
pub async fn increment_uploaded_bytes(
|
pub async fn increment_uploaded_bytes(
|
||||||
&self,
|
&self,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
@@ -155,27 +197,37 @@ impl AppState {
|
|||||||
let mut redis = self.redis.clone();
|
let mut redis = self.redis.clone();
|
||||||
let quota_key = format!("quota:{}", user_id);
|
let quota_key = format!("quota:{}", user_id);
|
||||||
|
|
||||||
// Проверяем, существует ли ключ в Redis
|
// Проверяем, существует ли ключ в Redis с таймаутом
|
||||||
let exists: bool = redis.exists::<_, bool>("a_key).await.map_err(|_| {
|
let exists: bool =
|
||||||
ErrorInternalServerError("Failed to check if user quota exists in Redis")
|
tokio::time::timeout(self.request_timeout, redis.exists::<_, bool>("a_key))
|
||||||
})?;
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
ErrorInternalServerError("Redis timeout checking user quota existence")
|
||||||
|
})?
|
||||||
|
.map_err(|_| {
|
||||||
|
ErrorInternalServerError("Failed to check if user quota exists in Redis")
|
||||||
|
})?;
|
||||||
|
|
||||||
// Если ключ не существует, создаем его с начальным значением без TTL
|
// Если ключ не существует, создаем его с начальным значением без TTL
|
||||||
if !exists {
|
if !exists {
|
||||||
redis
|
tokio::time::timeout(
|
||||||
.set::<_, u64, ()>("a_key, bytes)
|
self.request_timeout,
|
||||||
.await
|
redis.set::<_, u64, ()>("a_key, bytes),
|
||||||
.map_err(|_| {
|
)
|
||||||
ErrorInternalServerError("Failed to set initial user quota in Redis")
|
.await
|
||||||
})?;
|
.map_err(|_| ErrorInternalServerError("Redis timeout setting initial user quota"))?
|
||||||
|
.map_err(|_| ErrorInternalServerError("Failed to set initial user quota in Redis"))?;
|
||||||
return Ok(bytes);
|
return Ok(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если ключ существует, инкрементируем его значение на заданное количество байт
|
// Если ключ существует, инкрементируем его значение на заданное количество байт
|
||||||
let new_quota: u64 = redis
|
let new_quota: u64 = tokio::time::timeout(
|
||||||
.incr::<_, u64, u64>("a_key, bytes)
|
self.request_timeout,
|
||||||
.await
|
redis.incr::<_, u64, u64>("a_key, bytes),
|
||||||
.map_err(|_| ErrorInternalServerError("Failed to increment user quota in Redis"))?;
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ErrorInternalServerError("Redis timeout incrementing user quota"))?
|
||||||
|
.map_err(|_| ErrorInternalServerError("Failed to increment user quota in Redis"))?;
|
||||||
|
|
||||||
Ok(new_quota)
|
Ok(new_quota)
|
||||||
}
|
}
|
||||||
|
|||||||
38
src/auth.rs
38
src/auth.rs
@@ -2,11 +2,8 @@ use actix_web::error::ErrorInternalServerError;
|
|||||||
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode};
|
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode};
|
||||||
use log::{info, warn};
|
use log::{info, warn};
|
||||||
use redis::{AsyncCommands, aio::MultiplexedConnection};
|
use redis::{AsyncCommands, aio::MultiplexedConnection};
|
||||||
use reqwest::Client as HTTPClient;
|
|
||||||
use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use std::{error::Error, time::Duration};
|
||||||
use std::{collections::HashMap, env, error::Error};
|
|
||||||
|
|
||||||
// Структуры для JWT токенов
|
// Структуры для JWT токенов
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -87,10 +84,11 @@ pub fn validate_token(token: &str) -> Result<bool, Box<dyn Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Получает user_id из JWT токена и базовые данные пользователя
|
/// Получает user_id из JWT токена и базовые данные пользователя с таймаутом
|
||||||
pub async fn get_user_by_token(
|
pub async fn get_user_by_token(
|
||||||
token: &str,
|
token: &str,
|
||||||
redis: &mut MultiplexedConnection,
|
redis: &mut MultiplexedConnection,
|
||||||
|
timeout: Duration,
|
||||||
) -> Result<Author, Box<dyn Error>> {
|
) -> Result<Author, Box<dyn Error>> {
|
||||||
// Декодируем JWT токен для получения user_id
|
// Декодируем JWT токен для получения user_id
|
||||||
let claims = decode_jwt_token(token)?;
|
let claims = decode_jwt_token(token)?;
|
||||||
@@ -98,11 +96,15 @@ pub async fn get_user_by_token(
|
|||||||
|
|
||||||
info!("Extracted user_id from JWT token: {}", user_id);
|
info!("Extracted user_id from JWT token: {}", user_id);
|
||||||
|
|
||||||
// Проверяем валидность токена через сессию в Redis (опционально)
|
// Проверяем валидность токена через сессию в Redis (опционально) с таймаутом
|
||||||
let token_key = format!("session:{}:{}", user_id, token);
|
let token_key = format!("session:{}:{}", user_id, token);
|
||||||
let session_exists: bool = redis
|
let session_exists: bool = tokio::time::timeout(timeout, redis.exists(&token_key))
|
||||||
.exists(&token_key)
|
|
||||||
.await
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
warn!("Redis timeout checking session existence");
|
||||||
|
// Не критичная ошибка, продолжаем с базовыми данными
|
||||||
|
})
|
||||||
|
.unwrap_or(Ok(false))
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
warn!("Failed to check session existence in Redis: {}", e);
|
warn!("Failed to check session existence in Redis: {}", e);
|
||||||
// Не критичная ошибка, продолжаем с базовыми данными
|
// Не критичная ошибка, продолжаем с базовыми данными
|
||||||
@@ -116,13 +118,19 @@ pub async fn get_user_by_token(
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.as_secs();
|
.as_secs();
|
||||||
|
|
||||||
let _: () = redis
|
let _: () = tokio::time::timeout(
|
||||||
.hset(&token_key, "last_activity", current_time.to_string())
|
timeout,
|
||||||
.await
|
redis.hset(&token_key, "last_activity", current_time.to_string()),
|
||||||
.map_err(|e| {
|
)
|
||||||
warn!("Failed to update last_activity: {}", e);
|
.await
|
||||||
})
|
.map_err(|_| {
|
||||||
.unwrap_or(());
|
warn!("Redis timeout updating last_activity");
|
||||||
|
})
|
||||||
|
.unwrap_or(Ok(()))
|
||||||
|
.map_err(|e| {
|
||||||
|
warn!("Failed to update last_activity: {}", e);
|
||||||
|
})
|
||||||
|
.unwrap_or(());
|
||||||
|
|
||||||
info!("Updated last_activity for session: {}", token_key);
|
info!("Updated last_activity for session: {}", token_key);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,11 +1,76 @@
|
|||||||
use actix_web::{HttpRequest, HttpResponse, Result, error::ErrorUnauthorized};
|
use actix_web::{HttpRequest, HttpResponse, Result, error::ErrorUnauthorized};
|
||||||
use log::warn;
|
use log::{debug, info, warn};
|
||||||
|
use std::env;
|
||||||
|
|
||||||
use crate::auth::validate_token;
|
use crate::auth::validate_token;
|
||||||
|
|
||||||
/// Общие константы
|
/// Общие константы - optimized for Vercel Edge caching
|
||||||
pub const CACHE_CONTROL_IMMUTABLE: &str = "public, max-age=31536000, immutable"; // 1 год
|
pub const CACHE_CONTROL_VERCEL: &str = "public, max-age=86400, s-maxage=31536000"; // 1 day browser, 1 year CDN
|
||||||
pub const CORS_ALLOW_ORIGIN: &str = "*";
|
|
||||||
|
/// Log request source and check CORS origin
|
||||||
|
pub fn get_cors_origin(req: &HttpRequest) -> String {
|
||||||
|
let 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());
|
||||||
|
|
||||||
|
// Extract request source info for logging
|
||||||
|
let origin = req.headers().get("origin").and_then(|h| h.to_str().ok());
|
||||||
|
let referer = req.headers().get("referer").and_then(|h| h.to_str().ok());
|
||||||
|
let user_agent = req
|
||||||
|
.headers()
|
||||||
|
.get("user-agent")
|
||||||
|
.and_then(|h| h.to_str().ok())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
let remote_addr = req
|
||||||
|
.peer_addr()
|
||||||
|
.map(|addr| addr.ip().to_string())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
// Log request source for CORS whitelist analysis
|
||||||
|
match (origin, referer) {
|
||||||
|
(Some(orig), Some(ref_)) => {
|
||||||
|
info!(
|
||||||
|
"📥 Request source: origin={}, referer={}, ip={}, ua={}",
|
||||||
|
orig, ref_, remote_addr, user_agent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
(Some(orig), None) => {
|
||||||
|
info!(
|
||||||
|
"📥 Request source: origin={}, ip={}, ua={}",
|
||||||
|
orig, remote_addr, user_agent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
(None, Some(ref_)) => {
|
||||||
|
info!(
|
||||||
|
"📥 Request source: referer={}, ip={}, ua={}",
|
||||||
|
ref_, remote_addr, user_agent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
(None, None) => {
|
||||||
|
debug!("📥 Direct request: ip={}, ua={}", remote_addr, user_agent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(origin) = origin {
|
||||||
|
// Simple check - if origin contains any allowed domain, allow it
|
||||||
|
for allowed in allowed_origins.split(',') {
|
||||||
|
let allowed = allowed.trim();
|
||||||
|
if allowed.contains('*') {
|
||||||
|
let base = allowed.replace("*.", "");
|
||||||
|
if origin.contains(&base) {
|
||||||
|
debug!("✅ CORS allowed: {} matches {}", origin, allowed);
|
||||||
|
return origin.to_string();
|
||||||
|
}
|
||||||
|
} else if origin == allowed {
|
||||||
|
debug!("✅ CORS allowed: {} exact match", origin);
|
||||||
|
return origin.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
warn!("⚠️ CORS not whitelisted: {}", origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default permissive for file downloads
|
||||||
|
"*".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// Извлекает и валидирует токен авторизации из заголовков запроса
|
/// Извлекает и валидирует токен авторизации из заголовков запроса
|
||||||
pub fn extract_and_validate_token(req: &HttpRequest) -> Result<&str, actix_web::Error> {
|
pub fn extract_and_validate_token(req: &HttpRequest) -> Result<&str, actix_web::Error> {
|
||||||
@@ -45,49 +110,57 @@ pub fn extract_and_validate_token(req: &HttpRequest) -> Result<&str, actix_web::
|
|||||||
Ok(token)
|
Ok(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Создает HTTP ответ с оптимальными заголовками кэширования
|
// Removed unused create_file_response - using create_file_response_with_analytics instead
|
||||||
pub fn create_cached_response(content_type: &str, data: Vec<u8>, etag: &str) -> HttpResponse {
|
|
||||||
|
/// File response with analytics logging
|
||||||
|
pub fn create_file_response_with_analytics(
|
||||||
|
content_type: &str,
|
||||||
|
data: Vec<u8>,
|
||||||
|
req: &HttpRequest,
|
||||||
|
path: &str,
|
||||||
|
) -> HttpResponse {
|
||||||
|
let cors_origin = get_cors_origin(req);
|
||||||
|
|
||||||
|
// Log analytics for CORS whitelist analysis
|
||||||
|
log_request_analytics(req, path, data.len());
|
||||||
|
|
||||||
HttpResponse::Ok()
|
HttpResponse::Ok()
|
||||||
.content_type(content_type)
|
.content_type(content_type)
|
||||||
.insert_header(("etag", etag))
|
.insert_header(("cache-control", CACHE_CONTROL_VERCEL))
|
||||||
.insert_header(("cache-control", CACHE_CONTROL_IMMUTABLE))
|
.insert_header(("access-control-allow-origin", cors_origin))
|
||||||
.insert_header(("access-control-allow-origin", CORS_ALLOW_ORIGIN))
|
|
||||||
.body(data)
|
.body(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Создает стандартный HTTP ответ с заголовками CORS
|
// Removed complex ETag caching - Vercel handles caching on their edge
|
||||||
pub fn create_response_with_cors(content_type: &str, data: Vec<u8>) -> HttpResponse {
|
|
||||||
HttpResponse::Ok()
|
|
||||||
.content_type(content_type)
|
|
||||||
.insert_header(("access-control-allow-origin", CORS_ALLOW_ORIGIN))
|
|
||||||
.body(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Создает HTTP ответ с кэшированием на основе ETag
|
/// Log request analytics for CORS whitelist tuning
|
||||||
pub fn create_etag_response(content_type: &str, data: Vec<u8>, etag: &str) -> HttpResponse {
|
pub fn log_request_analytics(req: &HttpRequest, path: &str, response_size: usize) {
|
||||||
HttpResponse::Ok()
|
let origin = req.headers().get("origin").and_then(|h| h.to_str().ok());
|
||||||
.content_type(content_type)
|
let referer = req.headers().get("referer").and_then(|h| h.to_str().ok());
|
||||||
.insert_header(("etag", etag))
|
let user_agent = req
|
||||||
.insert_header(("cache-control", CACHE_CONTROL_IMMUTABLE))
|
|
||||||
.insert_header(("access-control-allow-origin", CORS_ALLOW_ORIGIN))
|
|
||||||
.body(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Проверяет ETag для кэширования и возвращает 304 если совпадает
|
|
||||||
pub fn check_etag_cache(req: &HttpRequest, etag: &str) -> Option<HttpResponse> {
|
|
||||||
let client_etag = req
|
|
||||||
.headers()
|
.headers()
|
||||||
.get("if-none-match")
|
.get("user-agent")
|
||||||
.and_then(|h| h.to_str().ok());
|
.and_then(|h| h.to_str().ok())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
let remote_addr = req
|
||||||
|
.peer_addr()
|
||||||
|
.map(|addr| addr.ip().to_string())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
if let Some(client_etag) = client_etag {
|
// Analytics log for future CORS configuration
|
||||||
if client_etag == etag {
|
info!(
|
||||||
return Some(HttpResponse::NotModified().finish());
|
"📊 ANALYTICS: path={}, size={}b, origin={}, referer={}, ip={}, ua={}",
|
||||||
}
|
path,
|
||||||
}
|
response_size,
|
||||||
None
|
origin.unwrap_or("none"),
|
||||||
|
referer.unwrap_or("none"),
|
||||||
|
remote_addr,
|
||||||
|
user_agent
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ETag caching removed - handled by Vercel Edge
|
||||||
|
|
||||||
/// Проверяет путь на ACME challenge и возвращает 404 если нужно
|
/// Проверяет путь на ACME challenge и возвращает 404 если нужно
|
||||||
pub fn check_acme_path(path: &str) -> Option<HttpResponse> {
|
pub fn check_acme_path(path: &str) -> Option<HttpResponse> {
|
||||||
if path.starts_with(".well-known/") || path.starts_with("/.well-known/") {
|
if path.starts_with(".well-known/") || path.starts_with("/.well-known/") {
|
||||||
@@ -107,31 +180,15 @@ pub fn validate_token_format(token: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем, что токен содержит только допустимые символы для JWT
|
// Проверяем, что токен содержит только допустимые символы для JWT
|
||||||
token.chars().all(|c| c.is_alphanumeric() || c == '.' || c == '-' || c == '_')
|
token
|
||||||
}
|
.chars()
|
||||||
|
.all(|c| c.is_alphanumeric() || c == '.' || c == '-' || c == '_')
|
||||||
/// Создает ответ с задержкой для предотвращения брутфорса
|
|
||||||
pub async fn create_delayed_error_response(
|
|
||||||
status: actix_web::http::StatusCode,
|
|
||||||
message: &str,
|
|
||||||
delay_ms: u64,
|
|
||||||
) -> HttpResponse {
|
|
||||||
if delay_ms > 0 {
|
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
HttpResponse::build(status)
|
|
||||||
.insert_header(("access-control-allow-origin", CORS_ALLOW_ORIGIN))
|
|
||||||
.json(serde_json::json!({
|
|
||||||
"error": message,
|
|
||||||
"retry_after": delay_ms / 1000
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Создает JSON ответ с ошибкой
|
/// Создает JSON ответ с ошибкой
|
||||||
pub fn create_error_response(status: actix_web::http::StatusCode, message: &str) -> HttpResponse {
|
pub fn create_error_response(status: actix_web::http::StatusCode, message: &str) -> HttpResponse {
|
||||||
HttpResponse::build(status)
|
HttpResponse::build(status)
|
||||||
.insert_header(("access-control-allow-origin", CORS_ALLOW_ORIGIN))
|
.insert_header(("access-control-allow-origin", "*"))
|
||||||
.json(serde_json::json!({
|
.json(serde_json::json!({
|
||||||
"error": message
|
"error": message
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
mod common;
|
mod common;
|
||||||
mod proxy;
|
mod proxy;
|
||||||
mod serve_file;
|
mod serve_file;
|
||||||
|
mod universal;
|
||||||
mod upload;
|
mod upload;
|
||||||
mod user;
|
mod user;
|
||||||
mod universal;
|
|
||||||
|
|
||||||
pub use universal::universal_handler;
|
pub use universal::universal_handler;
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,17 @@ use actix_web::error::ErrorNotFound;
|
|||||||
use actix_web::{HttpRequest, HttpResponse, Result, error::ErrorInternalServerError, web};
|
use actix_web::{HttpRequest, HttpResponse, Result, error::ErrorInternalServerError, web};
|
||||||
use log::{error, info, warn};
|
use log::{error, info, warn};
|
||||||
|
|
||||||
|
use super::common::create_file_response_with_analytics;
|
||||||
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::{find_closest_width, parse_file_path, thumbdata_save};
|
use crate::thumbnail::parse_file_path;
|
||||||
use super::common::{check_etag_cache, create_cached_response};
|
|
||||||
|
|
||||||
// Удалена дублирующая функция, используется из common модуля
|
// Удалена дублирующая функция, используется из common модуля
|
||||||
|
|
||||||
/// Обработчик для скачивания файла и генерации миниатюры, если она недоступна.
|
/// Обработчик для скачивания файла
|
||||||
|
/// без генерации миниатюр - это делает Vercel Edge API
|
||||||
#[allow(clippy::collapsible_if)]
|
#[allow(clippy::collapsible_if)]
|
||||||
pub async fn proxy_handler(
|
pub async fn proxy_handler(
|
||||||
req: HttpRequest,
|
req: HttpRequest,
|
||||||
@@ -38,12 +39,7 @@ pub async fn proxy_handler(
|
|||||||
base_filename, requested_width, ext
|
base_filename, requested_width, ext
|
||||||
);
|
);
|
||||||
|
|
||||||
// Генерируем ETag для кэширования и проверяем кэш
|
// Caching handled by Vercel Edge - focus on fast file serving
|
||||||
let file_etag = format!("\"{}\"", &filekey);
|
|
||||||
if let Some(response) = check_etag_cache(&req, &file_etag) {
|
|
||||||
info!("Cache hit for {}, returning 304", filekey);
|
|
||||||
return Ok(response);
|
|
||||||
}
|
|
||||||
let content_type = match get_mime_type(&ext) {
|
let content_type = match get_mime_type(&ext) {
|
||||||
Some(mime) => mime.to_string(),
|
Some(mime) => mime.to_string(),
|
||||||
None => {
|
None => {
|
||||||
@@ -77,72 +73,8 @@ pub async fn proxy_handler(
|
|||||||
);
|
);
|
||||||
if check_file_exists(&state.storj_client, &state.bucket, &stored_path).await? {
|
if check_file_exists(&state.storj_client, &state.bucket, &stored_path).await? {
|
||||||
warn!("File exists in Storj: {}", stored_path);
|
warn!("File exists in Storj: {}", stored_path);
|
||||||
if content_type.starts_with("image") {
|
// Просто отдаем файл, миниатюры генерирует Vercel Edge API
|
||||||
warn!("Processing image file with width: {}", requested_width);
|
serve_file(&stored_path, &state, &req).await
|
||||||
if requested_width == 0 {
|
|
||||||
warn!("Serving original file without resizing");
|
|
||||||
serve_file(&stored_path, &state).await
|
|
||||||
} else {
|
|
||||||
let closest: u32 = find_closest_width(requested_width);
|
|
||||||
warn!(
|
|
||||||
"Calculated closest width: {} for requested: {}",
|
|
||||||
closest, requested_width
|
|
||||||
);
|
|
||||||
let thumb_filename = &format!("{}_{}.{}", base_filename, closest, ext);
|
|
||||||
warn!("Generated thumbnail filename: {}", thumb_filename);
|
|
||||||
|
|
||||||
// Проверяем, существует ли уже миниатюра в Storj
|
|
||||||
match check_file_exists(&state.storj_client, &state.bucket, thumb_filename)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(true) => {
|
|
||||||
warn!("serve existed thumb file: {}", thumb_filename);
|
|
||||||
serve_file(thumb_filename, &state).await
|
|
||||||
}
|
|
||||||
Ok(false) => {
|
|
||||||
// Миниатюра не существует, возвращаем оригинал и запускаем генерацию миниатюры
|
|
||||||
let original_file = serve_file(&stored_path, &state).await?;
|
|
||||||
|
|
||||||
// Запускаем асинхронную задачу для генерации миниатюры
|
|
||||||
let state_clone = state.clone();
|
|
||||||
let stored_path_clone = stored_path.clone();
|
|
||||||
let filekey_clone = filekey.clone();
|
|
||||||
let content_type_clone = content_type.to_string();
|
|
||||||
|
|
||||||
actix_web::rt::spawn(async move {
|
|
||||||
if let Ok(filedata) = load_file_from_s3(
|
|
||||||
&state_clone.storj_client,
|
|
||||||
&state_clone.bucket,
|
|
||||||
&stored_path_clone,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
warn!("generate new thumb files: {}", stored_path_clone);
|
|
||||||
if let Err(e) = thumbdata_save(
|
|
||||||
filedata,
|
|
||||||
&state_clone,
|
|
||||||
&filekey_clone,
|
|
||||||
content_type_clone,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
error!("Failed to generate thumbnail: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(original_file)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error!("ошибка при проверке существования миниатюры: {}", e);
|
|
||||||
Err(ErrorInternalServerError("failed to load thumbnail"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
warn!("File is not an image, proceeding with normal serving");
|
|
||||||
serve_file(&stored_path, &state).await
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
warn!(
|
warn!(
|
||||||
"Attempting to load from AWS - bucket: {}, path: {}",
|
"Attempting to load from AWS - bucket: {}, path: {}",
|
||||||
@@ -197,7 +129,12 @@ pub async fn proxy_handler(
|
|||||||
|
|
||||||
let elapsed = start_time.elapsed();
|
let elapsed = start_time.elapsed();
|
||||||
info!("File served from AWS in {:?}: {}", elapsed, path);
|
info!("File served from AWS in {:?}: {}", elapsed, path);
|
||||||
return Ok(create_cached_response(&content_type, filedata, &file_etag));
|
return Ok(create_file_response_with_analytics(
|
||||||
|
&content_type,
|
||||||
|
filedata,
|
||||||
|
&req,
|
||||||
|
&path,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!("Failed to load from AWS path {}: {:?}", path, err);
|
warn!("Failed to load from AWS path {}: {:?}", path, err);
|
||||||
@@ -244,26 +181,9 @@ pub async fn proxy_handler(
|
|||||||
warn!("Checking existence in Storj: {}", exists_in_storj);
|
warn!("Checking existence in Storj: {}", exists_in_storj);
|
||||||
|
|
||||||
if exists_in_storj {
|
if exists_in_storj {
|
||||||
warn!(
|
warn!("file {} exists in storj, serving directly", filepath);
|
||||||
"file {} exists in storj, try to generate thumbnails",
|
// Файл существует в Storj, отдаем его напрямую
|
||||||
filepath
|
return serve_file(&filepath, &state, &req).await;
|
||||||
);
|
|
||||||
|
|
||||||
match load_file_from_s3(&state.aws_client, &state.bucket, &filepath).await {
|
|
||||||
Ok(filedata) => {
|
|
||||||
let _ = thumbdata_save(
|
|
||||||
filedata.clone(),
|
|
||||||
&state,
|
|
||||||
&filekey,
|
|
||||||
content_type.to_string(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error!("cannot download {} from storj: {}", filekey, e);
|
|
||||||
return Err(ErrorInternalServerError(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
warn!("file {} does not exist in storj", filepath);
|
warn!("file {} does not exist in storj", filepath);
|
||||||
}
|
}
|
||||||
@@ -280,13 +200,6 @@ pub async fn proxy_handler(
|
|||||||
"Successfully downloaded file from AWS, size: {} bytes",
|
"Successfully downloaded file from AWS, size: {} bytes",
|
||||||
filedata.len()
|
filedata.len()
|
||||||
);
|
);
|
||||||
let _ = thumbdata_save(
|
|
||||||
filedata.clone(),
|
|
||||||
&state,
|
|
||||||
&filekey,
|
|
||||||
content_type.to_string(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
if let Err(e) = upload_to_s3(
|
if let Err(e) = upload_to_s3(
|
||||||
&state.storj_client,
|
&state.storj_client,
|
||||||
&state.bucket,
|
&state.bucket,
|
||||||
@@ -303,7 +216,12 @@ pub async fn proxy_handler(
|
|||||||
}
|
}
|
||||||
let elapsed = start_time.elapsed();
|
let elapsed = start_time.elapsed();
|
||||||
info!("File served from AWS in {:?}: {}", elapsed, filepath);
|
info!("File served from AWS in {:?}: {}", elapsed, filepath);
|
||||||
Ok(create_cached_response(&content_type, filedata, &file_etag))
|
Ok(create_file_response_with_analytics(
|
||||||
|
&content_type,
|
||||||
|
filedata,
|
||||||
|
&req,
|
||||||
|
&filepath,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Failed to download from AWS: {} - Error: {}", filepath, e);
|
error!("Failed to download from AWS: {} - Error: {}", filepath, e);
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
use actix_web::{HttpResponse, Result, error::ErrorInternalServerError};
|
use actix_web::{HttpRequest, HttpResponse, Result, error::ErrorInternalServerError};
|
||||||
use mime_guess::MimeGuess;
|
use mime_guess::MimeGuess;
|
||||||
|
|
||||||
|
use super::common::create_file_response_with_analytics;
|
||||||
use crate::app_state::AppState;
|
use crate::app_state::AppState;
|
||||||
use crate::s3_utils::check_file_exists;
|
use crate::s3_utils::{check_file_exists, load_file_from_s3};
|
||||||
use super::common::{CACHE_CONTROL_IMMUTABLE, CORS_ALLOW_ORIGIN};
|
|
||||||
|
|
||||||
/// Функция для обслуживания файла по заданному пути.
|
/// Функция для обслуживания файла по заданному пути.
|
||||||
|
/// Теперь оптимизирована для Vercel Edge caching.
|
||||||
pub async fn serve_file(
|
pub async fn serve_file(
|
||||||
filepath: &str,
|
filepath: &str,
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
|
req: &HttpRequest,
|
||||||
) -> Result<HttpResponse, actix_web::Error> {
|
) -> Result<HttpResponse, actix_web::Error> {
|
||||||
if filepath.is_empty() {
|
if filepath.is_empty() {
|
||||||
return Err(ErrorInternalServerError("Filename is empty".to_string()));
|
return Err(ErrorInternalServerError("Filename is empty".to_string()));
|
||||||
@@ -23,35 +25,21 @@ pub async fn serve_file(
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получаем объект из Storj S3
|
// Загружаем файл из S3
|
||||||
let get_object_output = state
|
let filedata = load_file_from_s3(&state.storj_client, &state.bucket, filepath)
|
||||||
.storj_client
|
|
||||||
.get_object()
|
|
||||||
.bucket(&state.bucket)
|
|
||||||
.key(filepath)
|
|
||||||
.send()
|
|
||||||
.await
|
.await
|
||||||
.map_err(|_| {
|
.map_err(|e| {
|
||||||
ErrorInternalServerError(format!("Failed to get {} object from Storj", filepath))
|
ErrorInternalServerError(format!("Failed to load {} from Storj: {}", filepath, e))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let data: aws_sdk_s3::primitives::AggregatedBytes = get_object_output
|
// Определяем MIME тип
|
||||||
.body
|
|
||||||
.collect()
|
|
||||||
.await
|
|
||||||
.map_err(|_| ErrorInternalServerError("Failed to read object body"))?;
|
|
||||||
|
|
||||||
let data_bytes = data.into_bytes();
|
|
||||||
|
|
||||||
let mime_type = MimeGuess::from_path(filepath).first_or_octet_stream();
|
let mime_type = MimeGuess::from_path(filepath).first_or_octet_stream();
|
||||||
|
|
||||||
// Генерируем ETag для кэширования на основе пути файла
|
// Создаем ответ с аналитикой
|
||||||
let etag = format!("\"{}\"", filepath);
|
Ok(create_file_response_with_analytics(
|
||||||
|
mime_type.as_ref(),
|
||||||
Ok(HttpResponse::Ok()
|
filedata,
|
||||||
.content_type(mime_type.as_ref())
|
req,
|
||||||
.insert_header(("etag", etag.as_str()))
|
filepath,
|
||||||
.insert_header(("cache-control", CACHE_CONTROL_IMMUTABLE))
|
))
|
||||||
.insert_header(("access-control-allow-origin", CORS_ALLOW_ORIGIN))
|
|
||||||
.body(data_bytes))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use actix_web::{HttpRequest, HttpResponse, Result, web};
|
|
||||||
use actix_multipart::Multipart;
|
use actix_multipart::Multipart;
|
||||||
|
use actix_web::{HttpRequest, HttpResponse, Result, web};
|
||||||
use log::{info, warn};
|
use log::{info, warn};
|
||||||
|
|
||||||
|
use super::common::{check_acme_path, create_error_response};
|
||||||
use crate::app_state::AppState;
|
use crate::app_state::AppState;
|
||||||
use crate::security::{SecurityManager, SecurityConfig};
|
use crate::security::SecurityConfig;
|
||||||
use super::common::{create_error_response, check_acme_path};
|
|
||||||
|
|
||||||
/// Универсальный обработчик, который определяет HTTP метод и путь
|
/// Универсальный обработчик, который определяет HTTP метод и путь
|
||||||
pub async fn universal_handler(
|
pub async fn universal_handler(
|
||||||
@@ -14,7 +14,7 @@ pub async fn universal_handler(
|
|||||||
) -> Result<HttpResponse, actix_web::Error> {
|
) -> Result<HttpResponse, actix_web::Error> {
|
||||||
let method = req.method().clone();
|
let method = req.method().clone();
|
||||||
let path = req.path().to_string();
|
let path = req.path().to_string();
|
||||||
|
|
||||||
info!("Universal handler: {} {}", method, path);
|
info!("Universal handler: {} {}", method, path);
|
||||||
|
|
||||||
// Проверка ACME challenge путей
|
// Проверка ACME challenge путей
|
||||||
@@ -22,46 +22,31 @@ pub async fn universal_handler(
|
|||||||
return Ok(response);
|
return Ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Инициализация SecurityManager для проверок
|
// Базовая проверка безопасности
|
||||||
let security_config = SecurityConfig::default();
|
let security_config = SecurityConfig::default();
|
||||||
let client_ip = SecurityManager::extract_client_ip(&req);
|
if let Err(error) = security_config.validate_request(&req) {
|
||||||
|
warn!("Security validation failed: {}", error);
|
||||||
// Проверка базовых ограничений безопасности
|
|
||||||
if let Err(error) = SecurityManager::new(security_config.clone(), state.redis.clone())
|
|
||||||
.validate_request_security(&req) {
|
|
||||||
warn!("Security validation failed for IP {}: {}", client_ip, error);
|
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверка подозрительных паттернов
|
// Проверка upload лимитов только для POST запросов
|
||||||
let mut security_manager = SecurityManager::new(security_config.clone(), state.redis.clone());
|
if method == "POST" {
|
||||||
if security_manager.check_suspicious_patterns(&path) {
|
let client_ip = SecurityConfig::extract_client_ip(&req);
|
||||||
warn!("Suspicious pattern detected from IP {}: {}", client_ip, path);
|
if let Err(error) = security_config.check_upload_limit(&client_ip).await {
|
||||||
return Ok(create_error_response(
|
warn!("Upload limit exceeded for IP {}: {}", client_ip, error);
|
||||||
actix_web::http::StatusCode::NOT_FOUND,
|
return Err(error);
|
||||||
"Not found"
|
}
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверка rate limits в зависимости от endpoint
|
|
||||||
let endpoint_type = match method.as_str() {
|
|
||||||
"POST" if path == "/" => "upload",
|
|
||||||
"GET" if path == "/" => "auth",
|
|
||||||
_ => "general"
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(error) = security_manager.check_rate_limit(&client_ip, endpoint_type).await {
|
|
||||||
warn!("Rate limit exceeded for IP {} on {}: {}", client_ip, endpoint_type, error);
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
match method.as_str() {
|
match method.as_str() {
|
||||||
"GET" => handle_get(req, state, &path).await,
|
"GET" => handle_get(req, state, &path).await,
|
||||||
"POST" => handle_post(req, payload, state, &path).await,
|
"POST" => handle_post(req, payload, state, &path).await,
|
||||||
_ => Ok(create_error_response(
|
_ => Ok(create_error_response(
|
||||||
actix_web::http::StatusCode::METHOD_NOT_ALLOWED,
|
actix_web::http::StatusCode::METHOD_NOT_ALLOWED,
|
||||||
"Method not allowed"
|
"Method not allowed",
|
||||||
))
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +55,7 @@ async fn handle_get(
|
|||||||
state: web::Data<AppState>,
|
state: web::Data<AppState>,
|
||||||
path: &str,
|
path: &str,
|
||||||
) -> Result<HttpResponse, actix_web::Error> {
|
) -> Result<HttpResponse, actix_web::Error> {
|
||||||
if path == "/" || path == "" {
|
if path == "/" || path.is_empty() {
|
||||||
// GET / - получение информации о пользователе
|
// GET / - получение информации о пользователе
|
||||||
crate::handlers::user::get_current_user_handler(req, state).await
|
crate::handlers::user::get_current_user_handler(req, state).await
|
||||||
} else {
|
} else {
|
||||||
@@ -88,6 +73,6 @@ async fn handle_post(
|
|||||||
_path: &str,
|
_path: &str,
|
||||||
) -> Result<HttpResponse, actix_web::Error> {
|
) -> Result<HttpResponse, actix_web::Error> {
|
||||||
// POST / - загрузка файла (multipart)
|
// POST / - загрузка файла (multipart)
|
||||||
let multipart = Multipart::new(&req.headers(), payload);
|
let multipart = Multipart::new(req.headers(), payload);
|
||||||
crate::handlers::upload::upload_handler(req, multipart, state).await
|
crate::handlers::upload::upload_handler(req, multipart, state).await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ use actix_multipart::Multipart;
|
|||||||
use actix_web::{HttpRequest, HttpResponse, Result, web};
|
use actix_web::{HttpRequest, HttpResponse, Result, web};
|
||||||
use log::{error, info, warn};
|
use log::{error, info, warn};
|
||||||
|
|
||||||
|
use super::common::extract_and_validate_token;
|
||||||
use crate::app_state::AppState;
|
use crate::app_state::AppState;
|
||||||
use crate::auth::{extract_user_id_from_token, user_added_file};
|
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 super::common::extract_and_validate_token;
|
|
||||||
use futures::TryStreamExt;
|
use futures::TryStreamExt;
|
||||||
// use crate::thumbnail::convert_heic_to_jpeg;
|
// use crate::thumbnail::convert_heic_to_jpeg;
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ use actix_web::{HttpRequest, HttpResponse, Result, web};
|
|||||||
use log::{error, info, warn};
|
use log::{error, info, warn};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use super::common::extract_and_validate_token;
|
||||||
use crate::app_state::AppState;
|
use crate::app_state::AppState;
|
||||||
use crate::auth::{Author, get_user_by_token};
|
use crate::auth::{Author, get_user_by_token};
|
||||||
use super::common::extract_and_validate_token;
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct UserWithQuotaResponse {
|
pub struct UserWithQuotaResponse {
|
||||||
@@ -32,7 +32,7 @@ pub async fn get_current_user_handler(
|
|||||||
|
|
||||||
// Получаем информацию о пользователе из Redis сессии
|
// Получаем информацию о пользователе из Redis сессии
|
||||||
let mut redis = state.redis.clone();
|
let mut redis = state.redis.clone();
|
||||||
let user = match get_user_by_token(token, &mut redis).await {
|
let user = match get_user_by_token(token, &mut redis, state.request_timeout).await {
|
||||||
Ok(user) => {
|
Ok(user) => {
|
||||||
info!(
|
info!(
|
||||||
"Successfully retrieved user info: user_id={}, username={:?}",
|
"Successfully retrieved user info: user_id={}, username={:?}",
|
||||||
|
|||||||
27
src/main.rs
27
src/main.rs
@@ -10,14 +10,14 @@ use actix_cors::Cors;
|
|||||||
use actix_web::{
|
use actix_web::{
|
||||||
App, HttpServer,
|
App, HttpServer,
|
||||||
http::header,
|
http::header,
|
||||||
middleware::{Logger, DefaultHeaders},
|
middleware::{DefaultHeaders, Logger},
|
||||||
web,
|
web,
|
||||||
};
|
};
|
||||||
use app_state::AppState;
|
use app_state::AppState;
|
||||||
use security::{SecurityConfig, security_middleware};
|
use security::SecurityConfig;
|
||||||
|
|
||||||
use handlers::universal_handler;
|
use handlers::universal_handler;
|
||||||
use log::{warn, info};
|
use log::{info, warn};
|
||||||
use std::env;
|
use std::env;
|
||||||
use tokio::task::spawn_blocking;
|
use tokio::task::spawn_blocking;
|
||||||
|
|
||||||
@@ -41,10 +41,11 @@ async fn main() -> std::io::Result<()> {
|
|||||||
|
|
||||||
// Конфигурация безопасности
|
// Конфигурация безопасности
|
||||||
let security_config = SecurityConfig::default();
|
let security_config = SecurityConfig::default();
|
||||||
info!("Security config: max_payload={} MB, upload_rate_limit={}/{}s",
|
info!(
|
||||||
security_config.max_payload_size / (1024 * 1024),
|
"Security config: max_payload={} MB, timeout={}s",
|
||||||
security_config.upload_rate_limit.max_requests,
|
security_config.max_payload_size / (1024 * 1024),
|
||||||
security_config.upload_rate_limit.window_seconds);
|
security_config.request_timeout_seconds
|
||||||
|
);
|
||||||
|
|
||||||
HttpServer::new(move || {
|
HttpServer::new(move || {
|
||||||
// Настройка CORS middleware - ограничиваем в продакшене
|
// Настройка CORS middleware - ограничиваем в продакшене
|
||||||
@@ -71,14 +72,20 @@ async fn main() -> std::io::Result<()> {
|
|||||||
.add(("X-Frame-Options", "DENY"))
|
.add(("X-Frame-Options", "DENY"))
|
||||||
.add(("X-XSS-Protection", "1; mode=block"))
|
.add(("X-XSS-Protection", "1; mode=block"))
|
||||||
.add(("Referrer-Policy", "strict-origin-when-cross-origin"))
|
.add(("Referrer-Policy", "strict-origin-when-cross-origin"))
|
||||||
.add(("Content-Security-Policy", "default-src 'self'; img-src 'self' data: https:; object-src 'none';"))
|
.add((
|
||||||
.add(("Strict-Transport-Security", "max-age=31536000; includeSubDomains"));
|
"Content-Security-Policy",
|
||||||
|
"default-src 'self'; img-src 'self' data: https:; object-src 'none';",
|
||||||
|
))
|
||||||
|
.add((
|
||||||
|
"Strict-Transport-Security",
|
||||||
|
"max-age=31536000; includeSubDomains",
|
||||||
|
));
|
||||||
|
|
||||||
App::new()
|
App::new()
|
||||||
.app_data(web::Data::new(app_state.clone()))
|
.app_data(web::Data::new(app_state.clone()))
|
||||||
.app_data(web::PayloadConfig::new(security_config.max_payload_size))
|
.app_data(web::PayloadConfig::new(security_config.max_payload_size))
|
||||||
.app_data(web::JsonConfig::default().limit(1024 * 1024)) // 1MB для JSON
|
.app_data(web::JsonConfig::default().limit(1024 * 1024)) // 1MB для JSON
|
||||||
.wrap(actix_web::middleware::from_fn(security_middleware))
|
|
||||||
.wrap(security_headers)
|
.wrap(security_headers)
|
||||||
.wrap(cors)
|
.wrap(cors)
|
||||||
.wrap(Logger::default())
|
.wrap(Logger::default())
|
||||||
|
|||||||
372
src/security.rs
372
src/security.rs
@@ -1,42 +1,22 @@
|
|||||||
use actix_web::{HttpRequest, dev::ServiceRequest, middleware::Next, dev::ServiceResponse, error::ErrorTooManyRequests};
|
use actix_web::HttpRequest;
|
||||||
use log::{warn, error, info};
|
use log::warn;
|
||||||
use redis::{AsyncCommands, aio::MultiplexedConnection};
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tokio::sync::RwLock;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use serde::{Deserialize, Serialize};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
/// Конфигурация лимитов запросов
|
/// Простая защита от злоупотреблений для upload endpoint
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RateLimitConfig {
|
pub struct UploadProtection {
|
||||||
/// Максимальное количество запросов в окне времени
|
/// Максимальное количество загрузок в минуту с одного IP
|
||||||
pub max_requests: u32,
|
pub max_uploads_per_minute: u32,
|
||||||
/// Окно времени в секундах
|
/// Локальный кэш для подсчета загрузок
|
||||||
pub window_seconds: u64,
|
pub upload_counts: Arc<RwLock<HashMap<String, (u32, u64)>>>,
|
||||||
/// Блокировка на количество секунд при превышении лимита
|
|
||||||
pub block_duration_seconds: u64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for RateLimitConfig {
|
/// Конфигурация безопасности для простого storage proxy
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
max_requests: 100, // 100 запросов
|
|
||||||
window_seconds: 60, // в минуту
|
|
||||||
block_duration_seconds: 300, // блокировка на 5 минут
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Конфигурация для разных типов запросов
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SecurityConfig {
|
pub struct SecurityConfig {
|
||||||
/// Общий лимит по IP
|
|
||||||
pub general_rate_limit: RateLimitConfig,
|
|
||||||
/// Лимит для загрузки файлов
|
|
||||||
pub upload_rate_limit: RateLimitConfig,
|
|
||||||
/// Лимит для аутентификации
|
|
||||||
pub auth_rate_limit: RateLimitConfig,
|
|
||||||
/// Максимальный размер тела запроса (байты)
|
/// Максимальный размер тела запроса (байты)
|
||||||
pub max_payload_size: usize,
|
pub max_payload_size: usize,
|
||||||
/// Таймаут запроса (секунды)
|
/// Таймаут запроса (секунды)
|
||||||
@@ -47,217 +27,52 @@ pub struct SecurityConfig {
|
|||||||
pub max_headers_count: usize,
|
pub max_headers_count: usize,
|
||||||
/// Максимальная длина значения заголовка
|
/// Максимальная длина значения заголовка
|
||||||
pub max_header_value_length: usize,
|
pub max_header_value_length: usize,
|
||||||
|
/// Защита от злоупотреблений upload
|
||||||
|
pub upload_protection: UploadProtection,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for SecurityConfig {
|
impl Default for SecurityConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
general_rate_limit: RateLimitConfig::default(),
|
max_payload_size: 500 * 1024 * 1024, // 500MB
|
||||||
upload_rate_limit: RateLimitConfig {
|
|
||||||
max_requests: 10, // 10 загрузок
|
|
||||||
window_seconds: 300, // в 5 минут
|
|
||||||
block_duration_seconds: 600, // блокировка на 10 минут
|
|
||||||
},
|
|
||||||
auth_rate_limit: RateLimitConfig {
|
|
||||||
max_requests: 20, // 20 попыток аутентификации
|
|
||||||
window_seconds: 900, // в 15 минут
|
|
||||||
block_duration_seconds: 1800, // блокировка на 30 минут
|
|
||||||
},
|
|
||||||
max_payload_size: 4000 * 1024 * 1024, // 4000 МБ
|
|
||||||
request_timeout_seconds: 300, // 5 минут
|
request_timeout_seconds: 300, // 5 минут
|
||||||
max_path_length: 1000,
|
max_path_length: 1000,
|
||||||
max_headers_count: 50,
|
max_headers_count: 50,
|
||||||
max_header_value_length: 8192,
|
max_header_value_length: 8192,
|
||||||
|
upload_protection: UploadProtection {
|
||||||
|
max_uploads_per_minute: 10, // 10 загрузок в минуту
|
||||||
|
upload_counts: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Структура для хранения информации о запросах
|
impl SecurityConfig {
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
/// Валидирует запрос на базовые параметры безопасности
|
||||||
pub struct RequestInfo {
|
pub fn validate_request(&self, req: &HttpRequest) -> Result<(), actix_web::Error> {
|
||||||
pub count: u32,
|
|
||||||
pub first_request_time: u64,
|
|
||||||
pub blocked_until: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Менеджер безопасности
|
|
||||||
pub struct SecurityManager {
|
|
||||||
pub config: SecurityConfig,
|
|
||||||
redis: MultiplexedConnection,
|
|
||||||
// Локальный кэш для быстрых проверок
|
|
||||||
local_cache: Arc<RwLock<HashMap<String, RequestInfo>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SecurityManager {
|
|
||||||
pub fn new(config: SecurityConfig, redis: MultiplexedConnection) -> Self {
|
|
||||||
Self {
|
|
||||||
config,
|
|
||||||
redis,
|
|
||||||
local_cache: Arc::new(RwLock::new(HashMap::new())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Получает IP адрес из запроса, учитывая прокси
|
|
||||||
pub fn extract_client_ip(req: &HttpRequest) -> String {
|
|
||||||
// Проверяем заголовки прокси
|
|
||||||
if let Some(forwarded_for) = req.headers().get("x-forwarded-for") {
|
|
||||||
if let Ok(forwarded_str) = forwarded_for.to_str() {
|
|
||||||
if let Some(first_ip) = forwarded_str.split(',').next() {
|
|
||||||
return first_ip.trim().to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(real_ip) = req.headers().get("x-real-ip") {
|
|
||||||
if let Ok(ip_str) = real_ip.to_str() {
|
|
||||||
return ip_str.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback к connection info
|
|
||||||
req.connection_info()
|
|
||||||
.realip_remote_addr()
|
|
||||||
.unwrap_or("unknown")
|
|
||||||
.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Проверяет лимиты запросов для IP
|
|
||||||
pub async fn check_rate_limit(&mut self, ip: &str, endpoint_type: &str) -> Result<(), actix_web::Error> {
|
|
||||||
let config = match endpoint_type {
|
|
||||||
"upload" => &self.config.upload_rate_limit,
|
|
||||||
"auth" => &self.config.auth_rate_limit,
|
|
||||||
_ => &self.config.general_rate_limit,
|
|
||||||
};
|
|
||||||
|
|
||||||
let current_time = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_secs();
|
|
||||||
|
|
||||||
let redis_key = format!("rate_limit:{}:{}", endpoint_type, ip);
|
|
||||||
|
|
||||||
// Проверяем локальный кэш
|
|
||||||
{
|
|
||||||
let cache = self.local_cache.read().await;
|
|
||||||
if let Some(info) = cache.get(&redis_key) {
|
|
||||||
if let Some(blocked_until) = info.blocked_until {
|
|
||||||
if current_time < blocked_until {
|
|
||||||
warn!("IP {} blocked until {}", ip, blocked_until);
|
|
||||||
return Err(ErrorTooManyRequests("Rate limit exceeded, IP temporarily blocked"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем в Redis
|
|
||||||
let info_str: Option<String> = self.redis.get(&redis_key).await
|
|
||||||
.map_err(|e| {
|
|
||||||
error!("Redis error in rate limit check: {}", e);
|
|
||||||
actix_web::error::ErrorInternalServerError("Service temporarily unavailable")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let mut request_info = if let Some(info_str) = info_str {
|
|
||||||
serde_json::from_str::<RequestInfo>(&info_str)
|
|
||||||
.unwrap_or_else(|_| RequestInfo {
|
|
||||||
count: 0,
|
|
||||||
first_request_time: current_time,
|
|
||||||
blocked_until: None,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
RequestInfo {
|
|
||||||
count: 0,
|
|
||||||
first_request_time: current_time,
|
|
||||||
blocked_until: None,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Проверяем блокировку
|
|
||||||
if let Some(blocked_until) = request_info.blocked_until {
|
|
||||||
if current_time < blocked_until {
|
|
||||||
warn!("IP {} is blocked until {}", ip, blocked_until);
|
|
||||||
return Err(ErrorTooManyRequests("Rate limit exceeded, IP temporarily blocked"));
|
|
||||||
} else {
|
|
||||||
// Блокировка истекла, сбрасываем
|
|
||||||
request_info.blocked_until = None;
|
|
||||||
request_info.count = 0;
|
|
||||||
request_info.first_request_time = current_time;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем окно времени
|
|
||||||
if current_time - request_info.first_request_time > config.window_seconds {
|
|
||||||
// Новое окно времени, сбрасываем счетчик
|
|
||||||
request_info.count = 0;
|
|
||||||
request_info.first_request_time = current_time;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Увеличиваем счетчик
|
|
||||||
request_info.count += 1;
|
|
||||||
|
|
||||||
// Проверяем лимит
|
|
||||||
if request_info.count > config.max_requests {
|
|
||||||
warn!("Rate limit exceeded for IP {}: {} requests in window", ip, request_info.count);
|
|
||||||
|
|
||||||
// Устанавливаем блокировку
|
|
||||||
request_info.blocked_until = Some(current_time + config.block_duration_seconds);
|
|
||||||
|
|
||||||
// Сохраняем в Redis
|
|
||||||
let info_str = serde_json::to_string(&request_info).unwrap();
|
|
||||||
let _: () = self.redis.set_ex(&redis_key, info_str, config.block_duration_seconds).await
|
|
||||||
.map_err(|e| {
|
|
||||||
error!("Redis error saving rate limit: {}", e);
|
|
||||||
actix_web::error::ErrorInternalServerError("Service temporarily unavailable")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Обновляем локальный кэш
|
|
||||||
{
|
|
||||||
let mut cache = self.local_cache.write().await;
|
|
||||||
cache.insert(redis_key, request_info);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Err(ErrorTooManyRequests("Rate limit exceeded, IP temporarily blocked"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Сохраняем обновленную информацию
|
|
||||||
let info_str = serde_json::to_string(&request_info).unwrap();
|
|
||||||
let _: () = self.redis.set_ex(&redis_key, info_str, config.window_seconds * 2).await
|
|
||||||
.map_err(|e| {
|
|
||||||
error!("Redis error updating rate limit: {}", e);
|
|
||||||
actix_web::error::ErrorInternalServerError("Service temporarily unavailable")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let count = request_info.count;
|
|
||||||
|
|
||||||
// Обновляем локальный кэш
|
|
||||||
{
|
|
||||||
let mut cache = self.local_cache.write().await;
|
|
||||||
cache.insert(redis_key, request_info);
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("Rate limit check passed for IP {}: {}/{} requests", ip, count, config.max_requests);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Проверяет безопасность запроса (размер, заголовки, путь)
|
|
||||||
pub fn validate_request_security(&self, req: &HttpRequest) -> Result<(), actix_web::Error> {
|
|
||||||
// Проверка длины пути
|
|
||||||
let path = req.path();
|
let path = req.path();
|
||||||
if path.len() > self.config.max_path_length {
|
|
||||||
warn!("Request path too long: {} chars", path.len());
|
// Проверка длины пути
|
||||||
return Err(actix_web::error::ErrorBadRequest("Request path too long"));
|
if path.len() > self.max_path_length {
|
||||||
|
warn!("Path too long: {} chars", path.len());
|
||||||
|
return Err(actix_web::error::ErrorBadRequest("Path too long"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверка количества заголовков
|
// Проверка количества заголовков
|
||||||
if req.headers().len() > self.config.max_headers_count {
|
if req.headers().len() > self.max_headers_count {
|
||||||
warn!("Too many headers: {}", req.headers().len());
|
warn!("Too many headers: {}", req.headers().len());
|
||||||
return Err(actix_web::error::ErrorBadRequest("Too many headers"));
|
return Err(actix_web::error::ErrorBadRequest("Too many headers"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверка длины значений заголовков
|
// Проверка длины значений заголовков
|
||||||
for (name, value) in req.headers().iter() {
|
for (name, value) in req.headers() {
|
||||||
if let Ok(value_str) = value.to_str() {
|
if let Ok(value_str) = value.to_str() {
|
||||||
if value_str.len() > self.config.max_header_value_length {
|
if value_str.len() > self.max_header_value_length {
|
||||||
warn!("Header value too long: {} = {} chars", name, value_str.len());
|
warn!(
|
||||||
|
"Header value too long: {} = {} chars",
|
||||||
|
name,
|
||||||
|
value_str.len()
|
||||||
|
);
|
||||||
return Err(actix_web::error::ErrorBadRequest("Header value too long"));
|
return Err(actix_web::error::ErrorBadRequest("Header value too long"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,81 +81,104 @@ impl SecurityManager {
|
|||||||
// Проверка на подозрительные символы в пути
|
// Проверка на подозрительные символы в пути
|
||||||
if path.contains("..") || path.contains('\0') || path.contains('\r') || path.contains('\n') {
|
if path.contains("..") || path.contains('\0') || path.contains('\r') || path.contains('\n') {
|
||||||
warn!("Suspicious characters in path: {}", path);
|
warn!("Suspicious characters in path: {}", path);
|
||||||
return Err(actix_web::error::ErrorBadRequest("Invalid characters in path"));
|
return Err(actix_web::error::ErrorBadRequest(
|
||||||
|
"Invalid characters in path",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка на подозрительные паттерны
|
||||||
|
if self.check_suspicious_patterns(path) {
|
||||||
|
return Err(actix_web::error::ErrorBadRequest("Suspicious path pattern"));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Проверяет подозрительные паттерны в пути
|
/// Проверяет путь на подозрительные паттерны
|
||||||
pub fn check_suspicious_patterns(&self, path: &str) -> bool {
|
pub fn check_suspicious_patterns(&self, path: &str) -> bool {
|
||||||
let suspicious_patterns = [
|
let suspicious_patterns = [
|
||||||
"/admin", "/wp-admin", "/phpmyadmin", "/.env", "/config",
|
"/admin",
|
||||||
"/.git", "/backup", "/db", "/sql", "/.well-known/acme-challenge",
|
"/wp-admin",
|
||||||
"/xmlrpc.php", "/wp-login.php", "/wp-config.php",
|
"/phpmyadmin",
|
||||||
"script>", "<iframe", "javascript:", "data:",
|
"/.env",
|
||||||
|
"/config",
|
||||||
|
"/.git",
|
||||||
|
"/backup",
|
||||||
|
"/db",
|
||||||
|
"/sql",
|
||||||
|
"/xmlrpc.php",
|
||||||
|
"/wp-login.php",
|
||||||
|
"/wp-config.php",
|
||||||
|
"script>",
|
||||||
|
"<iframe",
|
||||||
|
"javascript:",
|
||||||
|
"data:",
|
||||||
];
|
];
|
||||||
|
|
||||||
let path_lower = path.to_lowercase();
|
let path_lower = path.to_lowercase();
|
||||||
for pattern in &suspicious_patterns {
|
for pattern in &suspicious_patterns {
|
||||||
if path_lower.contains(pattern) {
|
if path_lower.contains(pattern) {
|
||||||
warn!("Suspicious pattern detected in path: {} (pattern: {})", path, pattern);
|
warn!(
|
||||||
|
"Suspicious pattern detected in path: {} (pattern: {})",
|
||||||
|
path, pattern
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Очистка старых записей из локального кэша
|
/// Проверяет лимит загрузок для IP (только для upload endpoint)
|
||||||
pub async fn cleanup_cache(&mut self) {
|
pub async fn check_upload_limit(&self, ip: &str) -> Result<(), actix_web::Error> {
|
||||||
let current_time = SystemTime::now()
|
let current_time = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_secs();
|
.as_secs();
|
||||||
|
|
||||||
let mut cache = self.local_cache.write().await;
|
let mut counts = self.upload_protection.upload_counts.write().await;
|
||||||
let mut to_remove = Vec::new();
|
|
||||||
|
// Очищаем старые записи (старше минуты)
|
||||||
|
counts.retain(|_, (_, timestamp)| current_time - *timestamp < 60);
|
||||||
|
|
||||||
|
// Проверяем текущий IP
|
||||||
|
let current_count = counts.get(ip).map(|(count, _)| *count).unwrap_or(0);
|
||||||
|
let first_upload_time = counts.get(ip).map(|(_, time)| *time).unwrap_or(current_time);
|
||||||
|
|
||||||
|
if current_time - first_upload_time < 60 {
|
||||||
|
// В пределах минуты
|
||||||
|
if current_count >= self.upload_protection.max_uploads_per_minute {
|
||||||
|
warn!("Upload limit exceeded for IP {}: {} uploads in minute", ip, current_count);
|
||||||
|
return Err(actix_web::error::ErrorTooManyRequests("Upload limit exceeded"));
|
||||||
|
}
|
||||||
|
counts.insert(ip.to_string(), (current_count + 1, first_upload_time));
|
||||||
|
} else {
|
||||||
|
// Новая минута, сбрасываем счетчик
|
||||||
|
counts.insert(ip.to_string(), (1, current_time));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
for (key, info) in cache.iter() {
|
/// Извлекает IP адрес клиента
|
||||||
// Удаляем записи старше 1 часа
|
pub fn extract_client_ip(req: &HttpRequest) -> String {
|
||||||
if current_time - info.first_request_time > 3600 {
|
// Проверяем X-Forwarded-For (для прокси)
|
||||||
to_remove.push(key.clone());
|
if let Some(forwarded) = req.headers().get("x-forwarded-for") {
|
||||||
|
if let Ok(forwarded_str) = forwarded.to_str() {
|
||||||
|
if let Some(first_ip) = forwarded_str.split(',').next() {
|
||||||
|
return first_ip.trim().to_string();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for key in to_remove {
|
// Проверяем X-Real-IP
|
||||||
cache.remove(&key);
|
if let Some(real_ip) = req.headers().get("x-real-ip") {
|
||||||
|
if let Ok(real_ip_str) = real_ip.to_str() {
|
||||||
|
return real_ip_str.to_string();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Cleaned {} old entries from security cache", cache.len());
|
// Fallback на connection info
|
||||||
|
req.connection_info().peer_addr().unwrap_or("unknown").to_string()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Middleware для проверки безопасности
|
|
||||||
pub async fn security_middleware(
|
|
||||||
req: ServiceRequest,
|
|
||||||
next: Next<impl actix_web::body::MessageBody>,
|
|
||||||
) -> Result<ServiceResponse<impl actix_web::body::MessageBody>, actix_web::Error> {
|
|
||||||
let path = req.path().to_string();
|
|
||||||
let method = req.method().to_string();
|
|
||||||
|
|
||||||
// Быстрая проверка на известные атаки
|
|
||||||
if path.contains("..") || path.contains('\0') || path.len() > 1000 {
|
|
||||||
warn!("Blocked suspicious request: {} {}", method, path);
|
|
||||||
return Err(actix_web::error::ErrorBadRequest("Invalid request"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверка на bot patterns
|
|
||||||
if let Some(user_agent) = req.headers().get("user-agent") {
|
|
||||||
if let Ok(ua_str) = user_agent.to_str() {
|
|
||||||
let ua_lower = ua_str.to_lowercase();
|
|
||||||
if ua_lower.contains("bot") || ua_lower.contains("crawler") || ua_lower.contains("spider") {
|
|
||||||
// Для ботов применяем более строгие лимиты
|
|
||||||
info!("Bot detected: {}", ua_str);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let res = next.call(req).await?;
|
|
||||||
Ok(res)
|
|
||||||
}
|
|
||||||
|
|||||||
256
src/thumbnail.rs
256
src/thumbnail.rs
@@ -1,210 +1,74 @@
|
|||||||
use actix_web::error::ErrorInternalServerError;
|
// Модуль для парсинга путей к файлам (без генерации миниатюр)
|
||||||
use image::{DynamicImage, ImageFormat, imageops::FilterType};
|
|
||||||
use log::warn;
|
|
||||||
use std::{collections::HashMap, io::Cursor};
|
|
||||||
|
|
||||||
use crate::{app_state::AppState, s3_utils::upload_to_s3};
|
/// Парсит путь к файлу, извлекая базовое имя, ширину и расширение.
|
||||||
|
|
||||||
pub const THUMB_WIDTHS: [u32; 7] = [10, 40, 110, 300, 600, 800, 1400];
|
|
||||||
|
|
||||||
/// Парсит путь к файлу, извлекая оригинальное имя, требуемую ширину и формат.
|
|
||||||
/// Примеры:
|
|
||||||
/// - "filename_150.ext" -> ("filename", 150, "ext")
|
|
||||||
/// - "unsafe/1440x/production/image/439efaa0-816f-11ef-b201-439da98539bc.jpg" -> ("439efaa0-816f-11ef-b201-439da98539bc", 1440, "jpg")
|
|
||||||
/// - "unsafe/production/image/5627e002-0c53-11ee-9565-0242ac110006.png" -> ("5627e002-0c53-11ee-9565-0242ac110006", 0, "png")
|
|
||||||
/// - "unsafe/development/image/439efaa0-816f-11ef-b201-439da98539bc.jpg/webp" -> ("439efaa0-816f-11ef-b201-439da98539bc", 0, "webp")
|
|
||||||
#[allow(clippy::collapsible_if)]
|
|
||||||
pub fn parse_file_path(requested_path: &str) -> (String, u32, String) {
|
|
||||||
let mut path = requested_path.to_string();
|
|
||||||
if requested_path.ends_with("/webp") {
|
|
||||||
path = path.replace("/webp", "");
|
|
||||||
}
|
|
||||||
let mut path_parts: Vec<&str> = path.split('/').collect();
|
|
||||||
let mut extension = String::new();
|
|
||||||
let mut width = 0;
|
|
||||||
let mut base_filename = String::new();
|
|
||||||
|
|
||||||
if path_parts.is_empty() {
|
|
||||||
return (path.to_string(), width, extension);
|
|
||||||
}
|
|
||||||
|
|
||||||
// пытаемся извлечь формат из имени файла
|
|
||||||
if let Some(filename_part) = path_parts.pop() {
|
|
||||||
if let Some((base, ext_part)) = filename_part.rsplit_once('.') {
|
|
||||||
extension = ext_part.to_string();
|
|
||||||
base_filename = base.to_string(); // Устанавливаем base_filename без расширения
|
|
||||||
} else {
|
|
||||||
base_filename = filename_part.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если base_filename ещё не установлено, извлекаем его
|
|
||||||
if base_filename.is_empty() {
|
|
||||||
if let Some(filename_part) = path_parts.pop() {
|
|
||||||
if let Some((base, ext_part)) = filename_part.rsplit_once('.') {
|
|
||||||
extension = ext_part.to_string();
|
|
||||||
base_filename = base.to_string();
|
|
||||||
} else {
|
|
||||||
base_filename = filename_part.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Извлечение ширины из base_filename, если она есть
|
|
||||||
if let Some((name_part, width_str)) = base_filename.rsplit_once('_') {
|
|
||||||
if let Ok(w) = width_str.parse::<u32>() {
|
|
||||||
width = w;
|
|
||||||
base_filename = name_part.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверка на старую ширину в путях, начинающихся с "unsafe"
|
|
||||||
if path.starts_with("unsafe") && width == 0 && path_parts.len() >= 2 {
|
|
||||||
if let Some(old_width_str) = path_parts.get(1) {
|
|
||||||
// Получаем второй элемент
|
|
||||||
let old_width_str = old_width_str.trim_end_matches('x');
|
|
||||||
if let Ok(w) = old_width_str.parse::<u32>() {
|
|
||||||
width = w;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(base_filename, width, extension)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Генерирует миниатюры изображения.
|
|
||||||
///
|
///
|
||||||
/// Теперь функция принимает дополнительный параметр `format`, который определяет формат сохранения миниатюр.
|
/// Пример:
|
||||||
/// Это позволяет поддерживать различные форматы изображений без необходимости заранее предугадывать их.
|
/// - "image.jpg" -> ("image", 0, "jpg")
|
||||||
pub async fn generate_thumbnails(
|
/// - "image_300.jpg" -> ("image", 300, "jpg")
|
||||||
image: &DynamicImage,
|
/// - "image_large.jpg" -> ("image", 0, "jpg") - некорректная ширина игнорируется
|
||||||
format: ImageFormat,
|
pub fn parse_file_path(path: &str) -> (String, u32, String) {
|
||||||
) -> Result<HashMap<u32, Vec<u8>>, actix_web::Error> {
|
let path = path.trim_start_matches('/');
|
||||||
let mut thumbnails = HashMap::new();
|
|
||||||
|
|
||||||
for &width in THUMB_WIDTHS.iter().filter(|&&w| w < image.width()) {
|
// Находим последнюю точку для разделения имени и расширения
|
||||||
let thumbnail = image.resize(width, u32::MAX, FilterType::Lanczos3); // Ресайз изображения по ширине
|
let (name_part, extension) = match path.rfind('.') {
|
||||||
let mut buffer = Vec::new();
|
Some(dot_pos) => (&path[..dot_pos], path[dot_pos + 1..].to_string()),
|
||||||
thumbnail
|
None => (path, String::new()),
|
||||||
.write_to(&mut Cursor::new(&mut buffer), format)
|
};
|
||||||
.map_err(|e| {
|
|
||||||
log::error!("Ошибка при сохранении миниатюры: {}", e);
|
|
||||||
ErrorInternalServerError("Не удалось сгенерировать миниатюру")
|
|
||||||
})?; // Сохранение изображения в указанном формате
|
|
||||||
thumbnails.insert(width, buffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(thumbnails)
|
// Ищем последнее подчеркивание в имени файла
|
||||||
}
|
if let Some(underscore_pos) = name_part.rfind('_') {
|
||||||
|
let base_filename = name_part[..underscore_pos].to_string();
|
||||||
|
let width_str = &name_part[underscore_pos + 1..];
|
||||||
|
|
||||||
/// Определяет формат изображения на основе расширения файла.
|
// Пытаемся парсить ширину
|
||||||
fn determine_image_format(extension: &str) -> Result<ImageFormat, actix_web::Error> {
|
match width_str.parse::<u32>() {
|
||||||
match extension.to_lowercase().as_str() {
|
Ok(width) => {
|
||||||
"jpg" | "jpeg" => Ok(ImageFormat::Jpeg),
|
return (base_filename, width, extension);
|
||||||
"png" => Ok(ImageFormat::Png),
|
|
||||||
"gif" => Ok(ImageFormat::Gif),
|
|
||||||
"webp" => Ok(ImageFormat::WebP),
|
|
||||||
"heic" | "heif" | "tiff" | "tif" => {
|
|
||||||
// Конвертируем HEIC и TIFF в JPEG при сохранении
|
|
||||||
Ok(ImageFormat::Jpeg)
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
log::error!("Неподдерживаемый формат изображения: {}", extension);
|
|
||||||
Err(ErrorInternalServerError(
|
|
||||||
"Неподдерживаемый формат изображения",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Сохраняет данные миниатюры.
|
|
||||||
///
|
|
||||||
/// Обновлена для передачи корректного формата изображения.
|
|
||||||
pub async fn thumbdata_save(
|
|
||||||
original_data: Vec<u8>,
|
|
||||||
state: &AppState,
|
|
||||||
original_filename: &str,
|
|
||||||
content_type: String,
|
|
||||||
) -> Result<(), actix_web::Error> {
|
|
||||||
if content_type.starts_with("image") {
|
|
||||||
warn!("original file name: {}", original_filename);
|
|
||||||
let (base_filename, _, extension) = parse_file_path(original_filename);
|
|
||||||
warn!("detected file extension: {}", extension);
|
|
||||||
|
|
||||||
// Для HEIC файлов просто сохраняем оригинал как миниатюру
|
|
||||||
if content_type == "image/heic" {
|
|
||||||
warn!("HEIC file detected, using original as thumbnail");
|
|
||||||
let thumb_filename = format!("{}_{}.heic", base_filename, THUMB_WIDTHS[0]);
|
|
||||||
|
|
||||||
if let Err(e) = upload_to_s3(
|
|
||||||
&state.storj_client,
|
|
||||||
&state.bucket,
|
|
||||||
&thumb_filename,
|
|
||||||
original_data,
|
|
||||||
&content_type,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
warn!("cannot save HEIC thumb {}: {}", thumb_filename, e);
|
|
||||||
return Err(ErrorInternalServerError("cant save HEIC thumbnail"));
|
|
||||||
}
|
}
|
||||||
return Ok(());
|
Err(_) => {
|
||||||
}
|
// Если не получилось парсить как число, считаем все имя файла базовым
|
||||||
|
|
||||||
// Для остальных изображений продолжаем как обычно
|
|
||||||
let img = match image::load_from_memory(&original_data) {
|
|
||||||
Ok(img) => img,
|
|
||||||
Err(e) => {
|
|
||||||
warn!("cannot load image from memory: {}", e);
|
|
||||||
return Err(ErrorInternalServerError("cant load image"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
warn!("generate thumbnails for {}", original_filename);
|
|
||||||
let format = determine_image_format(&extension.to_lowercase())?;
|
|
||||||
|
|
||||||
match generate_thumbnails(&img, format).await {
|
|
||||||
Ok(thumbnails_bytes) => {
|
|
||||||
for (thumb_width, thumbnail) in thumbnails_bytes {
|
|
||||||
let thumb_filename = format!("{}_{}.{}", base_filename, thumb_width, extension);
|
|
||||||
if let Err(e) = upload_to_s3(
|
|
||||||
&state.storj_client,
|
|
||||||
&state.bucket,
|
|
||||||
&thumb_filename,
|
|
||||||
thumbnail,
|
|
||||||
&content_type,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
warn!("cannot load thumb {}: {}", thumb_filename, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!(
|
|
||||||
"cannot generate thumbnails for {}: {}",
|
|
||||||
original_filename, e
|
|
||||||
);
|
|
||||||
return Err(e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
// Если подчеркивания нет или ширина не парсится, возвращаем все как базовое имя
|
||||||
|
(name_part.to_string(), 0, extension)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Выбирает ближайший подходящий размер из предопределённых.
|
#[cfg(test)]
|
||||||
/// Если `requested_width` больше максимальной ширины в `THUMB_WIDTHS`,
|
mod tests {
|
||||||
/// возвращает максимальную ширину.
|
use super::*;
|
||||||
pub fn find_closest_width(requested_width: u32) -> u32 {
|
|
||||||
// Проверяем, превышает ли запрошенная ширина максимальную доступную ширину
|
#[test]
|
||||||
if requested_width > *THUMB_WIDTHS.last().unwrap() {
|
fn test_parse_file_path() {
|
||||||
return *THUMB_WIDTHS.last().unwrap();
|
// Обычный файл без ширины
|
||||||
|
let (base, width, ext) = parse_file_path("image.jpg");
|
||||||
|
assert_eq!(base, "image");
|
||||||
|
assert_eq!(width, 0);
|
||||||
|
assert_eq!(ext, "jpg");
|
||||||
|
|
||||||
|
// Файл с шириной
|
||||||
|
let (base, width, ext) = parse_file_path("photo_300.png");
|
||||||
|
assert_eq!(base, "photo");
|
||||||
|
assert_eq!(width, 300);
|
||||||
|
assert_eq!(ext, "png");
|
||||||
|
|
||||||
|
// Файл с нечисловым суффиксом
|
||||||
|
let (base, width, ext) = parse_file_path("document_large.pdf");
|
||||||
|
assert_eq!(base, "document_large");
|
||||||
|
assert_eq!(width, 0);
|
||||||
|
assert_eq!(ext, "pdf");
|
||||||
|
|
||||||
|
// Файл без расширения
|
||||||
|
let (base, width, ext) = parse_file_path("file_100");
|
||||||
|
assert_eq!(base, "file");
|
||||||
|
assert_eq!(width, 100);
|
||||||
|
assert_eq!(ext, "");
|
||||||
|
|
||||||
|
// Путь с префиксом
|
||||||
|
let (base, width, ext) = parse_file_path("/uploads/image_800.jpg");
|
||||||
|
assert_eq!(base, "uploads/image");
|
||||||
|
assert_eq!(width, 800);
|
||||||
|
assert_eq!(ext, "jpg");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Находим ширину с минимальной абсолютной разницей с запрошенной
|
|
||||||
*THUMB_WIDTHS
|
|
||||||
.iter()
|
|
||||||
.min_by_key(|&&width| (width as i32 - requested_width as i32).abs())
|
|
||||||
.unwrap_or(&THUMB_WIDTHS[0]) // Возвращаем самый маленький размер, если ничего не подошло
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user