diff --git a/Cargo.lock b/Cargo.lock index 6d7b412..10a36fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -269,6 +269,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "rustversion", @@ -844,6 +845,15 @@ dependencies = [ "serde", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "env_filter" version = "0.1.3" @@ -1755,6 +1765,23 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "nix" version = "0.26.4" diff --git a/Cargo.toml b/Cargo.toml index 658ae32..7c4fe83 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ path = "src/main.rs" askama = "0.14.0" # askama_web::WebTemplate implements axum::IntoResponse askama_web = { version = "0.14.6", features = ["axum-0.8"] } -axum = { version = "0.8.4", features = ["macros"] } +axum = { version = "0.8.4", features = ["macros","multipart"] } axum-extra = { version = "0.12.1", features = ["cookie"] } # UTF-8 paths for easier String/PathBuf interop camino = { version = "1.1.12", features = ["serde1"] } diff --git a/src/database/magnet.rs b/src/database/magnet.rs new file mode 100644 index 0000000..fc438ce --- /dev/null +++ b/src/database/magnet.rs @@ -0,0 +1,160 @@ +use chrono::Utc; +use hightorrent_api::hightorrent::{MagnetLink, MagnetLinkError}; +use sea_orm::entity::prelude::*; +use sea_orm::*; +use snafu::prelude::*; + +use crate::database::operation::*; +use crate::extractors::user::User; +use crate::routes::magnet::MagnetForm; +use crate::state::AppState; +use crate::state::logger::LoggerError; + +/// A category to store associated files. +/// +/// Each category has a name and an associated path on disk, where +/// symlinks to the content will be created. +// TODO: typed model fields +// see https://github.com/SeaQL/sea-orm/issues/2811 +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "magnet")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub torrent_id: String, + pub magnet: String, + pub name: String, + pub resolved: bool, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +#[async_trait::async_trait] +impl ActiveModelBehavior for ActiveModel {} + +#[derive(Debug, Snafu)] +#[snafu(visibility(pub))] +pub enum MagnetError { + #[snafu(display("The magnet is invalid"))] + InvalidMagnet { source: MagnetLinkError }, + // TODO: this is not an error + // we should redirect to the magnet page (eg. progress) + #[snafu(display("There is already a magnet with the TorrentID `{torrent_id}`"))] + TorrentIDTaken { torrent_id: String }, + #[snafu(display("Database error"))] + DB { source: sea_orm::DbErr }, + #[snafu(display("The magnet (ID: {id}) does not exist"))] + NotFound { id: i32 }, + #[snafu(display("Failed to save the operation log"))] + Logger { source: LoggerError }, +} + +#[derive(Clone, Debug)] +pub struct MagnetOperator { + pub state: AppState, + pub user: Option, +} + +impl MagnetOperator { + pub fn new(state: AppState, user: Option) -> Self { + Self { state, user } + } + + /// List categories + /// + /// Should not fail, unless SQLite was corrupted for some reason. + pub async fn list(&self) -> Result, MagnetError> { + Entity::find() + .all(&self.state.database) + .await + .context(DBSnafu) + } + + /// Delete an uploaded magnet + pub async fn delete(&self, id: i32, user: Option) -> Result { + let db = &self.state.database; + + let uploaded_magnet = Entity::find_by_id(id) + .one(db) + .await + .context(DBSnafu)? + .ok_or(MagnetError::NotFound { id })?; + + let clone: Model = uploaded_magnet.clone(); + uploaded_magnet.delete(db).await.context(DBSnafu)?; + + let operation_log = OperationLog { + user, + date: Utc::now(), + table: Table::Magnet, + operation: OperationType::Delete, + operation_id: OperationId { + object_id: clone.id, + name: clone.name.to_owned(), + }, + operation_form: None, + }; + + self.state + .logger + .write(operation_log) + .await + .context(LoggerSnafu)?; + + Ok(clone.name) + } + + /// Create a new uploaded magnet + /// + /// Fails if: + /// + /// - the magnet is invalid + pub async fn create(&self, f: &MagnetForm, user: Option) -> Result { + let magnet = MagnetLink::new(&f.magnet).context(InvalidMagnetSnafu)?; + + // Check duplicates + let list = self.list().await?; + + if list.iter().any(|x| x.torrent_id == magnet.id().as_str()) { + return Err(MagnetError::TorrentIDTaken { + torrent_id: magnet.id().to_string(), + }); + } + + let model = ActiveModel { + torrent_id: Set(magnet.id().to_string()), + magnet: Set(magnet.to_string()), + name: Set(magnet.name().to_string()), + // TODO: check if we already have the torrent in which case it's already resolved! + resolved: Set(false), + ..Default::default() + } + .save(&self.state.database) + .await + .context(DBSnafu)?; + + // Should not fail + let model = model.try_into_model().unwrap(); + + let operation_log = OperationLog { + user, + date: Utc::now(), + table: Table::Magnet, + operation: OperationType::Create, + operation_id: OperationId { + object_id: model.id.to_owned(), + name: model.name.to_string(), + }, + operation_form: Some(Operation::Magnet(f.clone())), + }; + + self.state + .logger + .write(operation_log) + .await + .context(LoggerSnafu)?; + + Ok(model) + } +} diff --git a/src/database/mod.rs b/src/database/mod.rs index 9f25b45..192ac0e 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1,3 +1,4 @@ // sea_orm example: https://github.com/SeaQL/sea-orm/blob/master/examples/axum_example/ pub mod category; +pub mod magnet; pub mod operation; diff --git a/src/database/operation.rs b/src/database/operation.rs index 549d35d..06b4e90 100644 --- a/src/database/operation.rs +++ b/src/database/operation.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; use crate::extractors::user::User; use crate::routes::category::CategoryForm; +use crate::routes::magnet::MagnetForm; /// Type of operation applied to the database. #[derive(Clone, Debug, Display, Serialize, Deserialize)] @@ -22,6 +23,7 @@ pub struct OperationId { #[derive(Clone, Debug, Display, Serialize, Deserialize)] pub enum Table { Category, + Magnet, } /// Operation applied to the database. @@ -31,6 +33,7 @@ pub enum Table { #[serde(untagged)] pub enum Operation { Category(CategoryForm), + Magnet(MagnetForm), } impl std::fmt::Display for Operation { diff --git a/src/lib.rs b/src/lib.rs index ad27452..99f914b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,6 +26,7 @@ pub fn router(state: state::AppState) -> Router { .route("/categories/new", get(routes::category::new)) .route("/categories/{id}/delete", get(routes::category::delete)) .route("/logs", get(routes::logs::index)) + .route("/magnet/upload", post(routes::magnet::upload)) // Register static assets routes .nest("/assets", static_router()) // Insert request timing diff --git a/src/migration/m20251114_01_create_table_magnet.rs b/src/migration/m20251114_01_create_table_magnet.rs new file mode 100644 index 0000000..233ad36 --- /dev/null +++ b/src/migration/m20251114_01_create_table_magnet.rs @@ -0,0 +1,39 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Magnet::Table) + .if_not_exists() + .col(pk_auto(Magnet::Id)) + .col(string(Magnet::TorrentID).unique_key()) + .col(string(Magnet::Name)) + .col(string(Magnet::Link)) + .col(boolean(Magnet::Resolved)) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Magnet::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Magnet { + Table, + Id, + TorrentID, + Name, + Link, + Resolved, +} diff --git a/src/migration/mod.rs b/src/migration/mod.rs index 55574d2..80a4b50 100644 --- a/src/migration/mod.rs +++ b/src/migration/mod.rs @@ -1,12 +1,16 @@ pub use sea_orm_migration::prelude::*; mod m20251110_01_create_table_category; +mod m20251114_01_create_table_magnet; pub struct Migrator; #[async_trait::async_trait] impl MigratorTrait for Migrator { fn migrations() -> Vec> { - vec![Box::new(m20251110_01_create_table_category::Migration)] + vec![ + Box::new(m20251110_01_create_table_category::Migration), + Box::new(m20251114_01_create_table_magnet::Migration), + ] } } diff --git a/src/routes/index.rs b/src/routes/index.rs index 62e83d4..a8e5720 100644 --- a/src/routes/index.rs +++ b/src/routes/index.rs @@ -6,6 +6,7 @@ use snafu::prelude::*; // TUTORIAL: https://github.com/SeaQL/sea-orm/blob/master/examples/axum_example/ use crate::database::category::CategoryOperator; use crate::extractors::user::User; +use crate::routes::magnet::MagnetForm; use crate::state::{AppState, AppStateContext, error::*}; #[derive(Template, WebTemplate)] @@ -17,6 +18,12 @@ pub struct IndexTemplate { pub user: Option, /// Categories pub categories: Vec, + // TODO: also support torrent upload + /// Magnet upload form + pub post: Option, + /// Error with submitted magnet + /// TODO: typed error + pub post_error: Option, } impl IndexTemplate { @@ -33,8 +40,16 @@ impl IndexTemplate { state: app_state.context().await?, user, categories, + post: None, + post_error: None, }) } + + pub fn with_errored_form(mut self, form: MagnetForm, error: String) -> Self { + self.post = Some(form); + self.post_error = Some(error); + self + } } pub async fn index( diff --git a/src/routes/magnet.rs b/src/routes/magnet.rs new file mode 100644 index 0000000..1087daf --- /dev/null +++ b/src/routes/magnet.rs @@ -0,0 +1,72 @@ +use askama::Template; +use askama_web::WebTemplate; +use axum::extract::{Multipart, State}; +use axum::response::{IntoResponse, Redirect}; +use hightorrent_api::hightorrent::MagnetLink; +use serde::{Deserialize, Serialize}; + +use crate::extractors::user::User; +use crate::routes::index::IndexTemplate; +use crate::state::{AppState, AppStateContext, error::AppStateError}; + +/// Multipart form submitted to /magnet/upload: +/// +/// - magnet: the magnet link to upload +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct MagnetForm { + pub magnet: String, +} + +#[derive(Template, WebTemplate)] +#[template(path = "magnet.html")] +pub struct MagnetTemplate { + /// Global application state (errors/warnings) + pub state: AppStateContext, + /// Logged-in user. + pub user: Option, + /// Parsed magnet from form + pub magnet: MagnetLink, +} + +pub async fn upload( + State(app_state): State, + user: Option, + mut form: Multipart, +) -> Result { + let mut magnet: Option = None; + + while let Some(field) = form.next_field().await.unwrap() { + let name = field.name().unwrap().to_string(); + let data = field.text().await.unwrap(); + + if name == "magnet" && !data.is_empty() { + magnet = Some(data); + } + } + + if magnet.is_none() { + // No magnet was submitted. This is not an error, simply display the form again + return Ok(Redirect::to("/").into_response()); + } + + let magnet = magnet.unwrap(); + + // Parse magnet + match MagnetLink::new(&magnet) { + Ok(magnet) => Ok(MagnetTemplate { + state: app_state.context().await?, + user, + magnet, + } + .into_response()), + Err(e) => { + let form = MagnetForm { magnet }; + + // TODO: typed error + Ok(IndexTemplate::new(app_state, user) + .await? + .with_errored_form(form, e.to_string()) + .into_response()) + } + } +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 75462de..df29430 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,4 +1,5 @@ pub mod category; pub mod index; pub mod logs; +pub mod magnet; pub mod progress; diff --git a/templates/index.html b/templates/index.html index 05a8542..a20c9ff 100755 --- a/templates/index.html +++ b/templates/index.html @@ -10,7 +10,13 @@

Download files

{% endif %} -
+ {% if let Some(error) = post_error %} +
+

{{ error }}

+
+ {% endif %} + + {#
@@ -25,6 +31,7 @@

Download files

+ #}
@@ -42,11 +49,6 @@

OR

-
- -
-
- {% if categories.len() == 0 %}