simpler-auth+no-overlay
Some checks failed
Deploy / deploy (push) Has been skipped
CI / lint (push) Failing after 8s
CI / test (push) Failing after 3m57s

This commit is contained in:
2025-09-01 20:36:15 +03:00
parent a44bf3302b
commit 6c3262edbe
20 changed files with 1516 additions and 686 deletions

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

@@ -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(),
)))
}
}

View File

@@ -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()),
}
}

View File

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

View File

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

View File

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

View File

@@ -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
View 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))
}

View File

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

View File

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