simpler-auth+no-overlay
This commit is contained in:
Binary file not shown.
147
src/auth.rs
147
src/auth.rs
@@ -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,
|
||||
|
||||
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 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;
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
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 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))
|
||||
|
||||
@@ -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