Files
quoter/src/handlers/upload.rs
Untone 82668768d0 🔒 Implement comprehensive security and DDoS protection
### Security Features:
- **Rate Limiting**: Redis-based IP tracking with configurable limits
  - General: 100 requests/minute (5min block)
  - Upload: 10 requests/5min (10min block)
  - Auth: 20 requests/15min (30min block)
- **Request Validation**: Path length, header count, suspicious patterns
- **Attack Detection**: Admin paths, script injections, bot patterns
- **Enhanced JWT**: Format validation, length checks, character filtering
- **IP Tracking**: X-Forwarded-For and X-Real-IP support

### Security Headers:
- X-Content-Type-Options: nosniff
- X-Frame-Options: DENY
- X-XSS-Protection: 1; mode=block
- Content-Security-Policy with strict rules
- Strict-Transport-Security with includeSubDomains

### CORS Hardening:
- Limited to specific domains: discours.io, new.discours.io
- Restricted methods: GET, POST, OPTIONS only
- Essential headers only

### Infrastructure:
- Security middleware for all requests
- Local cache + Redis for performance
- Comprehensive logging and monitoring
- Progressive blocking for repeat offenders

### Documentation:
- Complete security guide (docs/security.md)
- Configuration examples
- Incident response procedures
- Monitoring recommendations

Version bump to 0.6.0 for major security enhancement.
2025-09-02 11:40:43 +03:00

215 lines
8.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use actix_multipart::Multipart;
use actix_web::{HttpRequest, HttpResponse, Result, web};
use log::{error, info, warn};
use crate::app_state::AppState;
use crate::auth::{extract_user_id_from_token, user_added_file};
use crate::handlers::MAX_USER_QUOTA_BYTES;
use crate::lookup::store_file_info;
use crate::s3_utils::{self, generate_key_with_extension, upload_to_s3};
use super::common::extract_and_validate_token;
use futures::TryStreamExt;
// use crate::thumbnail::convert_heic_to_jpeg;
// Максимальный размер одного файла: 500 МБ
const MAX_SINGLE_FILE_BYTES: u64 = 500 * 1024 * 1024;
/// Обработчик для аплоада файлов с улучшенной логикой квот и валидацией.
pub async fn upload_handler(
req: HttpRequest,
mut payload: Multipart,
state: web::Data<AppState>,
) -> Result<HttpResponse, actix_web::Error> {
// Извлекаем и валидируем токен
let token = extract_and_validate_token(&req)?;
// Затем извлекаем 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);
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 {
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) => {
info!("Detected MIME type: {}", mime);
mime
}
None => {
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" {
info!("Processing HEIC file (saved as original)");
(file_bytes, detected_mime_type)
} else {
(file_bytes, detected_mime_type)
};
// Получаем расширение из MIME-типа
let extension = match s3_utils::get_extension_from_mime(&content_type) {
Some(ext) => ext,
None => {
warn!("No file extension found for MIME type: {}", content_type);
return Err(actix_web::error::ErrorUnsupportedMediaType(
"Unsupported content type",
));
}
};
// Генерируем имя файла с правильным расширением
let filename = format!("{}.{}", uuid::Uuid::new_v4(), extension);
info!("Generated filename: {}", filename);
// Загружаем файл в S3 storj
match upload_to_s3(
&state.storj_client,
&state.bucket,
&filename,
file_bytes,
&content_type,
)
.await
{
Ok(_) => {
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 for user {}: {}", user_id, e);
return Err(actix_web::error::ErrorInternalServerError(
"Failed to update user quota",
));
}
// Сохраняем информацию о файле в Redis
let mut redis = state.redis.clone();
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);
// Не прерываем процесс
}
// Сохраняем маппинг пути
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) => {
error!("Failed to upload file to S3: {}", e);
return Err(actix_web::error::ErrorInternalServerError(
"File upload failed",
));
}
}
}
// Возвращаем результат
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
})))
}
}
}