diff --git a/src/infrastructure/error.rs b/src/infrastructure/error.rs index 90deef2a..830fb9ef 100644 --- a/src/infrastructure/error.rs +++ b/src/infrastructure/error.rs @@ -57,6 +57,9 @@ quick_error! { String(err: ::std::string::FromUtf8Error){ from() } + Str(err: ::std::str::Utf8Error){ + from() + } Csv(err: ::csv::Error){ from() } diff --git a/src/ports/web/frontend/mod.rs b/src/ports/web/frontend/mod.rs index 6b7accef..480d3a57 100644 --- a/src/ports/web/frontend/mod.rs +++ b/src/ports/web/frontend/mod.rs @@ -1,13 +1,17 @@ use crate::{ core::{prelude::*, usecases}, - infrastructure::db::sqlite, + infrastructure::{db::sqlite, error::*, flows::prelude::*}, ports::web::{guards::*, tantivy::SearchEngine}, }; use maud::Markup; use rocket::{ self, http::RawStr, - response::content::{Css, JavaScript}, + request::Form, + response::{ + content::{Css, JavaScript}, + Flash, Redirect, + }, Route, }; @@ -18,6 +22,8 @@ mod view; const MAP_JS: &str = include_str!("map.js"); const MAIN_CSS: &str = include_str!("main.css"); +type Result = std::result::Result; + #[get("/")] pub fn get_index_user(account: Account) -> Markup { view::index(Some(&account.email())) @@ -35,8 +41,9 @@ pub fn get_index_html() -> Markup { #[get("/search?&")] pub fn get_search(search_engine: SearchEngine, q: &RawStr, limit: Option) -> Result { - let entries = usecases::global_search(&search_engine, q.as_str(), limit.unwrap_or(10))?; - Ok(view::search_results(q.as_str(), &entries)) + let q = q.url_decode()?; + let entries = usecases::global_search(&search_engine, &q, limit.unwrap_or(10))?; + Ok(view::search_results(None, &q, &entries)) } #[get("/map.js")] @@ -51,24 +58,40 @@ pub fn get_main_css() -> Css<&'static str> { #[get("/events/")] pub fn get_event(db: sqlite::Connections, id: &RawStr) -> Result { - let mut ev = usecases::get_event(&*db.shared().map_err(RepoError::from)?, &id)?; + let mut ev = usecases::get_event(&*db.shared()?, &id)?; // TODO: // Make sure within usecase that the creator email // is not shown to unregistered users ev.created_by = None; - Ok(view::event(ev)) + Ok(view::event(None, ev)) } #[get("/entries/")] +pub fn get_entry_admin(pool: sqlite::Connections, id: &RawStr, admin: Admin) -> Result { + //TODO: dry out + let (e, ratings) = { + let db = pool.shared()?; + let e = db.get_entry(id.as_str())?; + let ratings = db.load_ratings_of_entry(&e.id)?; + let ratings_with_comments = db.zip_ratings_with_comments(ratings)?; + (e, ratings_with_comments) + }; + Ok(view::entry( + Some(&admin.0), + (e, ratings, Role::Admin).into(), + )) +} + +#[get("/entries/", rank = 2)] pub fn get_entry(pool: sqlite::Connections, id: &RawStr) -> Result { let (e, ratings) = { - let db = pool.shared().map_err(RepoError::from)?; + let db = pool.shared()?; let e = db.get_entry(id.as_str())?; let ratings = db.load_ratings_of_entry(&e.id)?; let ratings_with_comments = db.zip_ratings_with_comments(ratings)?; (e, ratings_with_comments) }; - Ok(view::entry((e, ratings).into())) + Ok(view::entry(None, (e, ratings).into())) } #[get("/events")] @@ -78,8 +101,7 @@ pub fn get_events(db: sqlite::Connections) -> Result { .unwrap() .naive_utc(); let mut events: Vec<_> = db - .shared() - .map_err(RepoError::from)? + .shared()? .all_events()? .into_iter() .filter(|e| e.start > yesterday) @@ -91,7 +113,7 @@ pub fn get_events(db: sqlite::Connections) -> Result { #[get("/dashboard")] pub fn get_dashboard(db: sqlite::Connections, admin: Admin) -> Result { let data = { - let db = db.shared().map_err(RepoError::from)?; + let db = db.shared()?; let tag_count = db.count_tags()?; let entry_count = db.count_entries()?; let user_count = db.count_users()?; @@ -104,7 +126,47 @@ pub fn get_dashboard(db: sqlite::Connections, admin: Admin) -> Result { user_count, } }; - Ok(view::dashboard(data)) + Ok(view::dashboard(Some(&admin.0), data)) +} + +#[derive(FromForm)] +pub struct ArchiveAction { + ids: String, + entry_id: String, +} + +#[post("/comments/actions/archive", data = "")] +pub fn post_comments_archive( + db: sqlite::Connections, + data: Form, +) -> std::result::Result> { + //TODO: dry out + let d = data.into_inner(); + let ids: Vec<_> = d.ids.split(',').filter(|id| !id.is_empty()).collect(); + match archive_comments(&db, &ids) { + Err(_) => Err(Flash::error( + Redirect::to(uri!(get_entry:d.entry_id)), + "Failed to achive the comment.", + )), + Ok(_) => Ok(Redirect::to(uri!(get_entry:d.entry_id))), + } +} + +#[post("/ratings/actions/archive", data = "")] +pub fn post_ratings_archive( + db: sqlite::Connections, + mut search_engine: SearchEngine, + data: Form, +) -> std::result::Result> { + let d = data.into_inner(); + let ids: Vec<_> = d.ids.split(',').filter(|id| !id.is_empty()).collect(); + match archive_ratings(&db, &mut search_engine, &ids) { + Err(_) => Err(Flash::error( + Redirect::to(uri!(get_entry:d.entry_id)), + "Failed to archive the rating.", + )), + Ok(_) => Ok(Redirect::to(uri!(get_entry:d.entry_id))), + } } pub fn routes() -> Vec { @@ -115,10 +177,13 @@ pub fn routes() -> Vec { get_dashboard, get_search, get_entry, + get_entry_admin, get_events, get_event, get_main_css, get_map_js, + post_comments_archive, + post_ratings_archive, login::get_login, login::get_login_user, login::post_login, diff --git a/src/ports/web/frontend/view/dashboard.rs b/src/ports/web/frontend/view/dashboard.rs new file mode 100644 index 00000000..b7637e57 --- /dev/null +++ b/src/ports/web/frontend/view/dashboard.rs @@ -0,0 +1,42 @@ +use super::page; +use maud::{html, Markup}; + +pub struct DashBoardPresenter<'a> { + pub email: &'a str, + pub entry_count: usize, + pub event_count: usize, + pub tag_count: usize, + pub user_count: usize, +} + +pub fn dashboard(email: Option<&str>, data: DashBoardPresenter) -> Markup { + page( + "Admin Dashboard", + email, + None, + None, + html! { + main { + h3 { "Database Statistics" } + table { + tr { + td {"Number of Entries"} + td {(data.entry_count)} + } + tr { + td {"Number of Events"} + td {(data.event_count)} + } + tr { + td {"Number of Users"} + td {(data.user_count)} + } + tr { + td {"Number of Tags"} + td {(data.tag_count)} + } + } + } + }, + ) +} diff --git a/src/ports/web/frontend/view/entry.rs b/src/ports/web/frontend/view/entry.rs new file mode 100644 index 00000000..69a9b1d1 --- /dev/null +++ b/src/ports/web/frontend/view/entry.rs @@ -0,0 +1,143 @@ +use super::{address_to_html, leaflet_css_link, map_scripts, page}; +use crate::core::prelude::*; +use maud::{html, Markup}; +use std::collections::HashMap; + +type Ratings = Vec<(Rating, Vec)>; + +pub struct EntryPresenter { + pub entry: Entry, + pub ratings: HashMap, + pub allow_archiving: bool, +} + +impl From<(Entry, Vec<(Rating, Vec)>, Role)> for EntryPresenter { + fn from((entry, rtngs, role): (Entry, Vec<(Rating, Vec)>, Role)) -> EntryPresenter { + let mut p: EntryPresenter = (entry, rtngs).into(); + p.allow_archiving = match role { + Role::Admin => true, + _ => false, + }; + p + } +} + +impl From<(Entry, Vec<(Rating, Vec)>)> for EntryPresenter { + fn from((entry, rtngs): (Entry, Vec<(Rating, Vec)>)) -> EntryPresenter { + let mut ratings: HashMap = HashMap::new(); + + for (r, comments) in rtngs { + if let Some(x) = ratings.get_mut(&r.context) { + x.push((r, comments)); + } else { + ratings.insert(r.context, vec![(r, comments)]); + } + } + let allow_archiving = false; + EntryPresenter { + entry, + ratings, + allow_archiving, + } + } +} + +pub fn entry(email: Option<&str>, e: EntryPresenter) -> Markup { + page( + &format!("{} | OpenFairDB", e.entry.title), + email, + None, + Some(leaflet_css_link()), + entry_detail(e), + ) +} + +fn entry_detail(e: EntryPresenter) -> Markup { + html! { + h3 { (e.entry.title) } + p {(e.entry.description)} + p { + table { + @if let Some(ref h) = e.entry.homepage { + tr { + td { "Homepage" } + td { a href=(h) { (h) } } + } + } + @if let Some(ref c) = e.entry.contact { + @if let Some(ref m) = c.email { + tr { + td { "eMail" } + td { a href=(format!("mailto:{}",m)) { (m) } } + } + } + @if let Some(ref t) = c.telephone { + tr { + td { "Telephone" } + td { a href=(format!("tel:{}",t)) { (t) } } + } + } + } + @if let Some(ref a) = e.entry.location.address { + @if !a.is_empty() { + tr { + td { "Address" } + td { (address_to_html(&a)) } + } + } + } + } + } + p { + ul { + @for t in &e.entry.tags{ + li{ (format!("#{}", t)) } + } + } + } + h3 { "Ratings" } + + @for (ctx, ratings) in e.ratings { + h4 { (format!("{:?}",ctx)) } + ul { + @for (r,comments) in ratings { + li { + (rating(&e.entry.id, e.allow_archiving, &r, &comments)) + } + } + } + } + div id="map" style="height:300px;" { } + (map_scripts(&[e.entry.into()])) + } +} + +fn rating(entry_id: &str, archive: bool, r: &Rating, comments: &[Comment]) -> Markup { + html! { + h5 { (r.title) " " span { (format!("({})",i8::from(r.value))) } } + @if archive { + form action = "/ratings/actions/archive" method = "POST" { + input type="hidden" name="ids" value=(r.id); + input type="hidden" name="entry_id" value=(entry_id); + input type="submit" value="archive rating"; + } + } + @if let Some(ref src) = r.source { + p { (format!("source: {}",src)) } + } + ul { + @for c in comments { + li { + p { (c.text) } + @if archive { + form action = "/comments/actions/archive" method = "POST" { + input type="hidden" name="ids" value=(c.id); + input type="hidden" name="entry_id" value=(entry_id); + input type="submit" value="archive comment"; + } + } + } + } + } + } +} diff --git a/src/ports/web/frontend/view.rs b/src/ports/web/frontend/view/mod.rs similarity index 62% rename from src/ports/web/frontend/view.rs rename to src/ports/web/frontend/view/mod.rs index ccf4a8fb..ba1ccf95 100644 --- a/src/ports/web/frontend/view.rs +++ b/src/ports/web/frontend/view/mod.rs @@ -1,21 +1,28 @@ use crate::core::prelude::*; -use maud::{html, Markup, DOCTYPE}; +use maud::{html, Markup}; use rocket::request::FlashMessage; -use std::collections::HashMap; const LEAFLET_CSS_URL: &str = "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.4.0/leaflet.css"; const LEAFLET_CSS_SHA512: &str="sha512-puBpdR0798OZvTTbP4A8Ix/l+A4dHDD0DGqYW6RQ+9jxkRFclaxxQb/SJAWZfWAkuyeQUytO7+7N4QKrDh+drA=="; const LEAFLET_JS_URL: &str = "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.4.0/leaflet.js"; const LEAFLET_JS_SHA512 : &str="sha512-QVftwZFqvtRNi0ZyCtsznlKSWOStnDORoefr1enyq5mVL4tmKB3S/EnC3rRJcxCPavG10IcrVGSmPh6Qw5lwrg=="; -const MAIN_CSS_URL: &str = "/main.css"; const MAP_JS_URL: &str = "/map.js"; +mod dashboard; +mod entry; +mod page; + +pub use dashboard::*; +pub use entry::*; +use page::*; + pub fn index(email: Option<&str>) -> Markup { page( "OpenFairDB Search", + email, + None, None, html! { - (header(email)) div class="search" { h1 {"OpenFairDB Search"} (global_search_form(None)) @@ -24,68 +31,6 @@ pub fn index(email: Option<&str>) -> Markup { ) } -fn header(email: Option<&str>) -> Markup { - html! { - header { - @if let Some(email) = email { - div class="msg" { "Your are logged in as " span class="email" { (email) } } - nav { - a href="/" { "search" } - a href="dashboard" { "dashboard" } - form class="logout" action="logout" method ="POST" { - input type="submit" value="logout"; - } - } - } - @ else { - nav { - a href="login" { "login" } - a href="register" { "register" } - } - } - } - } -} - -pub struct DashBoardPresenter<'a> { - pub email: &'a str, - pub entry_count: usize, - pub event_count: usize, - pub tag_count: usize, - pub user_count: usize, -} - -pub fn dashboard(data: DashBoardPresenter) -> Markup { - page( - "Admin Dashboard", - None, - html! { - (header(Some(data.email))) - main { - h3 { "Database Statistics" } - table { - tr { - td {"Number of Entries"} - td {(data.entry_count)} - } - tr { - td {"Number of Events"} - td {(data.event_count)} - } - tr { - td {"Number of Users"} - td {(data.user_count)} - } - tr { - td {"Number of Tags"} - td {(data.tag_count)} - } - } - } - }, - ) -} - pub fn global_search_form(search_term: Option<&str>) -> Markup { html! { div class="search-form" { @@ -114,9 +59,11 @@ fn leaflet_css_link() -> Markup { } } -pub fn search_results(search_term: &str, entries: &[IndexedEntry]) -> Markup { +pub fn search_results(email: Option<&str>, search_term: &str, entries: &[IndexedEntry]) -> Markup { page( "OpenFairDB Search Results", + email, + None, None, html! { div class="search" { @@ -156,123 +103,6 @@ fn entry_result(e: &IndexedEntry) -> Markup { } } -type Ratings = Vec<(Rating, Vec)>; - -pub struct EntryPresenter { - pub entry: Entry, - pub ratings: HashMap, -} - -impl From<(Entry, Vec<(Rating, Vec)>)> for EntryPresenter { - fn from((entry, rtngs): (Entry, Vec<(Rating, Vec)>)) -> EntryPresenter { - let mut ratings: HashMap = HashMap::new(); - - for (r, comments) in rtngs { - if let Some(x) = ratings.get_mut(&r.context) { - x.push((r, comments)); - } else { - ratings.insert(r.context, vec![(r, comments)]); - } - } - - EntryPresenter { entry, ratings } - } -} - -pub fn entry(e: EntryPresenter) -> Markup { - page( - &format!("{} | OpenFairDB", e.entry.title), - Some(leaflet_css_link()), - entry_detail(e), - ) -} - -fn entry_detail(e: EntryPresenter) -> Markup { - html! { - h3 { (e.entry.title) } - p {(e.entry.description)} - p { - table { - @if let Some(ref h) = e.entry.homepage { - tr { - td { "Homepage" } - td { a href=(h) { (h) } } - } - } - @if let Some(ref c) = e.entry.contact { - @if let Some(ref m) = c.email { - tr { - td { "eMail" } - td { a href=(format!("mailto:{}",m)) { (m) } } - } - } - @if let Some(ref t) = c.telephone { - tr { - td { "Telephone" } - td { a href=(format!("tel:{}",t)) { (t) } } - } - } - } - @if let Some(ref a) = e.entry.location.address { - @if !a.is_empty() { - tr { - td { "Address" } - td { (address_to_html(&a)) } - } - } - } - } - } - p { - ul { - @for t in &e.entry.tags{ - li{ (format!("#{}", t)) } - } - } - } - h3 { "Ratings" } - - @for (ctx, ratings) in e.ratings { - h4 { (format!("{:?}",ctx)) } - ul { - @for (r,comments) in ratings { - li { - (rating(&r, &comments)) - } - } - } - } - div id="map" style="height:300px;" { } - (map_scripts(&[e.entry.into()])) - } -} - -fn rating(r: &Rating, comments: &[Comment]) -> Markup { - html! { - h5 { (r.title) " " span { (format!("({})",i8::from(r.value))) } } - // TODO: - // form action = "/rating/delete" method = "POST" { - // input type="hidden" name="id" value=(r.id); - // input type="submit" value="delete rating"; - // } - @if let Some(ref src) = r.source { - p { (format!("source: {}",src)) } - } - ul { - @for c in comments { - li { - p { (c.text) } - // TODO: - // form action = "/comments/delete" method = "POST" { - // input type="hidden" name="id" value=(c.id); - // input type="submit" value="delete comment"; - // } - } - } - } - } -} - fn address_to_html(addr: &Address) -> Markup { html! { @if let Some(ref s) = addr.street { @@ -290,9 +120,11 @@ fn address_to_html(addr: &Address) -> Markup { } } -pub fn event(ev: Event) -> Markup { +pub fn event(email: Option<&str>, ev: Event) -> Markup { page( &ev.title, + email, + None, Some(html! { link rel="stylesheet" @@ -459,6 +291,8 @@ pub fn events(events: &[Event]) -> Markup { page( "List of Events", None, + None, + None, html! { div class="events" { h3 { "Events" } @@ -498,10 +332,11 @@ pub fn events(events: &[Event]) -> Markup { } pub fn login(flash: Option) -> Markup { - flash_page( + page( "Login", None, flash, + None, html! { form class="login" action="login" method="POST" { fieldset{ @@ -525,10 +360,11 @@ pub fn login(flash: Option) -> Markup { } pub fn register(flash: Option) -> Markup { - flash_page( + page( "Register", None, flash, + None, html! { form class="register" action="register" method="POST" { fieldset{ @@ -550,47 +386,3 @@ pub fn register(flash: Option) -> Markup { }, ) } - -fn flash_msg(flash: Option) -> Markup { - html! { - @if let Some(msg) = flash { - div class=(format!("flash {}", msg.name())) { - (msg.msg()) - } - } - } -} - -fn flash_page( - title: &str, - h: Option, - flash: Option, - content: Markup, -) -> Markup { - page( - title, - h, - html! { - (flash_msg(flash)) - (content) - }, - ) -} - -fn page(title: &str, h: Option, content: Markup) -> Markup { - html! { - (DOCTYPE) - head{ - meta charset="utf-8"; - meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"; - title {(title)} - link rel="stylesheet" href=(MAIN_CSS_URL); - @if let Some(h) = h { - (h) - } - } - body{ - (content) - } - } -} diff --git a/src/ports/web/frontend/view/page.rs b/src/ports/web/frontend/view/page.rs new file mode 100644 index 00000000..023d79b4 --- /dev/null +++ b/src/ports/web/frontend/view/page.rs @@ -0,0 +1,63 @@ +use maud::{html, Markup, DOCTYPE}; +use rocket::request::FlashMessage; + +const MAIN_CSS_URL: &str = "/main.css"; + +pub fn page( + title: &str, + email: Option<&str>, + flash: Option, + h: Option, + content: Markup, +) -> Markup { + html! { + (DOCTYPE) + head{ + meta charset="utf-8"; + meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"; + title {(title)} + link rel="stylesheet" href=(MAIN_CSS_URL); + @if let Some(h) = h { + (h) + } + } + body{ + (flash_msg(flash)) + (header(email)) + (content) + } + } +} + +fn flash_msg(flash: Option) -> Markup { + html! { + @if let Some(msg) = flash { + div class=(format!("flash {}", msg.name())) { + (msg.msg()) + } + } + } +} + +fn header(email: Option<&str>) -> Markup { + html! { + header { + @if let Some(email) = email { + div class="msg" { "Your are logged in as " span class="email" { (email) } } + nav { + a href="/" { "search" } + a href="/dashboard" { "dashboard" } + form class="logout" action="/logout" method ="POST" { + input type="submit" value="logout"; + } + } + } + @ else { + nav { + a href="/login" { "login" } + a href="/register" { "register" } + } + } + } + } +} diff --git a/src/ports/web/guards.rs b/src/ports/web/guards.rs index e81dacea..224169a3 100644 --- a/src/ports/web/guards.rs +++ b/src/ports/web/guards.rs @@ -93,15 +93,17 @@ impl<'a, 'r> FromRequest<'a, 'r> for Admin { .get_private(COOKIE_USER_ACCESS_LEVEL) .and_then(|cookie| cookie.value().parse().ok()) .and_then(Role::from_usize); + match (user, role) { (Some(user), Some(role)) => { if role == Role::Admin { - Outcome::Success(Admin(user.0)) + Some(Admin(user.0)) } else { - Outcome::Failure((Status::Unauthorized, ())) + return Outcome::Failure((Status::Unauthorized, ())); } } - _ => Outcome::Failure((Status::Unauthorized, ())), + _ => None, } + .or_forward(()) } }