This commit is contained in:
JOLIMAITRE Matthieu 2024-07-25 01:24:41 +02:00
commit 3f461e8375
6 changed files with 1571 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/target
/store

1185
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

16
Cargo.toml Normal file
View file

@ -0,0 +1,16 @@
[package]
name = "motifs"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1.0.86"
axum = "0.7.5"
chrono = "0.4.38"
maud = { version = "0.26.0", features = ["axum"] }
serde = { version = "1.0.204", features = ["derive"] }
serde_bson = "0.0.1"
serde_json = "1.0.120"
sled = "0.34.7"
stylist = "0.13.0"
tokio = { version = "1.39.1", features = ["net", "rt-multi-thread"] }

91
src/common.rs Normal file
View file

@ -0,0 +1,91 @@
use axum::response::IntoResponse;
use maud::{html, Markup};
use stylist::{css, GlobalStyle};
pub fn head(title: &str) -> Markup {
html!(
head {
title { "Recueil " (title) }
link rel = "stylesheet" href = "/style.css";
}
)
}
pub fn header() -> Markup {
html!(
header {
a href = "/" { h1 { "Motifs" } }
a href = "/activity" { "activité" }
a href = "/topics" { "sujets" }
}
)
}
pub fn footer() -> Markup {
html!(
footer {
a href = "https://barnulf.net" { "barnulf.net" }
p { "Propulsé par la rouille." }
}
)
}
pub async fn style() -> impl IntoResponse {
#[allow(non_upper_case_globals)]
let style = GlobalStyle::new(css!(
page, html, body, content {
padding: 0;
border: 0;
margin: 0;
overflow-x: hidden;
}
body {
background-color: #181818;
color: white;
display: grid;
place-items: start center;
}
content {
background-color: #202020;
min-height: 100vh;
width: 100vw;
max-width: 1000px;
border-left: 1px solid #ffffff16;
border-right: 1px solid #ffffff16;
display: grid;
grid-template-rows: auto 1fr auto;
}
header {
border-bottom: 1px solid #ffffff16;
display: flex;
place-items: center;
padding-left: 2rem;
gap: 2rem;
}
a {
color: wheat;
}
main {
padding: 2rem;
}
footer {
border-top: 1px solid #ffffff16;
display: flex;
place-items: center;
padding-left: 2rem;
gap: 2rem;
}
))
.expect("Fails to compile style.")
.get_style_str()
.to_string();
([("content-type", "style/css")], style)
}

210
src/main.rs Normal file
View file

@ -0,0 +1,210 @@
use std::fmt::Write;
use std::sync::{Arc, RwLock};
use anyhow::Result;
use axum::{
extract::{Path, State},
response::IntoResponse,
routing::get,
Json, 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;
mod common;
mod store;
#[derive(Debug, Clone)]
struct MainState {
store: Arc<RwLock<Store>>,
}
#[tokio::main]
async fn main() -> Result<()> {
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("/activity", get(activity))
.route("/", get(home))
.with_state(state);
let listener = TcpListener::bind("0.0.0.0:3000").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<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)]
struct PostContent {
topic: String,
content: String,
author: String,
}
async fn post(
State(state): State<MainState>,
Json(PostContent {
topic,
content,
author,
}): Json<PostContent>,
) -> impl IntoResponse {
let mut store = state.store.write().unwrap();
let date = Utc::now().format("%d/%m/%Y");
let topic = sanithize_identifier(&topic);
let content = content.trim();
let content = format!("{content}\n\t~{author}, {date}");
store.insert(&topic, content).unwrap();
}
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(char::is_ascii)
.collect()
}
fn error(message: impl ToString) -> Markup {
let message = message.to_string();
html!(
(head("Failure"))
body {
content {
(header())
main {
h2 { "Failure" }
br;
(message)
}
(footer())
}
}
)
}

67
src/store.rs Normal file
View file

@ -0,0 +1,67 @@
use std::{
fs::{self, OpenOptions},
io::{ErrorKind, Write},
};
use anyhow::Result;
#[derive(Debug)]
pub struct Store {
path: String,
}
impl Store {
pub fn open(path: impl ToString) -> Result<Self> {
let path = path.to_string();
fs::create_dir_all(&path)?;
Ok(Self { path })
}
pub fn list(&self) -> Result<Vec<String>> {
let mut results = Vec::new();
for result in fs::read_dir(&self.path)? {
let entry = result?;
let name = entry.file_name().into_string().unwrap();
results.push(name);
}
results.sort();
Ok(results)
}
pub fn activity(&self) -> Result<Vec<String>> {
let mut results = Vec::new();
for result in fs::read_dir(&self.path)? {
let entry = result?;
let name = entry.file_name().into_string().unwrap();
let modif_time = entry.metadata()?.modified()?;
let duration = modif_time.duration_since(std::time::UNIX_EPOCH)?;
let date = duration.as_nanos();
results.push((name, date));
}
results.sort_by_key(|&(_, date)| date);
Ok(results.into_iter().rev().map(|(name, _)| name).collect())
}
pub fn get(&self, topic: &str) -> Result<Vec<String>> {
let base = &self.path;
let path = format!("{base}/{topic}");
let content = fs::read_to_string(path)?;
Ok(content.split(SEPARATOR).map(String::from).collect())
}
pub fn insert(&mut self, topic: &str, content: String) -> Result<()> {
let content = content.replace('\u{c}', " ");
let content = content.trim();
let base = &self.path;
let path = format!("{base}/{topic}");
match OpenOptions::new().append(true).create(false).open(&path) {
Ok(mut file) => write!(&mut file, "{SEPARATOR}\n{content}\n")?,
Err(error) if error.kind() == ErrorKind::NotFound => drop(fs::write(path, content)),
Err(error) => Err(error)?,
};
Ok(())
}
}
const SEPARATOR: &str = "\n\u{c}\n";