diff --git a/apps/server/src/middleware/auth.rs b/apps/server/src/middleware/auth.rs index 4648199f3..29aa7d77a 100644 --- a/apps/server/src/middleware/auth.rs +++ b/apps/server/src/middleware/auth.rs @@ -4,6 +4,7 @@ use axum::{ extract::{FromRef, FromRequestParts, OriginalUri}, http::{header, request::Parts, Method, StatusCode}, response::{IntoResponse, Redirect, Response}, + Json, }; use base64::{engine::general_purpose::STANDARD, Engine}; use prisma_client_rust::{ @@ -12,6 +13,10 @@ use prisma_client_rust::{ }; use stump_core::{ db::entity::{User, UserPermission}, + opds::v2_0::authentication::{ + OPDSAuthenticationDocumentBuilder, OPDSSupportedAuthFlow, + OPDS_AUTHENTICATION_DOCUMENT_REL, OPDS_AUTHENTICATION_DOCUMENT_TYPE, + }, prisma::{user, PrismaClient}, }; use tower_sessions::Session; @@ -87,8 +92,12 @@ where let Some(auth_header) = auth_header else { if is_opds { - // Prompt for basic auth on OPDS routes - return Err(BasicAuth.into_response()); + let opds_version = request_uri + .split('/') + .nth(2) + .map(|v| v.replace("v", "")) + .unwrap_or("1.2".to_string()); + return Err(OPDSBasicAuth::new(opds_version).into_response()); } else if is_swagger { // Sign in via React app and then redirect to server-side swagger-ui return Err(Redirect::to("/auth?redirect=%2Fswagger-ui/").into_response()); @@ -237,6 +246,72 @@ where } } +pub struct OPDSBasicAuth { + version: String, +} + +impl OPDSBasicAuth { + pub fn new(version: String) -> Self { + Self { version } + } +} + +impl IntoResponse for OPDSBasicAuth { + fn into_response(self) -> Response { + if self.version == "2.0" { + // TODO: investigate whether there is a better way to do this than + // piggybacking off of the IntoResponse impl of Json... + let json_repsonse = Json( + OPDSAuthenticationDocumentBuilder::default() + .description(OPDSSupportedAuthFlow::Basic.description().to_string()) + .build() + .unwrap(), + ) + .into_response(); + let body = json_repsonse.into_body(); + let body = BoxBody::from(body); + + // TODO: determine if relative paths work... + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header("Authorization", "Basic") + .header( + "WWW-Authenticate", + format!("Basic realm=\"stump OPDS v{}\"", self.version), + ) + .header( + "Content-Type", + format!("{OPDS_AUTHENTICATION_DOCUMENT_TYPE}; charset=utf-8"), + ) + .header( + "Link", + format!( + "<{}>; rel=\"{OPDS_AUTHENTICATION_DOCUMENT_REL}\"; type=\"{OPDS_AUTHENTICATION_DOCUMENT_TYPE}\"", + "/opds/v2.0/auth" + ), + ) + .body(body) + .unwrap_or_else(|e| { + tracing::error!(error = ?e, "Failed to build response"); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + }) + } else { + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header("Authorization", "Basic") + .header( + "WWW-Authenticate", + format!("Basic realm=\"stump OPDS v{}\"", self.version), + ) + .body(BoxBody::default()) + .unwrap_or_else(|e| { + tracing::error!(error = ?e, "Failed to build response"); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + }) + } + } +} + pub struct BasicAuth; impl IntoResponse for BasicAuth { diff --git a/apps/server/src/routers/opds/v2_0.rs b/apps/server/src/routers/opds/v2_0.rs index 009c8cf09..6f111c160 100644 --- a/apps/server/src/routers/opds/v2_0.rs +++ b/apps/server/src/routers/opds/v2_0.rs @@ -2,6 +2,10 @@ use axum::{ extract::State, middleware::from_extractor_with_state, routing::get, Json, Router, }; use stump_core::opds::v2_0::{ + authentication::{ + OPDSAuthenticationDocument, OPDSAuthenticationDocumentBuilder, + OPDSSupportedAuthFlow, + }, feed::{OPDSFeed, OPDSFeedBuilder}, group::OPDSFeedGroupBuilder, link::{ @@ -47,10 +51,23 @@ const DEFAULT_LIMIT: i64 = 10; pub(crate) fn mount(app_state: AppState) -> Router { Router::new() - .nest("/v2.0", Router::new().route("/catalog", get(catalog))) + .nest( + "/v2.0", + Router::new() + .route("/auth", get(auth)) + .route("/catalog", get(catalog)), + ) .layer(from_extractor_with_state::(app_state)) } +async fn auth() -> APIResult> { + Ok(Json( + OPDSAuthenticationDocumentBuilder::default() + .description(OPDSSupportedAuthFlow::Basic.description().to_string()) + .build()?, + )) +} + async fn catalog( State(ctx): State, session: Session, diff --git a/core/src/opds/v2_0/authentication.rs b/core/src/opds/v2_0/authentication.rs index 434a88e6b..47be8f5d7 100644 --- a/core/src/opds/v2_0/authentication.rs +++ b/core/src/opds/v2_0/authentication.rs @@ -1,68 +1,94 @@ +use derive_builder::Builder; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; -use super::link::OPDSLinkType; +use super::link::OPDSLink; + +pub const OPDS_AUTHENTICATION_DOCUMENT_REL: &str = "http://opds-spec.org/auth/document"; +pub const OPDS_AUTHENTICATION_DOCUMENT_TYPE: &str = + "application/opds-authentication+json"; /// A struct for representing an authentication document /// /// See https://drafts.opds.io/authentication-for-opds-1.0.html#23-syntax -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Builder, Serialize, Deserialize)] +#[builder(build_fn(error = "crate::CoreError"), default, setter(into))] #[skip_serializing_none] -pub struct AuthenticationDocument { +pub struct OPDSAuthenticationDocument { /// Unique identifier for the Catalog provider and canonical location for the Authentication Document. /// This is actually a URL id: String, /// A list of supported Authentication Flows as defined in section [3. Authentication Flows](https://drafts.opds.io/authentication-for-opds-1.0.html#3-authentication-flows). - authentication: Vec, + authentication: Vec, /// Title of the Catalog being accessed. title: String, /// A description of the service being displayed to the user. description: Option, + /// A list of links using the same syntax as defined in 2.3.2. Links. + links: Option>, +} + +impl Default for OPDSAuthenticationDocument { + fn default() -> Self { + Self { + id: String::from("/opds/v2.0/auth"), + authentication: vec![OPDSAuthenticationFlow::default()], + title: String::from("Stump OPDS V2 Auth"), + description: None, + links: Some(vec![OPDSLink::help(), OPDSLink::logo()]), + } + } } /// A struct for representing a supported authentication flow /// /// See https://drafts.opds.io/authentication-for-opds-1.0.html#3-authentication-flows -#[derive(Debug, Serialize, Deserialize)] -pub struct AuthenticationFlow { +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OPDSAuthenticationFlow { /// A URI that identifies the nature of an Authentication Flow. #[serde(rename = "type")] - _type: SupportedAuthFlow, - /// The URI of the authentication flow - uri: String, + _type: OPDSSupportedAuthFlow, + // /// The URI of the authentication flow + // uri: String, + /// A list of labels that can be used to provide alternate labels for fields that the client will display to the user. + labels: Option, +} + +impl Default for OPDSAuthenticationFlow { + fn default() -> Self { + Self { + _type: OPDSSupportedAuthFlow::Basic, + labels: Some(OPDSAuthenticationLabels { + login: Some(String::from("Username")), + password: Some(String::from("Password")), + }), + } + } } /// A struct for representing authentication labels, meant to provide alternate labels /// for fields that the client will display to the user. /// /// See https://drafts.opds.io/authentication-for-opds-1.0.html#311-labels -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[skip_serializing_none] -pub struct AuthenticationLabels { +pub struct OPDSAuthenticationLabels { login: Option, password: Option, } -#[derive(Debug, Serialize, Deserialize)] -pub enum SupportedAuthFlow { +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum OPDSSupportedAuthFlow { #[serde(rename = "http://opds-spec.org/auth/basic")] Basic, } -#[derive(Debug, Serialize, Deserialize)] -pub struct AuthenticateLink { - /// The URI of the authentication document - href: String, - /// The type of the link - #[serde(rename = "type")] - _type: OPDSLinkType, -} - -impl AuthenticateLink { - pub fn new(href: String) -> Self { - Self { - href, - _type: OPDSLinkType::OpdsAuth, +impl OPDSSupportedAuthFlow { + pub fn description(&self) -> &str { + match self { + OPDSSupportedAuthFlow::Basic => { + "Enter your username and password to authenticate." + }, } } } diff --git a/core/src/opds/v2_0/link.rs b/core/src/opds/v2_0/link.rs index 68642bf2e..34bb203ef 100644 --- a/core/src/opds/v2_0/link.rs +++ b/core/src/opds/v2_0/link.rs @@ -27,6 +27,8 @@ pub enum OPDSLinkRel { Previous, First, Last, + Help, + Logo, } impl OPDSLinkRel { @@ -47,7 +49,7 @@ impl OPDSLinkRel { pub enum OPDSLinkType { #[serde(rename = "application/opds+json")] OpdsJson, - #[serde(rename = "application/opds-publication+json")] + #[serde(rename = "http://opds-spec.org/auth/document")] OpdsAuth, #[serde(rename = "image/jpeg")] ImageJpeg, @@ -138,6 +140,25 @@ pub enum OPDSLink { Image(OPDSImageLink), } +impl OPDSLink { + pub fn help() -> Self { + Self::Link(OPDSBaseLink { + href: String::from("https://stumpapp.dev"), + rel: OPDSLinkRel::Help.item(), + ..Default::default() + }) + } + + pub fn logo() -> Self { + // TODO: determine if relative paths work... + Self::Link(OPDSBaseLink { + href: String::from("/favicon.ico"), + rel: OPDSLinkRel::Logo.item(), + ..Default::default() + }) + } +} + impl From for OPDSNavigationLink { fn from(library: library::Data) -> Self { OPDSNavigationLinkBuilder::default() diff --git a/core/src/opds/v2_0/mod.rs b/core/src/opds/v2_0/mod.rs index 506d93025..faf7eb23f 100644 --- a/core/src/opds/v2_0/mod.rs +++ b/core/src/opds/v2_0/mod.rs @@ -6,6 +6,7 @@ pub mod feed; pub mod group; pub mod link; pub mod metadata; +pub mod properties; mod utils; pub use utils::ArrayOrItem; diff --git a/core/src/opds/v2_0/properties.rs b/core/src/opds/v2_0/properties.rs new file mode 100644 index 000000000..4a57ffed8 --- /dev/null +++ b/core/src/opds/v2_0/properties.rs @@ -0,0 +1,44 @@ +use derive_builder::Builder; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +use super::link::OPDSLinkType; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OPDSDynamicProperties(serde_json::Value); + +#[derive(Debug, Default, Builder, Clone, Serialize, Deserialize)] +#[builder(build_fn(error = "crate::CoreError"), default, setter(into))] +#[skip_serializing_none] +pub struct OPDSProperties { + authenticate: Option, + #[serde(flatten)] + dynamic_properties: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OPDSAuthenticateProperties { + /// The URI of the authentication document + href: String, + /// The type of the link + #[serde(rename = "type")] + _type: OPDSLinkType, +} + +impl Default for OPDSAuthenticateProperties { + fn default() -> Self { + Self { + href: String::from("/opds/v2.0/auth"), + _type: OPDSLinkType::OpdsAuth, + } + } +} + +impl OPDSAuthenticateProperties { + pub fn new(href: String) -> Self { + Self { + href, + _type: OPDSLinkType::OpdsAuth, + } + } +}