#![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,
]))
}