motifs/src/main.rs

287 lines
7.2 KiB
Rust

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<RwLock<Store>>,
}
#[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.as_str(), port)).await.unwrap();
let server = axum::serve(listener, app);
println!("Listening on http://{address}:{port}");
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<MainState>) -> 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<MainState>) -> 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<String>, State(state): State<MainState>) -> 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', "<br>")
})) }
}
}
(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<MainState>, Form(post): Form<PostContent>) -> Response {
let mut store = state.store.write().unwrap();
let date = Utc::now().format("%d/%m/%Y");
if post.validate().is_err() {
return (StatusCode::BAD_REQUEST, "Bad input.").into_response();
}
let PostContent {
topic,
content,
author,
} = post;
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())
}
}
)
}