mirror of
https://github.com/YGGverse/mb.git
synced 2026-03-31 09:05:28 +00:00
initial commit
This commit is contained in:
parent
f2b0a1979d
commit
8e7df9ff72
14 changed files with 1011 additions and 0 deletions
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
custom: https://yggverse.github.io/#donate
|
||||
25
.github/workflows/build.yml
vendored
Normal file
25
.github/workflows/build.yml
vendored
Normal 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
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
/target
|
||||
Cargo.lock
|
||||
*.redb
|
||||
21
Cargo.toml
Normal file
21
Cargo.toml
Normal 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
54
README.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# mb
|
||||
|
||||

|
||||
[](https://deps.rs/repo/github/YGGverse/mb)
|
||||
[](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
263
public/theme/default.css
Normal 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
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,
|
||||
}
|
||||
33
templates/index.html.tera
Normal file
33
templates/index.html.tera
Normal 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 %}
|
||||
29
templates/layout.html.tera
Normal file
29
templates/layout.html.tera
Normal 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
17
templates/post.html.tera
Normal 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 %}
|
||||
Loading…
Add table
Add a link
Reference in a new issue