use std::{ fmt::Write, path::PathBuf, sync::{Arc, RwLock}, }; use anyhow::Result; use axum::{ extract::{Path, State}, http::StatusCode, response::{IntoResponse, Redirect, Response}, routing::get, Form, Router, }; use chrono::prelude::*; use common::{footer, head, header, style}; use maud::{html, Escaper, Markup, PreEscaped}; use serde::{Deserialize, Serialize}; use store::Store; use tokio::net::TcpListener; use validator::{Validate, ValidationError}; mod common; mod store; #[derive(Debug, clap::Parser)] /// Arbre struct Cmd { #[arg(short, long, default_value_t = 8200)] /// Port on which th server will listen. port: u16, #[arg(short, long, default_value = "0.0.0.0")] /// Hostname or address on which th server will listen. address: String, #[arg(short, long, default_value = "./store")] /// Path to the directory to use as storage for topics. store: PathBuf, } #[derive(Debug, Clone)] struct MainState { store: Arc>, } #[tokio::main] async fn main() -> Result<()> { let Cmd { address, port, store, } = clap::Parser::parse(); let store = Store::open(store)?; let store = Arc::new(RwLock::new(store)); let state = MainState { store }; let app = Router::new() .route("/style.css", get(style)) .route("/topics", get(topics).post(post)) .route("/topic/:name", get(topic)) .route("/create", get(create)) .route("/activity", get(activity)) .route("/", get(home)) .with_state(state); let listener = TcpListener::bind((address, port)).await.unwrap(); let server = axum::serve(listener, app); server.await?; Ok(()) } async fn home() -> Markup { html!( (head("")) body { content { (header()) main { "Bienvenue au recueil de Barnulf." br; " Ce site contient une collection ouverte de réflexions personnelles sur différents sujets relatifs à l'informatique. " } (footer()) } } ) } async fn topics(State(state): State) -> impl IntoResponse { let topics = state.store.read().unwrap().list().unwrap(); html!( (head("sujets")) body { content { (header()) main { h2 { "Sujets" } ul { @for topic in topics { li { a href = { "/topic/" (topic) } { (topic) } } } } } (footer()) } } ) } async fn activity(State(state): State) -> impl IntoResponse { let topics = state.store.read().unwrap().activity().unwrap(); html!( (head("activité")) body { content { (header()) main { h2 { "Activité" } ul { @for topic in topics { li { a href = { "/topic/" (topic) } { (topic) } } } } } (footer()) } } ) } async fn topic(Path(name): Path, State(state): State) -> impl IntoResponse { let Ok(posts) = state.store.read().unwrap().get(&name) else { return error("No such topic."); }; html!( (head(&name)) body { content { (header()) main { h2 { (name) } @for post in posts { hr; section { (PreEscaped({ let mut buffer = String::new(); write!(Escaper::new(&mut buffer), "{post}").unwrap(); buffer.replace('\n', "
") })) } } } (footer()) } } ) } #[derive(Debug, Clone, Serialize, Deserialize, Validate)] struct PostContent { #[validate(length(min = 1, max = 32), custom(function = "validate_topic"))] topic: String, #[validate(length(min = 10, max = 10_000))] content: String, #[validate(length(min = 2, max = 32))] author: String, } fn validate_topic(topic: &str) -> Result<(), ValidationError> { if topic.starts_with('_') || topic.ends_with('_') { return Err(ValidationError::new("Bad topic format.")); } Ok(()) } async fn post(State(state): State, Form(post_content): Form) -> Response { let mut store = state.store.write().unwrap(); let date = Utc::now().format("%d/%m/%Y"); if post_content.validate().is_err() { return (StatusCode::BAD_REQUEST, "Bad input.").into_response(); } let PostContent { topic, content, author, } = post_content; let topic = sanithize_identifier(&topic); let content = format!("{content}\n\t~{author}, {date}"); store.insert(&topic, content).unwrap(); Redirect::to("/activity").into_response() } fn sanithize_identifier(input: &str) -> String { let text = input.to_lowercase(); let replaces = [ ('à', 'a'), ('â', 'a'), ('ä', 'a'), ('é', 'e'), ('è', 'e'), ('ê', 'e'), ('ë', 'e'), ('î', 'i'), ('ï', 'i'), ('ô', 'o'), ('ö', 'o'), ('û', 'u'), ('ü', 'u'), (' ', '_'), ]; text.chars() .map(|c| { for (from, to) in replaces { if c == from { return to; } } c }) .filter(|c| c.is_ascii_lowercase() || *c == '_') .collect() } async fn create() -> impl IntoResponse { html!( (head("créer")) body { content { (header()) main { h2 { "Créer" } form id = "create" action = "/topics" method = "post" { label for = "topic" { "Sujet" } input type = "text" name = "topic"; br; label for = "author" { "Auteur" } input type = "text" name = "author"; br; label for = "content" { "Contenu" } textarea form = "create" name = "content" { } br; input type = "submit"; } } (footer()) } } ) } fn error(message: impl ToString) -> Markup { let message = message.to_string(); html!( (head("Failure")) body { content { (header()) main { h2 { "Failure" } br; (message) } (footer()) } } ) }