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