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

                                                                                        
 
                      
                          




                                                                       
                     
 
 
                    


                                                   

                                 
                                                                               





                                      
     
 
 



                                                            
                                                    

         
 
                                                                    

                                                                      
     
 




                                                                        
                                              








                                                                
                                    
                                                 
                                      
                                                 



                                                             
                                                 
                                              
                                                                                   
                        
                                                 
                                                 
                                                                
                 
                                 

     
                   

                       
                 
 
                   


                                  
                                                           
                      
                               
 




                                      
                                                                                                 
                         
                                                   




                                                                                           
 
                                       
                                                        
                                
                                       
 

                                            
                                                                 
 
                                      


                                                                
                                            
                                                                                   
                 



                                                                                                   
                                                                

                                                                    
                                                                                    
                                            
                                                               
                 





                                                                                
                                                      




                                                                               
                                        
                                                 
 
                                     
                            
                                                         
                     
                                                                             





                                                                           
 
                                                    
     
                                         



                                                                                        
                                                              
 
  
 


                                                                                                     
 






                                                                                                                                                 
                                                                   














                                                                       
                     
                 





                                                                                                                                                              
                                                                                             


                                                                          
                                                         
                                              
                                                   

                                                                                  
                                                           
                  
                                                        
                                      


                                                              
                                   
             
          
 
                                        

                                 




                                                                                  
                                               







                                                       
                                                                                                                    







                                                                                                                                                   
                                                                                                      




















                                                                                         
















                                                                                                                                                 
                                                                          


                                                                    
                                                                          


                                                            
                                                                         




                                                                                     
             








                                                                                                 
             






                                                                                                                        
                                                                                             




                                                                          
                                           




                                   

                                                        
                                                                  
 








































                                                                                                     
 
use std::convert::TryInto;
use crate::database::Storage;
use serde::Deserialize;
use futures_util::TryFutureExt;
use warp::{http::StatusCode, Filter, host::Authority, path::FullPath};

//pub mod login;

#[allow(unused_imports)]
use kittybox_templates::{ErrorPage, MainPage, OnboardingPage, Template, POSTS_PER_PAGE};

pub use kittybox_util::IndiewebEndpoints;

#[derive(Deserialize)]
struct QueryParams {
    after: Option<String>,
}

#[derive(Debug)]
struct FrontendError {
    msg: String,
    source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
    code: StatusCode,
}

impl FrontendError {
    pub fn with_code<C>(code: C, msg: &str) -> Self
    where
        C: TryInto<StatusCode>,
    {
        Self {
            msg: msg.to_string(),
            source: None,
            code: code.try_into().unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
        }
    }
    pub fn msg(&self) -> &str {
        &self.msg
    }
    pub fn code(&self) -> StatusCode {
        self.code
    }
}

impl From<crate::database::StorageError> for FrontendError {
    fn from(err: crate::database::StorageError) -> Self {
        Self {
            msg: "Database error".to_string(),
            source: Some(Box::new(err)),
            code: StatusCode::INTERNAL_SERVER_ERROR,
        }
    }
}

impl std::error::Error for FrontendError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        self.source
            .as_ref()
            .map(|e| e.as_ref() as &(dyn std::error::Error + 'static))
    }
}

impl std::fmt::Display for FrontendError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.msg)
    }
}

impl warp::reject::Reject for FrontendError {}

async fn get_post_from_database<S: Storage>(
    db: &S,
    url: &str,
    after: Option<String>,
    user: &Option<String>,
) -> std::result::Result<serde_json::Value, FrontendError> {
    match db
        .read_feed_with_limit(url, &after, POSTS_PER_PAGE, user)
        .await
    {
        Ok(result) => match result {
            Some(post) => Ok(post),
            None => Err(FrontendError::with_code(
                StatusCode::NOT_FOUND,
                "Post not found in the database",
            )),
        },
        Err(err) => match err.kind() {
            crate::database::ErrorKind::PermissionDenied => {
                // TODO: Authentication
                if user.is_some() {
                    Err(FrontendError::with_code(
                        StatusCode::FORBIDDEN,
                        "User authenticated AND forbidden to access this resource",
                    ))
                } else {
                    Err(FrontendError::with_code(
                        StatusCode::UNAUTHORIZED,
                        "User needs to authenticate themselves",
                    ))
                }
            }
            _ => Err(err.into()),
        },
    }
}

#[allow(dead_code)]
#[derive(Deserialize)]
struct OnboardingFeed {
    slug: String,
    name: String,
}

#[allow(dead_code)]
#[derive(Deserialize)]
struct OnboardingData {
    user: serde_json::Value,
    first_post: serde_json::Value,
    #[serde(default = "OnboardingData::default_blog_name")]
    blog_name: String,
    feeds: Vec<OnboardingFeed>,
}

impl OnboardingData {
    fn default_blog_name() -> String {
        "Kitty Box!".to_owned()
    }
}

/*pub async fn onboarding_receiver<S: Storage>(mut req: Request<ApplicationState<S>>) -> Result {
    use serde_json::json;

    log::debug!("Entering onboarding receiver...");

    // This cannot error out as the URL must be valid. Or there is something horribly wrong
    // and we shouldn't serve this request anyway.
    <dyn AsMut<tide::http::Request>>::as_mut(&mut req)
        .url_mut()
        .set_scheme("https")
        .unwrap();

    log::debug!("Parsing the body...");
    let body = req.body_json::<OnboardingData>().await?;
    log::debug!("Body parsed!");
    let backend = &req.state().storage;

    #[cfg(any(not(debug_assertions), test))]
    let me = req.url();
    #[cfg(all(debug_assertions, not(test)))]
    let me = url::Url::parse("https://localhost:8080/").unwrap();

    log::debug!("me value: {:?}", me);

    if get_post_from_database(backend, me.as_str(), None, &None)
        .await
        .is_ok()
    {
        return Err(FrontendError::with_code(
            StatusCode::Forbidden,
            "Onboarding is over. Are you trying to take over somebody's website?!",
        )
        .into());
    }
    info!("Onboarding new user: {}", me);

    let user = crate::indieauth::User::new(me.as_str(), "https://kittybox.fireburn.ru/", "create");

    log::debug!("Setting the site name to {}", &body.blog_name);
    backend
        .set_setting("site_name", user.me.as_str(), &body.blog_name)
        .await?;

    if body.user["type"][0] != "h-card" || body.first_post["type"][0] != "h-entry" {
        return Err(FrontendError::with_code(
            StatusCode::BadRequest,
            "user and first_post should be h-card and h-entry",
        )
        .into());
    }
    info!("Validated body.user and body.first_post as microformats2");

    let mut hcard = body.user;
    let hentry = body.first_post;

    // Ensure the h-card's UID is set to the main page, so it will be fetchable.
    hcard["properties"]["uid"] = json!([me.as_str()]);
    // Normalize the h-card - note that it should preserve the UID we set here.
    let (_, hcard) = crate::micropub::normalize_mf2(hcard, &user);
    // The h-card is written directly - all the stuff in the Micropub's
    // post function is just to ensure that the posts will be syndicated
    // and inserted into proper feeds. Here, we don't have a need for this,
    // since the h-card is DIRECTLY accessible via its own URL.
    log::debug!("Saving the h-card...");
    backend.put_post(&hcard, me.as_str()).await?;

    log::debug!("Creating feeds...");
    for feed in body.feeds {
        if feed.name.is_empty() || feed.slug.is_empty() {
            continue;
        };
        log::debug!("Creating feed {} with slug {}", &feed.name, &feed.slug);
        let (_, feed) = crate::micropub::normalize_mf2(
            json!({
                "type": ["h-feed"],
                "properties": {"name": [feed.name], "mp-slug": [feed.slug]}
            }),
            &user,
        );

        backend.put_post(&feed, me.as_str()).await?;
    }
    log::debug!("Saving the h-entry...");
    // This basically puts the h-entry post through the normal creation process.
    // We need to insert it into feeds and optionally send a notification to everywhere.
    req.set_ext(user);
    crate::micropub::post::new_post(req, hentry).await?;

    Ok(Response::builder(201).header("Location", "/").build())
}
*/

fn request_uri() -> impl Filter<Extract = (String,), Error = warp::Rejection> + Copy {
    crate::util::require_host()
        .and(warp::path::full())
        .map(|host: Authority, path: FullPath| "https://".to_owned() + host.as_str() + path.as_str())
}

#[forbid(clippy::unwrap_used)]
pub fn homepage<D: Storage>(db: D, endpoints: IndiewebEndpoints) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone {
    let inject_db = move || db.clone();
    warp::any()
        .map(inject_db.clone())
        .and(crate::util::require_host())
        .and(warp::query())
        .and_then(|db: D, host: Authority, q: QueryParams| async move {
            let path = format!("https://{}/", host);
            let feed_path = format!("https://{}/feeds/main", host);

            match tokio::try_join!(
                get_post_from_database(&db, &path, None, &None),
                get_post_from_database(&db, &feed_path, q.after, &None)
            ) {
                Ok((hcard, hfeed)) => Ok((
                    Some(hcard),
                    Some(hfeed),
                    StatusCode::OK
                )),
                Err(err) => {
                    if err.code == StatusCode::NOT_FOUND {
                        // signal for onboarding flow
                        Ok((None, None, err.code))
                    } else {
                        Err(warp::reject::custom(err))
                    }
                }
            }
        })
        .and(warp::any().map(move || endpoints.clone()))
        .and(crate::util::require_host())
        .and(warp::any().map(inject_db))
        .then(|content: (Option<serde_json::Value>, Option<serde_json::Value>, StatusCode), endpoints: IndiewebEndpoints, host: Authority, db: D| async move {
            let owner = format!("https://{}/", host.as_str());
            let blog_name = db.get_setting(crate::database::Settings::SiteName, &owner).await
                .unwrap_or_else(|_| "Kitty Box!".to_string());
            let feeds = db.get_channels(&owner).await.unwrap_or_default();
            match content {
                (Some(card), Some(feed), StatusCode::OK) => {
                    Box::new(warp::reply::html(Template {
                        title: &blog_name,
                        blog_name: &blog_name,
                        endpoints: Some(endpoints),
                        feeds,
                        user: None, // TODO
                        content: MainPage { feed: &feed, card: &card }.to_string()
                    }.to_string())) as Box<dyn warp::Reply>
                },
                (None, None, StatusCode::NOT_FOUND) => {
                    // TODO Onboarding
                    Box::new(warp::redirect::found(
                        hyper::Uri::from_static("/onboarding")
                    )) as Box<dyn warp::Reply>
                }
                _ => unreachable!()
            }
        })
}

pub fn onboarding<D: 'static + Storage>(
    db: D,
    endpoints: IndiewebEndpoints,
    http: reqwest::Client
) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone {
    let inject_db = move || db.clone();
    warp::get()
        .map(move || warp::reply::html(Template {
            title: "Kittybox - Onboarding",
            blog_name: "Kittybox",
            endpoints: Some(endpoints.clone()),
            feeds: vec![],
            user: None,
            content: OnboardingPage {}.to_string()
        }.to_string()))
        .or(warp::post()
            .and(crate::util::require_host())
            .and(warp::any().map(inject_db))
            .and(warp::body::json::<OnboardingData>())
            .and(warp::any().map(move || http.clone()))
            .and_then(|host: warp::host::Authority, db: D, body: OnboardingData, http: reqwest::Client| async move {
                let user_uid = format!("https://{}/", host.as_str());
                if db.post_exists(&user_uid).await.map_err(FrontendError::from)? {
                    
                    return Ok(warp::redirect(hyper::Uri::from_static("/")));
                }
                let user = crate::indieauth::User::new(&user_uid, "https://kittybox.fireburn.ru/", "create");
                if body.user["type"][0] != "h-card" || body.first_post["type"][0] != "h-entry" {
                    return Err(FrontendError::with_code(StatusCode::BAD_REQUEST, "user and first_post should be an h-card and an h-entry").into());
                }
                db.set_setting(crate::database::Settings::SiteName, user.me.as_str(), &body.blog_name)
                    .await
                    .map_err(FrontendError::from)?;

                let (_, hcard) = {
                    let mut hcard = body.user;
                    hcard["properties"]["uid"] = serde_json::json!([&user_uid]);
                    crate::micropub::normalize_mf2(hcard, &user)
                };
                db.put_post(&hcard, &user_uid).await.map_err(FrontendError::from)?;
                let (uid, post) = crate::micropub::normalize_mf2(body.first_post, &user);
                crate::micropub::_post(user, uid, post, db, http).await.map_err(|e| {
                    FrontendError {
                        msg: "Error while posting the first post".to_string(),
                        source: Some(Box::new(e)),
                        code: StatusCode::INTERNAL_SERVER_ERROR
                    }
                })?;
                Ok::<_, warp::Rejection>(warp::redirect(hyper::Uri::from_static("/")))
            }))
        
}

#[forbid(clippy::unwrap_used)]
pub fn catchall<D: Storage>(db: D, endpoints: IndiewebEndpoints) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone {
    let inject_db = move || db.clone();
    warp::any()
        .map(inject_db.clone())
        .and(request_uri())
        .and(warp::query())
        .and_then(|db: D, path: String, query: QueryParams| async move {
            get_post_from_database(&db, &path, query.after, &None).map_err(warp::reject::custom).await
        })
        // Rendering pipeline
        .and_then(|post: serde_json::Value| async move {
            let post_name = &post["properties"]["name"][0].as_str().to_owned();
            match post["type"][0]
                .as_str()
            {
                Some("h-entry") => Ok((
                    post_name.unwrap_or("Note").to_string(),
                    kittybox_templates::Entry { post: &post }.to_string(),
                    StatusCode::OK
                )),
                Some("h-card") => Ok((
                    post_name.unwrap_or("Contact card").to_string(),
                    kittybox_templates::VCard { card: &post }.to_string(),
                    StatusCode::OK
                )),
                Some("h-feed") => Ok((
                    post_name.unwrap_or("Feed").to_string(),
                    kittybox_templates::Feed { feed: &post }.to_string(),
                    StatusCode::OK
                )),
                _ => Err(warp::reject::custom(FrontendError::with_code(
                    StatusCode::INTERNAL_SERVER_ERROR,
                    &format!("Couldn't render an unknown type: {}", post["type"][0]),
                )))
            }
        })
        .recover(|err: warp::Rejection| {
            use warp::Rejection;
            use futures_util::future;
            if let Some(err) = err.find::<FrontendError>() {
                return future::ok::<(String, String, StatusCode), Rejection>((
                    format!("Error: HTTP {}", err.code().as_u16()),
                    ErrorPage { code: err.code(), msg: Some(err.msg().to_string()) }.to_string(),
                    err.code()
                ));
            }
            future::err::<(String, String, StatusCode), Rejection>(err)
        })
        .unify()
        .and(warp::any().map(move || endpoints.clone()))
        .and(crate::util::require_host())
        .and(warp::any().map(inject_db))
        .then(|content: (String, String, StatusCode), endpoints: IndiewebEndpoints, host: Authority, db: D| async move {
            let owner = format!("https://{}/", host.as_str());
            let blog_name = db.get_setting(crate::database::Settings::SiteName, &owner).await
                .unwrap_or_else(|_| "Kitty Box!".to_string());
            let feeds = db.get_channels(&owner).await.unwrap_or_default();
            let (title, content, code) = content;
            warp::reply::with_status(warp::reply::html(Template {
                title: &title,
                blog_name: &blog_name,
                endpoints: Some(endpoints),
                feeds,
                user: None, // TODO
                content,
            }.to_string()), code)
        })

}

static STYLE_CSS: &[u8] = include_bytes!("./style.css");
static ONBOARDING_JS: &[u8] = include_bytes!("./onboarding.js");
static ONBOARDING_CSS: &[u8] = include_bytes!("./onboarding.css");

static MIME_JS: &str = "application/javascript";
static MIME_CSS: &str = "text/css";

fn _dispatch_static(name: &str) -> Option<(&'static [u8], &'static str)> {
    match name {
        "style.css" => Some((STYLE_CSS, MIME_CSS)),
        "onboarding.js" => Some((ONBOARDING_JS, MIME_JS)),
        "onboarding.css" => Some((ONBOARDING_CSS, MIME_CSS)),
        _ => None
    }
}

pub fn static_files() -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Copy {
    use futures_util::future;

    warp::get()
        .and(warp::path::param()
             .and_then(|filename: String| {
                 match _dispatch_static(&filename) {
                     Some((buf, content_type)) => future::ok(
                         warp::reply::with_header(
                             buf, "Content-Type", content_type
                         )
                     ),
                     None => future::err(warp::reject())
                 }
             }))
        .or(warp::head()
            .and(warp::path::param()
                 .and_then(|filename: String| {
                     match _dispatch_static(&filename) {
                         Some((buf, content_type)) => future::ok(
                             warp::reply::with_header(
                                 warp::reply::with_header(
                                     warp::reply(), "Content-Type", content_type
                                 ),
                                 "Content-Length", buf.len()
                             )
                         ),
                         None => future::err(warp::reject())
                     }
                 })))
}