use std::convert::Infallible; use warp::http::StatusCode; use warp::{Filter, Rejection, reject::InvalidQuery}; use serde_json::{json, Value}; use serde::{Serialize, Deserialize}; use crate::database::{MicropubChannel, Storage}; #[derive(Serialize, Deserialize, Debug, PartialEq)] #[serde(rename_all = "kebab-case")] enum QueryType { Source, Config, Channel, SyndicateTo } #[derive(Serialize, Deserialize, Debug)] struct MicropubQuery { q: QueryType, url: Option } #[derive(Serialize, Deserialize, PartialEq, Debug)] #[serde(rename_all = "snake_case")] enum ErrorType { InvalidRequest, InternalServerError, NotFound, NotAuthorized, } #[derive(Serialize, Deserialize)] struct MicropubError { error: ErrorType, error_description: String } impl From for StatusCode { fn from(err: MicropubError) -> Self { use ErrorType::*; match err.error { InvalidRequest => StatusCode::BAD_REQUEST, InternalServerError => StatusCode::INTERNAL_SERVER_ERROR, NotFound => StatusCode::NOT_FOUND, NotAuthorized => StatusCode::UNAUTHORIZED } } } impl MicropubError { fn new(error: ErrorType, error_description: &str) -> Self { Self { error, error_description: error_description.to_owned() } } } pub mod media { use futures_util::{Stream, StreamExt}; use bytes::buf::Buf; use warp::{Filter, Rejection, Reply, multipart::{FormData, Part}}; pub fn query() -> impl Filter + Clone { warp::get() .and(crate::util::require_host()) .map(|host| "media endpoint query...") } pub fn options() -> impl Filter + Clone { warp::options() .map(|| warp::reply::json::>(&None)) // TODO: why doesn't this work? // .map(warp::reply::with::header("Allow", "GET, POST")) .map(|reply| warp::reply::with_header(reply, "Allow", "GET, POST")) } pub fn upload() -> impl Filter + Clone { warp::post() .and(crate::util::require_host()) .and(warp::multipart::form().max_length(1024*1024*150/*mb*/)) .and_then(|host, mut form: FormData| async move { // TODO get rid of the double unwrap() here let file: Part = form.next().await.unwrap().unwrap(); log::debug!("Uploaded: {:?}, type: {:?}", file.filename(), file.content_type()); let mut data = file.stream(); while let Some(buf) = data.next().await { // TODO save it into a file log::debug!("buffer length: {:?}", buf.map(|b| b.remaining())); } Ok::<_, warp::Rejection>(warp::reply::with_header( warp::reply::with_status( "", warp::http::StatusCode::CREATED ), "Location", "./awoo.png" )) }) } pub fn media() -> impl Filter + Clone { upload() .or(query()) .or(options()) } } async fn _post(db: D, host: warp::host::Authority, user: crate::indieauth::User) -> impl warp::Reply { todo!("post to database {:?} for host {:?} using user {:?}", db, host, user); #[allow(unreachable_code)] "" } pub fn post(db: D, token_endpoint: String, http: hyper::Client) -> impl Filter + Clone where T: hyper::client::connect::Connect + Clone + Send + Sync + 'static { warp::post() .map(move || db.clone()) .and(crate::util::require_host()) .and(crate::indieauth::require_token(token_endpoint, http)) // TODO get body and process it .then(_post) } pub fn options() -> impl Filter + Copy { warp::options() // TODO make it reply with a basic description of Micropub spec .map(|| warp::reply::json::>(&None)) // TODO: why doesn't this work? // .map(warp::reply::with::header("Allow", "GET, POST")) .map(|reply| warp::reply::with_header(reply, "Allow", "GET, POST")) } async fn _query(db: D, host: warp::host::Authority, query: MicropubQuery, user: crate::indieauth::User) -> impl warp::Reply { let user_authority = warp::http::Uri::try_from(user.me.as_str()).unwrap().authority().unwrap().clone(); // TODO compare with potential list of allowed websites // to allow one user to edit several websites with one token if host != user_authority { return Box::new(warp::reply::with_status( warp::reply::json(&MicropubError::new( ErrorType::NotAuthorized, "This user is not authorized to use Micropub on this website." )), StatusCode::UNAUTHORIZED )) as Box } match query.q { QueryType::Config => { let channels: Vec = match db.get_channels(host.as_str()).await { Ok(chans) => chans, Err(err) => return Box::new(warp::reply::with_status( warp::reply::json(&MicropubError::new( ErrorType::InternalServerError, &format!("Error fetching channels: {}", err) )), StatusCode::INTERNAL_SERVER_ERROR )) }; Box::new(warp::reply::json(json!({ "q": [ QueryType::Source, QueryType::Config, QueryType::Channel, QueryType::SyndicateTo ], "channels": channels, "_kittybox_authority": host.as_str() }).as_object().unwrap())) }, QueryType::Source => { match query.url { Some(url) => { if warp::http::Uri::try_from(&url).unwrap().authority().unwrap() != &user_authority { return Box::new(warp::reply::with_status( warp::reply::json(&MicropubError::new( ErrorType::NotAuthorized, "You are requesting a post from a website that doesn't belong to you." )), StatusCode::UNAUTHORIZED )) } match db.get_post(&url).await { Ok(some) => match some { Some(post) => Box::new(warp::reply::json(&post)), None => Box::new(warp::reply::with_status( warp::reply::json(&MicropubError::new( ErrorType::NotFound, "The specified MF2 object was not found in database." )), StatusCode::NOT_FOUND )) }, Err(err) => { return Box::new(warp::reply::json(&MicropubError::new( ErrorType::InternalServerError, &format!("Backend error: {}", err) ))) } } }, None => todo!() } }, _ => { todo!() } } } pub fn query(db: D, token_endpoint: String, http: hyper::Client) -> impl Filter + Clone where T: hyper::client::connect::Connect + Clone + Send + Sync + 'static { warp::get() .map(move || db.clone()) .and(crate::util::require_host()) .and(warp::query::()) .and(crate::indieauth::require_token(token_endpoint, http)) .then(_query) } pub async fn recover(err: Rejection) -> Result { let error = if err.find::().is_some() { MicropubError::new( ErrorType::InvalidRequest, "Invalid query parameters sent. Try ?q=config to see what you can do." ) } else { log::error!("Unhandled rejection: {:?}", err); MicropubError::new( ErrorType::InternalServerError, &format!("Unknown error: {:?}", err) ) }; Ok(warp::reply::with_status(warp::reply::json(&error), error.into())) } pub fn micropub(db: D, token_endpoint: String, http: hyper::Client) -> impl Filter + Clone where T: hyper::client::connect::Connect + Clone + Send + Sync + 'static { query(db.clone(), token_endpoint.clone(), http.clone()) .or(post(db.clone(), token_endpoint.clone(), http.clone())) .or(options()) .recover(recover) } #[cfg(test)] impl MicropubQuery { fn config() -> Self { Self { q: QueryType::Config, url: None } } fn source(url: &str) -> Self { Self { q: QueryType::Source, url: Some(url.to_owned()) } } } #[cfg(test)] mod tests { use hyper::body::HttpBody; use crate::micropub::MicropubError; use warp::{Filter, Reply}; #[tokio::test] async fn test_query_wrong_auth() { let mut res = warp::test::request() .filter(&warp::any().then(|| super::_query( crate::database::MemoryStorage::new(), warp::host::Authority::from_static("aaronparecki.com"), super::MicropubQuery::config(), crate::indieauth::User::new( "https://fireburn.ru/", "https://quill.p3k.io/", "create update media" ) ))) .await .unwrap() .into_response(); assert_eq!(res.status(), 401); let body = res.body_mut().data().await.unwrap().unwrap(); let json: MicropubError = serde_json::from_slice(&body as &[u8]).unwrap(); assert_eq!(json.error, super::ErrorType::NotAuthorized); } #[tokio::test] async fn test_query_foreign_url() { let mut res = warp::test::request() .filter(&warp::any().then(|| super::_query( crate::database::MemoryStorage::new(), warp::host::Authority::from_static("aaronparecki.com"), super::MicropubQuery::source("https://aaronparecki.com/feeds/main"), crate::indieauth::User::new( "https://fireburn.ru/", "https://quill.p3k.io/", "create update media" ) ))) .await .unwrap() .into_response(); assert_eq!(res.status(), 401); let body = res.body_mut().data().await.unwrap().unwrap(); let json: MicropubError = serde_json::from_slice(&body as &[u8]).unwrap(); assert_eq!(json.error, super::ErrorType::NotAuthorized); } }