mirror of
https://github.com/YGGverse/rssto.git
synced 2026-03-31 17:15:29 +00:00
draft initial http application
This commit is contained in:
parent
4c99208535
commit
353c78b2f0
12 changed files with 406 additions and 0 deletions
|
|
@ -2,5 +2,6 @@
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
"crates/crawler",
|
"crates/crawler",
|
||||||
|
"crates/http",
|
||||||
"crates/mysql",
|
"crates/mysql",
|
||||||
]
|
]
|
||||||
17
crates/http/Cargo.toml
Normal file
17
crates/http/Cargo.toml
Normal 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
21
crates/http/LICENSE
Normal 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
3
crates/http/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# rssto-http
|
||||||
|
|
||||||
|
Web server implementation based on the Rocket engine
|
||||||
50
crates/http/src/config.rs
Normal file
50
crates/http/src/config.rs
Normal 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
58
crates/http/src/feed.rs
Normal 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('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace("'", "'")
|
||||||
|
}
|
||||||
8
crates/http/src/global.rs
Normal file
8
crates/http/src/global.rs
Normal 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
181
crates/http/src/main.rs
Normal 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
9
crates/http/src/meta.rs
Normal 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,
|
||||||
|
}
|
||||||
24
crates/http/templates/index.html.tera
Normal file
24
crates/http/templates/index.html.tera
Normal 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 %}
|
||||||
12
crates/http/templates/info.html.tera
Normal file
12
crates/http/templates/info.html.tera
Normal 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 %}
|
||||||
22
crates/http/templates/layout.html.tera
Normal file
22
crates/http/templates/layout.html.tera
Normal 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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue