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"
|
||||
members = [
|
||||
"crates/crawler",
|
||||
"crates/http",
|
||||
"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