287 lines
7.2 KiB
Rust
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())
|
|
}
|
|
}
|
|
)
|
|
}
|