mirror of
https://git.alemi.dev/guestbook.rs.git
synced 2024-12-19 02:54:52 +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 std::net::SocketAddr;
|
||||||
use clap::Parser;
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
use crate::{storage::JsonFileStorageStrategy, routes::Context, notifications::console::ConsoleTracingNotifier};
|
use crate::{storage::JsonFileStorageStrategy, routes::Context, notifications::console::ConsoleTracingNotifier};
|
||||||
|
|
||||||
|
@ -11,20 +11,40 @@ mod storage;
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Parser)]
|
#[derive(Debug, Clone, Parser)]
|
||||||
|
#[command(author, version, about)]
|
||||||
/// api for sending anonymous telegram messages to a specific user
|
/// api for sending anonymous telegram messages to a specific user
|
||||||
struct CliArgs {
|
struct CliArgs {
|
||||||
// chat id of target user
|
/// action to execute
|
||||||
//target: i64,
|
#[clap(subcommand)]
|
||||||
|
action: CliAction,
|
||||||
#[arg(long, short, default_value = "127.0.0.1:37812")]
|
|
||||||
/// host to bind onto
|
|
||||||
addr: String,
|
|
||||||
|
|
||||||
#[arg(long, default_value_t = false)]
|
#[arg(long, default_value_t = false)]
|
||||||
/// increase log verbosity to DEBUG level
|
/// increase log verbosity to DEBUG level
|
||||||
debug: bool,
|
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]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
let args = CliArgs::parse();
|
let args = CliArgs::parse();
|
||||||
|
@ -34,19 +54,23 @@ async fn main() {
|
||||||
.pretty()
|
.pretty()
|
||||||
.finish();
|
.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)
|
let state = Context::new(storage)
|
||||||
.register(Box::new(ConsoleTracingNotifier {}));
|
.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)
|
axum::Server::bind(&addr)
|
||||||
.serve(router.into_make_service())
|
.serve(router.into_make_service())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
100
src/model.rs
100
src/model.rs
|
@ -1,18 +1,68 @@
|
||||||
|
use md5::{Md5, Digest};
|
||||||
use serde::{Serialize, Deserialize};
|
use serde::{Serialize, Deserialize};
|
||||||
use chrono::{DateTime, Utc};
|
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)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct GuestBookPage {
|
pub struct Page {
|
||||||
pub author: String,
|
pub author: String,
|
||||||
|
pub contact: Option<String>,
|
||||||
pub body: String,
|
pub body: String,
|
||||||
pub date: DateTime<Utc>,
|
pub date: DateTime<Utc>,
|
||||||
pub avatar: String,
|
pub public: bool,
|
||||||
pub url: Option<String>,
|
|
||||||
pub contact: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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")]
|
#[serde(deserialize_with = "non_empty_str")]
|
||||||
pub author: Option<String>,
|
pub author: Option<String>,
|
||||||
|
|
||||||
|
@ -22,16 +72,39 @@ pub struct Insertion {
|
||||||
pub body: String,
|
pub body: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Insertion {
|
impl PageInsertion {
|
||||||
pub fn sanitize(self) -> Self {
|
pub fn sanitize(&mut self) {
|
||||||
Insertion {
|
self.author = self.author.map(|x| html_escape::encode_safe(&x.chars().take(AUTHOR_MAX_CHARS).collect::<String>()).to_string());
|
||||||
author: self.author.map(|x| html_escape::encode_safe(&x).to_string()),
|
self.contact = self.contact.map(|x| html_escape::encode_safe(&x.chars().take(CONTACT_MAX_CHARS).collect::<String>()).to_string());
|
||||||
contact: self.contact.map(|x| html_escape::encode_safe(&x).to_string()),
|
self.body = html_escape::encode_safe(&self.body.chars().take(BODY_MAX_CHARS).collect::<String>()).to_string();
|
||||||
body: html_escape::encode_safe(&self.body).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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub enum Acknowledgement {
|
pub enum Acknowledgement {
|
||||||
Sent(String),
|
Sent(String),
|
||||||
|
@ -44,9 +117,6 @@ pub struct PageOptions {
|
||||||
pub limit: Option<usize>,
|
pub limit: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fn non_empty_str<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
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()))
|
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;
|
use super::NotificationProcessor;
|
||||||
|
|
||||||
|
@ -6,8 +6,8 @@ use super::NotificationProcessor;
|
||||||
pub struct ConsoleTracingNotifier {}
|
pub struct ConsoleTracingNotifier {}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl NotificationProcessor<GuestBookPage> for ConsoleTracingNotifier {
|
impl NotificationProcessor<Page> for ConsoleTracingNotifier {
|
||||||
async fn process(&self, suggestion: &GuestBookPage) {
|
async fn process(&self, suggestion: &Page) {
|
||||||
tracing::info!(" >> {:?}", suggestion);
|
tracing::info!(" >> {:?}", suggestion);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -15,8 +15,8 @@ impl NotificationProcessor<GuestBookPage> for ConsoleTracingNotifier {
|
||||||
pub struct ConsolePrettyNotifier {}
|
pub struct ConsolePrettyNotifier {}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl NotificationProcessor<GuestBookPage> for ConsolePrettyNotifier {
|
impl NotificationProcessor<Page> for ConsolePrettyNotifier {
|
||||||
async fn process(&self, suggestion: &GuestBookPage) {
|
async fn process(&self, suggestion: &Page) {
|
||||||
println!("{} -- {} <{}>", suggestion.body, suggestion.author, suggestion.contact.as_deref().unwrap_or(""));
|
println!("{} -- {} <{}>", suggestion.body, suggestion.author, suggestion.contact.as_deref().unwrap_or(""));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,7 @@ use md5::{Md5, Digest};
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use uuid::Uuid;
|
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 {
|
pub fn create_router_with_app_routes(state: Context) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
|
@ -19,46 +19,29 @@ pub fn create_router_with_app_routes(state: Context) -> Router {
|
||||||
type SafeContext = Arc<RwLock<Context>>;
|
type SafeContext = Arc<RwLock<Context>>;
|
||||||
|
|
||||||
pub struct Context {
|
pub struct Context {
|
||||||
providers: Vec<Box<dyn NotificationProcessor<GuestBookPage>>>,
|
providers: Vec<Box<dyn NotificationProcessor<Page>>>,
|
||||||
storage: Box<dyn StorageStrategy<GuestBookPage>>,
|
storage: Box<dyn StorageStrategy<Page>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Context {
|
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 }
|
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.providers.push(notifier);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn process(&self, x: &GuestBookPage) {
|
async fn process(&self, x: &Page) {
|
||||||
for p in self.providers.iter() {
|
for p in self.providers.iter() {
|
||||||
p.process(x).await;
|
p.process(x).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_suggestion(unsafe_payload: Insertion, state: SafeContext) -> Result<Redirect, String> {
|
async fn send_suggestion(payload: PageInsertion, state: SafeContext) -> Result<Redirect, String> {
|
||||||
// sanitize all user input! we don't want XSS or html injections!
|
let page = Page::from(payload);
|
||||||
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(),
|
|
||||||
};
|
|
||||||
// lock state, process and archive new page
|
// lock state, process and archive new page
|
||||||
let mut lock = state.write().await;
|
let mut lock = state.write().await;
|
||||||
lock.process(&page).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_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<Insertion>) -> 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 offset = page.offset.unwrap_or(0);
|
||||||
let limit = std::cmp::min(page.limit.unwrap_or(20), 20);
|
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 {
|
fn limit_string(s: &str, l: usize) -> String {
|
||||||
// TODO is there a better way? slicing doesn't work when l > s.len
|
// TODO is there a better way? slicing doesn't work when l > s.len
|
||||||
s.chars().take(l).collect()
|
s.chars().take(l).collect()
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
use crate::model::GuestBookPage;
|
use crate::model::Page;
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
@ -30,19 +30,19 @@ impl JsonFileStorageStrategy {
|
||||||
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl StorageStrategy<GuestBookPage> for JsonFileStorageStrategy {
|
impl StorageStrategy<Page> for JsonFileStorageStrategy {
|
||||||
async fn archive(&mut self, payload: GuestBookPage) -> Result<(), StorageStrategyError> {
|
async fn archive(&mut self, payload: Page) -> Result<(), StorageStrategyError> {
|
||||||
let file_content = std::fs::read_to_string(&self.path)?;
|
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);
|
current_content.push(payload);
|
||||||
let updated_content = serde_json::to_string(¤t_content)?;
|
let updated_content = serde_json::to_string(¤t_content)?;
|
||||||
std::fs::write(&self.path, updated_content)?;
|
std::fs::write(&self.path, updated_content)?;
|
||||||
Ok(())
|
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 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();
|
let mut out = Vec::new();
|
||||||
for sugg in current_content.iter().rev().skip(offset) {
|
for sugg in current_content.iter().rev().skip(offset) {
|
||||||
out.push(sugg.clone());
|
out.push(sugg.clone());
|
||||||
|
|
Loading…
Reference in a new issue