mirror of
https://git.alemi.dev/guestbook.rs.git
synced 2024-11-12 19:39:28 +01:00
chore: separated in/out model and conversions
also initial work on overrides coming from CLI
This commit is contained in:
parent
0ebf01ceb1
commit
d83b3d73d6
5 changed files with 148 additions and 86 deletions
58
src/main.rs
58
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<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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
100
src/model.rs
100
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<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()))
|
||||
}
|
||||
|
|
|
@ -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(""));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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(¤t_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());
|
||||
|
|
Loading…
Reference in a new issue