use std::io::{Cursor, Write};
use std::str::FromStr;
use std::sync::Arc;
use axum::extract::{self, Multipart, Path, Query, State};
use axum::response::{IntoResponse, Redirect, Response};
use axum::Json;
use serde::Deserialize;
use tracing::debug;
use uuid::Uuid;
use super::errors;
use super::forms::UpdateTorrentInfoForm;
use super::responses::{new_torrent_response, torrent_file_response};
use crate::common::AppData;
use crate::errors::ServiceError;
use crate::models::info_hash::InfoHash;
use crate::models::torrent_tag::TagId;
use crate::services::torrent::{AddTorrentRequest, ListingRequest};
use crate::services::torrent_file::generate_random_torrent;
use crate::utils::parse_torrent;
use crate::web::api::server::v1::extractors::optional_user_id::ExtractOptionalLoggedInUser;
use crate::web::api::server::v1::extractors::user_id::ExtractLoggedInUser;
use crate::web::api::server::v1::responses::OkResponseData;
use crate::web::api::server::v1::routes::API_VERSION_URL_PREFIX;
#[allow(clippy::unused_async)]
pub async fn upload_torrent_handler(
State(app_data): State<Arc<AppData>>,
ExtractOptionalLoggedInUser(maybe_user_id): ExtractOptionalLoggedInUser,
multipart: Multipart,
) -> Response {
let add_torrent_form = match build_add_torrent_request_from_payload(multipart).await {
Ok(torrent_request) => torrent_request,
Err(error) => return error.into_response(),
};
match app_data.torrent_service.add_torrent(add_torrent_form, maybe_user_id).await {
Ok(response) => new_torrent_response(&response).into_response(),
Err(error) => error.into_response(),
}
}
#[derive(Deserialize)]
pub struct InfoHashParam(pub String);
impl InfoHashParam {
fn lowercase(&self) -> String {
self.0.to_lowercase()
}
}
#[allow(clippy::unused_async)]
pub async fn download_torrent_handler(
State(app_data): State<Arc<AppData>>,
ExtractOptionalLoggedInUser(maybe_user_id): ExtractOptionalLoggedInUser,
Path(info_hash): Path<InfoHashParam>,
) -> Response {
let Ok(info_hash) = InfoHash::from_str(&info_hash.lowercase()) else {
return errors::Request::InvalidInfoHashParam.into_response();
};
debug!("Downloading torrent: {:?}", info_hash.to_hex_string());
if let Some(redirect_response) =
redirect_to_download_url_using_canonical_info_hash_if_needed(&app_data, &info_hash, maybe_user_id).await
{
debug!("Redirecting to URL with canonical info-hash");
redirect_response
} else {
let torrent = match app_data.torrent_service.get_torrent(&info_hash, maybe_user_id).await {
Ok(torrent) => torrent,
Err(error) => return error.into_response(),
};
let Ok(bytes) = parse_torrent::encode_torrent(&torrent) else {
return ServiceError::InternalServerError.into_response();
};
torrent_file_response(
bytes,
&format!("{}.torrent", torrent.info.name),
&torrent.canonical_info_hash_hex(),
)
}
}
async fn redirect_to_download_url_using_canonical_info_hash_if_needed(
app_data: &Arc<AppData>,
info_hash: &InfoHash,
maybe_user_id: Option<i64>,
) -> Option<Response> {
match app_data
.torrent_service
.get_canonical_info_hash(info_hash, maybe_user_id)
.await
{
Ok(Some(canonical_info_hash)) => {
if canonical_info_hash != *info_hash {
return Some(
Redirect::temporary(&format!(
"/{API_VERSION_URL_PREFIX}/torrent/download/{}",
canonical_info_hash.to_hex_string()
))
.into_response(),
);
}
None
}
Ok(None) => None,
Err(error) => Some(error.into_response()),
}
}
#[allow(clippy::unused_async)]
pub async fn get_torrents_handler(
State(app_data): State<Arc<AppData>>,
Query(criteria): Query<ListingRequest>,
ExtractOptionalLoggedInUser(maybe_user_id): ExtractOptionalLoggedInUser,
) -> Response {
match app_data
.torrent_service
.generate_torrent_info_listing(&criteria, maybe_user_id)
.await
{
Ok(torrents_response) => Json(OkResponseData { data: torrents_response }).into_response(),
Err(error) => error.into_response(),
}
}
#[allow(clippy::unused_async)]
pub async fn get_torrent_info_handler(
State(app_data): State<Arc<AppData>>,
ExtractOptionalLoggedInUser(maybe_user_id): ExtractOptionalLoggedInUser,
Path(info_hash): Path<InfoHashParam>,
) -> Response {
let Ok(info_hash) = InfoHash::from_str(&info_hash.lowercase()) else {
return errors::Request::InvalidInfoHashParam.into_response();
};
if let Some(redirect_response) =
redirect_to_details_url_using_canonical_info_hash_if_needed(&app_data, &info_hash, maybe_user_id).await
{
redirect_response
} else {
match app_data.torrent_service.get_torrent_info(&info_hash, maybe_user_id).await {
Ok(torrent_response) => Json(OkResponseData { data: torrent_response }).into_response(),
Err(error) => error.into_response(),
}
}
}
async fn redirect_to_details_url_using_canonical_info_hash_if_needed(
app_data: &Arc<AppData>,
info_hash: &InfoHash,
maybe_user_id: Option<i64>,
) -> Option<Response> {
match app_data
.torrent_service
.get_canonical_info_hash(info_hash, maybe_user_id)
.await
{
Ok(Some(canonical_info_hash)) => {
if canonical_info_hash != *info_hash {
return Some(
Redirect::temporary(&format!(
"/{API_VERSION_URL_PREFIX}/torrent/{}",
canonical_info_hash.to_hex_string()
))
.into_response(),
);
}
None
}
Ok(None) => None,
Err(error) => Some(error.into_response()),
}
}
#[allow(clippy::unused_async)]
pub async fn update_torrent_info_handler(
State(app_data): State<Arc<AppData>>,
ExtractLoggedInUser(user_id): ExtractLoggedInUser,
Path(info_hash): Path<InfoHashParam>,
extract::Json(update_torrent_info_form): extract::Json<UpdateTorrentInfoForm>,
) -> Response {
let Ok(info_hash) = InfoHash::from_str(&info_hash.lowercase()) else {
return errors::Request::InvalidInfoHashParam.into_response();
};
match app_data
.torrent_service
.update_torrent_info(
&info_hash,
&update_torrent_info_form.title,
&update_torrent_info_form.description,
&update_torrent_info_form.category,
&update_torrent_info_form.tags,
&user_id,
)
.await
{
Ok(torrent_response) => Json(OkResponseData { data: torrent_response }).into_response(),
Err(error) => error.into_response(),
}
}
#[allow(clippy::unused_async)]
pub async fn delete_torrent_handler(
State(app_data): State<Arc<AppData>>,
ExtractOptionalLoggedInUser(maybe_user_id): ExtractOptionalLoggedInUser,
Path(info_hash): Path<InfoHashParam>,
) -> Response {
let Ok(info_hash) = InfoHash::from_str(&info_hash.lowercase()) else {
return errors::Request::InvalidInfoHashParam.into_response();
};
match app_data.torrent_service.delete_torrent(&info_hash, maybe_user_id).await {
Ok(deleted_torrent_response) => Json(OkResponseData {
data: deleted_torrent_response,
})
.into_response(),
Err(error) => error.into_response(),
}
}
#[derive(Debug, Deserialize)]
pub struct UuidParam(pub String);
impl UuidParam {
fn value(&self) -> String {
self.0.to_lowercase()
}
}
#[allow(clippy::unused_async)]
pub async fn create_random_torrent_handler(State(_app_data): State<Arc<AppData>>, Path(uuid): Path<UuidParam>) -> Response {
let Ok(uuid) = Uuid::parse_str(&uuid.value()) else {
return errors::Request::InvalidInfoHashParam.into_response();
};
let torrent = generate_random_torrent(uuid);
let Ok(bytes) = parse_torrent::encode_torrent(&torrent) else {
return ServiceError::InternalServerError.into_response();
};
torrent_file_response(
bytes,
&format!("{}.torrent", torrent.info.name),
&torrent.canonical_info_hash_hex(),
)
}
async fn build_add_torrent_request_from_payload(mut payload: Multipart) -> Result<AddTorrentRequest, errors::Request> {
let torrent_buffer = vec![0u8];
let mut torrent_cursor = Cursor::new(torrent_buffer);
let mut title = String::new();
let mut description = String::new();
let mut category = String::new();
let mut tags: Vec<TagId> = vec![];
while let Some(mut field) = payload.next_field().await.unwrap() {
let name = field.name().unwrap();
match name {
"title" => {
let data = field.bytes().await.unwrap();
if data.is_empty() {
continue;
};
title = String::from_utf8(data.to_vec()).map_err(|_| errors::Request::TitleIsNotValidUtf8)?;
}
"description" => {
let data = field.bytes().await.unwrap();
if data.is_empty() {
continue;
};
description = String::from_utf8(data.to_vec()).map_err(|_| errors::Request::DescriptionIsNotValidUtf8)?;
}
"category" => {
let data = field.bytes().await.unwrap();
if data.is_empty() {
continue;
};
category = String::from_utf8(data.to_vec()).map_err(|_| errors::Request::CategoryIsNotValidUtf8)?;
}
"tags" => {
let data = field.bytes().await.unwrap();
if data.is_empty() {
continue;
};
let string_data = String::from_utf8(data.to_vec()).map_err(|_| errors::Request::TagsArrayIsNotValidUtf8)?;
tags = serde_json::from_str(&string_data).map_err(|_| errors::Request::TagsArrayIsNotValidJson)?;
}
"torrent" => {
let content_type = field.content_type().unwrap();
if content_type != "application/x-bittorrent" && content_type != "application/octet-stream" {
return Err(errors::Request::InvalidFileType);
}
while let Some(chunk) = field
.chunk()
.await
.map_err(|_| (errors::Request::CannotReadChunkFromUploadedBinary))?
{
torrent_cursor
.write_all(&chunk)
.map_err(|_| (errors::Request::CannotWriteChunkFromUploadedBinary))?;
}
}
_ => {}
}
}
Ok(AddTorrentRequest {
title,
description,
category_name: category,
tags,
torrent_buffer: torrent_cursor.into_inner(),
})
}