draft initial http application

This commit is contained in:
yggverse 2026-01-07 23:25:02 +02:00
parent 4c99208535
commit 353c78b2f0
12 changed files with 406 additions and 0 deletions

50
crates/http/src/config.rs Normal file
View file

@ -0,0 +1,50 @@
use clap::Parser;
use std::net::{IpAddr, Ipv4Addr};
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
pub struct Config {
/// Server name
#[arg(long, default_value_t = String::from("rssto"))]
pub title: String,
/// Server description
#[arg(long)]
pub description: Option<String>,
/// 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 format_time: String,
/// Default listing limit
#[arg(long, default_value_t = 20)]
pub list_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, short, default_value_t = 8000)]
pub port: u16,
/// Configure instance in the debug mode
#[arg(long, default_value_t = false)]
pub debug: bool,
// Database
#[arg(long, default_value_t = String::from("localhost"))]
pub mysql_host: String,
#[arg(long, default_value_t = 3306)]
pub mysql_port: u16,
pub mysql_user: String,
pub mysql_password: String,
pub mysql_database: String,
}

58
crates/http/src/feed.rs Normal file
View file

@ -0,0 +1,58 @@
/// Export crawl index to the RSS file
pub struct Feed {
buffer: String,
}
impl Feed {
pub fn new(title: &str, description: Option<&str>, 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(&format!("<pubDate>{t}</pubDate>"));
buffer.push_str(&format!("<lastBuildDate>{t}</lastBuildDate>"));
buffer.push_str(&format!("<title>{}</title>", escape(title)));
if let Some(d) = description {
buffer.push_str(&format!("<description>{}</description>", escape(d)));
}
Self { buffer }
}
/// Append `item` to the feed `channel`
pub fn push(
&mut self,
guid: u64,
time: chrono::DateTime<chrono::Utc>,
url: String,
title: String,
description: String,
) {
self.buffer.push_str(&format!(
"<item><guid>{guid}</guid><title>{}</title><link>{url}</link><description>{}</description><pubDate>{}</pubDate></item>",
escape(&title),
escape(&description),
time.to_rfc2822()
))
}
/// Write final bytes
pub fn commit(mut self) -> String {
self.buffer.push_str("</channel></rss>");
self.buffer
}
}
// @TODO use tera filters?
// https://keats.github.io/tera/docs/#built-in-filters
fn escape(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace("'", "&apos;")
}

View file

@ -0,0 +1,8 @@
use rocket::serde::Serialize;
#[derive(Clone, Debug, Serialize)]
#[serde(crate = "rocket::serde")]
pub struct Global {
pub list_limit: usize,
pub format_time: String,
}

181
crates/http/src/main.rs Normal file
View file

@ -0,0 +1,181 @@
#[macro_use]
extern crate rocket;
mod config;
mod feed;
mod global;
mod meta;
use chrono::{DateTime, Utc};
use config::Config;
use feed::Feed;
use global::Global;
use meta::Meta;
use mysql::Mysql;
use rocket::{State, http::Status, response::content::RawXml, serde::Serialize};
use rocket_dyn_templates::{Template, context};
#[get("/?<search>&<page>")]
fn index(
search: Option<&str>,
page: Option<usize>,
db: &State<Mysql>,
meta: &State<Meta>,
global: &State<Global>,
) -> Result<Template, Status> {
#[derive(Serialize)]
#[serde(crate = "rocket::serde")]
struct Content {
content_id: u64,
description: String,
link: String,
time: String,
title: String,
}
let total = db.contents_total().map_err(|e| {
error!("Could not get contents total: `{e}`");
Status::InternalServerError
})?;
Ok(Template::render(
"index",
context! {
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(&meta.title);
if let Some(ref description) = meta.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
},
meta: meta.inner(),
back: page.map(|p| uri!(index(search, if p > 2 { Some(p - 1) } else { None }))),
next: if page.unwrap_or(1) * global.list_limit >= total { None }
else { Some(uri!(index(search, Some(page.map_or(2, |p| p + 1))))) },
rows: db.contents(Some(global.list_limit)).map_err(|e| {
error!("Could not get contents: `{e}`");
Status::InternalServerError
})?
.into_iter()
.map(|c| {
let channel_item = db.channel_item(c.channel_item_id).unwrap().unwrap();
Content {
content_id: c.content_id,
description: c.description,
link: channel_item.link,
time: time(channel_item.pub_date).format(&global.format_time).to_string(),
title: c.title,
}
})
.collect::<Vec<Content>>(),
page: page.unwrap_or(1),
pages: (total as f64 / global.list_limit as f64).ceil(),
total,
search
},
))
}
#[get("/<content_id>")]
fn info(
content_id: u64,
db: &State<Mysql>,
meta: &State<Meta>,
global: &State<Global>,
) -> Result<Template, Status> {
match db.content(content_id).map_err(|e| {
error!("Could not get content `{content_id}`: `{e}`");
Status::InternalServerError
})? {
Some(c) => {
let i = db.channel_item(c.channel_item_id).unwrap().unwrap();
Ok(Template::render(
"info",
context! {
title: format!("{}{S}{}", c.title, meta.title),
description: c.description,
link: i.link,
time: time(i.pub_date).format(&global.format_time).to_string(),
},
))
}
None => Err(Status::NotFound),
}
}
#[get("/rss")]
fn rss(meta: &State<Meta>, db: &State<Mysql>) -> Result<RawXml<String>, Status> {
let mut f = Feed::new(
&meta.title,
meta.description.as_deref(),
1024, // @TODO
);
for c in db
.contents(Some(20)) // @TODO
.map_err(|e| {
error!("Could not load channel item contents: `{e}`");
Status::InternalServerError
})?
{
let channel_item = db.channel_item(c.channel_item_id).unwrap().unwrap();
f.push(
c.channel_item_id,
time(channel_item.pub_date),
channel_item.link,
c.title,
c.description,
)
}
Ok(RawXml(f.commit()))
}
#[launch]
fn rocket() -> _ {
use clap::Parser;
let config = Config::parse();
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()
}
})
.manage(Mysql::connect(
&config.mysql_host,
config.mysql_port,
&config.mysql_user,
&config.mysql_password,
&config.mysql_database,
))
.manage(Global {
format_time: config.format_time,
list_limit: config.list_limit,
})
.manage(Meta {
description: config.description,
title: config.title,
version: env!("CARGO_PKG_VERSION").into(),
})
.mount("/", routes![index, rss, info])
}
const S: &str = "";
fn time(timestamp: i64) -> DateTime<Utc> {
DateTime::<Utc>::from_timestamp(timestamp, 0).unwrap()
}

9
crates/http/src/meta.rs Normal file
View file

@ -0,0 +1,9 @@
use rocket::serde::Serialize;
#[derive(Clone, Debug, Serialize)]
#[serde(crate = "rocket::serde")]
pub struct Meta {
pub description: Option<String>,
pub title: String,
pub version: String,
}