mirror of
https://github.com/YGGverse/mb.git
synced 2026-04-01 01:25:29 +00:00
initial commit
This commit is contained in:
parent
f2b0a1979d
commit
8e7df9ff72
14 changed files with 1011 additions and 0 deletions
58
src/config.rs
Normal file
58
src/config.rs
Normal 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
132
src/db.rs
Normal 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
88
src/feed.rs
Normal 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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace("'", "'")
|
||||
}
|
||||
23
src/feed/link.rs
Normal file
23
src/feed/link.rs
Normal 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
264
src/main.rs
Normal 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,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue