chore: separated in/out model and conversions

also initial work on overrides coming from CLI
This commit is contained in:
əlemi 2023-12-28 18:19:16 +01:00
parent 0ebf01ceb1
commit d83b3d73d6
5 changed files with 148 additions and 86 deletions

View file

@ -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<bool>,
#[arg(long)]
/// force author field content
author: Option<String>,
}
}
struct CliServeOverrides {
author: Option<String>,
public: Option<bool>,
}
#[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();
}
}
}

View file

@ -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<String>,
pub body: String,
pub date: DateTime<Utc>,
pub avatar: String,
pub url: Option<String>,
pub contact: Option<String>,
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<String>,
pub url: Option<String>,
pub avatar: String,
pub body: String,
pub date: DateTime<Utc>,
}
impl From<Page> 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<String>,
@ -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::<String>()).to_string());
self.contact = self.contact.map(|x| html_escape::encode_safe(&x.chars().take(CONTACT_MAX_CHARS).collect::<String>()).to_string());
self.body = html_escape::encode_safe(&self.body.chars().take(BODY_MAX_CHARS).collect::<String>()).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<usize>,
}
fn non_empty_str<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
Ok(Option::deserialize(d)?.filter(|s: &String| !s.is_empty()))
}

View file

@ -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<GuestBookPage> for ConsoleTracingNotifier {
async fn process(&self, suggestion: &GuestBookPage) {
impl NotificationProcessor<Page> for ConsoleTracingNotifier {
async fn process(&self, suggestion: &Page) {
tracing::info!(" >> {:?}", suggestion);
}
}
@ -15,8 +15,8 @@ impl NotificationProcessor<GuestBookPage> for ConsoleTracingNotifier {
pub struct ConsolePrettyNotifier {}
#[async_trait::async_trait]
impl NotificationProcessor<GuestBookPage> for ConsolePrettyNotifier {
async fn process(&self, suggestion: &GuestBookPage) {
impl NotificationProcessor<Page> for ConsolePrettyNotifier {
async fn process(&self, suggestion: &Page) {
println!("{} -- {} <{}>", suggestion.body, suggestion.author, suggestion.contact.as_deref().unwrap_or(""));
}
}

View file

@ -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<RwLock<Context>>;
pub struct Context {
providers: Vec<Box<dyn NotificationProcessor<GuestBookPage>>>,
storage: Box<dyn StorageStrategy<GuestBookPage>>,
providers: Vec<Box<dyn NotificationProcessor<Page>>>,
storage: Box<dyn StorageStrategy<Page>>,
}
impl Context {
pub fn new(storage: Box<dyn StorageStrategy<GuestBookPage>>) -> Self {
pub fn new(storage: Box<dyn StorageStrategy<Page>>) -> Self {
Context { providers: Vec::new(), storage }
}
pub fn register(mut self, notifier: Box<dyn NotificationProcessor<GuestBookPage>>) -> Self {
pub fn register(mut self, notifier: Box<dyn NotificationProcessor<Page>>) -> 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<Redirect, String> {
// 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<Redirect, String> {
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<SafeContext>, Json(payload): Json<Insertion>) -> Result<Redirect, String> { send_suggestion(payload, state).await }
async fn send_suggestion_form(State(state): State<SafeContext>, Form(payload): Form<Insertion>) -> Result<Redirect, String> { send_suggestion(payload, state).await }
async fn send_suggestion_json(State(state): State<SafeContext>, Json(payload): Json<PageInsertion>) -> Result<Redirect, String> { send_suggestion(payload, state).await }
async fn send_suggestion_form(State(state): State<SafeContext>, Form(payload): Form<PageInsertion>) -> Result<Redirect, String> { send_suggestion(payload, state).await }
async fn get_suggestion(State(state): State<SafeContext>, Query(page): Query<PageOptions>) -> Result<Json<Vec<GuestBookPage>>, String> {
async fn get_suggestion(State(state): State<SafeContext>, Query(page): Query<PageOptions>) -> Result<Json<Vec<Page>>, 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<SafeContext>, Query(page): Query<Pag
}
}
fn url_from_contact(contact: Option<String>) -> Option<String> {
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()

View file

@ -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<GuestBookPage> for JsonFileStorageStrategy {
async fn archive(&mut self, payload: GuestBookPage) -> Result<(), StorageStrategyError> {
impl StorageStrategy<Page> 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<GuestBookPage> = serde_json::from_str(&file_content)?;
let mut current_content : Vec<Page> = serde_json::from_str(&file_content)?;
current_content.push(payload);
let updated_content = serde_json::to_string(&current_content)?;
std::fs::write(&self.path, updated_content)?;
Ok(())
}
async fn extract(&self, offset: usize, window: usize) -> Result<Vec<GuestBookPage>, StorageStrategyError> {
async fn extract(&self, offset: usize, window: usize) -> Result<Vec<Page>, StorageStrategyError> {
let file_content = std::fs::read_to_string(&self.path)?;
let current_content : Vec<GuestBookPage> = serde_json::from_str(&file_content)?;
let current_content : Vec<Page> = serde_json::from_str(&file_content)?;
let mut out = Vec::new();
for sugg in current_content.iter().rev().skip(offset) {
out.push(sugg.clone());