diff --git a/src/main.rs b/src/main.rs index 666c34e..0e3b479 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,5 @@ use std::net::SocketAddr; -use clap::Parser; +use clap::{Parser, Subcommand}; use crate::{storage::JsonFileStorageStrategy, routes::Context, notifications::console::ConsoleTracingNotifier}; @@ -11,20 +11,40 @@ mod storage; #[derive(Debug, Clone, Parser)] +#[command(author, version, about)] /// api for sending anonymous telegram messages to a specific user struct CliArgs { - // chat id of target user - //target: i64, - - #[arg(long, short, default_value = "127.0.0.1:37812")] - /// host to bind onto - addr: String, + /// action to execute + #[clap(subcommand)] + action: CliAction, #[arg(long, default_value_t = false)] /// increase log verbosity to DEBUG level debug: bool, } +#[derive(Debug, Clone, Subcommand)] +enum CliAction { + Serve { + #[arg(long, short, default_value = "127.0.0.1:37812")] + /// host to bind onto + addr: String, + + #[arg(long)] + /// force public field content + public: Option, + + #[arg(long)] + /// force author field content + author: Option, + } +} + +struct CliServeOverrides { + author: Option, + public: Option, +} + #[tokio::main] async fn main() { let args = CliArgs::parse(); @@ -34,19 +54,23 @@ async fn main() { .pretty() .finish(); - let addr : SocketAddr = args.addr.parse().expect("invalid host provided"); + match args.action { + CliAction::Serve { addr, public, author } => { + let addr : SocketAddr = addr.parse().expect("invalid host provided"); - let storage = Box::new(JsonFileStorageStrategy::new("./storage.json")); + let storage = Box::new(JsonFileStorageStrategy::new("./storage.json")); - let state = Context::new(storage) - .register(Box::new(ConsoleTracingNotifier {})); + let state = Context::new(storage) + .register(Box::new(ConsoleTracingNotifier {})); - let router = routes::create_router_with_app_routes(state); + let router = routes::create_router_with_app_routes(state); - tracing::info!("listening on {}", addr); + tracing::info!("listening on {}", addr); - axum::Server::bind(&addr) - .serve(router.into_make_service()) - .await - .unwrap(); + axum::Server::bind(&addr) + .serve(router.into_make_service()) + .await + .unwrap(); + } + } } diff --git a/src/model.rs b/src/model.rs index 5a3bd8f..4b8b9eb 100644 --- a/src/model.rs +++ b/src/model.rs @@ -1,18 +1,68 @@ +use md5::{Md5, Digest}; use serde::{Serialize, Deserialize}; use chrono::{DateTime, Utc}; +use uuid::Uuid; + +const AUTHOR_MAX_CHARS: usize = 25; +const CONTACT_MAX_CHARS: usize = 50; +const BODY_MAX_CHARS: usize = 4096; #[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct GuestBookPage { +pub struct Page { pub author: String, + pub contact: Option, pub body: String, pub date: DateTime, - pub avatar: String, - pub url: Option, - pub contact: Option, + pub public: bool, } -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct Insertion { + + + +#[derive(Debug, Clone, Default, Serialize)] +pub struct PageView { + pub author: String, + pub contact: Option, + pub url: Option, + pub avatar: String, + pub body: String, + pub date: DateTime, +} + +impl From for PageView { + fn from(page: Page) -> Self { + let mut hasher = Md5::new(); + hasher.update(page.contact.as_deref().unwrap_or(&Uuid::new_v4().to_string()).as_bytes()); + let avatar = format!("{:x}", hasher.finalize()); + + let url = match page.contact { + None => None, + Some(c) => if c.starts_with("http") { + Some(c) + } else if c.contains('@') { + Some(format!("mailto:{}", c)) + } else if c.contains('.') { + Some(format!("https://{}", c)) + } else { + None + } + }; + + PageView { + url, avatar, + author: page.author, + contact: page.contact, + body: page.body, + date: page.date, + } + } +} + + + + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct PageInsertion { #[serde(deserialize_with = "non_empty_str")] pub author: Option, @@ -22,16 +72,39 @@ pub struct Insertion { pub body: String, } -impl Insertion { - pub fn sanitize(self) -> Self { - Insertion { - author: self.author.map(|x| html_escape::encode_safe(&x).to_string()), - contact: self.contact.map(|x| html_escape::encode_safe(&x).to_string()), - body: html_escape::encode_safe(&self.body).to_string(), +impl PageInsertion { + pub fn sanitize(&mut self) { + self.author = self.author.map(|x| html_escape::encode_safe(&x.chars().take(AUTHOR_MAX_CHARS).collect::()).to_string()); + self.contact = self.contact.map(|x| html_escape::encode_safe(&x.chars().take(CONTACT_MAX_CHARS).collect::()).to_string()); + self.body = html_escape::encode_safe(&self.body.chars().take(BODY_MAX_CHARS).collect::()).to_string(); + } + + pub fn convert(mut self, overrides: crate::CliServeOverrides) -> Page { + self.sanitize(); + + let mut page = Page { + author: self.author.unwrap_or("".into()), + contact: self.contact, + body: self.body, + date: Utc::now(), + public: true, + }; + + if let Some(author) = overrides.author { + page.author = author; } + if let Some(public) = overrides.public { + page.public = public; + } + + page } } + + + + #[derive(Debug, Clone, Serialize, Deserialize)] pub enum Acknowledgement { Sent(String), @@ -44,9 +117,6 @@ pub struct PageOptions { pub limit: Option, } - - - fn non_empty_str<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { Ok(Option::deserialize(d)?.filter(|s: &String| !s.is_empty())) } diff --git a/src/notifications/console.rs b/src/notifications/console.rs index 60eef30..922f50c 100644 --- a/src/notifications/console.rs +++ b/src/notifications/console.rs @@ -1,4 +1,4 @@ -use crate::model::GuestBookPage; +use crate::model::Page; use super::NotificationProcessor; @@ -6,8 +6,8 @@ use super::NotificationProcessor; pub struct ConsoleTracingNotifier {} #[async_trait::async_trait] -impl NotificationProcessor for ConsoleTracingNotifier { - async fn process(&self, suggestion: &GuestBookPage) { +impl NotificationProcessor for ConsoleTracingNotifier { + async fn process(&self, suggestion: &Page) { tracing::info!(" >> {:?}", suggestion); } } @@ -15,8 +15,8 @@ impl NotificationProcessor for ConsoleTracingNotifier { pub struct ConsolePrettyNotifier {} #[async_trait::async_trait] -impl NotificationProcessor for ConsolePrettyNotifier { - async fn process(&self, suggestion: &GuestBookPage) { +impl NotificationProcessor for ConsolePrettyNotifier { + async fn process(&self, suggestion: &Page) { println!("{} -- {} <{}>", suggestion.body, suggestion.author, suggestion.contact.as_deref().unwrap_or("")); } } diff --git a/src/routes.rs b/src/routes.rs index 2aa4c94..feb6439 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -6,7 +6,7 @@ use md5::{Md5, Digest}; use tokio::sync::RwLock; use uuid::Uuid; -use crate::{notifications::NotificationProcessor, model::{GuestBookPage, PageOptions, Insertion}, storage::StorageStrategy}; +use crate::{notifications::NotificationProcessor, model::{Page, PageOptions, PageInsertion}, storage::StorageStrategy}; pub fn create_router_with_app_routes(state: Context) -> Router { Router::new() @@ -19,46 +19,29 @@ pub fn create_router_with_app_routes(state: Context) -> Router { type SafeContext = Arc>; pub struct Context { - providers: Vec>>, - storage: Box>, + providers: Vec>>, + storage: Box>, } impl Context { - pub fn new(storage: Box>) -> Self { + pub fn new(storage: Box>) -> Self { Context { providers: Vec::new(), storage } } - pub fn register(mut self, notifier: Box>) -> Self { + pub fn register(mut self, notifier: Box>) -> Self { self.providers.push(notifier); self } - async fn process(&self, x: &GuestBookPage) { + async fn process(&self, x: &Page) { for p in self.providers.iter() { p.process(x).await; } } } -async fn send_suggestion(unsafe_payload: Insertion, state: SafeContext) -> Result { - // sanitize all user input! we don't want XSS or html injections! - let payload = unsafe_payload.sanitize(); - // limit author and contact fields to 25 and 50 characters, TODO don't hardcode limits - let contact_limited = payload.contact.clone().map(|x| limit_string(&x, 50)); - let author_limited = payload.author.map(|x| limit_string(&x, 25)); - // calculate contact hash for libravatar - let mut hasher = Md5::new(); - hasher.update(contact_limited.as_deref().unwrap_or(&Uuid::new_v4().to_string()).as_bytes()); - let avatar = hasher.finalize(); - // populate guestbook page struct - let page = GuestBookPage { - avatar: format!("{:x}", avatar), - author: author_limited.unwrap_or("anonymous".to_string()), - url: url_from_contact(contact_limited.clone()), - contact: contact_limited, - body: payload.body, - date: Utc::now(), - }; +async fn send_suggestion(payload: PageInsertion, state: SafeContext) -> Result { + let page = Page::from(payload); // lock state, process and archive new page let mut lock = state.write().await; lock.process(&page).await; @@ -68,11 +51,11 @@ async fn send_suggestion(unsafe_payload: Insertion, state: SafeContext) -> Resul } } -async fn send_suggestion_json(State(state): State, Json(payload): Json) -> Result { send_suggestion(payload, state).await } -async fn send_suggestion_form(State(state): State, Form(payload): Form) -> Result { send_suggestion(payload, state).await } +async fn send_suggestion_json(State(state): State, Json(payload): Json) -> Result { send_suggestion(payload, state).await } +async fn send_suggestion_form(State(state): State, Form(payload): Form) -> Result { send_suggestion(payload, state).await } -async fn get_suggestion(State(state): State, Query(page): Query) -> Result>, String> { +async fn get_suggestion(State(state): State, Query(page): Query) -> Result>, String> { let offset = page.offset.unwrap_or(0); let limit = std::cmp::min(page.limit.unwrap_or(20), 20); @@ -82,21 +65,6 @@ async fn get_suggestion(State(state): State, Query(page): Query) -> Option { - match contact { - None => None, - Some(c) => if c.starts_with("http") { - Some(c) - } else if c.contains('@') { - Some(format!("mailto:{}", c)) - } else if c.contains('.') { - Some(format!("https://{}", c)) - } else { - None - } - } -} - fn limit_string(s: &str, l: usize) -> String { // TODO is there a better way? slicing doesn't work when l > s.len s.chars().take(l).collect() diff --git a/src/storage.rs b/src/storage.rs index b27ee94..674b0fd 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -1,4 +1,4 @@ -use crate::model::GuestBookPage; +use crate::model::Page; #[derive(Debug, thiserror::Error)] @@ -30,19 +30,19 @@ impl JsonFileStorageStrategy { #[async_trait::async_trait] -impl StorageStrategy for JsonFileStorageStrategy { - async fn archive(&mut self, payload: GuestBookPage) -> Result<(), StorageStrategyError> { +impl StorageStrategy for JsonFileStorageStrategy { + async fn archive(&mut self, payload: Page) -> Result<(), StorageStrategyError> { let file_content = std::fs::read_to_string(&self.path)?; - let mut current_content : Vec = serde_json::from_str(&file_content)?; + let mut current_content : Vec = serde_json::from_str(&file_content)?; current_content.push(payload); let updated_content = serde_json::to_string(¤t_content)?; std::fs::write(&self.path, updated_content)?; Ok(()) } - async fn extract(&self, offset: usize, window: usize) -> Result, StorageStrategyError> { + async fn extract(&self, offset: usize, window: usize) -> Result, StorageStrategyError> { let file_content = std::fs::read_to_string(&self.path)?; - let current_content : Vec = serde_json::from_str(&file_content)?; + let current_content : Vec = serde_json::from_str(&file_content)?; let mut out = Vec::new(); for sugg in current_content.iter().rev().skip(offset) { out.push(sugg.clone());