From 8e7df9ff72ed45f082a0fec0bf3fc9da9e17b042 Mon Sep 17 00:00:00 2001 From: yggverse Date: Wed, 20 Aug 2025 02:58:40 +0300 Subject: [PATCH] initial commit --- .github/FUNDING.yml | 1 + .github/workflows/build.yml | 25 ++++ .gitignore | 3 + Cargo.toml | 21 +++ README.md | 54 ++++++++ public/theme/default.css | 263 +++++++++++++++++++++++++++++++++++ src/config.rs | 58 ++++++++ src/db.rs | 132 ++++++++++++++++++ src/feed.rs | 88 ++++++++++++ src/feed/link.rs | 23 ++++ src/main.rs | 264 ++++++++++++++++++++++++++++++++++++ templates/index.html.tera | 33 +++++ templates/layout.html.tera | 29 ++++ templates/post.html.tera | 17 +++ 14 files changed, 1011 insertions(+) create mode 100644 .github/FUNDING.yml create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 public/theme/default.css create mode 100644 src/config.rs create mode 100644 src/db.rs create mode 100644 src/feed.rs create mode 100644 src/feed/link.rs create mode 100644 src/main.rs create mode 100644 templates/index.html.tera create mode 100644 templates/layout.html.tera create mode 100644 templates/post.html.tera diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..ada8a24 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: https://yggverse.github.io/#donate \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..b160a5f --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,25 @@ +name: Build + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -Dwarnings + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - run: rustup update + - run: cargo update + - run: cargo fmt --all -- --check + - run: cargo clippy --all-targets + - run: cargo build --verbose + - run: cargo test --verbose diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..21f9054 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +Cargo.lock +*.redb \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..6309c7e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "mb" +version = "0.1.0" +edition = "2024" +license = "MIT" +readme = "README.md" +description = "Simple, js-less micro-blogging platform written in Rust" +keywords = ["micro-blog", "redb", "rocket", "web", "js-less"] +categories = ["network-programming"] +repository = "https://github.com/yggverse/mb" +# homepage = "https://yggverse.github.io" + +[dependencies] +anyhow = "1.0" +chrono = { version = "0.4.41", features = ["serde"] } +clap = { version = "4.5", features = ["derive"] } +plurify = "0.2" +redb = "2.6" +rocket = "0.5" +rocket_dyn_templates = { version = "0.2", features = ["tera"] } +url = { version = "2.5", features = ["serde"] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..d237537 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# mb + +![Build](https://github.com/YGGverse/mb/actions/workflows/build.yml/badge.svg) +[![Dependencies](https://deps.rs/repo/github/YGGverse/mb/status.svg)](https://deps.rs/repo/github/YGGverse/mb) +[![crates.io](https://img.shields.io/crates/v/mb.svg)](https://crates.io/crates/mb) + +Simple, js-less micro-blogging platform written in Rust. + +It uses the [Rocket](https://rocket.rs) framework and the [redb](https://www.redb.org) database for serving messages. + +## Install + +1. `git clone https://github.com/YGGverse/mb.git && cd mb` +2. `cargo build --release` +3. `sudo install target/release/mb /usr/local/bin/mb` + +## Usage + +### systemd + +``` /etc/systemd/system/mb.service +[Unit] +After=network.target +Wants=network.target + +[Service] +Type=simple +User=mb +Group=mb +WorkingDirectory=/path/to/public-and-templates +ExecStart=/usr/local/bin/mb --token=strong_key +StandardOutput=file:///path/to/debug.log +StandardError=file:///path/to/error.log + +[Install] +WantedBy=multi-user.target +``` +* the `database` file will be created if it does not already exist at the given location +* the `token` value is the access key to create and delete your messages (the authentication feature has not yet been implemented) +* copy `templates` and `public` folders to `WorkingDirectory` destination (see [Rocket deployment](https://rocket.rs/guide/v0.5/deploying/#deploying) for details) + +### nginx + +``` default +server { + listen 80; + + location / { + proxy_pass http://127.0.0.1:8000; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` \ No newline at end of file diff --git a/public/theme/default.css b/public/theme/default.css new file mode 100644 index 0000000..e273fe4 --- /dev/null +++ b/public/theme/default.css @@ -0,0 +1,263 @@ +* { + border: 0; + margin: 0; + padding: 0; + box-sizing: border-box; + outline: none; +} +:focus, +:focus-within, +:focus-visible, +:active, +:target, +:hover { + opacity: 1; + transition: opacity .2s ease-in-out; +} + +:root { + --accent: #eea46d; + --background: #1e1e1e; + --default: #ccc; + --item: #262728; + --separator: #1e1e1e; +} + +body { + background: var(--background); + color: var(--default); + font-family: Sans-serif; + font-size: 14px; +} + +a, +a:visited, +a:active { + color: var(--accent); + text-decoration: none; + opacity: .9; +} + +h1, h2, h3, h4, h5 { + display: inline-block; + font-weight: normal; +} + +h1, h2 { + font-size: 14px; + padding: 0 8px; + word-break: break-word; +} + +body > * { + position: relative; + overflow: hidden; + max-width: 580px; +} + +header { + margin: 16px auto; + text-align: center; +} + +header > a, +header > a:active, +header > a:hover, +header > a:visited { + color: var(--default); + font-size: 20px; +} + +header::first-letter { + color: var(--accent); +} + +header > div { + font-size: small; + margin-top: 12px; +} + +header > div > code:not(:last-child)::after { + content: "|"; + margin: 0 6px; +} + +header > form { + margin-top: 20px; +} + +header > form > input { + background: var(--item); + border-color: var(--item); + border-radius: 3px; + border-style: solid; + border-width: 1px; + color: var(--default); + opacity: 0.9; +} + +form > input:hover { + opacity: 1; +} + +header > form > input[type="text"] { + padding: 8px; +} + +header > form > input[type="text"]:focus, form textarea:focus { + border-color: var(--item); +} + +header > form > input[type="submit"] { + cursor: pointer; + padding: 8px 16px; +} + +main { + margin: 0 auto; +} + +main > form { + border-bottom: 1px solid var(--item); + border-top: 1px solid var(--item); + overflow: hidden; + padding-bottom: 8px; +} + +main > form > textarea { + background: var(--item); + border-color: var(--item); + border-radius: 3px; + border-style: solid; + border-width: 1px; + color: var(--default); + font-size: 14px; + margin: 8px 0; + opacity: 0.9; + padding: 16px; + width: 100%; +} + +main > form > input { + background: var(--item); + border-radius: 3px; + border-width: 1px; + color: var(--accent); + cursor: pointer; + float: right; + font-weight: bold; + opacity: 0.9; + padding: 9px 16px; +} + +form > input:hover { + opacity: 1; +} + +/* pagination */ +main > a { + background: var(--item); + border-radius: 3px; + float: right; + font-size: small; + font-weight: bold; + margin-left: 8px; + opacity: .9; + padding: 8px 16px; +} + +/* item row */ +main > div { + background-color: var(--item); + border-radius: 3px; + margin: 8px 0; + padding: 16px; +} + +/* item row meta, controls */ +main > div > div { + border-top: 1px solid var(--separator); + font-size: small; + margin-top: 16px; + overflow: hidden; + padding-top: 16px; +} + +main > div > div > ul { + list-style: none; +} + +main > div > div > ul > li { + cursor: default; + float: left; +} + +main > div > div > ul > li > span { + color: white; + display: inline-block; + font-size: smaller; + opacity: 0.7; +} + +main > div > div > ul > li > span:hover { + opacity: 1; +} + +main > div > div > ul > li:not(:last-child)::after { + content: "•"; + margin: 0 6px; +} + +main > div > div > ul > li > span.leechers { + background-image: url('default/leechers.svg'); + background-position: left center; + background-repeat: no-repeat; + padding-left: 16px; +} + +main > div > div > ul > li > span.peers { + background-image: url('default/peers.svg'); + background-position: left center; + background-repeat: no-repeat; + padding-left: 16px; +} + +main > div > div > ul > li > span.seeders { + background-image: url('default/seeders.svg'); + background-position: left center; + background-repeat: no-repeat; + padding-left: 16px; +} + +/* control actions */ +main > div > div > div { + float: right; + opacity: 0; +} + +main > div:hover > div > div { + opacity: 1; +} + +main > div > div > div > a { + background-position: center; + background-repeat: no-repeat; + display: inline-block; +} + +main > div > div > div > a.magnet { + background-image: url('default/magnet.svg'); +} + +/* pagination info */ +main > span { + float: right; + font-size: small; + padding: 8px 4px; +} + +footer { + font-size: small; + margin: 16px auto 36px; + text-align: center; +} \ No newline at end of file diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..e066484 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,58 @@ +use clap::Parser; +use std::net::{IpAddr, Ipv4Addr}; +use url::Url; + +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +pub struct Config { + /// Access token to create and remove posts + #[arg(long, short)] + pub token: String, + + /// Path to the [redb](https://www.redb.org) + /// * if the given path does not exist, a new database will be created + #[arg(long, default_value_t = String::from("./mb.redb"))] + pub database: String, + + /// Path to the public directory (which contains the CSS theme and other multimedia files) + #[arg(long, default_value_t = String::from("./public"))] + pub public: String, + + /// Server name + #[arg(long, default_value_t = String::from("mb"))] + pub title: String, + + /// Server description + #[arg(long)] + pub description: Option, + + /// Canonical URL + #[arg(long, short)] + pub url: Option, + + /// Format timestamps (on the web view) + /// + /// * tip: escape with `%%d/%%m/%%Y %%H:%%M` in the CLI/bash argument + #[arg(long, default_value_t = String::from("%d/%m/%Y %H:%M"))] + pub time_format: String, + + /// Default listing limit + #[arg(long, short, default_value_t = 20)] + pub limit: usize, + + /// Default capacity (estimated torrents in the `public` directory) + #[arg(long, default_value_t = 1000)] + pub capacity: usize, + + /// Bind server on given host + #[arg(long, default_value_t = IpAddr::V4(Ipv4Addr::LOCALHOST))] + pub host: IpAddr, + + /// Bind server on given port + #[arg(long, default_value_t = 8000)] + pub port: u16, + + /// Configure instance in the debug mode + #[arg(long, default_value_t = false)] + pub debug: bool, +} diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..c95d8bd --- /dev/null +++ b/src/db.rs @@ -0,0 +1,132 @@ +use anyhow::Result; +use chrono::{DateTime, Utc}; +use redb::{Database, ReadableTable, TableDefinition}; + +const POST: TableDefinition = TableDefinition::new("post"); + +pub struct Post { + pub id: i64, + pub message: String, +} + +impl Post { + pub fn time(&self) -> DateTime { + DateTime::from_timestamp_micros(self.id).unwrap() + } +} + +pub struct Posts { + pub posts: Vec, + pub total: usize, +} + +#[derive(Clone, Debug, Default)] +pub enum Sort { + #[default] + Time, +} + +#[derive(Clone, Debug, Default)] +pub enum Order { + // Asc, + #[default] + Desc, +} + +pub struct Db(Database); + +impl Db { + pub fn init(path: &str) -> Result { + let db = Database::create(path)?; + let tx = db.begin_write()?; + { + tx.open_table(POST)?; // init table + } + tx.commit()?; + Ok(Self(db)) + } + + pub fn delete(&self, id: i64) -> Result { + let tx = self.0.begin_write()?; + let is_deleted = { + let mut t = tx.open_table(POST)?; + t.remove(id)?.is_some() + }; + tx.commit()?; + Ok(is_deleted) + } + + pub fn submit(&self, message: String) -> Result<()> { + let tx = self.0.begin_write()?; + { + let mut t = tx.open_table(POST)?; + t.insert(Utc::now().timestamp_micros(), message)?; + } + Ok(tx.commit()?) + } + + pub fn post(&self, id: i64) -> Result> { + Ok(self + .0 + .begin_read()? + .open_table(POST)? + .get(id)? + .map(|p| Post { + id, + message: p.value(), + })) + } + + pub fn posts( + &self, + keyword: Option<&str>, + sort_order: Option<(Sort, Order)>, + start: Option, + limit: Option, + ) -> Result { + let keys = self._posts(keyword, sort_order)?; + let total = keys.len(); + let l = limit.unwrap_or(total); + + let mut posts = Vec::with_capacity(total); + for id in keys.into_iter().skip(start.unwrap_or_default()).take(l) { + posts.push(self.post(id)?.unwrap()) + } + + Ok(Posts { total, posts }) + } + + fn _posts(&self, keyword: Option<&str>, sort_order: Option<(Sort, Order)>) -> Result> { + let mut posts: Vec = self + .0 + .begin_read()? + .open_table(POST)? + .iter()? + .filter_map(|p| { + let p = p.ok()?; + if let Some(k) = keyword + && !k.trim_matches(S).is_empty() + && !p.1.value().to_lowercase().contains(&k.to_lowercase()) + { + None + } else { + Some(p.0.value()) + } + }) + .collect(); + if let Some((sort, order)) = sort_order { + match sort { + Sort::Time => match order { + //Order::Asc => posts.sort_by(|a, b| a.cmp(b)), + Order::Desc => posts.sort_by(|a, b| b.cmp(a)), + }, + } + } + Ok(posts) + } +} + +/// Search keyword separators +const S: &[char] = &[ + '_', '-', ':', ';', ',', '(', ')', '[', ']', '/', '!', '?', ' ', // @TODO make optional +]; diff --git a/src/feed.rs b/src/feed.rs new file mode 100644 index 0000000..aecb4c9 --- /dev/null +++ b/src/feed.rs @@ -0,0 +1,88 @@ +mod link; + +use chrono::{DateTime, Utc}; +use link::Link; +use url::Url; + +/// Export crawl index to the RSS file +pub struct Feed { + buffer: String, + canonical: Link, +} + +impl Feed { + pub fn new( + title: &str, + description: Option<&str>, + canonical: Option, + capacity: usize, + ) -> Self { + let t = chrono::Utc::now().to_rfc2822(); + let mut buffer = String::with_capacity(capacity); + + buffer.push_str(""); + + buffer.push_str(""); + buffer.push_str(&t); + buffer.push_str(""); + + buffer.push_str(""); + buffer.push_str(&t); + buffer.push_str(""); + + buffer.push_str(""); + buffer.push_str(title); + buffer.push_str(""); + + if let Some(d) = description { + buffer.push_str(""); + buffer.push_str(d); + buffer.push_str("") + } + + if let Some(ref c) = canonical { + // @TODO required the RSS specification! + buffer.push_str(""); + buffer.push_str(c.as_str()); + buffer.push_str("") + } + + Self { + buffer, + canonical: Link::from_url(canonical), + } + } + + /// Append `item` to the feed `channel` + pub fn push(&mut self, guid: i64, time: DateTime, title: String, message: &str) { + self.buffer.push_str(&format!( + "{guid}{title}{}", + self.canonical.link(guid) + )); + + self.buffer.push_str(""); + self.buffer.push_str(&escape(message)); + self.buffer.push_str(""); + + self.buffer.push_str(""); + self.buffer.push_str(&time.to_rfc2822()); + self.buffer.push_str(""); + + self.buffer.push_str("") + } + + /// Write final bytes + pub fn commit(mut self) -> String { + self.buffer.push_str(""); + self.buffer + } +} + +fn escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace("'", "'") +} diff --git a/src/feed/link.rs b/src/feed/link.rs new file mode 100644 index 0000000..8a66338 --- /dev/null +++ b/src/feed/link.rs @@ -0,0 +1,23 @@ +use url::Url; + +/// Valid link prefix donor for the RSS channel item +pub struct Link(String); + +impl Link { + pub fn from_url(canonical: Option) -> Self { + Self( + canonical + .map(|mut c| { + c.set_path("/"); + c.set_fragment(None); + c.set_query(None); + super::escape(c.as_str()) // filter once + }) + .unwrap_or_default(), // should be non-optional absolute URL + // by the RSS specification @TODO + ) + } + pub fn link(&self, id: i64) -> String { + format!("{}{id}", self.0) + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..4d5282d --- /dev/null +++ b/src/main.rs @@ -0,0 +1,264 @@ +#[macro_use] +extern crate rocket; + +mod config; +mod db; +mod feed; + +use config::Config; +use db::{Db, Order, Sort}; +use feed::Feed; +use plurify::Plurify; +use rocket::{ + State, + form::Form, + http::Status, + response::{Redirect, content::RawXml}, + serde::Serialize, +}; +use rocket_dyn_templates::{Template, context}; + +#[get("/?&&")] +fn index( + search: Option<&str>, + page: Option, + token: Option<&str>, + db: &State, + config: &State, +) -> Result { + if token.is_some_and(|t| t != config.token) { + warn!("Invalid access token! Access denied."); + return Err(Status::Forbidden); + } + let posts = db + .posts( + search, + Some((Sort::Time, Order::Desc)), + page.map(|p| if p > 0 { p - 1 } else { p } * config.limit), + Some(config.limit), + ) + .map_err(|e| { + error!("DB read error: `{e}`"); + Status::InternalServerError + })?; + + Ok(Template::render( + "index", + context! { + meta_title: { + let mut t = String::new(); + if let Some(q) = search && !q.is_empty() { + t.push_str(q); + t.push_str(S); + t.push_str("Search"); + t.push_str(S) + } + if let Some(p) = page && p > 1 { + t.push_str(&format!("Page {p}")); + t.push_str(S) + } + t.push_str(&config.title); + if let Some(ref description) = config.description + && page.is_none_or(|p| p == 1) && search.is_none_or(|q| q.is_empty()) { + t.push_str(S); + t.push_str(description) + } + t + }, + title: &config.title, + description: config.description.as_deref(), + back: page.map(|p| uri!(index(search, if p > 2 { Some(p - 1) } else { None }, token))), + next: if page.unwrap_or(1) * config.limit >= posts.total { None } + else { Some(uri!(index(search, Some(page.map_or(2, |p| p + 1)), token))) }, + pagination_totals: if posts.total > 0 { Some(format!( + "Page {} / {} ({} {} total)", + page.unwrap_or(1), + (posts.total as f64 / config.limit as f64).ceil(), + posts.total, + posts.total.plurify(&["post", "posts", "posts"]) + )) } else { None }, + posts: posts.posts.into_iter().map(|p| Post { + id: p.id, + time: p.time().format(&config.time_format).to_string(), + message: p.message, + href: Href { + delete: token.map(|t| uri!(delete(p.id, t)).to_string()), + post: uri!(post(p.id, token)).to_string() + } + }).collect::>(), + home: uri!(index(None::<&str>, None::, token)), + version: env!("CARGO_PKG_VERSION"), + search, + token + }, + )) +} + +#[derive(FromForm)] +struct Submit { + message: String, + token: String, +} +#[post("/submit", data = "")] +fn submit(input: Form, db: &State, config: &State) -> Result { + if input.token != config.token { + warn!("Invalid access token! Access denied."); + return Err(Status::Forbidden); + } + let i = input.into_inner(); + db.submit(i.message).map_err(|e| { + error!("DB write error: `{e}`"); + Status::InternalServerError + })?; + Ok(Redirect::to(uri!(index( + None::<&str>, + None::, + Some(i.token), + )))) +} + +#[get("/delete?&")] +fn delete( + id: i64, + token: String, + db: &State, + config: &State, +) -> Result { + if token != config.token { + warn!("Invalid access token! Access denied."); + return Err(Status::Forbidden); + } + db.delete(id).map_err(|e| { + error!("DB write error: `{e}`"); + Status::InternalServerError + })?; + Ok(Redirect::to(uri!(index( + None::<&str>, + None::, + Some(token), + )))) +} + +#[get("/?")] +fn post( + id: i64, + token: Option<&str>, + db: &State, + config: &State, +) -> Result { + if token.is_some_and(|t| t != config.token) { + warn!("Invalid access token! Access denied."); + return Err(Status::Forbidden); + } + match db.post(id).map_err(|e| { + error!("DB read error: `{e}`"); + Status::InternalServerError + })? { + Some(post) => { + let time = post.time().format(&config.time_format).to_string(); + Ok(Template::render( + "post", + context! { + meta_title: format!("{time}{S}{}", &config.title), + title: &config.title, + description: config.description.as_deref(), + back: None::<&str>, + next: None::<&str>, + pagination_totals: None::<&str>, + post: Post { + id: post.id, + message: post.message, + href: Href { + delete: token.map(|t| uri!(delete(post.id, t)).to_string()), + post: uri!(post(post.id, token)).to_string() + }, + time + }, + home: uri!(index(None::<&str>, None::, token)), + version: env!("CARGO_PKG_VERSION"), + search: None::<&str> + }, + )) + } + None => Err(Status::NotFound), + } +} + +#[get("/rss")] +fn rss(db: &State, config: &State) -> Result, Status> { + let mut f = Feed::new( + &config.title, + config.description.as_deref(), + config.url.clone(), + 1024, // @TODO + ); + for p in db + .posts( + None, + Some((Sort::Time, Order::Desc)), + None, + Some(config.limit), + ) + .map_err(|e| { + error!("DB read error: `{e}`"); + Status::InternalServerError + })? + .posts + { + let time = p.time(); + f.push( + p.id, + time, + time.format(&config.time_format).to_string(), + &p.message, + ) + } + Ok(RawXml(f.commit())) +} + +#[launch] +fn rocket() -> _ { + use clap::Parser; + let config = config::Config::parse(); + if config.url.is_none() { + warn!("Canonical URL option is required for the RSS feed by the specification!") // @TODO + } + rocket::build() + .attach(Template::fairing()) + .configure(rocket::Config { + port: config.port, + address: config.host, + ..if config.debug { + rocket::Config::debug_default() + } else { + rocket::Config::release_default() + } + }) + .mount("/", rocket::fs::FileServer::from(&config.public)) + .mount("/", routes![index, post, submit, delete, rss]) + .manage(Db::init(&config.database).unwrap()) + .manage(config) +} + +/// Meta title separator +const S: &str = " • "; + +#[derive(Serialize)] +#[serde(crate = "rocket::serde")] +struct Href { + /// Reference to post delete action + /// * optional as dependent of access permissions + delete: Option, + /// Reference to post details page + post: String, +} +#[derive(Serialize)] +#[serde(crate = "rocket::serde")] +struct Post { + id: i64, + message: String, + /// Time created + /// * edit time should be implemented as the separated history table + time: String, + href: Href, +} diff --git a/templates/index.html.tera b/templates/index.html.tera new file mode 100644 index 0000000..e6d9ee2 --- /dev/null +++ b/templates/index.html.tera @@ -0,0 +1,33 @@ +{% extends "layout" %} +{% block content %} + {% if token %} +
+ + + +
+ {% endif %} + {% if posts %} + {% for post in posts %} +
+ +

{{ post.message }}

+
+ + {% if post.href.delete %} +
+ Delete +
+ {% endif %} +
+
+ {% endfor %} + {% else %} +
Nothing.
+ {% endif %} + {% if next %}Next{% endif %} + {% if back %}Back{% endif %} + {% if pagination_totals %}{{ pagination_totals }}{% endif %} +{% endblock content %} \ No newline at end of file diff --git a/templates/layout.html.tera b/templates/layout.html.tera new file mode 100644 index 0000000..79509bd --- /dev/null +++ b/templates/layout.html.tera @@ -0,0 +1,29 @@ + + + + + {{ meta_title }} + {% if description %} + + {% endif %} + + + +
+ {{ title }} + {% if description %}
{{ description }}
{% endif %} +
+ + + +
+
+
+ {% block content %}{% endblock content %} +
+ + + \ No newline at end of file diff --git a/templates/post.html.tera b/templates/post.html.tera new file mode 100644 index 0000000..ae1145d --- /dev/null +++ b/templates/post.html.tera @@ -0,0 +1,17 @@ +{% extends "layout" %} +{% block content %} +
+ +

{{ post.message }}

+
+ + {% if post.href.delete %} +
+ Delete +
+ {% endif %} +
+
+{% endblock content %} \ No newline at end of file