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










                                                        
                                 

















                                                                       
                                                                                     





                                                                                         


                                                                                                                



















































































































































































































































































































































                                                                                                                                                                                                                                                                                                                                              






                                        
























































                                                                                                                                                                       



                                                                                                                    

                                                                                
                                                                                




























                                                                                    


                                                         









                                                                                   
                                                                                


















                                                                                                                             


                                                       










                                                                                                                  
                                                                                        












                                                                           






                                                           















                                                                                              
use serde::{Serialize, Deserialize};
use tide::{Request, Response, Result, StatusCode, Next};
use log::{info,error};
use crate::ApplicationState;
use crate::database::Storage;

static POSTS_PER_PAGE: usize = 20;

mod templates {
    use log::error;
    use http_types::StatusCode;
    use ellipse::Ellipse;
    use chrono;
    use super::IndiewebEndpoints;

    /// Return a pretty location specifier from a geo: URI.
    fn decode_geo_uri(uri: &str) -> String {
        if let Some(part) = uri.split(":").collect::<Vec<_>>().get(1) {
            if let Some(part) = part.split(";").next() {
                let mut parts = part.split(",");
                let lat = parts.next().unwrap();
                let lon = parts.next().unwrap();
                // TODO - format them as proper latitude and longitude
                return format!("{}, {}", lat, lon)
            } else {
                return uri.to_string()
            }
        } else {
            return uri.to_string()
        }
    }

    markup::define! {
        Template<'a>(title: &'a str, endpoints: IndiewebEndpoints, content: String) {
            @markup::doctype()
            html {
                head {
                    title { @title }
                    link[rel="preconnect", href="https://fonts.gstatic.com"];
                    link[rel="stylesheet", href="/static/style.css"];
                    meta[name="viewport", content="initial-scale=1, width=device-width"];
                    // TODO: link rel= for common IndieWeb APIs: webmention, microsub
                    link[rel="micropub", href="/micropub"]; // Static, because it's built into the server itself
                    link[rel="authorization_endpoint", href=&endpoints.authorization_endpoint];
                    link[rel="token_endpoint", href=&endpoints.token_endpoint];
                }
                body {
                    nav#headerbar {
                        // TODO: Find a way to store the website name somewhere in the database
                        // Maybe in the settings?
                        ul {
                            li { a#homepage[href="/"] { "Vika's Hideout" } }
                            li.shiftright { a#login[href="/login"] { "Login" } }
                        }
                    }
                    main {
                        @markup::raw(content)
                    }
                }
            }
        }
        Entry<'a>(post: &'a serde_json::Value) {
            article."h-entry" {
                header.metadata {
                    @if post["properties"]["name"][0].is_string() {
                        h1."p-name" {
                            @post["properties"]["name"][0].as_str().unwrap()
                        }
                    }
                    div {
                        span {
                            a."u-url"."u-uid"[href=post["properties"]["uid"][0].as_str().unwrap()] {
                                time."dt-published"[datetime=post["properties"]["published"][0].as_str().unwrap()] {
                                    @chrono::DateTime::parse_from_rfc3339(post["properties"]["published"][0].as_str().unwrap())
                                        .map(|dt| dt.format("%a %b %e %T %Y").to_string())
                                        .unwrap_or("ERROR: Couldn't parse the datetime".to_string())
                                }
                            }
                        }
                        @if post["properties"]["category"].is_array() {
                            span {
                                ul.categories {
                                    "Tagged: "
                                    @for cat in post["properties"]["category"].as_array().unwrap() {
                                        li."p-category" { @cat.as_str().unwrap() }
                                    }
                                }
                            }
                        }
                        @if post["properties"]["in-reply-to"].is_array() {
                            // TODO: Rich reply contexts - blocked on MF2 parser
                            span {
                                "In reply to: "
                                ul.replyctx {
                                    @for ctx in post["properties"]["in-reply-to"].as_array().unwrap() {
                                        li { a."u-in-reply-to"[href=ctx.as_str().unwrap()] {
                                            @ctx.as_str().unwrap().truncate_ellipse(24).as_ref()
                                        } }
                                    }
                                }
                            }
                        }
                    }
                    @if post["properties"]["location"].is_array() || post["properties"]["checkin"].is_array() {
                        div {
                            @if post["properties"]["checkin"].is_array() {
                                span {
                                    "Check-in to: "
                                    @if post["properties"]["checkin"][0].is_string() {
                                        // It's a URL
                                        a."u-checkin"[href=post["properties"]["checkin"][0].as_str().unwrap()] {
                                            @post["properties"]["checkin"][0].as_str().unwrap().truncate_ellipse(24).as_ref()
                                        }
                                    } else {
                                        a."u-checkin"[href=post["properties"]["checkin"][0]["properties"]["uid"][0].as_str().unwrap()] {
                                            @post["properties"]["checkin"][0]["properties"]["name"][0].as_str().unwrap()
                                        }
                                    }
                                }
                            }
                            @if post["properties"]["location"].is_array() {
                                span {
                                    "Location: "
                                    @if post["properties"]["location"][0].is_string() {
                                        // It's a geo: URL
                                        // We need to decode it
                                        a."u-location"[href=post["properties"]["location"][0].as_str().unwrap()] {
                                            @decode_geo_uri(post["properties"]["location"][0].as_str().unwrap())
                                        }
                                    } else {
                                        // It's an inner h-geo object
                                        a."u-location"[href=post["properties"]["location"][0]["value"].as_str().map(|x| x.to_string()).unwrap_or(format!("geo:{},{}", post["properties"]["location"][0]["properties"]["latitude"][0].as_str().unwrap(), post["properties"]["location"][0]["properties"]["longitude"][0].as_str().unwrap()))] {
                                            // I'm a lazy bitch
                                            @decode_geo_uri(&post["properties"]["location"][0]["value"].as_str().map(|x| x.to_string()).unwrap_or(format!("geo:{},{}", post["properties"]["location"][0]["properties"]["latitude"][0].as_str().unwrap(), post["properties"]["location"][0]["properties"]["longitude"][0].as_str().unwrap())))
                                        }
                                    }
                                }
                            }
                        }
                    }
                    @if post["properties"]["ate"].is_array() || post["properties"]["drank"].is_array() {
                        div {
                            @if post["properties"]["ate"].is_array() {
                                span { ul {
                                    "Ate:"
                                    @for food in post["properties"]["ate"].as_array().unwrap() {
                                        li {
                                            @if food.is_string() {
                                                // If this is a string, it's a URL.
                                                a."u-ate"[href=food.as_str().unwrap()] {
                                                    @food.as_str().unwrap().truncate_ellipse(24).as_ref()
                                                }
                                            } else {
                                                // This is a rich food object (mm, sounds tasty! I wanna eat something tasty)
                                                a."u-ate"[href=food["properties"]["uid"][0].as_str().unwrap()] {
                                                    @food["properties"]["name"][0].as_str()
                                                        .unwrap_or(food["properties"]["uid"][0].as_str().unwrap().truncate_ellipse(24).as_ref())
                                                }
                                            }
                                        }
                                    }
                                } }
                            }
                            @if post["properties"]["drank"].is_array() {
                                span { ul {
                                    "Drank:"
                                    @for food in post["properties"]["drank"].as_array().unwrap() {
                                        li {
                                            @if food.is_string() {
                                                // If this is a string, it's a URL.
                                                a."u-drank"[href=food.as_str().unwrap()] {
                                                    @food.as_str().unwrap().truncate_ellipse(24).as_ref()
                                                }
                                            } else {
                                                // This is a rich food object (mm, sounds tasty! I wanna eat something tasty)
                                                a."u-drank"[href=food["properties"]["uid"][0].as_str().unwrap()] {
                                                    @food["properties"]["name"][0].as_str()
                                                        .unwrap_or(food["properties"]["uid"][0].as_str().unwrap().truncate_ellipse(24).as_ref())
                                                }
                                            }
                                        }
                                    }
                                } }
                            }
                        }
                    }
                }
                @PhotoGallery { photos: post["properties"]["photo"].as_array() }
                @if post["properties"]["content"][0]["html"].is_string() {
                    main."e-content" {
                        @markup::raw(post["properties"]["content"][0]["html"].as_str().unwrap().trim())
                    }
                }
                @WebInteractions { post: post }
            }
        }
        PhotoGallery<'a>(photos: Option<&'a Vec<serde_json::Value>>) {
            @if photos.is_some() {
                @for photo in photos.unwrap() {
                    @if photo.is_string() {
                        img."u-photo"[src=photo.as_str().unwrap(), loading="lazy"];
                    } else if photo.is_array() {
                        img."u-photo"[src=photo["value"].as_str().unwrap(), loading="lazy", alt=photo["alt"].as_str().unwrap_or("")];
                    }
                }
            }
        }
        WebInteractions<'a>(post: &'a serde_json::Value) {
            footer.webinteractions {
                ul.counters {
                    li {
                        span.icon { "❤️" }
                        span.counter { @post["properties"]["like"].as_array().map(|a| a.len()).unwrap_or(0) }
                    }
                    li {
                        span.icon { "💬" }
                        span.counter { @post["properties"]["comment"].as_array().map(|a| a.len()).unwrap_or(0) }
                    }
                    li {
                        span.icon { "🔄" }
                        span.counter { @post["properties"]["repost"].as_array().map(|a| a.len()).unwrap_or(0) }
                    }
                    li {
                        span.icon { "🔖" }
                        span.counter { @post["properties"]["bookmark"].as_array().map(|a| a.len()).unwrap_or(0) }
                    }
                }
                // Needs rich webmention support which may or may not depend on an MF2 parser
                // Might circumvent with an external parser with CORS support
                // why write this stuff in rust then tho
                /*details {
                    summary { "Show comments and reactions" }
                    @if post["properties"]["like"].as_array().map(|a| a.len()).unwrap_or(0) > 0 {
                        // Show a facepile of likes for a post
                    }
                    @if post["properties"]["bookmark"].as_array().map(|a| a.len()).unwrap_or(0) > 0 {
                        // Show a facepile of bookmarks for a post
                    }
                    @if post["properties"]["repost"].as_array().map(|a| a.len()).unwrap_or(0) > 0 {
                        // Show a facepile of reposts for a post
                    }
                    @if post["properties"]["comment"].as_array().map(|a| a.len()).unwrap_or(0) > 0 {
                        // Show all the comments recursively (so we could do Salmention with them)
                    }
                }*/
            }
        }
        VCard<'a>(card: &'a serde_json::Value) {
            article."h-card" {
                @if card["properties"]["photo"][0].is_string() {
                    img."u-photo"[src=card["properties"]["photo"][0].as_str().unwrap()];
                }
                h1 { 
                    a."u-url"."u-uid"."p-name"[href=card["properties"]["uid"][0].as_str().unwrap()] {
                        @card["properties"]["name"][0].as_str().unwrap()
                    }
                }
                @if card["properties"]["pronoun"].is_array() {
                    span {
                        "("
                        @for (i, pronoun) in card["properties"]["pronoun"].as_array().unwrap().iter().filter_map(|v| v.as_str()).enumerate() {
                            span."p-pronoun" {
                                @pronoun
                            }
                            // Insert commas between multiple sets of pronouns
                            @if i < (card["properties"]["pronoun"].as_array().unwrap().len() - 1) {", "}
                        }
                        ")"
                    }
                }
                p."p-note" {
                    @card["properties"]["note"][0]["value"].as_str().unwrap_or_else(|| card["properties"]["note"][0].as_str().unwrap())
                }
            }
        }
        Food<'a>(food: &'a serde_json::Value) {
            article."h-food" {
                header.metadata {
                    h1 {
                        a."p-name"."u-url"[href=food["properties"]["url"][0].as_str().unwrap()] {
                            @food["properties"]["name"][0].as_str().unwrap()
                        }
                    }
                }
                @PhotoGallery { photos: food["properties"]["photo"].as_array() }
            }
        }
        Feed<'a>(feed: &'a serde_json::Value) {
            div."h-feed" {
                div.metadata {
                    @if feed["properties"]["name"][0].is_string() {
                        h1."p-name".titanic {
                            a[href=feed["properties"]["uid"][0].as_str().unwrap(), rel="feed"] {
                                @feed["properties"]["name"][0].as_str().unwrap()
                            }
                        }
                    }
                }
                @if feed["children"].is_array() {
                    @for child in feed["children"].as_array().unwrap() {
                        @match child["type"][0].as_str().unwrap() {
                            "h-entry" => { @Entry { post: child } }
                            "h-feed" => { @Feed { feed: child } }
                            "h-event" => {
                                @{error!("Templating error: h-events aren't implemented yet");}
                            }
                            "h-card" => { @VCard { card: child }}
                            something_else => {
                                @{error!("Templating error: found a {} object that couldn't be parsed", something_else);}
                            }
                        }
                    }
                }
                @if feed["children"].as_array().map(|a| a.len()).unwrap_or(0) == 0 {
                    p {
                        "Looks like you reached the end. Wanna jump back to the "
                        a[href=feed["properties"]["uid"][0].as_str().unwrap()] {
                            "beginning"
                        } "?"
                    }
                }
                @if feed["children"].as_array().map(|a| a.len()).unwrap_or(0) == super::POSTS_PER_PAGE {
                    a[rel="prev", href=feed["properties"]["uid"][0].as_str().unwrap().to_string() 
                        + "?after=" + feed["children"][super::POSTS_PER_PAGE - 1]["properties"]["uid"][0].as_str().unwrap()] {
                        "Older posts"
                    }
                }
            }
        }
        MainPage<'a>(feed: &'a serde_json::Value, card: &'a serde_json::Value) {
            .sidebyside {
                @VCard { card }
                #dynamicstuff {
                    p { "This section will provide interesting statistics or tidbits about my life in this exact moment (with maybe a small delay)." }
                    p { "It will probably require JavaScript to self-update, but I promise to keep this widget lightweight and open-source!" }
                    p { small { 
                        "JavaScript isn't a menace, stop fearing it or I will switch to WebAssembly " 
                        "and knock your nico-nico-kneecaps so fast with its speed you won't even notice that... "
                        small { "omae ha mou shindeiru" }
                        // NANI?!!!
                    } }
                }
            }
            @Feed { feed }
        }
        ErrorPage(code: StatusCode) {
            h1 { @format!("HTTP {} {}", code, code.canonical_reason()) }
            @match code {
                StatusCode::Unauthorized => {
                    p { "Looks like you need to authenticate yourself before seeing this page. Try logging in with IndieAuth using the Login button above!" }
                }
                StatusCode::Forbidden => {
                    p { "Looks like you're forbidden from viewing this page." }
                    p {
                        "This might've been caused by being banned from viewing my website"
                        "or simply by trying to see what you're not supposed to see, "
                        "like a private post that's not intended for you. It's ok, it happens."
                    }
                }
                StatusCode::Gone => {
                    p { "Looks like the page you're trying to find is gone and is never coming back." }
                }
                StatusCode::UnavailableForLegalReasons => {
                p { "The page is there, but I can't legally provide it to you because the censorship said so." }
                }
                StatusCode::NotFound => {
                    p { "Looks like there's no such page. Maybe you or someone else mistyped a URL or my database experienced data loss." }
                }
                StatusCode::ImATeapot => {
                    p { "Wait, do you seriously expect my website to brew you coffee? It's not a coffee machine!" }

                    p { 
                        small { "I could brew you some coffee tho if we meet one day... "
                        small { i { "i-it's nothing personal, I just like brewing coffee, b-baka!!!~ >.<!" } } }
                    }
                }
                _ => { p { "It seems like you have found an error. Not to worry, it has already been logged." } }
            }
            P { "For now, may I suggest to visit " a[href="/"] {"the main page"} " of this website?" }
        
        }
    }
}

use templates::{Template,ErrorPage,MainPage};

#[derive(Clone, Serialize, Deserialize)]
pub struct IndiewebEndpoints {
    authorization_endpoint: String,
    token_endpoint: String,
    webmention: Option<String>,
    microsub: Option<String>
}

#[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(code: StatusCode, msg: &str) -> Self {
        Self { msg: msg.to_string(), source: None, code }
    }
    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::InternalServerError
        }
    }
}
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)
    }
}

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::NotFound, "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())
        }
    }
}

pub async fn coffee<S: Storage>(_: Request<ApplicationState<S>>) -> Result {
    Err(FrontendError::with_code(StatusCode::ImATeapot, "Someone asked this website to brew them some coffee..."))?;
    return Ok(Response::builder(500).build()) // unreachable
}

pub async fn mainpage<S: Storage>(req: Request<ApplicationState<S>>) -> Result {
    let backend = &req.state().storage;
    let query = req.query::<QueryParams>()?;
    let authorization_endpoint = req.state().authorization_endpoint.to_string();
    let token_endpoint = req.state().token_endpoint.to_string();
    let user: Option<String> = None;

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

    info!("Request at {}", url);
    let hcard_url = url.as_str();
    let feed_url = url.join("feeds/main").unwrap().to_string();
    
    let card = get_post_from_database(backend, hcard_url, None, &user).await;
    let feed = get_post_from_database(backend, &feed_url, query.after, &user).await;

    if card.is_err() && feed.is_err() {
        // Uh-oh! No main feed and no h-card? Need to do onboarding.
        // We can do it from inside the app without ever requesting an auth token.
        let card_err = card.unwrap_err();
        let feed_err = feed.unwrap_err();
        if card_err.code == 404 {
            // Yes, we definitely need some onboarding here.
            todo!()
        } else {
            Err(feed_err)?
        }
    } else {
        Ok(Response::builder(200)
            .content_type("text/html; charset=utf-8")
            .body(Template {
                title: &format!("{} - Main page", url.host().unwrap().to_string()),
                endpoints: IndiewebEndpoints {
                  authorization_endpoint, token_endpoint,
                  webmention: None, microsub: None
                },
                content: MainPage {
                    feed: &feed?,
                    card: &card?
                }.to_string()
            }.to_string()
        ).build())
    }
}

pub async fn render_post<S: Storage>(req: Request<ApplicationState<S>>) -> Result {
    let query = req.query::<QueryParams>()?;
    let authorization_endpoint = req.state().authorization_endpoint.to_string();
    let token_endpoint = req.state().token_endpoint.to_string();
    let user: Option<String> = None;

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

    let post = get_post_from_database(&req.state().storage, url.as_str(), query.after, &user).await?;

    let template: String = match post["type"][0].as_str().expect("Empty type array or invalid type") {
        "h-entry" => templates::Entry { post: &post }.to_string(),
        "h-card" => templates::VCard { card: &post }.to_string(),
        "h-feed" => templates::Feed { feed: &post }.to_string(),
        _ => Err(FrontendError::with_code(StatusCode::InternalServerError, "Couldn't render an unknown type"))?
    };

    Ok(Response::builder(200)
        .content_type("text/html; charset=utf-8")
        .body(Template {
            title: post["properties"]["name"][0].as_str().unwrap_or(&format!("Note at {}", url.host().unwrap().to_string())),
            endpoints: IndiewebEndpoints {
                authorization_endpoint, token_endpoint,
                webmention: None, microsub: None
            },
            content: template
        }.to_string()
    ).build())
}

pub struct ErrorHandlerMiddleware {}

#[async_trait::async_trait]
impl<S> tide::Middleware<ApplicationState<S>> for ErrorHandlerMiddleware where
    S: crate::database::Storage
{
    async fn handle(&self, request: Request<ApplicationState<S>>, next: Next<'_, ApplicationState<S>>) -> Result {
        let authorization_endpoint = request.state().authorization_endpoint.to_string();
        let token_endpoint = request.state().token_endpoint.to_string();
        let mut res = next.run(request).await;
        let mut code: Option<StatusCode> = None;
        if let Some(err) = res.downcast_error::<FrontendError>() {
            code = Some(err.code());
            error!("Error caught while processing request: {}", err.msg());
            let mut err: &dyn std::error::Error = err;
            while let Some(e) = err.source() {
                error!("Caused by: {}", e);
                err = e;
            }
        }
        if let Some(code) = code {
            res.set_status(code);
            res.set_content_type("text/html; charset=utf-8");
            res.set_body(Template {
                title: "Error",
                endpoints: IndiewebEndpoints {
                    authorization_endpoint, token_endpoint,
                    webmention: None, microsub: None
                },
                content: ErrorPage { code }.to_string()
            }.to_string());
        }
        Ok(res)
    }
}

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

pub async fn handle_static<S: Storage>(req: Request<ApplicationState<S>>) -> Result {
    Ok(match req.param("path") {
        Ok("style.css") => Ok(Response::builder(200)
            .content_type("text/css; charset=utf-8")
            .body(STYLE_CSS)
            .build()),
        Ok(_) => Err(FrontendError::with_code(StatusCode::NotFound, "Static file not found")),
        Err(_) => panic!("Invalid usage of the frontend::handle_static() function")
    }?)
}