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

17
crates/http/Cargo.toml Normal file
View file

@ -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"] }

21
crates/http/LICENSE Normal file
View file

@ -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.

3
crates/http/README.md Normal file
View file

@ -0,0 +1,3 @@
# rssto-http
Web server implementation based on the Rocket engine

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,
}

View file

@ -0,0 +1,24 @@
{% extends "layout" %}
{% block content %}
{% if rows %}
{% for row in rows %}
<div>
<a name="{{ row.content_id }}"></a>
<h2><a href="/{{ row.link }}">{{ row.title }}</a></h2>
{% if row.time %}<p>{{ row.time }}</p>{% endif %}
<div>
{{ row.description }}
</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 total %}
<span>
Page {{ page }} / {{ pages }} ({{ total }} torrent{{ total | pluralize(plural="s") }} total)
</span>
{% endif %}
{% endblock content %}

View file

@ -0,0 +1,12 @@
{% extends "layout" %}
{% block content %}
<div>
<h1>{{ title }}</h1>
<div>
{{ description }}
</div>
<div>
<a href="{{ link }}">{{ time }}</a>
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>{{ title }}</title>
{% if meta.description %}
<meta name="description" content="{{ meta.description }}" />
{% endif %}
</head>
<body>
<header>
<a href="/">{{ meta.title }}</a>
<form action="/" method="GET">
<input type="text" name="search" value="{% if search %}{{ search }}{% endif %}" placeholder="Keyword..." />
<input type="submit" value="Search" />
</form>
</header>
<main>
{% block content %}{% endblock content %}
</main>
</body>
</html>