From d6012fe88ed9e1b71d946fffae1bfb04e0b6d64c Mon Sep 17 00:00:00 2001 From: Claudio Bley Date: Sat, 27 Jun 2026 16:07:09 +0200 Subject: [PATCH 1/9] refactor(rust): idiomatic error handling with AppError and HtmlTemplate Add anyhow-based AppError type that implements IntoResponse, returning a JSON {"message": "..."} body on error (consistent with the Laravel API format). Add HtmlTemplate wrapper that renders Askama templates and falls back to AppError on render failure. Refactor all handler functions to return Result and use ? for error propagation instead of: - .unwrap_or_default() / .unwrap_or(0) silently masking DB failures - .expect() panics in api.rs (legacy_latest, latest_result, get_result) - repeated match template.render() { Ok => ..., Err => 500 } blocks Changes: - Cargo.toml: add anyhow = "1" - src/error.rs: new AppError + HtmlTemplate types - src/lib.rs: wire in pub mod error - src/handlers/{auth,dashboard,dashboard_admin,results,speedtest,tokens,profile,schedules}.rs - src/api.rs --- Cargo.lock | 1 + Cargo.toml | 1 + src/api.rs | 258 ++++++++++++-------------------- src/error.rs | 49 ++++++ src/handlers/auth.rs | 65 +++----- src/handlers/dashboard.rs | 72 +++------ src/handlers/dashboard_admin.rs | 72 +++------ src/handlers/profile.rs | 34 ++--- src/handlers/results.rs | 123 +++++++-------- src/handlers/schedules.rs | 41 ++--- src/handlers/speedtest.rs | 96 ++++-------- src/handlers/tokens.rs | 111 +++++++------- src/lib.rs | 1 + 13 files changed, 384 insertions(+), 540 deletions(-) create mode 100644 src/error.rs diff --git a/Cargo.lock b/Cargo.lock index da6c840f2..32a0a24c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3004,6 +3004,7 @@ dependencies = [ name = "speedtest-tracker" version = "0.1.0" dependencies = [ + "anyhow", "askama", "assert_float_eq", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 3d9fb5db9..173c37802 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ mysql = ["sqlx/mysql", "tower-sessions-sqlx-store/mysql"] postgres = ["sqlx/postgres", "tower-sessions-sqlx-store/postgres"] [dependencies] +anyhow = "1" tokio = { version = "1", features = ["full"] } axum = { version = "0.8", features = ["macros"] } axum-extra = { version = "0.12", features = ["typed-header", "cookie", "cookie-private"] } diff --git a/src/api.rs b/src/api.rs index 10ed1aa67..4e7a3e0f9 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,8 +1,9 @@ +use crate::error::AppError; use crate::{db::Database, models::Result as SpeedTestResult, AppState}; use axum::{ extract::{Path, Query, State}, http::StatusCode, - response::{IntoResponse, Json}, + response::{IntoResponse, Json, Response}, }; use serde::{Deserialize, Serialize}; @@ -139,41 +140,35 @@ pub async fn healthcheck() -> Json> { } // GET /api/speedtest/latest (legacy v0 endpoint) -pub async fn legacy_latest(State(state): State) -> impl IntoResponse { +pub async fn legacy_latest(State(state): State) -> Result { let result = match &state.db { #[cfg(feature = "sqlite")] - Database::Sqlite(pool) => { sqlx::query_as::<_, SpeedTestResult>( "SELECT * FROM results WHERE status IN ('completed', 'failed') ORDER BY created_at DESC LIMIT 1" ) .fetch_optional(pool) - .await - .expect("fetched one result") + .await? }, #[cfg(feature = "mysql")] - Database::MySql(pool) => { sqlx::query_as::<_, SpeedTestResult>( "SELECT * FROM results WHERE status IN ('completed', 'failed') ORDER BY created_at DESC LIMIT 1" ) .fetch_optional(pool) - .await - .expect("fetched one result") + .await? }, #[cfg(feature = "postgres")] - Database::Postgres(pool) => { sqlx::query_as::<_, SpeedTestResult>( "SELECT * FROM results WHERE status IN ('completed', 'failed') ORDER BY created_at DESC LIMIT 1" ) .fetch_optional(pool) - .await - .expect("fetched one result") + .await? }, }; - if let Some(r) = result { + let response = if let Some(r) = result { // Parse server info from data JSON if available let (server_id, server_host, server_name, result_url) = r .data @@ -195,7 +190,7 @@ pub async fn legacy_latest(State(state): State) -> impl IntoResponse { }) .unwrap_or((None, None, None, None)); - let response = serde_json::json!({ + let body = serde_json::json!({ "message": "ok", "data": { "id": r.id, @@ -212,20 +207,17 @@ pub async fn legacy_latest(State(state): State) -> impl IntoResponse { "updated_at": r.updated_at.format("%Y-%m-%dT%H:%M:%S").to_string(), } }); - (StatusCode::OK, Json(response)) + (StatusCode::OK, Json(body)).into_response() } else { - let response = serde_json::json!({ - "message": "No results found." - }); - (StatusCode::NOT_FOUND, Json(response)) - } + let body = serde_json::json!({ "message": "No results found." }); + (StatusCode::NOT_FOUND, Json(body)).into_response() + }; + Ok(response) } - -// GET /api/v1/results pub async fn list_results( State(state): State, Query(params): Query, -) -> Json { +) -> Result, AppError> { let offset = (params.page - 1) * params.per_page; let (results, total) = match &state.db { @@ -237,13 +229,11 @@ pub async fn list_results( .bind(params.per_page) .bind(offset) .fetch_all(pool) - .await - .unwrap_or_default(); + .await?; let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM results") .fetch_one(pool) - .await - .unwrap_or(0); + .await?; (results, total) } @@ -255,13 +245,11 @@ pub async fn list_results( .bind(params.per_page) .bind(offset) .fetch_all(pool) - .await - .unwrap_or_default(); + .await?; let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM results") .fetch_one(pool) - .await - .unwrap_or(0); + .await?; (results, total) } @@ -273,109 +261,112 @@ pub async fn list_results( .bind(params.per_page) .bind(offset) .fetch_all(pool) - .await - .unwrap_or_default(); + .await?; let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM results") .fetch_one(pool) - .await - .unwrap_or(0); + .await?; (results, total) } }; - Json(PaginatedResults { + Ok(Json(PaginatedResults { data: results.into_iter().map(Into::into).collect(), page: params.page, per_page: params.per_page, total, - }) + })) } // GET /api/v1/results/latest -pub async fn latest_result(State(state): State) -> impl IntoResponse { +pub async fn latest_result(State(state): State) -> Result { let result = match &state.db { #[cfg(feature = "sqlite")] - Database::Sqlite(pool) => sqlx::query_as::<_, SpeedTestResult>( - "SELECT * FROM results ORDER BY created_at DESC LIMIT 1", - ) - .fetch_optional(pool) - .await - .expect("fetch latest result"), + Database::Sqlite(pool) => { + sqlx::query_as::<_, SpeedTestResult>( + "SELECT * FROM results ORDER BY created_at DESC LIMIT 1", + ) + .fetch_optional(pool) + .await? + } #[cfg(feature = "mysql")] - Database::MySql(pool) => sqlx::query_as::<_, SpeedTestResult>( - "SELECT * FROM results ORDER BY created_at DESC LIMIT 1", - ) - .fetch_optional(pool) - .await - .expect("fetch latest result"), + Database::MySql(pool) => { + sqlx::query_as::<_, SpeedTestResult>( + "SELECT * FROM results ORDER BY created_at DESC LIMIT 1", + ) + .fetch_optional(pool) + .await? + } #[cfg(feature = "postgres")] - Database::Postgres(pool) => sqlx::query_as::<_, SpeedTestResult>( - "SELECT * FROM results ORDER BY created_at DESC LIMIT 1", - ) - .fetch_optional(pool) - .await - .expect("fetch latest result"), + Database::Postgres(pool) => { + sqlx::query_as::<_, SpeedTestResult>( + "SELECT * FROM results ORDER BY created_at DESC LIMIT 1", + ) + .fetch_optional(pool) + .await? + } }; - if let Some(r) = result { + let response = if let Some(r) = result { let response = ApiResponse { data: Some(ResultResponse::from(r)), message: "Success".to_string(), }; - (StatusCode::OK, Json(response)) + (StatusCode::OK, Json(response)).into_response() } else { let response: ApiResponse = ApiResponse { data: None, message: "No results found.".to_string(), }; - (StatusCode::NOT_FOUND, Json(response)) - } + (StatusCode::NOT_FOUND, Json(response)).into_response() + }; + Ok(response) } // GET /api/v1/results/{id} -pub async fn get_result(State(state): State, Path(id): Path) -> impl IntoResponse { +pub async fn get_result( + State(state): State, + Path(id): Path, +) -> Result { let result = match &state.db { #[cfg(feature = "sqlite")] Database::Sqlite(pool) => { sqlx::query_as::<_, SpeedTestResult>("SELECT * FROM results WHERE id = ?") .bind(id) .fetch_optional(pool) - .await - .expect("fetch a result") + .await? } #[cfg(feature = "mysql")] Database::MySql(pool) => { sqlx::query_as::<_, SpeedTestResult>("SELECT * FROM results WHERE id = ?") .bind(id) .fetch_optional(pool) - .await - .expect("fetch a result") + .await? } #[cfg(feature = "postgres")] Database::Postgres(pool) => { sqlx::query_as::<_, SpeedTestResult>("SELECT * FROM results WHERE id = $1") .bind(id) .fetch_optional(pool) - .await - .expect("fetch a result") + .await? } }; - if let Some(r) = result { + let response = if let Some(r) = result { let response = ApiResponse { data: Some(ResultResponse::from(r)), message: "Success".to_string(), }; - (StatusCode::OK, Json(response)) + (StatusCode::OK, Json(response)).into_response() } else { let response: ApiResponse = ApiResponse { data: None, message: "Result not found.".to_string(), }; - (StatusCode::NOT_FOUND, Json(response)) - } + (StatusCode::NOT_FOUND, Json(response)).into_response() + }; + Ok(response) } #[derive(Serialize)] @@ -434,8 +425,10 @@ struct StatsRow { } // GET /api/v1/stats -pub async fn get_stats(State(state): State) -> Json> { - let query = match &state.db { +pub async fn get_stats( + State(state): State, +) -> Result>, AppError> { + let row = match &state.db { #[cfg(feature = "sqlite")] Database::Sqlite(pool) => { sqlx::query_as::<_, StatsRow>( @@ -453,7 +446,7 @@ pub async fn get_stats(State(state): State) -> Json { @@ -472,7 +465,7 @@ pub async fn get_stats(State(state): State) -> Json { @@ -491,35 +484,16 @@ pub async fn get_stats(State(state): State) -> Json { - tracing::debug!( - "Stats query successful: total={}, avg_download={:?}", - r.total_results, - r.avg_download - ); - r - } - Err(e) => { - tracing::error!("Stats query failed: {}", e); - StatsRow { - total_results: 0, - avg_ping: None, - avg_download: None, - avg_upload: None, - min_ping: None, - min_download: None, - min_upload: None, - max_ping: None, - max_download: None, - max_upload: None, - } + .await? } }; + tracing::debug!( + "Stats query successful: total={}, avg_download={:?}", + row.total_results, + row.avg_download + ); + let avg_download = row.avg_download.unwrap_or(0.0).round() as i64; let min_download = row.min_download.unwrap_or(0.0).round() as i64; let max_download = row.max_download.unwrap_or(0.0).round() as i64; @@ -558,10 +532,10 @@ pub async fn get_stats(State(state): State) -> Json, Json(payload): Json, -) -> impl IntoResponse { +) -> Result { tracing::info!( "API speedtest requested with server_id: {:?}", payload.server_id ); - // Run speedtest - let result = match crate::speedtest::run_speedtest(payload.server_id).await { - Ok(r) => r, - Err(e) => { + let result = crate::speedtest::run_speedtest(payload.server_id) + .await + .map_err(|e| { tracing::error!("Speedtest execution failed: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiResponse::<()> { - data: None, - message: format!("Speedtest failed: {e}"), - }), - ) - .into_response(); - } - }; + AppError::from(anyhow::anyhow!("Speedtest failed: {e}")) + })?; - // Save to database - let result_id = match crate::speedtest::save_result(&state.db, result, false).await { - Ok(id) => id, - Err(e) => { + let result_id = crate::speedtest::save_result(&state.db, result, false) + .await + .map_err(|e| { tracing::error!("Failed to save speedtest result: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiResponse::<()> { - data: None, - message: format!("Test completed but failed to save: {e}"), - }), - ) - .into_response(); - } - }; + AppError::from(anyhow::anyhow!("Test completed but failed to save: {e}")) + })?; - // Fetch the saved result to return let saved_result = match &state.db { #[cfg(feature = "sqlite")] Database::Sqlite(pool) => { sqlx::query_as::<_, SpeedTestResult>("SELECT * FROM results WHERE id = ?") .bind(result_id) .fetch_one(pool) - .await + .await? } #[cfg(feature = "mysql")] Database::MySql(pool) => { sqlx::query_as::<_, SpeedTestResult>("SELECT * FROM results WHERE id = ?") .bind(result_id) .fetch_one(pool) - .await + .await? } #[cfg(feature = "postgres")] Database::Postgres(pool) => { sqlx::query_as::<_, SpeedTestResult>("SELECT * FROM results WHERE id = $1") .bind(result_id) .fetch_one(pool) - .await + .await? } }; - match saved_result { - Ok(result) => { - tracing::info!("Speedtest completed and saved with id: {}", result_id); - ( - StatusCode::CREATED, - Json(ApiResponse { - data: Some(ResultResponse::from(result)), - message: "Speedtest completed successfully".to_string(), - }), - ) - .into_response() - } - Err(e) => { - tracing::error!("Failed to fetch saved result: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiResponse::<()> { - data: None, - message: format!("Test saved but failed to retrieve: {e}"), - }), - ) - .into_response() - } - } + tracing::info!("Speedtest completed and saved with id: {}", result_id); + Ok(( + StatusCode::CREATED, + Json(ApiResponse { + data: Some(ResultResponse::from(saved_result)), + message: "Speedtest completed successfully".to_string(), + }), + ) + .into_response()) } diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 000000000..86f43e37c --- /dev/null +++ b/src/error.rs @@ -0,0 +1,49 @@ +use askama::Template; +use axum::{ + http::StatusCode, + response::{Html, IntoResponse, Response}, +}; + +/// Application error type. Wraps `anyhow::Error` and converts it to an HTTP +/// response. All errors are returned as JSON so they work consistently for +/// both API and web handler callers. +pub struct AppError(StatusCode, anyhow::Error); + +impl AppError { + pub fn not_found(msg: impl std::fmt::Display) -> Self { + AppError(StatusCode::NOT_FOUND, anyhow::anyhow!("{}", msg)) + } + + pub fn bad_request(msg: impl std::fmt::Display) -> Self { + AppError(StatusCode::BAD_REQUEST, anyhow::anyhow!("{}", msg)) + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let status = self.0; + tracing::error!("HTTP {}: {:?}", status, self.1); + let body = serde_json::json!({ "message": self.1.to_string() }); + (status, axum::response::Json(body)).into_response() + } +} + +/// Convert any `anyhow`-compatible error into an `AppError` with status 500. +impl> From for AppError { + fn from(err: E) -> Self { + AppError(StatusCode::INTERNAL_SERVER_ERROR, err.into()) + } +} + +/// Wraps an Askama template and renders it to an HTML response. +/// On render failure it falls back to `AppError` (500). +pub struct HtmlTemplate(pub T); + +impl IntoResponse for HtmlTemplate { + fn into_response(self) -> Response { + match self.0.render() { + Ok(html) => Html(html).into_response(), + Err(err) => AppError::from(err).into_response(), + } + } +} diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index d19cced07..66f360c26 100644 --- a/src/handlers/auth.rs +++ b/src/handlers/auth.rs @@ -1,9 +1,10 @@ +use crate::error::{AppError, HtmlTemplate}; use crate::locale_middleware::Locale; use crate::{db::Database, filters, AppState}; use askama::Template; use axum::{ extract::State, - response::{Html, IntoResponse, Redirect, Response}, + response::{IntoResponse, Redirect, Response}, Form, }; use serde::Deserialize; @@ -15,19 +16,11 @@ pub struct LoginTemplate { error: Option, } -pub async fn login_page(locale: Locale) -> Response { - let template = LoginTemplate { +pub async fn login_page(locale: Locale) -> impl IntoResponse { + HtmlTemplate(LoginTemplate { locale: locale.0, error: None, - }; - match template.render() { - Ok(html) => Html(html).into_response(), - Err(err) => ( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - err.to_string(), - ) - .into_response(), - } + }) } #[derive(Deserialize)] @@ -41,7 +34,7 @@ pub async fn login_post( session: tower_sessions::Session, locale: Locale, Form(form): Form, -) -> Response { +) -> Result { tracing::debug!("Login attempt for email: {}", form.email); let user = match &state.db { @@ -54,9 +47,7 @@ pub async fn login_post( .map_err(|e| { tracing::error!("Database query error during login: {}", e); e - }) - .ok() - .flatten() + })? } #[cfg(feature = "mysql")] Database::MySql(pool) => { @@ -67,9 +58,7 @@ pub async fn login_post( .map_err(|e| { tracing::error!("Database query error during login: {}", e); e - }) - .ok() - .flatten() + })? } #[cfg(feature = "postgres")] Database::Postgres(pool) => { @@ -80,9 +69,7 @@ pub async fn login_post( .map_err(|e| { tracing::error!("Database query error during login: {}", e); e - }) - .ok() - .flatten() + })? } }; @@ -103,18 +90,11 @@ pub async fn login_post( if let Err(e) = crate::session::set_user_session(session, user.id).await { tracing::error!("Failed to set session: {}", e); - let template = LoginTemplate { - locale: locale.0.clone(), + return Ok(HtmlTemplate(LoginTemplate { + locale: locale.0, error: Some(format!("Login failed - session error: {e}")), - }; - return match template.render() { - Ok(html) => Html(html).into_response(), - Err(err) => ( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - err.to_string(), - ) - .into_response(), - }; + }) + .into_response()); } tracing::info!( @@ -122,7 +102,7 @@ pub async fn login_post( user.email, redirect_url ); - return Redirect::to(&redirect_url).into_response(); + return Ok(Redirect::to(&redirect_url).into_response()); } Ok(false) => { tracing::debug!("Password verification failed"); @@ -135,23 +115,16 @@ pub async fn login_post( tracing::debug!("User not found"); } - let template = LoginTemplate { + Ok(HtmlTemplate(LoginTemplate { locale: locale.0, error: Some("Invalid credentials".to_string()), - }; - match template.render() { - Ok(html) => Html(html).into_response(), - Err(err) => ( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - err.to_string(), - ) - .into_response(), - } + }) + .into_response()) } -pub async fn logout(session: tower_sessions::Session) -> Response { +pub async fn logout(session: tower_sessions::Session) -> impl IntoResponse { if let Err(e) = crate::session::clear_session(session).await { tracing::error!("Failed to clear session: {}", e); } - Redirect::to("/").into_response() + Redirect::to("/") } diff --git a/src/handlers/dashboard.rs b/src/handlers/dashboard.rs index 7dd10cd30..074da2685 100644 --- a/src/handlers/dashboard.rs +++ b/src/handlers/dashboard.rs @@ -1,9 +1,10 @@ +use crate::error::{AppError, HtmlTemplate}; use crate::locale_middleware::Locale; use crate::{db::Database, filters, models::Result as SpeedTestResult, AppState}; use askama::Template; use axum::{ extract::{Query, State}, - response::{Html, IntoResponse, Response}, + response::IntoResponse, }; use chrono::{NaiveDateTime, Utc}; use serde::Deserialize; @@ -82,7 +83,7 @@ pub async fn home_dashboard( locale: Locale, session: tower_sessions::Session, Query(params): Query, -) -> Response { +) -> Result { let hours_ago = match params.range.as_str() { "week" => 24 * 7, "month" => 24 * 30, @@ -99,8 +100,7 @@ pub async fn home_dashboard( "SELECT * FROM results ORDER BY created_at DESC LIMIT 5", ) .fetch_all(pool) - .await - .unwrap_or_default(); + .await?; let chart_results: Vec = sqlx::query_as( "SELECT * FROM results WHERE created_at >= ? AND status = ? ORDER BY created_at ASC" @@ -108,8 +108,7 @@ pub async fn home_dashboard( .bind(time_cutoff) .bind("completed") .fetch_all(pool) - .await - .unwrap_or_default(); + .await?; let total: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM results WHERE created_at >= ? AND status = ?", @@ -117,8 +116,7 @@ pub async fn home_dashboard( .bind(time_cutoff) .bind("completed") .fetch_one(pool) - .await - .unwrap_or(0); + .await?; let avg_download: Option = sqlx::query_scalar( "SELECT AVG(download) FROM results WHERE download IS NOT NULL AND created_at >= ? AND status = ?" @@ -126,8 +124,7 @@ pub async fn home_dashboard( .bind(time_cutoff) .bind("completed") .fetch_one(pool) - .await - .ok(); + .await?; let avg_upload: Option = sqlx::query_scalar( "SELECT AVG(upload) FROM results WHERE upload IS NOT NULL AND created_at >= ? AND status = ?" @@ -135,8 +132,7 @@ pub async fn home_dashboard( .bind(time_cutoff) .bind("completed") .fetch_one(pool) - .await - .ok(); + .await?; let avg_ping: Option = sqlx::query_scalar( "SELECT AVG(ping) FROM results WHERE ping IS NOT NULL AND created_at >= ? AND status = ?" @@ -144,8 +140,7 @@ pub async fn home_dashboard( .bind(time_cutoff) .bind("completed") .fetch_one(pool) - .await - .ok(); + .await?; let chart_data = chart_results .iter() @@ -173,8 +168,7 @@ pub async fn home_dashboard( "SELECT * FROM results ORDER BY created_at DESC LIMIT 5", ) .fetch_all(pool) - .await - .unwrap_or_default(); + .await?; let chart_results: Vec = sqlx::query_as( "SELECT * FROM results WHERE created_at >= ? AND status = ? ORDER BY created_at ASC" @@ -182,8 +176,7 @@ pub async fn home_dashboard( .bind(time_cutoff) .bind("completed") .fetch_all(pool) - .await - .unwrap_or_default(); + .await?; let total: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM results WHERE created_at >= ? AND status = ?", @@ -191,8 +184,7 @@ pub async fn home_dashboard( .bind(time_cutoff) .bind("completed") .fetch_one(pool) - .await - .unwrap_or(0); + .await?; let avg_download: Option = sqlx::query_scalar( "SELECT AVG(download) FROM results WHERE download IS NOT NULL AND created_at >= ? AND status = ?" @@ -200,8 +192,7 @@ pub async fn home_dashboard( .bind(time_cutoff) .bind("completed") .fetch_one(pool) - .await - .ok(); + .await?; let avg_upload: Option = sqlx::query_scalar( "SELECT AVG(upload) FROM results WHERE upload IS NOT NULL AND created_at >= ? AND status = ?" @@ -209,8 +200,7 @@ pub async fn home_dashboard( .bind(time_cutoff) .bind("completed") .fetch_one(pool) - .await - .ok(); + .await?; let avg_ping: Option = sqlx::query_scalar( "SELECT AVG(ping) FROM results WHERE ping IS NOT NULL AND created_at >= ? AND status = ?" @@ -218,8 +208,7 @@ pub async fn home_dashboard( .bind(time_cutoff) .bind("completed") .fetch_one(pool) - .await - .ok(); + .await?; let chart_data = chart_results .iter() @@ -247,8 +236,7 @@ pub async fn home_dashboard( "SELECT * FROM results ORDER BY created_at DESC LIMIT 5", ) .fetch_all(pool) - .await - .unwrap_or_default(); + .await?; let chart_results: Vec = sqlx::query_as( "SELECT * FROM results WHERE created_at >= $1 AND status = $2 ORDER BY created_at ASC" @@ -256,8 +244,7 @@ pub async fn home_dashboard( .bind(time_cutoff) .bind("completed") .fetch_all(pool) - .await - .unwrap_or_default(); + .await?; let total: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM results WHERE created_at >= $1 AND status = $2", @@ -265,8 +252,7 @@ pub async fn home_dashboard( .bind(time_cutoff) .bind("completed") .fetch_one(pool) - .await - .unwrap_or(0); + .await?; let avg_download: Option = sqlx::query_scalar( "SELECT AVG(download) FROM results WHERE download IS NOT NULL AND created_at >= $1 AND status = $2" @@ -274,8 +260,7 @@ pub async fn home_dashboard( .bind(time_cutoff) .bind("completed") .fetch_one(pool) - .await - .ok(); + .await?; let avg_upload: Option = sqlx::query_scalar( "SELECT AVG(upload) FROM results WHERE upload IS NOT NULL AND created_at >= $1 AND status = $2" @@ -283,8 +268,7 @@ pub async fn home_dashboard( .bind(time_cutoff) .bind("completed") .fetch_one(pool) - .await - .ok(); + .await?; let avg_ping: Option = sqlx::query_scalar( "SELECT AVG(ping) FROM results WHERE ping IS NOT NULL AND created_at >= $1 AND status = $2" @@ -292,8 +276,7 @@ pub async fn home_dashboard( .bind(time_cutoff) .bind("completed") .fetch_one(pool) - .await - .ok(); + .await?; let chart_data = chart_results .iter() @@ -320,21 +303,12 @@ pub async fn home_dashboard( let next_speedtest = get_next_scheduled_test(); let is_authenticated = crate::session::get_user_id(session).await.is_some(); - let template = HomeDashboardTemplate { + Ok(HtmlTemplate(HomeDashboardTemplate { locale: locale.0, latest_results, stats, time_range: params.range.clone(), next_speedtest, is_authenticated, - }; - - match template.render() { - Ok(html) => Html(html).into_response(), - Err(err) => ( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - err.to_string(), - ) - .into_response(), - } + })) } diff --git a/src/handlers/dashboard_admin.rs b/src/handlers/dashboard_admin.rs index bf52ea496..e456fbcab 100644 --- a/src/handlers/dashboard_admin.rs +++ b/src/handlers/dashboard_admin.rs @@ -1,10 +1,8 @@ +use crate::error::{AppError, HtmlTemplate}; use crate::locale_middleware::Locale; use crate::{db::Database, filters, models::Result as SpeedTestResult, AppState}; use askama::Template; -use axum::{ - extract::State, - response::{Html, IntoResponse, Response}, -}; +use axum::{extract::State, response::IntoResponse}; #[derive(Template)] #[template(path = "pages/admin.html")] @@ -21,7 +19,10 @@ pub struct AdminStats { pub avg_upload: f64, pub avg_ping: f64, } -pub async fn admin_dashboard(State(state): State, locale: Locale) -> Response { +pub async fn admin_dashboard( + State(state): State, + locale: Locale, +) -> Result { let (latest_result, stats) = match &state.db { #[cfg(feature = "sqlite")] Database::Sqlite(pool) => { @@ -29,32 +30,26 @@ pub async fn admin_dashboard(State(state): State, locale: Locale) -> R "SELECT * FROM results ORDER BY created_at DESC LIMIT 1", ) .fetch_optional(pool) - .await - .ok() - .flatten(); + .await?; let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM results") .fetch_one(pool) - .await - .unwrap_or(0); + .await?; let avg_download: Option = sqlx::query_scalar("SELECT AVG(download) FROM results WHERE download IS NOT NULL") .fetch_one(pool) - .await - .ok(); + .await?; let avg_upload: Option = sqlx::query_scalar("SELECT AVG(upload) FROM results WHERE upload IS NOT NULL") .fetch_one(pool) - .await - .ok(); + .await?; let avg_ping: Option = sqlx::query_scalar("SELECT AVG(ping) FROM results WHERE ping IS NOT NULL") .fetch_one(pool) - .await - .ok(); + .await?; let stats = AdminStats { total_tests: total, @@ -71,32 +66,26 @@ pub async fn admin_dashboard(State(state): State, locale: Locale) -> R "SELECT * FROM results ORDER BY created_at DESC LIMIT 1", ) .fetch_optional(pool) - .await - .ok() - .flatten(); + .await?; let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM results") .fetch_one(pool) - .await - .unwrap_or(0); + .await?; let avg_download: Option = sqlx::query_scalar("SELECT AVG(download) FROM results WHERE download IS NOT NULL") .fetch_one(pool) - .await - .ok(); + .await?; let avg_upload: Option = sqlx::query_scalar("SELECT AVG(upload) FROM results WHERE upload IS NOT NULL") .fetch_one(pool) - .await - .ok(); + .await?; let avg_ping: Option = sqlx::query_scalar("SELECT AVG(ping) FROM results WHERE ping IS NOT NULL") .fetch_one(pool) - .await - .ok(); + .await?; let stats = AdminStats { total_tests: total, @@ -113,32 +102,26 @@ pub async fn admin_dashboard(State(state): State, locale: Locale) -> R "SELECT * FROM results ORDER BY created_at DESC LIMIT 1", ) .fetch_optional(pool) - .await - .ok() - .flatten(); + .await?; let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM results") .fetch_one(pool) - .await - .unwrap_or(0); + .await?; let avg_download: Option = sqlx::query_scalar("SELECT AVG(download) FROM results WHERE download IS NOT NULL") .fetch_one(pool) - .await - .ok(); + .await?; let avg_upload: Option = sqlx::query_scalar("SELECT AVG(upload) FROM results WHERE upload IS NOT NULL") .fetch_one(pool) - .await - .ok(); + .await?; let avg_ping: Option = sqlx::query_scalar("SELECT AVG(ping) FROM results WHERE ping IS NOT NULL") .fetch_one(pool) - .await - .ok(); + .await?; let stats = AdminStats { total_tests: total, @@ -151,19 +134,10 @@ pub async fn admin_dashboard(State(state): State, locale: Locale) -> R } }; - let template = AdminDashboardTemplate { + Ok(HtmlTemplate(AdminDashboardTemplate { locale: locale.0, stats, latest_result, is_authenticated: true, - }; - - match template.render() { - Ok(html) => Html(html).into_response(), - Err(err) => ( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - err.to_string(), - ) - .into_response(), - } + })) } diff --git a/src/handlers/profile.rs b/src/handlers/profile.rs index 904c1cf3f..f8491de5e 100644 --- a/src/handlers/profile.rs +++ b/src/handlers/profile.rs @@ -1,9 +1,10 @@ +use crate::error::{AppError, HtmlTemplate}; use crate::locale_middleware::Locale; use crate::{db::Database, filters, AppState}; use askama::Template; use axum::{ extract::State, - response::{Html, IntoResponse, Redirect, Response}, + response::{IntoResponse, Redirect, Response}, Form, }; use serde::Deserialize; @@ -21,11 +22,11 @@ pub async fn profile_page( State(state): State, locale: Locale, session: tower_sessions::Session, -) -> Response { +) -> Result { // Get logged-in user from session let user_id = match crate::session::get_user_id(session).await { Some(id) => id, - None => return Redirect::to("/login").into_response(), + None => return Ok(Redirect::to("/login").into_response()), }; let user = match &state.db { @@ -34,42 +35,35 @@ pub async fn profile_page( sqlx::query_as::<_, crate::models::User>("SELECT * FROM users WHERE id = ?") .bind(user_id) .fetch_optional(pool) - .await + .await? } #[cfg(feature = "mysql")] Database::MySql(pool) => { sqlx::query_as::<_, crate::models::User>("SELECT * FROM users WHERE id = ?") .bind(user_id) .fetch_optional(pool) - .await + .await? } #[cfg(feature = "postgres")] Database::Postgres(pool) => { sqlx::query_as::<_, crate::models::User>("SELECT * FROM users WHERE id = $1") .bind(user_id) .fetch_optional(pool) - .await + .await? } }; match user { - Ok(Some(user)) => { + Some(user) => { let template = ProfileTemplate { locale: locale.0, user, message: None, is_authenticated: true, }; - match template.render() { - Ok(html) => Html(html).into_response(), - Err(err) => ( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - err.to_string(), - ) - .into_response(), - } + Ok(HtmlTemplate(template).into_response()) } - _ => Redirect::to("/login").into_response(), + None => Ok(Redirect::to("/login").into_response()), } } @@ -83,7 +77,7 @@ pub struct ProfileForm { pub async fn profile_update( State(state): State, Form(form): Form, -) -> Response { +) -> Result { // TODO: Get actual user ID from session // For now, update first admin user @@ -197,12 +191,12 @@ pub async fn profile_update( }; result.is_ok() } - _ => return Redirect::to("/admin/profile").into_response(), + _ => return Ok(Redirect::to("/admin/profile")), }; if success { - Redirect::to("/admin/profile?updated=1").into_response() + Ok(Redirect::to("/admin/profile?updated=1")) } else { - Redirect::to("/admin/profile?error=1").into_response() + Ok(Redirect::to("/admin/profile?error=1")) } } diff --git a/src/handlers/results.rs b/src/handlers/results.rs index 46dd18cb6..cdeeb1b11 100644 --- a/src/handlers/results.rs +++ b/src/handlers/results.rs @@ -1,9 +1,10 @@ +use crate::error::{AppError, HtmlTemplate}; use crate::locale_middleware::Locale; use crate::{db::Database, filters, models::Result as SpeedTestResult, AppState}; use askama::Template; use axum::{ extract::{Query, State}, - response::{Html, IntoResponse, Redirect, Response}, + response::{IntoResponse, Redirect}, Form, }; use serde::Deserialize; @@ -39,26 +40,29 @@ pub async fn results_list( State(state): State, locale: Locale, Query(params): Query, -) -> Response { +) -> Result { let offset = (params.page - 1) * params.per_page; // Get total count let total_results: i64 = match &state.db { #[cfg(feature = "sqlite")] - Database::Sqlite(pool) => sqlx::query_scalar("SELECT COUNT(*) FROM results") - .fetch_one(pool) - .await - .unwrap_or(0), + Database::Sqlite(pool) => { + sqlx::query_scalar("SELECT COUNT(*) FROM results") + .fetch_one(pool) + .await? + } #[cfg(feature = "mysql")] - Database::MySql(pool) => sqlx::query_scalar("SELECT COUNT(*) FROM results") - .fetch_one(pool) - .await - .unwrap_or(0), + Database::MySql(pool) => { + sqlx::query_scalar("SELECT COUNT(*) FROM results") + .fetch_one(pool) + .await? + } #[cfg(feature = "postgres")] - Database::Postgres(pool) => sqlx::query_scalar("SELECT COUNT(*) FROM results") - .fetch_one(pool) - .await - .unwrap_or(0), + Database::Postgres(pool) => { + sqlx::query_scalar("SELECT COUNT(*) FROM results") + .fetch_one(pool) + .await? + } }; let total_pages = if total_results > 0 { @@ -69,44 +73,38 @@ pub async fn results_list( let results = match &state.db { #[cfg(feature = "sqlite")] - Database::Sqlite(pool) => sqlx::query_as::<_, SpeedTestResult>( - "SELECT * FROM results ORDER BY created_at DESC LIMIT ? OFFSET ?", - ) - .bind(params.per_page) - .bind(offset) - .fetch_all(pool) - .await - .unwrap_or_else(|e| { - tracing::error!("Failed to fetch results: {}", e); - Vec::new() - }), + Database::Sqlite(pool) => { + sqlx::query_as::<_, SpeedTestResult>( + "SELECT * FROM results ORDER BY created_at DESC LIMIT ? OFFSET ?", + ) + .bind(params.per_page) + .bind(offset) + .fetch_all(pool) + .await? + } #[cfg(feature = "mysql")] - Database::MySql(pool) => sqlx::query_as::<_, SpeedTestResult>( - "SELECT * FROM results ORDER BY created_at DESC LIMIT ? OFFSET ?", - ) - .bind(params.per_page) - .bind(offset) - .fetch_all(pool) - .await - .unwrap_or_else(|e| { - tracing::error!("Failed to fetch results: {}", e); - Vec::new() - }), + Database::MySql(pool) => { + sqlx::query_as::<_, SpeedTestResult>( + "SELECT * FROM results ORDER BY created_at DESC LIMIT ? OFFSET ?", + ) + .bind(params.per_page) + .bind(offset) + .fetch_all(pool) + .await? + } #[cfg(feature = "postgres")] - Database::Postgres(pool) => sqlx::query_as::<_, SpeedTestResult>( - "SELECT * FROM results ORDER BY created_at DESC LIMIT $1 OFFSET $2", - ) - .bind(params.per_page) - .bind(offset) - .fetch_all(pool) - .await - .unwrap_or_else(|e| { - tracing::error!("Failed to fetch results: {}", e); - Vec::new() - }), + Database::Postgres(pool) => { + sqlx::query_as::<_, SpeedTestResult>( + "SELECT * FROM results ORDER BY created_at DESC LIMIT $1 OFFSET $2", + ) + .bind(params.per_page) + .bind(offset) + .fetch_all(pool) + .await? + } }; - let template = ResultsListTemplate { + Ok(HtmlTemplate(ResultsListTemplate { locale: locale.0, results, page: params.page, @@ -114,16 +112,7 @@ pub async fn results_list( total_results, total_pages, is_authenticated: true, - }; - - match template.render() { - Ok(html) => Html(html).into_response(), - Err(err) => ( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - err.to_string(), - ) - .into_response(), - } + })) } #[derive(Deserialize)] @@ -134,7 +123,7 @@ pub struct DeleteResultsForm { pub async fn delete_results( State(state): State, Form(form): Form, -) -> impl IntoResponse { +) -> Result { let ids: Vec = form .ids .split(',') @@ -142,38 +131,38 @@ pub async fn delete_results( .collect(); if ids.is_empty() { - return Redirect::to("/admin/results"); + return Ok(Redirect::to("/admin/results")); } match &state.db { #[cfg(feature = "sqlite")] Database::Sqlite(pool) => { for id in ids { - let _ = sqlx::query("DELETE FROM results WHERE id = ?") + sqlx::query("DELETE FROM results WHERE id = ?") .bind(id) .execute(pool) - .await; + .await?; } } #[cfg(feature = "mysql")] Database::MySql(pool) => { for id in ids { - let _ = sqlx::query("DELETE FROM results WHERE id = ?") + sqlx::query("DELETE FROM results WHERE id = ?") .bind(id) .execute(pool) - .await; + .await?; } } #[cfg(feature = "postgres")] Database::Postgres(pool) => { for id in ids { - let _ = sqlx::query("DELETE FROM results WHERE id = $1") + sqlx::query("DELETE FROM results WHERE id = $1") .bind(id) .execute(pool) - .await; + .await?; } } } - Redirect::to("/admin/results") + Ok(Redirect::to("/admin/results")) } diff --git a/src/handlers/schedules.rs b/src/handlers/schedules.rs index 227de6eb4..f3406d47e 100644 --- a/src/handlers/schedules.rs +++ b/src/handlers/schedules.rs @@ -1,9 +1,10 @@ +use crate::error::{AppError, HtmlTemplate}; use crate::locale_middleware::Locale; use crate::{db::Database, filters, models::Schedule, AppState}; use askama::Template; use axum::{ extract::{Query, State}, - response::{Html, IntoResponse, Redirect, Response}, + response::{IntoResponse, Redirect}, Form, }; use serde::Deserialize; @@ -22,7 +23,7 @@ pub async fn schedules_page( State(state): State, locale: Locale, Query(params): Query>, -) -> Response { +) -> Result { let message = if params.contains_key("created") { Some("Schedule created successfully!".to_string()) } else if params.contains_key("updated") { @@ -40,22 +41,19 @@ pub async fn schedules_page( Database::Sqlite(pool) => { sqlx::query_as::<_, Schedule>("SELECT * FROM schedules ORDER BY created_at DESC") .fetch_all(pool) - .await - .unwrap_or_default() + .await? } #[cfg(feature = "mysql")] Database::MySql(pool) => { sqlx::query_as::<_, Schedule>("SELECT * FROM schedules ORDER BY created_at DESC") .fetch_all(pool) - .await - .unwrap_or_default() + .await? } #[cfg(feature = "postgres")] Database::Postgres(pool) => { sqlx::query_as::<_, Schedule>("SELECT * FROM schedules ORDER BY created_at DESC") .fetch_all(pool) - .await - .unwrap_or_default() + .await? } }; @@ -69,14 +67,7 @@ pub async fn schedules_page( is_authenticated: true, }; - match template.render() { - Ok(html) => Html(html).into_response(), - Err(err) => ( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - err.to_string(), - ) - .into_response(), - } + Ok(HtmlTemplate(template)) } #[derive(Deserialize)] @@ -90,7 +81,7 @@ pub struct CreateScheduleForm { pub async fn create_schedule( State(state): State, Form(form): Form, -) -> impl IntoResponse { +) -> Result { let enabled = form.enabled.is_some(); let server_ids = form.server_ids.filter(|s| !s.trim().is_empty()); @@ -134,9 +125,9 @@ pub async fn create_schedule( }; if success { - Redirect::to("/admin/schedules?created=1") + Ok(Redirect::to("/admin/schedules?created=1")) } else { - Redirect::to("/admin/schedules?error=1") + Ok(Redirect::to("/admin/schedules?error=1")) } } @@ -148,7 +139,7 @@ pub struct DeleteScheduleForm { pub async fn delete_schedule( State(state): State, Form(form): Form, -) -> impl IntoResponse { +) -> Result { let success = match &state.db { #[cfg(feature = "sqlite")] Database::Sqlite(pool) => sqlx::query("DELETE FROM schedules WHERE id = ?") @@ -171,9 +162,9 @@ pub async fn delete_schedule( }; if success { - Redirect::to("/admin/schedules?deleted=1") + Ok(Redirect::to("/admin/schedules?deleted=1")) } else { - Redirect::to("/admin/schedules?error=1") + Ok(Redirect::to("/admin/schedules?error=1")) } } @@ -185,7 +176,7 @@ pub struct ToggleScheduleForm { pub async fn toggle_schedule( State(state): State, Form(form): Form, -) -> impl IntoResponse { +) -> Result { let success = match &state.db { #[cfg(feature = "sqlite")] Database::Sqlite(pool) => sqlx::query( @@ -214,8 +205,8 @@ pub async fn toggle_schedule( }; if success { - Redirect::to("/admin/schedules?updated=1") + Ok(Redirect::to("/admin/schedules?updated=1")) } else { - Redirect::to("/admin/schedules?error=1") + Ok(Redirect::to("/admin/schedules?error=1")) } } diff --git a/src/handlers/speedtest.rs b/src/handlers/speedtest.rs index a7518aea4..161bb4d29 100644 --- a/src/handlers/speedtest.rs +++ b/src/handlers/speedtest.rs @@ -1,10 +1,11 @@ +use crate::error::{AppError, HtmlTemplate}; use crate::locale_middleware::Locale; use crate::{db::Database, filters, models::Result as SpeedTestResult, AppState}; use askama::Template; use axum::{ extract::State, http::StatusCode, - response::{Html, IntoResponse, Response}, + response::{IntoResponse, Response}, Form, Json, }; @@ -17,20 +18,14 @@ pub struct RunTestTemplate { } #[axum::debug_handler] -pub async fn run_test_page(locale: Locale) -> Response { - // Fetch server list (can be cached in production) +pub async fn run_test_page(locale: Locale) -> Result { let servers = crate::api::fetch_ookla_servers().await.unwrap_or_default(); - let template = RunTestTemplate { + Ok(HtmlTemplate(RunTestTemplate { locale: locale.0, - servers: servers.into_iter().take(50).collect(), // Limit to top 50 + servers: servers.into_iter().take(50).collect(), is_authenticated: true, - }; - - match template.render() { - Ok(html) => Html(html).into_response(), - Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(), - } + })) } #[derive(serde::Deserialize)] @@ -41,8 +36,7 @@ pub struct RunTestForm { pub async fn run_test_execute( State(state): State, Form(form): Form, -) -> Response { - // Parse server_id +) -> Result { let server_id = form .server_id .and_then(|s| if s.is_empty() { None } else { Some(s) }) @@ -50,82 +44,52 @@ pub async fn run_test_execute( tracing::info!("Manual speedtest requested with server_id: {:?}", server_id); - // Run speedtest - let result = match crate::speedtest::run_speedtest(server_id).await { - Ok(r) => r, - Err(e) => { + let result = crate::speedtest::run_speedtest(server_id) + .await + .map_err(|e| { tracing::error!("Speedtest execution failed: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": e - })), - ) - .into_response(); - } - }; + AppError::from(anyhow::anyhow!("{}", e)) + })?; - // Save to database - let result_id = match crate::speedtest::save_result(&state.db, result, false).await { - Ok(id) => id, - Err(e) => { + let result_id = crate::speedtest::save_result(&state.db, result, false) + .await + .map_err(|e| { tracing::error!("Failed to save speedtest result: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": format!("Test completed but failed to save: {}", e) - })), - ) - .into_response(); - } - }; + AppError::from(anyhow::anyhow!("Test completed but failed to save: {}", e)) + })?; - // Fetch the saved result to return let saved_result = match &state.db { #[cfg(feature = "sqlite")] Database::Sqlite(pool) => { sqlx::query_as::<_, SpeedTestResult>("SELECT * FROM results WHERE id = ?") .bind(result_id) .fetch_one(pool) - .await + .await? } #[cfg(feature = "mysql")] Database::MySql(pool) => { sqlx::query_as::<_, SpeedTestResult>("SELECT * FROM results WHERE id = ?") .bind(result_id) .fetch_one(pool) - .await + .await? } #[cfg(feature = "postgres")] Database::Postgres(pool) => { sqlx::query_as::<_, SpeedTestResult>("SELECT * FROM results WHERE id = $1") .bind(result_id) .fetch_one(pool) - .await + .await? } }; - match saved_result { - Ok(result) => ( - StatusCode::OK, - Json(serde_json::json!({ - "id": result.id, - "download_mbps": format!("{:.2}", result.download_mbps()), - "upload_mbps": format!("{:.2}", result.upload_mbps()), - "ping": format!("{:.1}", result.ping.unwrap_or(0.0)) - })), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to fetch saved result: {}", e); - ( - StatusCode::OK, - Json(serde_json::json!({ - "id": result_id, - "message": "Test completed successfully" - })), - ) - .into_response() - } - } + Ok(( + StatusCode::OK, + Json(serde_json::json!({ + "id": saved_result.id, + "download_mbps": format!("{:.2}", saved_result.download_mbps()), + "upload_mbps": format!("{:.2}", saved_result.upload_mbps()), + "ping": format!("{:.1}", saved_result.ping.unwrap_or(0.0)) + })), + ) + .into_response()) } diff --git a/src/handlers/tokens.rs b/src/handlers/tokens.rs index 33108a42e..dc8fd6670 100644 --- a/src/handlers/tokens.rs +++ b/src/handlers/tokens.rs @@ -1,9 +1,10 @@ +use crate::error::{AppError, HtmlTemplate}; use crate::locale_middleware::Locale; use crate::{db::Database, filters, models::PersonalAccessToken, AppState}; use askama::Template; use axum::{ extract::{Query, State}, - response::{Html, IntoResponse, Redirect, Response}, + response::{IntoResponse, Redirect, Response}, Form, }; use rand::RngExt; @@ -24,7 +25,7 @@ pub async fn api_tokens_page( State(state): State, locale: Locale, Query(params): Query>, -) -> Response { +) -> Result { let message = if params.contains_key("deleted") { Some("Token deleted successfully!".to_string()) } else if params.contains_key("error") { @@ -38,26 +39,29 @@ pub async fn api_tokens_page( let tokens = match &state.db { #[cfg(feature = "sqlite")] - Database::Sqlite(pool) => sqlx::query_as::<_, PersonalAccessToken>( - "SELECT * FROM personal_access_tokens ORDER BY created_at DESC", - ) - .fetch_all(pool) - .await - .unwrap_or_default(), + Database::Sqlite(pool) => { + sqlx::query_as::<_, PersonalAccessToken>( + "SELECT * FROM personal_access_tokens ORDER BY created_at DESC", + ) + .fetch_all(pool) + .await? + } #[cfg(feature = "mysql")] - Database::MySql(pool) => sqlx::query_as::<_, PersonalAccessToken>( - "SELECT * FROM personal_access_tokens ORDER BY created_at DESC", - ) - .fetch_all(pool) - .await - .unwrap_or_default(), + Database::MySql(pool) => { + sqlx::query_as::<_, PersonalAccessToken>( + "SELECT * FROM personal_access_tokens ORDER BY created_at DESC", + ) + .fetch_all(pool) + .await? + } #[cfg(feature = "postgres")] - Database::Postgres(pool) => sqlx::query_as::<_, PersonalAccessToken>( - "SELECT * FROM personal_access_tokens ORDER BY created_at DESC", - ) - .fetch_all(pool) - .await - .unwrap_or_default(), + Database::Postgres(pool) => { + sqlx::query_as::<_, PersonalAccessToken>( + "SELECT * FROM personal_access_tokens ORDER BY created_at DESC", + ) + .fetch_all(pool) + .await? + } }; let template = ApiTokensTemplate { @@ -69,17 +73,13 @@ pub async fn api_tokens_page( is_authenticated: true, }; - match template.render() { - Ok(html) => Html(html).into_response(), - Err(err) => ( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - err.to_string(), - ) - .into_response(), - } + Ok(HtmlTemplate(template)) } -pub async fn create_token(State(state): State, body: String) -> Response { +pub async fn create_token( + State(state): State, + body: String, +) -> Result { use sha2::{Digest, Sha256}; // Parse form manually to handle duplicate keys @@ -126,8 +126,7 @@ pub async fn create_token(State(state): State, body: String) -> Respon let user_id: i64 = sqlx::query_scalar("SELECT id FROM users WHERE role = 'admin' LIMIT 1") .fetch_one(pool) - .await - .unwrap_or(1); + .await?; let result = sqlx::query( "INSERT INTO personal_access_tokens @@ -149,8 +148,7 @@ pub async fn create_token(State(state): State, body: String) -> Respon let user_id: i64 = sqlx::query_scalar("SELECT id FROM users WHERE role = 'admin' LIMIT 1") .fetch_one(pool) - .await - .unwrap_or(1); + .await?; let result = sqlx::query( "INSERT INTO personal_access_tokens @@ -172,8 +170,7 @@ pub async fn create_token(State(state): State, body: String) -> Respon let user_id: i64 = sqlx::query_scalar("SELECT id FROM users WHERE role = 'admin' LIMIT 1") .fetch_one(pool) - .await - .unwrap_or(1); + .await?; let result = sqlx::query( "INSERT INTO personal_access_tokens @@ -189,7 +186,7 @@ pub async fn create_token(State(state): State, body: String) -> Respon .await; result.is_ok() } - _ => return Redirect::to("/admin/api-tokens").into_response(), + _ => return Ok(Redirect::to("/admin/api-tokens")), }; if success { @@ -199,9 +196,9 @@ pub async fn create_token(State(state): State, body: String) -> Respon urlencoding::encode(&token), urlencoding::encode(&name) ); - Redirect::to(&redirect_url).into_response() + Ok(Redirect::to(&redirect_url)) } else { - Redirect::to("/admin/api-tokens?error=1").into_response() + Ok(Redirect::to("/admin/api-tokens?error=1")) } } @@ -213,7 +210,7 @@ pub struct DeleteTokenForm { pub async fn delete_token( State(state): State, Form(form): Form, -) -> Response { +) -> Result { let success = match &state.db { #[cfg(feature = "sqlite")] Database::Sqlite(pool) => sqlx::query("DELETE FROM personal_access_tokens WHERE id = ?") @@ -236,9 +233,9 @@ pub async fn delete_token( }; if success { - Redirect::to("/admin/api-tokens?deleted=1").into_response() + Ok(Redirect::to("/admin/api-tokens?deleted=1")) } else { - Redirect::to("/admin/api-tokens?error=1").into_response() + Ok(Redirect::to("/admin/api-tokens?error=1")) } } @@ -255,7 +252,7 @@ pub async fn edit_token_page( State(state): State, locale: Locale, axum::extract::Path(token_id): axum::extract::Path, -) -> Response { +) -> Result { let token = match &state.db { #[cfg(feature = "sqlite")] Database::Sqlite(pool) => { @@ -264,7 +261,7 @@ pub async fn edit_token_page( ) .bind(token_id) .fetch_optional(pool) - .await + .await? } #[cfg(feature = "mysql")] Database::MySql(pool) => { @@ -273,7 +270,7 @@ pub async fn edit_token_page( ) .bind(token_id) .fetch_optional(pool) - .await + .await? } #[cfg(feature = "postgres")] Database::Postgres(pool) => { @@ -282,32 +279,28 @@ pub async fn edit_token_page( ) .bind(token_id) .fetch_optional(pool) - .await + .await? } }; match token { - Ok(Some(token)) => { + Some(token) => { let template = EditTokenTemplate { locale: locale.0, token, error: None, is_authenticated: true, }; - match template.render() { - Ok(html) => Html(html).into_response(), - Err(err) => ( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - err.to_string(), - ) - .into_response(), - } + Ok(HtmlTemplate(template).into_response()) } - _ => Redirect::to("/admin/api-tokens").into_response(), + None => Ok(Redirect::to("/admin/api-tokens").into_response()), } } -pub async fn update_token(State(state): State, body: String) -> Response { +pub async fn update_token( + State(state): State, + body: String, +) -> Result { let mut token_id: Option = None; let mut name = String::new(); let mut abilities: Vec = Vec::new(); @@ -329,7 +322,7 @@ pub async fn update_token(State(state): State, body: String) -> Respon } let Some(id) = token_id else { - return Redirect::to("/admin/api-tokens?error=1").into_response(); + return Ok(Redirect::to("/admin/api-tokens?error=1")); }; let abilities_json = serde_json::to_string(&abilities).unwrap_or_else(|_| "[]".to_string()); @@ -374,8 +367,8 @@ pub async fn update_token(State(state): State, body: String) -> Respon }; if success { - Redirect::to("/admin/api-tokens?updated=1").into_response() + Ok(Redirect::to("/admin/api-tokens?updated=1")) } else { - Redirect::to("/admin/api-tokens?error=1").into_response() + Ok(Redirect::to("/admin/api-tokens?error=1")) } } diff --git a/src/lib.rs b/src/lib.rs index 4e714d59c..23a61a5ea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub mod api; pub mod auth; pub mod db; pub mod embedded_assets; +pub mod error; pub mod filters; pub mod handlers; pub mod i18n; From e1c4b9b76d4041a2d8ce317de397570bc1a5d4cd Mon Sep 17 00:00:00 2001 From: Claudio Bley Date: Sat, 27 Jun 2026 16:18:48 +0200 Subject: [PATCH 2/9] chore(rust): enable clippy warnings and fix violations Enable five lints across all crate targets (lib.rs, main.rs, create-test-user.rs): - clippy::uninlined_format_args - clippy::unreadable_literal - clippy::unused_async - clippy::manual_let_else - clippy::match_same_arms Fix all existing violations: - src/error.rs, src/handlers/speedtest.rs: inline format args (auto-fixed) - src/i18n.rs: merge duplicate match arms into single arm per language - src/handlers/profile.rs: rewrite match-with-early-return as let...else - src/bin/create-test-user.rs: add _ separators to numeric literals - src/speedtest.rs: add #[allow(clippy::unused_async)] with a comment explaining that run_speedtest uses std::process::Command and should eventually migrate to tokio::process::Command --- src/bin/create-test-user.rs | 19 +++++++++++++------ src/error.rs | 4 ++-- src/handlers/profile.rs | 5 ++--- src/handlers/speedtest.rs | 4 ++-- src/i18n.rs | 14 ++++---------- src/lib.rs | 7 +++++++ src/main.rs | 7 +++++++ src/speedtest.rs | 4 ++++ 8 files changed, 41 insertions(+), 23 deletions(-) diff --git a/src/bin/create-test-user.rs b/src/bin/create-test-user.rs index 810bd57dd..1ce17232c 100644 --- a/src/bin/create-test-user.rs +++ b/src/bin/create-test-user.rs @@ -1,6 +1,13 @@ // Utility to create a test user in the database // Only compiled when sqlite feature is enabled #![cfg(feature = "sqlite")] +#![warn( + clippy::uninlined_format_args, + clippy::unreadable_literal, + clippy::unused_async, + clippy::manual_let_else, + clippy::match_same_arms, +)] use std::env; @@ -96,8 +103,8 @@ async fn main() -> Result<(), Box> { ) .bind("ookla") .bind(15.2) - .bind(95000000_i64) - .bind(45000000_i64) + .bind(95_000_000_i64) + .bind(45_000_000_i64) .bind("completed") .bind(true) .bind("-1 hour") @@ -111,8 +118,8 @@ async fn main() -> Result<(), Box> { ) .bind("ookla") .bind(14.8) - .bind(98000000_i64) - .bind(47000000_i64) + .bind(98_000_000_i64) + .bind(47_000_000_i64) .bind("completed") .bind(true) .bind("-2 hours") @@ -126,8 +133,8 @@ async fn main() -> Result<(), Box> { ) .bind("ookla") .bind(16.1) - .bind(92000000_i64) - .bind(43000000_i64) + .bind(92_000_000_i64) + .bind(43_000_000_i64) .bind("completed") .bind(false) .bind("-3 hours") diff --git a/src/error.rs b/src/error.rs index 86f43e37c..c242f4598 100644 --- a/src/error.rs +++ b/src/error.rs @@ -11,11 +11,11 @@ pub struct AppError(StatusCode, anyhow::Error); impl AppError { pub fn not_found(msg: impl std::fmt::Display) -> Self { - AppError(StatusCode::NOT_FOUND, anyhow::anyhow!("{}", msg)) + AppError(StatusCode::NOT_FOUND, anyhow::anyhow!("{msg}")) } pub fn bad_request(msg: impl std::fmt::Display) -> Self { - AppError(StatusCode::BAD_REQUEST, anyhow::anyhow!("{}", msg)) + AppError(StatusCode::BAD_REQUEST, anyhow::anyhow!("{msg}")) } } diff --git a/src/handlers/profile.rs b/src/handlers/profile.rs index f8491de5e..85d221bc4 100644 --- a/src/handlers/profile.rs +++ b/src/handlers/profile.rs @@ -24,9 +24,8 @@ pub async fn profile_page( session: tower_sessions::Session, ) -> Result { // Get logged-in user from session - let user_id = match crate::session::get_user_id(session).await { - Some(id) => id, - None => return Ok(Redirect::to("/login").into_response()), + let Some(user_id) = crate::session::get_user_id(session).await else { + return Ok(Redirect::to("/login").into_response()); }; let user = match &state.db { diff --git a/src/handlers/speedtest.rs b/src/handlers/speedtest.rs index 161bb4d29..3d1149647 100644 --- a/src/handlers/speedtest.rs +++ b/src/handlers/speedtest.rs @@ -48,14 +48,14 @@ pub async fn run_test_execute( .await .map_err(|e| { tracing::error!("Speedtest execution failed: {}", e); - AppError::from(anyhow::anyhow!("{}", e)) + AppError::from(anyhow::anyhow!("{e}")) })?; let result_id = crate::speedtest::save_result(&state.db, result, false) .await .map_err(|e| { tracing::error!("Failed to save speedtest result: {}", e); - AppError::from(anyhow::anyhow!("Test completed but failed to save: {}", e)) + AppError::from(anyhow::anyhow!("Test completed but failed to save: {e}")) })?; let saved_result = match &state.db { diff --git a/src/i18n.rs b/src/i18n.rs index 941e39fa8..229537fe7 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -7,17 +7,11 @@ pub fn normalize_locale(lang: &str) -> String { let normalized = lang.replace('_', "-").to_lowercase(); match normalized.as_str() { - "en" | "en-us" | "en-gb" | "en-ca" | "en-au" => "en".to_string(), - "de" | "de-de" | "de-at" | "de-ch" => "de_DE".to_string(), - "es" | "es-es" | "es-mx" | "es-ar" => "es_ES".to_string(), - "fr" | "fr-fr" | "fr-ca" | "fr-be" => "fr_FR".to_string(), - "nl" | "nl-nl" | "nl-be" => "nl_NL".to_string(), + "de" | "de-de" | "de-at" | "de-ch" | "de_de" => "de_DE".to_string(), + "es" | "es-es" | "es-mx" | "es-ar" | "es_es" => "es_ES".to_string(), + "fr" | "fr-fr" | "fr-ca" | "fr-be" | "fr_fr" => "fr_FR".to_string(), + "nl" | "nl-nl" | "nl-be" | "nl_nl" => "nl_NL".to_string(), "pt-br" | "pt_br" => "pt_BR".to_string(), - // If already in our format, check if valid and return as-is - "de_de" => "de_DE".to_string(), - "es_es" => "es_ES".to_string(), - "fr_fr" => "fr_FR".to_string(), - "nl_nl" => "nl_NL".to_string(), _ => "en".to_string(), } } diff --git a/src/lib.rs b/src/lib.rs index 23a61a5ea..b56a4b9e2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,11 @@ // Library exports for testing +#![warn( + clippy::uninlined_format_args, + clippy::unreadable_literal, + clippy::unused_async, + clippy::manual_let_else, + clippy::match_same_arms, +)] pub mod api; pub mod auth; pub mod db; diff --git a/src/main.rs b/src/main.rs index 6183b859b..3da68f1e0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,11 @@ // Import all modules from the library +#![warn( + clippy::uninlined_format_args, + clippy::unreadable_literal, + clippy::unused_async, + clippy::manual_let_else, + clippy::match_same_arms, +)] use speedtest_tracker::{db, locale_middleware, scheduler, AppState}; rust_i18n::i18n!("locales", fallback = "en"); diff --git a/src/speedtest.rs b/src/speedtest.rs index d1f6206fb..bf1a22e25 100644 --- a/src/speedtest.rs +++ b/src/speedtest.rs @@ -86,6 +86,10 @@ struct OoklaResultInfo { persisted: bool, } +// The function uses std::process::Command (blocking). The async signature is +// intentional — callers treat it as async and it should be migrated to +// tokio::process::Command in the future. +#[allow(clippy::unused_async)] pub async fn run_speedtest(server_id: Option) -> Result { tracing::info!( "Starting speedtest{}", From 1e3d9795943755f3ff536c0c9391ff92074f1ee7 Mon Sep 17 00:00:00 2001 From: Claudio Bley Date: Sat, 27 Jun 2026 16:31:10 +0200 Subject: [PATCH 3/9] chore(rust): move clippy config to Cargo.toml [lints] The [lints.clippy] table in Cargo.toml (added in the previous commit) is the idiomatic approach since Rust 1.81 and applies to all crate targets automatically. --- Cargo.toml | 7 +++++++ src/bin/create-test-user.rs | 7 ------- src/lib.rs | 7 ------- src/main.rs | 7 ------- 4 files changed, 7 insertions(+), 21 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 173c37802..7fe2017bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,13 @@ lto = true codegen-units = 1 opt-level = "z" +[lints.clippy] +uninlined_format_args = "warn" +unreadable_literal = "warn" +unused_async = "warn" +manual_let_else = "warn" +match_same_arms = "warn" + [dev-dependencies] assert_float_eq = "1.2" axum-test = "20" diff --git a/src/bin/create-test-user.rs b/src/bin/create-test-user.rs index 1ce17232c..40d9e8a81 100644 --- a/src/bin/create-test-user.rs +++ b/src/bin/create-test-user.rs @@ -1,13 +1,6 @@ // Utility to create a test user in the database // Only compiled when sqlite feature is enabled #![cfg(feature = "sqlite")] -#![warn( - clippy::uninlined_format_args, - clippy::unreadable_literal, - clippy::unused_async, - clippy::manual_let_else, - clippy::match_same_arms, -)] use std::env; diff --git a/src/lib.rs b/src/lib.rs index b56a4b9e2..23a61a5ea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,11 +1,4 @@ // Library exports for testing -#![warn( - clippy::uninlined_format_args, - clippy::unreadable_literal, - clippy::unused_async, - clippy::manual_let_else, - clippy::match_same_arms, -)] pub mod api; pub mod auth; pub mod db; diff --git a/src/main.rs b/src/main.rs index 3da68f1e0..6183b859b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,4 @@ // Import all modules from the library -#![warn( - clippy::uninlined_format_args, - clippy::unreadable_literal, - clippy::unused_async, - clippy::manual_let_else, - clippy::match_same_arms, -)] use speedtest_tracker::{db, locale_middleware, scheduler, AppState}; rust_i18n::i18n!("locales", fallback = "en"); From 7ef91e22cb77477ec3dba9f41765270fc527f78a Mon Sep 17 00:00:00 2001 From: Claudio Bley Date: Sun, 28 Jun 2026 13:28:36 +0200 Subject: [PATCH 4/9] Fail compilation of test if sqlite disabled --- tests/api_tests.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/api_tests.rs b/tests/api_tests.rs index b317d999e..7b71261c1 100644 --- a/tests/api_tests.rs +++ b/tests/api_tests.rs @@ -6,6 +6,9 @@ use sha2::Digest; use speedtest_tracker::{create_app, AppState, Database}; use std::env; +#[cfg(not(feature = "sqlite"))] +compile_error!("Tests require feature `sqlite` to be enabled."); + /// Helper function to create a test database and app state async fn setup_test_app(test_name: &str) -> TestServer { // Set up test database URL (in-memory SQLite for tests) @@ -59,7 +62,6 @@ async fn create_test_result(state: &AppState) -> i64 { "#; match &state.db { - #[cfg(feature = "sqlite")] Database::Sqlite(pool) => { let effected = sqlx::query(query) .execute(pool) @@ -84,7 +86,6 @@ async fn create_test_token(state: &AppState, abilities: &str) -> String { "#; match &state.db { - #[cfg(feature = "sqlite")] Database::Sqlite(pool) => { sqlx::query(query) .bind(&token_hash) From d1abeec7789c10f2a8918c9a69f15765b4526989 Mon Sep 17 00:00:00 2001 From: Claudio Bley Date: Sun, 28 Jun 2026 13:47:40 +0200 Subject: [PATCH 5/9] Change Rust edition 2021 -> 2024 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 7fe2017bd..16d42c046 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "speedtest-tracker" version = "0.1.0" -edition = "2021" +edition = "2024" default-run = "speedtest-tracker" [[bin]] From 3b6ee10c7e6da71c888de4365f63a5b1a06b281d Mon Sep 17 00:00:00 2001 From: Claudio Bley Date: Sun, 28 Jun 2026 13:51:23 +0200 Subject: [PATCH 6/9] Fix clippy warnings --- build.rs | 5 ++--- src/embedded_assets.rs | 5 ++--- src/speedtest.rs | 4 ++-- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/build.rs b/build.rs index 90a2ed43c..9a0f94fde 100644 --- a/build.rs +++ b/build.rs @@ -115,14 +115,13 @@ fn hash_templates_recursive(dir: &str, hasher: &mut DefaultHasher) { if let Some(path_str) = path.to_str() { hash_templates_recursive(path_str, hasher); } - } else if path.extension().is_some_and(|ext| ext == "html") { - if let Ok(mut file) = fs::File::open(&path) { + } else if path.extension().is_some_and(|ext| ext == "html") + && let Ok(mut file) = fs::File::open(&path) { let mut contents = Vec::new(); if file.read_to_end(&mut contents).is_ok() { contents.hash(hasher); } } - } } } } diff --git a/src/embedded_assets.rs b/src/embedded_assets.rs index 8f62263fe..ebb088cd0 100644 --- a/src/embedded_assets.rs +++ b/src/embedded_assets.rs @@ -50,8 +50,8 @@ where ); // Add Last-Modified if available - if let Some(last_modified) = content.metadata.last_modified() { - if let Some(system_time) = + if let Some(last_modified) = content.metadata.last_modified() + && let Some(system_time) = std::time::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(last_modified)) { let datetime = httpdate::fmt_http_date(system_time); @@ -60,7 +60,6 @@ where HeaderValue::from_str(&datetime).unwrap(), ); } - } builder.body(Body::from(content.data)).unwrap() } diff --git a/src/speedtest.rs b/src/speedtest.rs index bf1a22e25..f5859d968 100644 --- a/src/speedtest.rs +++ b/src/speedtest.rs @@ -201,7 +201,7 @@ pub async fn save_result( .bind(scheduled) .execute(pool) .await - .map_err(|e| format!("Failed to save result: {}", e))? + .map_err(|e| format!("Failed to save result: {e}"))? .last_insert_id() as i64 }, #[cfg(feature = "postgres")] @@ -223,7 +223,7 @@ pub async fn save_result( .bind(scheduled) .fetch_one(pool) .await - .map_err(|e| format!("Failed to save result: {}", e))? + .map_err(|e| format!("Failed to save result: {e}"))? }, }; From 43986a3acbfff51be24d1353354fb21c383be725 Mon Sep 17 00:00:00 2001 From: Claudio Bley Date: Sun, 28 Jun 2026 16:02:04 +0200 Subject: [PATCH 7/9] flake: Configure clippy * enable all-features * run an all targets --- flake.nix | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/flake.nix b/flake.nix index c144f14fd..89e9cc34e 100644 --- a/flake.nix +++ b/flake.nix @@ -221,6 +221,11 @@ actionlint.enable = true; clippy = { enable = true; + settings = { + allFeatures = true; + denyWarnings = true; + extraArgs = "--all-targets"; + }; packageOverrides = { inherit (fx.stable) cargo clippy; }; From 722b5ae4cdf706adc1ae99d37a1336cf1266e68d Mon Sep 17 00:00:00 2001 From: Claudio Bley Date: Sun, 28 Jun 2026 16:19:28 +0200 Subject: [PATCH 8/9] Rustfmt --- build.rs | 11 ++++++----- src/api.rs | 2 +- src/auth.rs | 8 ++++---- src/embedded_assets.rs | 16 ++++++++-------- src/handlers/auth.rs | 4 ++-- src/handlers/dashboard.rs | 2 +- src/handlers/dashboard_admin.rs | 2 +- src/handlers/language.rs | 4 ++-- src/handlers/profile.rs | 4 ++-- src/handlers/results.rs | 4 ++-- src/handlers/schedules.rs | 4 ++-- src/handlers/speedtest.rs | 4 ++-- src/handlers/tokens.rs | 4 ++-- src/lib.rs | 2 +- src/locale_middleware.rs | 2 +- src/main.rs | 2 +- tests/api_tests.rs | 4 ++-- 17 files changed, 40 insertions(+), 39 deletions(-) diff --git a/build.rs b/build.rs index 9a0f94fde..1ee50804a 100644 --- a/build.rs +++ b/build.rs @@ -116,12 +116,13 @@ fn hash_templates_recursive(dir: &str, hasher: &mut DefaultHasher) { hash_templates_recursive(path_str, hasher); } } else if path.extension().is_some_and(|ext| ext == "html") - && let Ok(mut file) = fs::File::open(&path) { - let mut contents = Vec::new(); - if file.read_to_end(&mut contents).is_ok() { - contents.hash(hasher); - } + && let Ok(mut file) = fs::File::open(&path) + { + let mut contents = Vec::new(); + if file.read_to_end(&mut contents).is_ok() { + contents.hash(hasher); } + } } } } diff --git a/src/api.rs b/src/api.rs index 4e7a3e0f9..6d0b032b7 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,5 +1,5 @@ use crate::error::AppError; -use crate::{db::Database, models::Result as SpeedTestResult, AppState}; +use crate::{AppState, db::Database, models::Result as SpeedTestResult}; use axum::{ extract::{Path, Query, State}, http::StatusCode, diff --git a/src/auth.rs b/src/auth.rs index 6319af73e..d11483e84 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,18 +1,18 @@ use axum::{ + Json, extract::{FromRequestParts, Request, State}, - http::{request::Parts, StatusCode}, + http::{StatusCode, request::Parts}, middleware::Next, response::Response, - Json, }; use axum_extra::{ - headers::{authorization::Bearer, Authorization}, TypedHeader, + headers::{Authorization, authorization::Bearer}, }; use serde::Serialize; use sha2::{Digest, Sha256}; -use crate::{db::Database, models::PersonalAccessToken, AppState}; +use crate::{AppState, db::Database, models::PersonalAccessToken}; #[derive(Serialize)] pub struct ErrorResponse { diff --git a/src/embedded_assets.rs b/src/embedded_assets.rs index ebb088cd0..8573ce7e2 100644 --- a/src/embedded_assets.rs +++ b/src/embedded_assets.rs @@ -1,6 +1,6 @@ use axum::{ body::Body, - http::{header, HeaderValue, Response, StatusCode}, + http::{HeaderValue, Response, StatusCode, header}, response::IntoResponse, }; use rust_embed::RustEmbed; @@ -53,13 +53,13 @@ where if let Some(last_modified) = content.metadata.last_modified() && let Some(system_time) = std::time::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(last_modified)) - { - let datetime = httpdate::fmt_http_date(system_time); - builder = builder.header( - header::LAST_MODIFIED, - HeaderValue::from_str(&datetime).unwrap(), - ); - } + { + let datetime = httpdate::fmt_http_date(system_time); + builder = builder.header( + header::LAST_MODIFIED, + HeaderValue::from_str(&datetime).unwrap(), + ); + } builder.body(Body::from(content.data)).unwrap() } diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index 66f360c26..708620135 100644 --- a/src/handlers/auth.rs +++ b/src/handlers/auth.rs @@ -1,11 +1,11 @@ use crate::error::{AppError, HtmlTemplate}; use crate::locale_middleware::Locale; -use crate::{db::Database, filters, AppState}; +use crate::{AppState, db::Database, filters}; use askama::Template; use axum::{ + Form, extract::State, response::{IntoResponse, Redirect, Response}, - Form, }; use serde::Deserialize; diff --git a/src/handlers/dashboard.rs b/src/handlers/dashboard.rs index 074da2685..ec2d7155b 100644 --- a/src/handlers/dashboard.rs +++ b/src/handlers/dashboard.rs @@ -1,6 +1,6 @@ use crate::error::{AppError, HtmlTemplate}; use crate::locale_middleware::Locale; -use crate::{db::Database, filters, models::Result as SpeedTestResult, AppState}; +use crate::{AppState, db::Database, filters, models::Result as SpeedTestResult}; use askama::Template; use axum::{ extract::{Query, State}, diff --git a/src/handlers/dashboard_admin.rs b/src/handlers/dashboard_admin.rs index e456fbcab..3147bce8c 100644 --- a/src/handlers/dashboard_admin.rs +++ b/src/handlers/dashboard_admin.rs @@ -1,6 +1,6 @@ use crate::error::{AppError, HtmlTemplate}; use crate::locale_middleware::Locale; -use crate::{db::Database, filters, models::Result as SpeedTestResult, AppState}; +use crate::{AppState, db::Database, filters, models::Result as SpeedTestResult}; use askama::Template; use axum::{extract::State, response::IntoResponse}; diff --git a/src/handlers/language.rs b/src/handlers/language.rs index 7fd1a6ab9..de30c0612 100644 --- a/src/handlers/language.rs +++ b/src/handlers/language.rs @@ -1,10 +1,10 @@ use axum::{ extract::Path, http::HeaderMap, - http::{header::REFERER, Uri}, + http::{Uri, header::REFERER}, response::{IntoResponse, Redirect}, }; -use axum_extra::extract::{cookie::Cookie, CookieJar}; +use axum_extra::extract::{CookieJar, cookie::Cookie}; /// Handle language change requests /// Route: GET /set-language/:locale diff --git a/src/handlers/profile.rs b/src/handlers/profile.rs index 85d221bc4..d9025f83c 100644 --- a/src/handlers/profile.rs +++ b/src/handlers/profile.rs @@ -1,11 +1,11 @@ use crate::error::{AppError, HtmlTemplate}; use crate::locale_middleware::Locale; -use crate::{db::Database, filters, AppState}; +use crate::{AppState, db::Database, filters}; use askama::Template; use axum::{ + Form, extract::State, response::{IntoResponse, Redirect, Response}, - Form, }; use serde::Deserialize; diff --git a/src/handlers/results.rs b/src/handlers/results.rs index cdeeb1b11..dd745cd30 100644 --- a/src/handlers/results.rs +++ b/src/handlers/results.rs @@ -1,11 +1,11 @@ use crate::error::{AppError, HtmlTemplate}; use crate::locale_middleware::Locale; -use crate::{db::Database, filters, models::Result as SpeedTestResult, AppState}; +use crate::{AppState, db::Database, filters, models::Result as SpeedTestResult}; use askama::Template; use axum::{ + Form, extract::{Query, State}, response::{IntoResponse, Redirect}, - Form, }; use serde::Deserialize; diff --git a/src/handlers/schedules.rs b/src/handlers/schedules.rs index f3406d47e..dadbce69d 100644 --- a/src/handlers/schedules.rs +++ b/src/handlers/schedules.rs @@ -1,11 +1,11 @@ use crate::error::{AppError, HtmlTemplate}; use crate::locale_middleware::Locale; -use crate::{db::Database, filters, models::Schedule, AppState}; +use crate::{AppState, db::Database, filters, models::Schedule}; use askama::Template; use axum::{ + Form, extract::{Query, State}, response::{IntoResponse, Redirect}, - Form, }; use serde::Deserialize; diff --git a/src/handlers/speedtest.rs b/src/handlers/speedtest.rs index 3d1149647..4138d86bc 100644 --- a/src/handlers/speedtest.rs +++ b/src/handlers/speedtest.rs @@ -1,12 +1,12 @@ use crate::error::{AppError, HtmlTemplate}; use crate::locale_middleware::Locale; -use crate::{db::Database, filters, models::Result as SpeedTestResult, AppState}; +use crate::{AppState, db::Database, filters, models::Result as SpeedTestResult}; use askama::Template; use axum::{ + Form, Json, extract::State, http::StatusCode, response::{IntoResponse, Response}, - Form, Json, }; #[derive(Template)] diff --git a/src/handlers/tokens.rs b/src/handlers/tokens.rs index dc8fd6670..6ce489ca9 100644 --- a/src/handlers/tokens.rs +++ b/src/handlers/tokens.rs @@ -1,11 +1,11 @@ use crate::error::{AppError, HtmlTemplate}; use crate::locale_middleware::Locale; -use crate::{db::Database, filters, models::PersonalAccessToken, AppState}; +use crate::{AppState, db::Database, filters, models::PersonalAccessToken}; use askama::Template; use axum::{ + Form, extract::{Query, State}, response::{IntoResponse, Redirect, Response}, - Form, }; use rand::RngExt; use serde::Deserialize; diff --git a/src/lib.rs b/src/lib.rs index 23a61a5ea..98b0a3d75 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,11 +20,11 @@ pub use db::Database; use axum::http::StatusCode; use axum::{ + Router, extract::Request, middleware::{self, Next}, response::{Json, Response}, routing::{get, post}, - Router, }; use tower_http::trace::TraceLayer; diff --git a/src/locale_middleware.rs b/src/locale_middleware.rs index 1cbace483..fa5866884 100644 --- a/src/locale_middleware.rs +++ b/src/locale_middleware.rs @@ -1,7 +1,7 @@ /// Middleware for locale detection and i18n setup use axum::{ extract::{FromRequestParts, Request}, - http::{request::Parts, HeaderMap, StatusCode}, + http::{HeaderMap, StatusCode, request::Parts}, middleware::Next, response::Response, }; diff --git a/src/main.rs b/src/main.rs index 6183b859b..ec733b284 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,5 @@ // Import all modules from the library -use speedtest_tracker::{db, locale_middleware, scheduler, AppState}; +use speedtest_tracker::{AppState, db, locale_middleware, scheduler}; rust_i18n::i18n!("locales", fallback = "en"); diff --git a/tests/api_tests.rs b/tests/api_tests.rs index 7b71261c1..05be20cfa 100644 --- a/tests/api_tests.rs +++ b/tests/api_tests.rs @@ -1,9 +1,9 @@ use assert_float_eq::assert_float_absolute_eq; -use axum_test::http::{HeaderName, HeaderValue}; use axum_test::TestServer; +use axum_test::http::{HeaderName, HeaderValue}; use serde_json::Value; use sha2::Digest; -use speedtest_tracker::{create_app, AppState, Database}; +use speedtest_tracker::{AppState, Database, create_app}; use std::env; #[cfg(not(feature = "sqlite"))] From 17f6183ca11fe89c4877e7d5c1cd237f305f7f44 Mon Sep 17 00:00:00 2001 From: Claudio Bley Date: Sun, 28 Jun 2026 16:22:46 +0200 Subject: [PATCH 9/9] Fix clippy warnings --- tests/api_tests.rs | 25 ++++++++++++++----------- tests/cache_headers_test.rs | 3 +-- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/tests/api_tests.rs b/tests/api_tests.rs index 05be20cfa..13f6fa902 100644 --- a/tests/api_tests.rs +++ b/tests/api_tests.rs @@ -14,7 +14,10 @@ async fn setup_test_app(test_name: &str) -> TestServer { // Set up test database URL (in-memory SQLite for tests) let database_url: String = format!("sqlite:file:{test_name}?mode=memory&cache=shared"); - env::set_var("SESSION_SECRET", "test-secret-key-32-characters!!"); + unsafe { + // FIXME + env::set_var("SESSION_SECRET", "test-secret-key-32-characters!!"); + } // Create database connection let db = Database::connect(&database_url) @@ -154,7 +157,7 @@ mod api_endpoint_tests { .as_number() .and_then(|f| f.as_f64()) .unwrap(), - 987.654312 + 987.654_312 ); // Converted to Mbps assert_eq!(json["data"]["upload"], 50.2); // Converted to Mbps } @@ -199,7 +202,7 @@ mod api_endpoint_tests { .get("/api/v1/results") .add_header( HeaderName::from_static("authorization"), - HeaderValue::from_str(&format!("Bearer {}", token)).unwrap(), + HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), ) .await; @@ -226,7 +229,7 @@ mod api_endpoint_tests { .get("/api/v1/results/latest") .add_header( HeaderName::from_static("authorization"), - HeaderValue::from_str(&format!("Bearer {}", token)).unwrap(), + HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), ) .await; @@ -262,7 +265,7 @@ mod api_endpoint_tests { .get("/api/v1/results/99999") .add_header( HeaderName::from_static("authorization"), - HeaderValue::from_str(&format!("Bearer {}", token)).unwrap(), + HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), ) .await; @@ -280,10 +283,10 @@ mod api_endpoint_tests { let server = TestServer::new(app); let response = server - .get(&format!("/api/v1/results/{}", result_id)) + .get(&format!("/api/v1/results/{result_id}")) .add_header( HeaderName::from_static("authorization"), - HeaderValue::from_str(&format!("Bearer {}", token)).unwrap(), + HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), ) .await; @@ -319,7 +322,7 @@ mod api_endpoint_tests { .get("/api/v1/stats") .add_header( HeaderName::from_static("authorization"), - HeaderValue::from_str(&format!("Bearer {}", token)).unwrap(), + HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), ) .await; @@ -360,7 +363,7 @@ mod api_endpoint_tests { .get("/api/v1/results?page=1&per_page=5") .add_header( HeaderName::from_static("authorization"), - HeaderValue::from_str(&format!("Bearer {}", token)).unwrap(), + HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), ) .await; @@ -401,7 +404,7 @@ mod api_endpoint_tests { .get("/api/v1/results") .add_header( HeaderName::from_static("authorization"), - HeaderValue::from_str(&format!("bearer {}", token)).unwrap(), + HeaderValue::from_str(&format!("bearer {token}")).unwrap(), ) .await; @@ -422,7 +425,7 @@ mod api_endpoint_tests { .get("/api/v1/results") .add_header( HeaderName::from_static("authorization"), - HeaderValue::from_str(&format!("Bearer {}", token)).unwrap(), + HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), ) .await; diff --git a/tests/cache_headers_test.rs b/tests/cache_headers_test.rs index db1bdbb47..b9f22e2b2 100644 --- a/tests/cache_headers_test.rs +++ b/tests/cache_headers_test.rs @@ -79,8 +79,7 @@ async fn test_favicon_cache_headers() { let ct = content_type.unwrap().to_str().unwrap(); assert!( ct == "image/x-icon" || ct == "image/vnd.microsoft.icon", - "Content-Type should be a valid icon MIME type, got: {}", - ct + "Content-Type should be a valid icon MIME type, got: {ct}" ); }