-
Notifications
You must be signed in to change notification settings - Fork 4
Move Dioxus routes and extract web API #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dioxus-ui
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,24 @@ | ||||||||||
| [package] | ||||||||||
| name = "mlm_web_api" | ||||||||||
| version = "0.1.0" | ||||||||||
| edition = "2024" | ||||||||||
|
|
||||||||||
| [dependencies] | ||||||||||
| anyhow = "1.0.100" | ||||||||||
| axum = { version = "0.8.4", features = ["query"] } | ||||||||||
| axum-extra = { version = "0.10.1", features = ["form"] } | ||||||||||
| mime_guess = "2.0.5" | ||||||||||
| mlm_core = { path = "../mlm_core" } | ||||||||||
| mlm_db = { path = "../mlm_db" } | ||||||||||
| mlm_mam = { path = "../mlm_mam" } | ||||||||||
| native_db = { git = "https://github.com/StirlingMouse/native_db.git", branch = "0.8.x" } | ||||||||||
| qbit = { git = "https://github.com/StirlingMouse/qbittorrent-webui-api.git" } | ||||||||||
|
Comment on lines
+14
to
+15
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== Branch/floating git dependencies in Cargo.toml files =="
rg -n 'git\s*=\s*"[^"]+"|branch\s*=' --glob '**/Cargo.toml' -C1
echo
if [ -f Cargo.lock ]; then
echo "== Git sources in Cargo.lock (native_db/qbit) =="
rg -n 'name = "(native_db|qbit)"|source = "git\+' Cargo.lock -C2
else
echo "Cargo.lock not found."
fiRepository: StirlingMouse/MLM Length of output: 4185 🏁 Script executed: git ls-files | grep -E "^Cargo\.lock$"Repository: StirlingMouse/MLM Length of output: 71 Pin git dependencies to immutable commits instead of floating branches. Lines 13–14 use branch-based and floating git dependencies, which allow upstream movement. Even with Cargo.lock pinning current builds, manifests should explicitly specify Suggested manifest change-native_db = { git = "https://github.com/StirlingMouse/native_db.git", branch = "0.8.x" }
-qbit = { git = "https://github.com/StirlingMouse/qbittorrent-webui-api.git" }
+native_db = { git = "https://github.com/StirlingMouse/native_db.git", rev = "cddaafe0f6863146fd0879cae88fdb613bc50b47" }
+qbit = { git = "https://github.com/StirlingMouse/qbittorrent-webui-api.git", rev = "1038c6000e749b6b0d55fe2108618e82beeaebfe" }Note: This pattern also appears in server/Cargo.toml, mlm_core/Cargo.toml, and other manifests. 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
| serde = "1.0.136" | ||||||||||
| serde_json = "1.0.140" | ||||||||||
| thiserror = "2.0.17" | ||||||||||
| tokio = { version = "1.45.1", features = ["fs"] } | ||||||||||
| tokio-util = "0.7" | ||||||||||
| tower = "0.5.2" | ||||||||||
| tower-http = { version = "0.6.6", features = ["fs"] } | ||||||||||
| toml = "0.8.23" | ||||||||||
| tracing = "0.1.41" | ||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,69 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||
| use axum::{ | ||||||||||||||||||||||||||||||||||||||||||||||||
| body::Body, | ||||||||||||||||||||||||||||||||||||||||||||||||
| extract::{Path, State}, | ||||||||||||||||||||||||||||||||||||||||||||||||
| response::IntoResponse, | ||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||
| use mlm_db::Torrent; | ||||||||||||||||||||||||||||||||||||||||||||||||
| use tokio_util::io::ReaderStream; | ||||||||||||||||||||||||||||||||||||||||||||||||
| use tracing::warn; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| use crate::error::AppError; | ||||||||||||||||||||||||||||||||||||||||||||||||
| use mlm_core::{ | ||||||||||||||||||||||||||||||||||||||||||||||||
| Context, ContextExt, | ||||||||||||||||||||||||||||||||||||||||||||||||
| linker::map_path, | ||||||||||||||||||||||||||||||||||||||||||||||||
| qbittorrent::{self}, | ||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| pub async fn torrent_file( | ||||||||||||||||||||||||||||||||||||||||||||||||
| State(context): State<Context>, | ||||||||||||||||||||||||||||||||||||||||||||||||
| Path((id, filename)): Path<(String, String)>, | ||||||||||||||||||||||||||||||||||||||||||||||||
| ) -> Result<impl IntoResponse, AppError> { | ||||||||||||||||||||||||||||||||||||||||||||||||
| let config = context.config().await; | ||||||||||||||||||||||||||||||||||||||||||||||||
| let Some(torrent) = context.db().r_transaction()?.get().primary::<Torrent>(id)? else { | ||||||||||||||||||||||||||||||||||||||||||||||||
| return Err(AppError::NotFound); | ||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||
| let Some(path) = (if let (Some(library_path), Some(library_file)) = ( | ||||||||||||||||||||||||||||||||||||||||||||||||
| &torrent.library_path, | ||||||||||||||||||||||||||||||||||||||||||||||||
| torrent | ||||||||||||||||||||||||||||||||||||||||||||||||
| .library_files | ||||||||||||||||||||||||||||||||||||||||||||||||
| .iter() | ||||||||||||||||||||||||||||||||||||||||||||||||
| .find(|f| f.to_string_lossy() == filename), | ||||||||||||||||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| Some(library_path.join(library_file)) | ||||||||||||||||||||||||||||||||||||||||||||||||
| } else if let Some((torrent, qbit, qbit_config)) = | ||||||||||||||||||||||||||||||||||||||||||||||||
| qbittorrent::get_torrent(&config, &torrent.id).await? | ||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||
| qbit.files(&torrent.hash, None) | ||||||||||||||||||||||||||||||||||||||||||||||||
| .await? | ||||||||||||||||||||||||||||||||||||||||||||||||
| .into_iter() | ||||||||||||||||||||||||||||||||||||||||||||||||
| .find(|f| f.name == filename) | ||||||||||||||||||||||||||||||||||||||||||||||||
| .map(|file| map_path(&qbit_config.path_mapping, &torrent.save_path).join(&file.name)) | ||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||
| None | ||||||||||||||||||||||||||||||||||||||||||||||||
| }) else { | ||||||||||||||||||||||||||||||||||||||||||||||||
| return Err(AppError::NotFound); | ||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||
| let file = match tokio::fs::File::open(&path).await { | ||||||||||||||||||||||||||||||||||||||||||||||||
| Ok(file) => file, | ||||||||||||||||||||||||||||||||||||||||||||||||
| Err(err) => { | ||||||||||||||||||||||||||||||||||||||||||||||||
| warn!("Failed opening torrent file {}: {err}", path.display()); | ||||||||||||||||||||||||||||||||||||||||||||||||
| return Err(AppError::NotFound); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||
| let stream = ReaderStream::new(file); | ||||||||||||||||||||||||||||||||||||||||||||||||
| let body = Body::from_stream(stream); | ||||||||||||||||||||||||||||||||||||||||||||||||
| let content_type = mime_guess::from_path(&filename) | ||||||||||||||||||||||||||||||||||||||||||||||||
| .first_or_octet_stream() | ||||||||||||||||||||||||||||||||||||||||||||||||
| .to_string(); | ||||||||||||||||||||||||||||||||||||||||||||||||
| let safe_filename = filename.replace(['\r', '\n', '"'], "_"); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| let headers = [ | ||||||||||||||||||||||||||||||||||||||||||||||||
| (axum::http::header::CONTENT_TYPE, content_type), | ||||||||||||||||||||||||||||||||||||||||||||||||
| ( | ||||||||||||||||||||||||||||||||||||||||||||||||
| axum::http::header::CONTENT_DISPOSITION, | ||||||||||||||||||||||||||||||||||||||||||||||||
| format!("attachment; filename=\"{}\"", safe_filename), | ||||||||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||||||||
| ]; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+60
to
+66
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Incorrect Content-Type for file downloads. The Additionally, the 🐛 Proposed fix let headers = [
(
axum::http::header::CONTENT_TYPE,
- "text/toml; charset=utf-8".to_string(),
+ "application/octet-stream".to_string(),
),
(
axum::http::header::CONTENT_DISPOSITION,
- format!("attachment; filename=\"{}\"", filename),
+ format!(
+ "attachment; filename=\"{}\"",
+ filename.replace('"', "\\\"").replace('\n', "").replace('\r', "")
+ ),
),
];📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| Ok((headers, body)) | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| use axum::{ | ||
| http::StatusCode, | ||
| response::{IntoResponse, Response}, | ||
| }; | ||
|
|
||
| #[derive(Debug, thiserror::Error)] | ||
| pub enum AppError { | ||
| #[error("Could not query db: {0}")] | ||
| Db(#[from] native_db::db_type::Error), | ||
| #[error("Meta Error: {0:?}")] | ||
| MetaError(#[from] mlm_mam::meta::MetaError), | ||
| #[error("Qbit Error: {0:?}")] | ||
| QbitError(#[from] qbit::Error), | ||
| #[error("Toml Parse Error: {0:?}")] | ||
| Toml(#[from] toml::de::Error), | ||
| #[error("Error: {0:?}")] | ||
| Generic(#[from] anyhow::Error), | ||
| #[error("JSON Error: {0}")] | ||
| Json(#[from] serde_json::Error), | ||
| #[error("Page Not Found")] | ||
| NotFound, | ||
| } | ||
|
|
||
| impl IntoResponse for AppError { | ||
| fn into_response(self) -> Response { | ||
| let status = match self { | ||
| AppError::NotFound => StatusCode::NOT_FOUND, | ||
| _ => StatusCode::INTERNAL_SERVER_ERROR, | ||
| }; | ||
| (status, self.to_string()).into_response() | ||
| } | ||
|
Comment on lines
+24
to
+31
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consider sanitizing error messages in responses to avoid information leakage. The current implementation returns Consider returning generic messages for 500 errors while logging the actual error server-side. 🛡️ Proposed fix impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status = match self {
AppError::NotFound => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
- (status, self.to_string()).into_response()
+ let message = match &self {
+ AppError::NotFound => "Not Found".to_string(),
+ _ => {
+ tracing::error!("API error: {self:?}");
+ "Internal Server Error".to_string()
+ }
+ };
+ (status, message).into_response()
}
}🤖 Prompt for AI Agents |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| mod download; | ||
| mod error; | ||
| mod search; | ||
| mod torrent; | ||
|
|
||
| use std::path::PathBuf; | ||
|
|
||
| use axum::{ | ||
| Router, | ||
| body::Body, | ||
| http::{HeaderValue, Request}, | ||
| middleware::{self, Next}, | ||
| response::Response, | ||
| routing::get, | ||
| }; | ||
| use mlm_core::Context; | ||
| use tower::ServiceBuilder; | ||
| use tower_http::services::{ServeDir, ServeFile}; | ||
|
|
||
| use crate::{ | ||
| download::torrent_file, | ||
| search::{search_api, search_api_post}, | ||
| torrent::torrent_api, | ||
| }; | ||
|
|
||
| pub fn router(context: Context, dioxus_public_path: PathBuf) -> Router { | ||
| let dioxus_assets_path = dioxus_public_path.join("assets"); | ||
| let app = Router::new() | ||
| .route("/api/search", get(search_api).post(search_api_post)) | ||
| .route( | ||
| "/api/torrents/{id}", | ||
| get(torrent_api).with_state(context.clone()), | ||
| ) | ||
| .route( | ||
| "/torrents/{id}/{filename}", | ||
| get(torrent_file).with_state(context.clone()), | ||
| ) | ||
| .with_state(context.clone()) | ||
| .nest_service( | ||
| "/assets", | ||
| ServiceBuilder::new() | ||
| .layer(middleware::from_fn(set_static_cache_control)) | ||
| .service(ServeDir::new(dioxus_assets_path).fallback(ServeDir::new("server/assets"))), | ||
| ); | ||
|
|
||
| #[cfg(debug_assertions)] | ||
| let app = app.nest_service( | ||
| "/assets/favicon.png", | ||
| ServiceBuilder::new() | ||
| .layer(middleware::from_fn(set_static_cache_control)) | ||
| .service(ServeFile::new("server/assets/favicon_dev.png")), | ||
| ); | ||
|
|
||
| #[cfg(debug_assertions)] | ||
| let app = app.nest_service( | ||
| "/favicon.ico", | ||
| ServiceBuilder::new() | ||
| .layer(middleware::from_fn(set_static_cache_control)) | ||
| .service(ServeFile::new("server/assets/favicon_dev.png")), | ||
| ); | ||
|
|
||
| #[cfg(not(debug_assertions))] | ||
| let app = app.nest_service( | ||
| "/favicon.ico", | ||
| ServiceBuilder::new() | ||
| .layer(middleware::from_fn(set_static_cache_control)) | ||
| .service(ServeFile::new("server/assets/favicon.png")), | ||
| ); | ||
|
|
||
| app | ||
| } | ||
|
|
||
| async fn set_static_cache_control(request: Request<Body>, next: Next) -> Response { | ||
| let mut response = next.run(request).await; | ||
| response.headers_mut().insert( | ||
| axum::http::header::CACHE_CONTROL, | ||
| HeaderValue::from_static("must-revalidate"), | ||
| ); | ||
| response | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Consider adding a matching dev-profile entry for
mlm_web_api.Line 5 correctly adds the crate to the workspace. For consistency with other workspace crates, you may also add
[profile.dev.package.mlm_web_api] opt-level = 0.🤖 Prompt for AI Agents