use anyhow::Result; use tokio::net::ToSocketAddrs; use serde::{Serialize, de::DeserializeOwned}; use std::future::Future; use crate::{Document, Request, Response}; use crate::types::document::HeadingLevel; use crate::user_management::{User, RegisteredUser, UserManagerError}; const UNAUTH: &str = include_str!("pages/unauth.gmi"); const NSI: &str = include_str!("pages/nsi.gmi"); /// Import this trait to use [`add_um_routes()`](Self::add_um_routes()) pub trait UserManagementRoutes: private::Sealed { /// Add pre-configured routes to the serve to handle authentication /// /// Specifically, the following routes are added: /// * `/account`, the main settings & login page /// * `/account/askcert`, a page which always prompts for a certificate /// * `/account/register`, for users to register a new account /// * `/account/login`, for users to link their certificate to an existing account /// * `/account/password`, to change the user's password /// /// If this method is used, no more routes should be added under `/account`. If you /// would like to direct a user to login from your application, you should send them /// to `/account`, which will start the login/registration flow. /// /// The `redir` argument allows you to specify the point that users will be directed /// to return to once their account has been created. fn add_um_routes(self, redir: &'static str) -> Self; /// Add a special route that requires users to be logged in /// /// In addition to the normal [`Request`], your handler will recieve a copy of the /// [`RegisteredUser`] for the current user. If a user tries to connect to the page /// without logging in, they will be prompted to register or link an account. /// /// To use this method, ensure that [`add_um_routes()`](Self::add_um_routes()) has /// also been called. fn add_authenticated_route( self, path: &'static str, handler: Handler, ) -> Self where UserData: Serialize + DeserializeOwned + 'static + Send + Sync, Handler: Clone + Send + Sync + 'static + Fn(Request, RegisteredUser) -> F, F: Send + Sync + 'static + Future>; /// Add a special route that requires users to be logged in AND takes input /// /// Like with [`add_authenticated_route()`](Self::add_authenticated_route()), this /// prompts the user to log in if they haven't already, but additionally prompts the /// user for input before running the handler with both the user object and the input /// they provided. /// /// To a user, this might look something like this: /// * Click a link to `/your/route` /// * See a screen asking you to sign in or create an account /// * Create a new account, and return to the app. /// * Now, clicking the link shows the prompt provided. /// * After entering some value, the user receives the response from the handler. /// /// For a user whose already logged in, this will just look like a normal input route, /// where they enter some query and see a page. This method just takes the burden of /// having to check if the user sent a query string and respond with an INPUT response /// if not. /// /// To use this method, ensure that [`add_um_routes()`](Self::add_um_routes()) has /// also been called. fn add_authenticated_input_route( self, path: &'static str, prompt: &'static str, handler: Handler, ) -> Self where UserData: Serialize + DeserializeOwned + 'static + Send + Sync, Handler: Clone + Send + Sync + 'static + Fn(Request, RegisteredUser, String) -> F, F: Send + Sync + 'static + Future>; } impl UserManagementRoutes for crate::Builder { /// Add pre-configured routes to the serve to handle authentication /// /// See [`UserManagementRoutes::add_um_routes()`] fn add_um_routes(self, redir: &'static str) -> Self { self.add_route("/account", move|r|handle_base::(r, redir)) .add_route("/account/askcert", move|r|handle_ask_cert::(r, redir)) .add_route("/account/register", move|r|handle_register::(r, redir)) .add_route("/account/login", move|r|handle_login::(r, redir)) .add_route("/account/password", handle_password::) } /// Add a special route that requires users to be logged in /// /// See [`UserManagementRoutes::add_authenticated_route()`] fn add_authenticated_route( self, path: &'static str, handler: Handler, ) -> Self where UserData: Serialize + DeserializeOwned + 'static + Send + Sync, Handler: Clone + Send + Sync + 'static + Fn(Request, RegisteredUser) -> F, F: Send + Sync + 'static + Future> { self.add_route(path, move|request| { let handler = handler.clone(); async move { Ok(match request.user::()? { User::Unauthenticated => { Response::success_gemini(UNAUTH) }, User::NotSignedIn(_) => { Response::success_gemini(NSI) }, User::SignedIn(user) => { (handler)(request, user).await? }, }) } }) } /// Add a special route that requires users to be logged in AND takes input /// /// See [`UserManagementRoutes::add_authenticated_input_route()`] fn add_authenticated_input_route( self, path: &'static str, prompt: &'static str, handler: Handler, ) -> Self where UserData: Serialize + DeserializeOwned + 'static + Send + Sync, Handler: Clone + Send + Sync + 'static + Fn(Request, RegisteredUser, String) -> F, F: Send + Sync + 'static + Future> { self.add_authenticated_route(path, move|request, user| { let handler = handler.clone(); async move { if let Some(input) = request.input().map(str::to_owned) { (handler.clone())(request, user, input).await } else { Response::input(prompt) } } }) } } async fn handle_base(request: Request, redirect: &'static str) -> Result { Ok(match request.user::()? { User::Unauthenticated => { Response::success_gemini(UNAUTH) }, User::NotSignedIn(_) => { Response::success_gemini(NSI) }, User::SignedIn(user) => { render_settings_menu(user, redirect) }, }) } async fn handle_ask_cert(request: Request, redirect: &'static str) -> Result { Ok(match request.user::()? { User::Unauthenticated => { Response::client_certificate_required() }, User::NotSignedIn(_) => { Response::success_gemini(include_str!("pages/askcert/success.gmi")) }, User::SignedIn(user) => { Response::success_gemini(format!( include_str!("pages/askcert/exists.gmi"), username = user.username(), redirect = redirect, )) }, }) } async fn handle_register(request: Request, redirect: &'static str) -> Result { Ok(match request.user::()? { User::Unauthenticated => { Response::success_gemini(UNAUTH) }, User::NotSignedIn(nsi) => { if let Some(username) = request.input() { match nsi.register::(username.to_owned()) { Err(UserManagerError::UsernameNotUnique) => { Response::success_gemini(format!( include_str!("pages/register/exists.gmi"), username = username, )) }, Ok(_) => { Response::success_gemini(format!( include_str!("pages/register/success.gmi"), username = username, redirect = redirect, )) }, Err(e) => return Err(e.into()) } } else { Response::input_lossy("Please pick a username") } }, User::SignedIn(user) => { render_settings_menu(user, redirect) }, }) } async fn handle_login(request: Request, redirect: &'static str) -> Result { Ok(match request.user::()? { User::Unauthenticated => { Response::success_gemini(UNAUTH) }, User::NotSignedIn(nsi) => { if let Some(username) = request.trailing_segments().get(0) { if let Some(password) = request.input() { match nsi.attach::(username, Some(password.as_bytes())) { Err(UserManagerError::PasswordNotSet) | Ok(None) => { Response::success_gemini(format!( include_str!("pages/login/wrong.gmi"), username = username, )) }, Ok(_) => { Response::success_gemini(format!( include_str!("pages/login/success.gmi"), username = username, redirect = redirect, )) }, Err(e) => return Err(e.into()), } } else { Response::input_lossy("Please enter your password") } } else if let Some(username) = request.input() { Response::redirect_temporary_lossy( format!("/account/login/{}", username).as_str() ) } else { Response::input_lossy("Please enter your username") } }, User::SignedIn(user) => { render_settings_menu(user, redirect) }, }) } async fn handle_password(request: Request) -> Result { Ok(match request.user::()? { User::Unauthenticated => { Response::success_gemini(UNAUTH) }, User::NotSignedIn(_) => { Response::success_gemini(NSI) }, User::SignedIn(mut user) => { if let Some(password) = request.input() { user.set_password(password)?; Response::success_gemini(include_str!("pages/password/success.gmi")) } else { Response::input( format!("Please enter a {}password", if user.has_password() { "new " } else { "" } ) )? } }, }) } fn render_settings_menu( user: RegisteredUser, redirect: &str ) -> Response { Document::new() .add_heading(HeadingLevel::H1, "User Settings") .add_blank_line() .add_text(&format!("Welcome {}!", user.username())) .add_blank_line() .add_link(redirect, "Back to the app") .add_blank_line() .add_text( if user.has_password() { concat!( "You currently have a password set. This can be used to link any new", " certificates or clients to your account. If you don't remember your", " password, or would like to change it, you may do so here.", ) } else { concat!( "You don't currently have a password set! Without a password, you cannot", " link any new certificates to your account, and if you lose your current", " client or certificate, you won't be able to recover your account.", ) } ) .add_blank_line() .add_link("/account/password", if user.has_password() { "Change password" } else { "Set password" }) .into() } mod private { pub trait Sealed {} impl Sealed for crate::Builder {} }