about summary refs log blame commit diff
path: root/src/lib.rs
blob: 5fe3b1877770ba4e8389f7d0e519b1d048345bc1 (plain) (tree)
1
2
3
4
5
6
7
8
9
                       
                      
 
                   
                                               
                                                        
                                                                      
                                   
                                                                    
                                                  
                                         
 
                                                                                                
                 
                 
                  
                    
              
                
                                                                        










                                            
                                                       
                                                 























































                                                                                                                          



















                                                                     






                                                                     






























                                                                                     
                                                                                           





















                                                                                            
 






                                                                                     
                   
                                               
                                   
                                          






                                 








                                                              



                                                      
                   
                                                                           







                                                                      

         
                                                                            
                                         
                                            
                                                                                    
                                                     


                                                                          
                                                                                                                     
                                                                          


                                
                    
                                                                

                                                               
                                                    

                                         
                         








                                                 



















                                                                                                      
                                                      
                                     
                                              










                                                                                       
                                                                  












                                                                             
#![forbid(unsafe_code)]
#![warn(clippy::todo)]

use std::sync::Arc;

use axum::extract::{FromRef, FromRequestParts};
use axum_extra::extract::{cookie::Key, SignedCookieJar};
use database::{FileStorage, PostgresStorage, Storage};
use indieauth::backend::{AuthBackend, FileBackend as FileAuthBackend};
use kittybox_util::queue::JobQueue;
use media::storage::{MediaStore, file::FileStore as FileMediaStore};
use tokio::{sync::{Mutex, RwLock}, task::JoinSet};
use webmentions::queue::PostgresJobQueue;

/// Database abstraction layer for Kittybox, allowing the CMS to work with any kind of database.
pub mod database;
pub mod frontend;
pub mod media;
pub mod micropub;
pub mod indieauth;
pub mod webmentions;
pub mod login;
//pub mod admin;

const OAUTH2_SOFTWARE_ID: &str = "6f2eee84-c22c-4c9e-b900-10d4e97273c8";

#[derive(Clone)]
pub struct AppState<A, S, M, Q>
where
A: AuthBackend + Sized + 'static,
S: Storage + Sized + 'static,
M: MediaStore + Sized + 'static,
Q: JobQueue<webmentions::Webmention> + Sized
{
    pub auth_backend: A,
    pub storage: S,
    pub media_store: M,
    pub job_queue: Q,
    pub http: reqwest_middleware::ClientWithMiddleware,
    pub background_jobs: Arc<Mutex<JoinSet<()>>>,
    pub cookie_key: Key,
    pub session_store: SessionStore
}

pub type SessionStore = Arc<RwLock<std::collections::HashMap<uuid::Uuid, Session>>>;

#[derive(Debug, Clone)]
#[repr(transparent)]
pub struct Session(kittybox_indieauth::ProfileUrl);

impl std::ops::Deref for Session {
    type Target = kittybox_indieauth::ProfileUrl;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

pub struct NoSessionError;
impl axum::response::IntoResponse for NoSessionError {
    fn into_response(self) -> axum::response::Response {
        // TODO: prettier error message
        (axum::http::StatusCode::UNAUTHORIZED, "You are not logged in, but this page requires a session.").into_response()
    }
}

#[async_trait::async_trait]
impl<S> FromRequestParts<S> for Session
where
    SessionStore: FromRef<S>,
    Key: FromRef<S>,
    S: Send + Sync,
{
    type Rejection = NoSessionError;

    async fn from_request_parts(parts: &mut axum::http::request::Parts, state: &S) ->  Result<Self, Self::Rejection> {
        let jar = SignedCookieJar::<Key>::from_request_parts(parts, state).await.unwrap();
        let session_store = SessionStore::from_ref(state).read_owned().await;

        tracing::debug!("Cookie jar: {:#?}", jar);
        let cookie = match jar.get("session_id") {
            Some(cookie) => {
                tracing::debug!("Session ID cookie: {}", cookie);
                cookie
            },
            None => { return Err(NoSessionError) }
        };

        session_store.get(
            &dbg!(cookie.value_trimmed())
                .parse()
                .map_err(|err| {
                    tracing::error!("Error parsing cookie: {}", err);
                    NoSessionError
                })?
        ).cloned().ok_or(NoSessionError)
    }
}

// This is really regrettable, but I can't write:
//
// ```compile-error
// impl <A, S, M> FromRef<AppState<A, S, M>> for A
// where A: AuthBackend, S: Storage, M: MediaStore {
//     fn from_ref(input: &AppState<A, S, M>) -> A {
//         input.auth_backend.clone()
//     }
// }
// ```
//
// ...because of the orphan rule.
//
// I wonder if this would stifle external implementations. I think it
// shouldn't, because my AppState type is generic, and since the
// target type is local, the orphan rule will not kick in. You just
// have to repeat this magic invocation.

impl<S, M, Q> FromRef<AppState<Self, S, M, Q>> for FileAuthBackend
where S: Storage, M: MediaStore, Q: JobQueue<webmentions::Webmention>
{
    fn from_ref(input: &AppState<Self, S, M, Q>) -> Self {
        input.auth_backend.clone()
    }
}

impl<A, M, Q> FromRef<AppState<A, Self, M, Q>> for PostgresStorage
where A: AuthBackend, M: MediaStore, Q: JobQueue<webmentions::Webmention>
{
    fn from_ref(input: &AppState<A, Self, M, Q>) -> Self {
        input.storage.clone()
    }
}

impl<A, M, Q> FromRef<AppState<A, Self, M, Q>> for FileStorage
where A: AuthBackend, M: MediaStore, Q: JobQueue<webmentions::Webmention>
{
    fn from_ref(input: &AppState<A, Self, M, Q>) -> Self {
        input.storage.clone()
    }
}

impl<A, S, Q> FromRef<AppState<A, S, Self, Q>> for FileMediaStore
// where A: AuthBackend, S: Storage
where A: AuthBackend, S: Storage, Q: JobQueue<webmentions::Webmention>
{
    fn from_ref(input: &AppState<A, S, Self, Q>) -> Self {
        input.media_store.clone()
    }
}

impl<A, S, M, Q> FromRef<AppState<A, S, M, Q>> for Key
where A: AuthBackend, S: Storage, M: MediaStore, Q: JobQueue<webmentions::Webmention>
{
    fn from_ref(input: &AppState<A, S, M, Q>) -> Self {
        input.cookie_key.clone()
    }
}

impl<A, S, M, Q> FromRef<AppState<A, S, M, Q>> for reqwest_middleware::ClientWithMiddleware
where A: AuthBackend, S: Storage, M: MediaStore, Q: JobQueue<webmentions::Webmention>
{
    fn from_ref(input: &AppState<A, S, M, Q>) -> Self {
        input.http.clone()
    }
}

impl<A, S, M, Q> FromRef<AppState<A, S, M, Q>> for Arc<Mutex<JoinSet<()>>>
where A: AuthBackend, S: Storage, M: MediaStore, Q: JobQueue<webmentions::Webmention>
{
    fn from_ref(input: &AppState<A, S, M, Q>) -> Self {
        input.background_jobs.clone()
    }
}

#[cfg(feature = "sqlx")]
impl<A, S, M> FromRef<AppState<A, S, M, Self>> for PostgresJobQueue<webmentions::Webmention>
where A: AuthBackend, S: Storage, M: MediaStore
{
    fn from_ref(input: &AppState<A, S, M, Self>) -> Self {
        input.job_queue.clone()
    }
}

impl<A, S, M, Q> FromRef<AppState<A, S, M, Q>> for SessionStore
where A: AuthBackend, S: Storage, M: MediaStore, Q: JobQueue<webmentions::Webmention>
{
    fn from_ref(input: &AppState<A, S, M, Q>) -> Self {
        input.session_store.clone()
    }
}

pub mod companion {
    use std::{collections::HashMap, sync::Arc};
    use axum::{
        extract::{Extension, Path},
        response::{IntoResponse, Response}
    };

    #[derive(Debug, Clone, Copy)]
    struct Resource {
        data: &'static [u8],
        mime: &'static str
    }

    impl IntoResponse for &Resource {
        fn into_response(self) -> Response {
            (axum::http::StatusCode::OK,
             [("Content-Type", self.mime)],
             self.data).into_response()
        }
    }

    // TODO replace with the "phf" crate someday
    type ResourceTable = Arc<HashMap<&'static str, Resource>>;

    #[tracing::instrument]
    async fn map_to_static(
        Path(name): Path<String>,
        Extension(resources): Extension<ResourceTable>
    ) -> Response {
        tracing::debug!("Searching for {} in the resource table...", name);
        match resources.get(name.as_str()) {
            Some(res) => res.into_response(),
            None => {
                #[cfg(debug_assertions)] tracing::error!("Not found");

                (axum::http::StatusCode::NOT_FOUND,
                 [("Content-Type", "text/plain")],
                 "Not found. Sorry.".as_bytes()).into_response()
            }
        }
    }

    pub fn router<St: Clone + Send + Sync + 'static>() -> axum::Router<St> {
        let resources: ResourceTable = {
            let mut map = HashMap::new();

            macro_rules! register_resource {
                ($map:ident, $prefix:expr, ($filename:literal, $mime:literal)) => {{
                    $map.insert($filename, Resource {
                        data: include_bytes!(concat!($prefix, $filename)),
                        mime: $mime
                    })
                }};
                ($map:ident, $prefix:expr, ($filename:literal, $mime:literal), $( ($f:literal, $m:literal) ),+) => {{
                    register_resource!($map, $prefix, ($filename, $mime));
                    register_resource!($map, $prefix, $(($f, $m)),+);
                }};
            }

            register_resource! {
                map,
                concat!(env!("OUT_DIR"), "/", "companion", "/"),
                ("index.html", "text/html; charset=\"utf-8\""),
                ("main.js", "text/javascript"),
                ("micropub_api.js", "text/javascript"),
                ("indieauth.js", "text/javascript"),
                ("base64.js", "text/javascript"),
                ("style.css", "text/css")
            };

            Arc::new(map)
        };

        axum::Router::new()
            .route(
                "/:filename",
                axum::routing::get(map_to_static)
                    .layer(Extension(resources))
            )
    }
}

async fn teapot_route() -> impl axum::response::IntoResponse {
    use axum::http::{header, StatusCode};
    (StatusCode::IM_A_TEAPOT, [(header::CONTENT_TYPE, "text/plain")], "Sorry, can't brew coffee yet!")
}

async fn health_check<D>(
    axum::extract::State(data): axum::extract::State<D>,
) -> impl axum::response::IntoResponse
where
    D: crate::database::Storage
{
    (axum::http::StatusCode::OK, std::borrow::Cow::Borrowed("OK"))
}

pub async fn compose_kittybox<St, A, S, M, Q>() -> axum::Router<St>
where
A: AuthBackend + 'static + FromRef<St>,
S: Storage + 'static + FromRef<St>,
M: MediaStore + 'static + FromRef<St>,
Q: kittybox_util::queue::JobQueue<crate::webmentions::Webmention> + FromRef<St>,
reqwest_middleware::ClientWithMiddleware: FromRef<St>,
Arc<Mutex<JoinSet<()>>>: FromRef<St>,
crate::SessionStore: FromRef<St>,
axum_extra::extract::cookie::Key: FromRef<St>,
St: Clone + Send + Sync + 'static
{
    use axum::routing::get;
    axum::Router::new()
        .route("/", get(crate::frontend::homepage::<S>))
        .fallback(get(crate::frontend::catchall::<S>))
        .route("/.kittybox/micropub", crate::micropub::router::<A, S, St>())
        .route("/.kittybox/onboarding", crate::frontend::onboarding::router::<St, S>())
        .nest("/.kittybox/media", crate::media::router::<St, A, M>())
        .merge(crate::indieauth::router::<St, A, S>())
        .merge(crate::webmentions::router::<St, Q>())
        .route("/.kittybox/health", get(health_check::<S>))
        .nest("/.kittybox/login", crate::login::router::<St, S>())
        .route(
            "/.kittybox/static/:path",
            axum::routing::get(crate::frontend::statics)
        )
        .route("/.kittybox/coffee", get(teapot_route))
        .nest("/.kittybox/micropub/client", crate::companion::router::<St>())
        .layer(tower_http::trace::TraceLayer::new_for_http())
        .layer(tower_http::catch_panic::CatchPanicLayer::new())
        .layer(tower_http::sensitive_headers::SetSensitiveHeadersLayer::new([
            axum::http::header::AUTHORIZATION,
            axum::http::header::COOKIE,
            axum::http::header::SET_COOKIE,
        ]))
}