diff --git a/Cargo.toml b/Cargo.toml index 42a2bbe..c830ee8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ repository = "https://github.com/YGGverse/btracker-gemini" [dependencies] anyhow = "1.0.95" btracker-fs = { version = "0.2.0", features = ["public"] } +btracker-scrape = "0.1.0" chrono = "^0.4.20" clap = { version = "4.5.30", features = ["derive"] } log = "0.4.28" @@ -27,4 +28,5 @@ regex = "1.11.2" # development [patch.crates-io] btracker-fs = { git = "https://github.com/YGGverse/btracker-fs.git" } +btracker-scrape = { git = "https://github.com/YGGverse/btracker-scrape.git" } # btracker-fs = { path = "../btracker-fs" } diff --git a/src/config.rs b/src/config.rs index ed90fd6..d701e03 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,6 @@ use clap::Parser; use std::{ - net::{Ipv4Addr, SocketAddr, SocketAddrV4}, + net::{Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6}, path::PathBuf, }; use url::Url; @@ -47,4 +47,13 @@ pub struct Config { /// Default index capacity #[arg(short, long, default_value_t = 1000)] pub capacity: usize, + + /// Bind scrape UDP server + /// + /// * requires `tracker` value(s) to enable scrape features + #[arg(long, default_values_t = vec![ + SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0)), + SocketAddr::V6(SocketAddrV6::new(Ipv6Addr::UNSPECIFIED, 0, 0, 0)) + ])] + pub udp: Vec, } diff --git a/src/main.rs b/src/main.rs index a693568..bfcfe6a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod route; use anyhow::Result; use btracker_fs::public::{Order, Public, Sort, Torrent}; +use btracker_scrape::*; use config::Config; use librqbit_core::torrent_metainfo::{TorrentMetaV1Owned, torrent_from_bytes}; use log::*; @@ -11,7 +12,7 @@ use native_tls::{HandshakeError, Identity, TlsAcceptor, TlsStream}; use std::{ fs::File, io::{Read, Write}, - net::{SocketAddr, TcpListener, TcpStream}, + net::{IpAddr, SocketAddr, TcpListener, TcpStream}, path::PathBuf, sync::Arc, thread, @@ -35,8 +36,43 @@ fn main() -> Result<()> { .init() } - let config = Arc::new(Config::parse()); - let public = Arc::new(Public::init(&config.storage, config.limit, config.capacity).unwrap()); + let config = Config::parse(); + let state = Arc::new(State { + public: Public::init(&config.storage, config.limit, config.capacity).unwrap(), + scrape: Scrape::init( + config + .tracker + .as_ref() + .map(|u| { + u.iter() + .map(|url| { + use std::str::FromStr; + if url.scheme() == "tcp" { + todo!("TCP scrape is not implemented") + } + if url.scheme() != "udp" { + todo!("Scheme `{}` is not supported", url.scheme()) + } + SocketAddr::new( + IpAddr::from_str( + url.host_str() + .expect("Required valid host value") + .trim_start_matches('[') + .trim_end_matches(']'), + ) + .unwrap(), + url.port().expect("Required valid port value"), + ) + }) + .collect() + }) + .map(|a| (config.udp, a)), + ), + format_date: config.format_date, + name: config.name, + description: config.description, + tracker: config.tracker, + }); // https://geminiprotocol.net/docs/protocol-specification.gmi#the-use-of-tls let acceptor = TlsAcceptor::new(Identity::from_pkcs12( @@ -48,7 +84,7 @@ fn main() -> Result<()> { &config.password, )?)?; - let listener = TcpListener::bind(&config.bind)?; + let listener = TcpListener::bind(config.bind)?; info!("Server started on `{}`", config.bind); @@ -56,11 +92,10 @@ fn main() -> Result<()> { match stream { Ok(stream) => { thread::spawn({ - let config = config.clone(); - let public = public.clone(); + let state = state.clone(); let peer = stream.peer_addr()?; let connection = acceptor.accept(stream); - move || handle(config, public, peer, connection) + move || handle(state, peer, connection) }); } Err(e) => error!("{e}"), @@ -70,8 +105,7 @@ fn main() -> Result<()> { } fn handle( - config: Arc, - public: Arc, + state: Arc, peer: SocketAddr, connection: Result, HandshakeError>, ) { @@ -110,7 +144,7 @@ fn handle( if header_buffer.last().is_some_and(|&b| b == b'\n') { // header bytes contain valid Gemini **request** if let Ok(request) = request::Gemini::from_bytes(&header_buffer) { - return response(request, &config, &public, &peer, &mut stream); + return response(request, &state, &peer, &mut stream); } // header bytes received but yet could not be parsed, @@ -148,8 +182,7 @@ fn handle( fn response( request: titanite::request::Gemini, - config: &Config, - public: &Public, + state: &State, peer: &SocketAddr, stream: &mut TlsStream, ) { @@ -157,7 +190,7 @@ fn response( use titanite::response::*; debug!("Incoming request from `{peer}` to `{}`", request.url.path()); send( - &match Route::from_url(&request.url, public) { + &match Route::from_url(&request.url, &state.public) { Route::File(ref path) => success::Default { data: &std::fs::read(path).unwrap(), meta: success::default::Meta { @@ -174,6 +207,8 @@ fn response( "image/webp" } else if e == "txt" || e == "log" { "text/plain" + } else if e == "gemini" || e == "gmi" { + "text/gemini" } else { todo!() } @@ -184,7 +219,7 @@ fn response( }, } .into_bytes(), - Route::List { page, keyword } => match list(config, public, keyword.as_deref(), page) { + Route::List { page, keyword } => match list(state, keyword.as_deref(), page) { Ok(data) => success::Default { data: data.as_bytes(), meta: success::default::Meta { @@ -204,8 +239,8 @@ fn response( message: Some("Keyword, file, hash...".into()), }) .into_bytes(), - Route::Info(id) => match public.torrent(id) { - Some(torrent) => match info(config, public, torrent) { + Route::Info(id) => match state.public.torrent(id) { + Some(torrent) => match info(state, torrent) { Ok(data) => success::Default { data: data.as_bytes(), meta: success::default::Meta { @@ -267,12 +302,7 @@ fn send(data: &[u8], stream: &mut TlsStream, callback: impl FnOnce(Re })()); } -fn list( - config: &Config, - public: &Public, - keyword: Option<&str>, - page: Option, -) -> Result { +fn list(state: &State, keyword: Option<&str>, page: Option) -> Result { use plurify::Plurify; /// format search keyword as the pagination query @@ -280,22 +310,22 @@ fn list( keyword.map(|k| format!("?{}", k)).unwrap_or_default() } - let (total, torrents) = public.torrents( + let (total, torrents) = state.public.torrents( keyword, Some((Sort::Modified, Order::Desc)), - page.map(|p| if p > 0 { p - 1 } else { p } * public.default_limit), - Some(public.default_limit), + page.map(|p| if p > 0 { p - 1 } else { p } * state.public.default_limit), + Some(state.public.default_limit), )?; let mut b = Vec::new(); - b.push(format!("# {}\n", config.name)); + b.push(format!("# {}\n", state.name)); - if let Some(ref description) = config.description { + if let Some(ref description) = state.description { b.push(format!("{description}\n")); } - if let Some(ref trackers) = config.tracker { + if let Some(ref trackers) = state.tracker { //b.push(format!("## Connect\n")); b.push("```".into()); for tracker in trackers { @@ -321,11 +351,16 @@ fn list( .unwrap_or_default() )); b.push(format!( - "{} • {} • {}\n", - torrent.time.format(&config.format_date), + "{} • {} • {}{}\n", + torrent.time.format(&state.format_date), format::total(&i), format::files(&i), - )) + state + .scrape + .scrape(i.info_hash.0) + .map(|s| format!(" ↑ {} ↓ {} ⏲ {}", s.seeders, s.peers, s.leechers)) + .unwrap_or_default() + )); } } @@ -334,11 +369,11 @@ fn list( b.push(format!( "Page {} / {} ({total} {} total)\n", page.unwrap_or(1), - (total as f64 / public.default_limit as f64).ceil(), + (total as f64 / state.public.default_limit as f64).ceil(), total.plurify(&["torrent", "torrents", "torrents"]) )); - if page.unwrap_or(1) * public.default_limit < total { + if page.unwrap_or(1) * state.public.default_limit < total { b.push(format!( "=> /{}{} Next", page.map_or(2, |p| p + 1), @@ -363,7 +398,7 @@ fn list( Ok(b.join("\n")) } -fn info(config: &Config, public: &Public, torrent: Torrent) -> Result { +fn info(state: &State, torrent: Torrent) -> Result { struct File { path: Option, length: u64, @@ -387,25 +422,30 @@ fn info(config: &Config, public: &Public, torrent: Torrent) -> Result { .name .as_ref() .map(|n| n.to_string()) - .unwrap_or(config.name.clone()) + .unwrap_or(state.name.clone()) )); b.push(format!( - "{} • {} • {}\n", - torrent.time.format(&config.format_date), + "{} • {} • {}{}\n", + torrent.time.format(&state.format_date), format::total(&i), format::files(&i), + state + .scrape + .scrape(i.info_hash.0) + .map(|s| format!(" ↑ {} ↓ {} ⏲ {}", s.seeders, s.peers, s.leechers)) + .unwrap_or_default() )); b.push(format!( "=> {} Magnet\n", - format::magnet(&i, config.tracker.as_ref()) + format::magnet(&i, state.tracker.as_ref()) )); if let Some(files) = i.info.files.map(|files| { let mut b = Vec::with_capacity(files.len()); for f in files { - let mut p = std::path::PathBuf::new(); + let mut p = PathBuf::new(); b.push(File { length: f.length, path: match f.full_path(&mut p) { @@ -423,7 +463,7 @@ fn info(config: &Config, public: &Public, torrent: Torrent) -> Result { b.push("## Files\n".into()); for file in files { let p = file.path(); - b.push(match public.href(&i.info_hash.as_string(), &p) { + b.push(match state.public.href(&i.info_hash.as_string(), &p) { Some(href) => format!( "=> {} {} ({})", urlencoding::encode(&href), @@ -437,3 +477,12 @@ fn info(config: &Config, public: &Public, torrent: Torrent) -> Result { Ok(b.join("\n")) } + +struct State { + description: Option, + format_date: String, + name: String, + public: Public, + scrape: Scrape, + tracker: Option>, +}