From 353c78b2f029bab852234eb46a968dcc6b31023a Mon Sep 17 00:00:00 2001 From: yggverse Date: Wed, 7 Jan 2026 23:25:02 +0200 Subject: [PATCH] draft initial http application --- Cargo.toml | 1 + crates/http/Cargo.toml | 17 +++ crates/http/LICENSE | 21 +++ crates/http/README.md | 3 + crates/http/src/config.rs | 50 +++++++ crates/http/src/feed.rs | 58 ++++++++ crates/http/src/global.rs | 8 ++ crates/http/src/main.rs | 181 +++++++++++++++++++++++++ crates/http/src/meta.rs | 9 ++ crates/http/templates/index.html.tera | 24 ++++ crates/http/templates/info.html.tera | 12 ++ crates/http/templates/layout.html.tera | 22 +++ 12 files changed, 406 insertions(+) create mode 100644 crates/http/Cargo.toml create mode 100644 crates/http/LICENSE create mode 100644 crates/http/README.md create mode 100644 crates/http/src/config.rs create mode 100644 crates/http/src/feed.rs create mode 100644 crates/http/src/global.rs create mode 100644 crates/http/src/main.rs create mode 100644 crates/http/src/meta.rs create mode 100644 crates/http/templates/index.html.tera create mode 100644 crates/http/templates/info.html.tera create mode 100644 crates/http/templates/layout.html.tera diff --git a/Cargo.toml b/Cargo.toml index 22e1445..d5a938d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,5 +2,6 @@ resolver = "2" members = [ "crates/crawler", + "crates/http", "crates/mysql", ] \ No newline at end of file diff --git a/crates/http/Cargo.toml b/crates/http/Cargo.toml new file mode 100644 index 0000000..e2cfbf8 --- /dev/null +++ b/crates/http/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "rssto-http" +version = "0.1.0" +edition = "2024" +license = "MIT" +readme = "README.md" +description = "Web server for the rssto DB, based on Rocket engine" +keywords = ["rss", "aggregator", "http", "server"] +categories = ["command-line-utilities", "parsing", "text-processing", "value-formatting"] +repository = "https://github.com/YGGverse/rssto" + +[dependencies] +chrono = { version = "0.4.41", features = ["serde"] } +clap = { version = "4.5.54", features = ["derive"] } +mysql = { package = "rssto-mysql", version = "0.1.0", path = "../mysql" } +rocket = "0.5.1" +rocket_dyn_templates = { version = "0.2.0", features = ["tera"] } diff --git a/crates/http/LICENSE b/crates/http/LICENSE new file mode 100644 index 0000000..a9c0006 --- /dev/null +++ b/crates/http/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 YGGverse + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/http/README.md b/crates/http/README.md new file mode 100644 index 0000000..b92db47 --- /dev/null +++ b/crates/http/README.md @@ -0,0 +1,3 @@ +# rssto-http + +Web server implementation based on the Rocket engine diff --git a/crates/http/src/config.rs b/crates/http/src/config.rs new file mode 100644 index 0000000..7b53c13 --- /dev/null +++ b/crates/http/src/config.rs @@ -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, + + /// 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, +} diff --git a/crates/http/src/feed.rs b/crates/http/src/feed.rs new file mode 100644 index 0000000..73d582e --- /dev/null +++ b/crates/http/src/feed.rs @@ -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(""); + + buffer.push_str(&format!("{t}")); + buffer.push_str(&format!("{t}")); + buffer.push_str(&format!("{}", escape(title))); + + if let Some(d) = description { + buffer.push_str(&format!("{}", escape(d))); + } + + Self { buffer } + } + + /// Append `item` to the feed `channel` + pub fn push( + &mut self, + guid: u64, + time: chrono::DateTime, + url: String, + title: String, + description: String, + ) { + self.buffer.push_str(&format!( + "{guid}{}{url}{}{}", + escape(&title), + escape(&description), + time.to_rfc2822() + )) + } + + /// Write final bytes + pub fn commit(mut self) -> String { + self.buffer.push_str(""); + self.buffer + } +} + +// @TODO use tera filters? +// https://keats.github.io/tera/docs/#built-in-filters + +fn escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace("'", "'") +} diff --git a/crates/http/src/global.rs b/crates/http/src/global.rs new file mode 100644 index 0000000..933e67e --- /dev/null +++ b/crates/http/src/global.rs @@ -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, +} diff --git a/crates/http/src/main.rs b/crates/http/src/main.rs new file mode 100644 index 0000000..43f8db0 --- /dev/null +++ b/crates/http/src/main.rs @@ -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("/?&")] +fn index( + search: Option<&str>, + page: Option, + db: &State, + meta: &State, + global: &State, +) -> Result { + #[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::>(), + page: page.unwrap_or(1), + pages: (total as f64 / global.list_limit as f64).ceil(), + total, + search + }, + )) +} + +#[get("/")] +fn info( + content_id: u64, + db: &State, + meta: &State, + global: &State, +) -> Result { + 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, db: &State) -> Result, 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 { + DateTime::::from_timestamp(timestamp, 0).unwrap() +} diff --git a/crates/http/src/meta.rs b/crates/http/src/meta.rs new file mode 100644 index 0000000..c8512d0 --- /dev/null +++ b/crates/http/src/meta.rs @@ -0,0 +1,9 @@ +use rocket::serde::Serialize; + +#[derive(Clone, Debug, Serialize)] +#[serde(crate = "rocket::serde")] +pub struct Meta { + pub description: Option, + pub title: String, + pub version: String, +} diff --git a/crates/http/templates/index.html.tera b/crates/http/templates/index.html.tera new file mode 100644 index 0000000..9c6c87f --- /dev/null +++ b/crates/http/templates/index.html.tera @@ -0,0 +1,24 @@ +{% extends "layout" %} +{% block content %} + {% if rows %} + {% for row in rows %} +
+ +

{{ row.title }}

+ {% if row.time %}

{{ row.time }}

{% endif %} +
+ {{ row.description }} +
+
+ {% endfor %} + {% else %} +
Nothing.
+ {% endif %} + {% if next %}Next{% endif %} + {% if back %}Back{% endif %} + {% if total %} + + Page {{ page }} / {{ pages }} ({{ total }} torrent{{ total | pluralize(plural="s") }} total) + + {% endif %} +{% endblock content %} \ No newline at end of file diff --git a/crates/http/templates/info.html.tera b/crates/http/templates/info.html.tera new file mode 100644 index 0000000..a2516d9 --- /dev/null +++ b/crates/http/templates/info.html.tera @@ -0,0 +1,12 @@ +{% extends "layout" %} +{% block content %} +
+

{{ title }}

+
+ {{ description }} +
+ +
+{% endblock content %} \ No newline at end of file diff --git a/crates/http/templates/layout.html.tera b/crates/http/templates/layout.html.tera new file mode 100644 index 0000000..7049747 --- /dev/null +++ b/crates/http/templates/layout.html.tera @@ -0,0 +1,22 @@ + + + + + {{ title }} + {% if meta.description %} + + {% endif %} + + +
+ {{ meta.title }} +
+ + +
+
+
+ {% block content %}{% endblock content %} +
+ + \ No newline at end of file