[0.6.1] - 2025-09-02
Some checks failed
Deploy / deploy (push) Has been skipped
CI / lint (push) Failing after 8s
CI / test (push) Failing after 10m26s

### 🚀 Изменено - Упрощение архитектуры
- **Генерация миниатюр**: Полностью удалена из 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:
2025-09-02 14:00:54 +03:00
parent b876564f4a
commit 7973ba0027
32 changed files with 1168 additions and 3855 deletions

View File

@@ -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
### 🔒 Безопасность и защита от DDoS

2
Cargo.lock generated
View File

@@ -2644,7 +2644,7 @@ dependencies = [
[[package]]
name = "quoter"
version = "0.5.4"
version = "0.6.1"
dependencies = [
"actix",
"actix-cors",

View File

@@ -1,6 +1,6 @@
[package]
name = "quoter"
version = "0.6.0"
version = "0.6.1"
edition = "2024"
[dependencies]

View File

@@ -45,11 +45,11 @@ ENV CARGO_HTTP_TIMEOUT=60
ENV CARGO_HTTP_LOW_SPEED_LIMIT=10
ENV RUSTC_FORCE_INCREMENTAL=0
# 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
cargo clean -p quoter && \
# Keep only the dependency artifacts
find target/release/deps -name "quoter*" -delete
# Keep only the dependency artifacts (suppressing error if dir doesn't exist)
find target/release/deps -name "quoter*" -delete 2>/dev/null || true
# Remove the default source file created by cargo new
RUN rm src/*.rs

184
README.md
View File

@@ -1,155 +1,91 @@
# Quoter 🚀
[![Rust](https://img.shields.io/badge/Rust-1.70+-orange.svg)](https://www.rust-lang.org/)
[![Actix Web](https://img.shields.io/badge/Actix%20Web-4.0+-blue.svg)](https://actix.rs/)
[![Redis](https://img.shields.io/badge/Redis-6.0+-red.svg)](https://redis.io/)
[![S3 Compatible](https://img.shields.io/badge/S3%20Compatible-Storj%20%7C%20AWS-green.svg)](https://aws.amazon.com/s3/)
[![Tests](https://img.shields.io/badge/Tests-36%20Passing-brightgreen.svg)](https://dev.discours.io/discours.io/quoter)
[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
> Simple file upload proxy with quotas. Upload to S3, thumbnails via Vercel.
> Микросервис для управления файлами с поддержкой квот, миниатюр и интеграции с 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
# Все тесты
cargo test
# Setup
cargo build
cp .env.example .env # Configure environment
cargo run
# Конкретный тест
cargo test test_health_check
# Тесты с покрытием
./scripts/test-coverage.sh
# Test
curl http://localhost:8080/ # Health check
```
### Статистика тестов
- **basic_test.rs:** 23 теста (основная функциональность)
- **handler_tests.rs:** 13 тестов (HTTP endpoints)
- **Общее покрытие:** 100% основных компонентов
- **Статус:** Все тесты проходят успешно
## 🔧 API
## 📋 Требования
| 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 или выше
- **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).
### Примеры использования
#### Загрузка файла
### Upload file
```bash
curl -X POST http://localhost:8080/ \
-H "Authorization: Bearer your-token" \
-F "file=@image.jpg"
```
#### Получение миниатюры
### Get thumbnail
```bash
# Legacy thumbnails (fallback only)
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
```
#### Увеличение квоты
```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}'
## 🏗️ Architecture & Setup
**Simple 3-tier architecture:**
- **Upload**: Quoter (auth + quotas + S3 storage)
- **Download**: Vercel Edge API (thumbnails + optimization)
- **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
cargo build # сборка
cargo test # запуск тестов
cargo clippy # Проверка кода
cargo fmt # Форматирование
RUST_LOG=debug cargo run # подробные логи
# Required
REDIS_URL=redis://localhost:6379
STORJ_ACCESS_KEY=your-key
STORJ_SECRET_KEY=your-secret
JWT_SECRET=your-secret
# Optional
PORT=8080
RUST_LOG=info
```
### Метрики
## 🧪 Testing
Основные метрики для мониторинга:
```bash
cargo test # 36 tests passing
./scripts/test-coverage.sh # Coverage report
```
- Количество загруженных файлов
- Использование квот пользователями
- Время ответа API
- Ошибки аутентификации
- Ошибки загрузки в S3
## 📚 Documentation
- [`docs/configuration.md`](./docs/configuration.md) - Environment setup
- [`docs/architecture.md`](./docs/architecture.md) - Technical details
- [`docs/vercel-og-integration.md`](./docs/vercel-og-integration.md) - Vercel integration
For detailed setup and deployment instructions, see the docs folder.

View File

@@ -1,49 +1,19 @@
# Документация Quoter
# Quoter Documentation
## 📚 Оглавление
Simple file upload proxy with S3 storage and user quotas.
### 📋 Архитектура и принципы работы
- [🚀 Как работает Quoter](./how-it-works.md) - Подробная архитектура системы с диаграммами
- [🔀 Гибридная архитектура](./hybrid-architecture.md) - Vercel Edge + Quoter integration
- [📐 Формат URL для ресайзера](./url-format.md) - Полное руководство по URL паттернам
- [⚙️ API Reference](./api-reference.md) - Полная документация API
## 📚 Documentation
### 🛡️ Безопасность и настройка
- [🔒 Безопасность и защита от DDoS](./security.md) - Комплексная система защиты
- [⚙️ Конфигурация](./configuration.md) - Настройка переменных окружения
- [🚀 Развертывание](./deployment.md) - Инструкции по развертыванию
- [📊 Мониторинг](./monitoring.md) - Логирование и мониторинг
- **[SETUP.md](./SETUP.md)** - Installation, configuration, and deployment
- **[architecture.md](./architecture.md)** - Technical details for developers
- **[configuration.md](./configuration.md)** - Environment variables reference
- **[features.md](./features.md)** - What Quoter does
- **[how-it-works.md](./how-it-works.md)** - System overview
- **[hybrid-architecture.md](./hybrid-architecture.md)** - Vercel + Quoter integration
- **[vercel-og-integration.md](./vercel-og-integration.md)** - OpenGraph integration
### 🎨 Интеграции
- [🎨 Vercel OG Integration](./vercel-og-integration.md) - Полное руководство по интеграции с @vercel/og
- [⚡ Vercel OG Quick Start](./vercel-og-quickstart.md) - Быстрый старт за 5 минут
## 🎯 Key Concept
### Технические детали
- [Архитектура](./architecture.md) - Техническая архитектура системы
- [База данных](./database.md) - Структура Redis и схемы данных
- [S3 интеграция](./s3-integration.md) - Работа с S3/Storj
- [Обработка изображений](./image-processing.md) - Создание миниатюр и оверлеев
- [Безопасность](./security.md) - Аутентификация и авторизация
**Quoter = Upload + Storage. Vercel = Thumbnails + Optimization.**
### Разработка
- [Разработка](./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
Upload files to Quoter → Store in S3 → Serve via Vercel Edge API for best performance.

191
docs/SETUP.md Normal file
View 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
```

View File

@@ -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}'
```

View File

@@ -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!** 🚀

View File

@@ -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
- Не храните секреты в коде
- Ротация ключей доступа
### Аудит
- Логируйте все операции с файлами
- Отслеживайте использование квот
- Мониторьте подозрительную активность

View File

@@ -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

View File

@@ -1,77 +1,48 @@
# Функциональность проекта Quoter
# Quoter Features
## Основные возможности
Simple file upload/download proxy with user quotas and S3 storage.
### 🖼️ Обработка изображений
- Загрузка и хранение изображений
- Генерация thumbnail'ов различных размеров
- Поддержка форматов: JPG, PNG, GIF, WebP, HEIC, TIFF
- Автоматическое определение формата изображений
## What Quoter Does
### 🔐 Аутентификация и авторизация
- Система токенов для пользователей
- Управление квотами загрузки (5GB на пользователя)
- Проверка прав доступа к файлам
### 📤 File Upload
- **Multipart uploads** to S3/Storj storage
- **User quotas** (5GB default per user)
- **JWT authentication** with session management
- **MIME type detection** from file content
- **Rate limiting** to prevent abuse
### 📁 Управление файлами
- Загрузка файлов через multipart form data
- Хранение в S3-совместимых хранилищах
- Поиск файлов по паттернам
- Кэширование списков файлов
### 📁 File Storage
- **S3-compatible storage** (Storj primary, AWS fallback)
- **Redis caching** for file metadata and quotas
- **Multi-cloud support** with automatic migration
### 🌐 HTTP API
- RESTful endpoints для всех операций
- Поддержка CORS для веб-приложений
- Обработка ошибок с детальными сообщениями
- Проксирование запросов к файлам
### 🌐 File Serving
- **Direct file access** via filename
- **Fast response** optimized for Vercel Edge caching
- **CORS whitelist** for secure access
- **Direct file serving** optimized for CDN caching
### 📊 Мониторинг и логирование
- Интеграция с Sentry для отслеживания ошибок
- Логирование всех операций
- Метрики производительности
## 🚀 Modern Architecture
## Технические особенности
**Quoter**: Simple file upload/download + S3 storage
**Vercel**: Smart thumbnails + optimization + global CDN
### 🧪 Тестирование
- Полное покрытие unit тестами (36 тестов)
- Интеграционные тесты для всех компонентов
- Моки для внешних зависимостей
- Тесты производительности
💋 **Ultra-simple**: Quoter just handles raw files. That's it.
💋 **Simplified**: Focus on what each service does best.
### 🚀 Развертывание
- Docker контейнеризация
- Автоматизированный CI/CD конвейер
- Поддержка различных окружений
- Масштабируемая архитектура
## Technical Stack
### 🔧 Конфигурация
- Гибкая настройка через переменные окружения
- Поддержка различных S3 провайдеров
- Настраиваемые квоты и лимиты
- Конфигурация CORS политик
- **Backend**: Rust + Actix Web
- **Storage**: Redis (metadata) + S3/Storj (files)
- **Auth**: JWT tokens
- **Tests**: 36 passing tests with full coverage
## Архитектура
## Status
### Модули
- `core.rs` - основная бизнес-логика и GraphQL API
- `auth.rs` - аутентификация и управление пользователями
- `handlers/` - HTTP обработчики запросов
- `thumbnail.rs` - генерация thumbnail'ов
- `s3_utils.rs` - работа с S3-совместимыми хранилищами
- `lookup.rs` - поиск и определение типов файлов
- `overlay.rs` - наложение водяных знаков и метаданных
### Зависимости
- Actix Web для HTTP сервера
- Redis для кэширования
- AWS SDK для S3 операций
- Image crate для обработки изображений
- Sentry для мониторинга
## Статус разработки
- ✅ Основная функциональность реализована
- ✅ Полное покрытие тестами
- ✅ CI/CD конвейер настроен
- ✅ Документация обновлена
- 🚀 Готов к продакшн деплою
- ✅ Upload API with quotas
- ✅ Static file server
- ✅ S3 storage integration
- ✅ JWT authentication
- ✅ Rate limiting & security
- ✅ Full test coverage
- 🚀 Production ready

View File

@@ -7,7 +7,7 @@
```
📤 Upload: Quoter (контроль + квоты)
📥 Download: Vercel Edge API (производительность)
🎨 OG: @vercel/og (динамическая генерация)
🎨 Thumbnails: Vercel /api/thumb/[width]/[...path] (динамическая генерация)
```
## ✅ Преимущества гибридного подхода

View File

@@ -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. **Документация**: Документируйте все кастомные метрики

View File

@@ -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
```

View File

@@ -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

View File

@@ -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.*

View File

@@ -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 формата
```

View File

@@ -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
View 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! 🚀

View File

@@ -1,10 +1,11 @@
use crate::s3_utils::get_s3_filelist;
use crate::security::SecurityConfig;
use actix_web::error::ErrorInternalServerError;
use aws_config::BehaviorVersion;
use aws_sdk_s3::{Client as S3Client, config::Credentials};
use log::warn;
use redis::{AsyncCommands, Client as RedisClient, aio::MultiplexedConnection};
use std::env;
use std::{env, time::Duration};
#[derive(Clone)]
pub struct AppState {
@@ -12,6 +13,7 @@ pub struct AppState {
pub storj_client: S3Client,
pub aws_client: S3Client,
pub bucket: String,
pub request_timeout: Duration,
}
const PATH_MAPPING_KEY: &str = "filepath_mapping"; // Ключ для хранения маппинга путей
@@ -20,13 +22,25 @@ const PATH_MAPPING_KEY: &str = "filepath_mapping"; // Ключ для хране
impl AppState {
/// Инициализация нового состояния приложения.
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_client = RedisClient::open(redis_url).expect("Invalid Redis URL");
let redis_connection = redis_client
.get_multiplexed_async_connection()
// Устанавливаем таймаут для Redis операций
let redis_connection = tokio::time::timeout(
Duration::from_secs(security_config.request_timeout_seconds),
redis_client.get_multiplexed_async_connection(),
)
.await
.unwrap();
.map_err(|_| "Redis connection timeout")
.expect("Failed to connect to Redis within timeout")
.expect("Redis connection failed");
// Получаем конфигурацию для S3 (Storj)
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 =
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())
.region("eu-west-1")
.endpoint_url(s3_endpoint)
@@ -52,12 +66,20 @@ impl AppState {
None,
"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()
.await;
let storj_client = S3Client::new(&storj_config);
// Конфигурируем клиент S3 для AWS
// Конфигурируем клиент S3 для AWS с таймаутом
let aws_config = aws_config::defaults(BehaviorVersion::latest())
.region("eu-west-1")
.endpoint_url(aws_endpoint)
@@ -68,6 +90,14 @@ impl AppState {
None,
"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()
.await;
@@ -78,6 +108,7 @@ impl AppState {
storj_client,
aws_client,
bucket,
request_timeout: Duration::from_secs(security_config.request_timeout_seconds),
};
// Кэшируем список файлов из AWS при старте приложения
@@ -105,40 +136,51 @@ impl AppState {
warn!("cached {} files", filelist.len());
}
/// Получает путь из ключа (имени файла) в Redis.
/// Получает путь из ключа (имени файла) в Redis с таймаутом.
pub async fn get_path(&self, filename: &str) -> Result<Option<String>, actix_web::Error> {
let mut redis = self.redis.clone();
let new_path: Option<String> = redis
.hget(PATH_MAPPING_KEY, filename)
let new_path: Option<String> =
tokio::time::timeout(self.request_timeout, redis.hget(PATH_MAPPING_KEY, filename))
.await
.map_err(|_| ErrorInternalServerError("Redis operation timeout"))?
.map_err(|_| ErrorInternalServerError("Failed to get path mapping from Redis"))?;
Ok(new_path)
}
pub async fn set_path(&self, filename: &str, filepath: &str) {
let mut redis = self.redis.clone();
let _: () = redis
.hset(PATH_MAPPING_KEY, filename, filepath)
let _: () = tokio::time::timeout(
self.request_timeout,
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> {
let mut redis = self.redis.clone();
let quota_key = format!("quota:{}", user_id);
// Попытка получить квоту из Redis
let quota: u64 = redis.get(&quota_key).await.unwrap_or(0);
// Попытка получить квоту из Redis с таймаутом
let quota: u64 = tokio::time::timeout(self.request_timeout, redis.get(&quota_key))
.await
.map_err(|_| ErrorInternalServerError("Redis timeout getting user quota"))?
.unwrap_or(0);
if quota == 0 {
// Если квота не найдена, устанавливаем её в 0 байт без TTL (постоянная квота)
redis
.set::<&str, u64, ()>(&quota_key, 0)
// Если квота не найдена, устанавливаем её в 0 байт без TTL с таймаутом
tokio::time::timeout(
self.request_timeout,
redis.set::<&str, u64, ()>(&quota_key, 0),
)
.await
.map_err(|_| {
ErrorInternalServerError("Failed to set initial user quota in Redis")
})?;
.map_err(|_| ErrorInternalServerError("Redis timeout setting user quota"))?
.map_err(|_| ErrorInternalServerError("Failed to set initial user quota in Redis"))?;
Ok(0) // Возвращаем 0 как начальную квоту
} else {
@@ -146,7 +188,7 @@ impl AppState {
}
}
/// инкрементирует значение квоты пользователя в байтах
/// инкрементирует значение квоты пользователя в байтах с таймаутом
pub async fn increment_uploaded_bytes(
&self,
user_id: &str,
@@ -155,26 +197,36 @@ impl AppState {
let mut redis = self.redis.clone();
let quota_key = format!("quota:{}", user_id);
// Проверяем, существует ли ключ в Redis
let exists: bool = redis.exists::<_, bool>(&quota_key).await.map_err(|_| {
// Проверяем, существует ли ключ в Redis с таймаутом
let exists: bool =
tokio::time::timeout(self.request_timeout, redis.exists::<_, bool>(&quota_key))
.await
.map_err(|_| {
ErrorInternalServerError("Redis timeout checking user quota existence")
})?
.map_err(|_| {
ErrorInternalServerError("Failed to check if user quota exists in Redis")
})?;
// Если ключ не существует, создаем его с начальным значением без TTL
if !exists {
redis
.set::<_, u64, ()>(&quota_key, bytes)
tokio::time::timeout(
self.request_timeout,
redis.set::<_, u64, ()>(&quota_key, bytes),
)
.await
.map_err(|_| {
ErrorInternalServerError("Failed to set initial user quota in Redis")
})?;
.map_err(|_| ErrorInternalServerError("Redis timeout setting initial user quota"))?
.map_err(|_| ErrorInternalServerError("Failed to set initial user quota in Redis"))?;
return Ok(bytes);
}
// Если ключ существует, инкрементируем его значение на заданное количество байт
let new_quota: u64 = redis
.incr::<_, u64, u64>(&quota_key, bytes)
let new_quota: u64 = tokio::time::timeout(
self.request_timeout,
redis.incr::<_, u64, u64>(&quota_key, bytes),
)
.await
.map_err(|_| ErrorInternalServerError("Redis timeout incrementing user quota"))?
.map_err(|_| ErrorInternalServerError("Failed to increment user quota in Redis"))?;
Ok(new_quota)

View File

@@ -2,11 +2,8 @@ use actix_web::error::ErrorInternalServerError;
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode};
use log::{info, warn};
use redis::{AsyncCommands, aio::MultiplexedConnection};
use reqwest::Client as HTTPClient;
use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::{collections::HashMap, env, error::Error};
use std::{error::Error, time::Duration};
// Структуры для JWT токенов
#[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(
token: &str,
redis: &mut MultiplexedConnection,
timeout: Duration,
) -> Result<Author, Box<dyn Error>> {
// Декодируем JWT токен для получения user_id
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);
// Проверяем валидность токена через сессию в Redis (опционально)
// Проверяем валидность токена через сессию в Redis (опционально) с таймаутом
let token_key = format!("session:{}:{}", user_id, token);
let session_exists: bool = redis
.exists(&token_key)
let session_exists: bool = tokio::time::timeout(timeout, redis.exists(&token_key))
.await
.map_err(|_| {
warn!("Redis timeout checking session existence");
// Не критичная ошибка, продолжаем с базовыми данными
})
.unwrap_or(Ok(false))
.map_err(|e| {
warn!("Failed to check session existence in Redis: {}", e);
// Не критичная ошибка, продолжаем с базовыми данными
@@ -116,9 +118,15 @@ pub async fn get_user_by_token(
.unwrap()
.as_secs();
let _: () = redis
.hset(&token_key, "last_activity", current_time.to_string())
let _: () = tokio::time::timeout(
timeout,
redis.hset(&token_key, "last_activity", current_time.to_string()),
)
.await
.map_err(|_| {
warn!("Redis timeout updating last_activity");
})
.unwrap_or(Ok(()))
.map_err(|e| {
warn!("Failed to update last_activity: {}", e);
})

View File

@@ -1,11 +1,76 @@
use actix_web::{HttpRequest, HttpResponse, Result, error::ErrorUnauthorized};
use log::warn;
use log::{debug, info, warn};
use std::env;
use crate::auth::validate_token;
/// Общие константы
pub const CACHE_CONTROL_IMMUTABLE: &str = "public, max-age=31536000, immutable"; // 1 год
pub const CORS_ALLOW_ORIGIN: &str = "*";
/// Общие константы - optimized for Vercel Edge caching
pub const CACHE_CONTROL_VERCEL: &str = "public, max-age=86400, s-maxage=31536000"; // 1 day browser, 1 year CDN
/// 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> {
@@ -45,49 +110,57 @@ pub fn extract_and_validate_token(req: &HttpRequest) -> Result<&str, actix_web::
Ok(token)
}
/// Создает HTTP ответ с оптимальными заголовками кэширования
pub fn create_cached_response(content_type: &str, data: Vec<u8>, etag: &str) -> HttpResponse {
// Removed unused create_file_response - using create_file_response_with_analytics instead
/// 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()
.content_type(content_type)
.insert_header(("etag", etag))
.insert_header(("cache-control", CACHE_CONTROL_IMMUTABLE))
.insert_header(("access-control-allow-origin", CORS_ALLOW_ORIGIN))
.insert_header(("cache-control", CACHE_CONTROL_VERCEL))
.insert_header(("access-control-allow-origin", cors_origin))
.body(data)
}
/// Создает стандартный HTTP ответ с заголовками CORS
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)
}
// Removed complex ETag caching - Vercel handles caching on their edge
/// Создает HTTP ответ с кэшированием на основе ETag
pub fn create_etag_response(content_type: &str, data: Vec<u8>, etag: &str) -> HttpResponse {
HttpResponse::Ok()
.content_type(content_type)
.insert_header(("etag", etag))
.insert_header(("cache-control", CACHE_CONTROL_IMMUTABLE))
.insert_header(("access-control-allow-origin", CORS_ALLOW_ORIGIN))
.body(data)
}
/// Проверяет ETag для кэширования и возвращает 304 если совпадает
pub fn check_etag_cache(req: &HttpRequest, etag: &str) -> Option<HttpResponse> {
let client_etag = req
/// Log request analytics for CORS whitelist tuning
pub fn log_request_analytics(req: &HttpRequest, path: &str, response_size: usize) {
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("if-none-match")
.and_then(|h| h.to_str().ok());
.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());
if let Some(client_etag) = client_etag {
if client_etag == etag {
return Some(HttpResponse::NotModified().finish());
}
}
None
// Analytics log for future CORS configuration
info!(
"📊 ANALYTICS: path={}, size={}b, origin={}, referer={}, ip={}, ua={}",
path,
response_size,
origin.unwrap_or("none"),
referer.unwrap_or("none"),
remote_addr,
user_agent
);
}
// ETag caching removed - handled by Vercel Edge
/// Проверяет путь на ACME challenge и возвращает 404 если нужно
pub fn check_acme_path(path: &str) -> Option<HttpResponse> {
if path.starts_with(".well-known/") || path.starts_with("/.well-known/") {
@@ -107,31 +180,15 @@ pub fn validate_token_format(token: &str) -> bool {
}
// Проверяем, что токен содержит только допустимые символы для JWT
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
}))
token
.chars()
.all(|c| c.is_alphanumeric() || c == '.' || c == '-' || c == '_')
}
/// Создает JSON ответ с ошибкой
pub fn create_error_response(status: actix_web::http::StatusCode, message: &str) -> HttpResponse {
HttpResponse::build(status)
.insert_header(("access-control-allow-origin", CORS_ALLOW_ORIGIN))
.insert_header(("access-control-allow-origin", "*"))
.json(serde_json::json!({
"error": message
}))

View File

@@ -1,9 +1,9 @@
mod common;
mod proxy;
mod serve_file;
mod universal;
mod upload;
mod user;
mod universal;
pub use universal::universal_handler;

View File

@@ -2,16 +2,17 @@ use actix_web::error::ErrorNotFound;
use actix_web::{HttpRequest, HttpResponse, Result, error::ErrorInternalServerError, web};
use log::{error, info, warn};
use super::common::create_file_response_with_analytics;
use crate::app_state::AppState;
use crate::handlers::serve_file::serve_file;
use crate::lookup::{find_file_by_pattern, get_mime_type};
use crate::s3_utils::{check_file_exists, load_file_from_s3, upload_to_s3};
use crate::thumbnail::{find_closest_width, parse_file_path, thumbdata_save};
use super::common::{check_etag_cache, create_cached_response};
use crate::thumbnail::parse_file_path;
// Удалена дублирующая функция, используется из common модуля
/// Обработчик для скачивания файла и генерации миниатюры, если она недоступна.
/// Обработчик для скачивания файла
/// без генерации миниатюр - это делает Vercel Edge API
#[allow(clippy::collapsible_if)]
pub async fn proxy_handler(
req: HttpRequest,
@@ -38,12 +39,7 @@ pub async fn proxy_handler(
base_filename, requested_width, ext
);
// Генерируем ETag для кэширования и проверяем кэш
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);
}
// Caching handled by Vercel Edge - focus on fast file serving
let content_type = match get_mime_type(&ext) {
Some(mime) => mime.to_string(),
None => {
@@ -77,72 +73,8 @@ pub async fn proxy_handler(
);
if check_file_exists(&state.storj_client, &state.bucket, &stored_path).await? {
warn!("File exists in Storj: {}", stored_path);
if content_type.starts_with("image") {
warn!("Processing image file with width: {}", requested_width);
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
}
// Просто отдаем файл, миниатюры генерирует Vercel Edge API
serve_file(&stored_path, &state, &req).await
} else {
warn!(
"Attempting to load from AWS - bucket: {}, path: {}",
@@ -197,7 +129,12 @@ pub async fn proxy_handler(
let elapsed = start_time.elapsed();
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) => {
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);
if exists_in_storj {
warn!(
"file {} exists in storj, try to generate thumbnails",
filepath
);
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));
}
}
warn!("file {} exists in storj, serving directly", filepath);
// Файл существует в Storj, отдаем его напрямую
return serve_file(&filepath, &state, &req).await;
} else {
warn!("file {} does not exist in storj", filepath);
}
@@ -280,13 +200,6 @@ pub async fn proxy_handler(
"Successfully downloaded file from AWS, size: {} bytes",
filedata.len()
);
let _ = thumbdata_save(
filedata.clone(),
&state,
&filekey,
content_type.to_string(),
)
.await;
if let Err(e) = upload_to_s3(
&state.storj_client,
&state.bucket,
@@ -303,7 +216,12 @@ pub async fn proxy_handler(
}
let elapsed = start_time.elapsed();
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) => {
error!("Failed to download from AWS: {} - Error: {}", filepath, e);

View File

@@ -1,14 +1,16 @@
use actix_web::{HttpResponse, Result, error::ErrorInternalServerError};
use actix_web::{HttpRequest, HttpResponse, Result, error::ErrorInternalServerError};
use mime_guess::MimeGuess;
use super::common::create_file_response_with_analytics;
use crate::app_state::AppState;
use crate::s3_utils::check_file_exists;
use super::common::{CACHE_CONTROL_IMMUTABLE, CORS_ALLOW_ORIGIN};
use crate::s3_utils::{check_file_exists, load_file_from_s3};
/// Функция для обслуживания файла по заданному пути.
/// Теперь оптимизирована для Vercel Edge caching.
pub async fn serve_file(
filepath: &str,
state: &AppState,
req: &HttpRequest,
) -> Result<HttpResponse, actix_web::Error> {
if filepath.is_empty() {
return Err(ErrorInternalServerError("Filename is empty".to_string()));
@@ -23,35 +25,21 @@ pub async fn serve_file(
)));
}
// Получаем объект из Storj S3
let get_object_output = state
.storj_client
.get_object()
.bucket(&state.bucket)
.key(filepath)
.send()
// Загружаем файл из S3
let filedata = load_file_from_s3(&state.storj_client, &state.bucket, filepath)
.await
.map_err(|_| {
ErrorInternalServerError(format!("Failed to get {} object from Storj", filepath))
.map_err(|e| {
ErrorInternalServerError(format!("Failed to load {} from Storj: {}", filepath, e))
})?;
let data: aws_sdk_s3::primitives::AggregatedBytes = get_object_output
.body
.collect()
.await
.map_err(|_| ErrorInternalServerError("Failed to read object body"))?;
let data_bytes = data.into_bytes();
// Определяем MIME тип
let mime_type = MimeGuess::from_path(filepath).first_or_octet_stream();
// Генерируем ETag для кэширования на основе пути файла
let etag = format!("\"{}\"", filepath);
Ok(HttpResponse::Ok()
.content_type(mime_type.as_ref())
.insert_header(("etag", etag.as_str()))
.insert_header(("cache-control", CACHE_CONTROL_IMMUTABLE))
.insert_header(("access-control-allow-origin", CORS_ALLOW_ORIGIN))
.body(data_bytes))
// Создаем ответ с аналитикой
Ok(create_file_response_with_analytics(
mime_type.as_ref(),
filedata,
req,
filepath,
))
}

View File

@@ -1,10 +1,10 @@
use actix_web::{HttpRequest, HttpResponse, Result, web};
use actix_multipart::Multipart;
use actix_web::{HttpRequest, HttpResponse, Result, web};
use log::{info, warn};
use super::common::{check_acme_path, create_error_response};
use crate::app_state::AppState;
use crate::security::{SecurityManager, SecurityConfig};
use super::common::{create_error_response, check_acme_path};
use crate::security::SecurityConfig;
/// Универсальный обработчик, который определяет HTTP метод и путь
pub async fn universal_handler(
@@ -22,46 +22,31 @@ pub async fn universal_handler(
return Ok(response);
}
// Инициализация SecurityManager для проверок
// Базовая проверка безопасности
let security_config = SecurityConfig::default();
let client_ip = SecurityManager::extract_client_ip(&req);
// Проверка базовых ограничений безопасности
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);
if let Err(error) = security_config.validate_request(&req) {
warn!("Security validation failed: {}", error);
return Err(error);
}
// Проверка подозрительных паттернов
let mut security_manager = SecurityManager::new(security_config.clone(), state.redis.clone());
if security_manager.check_suspicious_patterns(&path) {
warn!("Suspicious pattern detected from IP {}: {}", client_ip, path);
return Ok(create_error_response(
actix_web::http::StatusCode::NOT_FOUND,
"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);
// Проверка upload лимитов только для POST запросов
if method == "POST" {
let client_ip = SecurityConfig::extract_client_ip(&req);
if let Err(error) = security_config.check_upload_limit(&client_ip).await {
warn!("Upload limit exceeded for IP {}: {}", client_ip, error);
return Err(error);
}
}
match method.as_str() {
"GET" => handle_get(req, state, &path).await,
"POST" => handle_post(req, payload, state, &path).await,
_ => Ok(create_error_response(
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>,
path: &str,
) -> Result<HttpResponse, actix_web::Error> {
if path == "/" || path == "" {
if path == "/" || path.is_empty() {
// GET / - получение информации о пользователе
crate::handlers::user::get_current_user_handler(req, state).await
} else {
@@ -88,6 +73,6 @@ async fn handle_post(
_path: &str,
) -> Result<HttpResponse, actix_web::Error> {
// 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
}

View File

@@ -2,12 +2,12 @@ use actix_multipart::Multipart;
use actix_web::{HttpRequest, HttpResponse, Result, web};
use log::{error, info, warn};
use super::common::extract_and_validate_token;
use crate::app_state::AppState;
use crate::auth::{extract_user_id_from_token, user_added_file};
use crate::handlers::MAX_USER_QUOTA_BYTES;
use crate::lookup::store_file_info;
use crate::s3_utils::{self, generate_key_with_extension, upload_to_s3};
use super::common::extract_and_validate_token;
use futures::TryStreamExt;
// use crate::thumbnail::convert_heic_to_jpeg;

View File

@@ -2,9 +2,9 @@ use actix_web::{HttpRequest, HttpResponse, Result, web};
use log::{error, info, warn};
use serde::Serialize;
use super::common::extract_and_validate_token;
use crate::app_state::AppState;
use crate::auth::{Author, get_user_by_token};
use super::common::extract_and_validate_token;
#[derive(Serialize)]
pub struct UserWithQuotaResponse {
@@ -32,7 +32,7 @@ pub async fn get_current_user_handler(
// Получаем информацию о пользователе из Redis сессии
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) => {
info!(
"Successfully retrieved user info: user_id={}, username={:?}",

View File

@@ -10,14 +10,14 @@ use actix_cors::Cors;
use actix_web::{
App, HttpServer,
http::header,
middleware::{Logger, DefaultHeaders},
middleware::{DefaultHeaders, Logger},
web,
};
use app_state::AppState;
use security::{SecurityConfig, security_middleware};
use security::SecurityConfig;
use handlers::universal_handler;
use log::{warn, info};
use log::{info, warn};
use std::env;
use tokio::task::spawn_blocking;
@@ -41,10 +41,11 @@ async fn main() -> std::io::Result<()> {
// Конфигурация безопасности
let security_config = SecurityConfig::default();
info!("Security config: max_payload={} MB, upload_rate_limit={}/{}s",
info!(
"Security config: max_payload={} MB, timeout={}s",
security_config.max_payload_size / (1024 * 1024),
security_config.upload_rate_limit.max_requests,
security_config.upload_rate_limit.window_seconds);
security_config.request_timeout_seconds
);
HttpServer::new(move || {
// Настройка CORS middleware - ограничиваем в продакшене
@@ -71,14 +72,20 @@ async fn main() -> std::io::Result<()> {
.add(("X-Frame-Options", "DENY"))
.add(("X-XSS-Protection", "1; mode=block"))
.add(("Referrer-Policy", "strict-origin-when-cross-origin"))
.add(("Content-Security-Policy", "default-src 'self'; img-src 'self' data: https:; object-src 'none';"))
.add(("Strict-Transport-Security", "max-age=31536000; includeSubDomains"));
.add((
"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_data(web::Data::new(app_state.clone()))
.app_data(web::PayloadConfig::new(security_config.max_payload_size))
.app_data(web::JsonConfig::default().limit(1024 * 1024)) // 1MB для JSON
.wrap(actix_web::middleware::from_fn(security_middleware))
.wrap(security_headers)
.wrap(cors)
.wrap(Logger::default())

View File

@@ -1,42 +1,22 @@
use actix_web::{HttpRequest, dev::ServiceRequest, middleware::Next, dev::ServiceResponse, error::ErrorTooManyRequests};
use log::{warn, error, info};
use redis::{AsyncCommands, aio::MultiplexedConnection};
use std::time::{SystemTime, UNIX_EPOCH};
use actix_web::HttpRequest;
use log::warn;
use std::collections::HashMap;
use tokio::sync::RwLock;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::RwLock;
/// Конфигурация лимитов запросов
/// Простая защита от злоупотреблений для upload endpoint
#[derive(Debug, Clone)]
pub struct RateLimitConfig {
/// Максимальное количество запросов в окне времени
pub max_requests: u32,
/// Окно времени в секундах
pub window_seconds: u64,
/// Блокировка на количество секунд при превышении лимита
pub block_duration_seconds: u64,
pub struct UploadProtection {
/// Максимальное количество загрузок в минуту с одного IP
pub max_uploads_per_minute: u32,
/// Локальный кэш для подсчета загрузок
pub upload_counts: Arc<RwLock<HashMap<String, (u32, u64)>>>,
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
max_requests: 100, // 100 запросов
window_seconds: 60, // в минуту
block_duration_seconds: 300, // блокировка на 5 минут
}
}
}
/// Конфигурация для разных типов запросов
/// Конфигурация безопасности для простого storage proxy
#[derive(Debug, Clone)]
pub struct SecurityConfig {
/// Общий лимит по IP
pub general_rate_limit: RateLimitConfig,
/// Лимит для загрузки файлов
pub upload_rate_limit: RateLimitConfig,
/// Лимит для аутентификации
pub auth_rate_limit: RateLimitConfig,
/// Максимальный размер тела запроса (байты)
pub max_payload_size: usize,
/// Таймаут запроса (секунды)
@@ -47,217 +27,52 @@ pub struct SecurityConfig {
pub max_headers_count: usize,
/// Максимальная длина значения заголовка
pub max_header_value_length: usize,
/// Защита от злоупотреблений upload
pub upload_protection: UploadProtection,
}
impl Default for SecurityConfig {
fn default() -> Self {
Self {
general_rate_limit: RateLimitConfig::default(),
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 МБ
max_payload_size: 500 * 1024 * 1024, // 500MB
request_timeout_seconds: 300, // 5 минут
max_path_length: 1000,
max_headers_count: 50,
max_header_value_length: 8192,
upload_protection: UploadProtection {
max_uploads_per_minute: 10, // 10 загрузок в минуту
upload_counts: Arc::new(RwLock::new(HashMap::new())),
},
}
}
}
/// Структура для хранения информации о запросах
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RequestInfo {
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> {
// Проверка длины пути
impl SecurityConfig {
/// Валидирует запрос на базовые параметры безопасности
pub fn validate_request(&self, req: &HttpRequest) -> Result<(), actix_web::Error> {
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());
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 value_str.len() > self.config.max_header_value_length {
warn!("Header value too long: {} = {} chars", name, value_str.len());
if value_str.len() > self.max_header_value_length {
warn!(
"Header value too long: {} = {} chars",
name,
value_str.len()
);
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') {
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(())
}
/// Проверяет подозрительные паттерны в пути
/// Проверяет путь на подозрительные паттерны
pub fn check_suspicious_patterns(&self, path: &str) -> bool {
let suspicious_patterns = [
"/admin", "/wp-admin", "/phpmyadmin", "/.env", "/config",
"/.git", "/backup", "/db", "/sql", "/.well-known/acme-challenge",
"/xmlrpc.php", "/wp-login.php", "/wp-config.php",
"script>", "<iframe", "javascript:", "data:",
"/admin",
"/wp-admin",
"/phpmyadmin",
"/.env",
"/config",
"/.git",
"/backup",
"/db",
"/sql",
"/xmlrpc.php",
"/wp-login.php",
"/wp-config.php",
"script>",
"<iframe",
"javascript:",
"data:",
];
let path_lower = path.to_lowercase();
for pattern in &suspicious_patterns {
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;
}
}
false
}
/// Очистка старых записей из локального кэша
pub async fn cleanup_cache(&mut self) {
/// Проверяет лимит загрузок для IP (только для upload endpoint)
pub async fn check_upload_limit(&self, ip: &str) -> Result<(), actix_web::Error> {
let current_time = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let mut cache = self.local_cache.write().await;
let mut to_remove = Vec::new();
let mut counts = self.upload_protection.upload_counts.write().await;
for (key, info) in cache.iter() {
// Удаляем записи старше 1 часа
if current_time - info.first_request_time > 3600 {
to_remove.push(key.clone());
// Очищаем старые записи (старше минуты)
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));
}
for key in to_remove {
cache.remove(&key);
Ok(())
}
info!("Cleaned {} old entries from security cache", cache.len());
}
}
/// 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);
/// Извлекает IP адрес клиента
pub fn extract_client_ip(req: &HttpRequest) -> String {
// Проверяем X-Forwarded-For (для прокси)
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();
}
}
}
let res = next.call(req).await?;
Ok(res)
// Проверяем X-Real-IP
if let Some(real_ip) = req.headers().get("x-real-ip") {
if let Ok(real_ip_str) = real_ip.to_str() {
return real_ip_str.to_string();
}
}
// Fallback на connection info
req.connection_info().peer_addr().unwrap_or("unknown").to_string()
}
}

View File

@@ -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`, который определяет формат сохранения миниатюр.
/// Это позволяет поддерживать различные форматы изображений без необходимости заранее предугадывать их.
pub async fn generate_thumbnails(
image: &DynamicImage,
format: ImageFormat,
) -> Result<HashMap<u32, Vec<u8>>, actix_web::Error> {
let mut thumbnails = HashMap::new();
/// Пример:
/// - "image.jpg" -> ("image", 0, "jpg")
/// - "image_300.jpg" -> ("image", 300, "jpg")
/// - "image_large.jpg" -> ("image", 0, "jpg") - некорректная ширина игнорируется
pub fn parse_file_path(path: &str) -> (String, u32, String) {
let path = path.trim_start_matches('/');
for &width in THUMB_WIDTHS.iter().filter(|&&w| w < image.width()) {
let thumbnail = image.resize(width, u32::MAX, FilterType::Lanczos3); // Ресайз изображения по ширине
let mut buffer = Vec::new();
thumbnail
.write_to(&mut Cursor::new(&mut buffer), format)
.map_err(|e| {
log::error!("Ошибка при сохранении миниатюры: {}", e);
ErrorInternalServerError("Не удалось сгенерировать миниатюру")
})?; // Сохранение изображения в указанном формате
thumbnails.insert(width, buffer);
}
Ok(thumbnails)
}
/// Определяет формат изображения на основе расширения файла.
fn determine_image_format(extension: &str) -> Result<ImageFormat, actix_web::Error> {
match extension.to_lowercase().as_str() {
"jpg" | "jpeg" => Ok(ImageFormat::Jpeg),
"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(());
}
// Для остальных изображений продолжаем как обычно
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"));
}
// Находим последнюю точку для разделения имени и расширения
let (name_part, extension) = match path.rfind('.') {
Some(dot_pos) => (&path[..dot_pos], path[dot_pos + 1..].to_string()),
None => (path, String::new()),
};
warn!("generate thumbnails for {}", original_filename);
let format = determine_image_format(&extension.to_lowercase())?;
// Ищем последнее подчеркивание в имени файла
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..];
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);
// Пытаемся парсить ширину
match width_str.parse::<u32>() {
Ok(width) => {
return (base_filename, width, extension);
}
}
}
Err(e) => {
warn!(
"cannot generate thumbnails for {}: {}",
original_filename, e
);
return Err(e);
Err(_) => {
// Если не получилось парсить как число, считаем все имя файла базовым
}
}
}
Ok(())
// Если подчеркивания нет или ширина не парсится, возвращаем все как базовое имя
(name_part.to_string(), 0, extension)
}
/// Выбирает ближайший подходящий размер из предопределённых.
/// Если `requested_width` больше максимальной ширины в `THUMB_WIDTHS`,
/// возвращает максимальную ширину.
pub fn find_closest_width(requested_width: u32) -> u32 {
// Проверяем, превышает ли запрошенная ширина максимальную доступную ширину
if requested_width > *THUMB_WIDTHS.last().unwrap() {
return *THUMB_WIDTHS.last().unwrap();
}
#[cfg(test)]
mod tests {
use super::*;
// Находим ширину с минимальной абсолютной разницей с запрошенной
*THUMB_WIDTHS
.iter()
.min_by_key(|&&width| (width as i32 - requested_width as i32).abs())
.unwrap_or(&THUMB_WIDTHS[0]) // Возвращаем самый маленький размер, если ничего не подошло
#[test]
fn test_parse_file_path() {
// Обычный файл без ширины
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");
}
}