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

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,
}