diff --git a/Cargo.toml b/Cargo.toml index 7f8e797..47419c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,4 +8,4 @@ members = [ [profile.release] lto = true #codegen-units = 1 -opt-level = 'z' \ No newline at end of file +#opt-level = 'z' # Z is not supported by argonautica \ No newline at end of file diff --git a/app/locales/de/main.ftl b/app/locales/de/main.ftl index b6617d1..604672e 100644 --- a/app/locales/de/main.ftl +++ b/app/locales/de/main.ftl @@ -3,8 +3,11 @@ add-link = Link hinzufügen invite-user = Benutzer einladen list-users = Liste der Benutzer welcome-user = Herzlich willkommen {$username} +welcome = Herzlich willkommen logout = Abmelden login = Login +yes = Ja +no = Nein not-found = Dieser Link existiert nicht, oder wurde gelöscht. @@ -17,6 +20,7 @@ link-code = Link Code shortlink = Shortlink qr-code = QR-code search-placeholder = Filtern nach... +really-delete = Wollen Sie {$code} wirklich löschen? danger-zone = Achtung! danger-zone-text = Verändern Sie den Code von bereits veröffentlichten Links nicht. Sollte es dennoch geschehen werden veröffentlichte links unbenutzbar. Wird das Linkziel verändert, so zeigen auch die bereits veröffentlichten Links auf das neue Ziel. diff --git a/app/locales/en/main.ftl b/app/locales/en/main.ftl index a3bbf75..aec4306 100644 --- a/app/locales/en/main.ftl +++ b/app/locales/en/main.ftl @@ -3,8 +3,11 @@ add-link = Add a new link invite-user = Invite a new user list-users = List of existing users welcome-user = Welcome {$username} +welcome = Welcome logout = Logout login = Login +yes = Ja +no = Nein not-found = This Link has not been found or has been deleted @@ -16,6 +19,7 @@ link-target = Link target link-code = Link code shortlink = Shortlink search-placeholder = Filter according to... +really-delete = Do you really want to delete {$code}? danger-zone = Danger Zone! danger-zone-text = Do not change the code of links that are published. If you do so the published links will become invalid! If you change the target the published links will point to the new target. diff --git a/app/src/lib.rs b/app/src/lib.rs index 09aa251..6a03e82 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -5,6 +5,7 @@ pub mod pages; use pages::list_links; use pages::list_users; use seed::{div, log, prelude::*, App, Url, C}; +use shared::datatypes::User; use crate::i18n::{I18n, Lang}; @@ -14,6 +15,7 @@ use crate::i18n::{I18n, Lang}; fn init(url: Url, orders: &mut impl Orders) -> Model { orders.subscribe(Msg::UrlChanged); + orders.send_msg(Msg::GetLoggedUser); log!(url); @@ -24,6 +26,7 @@ fn init(url: Url, orders: &mut impl Orders) -> Model { base_url: Url::new().add_path_part("app"), page: Page::init(url, orders, lang.clone()), i18n: lang, + user: None, } } @@ -37,6 +40,7 @@ struct Model { base_url: Url, page: Page, i18n: i18n::I18n, + user: Option, } #[derive(Debug)] @@ -76,6 +80,8 @@ pub enum Msg { UrlChanged(subs::UrlChanged), ListLinks(list_links::Msg), ListUsers(list_users::Msg), + GetLoggedUser, + UserReceived(User), NoMessage, } @@ -97,6 +103,29 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { } } Msg::NoMessage => (), + Msg::GetLoggedUser => { + orders.skip(); // No need to rerender/ complicated way to move into the closure + orders.perform_cmd(async { + let response = fetch( + Request::new("/admin/json/get_logged_user/") + .method(Method::Post) + .json(&()) + .expect("serialization failed"), + ) + .await + .expect("HTTP request failed"); + + let user: User = response + .check_status() // ensure we've got 2xx status + .expect("status check failed") + .json() + .await + .expect("deserialization failed"); + + Msg::UserReceived(user) + }); + } + Msg::UserReceived(user) => model.user = Some(user), } } @@ -140,6 +169,10 @@ impl<'a> Urls<'a> { self.base_url().add_path_part("list_links") } #[must_use] + pub fn create_link(self) -> Url { + self.list_links().add_path_part("create_link") + } + #[must_use] pub fn list_users(self) -> Url { self.base_url().add_path_part("list_users") } @@ -156,7 +189,7 @@ impl<'a> Urls<'a> { fn view(model: &Model) -> Node { div![ C!["page"], - navigation::navigation(&model.i18n, &model.base_url,), + navigation::navigation(&model.i18n, &model.base_url, &model.user), view_content(&model.page, &model.base_url), ] } diff --git a/app/src/navigation.rs b/app/src/navigation.rs index 4038559..68de389 100644 --- a/app/src/navigation.rs +++ b/app/src/navigation.rs @@ -1,29 +1,34 @@ use fluent::fluent_args; use seed::{a, attrs, div, li, nav, ol, prelude::*, Url}; +use shared::datatypes::User; use crate::{i18n::I18n, Msg}; #[must_use] -pub fn navigation(i18n: &I18n, base_url: &Url) -> Node { - let username = fluent_args![ "username" => "enaut"]; - macro_rules! t { - { $key:expr } => { - { - i18n.translate($key, None) - } - }; - { $key:expr, $args:expr } => { - { - i18n.translate($key, Some(&$args)) - } - }; - } +pub fn navigation(i18n: &I18n, base_url: &Url, user: &Option) -> Node { + let t = move |key: &str| i18n.translate(key, None); + let welcome = if let Some(user) = user { + i18n.translate( + "welcome-user", + Some(&fluent_args![ "username" => user.username.clone()]), + ) + } else { + t("welcome") + }; nav![ ol![ li![a![ attrs! {At::Href => crate::Urls::new(base_url).list_links()}, - t!("list-links"), + t("list-links"), + ],], + li![a![ + attrs! {At::Href => crate::Urls::new(base_url).create_link()}, + ev(Ev::Click, |_| Msg::ListLinks( + super::pages::list_links::Msg::Edit( + super::pages::list_links::EditMsg::CreateNewLink + ) + )), + t("add-link"), ],], - li![a![ev(Ev::Click, |_| Msg::NoMessage), t!("add-link"),],], li![a![ attrs! {At::Href => crate::Urls::new(base_url).create_user()}, ev(Ev::Click, |_| Msg::ListUsers( @@ -31,19 +36,19 @@ pub fn navigation(i18n: &I18n, base_url: &Url) -> Node { super::pages::list_users::UserEditMsg::CreateNewUser ) )), - t!("invite-user"), + t("invite-user"), ],], li![a![ attrs! {At::Href => crate::Urls::new(base_url).list_users()}, - t!("list-users"), + t("list-users"), ],], ], ol![ - li![div![t!("welcome-user", username)]], + li![div![welcome]], li![a![ attrs! {At::Href => "#"}, ev(Ev::Click, |_| Msg::NoMessage), - t!("logout"), + t("logout"), ]] ] ] diff --git a/app/src/pages/list_links.rs b/app/src/pages/list_links.rs index fa8bf96..871b986 100644 --- a/app/src/pages/list_links.rs +++ b/app/src/pages/list_links.rs @@ -1,25 +1,50 @@ +use std::cell::RefCell; + use enum_map::EnumMap; -use seed::{a, attrs, button, h1, input, log, prelude::*, section, table, td, th, tr, Url, C}; +use fluent::fluent_args; +use seed::{ + a, attrs, button, div, h1, img, input, log, prelude::*, section, span, table, td, th, tr, Url, + C, +}; use shared::{ apirequests::general::Ordering, apirequests::{ - general::Operation, - links::{LinkOverviewColumns, LinkRequestForm}, + general::{EditMode, Message, Operation, Status}, + links::{LinkDelta, LinkOverviewColumns, LinkRequestForm}, }, datatypes::FullLink, }; use crate::i18n::I18n; -pub fn init(_: Url, orders: &mut impl Orders, i18n: I18n) -> Model { - orders.send_msg(Msg::Fetch); +/// Unwrap a result and return it's content, or return from the function with another expression. +macro_rules! unwrap_or_return { + ( $e:expr, $result:expr) => { + match $e { + Ok(x) => x, + Err(_) => return $result, + } + }; +} + +pub fn init(mut url: Url, orders: &mut impl Orders, i18n: I18n) -> Model { + log!(url); + orders.send_msg(Msg::Query(QueryMsg::Fetch)); + let edit_link = match url.next_path_part() { + Some("create_link") => Some(RefCell::new(LinkDelta::default())), + None | Some(_) => None, + }; + log!(edit_link); Model { links: Vec::new(), i18n, formconfig: LinkRequestForm::default(), inputs: EnumMap::default(), + edit_link, + last_message: None, + question: None, } } @@ -29,6 +54,9 @@ pub struct Model { i18n: I18n, formconfig: LinkRequestForm, inputs: EnumMap, + edit_link: Option>, + last_message: Option, + question: Option, } #[derive(Default, Debug, Clone)] @@ -38,6 +66,14 @@ struct FilterInput { #[derive(Clone)] pub enum Msg { + Query(QueryMsg), + Edit(EditMsg), + ClearAll, + SetMessage(String), +} + +#[derive(Clone)] +pub enum QueryMsg { Fetch, OrderBy(LinkOverviewColumns), Received(Vec), @@ -46,36 +82,51 @@ pub enum Msg { TargetFilterChanged(String), AuthorFilterChanged(String), } +/// All the messages on link editing +#[derive(Clone, Debug)] +pub enum EditMsg { + EditSelected(LinkDelta), + CreateNewLink, + Created(Status), + EditCodeChanged(String), + EditDescriptionChanged(String), + EditTargetChanged(String), + MayDeleteSelected(LinkDelta), + DeleteSelected(LinkDelta), + SaveLink, + FailedToCreateLink, + FailedToDeleteLink, + DeletedLink(Status), +} /// # Panics /// Sould only panic on bugs. pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { match msg { - Msg::Fetch => { - orders.skip(); // No need to rerender - let data = model.formconfig.clone(); // complicated way to move into the closure - orders.perform_cmd(async { - let data = data; - let response = fetch( - Request::new("/admin/json/list_links/") - .method(Method::Post) - .json(&data) - .expect("serialization failed"), - ) - .await - .expect("HTTP request failed"); - - let user: Vec = response - .check_status() // ensure we've got 2xx status - .expect("status check failed") - .json() - .await - .expect("deserialization failed"); - - Msg::Received(user) - }); + Msg::Query(msg) => process_query_messages(msg, model, orders), + Msg::Edit(msg) => process_edit_messages(msg, model, orders), + Msg::ClearAll => { + model.edit_link = None; + model.last_message = None; + model.question = None; } - Msg::OrderBy(column) => { + Msg::SetMessage(msg) => { + model.edit_link = None; + model.question = None; + model.last_message = Some(Status::Error(Message { message: msg })); + } + } +} + +/// # Panics +/// Sould only panic on bugs. +pub fn process_query_messages(msg: QueryMsg, model: &mut Model, orders: &mut impl Orders) { + match msg { + QueryMsg::Fetch => { + orders.skip(); // No need to rerender + load_links(model, orders) + } + QueryMsg::OrderBy(column) => { model.formconfig.order = model.formconfig.order.as_ref().map_or_else( || { Some(Operation { @@ -94,7 +145,7 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { }) }, ); - orders.send_msg(Msg::Fetch); + orders.send_msg(Msg::Query(QueryMsg::Fetch)); model.links.sort_by(match column { LinkOverviewColumns::Code => { @@ -114,44 +165,231 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { } }) } - Msg::Received(response) => { + QueryMsg::Received(response) => { model.links = response; } - Msg::CodeFilterChanged(s) => { + QueryMsg::CodeFilterChanged(s) => { log!("Filter is: ", &s); let sanit = s.chars().filter(|x| x.is_alphanumeric()).collect(); model.formconfig.filter[LinkOverviewColumns::Code].sieve = sanit; - orders.send_msg(Msg::Fetch); + orders.send_msg(Msg::Query(QueryMsg::Fetch)); } - Msg::DescriptionFilterChanged(s) => { + QueryMsg::DescriptionFilterChanged(s) => { log!("Filter is: ", &s); let sanit = s.chars().filter(|x| x.is_alphanumeric()).collect(); model.formconfig.filter[LinkOverviewColumns::Description].sieve = sanit; - orders.send_msg(Msg::Fetch); + orders.send_msg(Msg::Query(QueryMsg::Fetch)); } - Msg::TargetFilterChanged(s) => { + QueryMsg::TargetFilterChanged(s) => { log!("Filter is: ", &s); let sanit = s.chars().filter(|x| x.is_alphanumeric()).collect(); model.formconfig.filter[LinkOverviewColumns::Target].sieve = sanit; - orders.send_msg(Msg::Fetch); + orders.send_msg(Msg::Query(QueryMsg::Fetch)); } - Msg::AuthorFilterChanged(s) => { + QueryMsg::AuthorFilterChanged(s) => { log!("Filter is: ", &s); let sanit = s.chars().filter(|x| x.is_alphanumeric()).collect(); model.formconfig.filter[LinkOverviewColumns::Author].sieve = sanit; - orders.send_msg(Msg::Fetch); + orders.send_msg(Msg::Query(QueryMsg::Fetch)); + } + } +} +fn load_links(model: &Model, orders: &mut impl Orders) { + let data = model.formconfig.clone(); // complicated way to move into the closure + orders.perform_cmd(async { + let data = data; + let request = unwrap_or_return!( + Request::new("/admin/json/list_links/") + .method(Method::Post) + .json(&data), + Msg::SetMessage("Failed to parse data".to_string()) + ); + let response = unwrap_or_return!( + fetch(request).await, + Msg::SetMessage("Failed to send data".to_string()) + ); + + let response = unwrap_or_return!( + response.check_status(), + Msg::SetMessage("Wrong response code".to_string()) + ); + + let links: Vec = unwrap_or_return!( + response.json().await, + Msg::SetMessage("Invalid response".to_string()) + ); + + Msg::Query(QueryMsg::Received(links)) + }); +} + +pub fn process_edit_messages(msg: EditMsg, model: &mut Model, orders: &mut impl Orders) { + match msg { + EditMsg::EditSelected(link) => { + log!("Editing link: ", link); + model.last_message = None; + model.edit_link = Some(RefCell::new(link)) + } + EditMsg::CreateNewLink => { + log!("Create new link!"); + model.edit_link = Some(RefCell::new(LinkDelta::default())) + } + EditMsg::Created(success_msg) => { + model.last_message = Some(success_msg); + model.edit_link = None; + orders.send_msg(Msg::Query(QueryMsg::Fetch)); + } + EditMsg::EditCodeChanged(s) => { + if let Some(ref le) = model.edit_link { + le.try_borrow_mut().expect("Failed to borrow mutably").code = s; + } + } + EditMsg::EditDescriptionChanged(s) => { + if let Some(ref le) = model.edit_link { + le.try_borrow_mut().expect("Failed to borrow mutably").title = s; + } + } + EditMsg::EditTargetChanged(s) => { + if let Some(ref le) = model.edit_link { + le.try_borrow_mut() + .expect("Failed to borrow mutably") + .target = s; + } + } + EditMsg::SaveLink => { + save_link(model, orders); + } + EditMsg::FailedToCreateLink => { + log!("Failed to create Link"); + } + link @ EditMsg::MayDeleteSelected(..) => { + log!("Deleting link: ", link); + model.last_message = None; + model.edit_link = None; + model.question = Some(link) + } + EditMsg::DeleteSelected(link) => { + orders.perform_cmd(async { + let data = link; + let response = unwrap_or_return!( + fetch( + Request::new("/admin/json/delete_link/") + .method(Method::Post) + .json(&data) + .expect("serialization failed"), + ) + .await, + Msg::Edit(EditMsg::FailedToDeleteLink) + ); + + let response = unwrap_or_return!( + response.check_status(), + Msg::SetMessage("Wrong response code!".to_string()) + ); + let message: Status = unwrap_or_return!( + response.json().await, + Msg::SetMessage( + "Failed to parse the response the link might be deleted however!" + .to_string() + ) + ); + + Msg::Edit(EditMsg::DeletedLink(message)) + }); + } + EditMsg::FailedToDeleteLink => { + log!("Failed to delete Link"); + } + EditMsg::DeletedLink(message) => { + log!("Deleted link", message); } } } +fn save_link(model: &Model, orders: &mut impl Orders) { + let data = model + .edit_link + .as_ref() + .expect("should exist!") + .borrow() + .clone(); + orders.perform_cmd(async { + let data = data; + let request = unwrap_or_return!( + Request::new(match data.edit { + EditMode::Create => "/admin/json/create_link/", + EditMode::Edit => "/admin/json/edit_link/", + }) + .method(Method::Post) + .json(&data), + Msg::SetMessage("Failed to encode the link!".to_string()) + ); + let response = + unwrap_or_return!(fetch(request).await, Msg::Edit(EditMsg::FailedToCreateLink)); + + log!(response); + let response = unwrap_or_return!( + response.check_status(), + Msg::SetMessage("Wrong response code".to_string()) + ); + + let message: Status = unwrap_or_return!( + response.json().await, + Msg::SetMessage("Invalid response!".to_string()) + ); + + Msg::Edit(EditMsg::Created(message)) + }); +} + #[must_use] -/// # Panics -/// Sould only panic on bugs. pub fn view(model: &Model) -> Node { - let lang = model.i18n.clone(); + let lang = &model.i18n.clone(); let t = move |key: &str| lang.translate(key, None); section![ - h1!("List Links Page from list_links"), + if let Some(message) = &model.last_message { + div![ + C!["message", "center"], + div![ + C!["closebutton"], + a!["\u{d7}"], + ev(Ev::Click, |_| Msg::ClearAll) + ], + match message { + Status::Success(m) | Status::Error(m) => &m.message, + } + ] + } else { + section![] + }, + if let Some(question) = &model.question { + div![ + C!["message", "center"], + div![ + C!["closebutton"], + a!["\u{d7}"], + ev(Ev::Click, |_| Msg::ClearAll) + ], + if let EditMsg::MayDeleteSelected(l) = question.clone() { + div![ + lang.translate( + "really-delete", + Some(&fluent_args!["code" => l.code.clone()]) + ), + a![t("no"), C!["button"], ev(Ev::Click, |_| Msg::ClearAll)], + a![ + t("yes"), + C!["button"], + ev(Ev::Click, move |_| Msg::Edit(EditMsg::DeleteSelected(l))) + ] + ] + } else { + span!() + } + ] + } else { + section![] + }, table![ // Add the headlines view_link_table_head(&t), @@ -160,34 +398,51 @@ pub fn view(model: &Model) -> Node { // Add all the content lines model.links.iter().map(view_link) ], - button![ev(Ev::Click, |_| Msg::Fetch), "Fetch links"] + button![ + ev(Ev::Click, |_| Msg::Query(QueryMsg::Fetch)), + "Fetch links" + ], + if let Some(l) = &model.edit_link { + edit_or_create_link(l, t) + } else { + section!() + } ] } fn view_link_table_head String>(t: F) -> Node { tr![ th![ - ev(Ev::Click, |_| Msg::OrderBy(LinkOverviewColumns::Code)), + ev(Ev::Click, |_| Msg::Query(QueryMsg::OrderBy( + LinkOverviewColumns::Code + ))), t("link-code") ], th![ - ev(Ev::Click, |_| Msg::OrderBy( + ev(Ev::Click, |_| Msg::Query(QueryMsg::OrderBy( LinkOverviewColumns::Description - )), + ))), t("link-description") ], th![ - ev(Ev::Click, |_| Msg::OrderBy(LinkOverviewColumns::Target)), + ev(Ev::Click, |_| Msg::Query(QueryMsg::OrderBy( + LinkOverviewColumns::Target + ))), t("link-target") ], th![ - ev(Ev::Click, |_| Msg::OrderBy(LinkOverviewColumns::Author)), + ev(Ev::Click, |_| Msg::Query(QueryMsg::OrderBy( + LinkOverviewColumns::Author + ))), t("username") ], th![ - ev(Ev::Click, |_| Msg::OrderBy(LinkOverviewColumns::Statistics)), + ev(Ev::Click, |_| Msg::Query(QueryMsg::OrderBy( + LinkOverviewColumns::Statistics + ))), t("statistics") - ] + ], + th![] ] } @@ -200,7 +455,7 @@ fn view_link_table_filter_input String>(model: &Model, t: F) -> N At::Type => "search", At::Placeholder => t("search-placeholder") }, - input_ev(Ev::Input, Msg::CodeFilterChanged), + input_ev(Ev::Input, |s| Msg::Query(QueryMsg::CodeFilterChanged(s))), el_ref(&model.inputs[LinkOverviewColumns::Code].filter_input), ]], td![input![ @@ -210,7 +465,9 @@ fn view_link_table_filter_input String>(model: &Model, t: F) -> N At::Type => "search", At::Placeholder => t("search-placeholder") }, - input_ev(Ev::Input, Msg::DescriptionFilterChanged), + input_ev(Ev::Input, |s| Msg::Query( + QueryMsg::DescriptionFilterChanged(s) + )), el_ref(&model.inputs[LinkOverviewColumns::Description].filter_input), ]], td![input![ @@ -220,7 +477,7 @@ fn view_link_table_filter_input String>(model: &Model, t: F) -> N At::Type => "search", At::Placeholder => t("search-placeholder") }, - input_ev(Ev::Input, Msg::TargetFilterChanged), + input_ev(Ev::Input, |s| Msg::Query(QueryMsg::TargetFilterChanged(s))), el_ref(&model.inputs[LinkOverviewColumns::Target].filter_input), ]], td![input![ @@ -230,19 +487,114 @@ fn view_link_table_filter_input String>(model: &Model, t: F) -> N At::Type => "search", At::Placeholder => t("search-placeholder") }, - input_ev(Ev::Input, Msg::AuthorFilterChanged), + input_ev(Ev::Input, |s| Msg::Query(QueryMsg::AuthorFilterChanged(s))), el_ref(&model.inputs[LinkOverviewColumns::Author].filter_input), ]], - td![] + td![], + td![], ] } fn view_link(l: &FullLink) -> Node { + // Ugly hack + let link = LinkDelta::from(l.clone()); + let link2 = LinkDelta::from(l.clone()); + let link3 = LinkDelta::from(l.clone()); + let link4 = LinkDelta::from(l.clone()); + let link5 = LinkDelta::from(l.clone()); tr![ - td![&l.link.code], - td![&l.link.title], + { + td![ + ev(Ev::Click, |_| Msg::Edit(EditMsg::EditSelected(link))), + &l.link.code + ] + }, + { + td![ + ev(Ev::Click, |_| Msg::Edit(EditMsg::EditSelected(link2))), + &l.link.title + ] + }, td![a![attrs![At::Href => &l.link.target], &l.link.target]], - td![&l.user.username], - td![&l.clicks.number] + { + td![ + ev(Ev::Click, |_| Msg::Edit(EditMsg::EditSelected(link3))), + &l.user.username + ] + }, + { + td![ + ev(Ev::Click, |_| Msg::Edit(EditMsg::EditSelected(link4))), + &l.clicks.number + ] + }, + { + td![img![ + ev(Ev::Click, |_| Msg::Edit(EditMsg::MayDeleteSelected(link5))), + C!["trashicon"], + attrs!(At::Src => "/static/trash.svg") + ]] + }, + ] +} + +fn edit_or_create_link String>(l: &RefCell, t: F) -> Node { + let link = l.borrow(); + div![ + C!["editdialog", "center"], + div![ + C!["closebutton"], + a!["\u{d7}"], + ev(Ev::Click, |_| Msg::ClearAll) + ], + h1![match &link.edit { + EditMode::Edit => t("edit-link"), + EditMode::Create => t("create-link"), + }], + table![ + tr![ + th![t("link-description")], + td![input![ + attrs! { + At::Value => &link.title, + At::Type => "text", + At::Placeholder => t("link-description") + }, + input_ev(Ev::Input, |s| { + Msg::Edit(EditMsg::EditDescriptionChanged(s)) + }), + ]] + ], + tr![ + th![t("link-target")], + td![input![ + attrs! { + At::Value => &link.target, + At::Type => "text", + At::Placeholder => t("link-target") + }, + input_ev(Ev::Input, |s| { Msg::Edit(EditMsg::EditTargetChanged(s)) }), + ]] + ], + tr![ + th![t("link-code")], + td![input![ + attrs! { + At::Value => &link.code, + At::Type => "text", + At::Placeholder => t("password") + }, + input_ev(Ev::Input, |s| { Msg::Edit(EditMsg::EditCodeChanged(s)) }), + ],] + ] + ], + a![ + match &link.edit { + EditMode::Edit => t("edit-link"), + EditMode::Create => t("create-link"), + }, + C!["button"], + ev(Ev::Click, |_| Msg::Edit(EditMsg::SaveLink)) + ] ] } diff --git a/app/src/pages/list_users.rs b/app/src/pages/list_users.rs index b4b972a..4397429 100644 --- a/app/src/pages/list_users.rs +++ b/app/src/pages/list_users.rs @@ -7,7 +7,7 @@ use seed::{ use shared::{ apirequests::general::{Operation, Ordering}, apirequests::{ - general::{EditMode, SuccessMessage}, + general::{EditMode, Status}, users::{UserDelta, UserOverviewColumns, UserRequestForm}, }, datatypes::User, @@ -37,7 +37,7 @@ pub struct Model { formconfig: UserRequestForm, inputs: EnumMap, user_edit: Option>, - last_message: Option, + last_message: Option, } #[derive(Default, Debug, Clone)] @@ -68,7 +68,7 @@ pub enum UserQueryMsg { pub enum UserEditMsg { EditUserSelected(UserDelta), CreateNewUser, - UserCreated(SuccessMessage), + UserCreated(Status), EditUsernameChanged(String), EditEmailChanged(String), EditPasswordChanged(String), @@ -236,7 +236,7 @@ pub fn process_user_edit_messages( Err(_) => return Msg::Edit(UserEditMsg::FailedToCreateUser), }; - let message: SuccessMessage = response + let message: Status = response .check_status() // ensure we've got 2xx status .expect("status check failed") .json() @@ -268,7 +268,6 @@ pub fn view(model: &Model) -> Node { keyboard_ev(Ev::KeyDown, |keyboard_event| { IF!(keyboard_event.key() == "Escape" => Msg::ClearAll) }), - h1!("List Users Page from list_users"), if let Some(message) = &model.last_message { div![ C!["message", "center"], @@ -277,7 +276,11 @@ pub fn view(model: &Model) -> Node { a!["\u{d7}"], ev(Ev::Click, |_| Msg::ClearAll) ], - &message.message + match message { + Status::Success(m) | Status::Error(m) => { + &m.message + } + } ] } else { section![] diff --git a/pslink/src/bin/pslink/cli.rs b/pslink/src/bin/pslink/cli.rs index 59ed27f..790c5e0 100644 --- a/pslink/src/bin/pslink/cli.rs +++ b/pslink/src/bin/pslink/cli.rs @@ -3,7 +3,7 @@ use clap::{ ArgMatches, SubCommand, }; use dotenv::dotenv; -use shared::datatypes::User; +use shared::datatypes::{Secret, User}; use sqlx::{migrate::Migrator, Pool, Sqlite}; use std::{ fs::File, @@ -151,7 +151,7 @@ async fn parse_args_to_config(config: ArgMatches<'_>) -> ServerConfig { } else { secret }; - let secret = pslink::Secret::new(secret); + let secret = Secret::new(secret); let db = config .value_of("database") .expect(concat!( diff --git a/pslink/src/lib.rs b/pslink/src/lib.rs index 007bd37..0dff6c6 100644 --- a/pslink/src/lib.rs +++ b/pslink/src/lib.rs @@ -11,6 +11,7 @@ use actix_web::HttpResponse; use actix_web::{web, App, HttpServer}; use fluent_templates::{static_loader, FluentLoader}; use qrcode::types::QrError; +use shared::datatypes::Secret; use sqlx::{Pool, Sqlite}; use std::{fmt::Display, path::PathBuf, str::FromStr}; use tera::Tera; @@ -156,30 +157,6 @@ impl FromStr for Protocol { } } -#[derive(Clone)] -pub struct Secret { - secret: String, -} - -impl Secret { - #[must_use] - pub const fn new(secret: String) -> Self { - Self { secret } - } -} - -impl std::fmt::Debug for Secret { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("*****SECRET*****") - } -} - -impl std::fmt::Display for Secret { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("*****SECRET*****") - } -} - #[derive(Debug, Clone)] pub struct ServerConfig { pub secret: Secret, @@ -209,7 +186,13 @@ impl ServerConfig { "# If it is changed all existing passwords are invalid.\n" ) .to_owned(), - format!("PSLINK_SECRET=\"{}\"\n", self.secret.secret), + format!( + "PSLINK_SECRET=\"{}\"\n", + self.secret + .secret + .as_ref() + .expect("A Secret was not specified!") + ), ] } } @@ -370,6 +353,18 @@ pub async fn webservice( .service( web::scope("/json") .route("/list_links/", web::post().to(views::index_json)) + .route( + "/create_link/", + web::post().to(views::process_create_link_json), + ) + .route( + "/edit_link/", + web::post().to(views::process_update_link_json), + ) + .route( + "/delete_link/", + web::post().to(views::process_delete_link_json), + ) .route("/list_users/", web::post().to(views::index_users_json)) .route( "/create_user/", @@ -378,6 +373,10 @@ pub async fn webservice( .route( "/update_user/", web::post().to(views::process_update_user_json), + ) + .route( + "/get_logged_user/", + web::post().to(views::get_logged_user_json), ), ) // login to the admin area diff --git a/pslink/src/models.rs b/pslink/src/models.rs index 4ad2061..14b72d4 100644 --- a/pslink/src/models.rs +++ b/pslink/src/models.rs @@ -5,7 +5,12 @@ use async_trait::async_trait; use dotenv::dotenv; use serde::{Deserialize, Serialize}; -use shared::datatypes::{Count, Link, User}; +use shared::{ + apirequests::links::LinkDelta, + datatypes::{Count, Link, User}, +}; +use sqlx::Row; +use tracing::{error, info, instrument}; #[async_trait] pub trait UserDbOperations { @@ -24,10 +29,19 @@ pub trait UserDbOperations { #[async_trait] impl UserDbOperations for User { + #[instrument()] async fn get_user(id: i64, server_config: &ServerConfig) -> Result { - let user = sqlx::query_as!(Self, "Select * from users where id = ? ", id) + let user = sqlx::query!("Select * from users where id = ? ", id) .fetch_one(&server_config.db_pool) - .await; + .await + .map(|row| Self { + id: row.id, + username: row.username, + email: row.email, + password: Secret::new(row.password), + role: row.role, + language: row.language, + }); user.map_err(ServerError::Database) } @@ -35,23 +49,46 @@ impl UserDbOperations for User { /// /// # Errors /// fails with [`ServerError`] if the user does not exist or the database cannot be acessed. + #[instrument()] async fn get_user_by_name( name: &str, server_config: &ServerConfig, ) -> Result { - let user = sqlx::query_as!(Self, "Select * from users where username = ? ", name) + let user = sqlx::query!("Select * from users where username = ? ", name) .fetch_one(&server_config.db_pool) - .await; + .await + .map(|row| Self { + id: row.id, + username: row.username, + email: row.email, + password: Secret::new(row.password), + role: row.role, + language: row.language, + }); user.map_err(ServerError::Database) } + #[instrument()] async fn get_all_users(server_config: &ServerConfig) -> Result, ServerError> { - let user = sqlx::query_as!(Self, "Select * from users") + let user = sqlx::query("Select * from users") .fetch_all(&server_config.db_pool) - .await; + .await + .map(|row| { + row.into_iter() + .map(|r| Self { + id: r.get("id"), + username: r.get("username"), + email: r.get("email"), + password: Secret::new(r.get("password")), + role: r.get("role"), + language: r.get("language"), + }) + .collect() + }); user.map_err(ServerError::Database) } + #[instrument()] async fn update_user(&self, server_config: &ServerConfig) -> Result<(), ServerError> { sqlx::query!( "UPDATE users SET @@ -61,7 +98,7 @@ impl UserDbOperations for User { role = ? where id = ?", self.username, self.email, - self.password, + self.password.secret, self.role, self.id ) @@ -69,10 +106,12 @@ impl UserDbOperations for User { .await?; Ok(()) } + /// Change an admin user to normal user and a normal user to admin /// /// # Errors /// fails with [`ServerError`] if the database cannot be acessed. (the user should exist) + #[instrument()] async fn toggle_admin(self, server_config: &ServerConfig) -> Result<(), ServerError> { let new_role = 2 - (self.role + 1) % 2; sqlx::query!("UPDATE users SET role = ? where id = ?", new_role, self.id) @@ -81,6 +120,7 @@ impl UserDbOperations for User { Ok(()) } + #[instrument()] async fn set_language( self, server_config: &ServerConfig, @@ -102,6 +142,7 @@ impl UserDbOperations for User { /// /// # Errors /// fails with [`ServerError`] if the database cannot be acessed. + #[instrument()] async fn count_admins(server_config: &ServerConfig) -> Result { let num = sqlx::query_as!(Count, "select count(*) as number from users where role = 2") .fetch_one(&server_config.db_pool) @@ -122,6 +163,7 @@ impl NewUser { /// /// # Errors /// fails with [`ServerError`] if the password could not be encrypted. + #[instrument()] pub fn new( username: String, email: String, @@ -137,12 +179,13 @@ impl NewUser { }) } + #[instrument()] pub(crate) fn hash_password(password: &str, secret: &Secret) -> Result { dotenv().ok(); let hash = Hasher::default() .with_password(password) - .with_secret_key(&secret.secret) + .with_secret_key(secret.secret.as_ref().expect("A secret key was not given")) .hash()?; Ok(hash) @@ -152,6 +195,7 @@ impl NewUser { /// /// # Errors /// fails with [`ServerError`] if the database cannot be acessed. + #[instrument()] pub async fn insert_user(&self, server_config: &ServerConfig) -> Result<(), ServerError> { sqlx::query!( "Insert into users ( @@ -178,6 +222,7 @@ pub struct LoginUser { #[async_trait] pub trait LinkDbOperations { async fn get_link_by_code(code: &str, server_config: &ServerConfig) -> Result; + async fn get_link_by_id(id: i64, server_config: &ServerConfig) -> Result; async fn delete_link_by_code( code: &str, server_config: &ServerConfig, @@ -187,6 +232,7 @@ pub trait LinkDbOperations { #[async_trait] impl LinkDbOperations for Link { + #[instrument()] async fn get_link_by_code( code: &str, server_config: &ServerConfig, @@ -197,7 +243,16 @@ impl LinkDbOperations for Link { tracing::info!("Found link: {:?}", &link); link.map_err(ServerError::Database) } + #[instrument()] + async fn get_link_by_id(id: i64, server_config: &ServerConfig) -> Result { + let link = sqlx::query_as!(Self, "Select * from links where id = ? ", id) + .fetch_one(&server_config.db_pool) + .await; + tracing::info!("Found link: {:?}", &link); + link.map_err(ServerError::Database) + } + #[instrument()] async fn delete_link_by_code( code: &str, server_config: &ServerConfig, @@ -207,8 +262,11 @@ impl LinkDbOperations for Link { .await?; Ok(()) } + + #[instrument()] async fn update_link(&self, server_config: &ServerConfig) -> Result<(), ServerError> { - sqlx::query!( + info!("{:?}", self); + let qry = sqlx::query!( "UPDATE links SET title = ?, target = ?, @@ -221,10 +279,15 @@ impl LinkDbOperations for Link { self.author, self.created_at, self.id - ) - .execute(&server_config.db_pool) - .await?; - Ok(()) + ); + match qry.execute(&server_config.db_pool).await { + Ok(_) => Ok(()), + Err(e) => { + //error!("{}", qry); + error!("{}", e); + Err(e.into()) + } + } } } @@ -247,6 +310,15 @@ impl NewLink { created_at: chrono::Local::now().naive_utc(), } } + pub(crate) fn from_link_delta(link: LinkDelta, uid: i64) -> Self { + Self { + title: link.title, + target: link.target, + code: link.code, + author: uid, + created_at: chrono::Local::now().naive_utc(), + } + } pub(crate) async fn insert(self, server_config: &ServerConfig) -> Result<(), ServerError> { sqlx::query!( diff --git a/pslink/src/queries.rs b/pslink/src/queries.rs index 6090e3a..01c9557 100644 --- a/pslink/src/queries.rs +++ b/pslink/src/queries.rs @@ -5,10 +5,10 @@ use serde::Serialize; use shared::{ apirequests::{ general::{EditMode, Filter, Operation, Ordering}, - links::{LinkOverviewColumns, LinkRequestForm}, + links::{LinkDelta, LinkOverviewColumns, LinkRequestForm}, users::{UserDelta, UserOverviewColumns, UserRequestForm}, }, - datatypes::{Count, FullLink, Link, User}, + datatypes::{Count, FullLink, Link, Secret, User}, }; use sqlx::Row; use tracing::{info, instrument, warn}; @@ -127,7 +127,7 @@ pub async fn list_all_allowed( id: v.get("usid"), username: v.get("usern"), email: v.get("uemail"), - password: "invalid".to_owned(), + password: Secret::new("invalid".to_string()), role: v.get("urole"), language: v.get("ulang"), }, @@ -240,7 +240,7 @@ pub async fn list_users( id: v.get("id"), username: v.get("username"), email: v.get("email"), - password: "invalid".to_owned(), + password: Secret::new("".to_string()), role: v.get("role"), language: v.get("language"), }) @@ -458,7 +458,9 @@ pub async fn update_user_json( Role::Admin { .. } | Role::Regular { .. } => { info!("Updating userinfo: "); let password = match &data.password { - Some(password) => NewUser::hash_password(password, &server_config.secret)?, + Some(password) => { + Secret::new(NewUser::hash_password(password, &server_config.secret)?) + } None => unmodified_user.password, }; let new_user = User { @@ -510,7 +512,10 @@ pub async fn update_user( Role::Admin { .. } | Role::Regular { .. } => { info!("Updating userinfo: "); let password = if data.password.len() > 3 { - NewUser::hash_password(&data.password, &server_config.secret)? + Secret::new(NewUser::hash_password( + &data.password, + &server_config.secret, + )?) } else { unmodified_user.password }; @@ -629,6 +634,28 @@ pub async fn get_link( } } +/// Get one link if permissions are accordingly. +/// +/// # Errors +/// Fails with [`ServerError`] if access to the database fails or this user does not have permissions. +#[instrument(skip(id))] +pub async fn get_link_by_id( + id: &Identity, + lid: i64, + server_config: &ServerConfig, +) -> Result, ServerError> { + match authenticate(id, server_config).await? { + Role::Admin { user } | Role::Regular { user } => { + let link = Link::get_link_by_id(lid, server_config).await?; + Ok(Item { user, item: link }) + } + Role::Disabled | Role::NotAuthenticated => { + warn!("User could not be authenticated!"); + Err(ServerError::User("Not Allowed".to_owned())) + } + } +} + /// Get link **without authentication** /// /// # Errors @@ -743,3 +770,73 @@ pub async fn create_link( } } } + +/// Create a new link +/// +/// # Errors +/// Fails with [`ServerError`] if access to the database fails or this user does not have permissions. +#[instrument(skip(id))] +pub async fn create_link_json( + id: &Identity, + data: web::Json, + server_config: &ServerConfig, +) -> Result, ServerError> { + let auth = authenticate(id, server_config).await?; + match auth { + Role::Admin { user } | Role::Regular { user } => { + let code = data.code.clone(); + info!("Creating link for: {}", &code); + let new_link = NewLink::from_link_delta(data.into_inner(), user.id); + info!("Creating link for: {:?}", &new_link); + + new_link.insert(server_config).await?; + let new_link: Link = get_link_simple(&code, server_config).await?; + Ok(Item { + user, + item: new_link, + }) + } + Role::Disabled | Role::NotAuthenticated => { + Err(ServerError::User("Permission denied!".to_owned())) + } + } +} + +/// Update a link if the user is admin or it is its own link. +/// +/// # Errors +/// Fails with [`ServerError`] if access to the database fails or this user does not have permissions. +#[instrument(skip(ident))] +pub async fn update_link_json( + ident: &Identity, + data: web::Json, + server_config: &ServerConfig, +) -> Result, ServerError> { + let auth = authenticate(ident, server_config).await?; + match auth { + Role::Admin { .. } | Role::Regular { .. } => { + if let Some(id) = data.id { + let query: Item = get_link_by_id(ident, id, server_config).await?; + if auth.admin_or_self(query.item.author) { + let mut link = query.item; + let LinkDelta { + title, + target, + code, + .. + } = data.into_inner(); + link.code = code.clone(); + link.target = target; + link.title = title; + link.update_link(server_config).await?; + get_link(ident, &code, server_config).await + } else { + Err(ServerError::User("Invalid Request".to_owned())) + } + } else { + Err(ServerError::User("Not Allowed".to_owned())) + } + } + Role::Disabled | Role::NotAuthenticated => Err(ServerError::User("Not Allowed".to_owned())), + } +} diff --git a/pslink/src/views.rs b/pslink/src/views.rs index 5df6d9d..c5975e6 100644 --- a/pslink/src/views.rs +++ b/pslink/src/views.rs @@ -15,8 +15,8 @@ use image::{DynamicImage, ImageOutputFormat, Luma}; use qrcode::{render::svg, QrCode}; use queries::{authenticate, Role}; use shared::apirequests::{ - general::SuccessMessage, - links::LinkRequestForm, + general::{Message, Status}, + links::{LinkDelta, LinkRequestForm}, users::{UserDelta, UserRequestForm}, }; use tera::{Context, Tera}; @@ -171,6 +171,19 @@ pub async fn index_users_json( } } +pub async fn get_logged_user_json( + config: web::Data, + id: Identity, +) -> Result { + let user = authenticate(&id, &config).await?; + match user { + Role::NotAuthenticated | Role::Disabled => { + Err(ServerError::User("User not logged in!".to_string())) + } + Role::Regular { user } | Role::Admin { user } => Ok(HttpResponse::Ok().json2(&user)), + } +} + #[instrument(skip(id, tera))] pub async fn view_link_empty( tera: web::Data, @@ -358,9 +371,9 @@ pub async fn process_create_user_json( ) -> Result { info!("Listing Users to Json api"); match queries::create_user_json(&id, &form, &config).await { - Ok(item) => Ok(HttpResponse::Ok().json2(&SuccessMessage { + Ok(item) => Ok(HttpResponse::Ok().json2(&Status::Success(Message { message: format!("Successfully saved user: {}", item.item.username), - })), + }))), Err(e) => Err(e), } } @@ -373,9 +386,9 @@ pub async fn process_update_user_json( ) -> Result { info!("Listing Users to Json api"); match queries::update_user_json(&id, &form, &config).await { - Ok(item) => Ok(HttpResponse::Ok().json2(&SuccessMessage { + Ok(item) => Ok(HttpResponse::Ok().json2(&Status::Success(Message { message: format!("Successfully saved user: {}", item.item.username), - })), + }))), Err(e) => Err(e), } } @@ -447,20 +460,26 @@ pub async fn process_login( match user { Ok(u) => { - let secret = &config.secret; - let valid = Verifier::default() - .with_hash(&u.password) - .with_password(&data.password) - .with_secret_key(&secret.secret) - .verify()?; + if let Some(hash) = u.password.secret { + let secret = &config.secret; + let valid = Verifier::default() + .with_hash(hash) + .with_password(&data.password) + .with_secret_key(secret.secret.as_ref().expect("No secret available")) + .verify()?; - if valid { - info!("Log-in of user: {}", &u.username); - let session_token = u.username; - id.remember(session_token); - Ok(redirect_builder("/admin/index/")) + if valid { + info!("Log-in of user: {}", &u.username); + let session_token = u.username; + id.remember(session_token); + Ok(redirect_builder("/admin/index/")) + } else { + Ok(redirect_builder("/admin/login/")) + } } else { - Ok(redirect_builder("/admin/login/")) + Ok(HttpResponse::Unauthorized().json2(&Status::Error(Message { + message: "Failed to Login".to_string(), + }))) } } Err(e) => { @@ -515,6 +534,36 @@ pub async fn redirect_empty( Ok(redirect_builder(&config.empty_forward_url)) } +#[instrument(skip(id))] +pub async fn process_create_link_json( + config: web::Data, + data: web::Json, + id: Identity, +) -> Result { + let new_link = queries::create_link_json(&id, data, &config).await; + match new_link { + Ok(item) => Ok(HttpResponse::Ok().json2(&Status::Success(Message { + message: format!("Successfully saved link: {}", item.item.code), + }))), + Err(e) => Err(e), + } +} + +#[instrument(skip(id))] +pub async fn process_update_link_json( + config: web::Data, + data: web::Json, + id: Identity, +) -> Result { + let new_link = queries::update_link_json(&id, data, &config).await; + match new_link { + Ok(item) => Ok(HttpResponse::Ok().json2(&Status::Success(Message { + message: format!("Successfully updated link: {}", item.item.code), + }))), + Err(e) => Err(e), + } +} + #[instrument(skip(id))] pub async fn create_link( tera: web::Data, @@ -591,3 +640,14 @@ pub async fn process_link_delete( queries::delete_link(&id, &link_code.0, &config).await?; Ok(redirect_builder("/admin/login/")) } +#[instrument(skip(id))] +pub async fn process_delete_link_json( + id: Identity, + config: web::Data, + data: web::Json, +) -> Result { + queries::delete_link(&id, &data.code, &config).await?; + Ok(HttpResponse::Ok().json2(&Status::Success(Message { + message: format!("Successfully deleted link: {}", &data.code), + }))) +} diff --git a/pslink/static/admin.css b/pslink/static/admin.css index bc4370c..7168b50 100644 --- a/pslink/static/admin.css +++ b/pslink/static/admin.css @@ -43,6 +43,7 @@ form { table { border-collapse: collapse; width: 100%; + margin-bottom: 10px; } th, @@ -184,4 +185,12 @@ div.message { background-color: aliceblue; border: 5px solid rgb(90, 90, 90); height: auto; +} + +a { + cursor: pointer +} + +img.trashicon { + width: 0.5cm; } \ No newline at end of file diff --git a/pslink/static/trash.svg b/pslink/static/trash.svg new file mode 100644 index 0000000..1099ff4 --- /dev/null +++ b/pslink/static/trash.svg @@ -0,0 +1,19 @@ + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/pslink/tests/integration-tests.rs b/pslink/tests/integration-tests.rs index 56da9a9..bca938d 100644 --- a/pslink/tests/integration-tests.rs +++ b/pslink/tests/integration-tests.rs @@ -1,3 +1,5 @@ +use shared::datatypes::Secret; + #[test] fn test_help_of_command_for_breaking_changes() { let output = test_bin::get_test_bin("pslink") @@ -208,7 +210,7 @@ async fn run_server() { ); let server_config = pslink::ServerConfig { - secret: pslink::Secret::new("abcdefghijklmnopqrstuvw".to_string()), + secret: Secret::new("abcdefghijklmnopqrstuvw".to_string()), db: std::path::PathBuf::from("links.db"), db_pool, public_url: "localhost:8080".to_string(), diff --git a/shared/src/apirequests/general.rs b/shared/src/apirequests/general.rs index 1bf183c..4a3a4b4 100644 --- a/shared/src/apirequests/general.rs +++ b/shared/src/apirequests/general.rs @@ -39,6 +39,12 @@ impl Default for EditMode { } #[derive(Clone, Deserialize, Serialize, Debug)] -pub struct SuccessMessage { +pub struct Message { pub message: String, } + +#[derive(Clone, Deserialize, Serialize, Debug)] +pub enum Status { + Success(Message), + Error(Message), +} diff --git a/shared/src/apirequests/links.rs b/shared/src/apirequests/links.rs index 6f7592d..9c9a9de 100644 --- a/shared/src/apirequests/links.rs +++ b/shared/src/apirequests/links.rs @@ -1,7 +1,9 @@ use enum_map::{Enum, EnumMap}; use serde::{Deserialize, Serialize}; -use super::general::{Filter, Operation, Ordering}; +use crate::datatypes::{FullLink, Link}; + +use super::general::{EditMode, Filter, Operation, Ordering}; /// A generic list returntype containing the User and a Vec containing e.g. Links or Users #[derive(Clone, Deserialize, Serialize, Debug)] @@ -21,6 +23,48 @@ impl Default for LinkRequestForm { } } +/// The Struct that is responsible for creating and editing users. +#[derive(Default, Debug, Clone, Serialize, Deserialize)] +pub struct LinkDelta { + pub edit: EditMode, + pub id: Option, + pub title: String, + pub target: String, + pub code: String, + pub author: i64, + pub created_at: Option, +} + +impl From for LinkDelta { + /// Automatically create a `UserDelta` from a User. + fn from(l: Link) -> Self { + Self { + edit: EditMode::Edit, + id: Some(l.id), + title: l.title, + target: l.target, + code: l.code, + author: l.author, + created_at: Some(l.created_at), + } + } +} + +impl From for LinkDelta { + /// Automatically create a `UserDelta` from a User. + fn from(l: FullLink) -> Self { + Self { + edit: EditMode::Edit, + id: Some(l.link.id), + title: l.link.title, + target: l.link.target, + code: l.link.code, + author: l.link.author, + created_at: Some(l.link.created_at), + } + } +} + #[derive(Clone, Deserialize, Serialize, Debug, PartialEq, Eq, Hash, Enum)] pub enum LinkOverviewColumns { Code, diff --git a/shared/src/datatypes.rs b/shared/src/datatypes.rs index f37b998..0ecff3d 100644 --- a/shared/src/datatypes.rs +++ b/shared/src/datatypes.rs @@ -1,4 +1,4 @@ -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Serialize, Serializer}; /// A generic list returntype containing the User and a Vec containing e.g. Links or Users #[derive(Clone, Deserialize, Serialize)] pub struct ListWithOwner { @@ -19,7 +19,7 @@ pub struct User { pub id: i64, pub username: String, pub email: String, - pub password: String, + pub password: Secret, pub role: i64, pub language: String, } @@ -44,3 +44,45 @@ pub struct Click { pub link: i64, pub created_at: chrono::NaiveDateTime, } + +#[derive(PartialEq, Clone, Deserialize)] +#[serde(from = "String")] +pub struct Secret { + pub secret: Option, +} + +impl From for Secret { + fn from(_: String) -> Self { + Self { secret: None } + } +} + +impl Serialize for Secret { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str("*****SECRET*****") + } +} + +impl Secret { + #[must_use] + pub const fn new(secret: String) -> Self { + Self { + secret: Some(secret), + } + } +} + +impl std::fmt::Debug for Secret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("*****SECRET*****") + } +} + +impl std::fmt::Display for Secret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("*****SECRET*****") + } +}