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

Binary file not shown.

View File

@@ -1,12 +1,14 @@
use actix_web::error::ErrorInternalServerError;
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::Client as HTTPClient;
use serde::Deserialize;
use serde_json::json;
use std::{collections::HashMap, env, error::Error};
// Структура для десериализации ответа от сервиса аутентификации
// Старые структуры для совместимости с get_id_by_token
#[derive(Deserialize)]
struct AuthResponse {
data: Option<AuthData>,
@@ -28,6 +30,27 @@ struct Claims {
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>> {
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 для пользователя
pub async fn user_added_file(
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 serve_file;
mod upload;
mod user;
pub use proxy::proxy_handler;
pub use quota::{get_quota_handler, increase_quota_handler, set_quota_handler};
pub use upload::upload_handler;
pub use user::get_current_user_handler;
// Общий лимит квоты на пользователя: 5 ГБ
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()),
}
}
pub const MAX_USER_QUOTA_BYTES: u64 = 5 * 1024 * 1024 * 1024;

View File

@@ -1,6 +1,6 @@
use actix_web::error::ErrorNotFound;
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::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::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(
req: HttpRequest,
requested_res: web::Path<String>,
state: web::Data<AppState>,
) -> 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") {
warn!("Removing /webp suffix from path");
info!("Converting to WebP format: {}", requested_res);
requested_res.replace("/webp", "")
} else {
requested_res.to_string()
};
// Проверяем If-None-Match заголовок для кэширования
let client_etag = req.headers().get("if-none-match")
.and_then(|h| h.to_str().ok());
// парсим GET запрос
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();
warn!("normalized to lowercase: {}", 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) {
Some(mime) => mime.to_string(),
None => {
@@ -46,24 +68,15 @@ pub async fn proxy_handler(
}
}
_ => {
error!("unsupported file format");
return Err(ErrorInternalServerError("unsupported file format"));
error!("Unsupported file format for: {}", base_filename);
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 {
Ok(Some(stored_path)) => {
@@ -78,7 +91,7 @@ pub async fn proxy_handler(
warn!("Processing image file with width: {}", requested_width);
if requested_width == 0 {
warn!("Serving original file without resizing");
serve_file(&stored_path, &state, shout_id).await
serve_file(&stored_path, &state).await
} else {
let closest: u32 = find_closest_width(requested_width);
warn!(
@@ -94,12 +107,12 @@ pub async fn proxy_handler(
{
Ok(true) => {
warn!("serve existed thumb file: {}", thumb_filename);
serve_file(thumb_filename, &state, shout_id).await
serve_file(thumb_filename, &state).await
}
Ok(false) => {
// Миниатюра не существует, возвращаем оригинал и запускаем генерацию миниатюры
let original_file =
serve_file(&stored_path, &state, shout_id).await?;
serve_file(&stored_path, &state).await?;
// Запускаем асинхронную задачу для генерации миниатюры
let state_clone = state.clone();
@@ -139,7 +152,7 @@ pub async fn proxy_handler(
}
} else {
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 {
warn!(
@@ -193,9 +206,9 @@ pub async fn proxy_handler(
warn!("Successfully uploaded to Storj: {}", filekey);
}
return Ok(HttpResponse::Ok()
.content_type(content_type)
.body(filedata));
let elapsed = start_time.elapsed();
info!("File served from AWS in {:?}: {}", elapsed, path);
return Ok(create_cached_response(&content_type, filedata, &file_etag));
}
Err(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);
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) => {
error!("Failed to download from AWS: {} - Error: {}", filepath, e);
@@ -312,9 +327,10 @@ pub async fn proxy_handler(
}
}
Err(e) => {
let elapsed = start_time.elapsed();
error!(
"Database error while getting path: {} - Full error: {:?}",
filekey, e
"Database error while getting path: {} in {:?} - Full error: {:?}",
filekey, elapsed, 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())
.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 параметров
let user_id = req
@@ -76,7 +83,14 @@ pub async fn increase_quota_handler(
let _admin_id = get_id_by_token(token.unwrap())
.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
.additional_bytes
@@ -125,7 +139,14 @@ pub async fn set_quota_handler(
let _admin_id = get_id_by_token(token.unwrap())
.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
.new_quota_bytes

View File

@@ -2,14 +2,12 @@ use actix_web::{error::ErrorInternalServerError, HttpResponse, Result};
use mime_guess::MimeGuess;
use crate::app_state::AppState;
use crate::overlay::generate_overlay;
use crate::s3_utils::check_file_exists;
/// Функция для обслуживания файла по заданному пути.
pub async fn serve_file(
filepath: &str,
state: &AppState,
shout_id: &str,
) -> Result<HttpResponse, actix_web::Error> {
if filepath.is_empty() {
return Err(ErrorInternalServerError("Filename is empty".to_string()));
@@ -42,14 +40,17 @@ pub async fn serve_file(
.await
.map_err(|_| ErrorInternalServerError("Failed to read object body"))?;
let data_bytes = match shout_id.is_empty() {
true => data.into_bytes(),
false => generate_overlay(shout_id, data.into_bytes()).await?,
};
let data_bytes = data.into_bytes();
let mime_type = MimeGuess::from_path(filepath).first_or_octet_stream();
// Генерируем ETag для кэширования на основе пути файла
let etag = format!("\"{}\"", filepath);
Ok(HttpResponse::Ok()
.content_type(mime_type.as_ref())
.insert_header(("etag", etag.as_str()))
.insert_header(("cache-control", "public, max-age=31536000, immutable")) // 1 год
.insert_header(("access-control-allow-origin", "*"))
.body(data_bytes))
}

View File

@@ -1,16 +1,19 @@
use actix_multipart::Multipart;
use actix_web::{web, HttpRequest, HttpResponse, Result};
use log::{error, warn};
use log::{error, info, warn};
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::lookup::store_file_info;
use crate::s3_utils::{self, generate_key_with_extension, upload_to_s3};
use futures::TryStreamExt;
// use crate::thumbnail::convert_heic_to_jpeg;
/// Обработчик для аплоада файлов.
// Максимальный размер одного файла: 500 МБ
const MAX_SINGLE_FILE_BYTES: u64 = 500 * 1024 * 1024;
/// Обработчик для аплоада файлов с улучшенной логикой квот и валидацией.
pub async fn upload_handler(
req: HttpRequest,
mut payload: Multipart,
@@ -21,40 +24,91 @@ pub async fn upload_handler(
.headers()
.get("Authorization")
.and_then(|header_value| header_value.to_str().ok());
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 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 {
let mut field = field;
let mut file_bytes = Vec::new();
let mut file_size: u64 = 0;
// Читаем данные файла
// Читаем данные файла с проверкой размера
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);
}
// Пропускаем пустые файлы
if file_size == 0 {
warn!("Skipping empty file upload");
continue;
}
info!("Processing file: {} bytes", file_size);
// Определяем MIME-тип из содержимого файла
let detected_mime_type = match s3_utils::detect_mime_type(&file_bytes) {
Some(mime) => mime,
Some(mime) => {
info!("Detected MIME type: {}", mime);
mime
}
None => {
warn!("Неподдерживаемый формат файла");
warn!("Unsupported file format detected");
return Err(actix_web::error::ErrorUnsupportedMediaType(
"Неподдерживаемый формат файла",
"Unsupported file format",
));
}
};
// Для 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)
} else {
(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) {
Some(ext) => ext,
None => {
warn!("Неподдерживаемый тип содержимого: {}", content_type);
warn!("No file extension found for MIME type: {}", content_type);
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);
info!("Generated filename: {}", filename);
// Загружаем файл в S3 storj
match upload_to_s3(
@@ -94,36 +140,66 @@ pub async fn upload_handler(
.await
{
Ok(_) => {
warn!(
"file {} uploaded to storj, incrementing quota by {} bytes",
filename, file_size
);
info!("File {} successfully uploaded to S3 ({} bytes)", filename, file_size);
// Обновляем квоту пользователя
if let Err(e) = state.increment_uploaded_bytes(&user_id, file_size).await {
error!("Failed to increment quota: {}", e);
return Err(e);
error!("Failed to increment quota for user {}: {}", user_id, e);
return Err(actix_web::error::ErrorInternalServerError(
"Failed to update user quota"
));
}
// Сохраняем информацию о файле в Redis
let mut redis = state.redis.clone();
store_file_info(&mut redis, &filename, &content_type).await?;
user_added_file(&mut redis, &user_id, &filename).await?;
// Сохраняем маппинг пути
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 {
warn!("New quota for user {}: {} bytes", user_id, new_quota);
if let Err(e) = store_file_info(&mut redis, &filename, &content_type).await {
error!("Failed to store file info in Redis: {}", e);
// Не прерываем процесс, файл уже загружен в S3
}
if let Err(e) = user_added_file(&mut redis, &user_id, &filename).await {
error!("Failed to record user file association: {}", e);
// Не прерываем процесс
}
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) => {
warn!("Failed to upload to storj: {}", e);
return Err(actix_web::error::ErrorInternalServerError(e));
error!("Failed to upload file to S3: {}", 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 auth;
mod core;
mod handlers;
mod lookup;
mod overlay;
mod s3_utils;
mod thumbnail;
@@ -16,8 +14,9 @@ use actix_web::{
use app_state::AppState;
use handlers::{
get_quota_handler, increase_quota_handler, proxy_handler, root_handler, set_quota_handler,
upload_handler,
get_current_user_handler, get_quota_handler,
increase_quota_handler, proxy_handler,
set_quota_handler, upload_handler,
};
use log::warn;
use std::env;
@@ -64,7 +63,7 @@ async fn main() -> std::io::Result<()> {
.app_data(web::Data::new(app_state.clone()))
.wrap(cors)
.wrap(Logger::default())
.route("/", web::get().to(root_handler))
.route("/", web::get().to(get_current_user_handler))
.route("/", web::post().to(upload_handler))
.route("/quota", web::get().to(get_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)
}
}
}