initial commit

This commit is contained in:
yggverse 2025-08-20 02:58:40 +03:00
parent f2b0a1979d
commit 8e7df9ff72
14 changed files with 1011 additions and 0 deletions

1
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1 @@
custom: https://yggverse.github.io/#donate

25
.github/workflows/build.yml vendored Normal file
View file

@ -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

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
/target
Cargo.lock
*.redb

21
Cargo.toml Normal file
View file

@ -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"] }

54
README.md Normal file
View file

@ -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;
}
}
```

263
public/theme/default.css Normal file
View file

@ -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;
}

58
src/config.rs Normal file
View file

@ -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<String>,
/// Canonical URL
#[arg(long, short)]
pub url: Option<Url>,
/// 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,
}

132
src/db.rs Normal file
View file

@ -0,0 +1,132 @@
use anyhow::Result;
use chrono::{DateTime, Utc};
use redb::{Database, ReadableTable, TableDefinition};
const POST: TableDefinition<i64, String> = TableDefinition::new("post");
pub struct Post {
pub id: i64,
pub message: String,
}
impl Post {
pub fn time(&self) -> DateTime<Utc> {
DateTime::from_timestamp_micros(self.id).unwrap()
}
}
pub struct Posts {
pub posts: Vec<Post>,
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<Self> {
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<bool> {
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<Option<Post>> {
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<usize>,
limit: Option<usize>,
) -> Result<Posts> {
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<Vec<i64>> {
let mut posts: Vec<i64> = 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
];

88
src/feed.rs Normal file
View file

@ -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<Url>,
capacity: usize,
) -> Self {
let t = chrono::Utc::now().to_rfc2822();
let mut buffer = String::with_capacity(capacity);
buffer.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?><rss version=\"2.0\"><channel>");
buffer.push_str("<pubDate>");
buffer.push_str(&t);
buffer.push_str("</pubDate>");
buffer.push_str("<lastBuildDate>");
buffer.push_str(&t);
buffer.push_str("</lastBuildDate>");
buffer.push_str("<title>");
buffer.push_str(title);
buffer.push_str("</title>");
if let Some(d) = description {
buffer.push_str("<description>");
buffer.push_str(d);
buffer.push_str("</description>")
}
if let Some(ref c) = canonical {
// @TODO required the RSS specification!
buffer.push_str("<link>");
buffer.push_str(c.as_str());
buffer.push_str("</link>")
}
Self {
buffer,
canonical: Link::from_url(canonical),
}
}
/// Append `item` to the feed `channel`
pub fn push(&mut self, guid: i64, time: DateTime<Utc>, title: String, message: &str) {
self.buffer.push_str(&format!(
"<item><guid>{guid}</guid><title>{title}</title><link>{}</link>",
self.canonical.link(guid)
));
self.buffer.push_str("<description>");
self.buffer.push_str(&escape(message));
self.buffer.push_str("</description>");
self.buffer.push_str("<pubDate>");
self.buffer.push_str(&time.to_rfc2822());
self.buffer.push_str("</pubDate>");
self.buffer.push_str("</item>")
}
/// Write final bytes
pub fn commit(mut self) -> String {
self.buffer.push_str("</channel></rss>");
self.buffer
}
}
fn escape(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace("'", "&apos;")
}

23
src/feed/link.rs Normal file
View file

@ -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<Url>) -> 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)
}
}

264
src/main.rs Normal file
View file

@ -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("/?<search>&<page>&<token>")]
fn index(
search: Option<&str>,
page: Option<usize>,
token: Option<&str>,
db: &State<Db>,
config: &State<Config>,
) -> Result<Template, Status> {
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::<Vec<Post>>(),
home: uri!(index(None::<&str>, None::<usize>, token)),
version: env!("CARGO_PKG_VERSION"),
search,
token
},
))
}
#[derive(FromForm)]
struct Submit {
message: String,
token: String,
}
#[post("/submit", data = "<input>")]
fn submit(input: Form<Submit>, db: &State<Db>, config: &State<Config>) -> Result<Redirect, Status> {
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::<usize>,
Some(i.token),
))))
}
#[get("/delete?<id>&<token>")]
fn delete(
id: i64,
token: String,
db: &State<Db>,
config: &State<Config>,
) -> Result<Redirect, Status> {
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::<usize>,
Some(token),
))))
}
#[get("/<id>?<token>")]
fn post(
id: i64,
token: Option<&str>,
db: &State<Db>,
config: &State<Config>,
) -> Result<Template, Status> {
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::<usize>, token)),
version: env!("CARGO_PKG_VERSION"),
search: None::<&str>
},
))
}
None => Err(Status::NotFound),
}
}
#[get("/rss")]
fn rss(db: &State<Db>, config: &State<Config>) -> Result<RawXml<String>, 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<String>,
/// 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,
}

33
templates/index.html.tera Normal file
View file

@ -0,0 +1,33 @@
{% extends "layout" %}
{% block content %}
{% if token %}
<form action="/submit" method="post">
<textarea name="message" placeholder="Enter your message..."></textarea>
<input type="hidden" name="token" value="{{ token }}" />
<input type="submit" value="Submit" />
</form>
{% endif %}
{% if posts %}
{% for post in posts %}
<div>
<a name="{{ post.id }}"></a>
<p>{{ post.message }}</p>
<div>
<ul>
<li><a href="{{ post.href.post }}" title="Created">{{ post.time }}</a></li>
</ul>
{% if post.href.delete %}
<div>
<a rel="nofollow" href="{{ post.href.delete }}" title="Delete">Delete</a>
</div>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<div>Nothing.</div>
{% endif %}
{% if next %}<a href="{{ next }}">Next</a>{% endif %}
{% if back %}<a href="{{ back }}">Back</a>{% endif %}
{% if pagination_totals %}<span>{{ pagination_totals }}</span>{% endif %}
{% endblock content %}

View file

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>{{ meta_title }}</title>
{% if description %}
<meta name="description" content="{{ description }}" />
{% endif %}
<link rel="stylesheet" type="text/css" href="/theme/default.css?v={{ version }}" />
</head>
<body>
<header>
<a href="{{ home }}">{{ title }}</a>
{% if description %}<div>{{ description }}</div>{% endif %}
<form action="/" method="GET">
<input type="text" name="search" value="{% if search %}{{ search }}{% endif %}" placeholder="Search..." />
<input type="hidden" name="token" value="{{ token }}" />
<input type="submit" value="Search" />
</form>
</header>
<main>
{% block content %}{% endblock content %}
</main>
<footer>
<a href="/rss">RSS</a> |
<a href="https://github.com/yggverse/mb" title="v{{ version }}">GitHub</a>
</footer>
</body>
</html>

17
templates/post.html.tera Normal file
View file

@ -0,0 +1,17 @@
{% extends "layout" %}
{% block content %}
<div>
<a name="{{ post.id }}"></a>
<p>{{ post.message }}</p>
<div>
<ul>
<li><a href="{{ post.href.post }}" title="Created">{{ post.time }}</a></li>
</ul>
{% if post.href.delete %}
<div>
<a rel="nofollow" href="{{ post.href.delete }}" title="Delete">Delete</a>
</div>
{% endif %}
</div>
</div>
{% endblock content %}