#![forbid(unsafe_code)] #![warn(clippy::todo)] use std::sync::Arc; use axum::extract::{FromRef, FromRequestParts, OptionalFromRequestParts}; use axum_extra::extract::{ cookie::{Cookie, Key}, SignedCookieJar, }; use database::{FileStorage, PostgresStorage, Storage}; use indieauth::backend::{AuthBackend, FileBackend as FileAuthBackend}; use kittybox_util::queue::JobQueue; use media::storage::{file::FileStore as FileMediaStore, MediaStore}; 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 indieauth; pub mod login; pub mod media; pub mod micropub; pub mod webmentions; //pub mod admin; const OAUTH2_SOFTWARE_ID: &str = "6f2eee84-c22c-4c9e-b900-10d4e97273c8"; #[derive(Clone)] pub struct AppState where A: AuthBackend + Sized + 'static, S: Storage + Sized + 'static, M: MediaStore + Sized + 'static, Q: JobQueue + 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>>, pub cookie_key: Key, pub session_store: SessionStore, } pub type SessionStore = Arc>>; #[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() } } impl OptionalFromRequestParts for Session where SessionStore: FromRef, Key: FromRef, S: Send + Sync, { type Rejection = std::convert::Infallible; async fn from_request_parts( parts: &mut axum::http::request::Parts, state: &S, ) -> Result, Self::Rejection> { let jar = SignedCookieJar::::from_request_parts(parts, state) .await .unwrap(); let session_store = SessionStore::from_ref(state).read_owned().await; Ok(jar .get("session_id") .as_ref() .map(Cookie::value_trimmed) .and_then(|id| uuid::Uuid::parse_str(id).ok()) .and_then(|id| session_store.get(&id).cloned())) } } // This is really regrettable, but I can't write: // // ```compile-error // impl FromRef> for A // where A: AuthBackend, S: Storage, M: MediaStore { // fn from_ref(input: &AppState) -> 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 FromRef> for FileAuthBackend where S: Storage, M: MediaStore, Q: JobQueue, { fn from_ref(input: &AppState) -> Self { input.auth_backend.clone() } } impl FromRef> for PostgresStorage where A: AuthBackend, M: MediaStore, Q: JobQueue, { fn from_ref(input: &AppState) -> Self { input.storage.clone() } } impl FromRef> for FileStorage where A: AuthBackend, M: MediaStore, Q: JobQueue, { fn from_ref(input: &AppState) -> Self { input.storage.clone() } } impl FromRef> for FileMediaStore // where A: AuthBackend, S: Storage where A: AuthBackend, S: Storage, Q: JobQueue, { fn from_ref(input: &AppState) -> Self { input.media_store.clone() } } impl FromRef> for Key where A: AuthBackend, S: Storage, M: MediaStore, Q: JobQueue, { fn from_ref(input: &AppState) -> Self { input.cookie_key.clone() } } impl FromRef> for reqwest_middleware::ClientWithMiddleware where A: AuthBackend, S: Storage, M: MediaStore, Q: JobQueue, { fn from_ref(input: &AppState) -> Self { input.http.clone() } } impl FromRef> for Arc>> where A: AuthBackend, S: Storage, M: MediaStore, Q: JobQueue, { fn from_ref(input: &AppState) -> Self { input.background_jobs.clone() } } #[cfg(feature = "sqlx")] impl FromRef> for PostgresJobQueue where A: AuthBackend, S: Storage, M: MediaStore, { fn from_ref(input: &AppState) -> Self { input.job_queue.clone() } } impl FromRef> for SessionStore where A: AuthBackend, S: Storage, M: MediaStore, Q: JobQueue, { fn from_ref(input: &AppState) -> Self { input.session_store.clone() } } pub mod companion; 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( axum::extract::State(data): axum::extract::State, ) -> impl axum::response::IntoResponse where D: crate::database::Storage, { (axum::http::StatusCode::OK, std::borrow::Cow::Borrowed("OK")) } pub async fn compose_kittybox() -> axum::Router where A: AuthBackend + 'static + FromRef, S: Storage + 'static + FromRef, M: MediaStore + 'static + FromRef, Q: kittybox_util::queue::JobQueue + FromRef, reqwest_middleware::ClientWithMiddleware: FromRef, Arc>>: FromRef, crate::SessionStore: FromRef, axum_extra::extract::cookie::Key: FromRef, St: Clone + Send + Sync + 'static, { use axum::routing::get; axum::Router::new() .route("/", get(crate::frontend::homepage::)) .fallback(get(crate::frontend::catchall::)) .route("/.kittybox/micropub", crate::micropub::router::()) .route( "/.kittybox/onboarding", crate::frontend::onboarding::router::(), ) .nest("/.kittybox/media", crate::media::router::()) .merge(crate::indieauth::router::()) .merge(crate::webmentions::router::()) .route("/.kittybox/health", get(health_check::)) .nest("/.kittybox/login", crate::login::router::()) .route( "/.kittybox/static/{*path}", axum::routing::get(crate::frontend::statics), ) .route("/.kittybox/coffee", get(teapot_route)) .nest( "/.kittybox/micropub/client", crate::companion::router::(), ) .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, ]), ) .layer(tower_http::set_header::SetResponseHeaderLayer::appending( axum::http::header::CONTENT_SECURITY_POLICY, axum::http::HeaderValue::from_static(concat!( "default-src 'none';", // Do not allow unknown things we didn't foresee. "img-src https:;", // Allow hotlinking images from anywhere. "form-action 'self';", // Only allow sending forms back to us. "media-src 'self';", // Only allow embedding media from us. "script-src 'self';", // Only run scripts we serve. "font-src 'self';", // Only use fonts we serve. "style-src 'self';", // Only use styles we serve. "base-uri 'none';", // Do not allow to change the base URI. "object-src 'none';", // Do not allow to embed objects (Flash/ActiveX). "connect-src 'self';", // Allow sending data back to us. (WHY IS THIS A THING OMG) // Allow embedding the Bandcamp player for jam posts. // TODO: perhaps make this policy customizable?… "frame-src 'self' https://bandcamp.com/EmbeddedPlayer/;" )), )) }