pub mod handlers; use crate::config::{EngineConfig, Shortcut}; use crate::search_engines::ResultEntry; use crate::storage; use actix_web::{web, App, HttpServer}; use chrono::{DateTime, Utc}; use rusqlite::Connection; use serde::Serialize; use std::collections::HashMap; use std::sync::{Arc, Mutex, RwLock}; use tera::Tera; use tracing::info; const USER_AGENT: &str = concat!("search_hub/", env!("CARGO_PKG_VERSION")); pub struct DbPool(Mutex<Connection>); impl DbPool { pub fn new(path: &str) -> Self { let conn = storage::init_db(path).expect("Failed to initialize database"); DbPool(Mutex::new(conn)) } pub fn conn(&self) -> std::sync::MutexGuard<'_, Connection> { self.0.lock().unwrap() } } const VERSION: &str = concat!( env!("CARGO_PKG_VERSION"), " (", env!("SEARCH_HUB_GIT_HASH"), ")", ); #[derive(Clone)] pub struct ServerConfig { pub port: u16, pub bind_address: String, pub page_size: usize, pub workers: usize, } /// Simplified shortcut info for rendering in Tera templates. /// `has_at` is `false` for standalone [[bangs]] that have no engine backing. #[derive(Serialize)] pub struct ShortcutHelp { pub trigger: String, pub name: String, pub has_at: bool, } #[derive(serde::Deserialize)] pub struct SearchQuery { pub q: Option<String>, pub page: Option<usize>, pub page_size: Option<usize>, } #[derive(Serialize)] pub struct SearchApiResponse { query: String, page: usize, page_size: usize, total_results: usize, total_pages: usize, page_time_ms: f64, results: Vec<SearchApiResult>, } #[derive(Serialize)] #[serde(tag = "type")] pub enum SearchApiResult { #[serde(rename = "bookmark")] Bookmark(ApiBookmark), #[serde(rename = "external")] External(ApiExternal), } #[derive(Serialize)] pub struct ApiBookmark { pub id: i32, pub title: String, pub url: String, pub description: Option<String>, pub source: String, pub tags: Option<String>, pub created_at: DateTime<Utc>, } #[derive(Serialize)] pub struct ApiExternal { pub title: String, pub url: String, pub description: Option<String>, pub engine: String, } pub async fn run_server( db_path: &str, cfg: ServerConfig, engines: Arc<RwLock<Vec<EngineConfig>>>, shortcuts: HashMap<String, Shortcut>, ) -> std::io::Result<()> { let db_pool = web::Data::new(DbPool::new(db_path)); let engines = web::Data::new(engines); let cfg = web::Data::new(cfg); let shortcuts = web::Data::new(shortcuts); let mut tera = Tera::default(); tera.add_raw_template("index.html", include_str!("../../templates/index.html")) .expect("Failed to parse index template"); tera.add_raw_template("opensearch.xml", include_str!("../../templates/opensearch.xml")) .expect("Failed to parse opensearch template"); let tera = web::Data::new(tera); let bind_addr = cfg.bind_address.clone(); let bind_port = cfg.port; let workers = cfg.workers; info!( "Starting server on {}:{}, {} workers", bind_addr, bind_port, workers ); HttpServer::new(move || { App::new() .app_data(tera.clone()) .app_data(db_pool.clone()) .app_data(engines.clone()) .app_data(cfg.clone()) .app_data(shortcuts.clone()) .service(handlers::index) .service(handlers::search) .service(handlers::api_search) .service(handlers::opensearch) }) .workers(workers) .bind((bind_addr.as_str(), bind_port))? .run() .await } pub fn interleave(per_engine: &[Vec<ResultEntry>]) -> Vec<ResultEntry> { let max_len = per_engine.iter().map(|r| r.len()).max().unwrap_or(0); let mut out = Vec::with_capacity(max_len * per_engine.len()); for i in 0..max_len { for results in per_engine { if let Some(entry) = results.get(i) { out.push(entry.clone()); } } } out } #[cfg(test)] mod tests { use super::*; use crate::models::Bookmark; use chrono::Utc; fn test_tera() -> Tera { let mut tera = Tera::default(); tera.add_raw_template("index.html", include_str!("../../templates/index.html")) .expect("template parse"); tera } #[test] fn render_template_no_query() { let mut ctx = tera::Context::new(); ctx.insert("version", &"0.0.0"); let rendered = test_tera().render("index.html", &ctx).expect("render"); assert!(rendered.contains("Bookmark Search")); assert!(rendered.contains("enter a query")); } #[test] fn render_template_with_results() { let mut ctx = tera::Context::new(); ctx.insert("query", &"rust"); ctx.insert("page", &1usize); ctx.insert("total_pages", &1usize); ctx.insert("total_results", &1usize); ctx.insert("bookmarks", &vec![ Bookmark { id: 1, title: "Rust Lang".into(), url: "https://rust-lang.org".into(), description: Some("The Rust programming language".into()), source: "bookmark".into(), content: None, tags: None, created_at: Utc::now(), }, ]); ctx.insert("version", &"0.0.0"); ctx.insert("external_results", &Vec::<ResultEntry>::new()); let rendered = test_tera().render("index.html", &ctx).expect("render"); assert!(rendered.contains("Rust Lang")); assert!(rendered.contains("rust-lang.org")); assert!(rendered.contains("1 result")); } #[test] fn render_template_no_results() { let mut ctx = tera::Context::new(); ctx.insert("query", &"zzznotfound"); ctx.insert("page", &1usize); ctx.insert("total_pages", &0usize); ctx.insert("total_results", &0usize); ctx.insert("bookmarks", &Vec::<Bookmark>::new()); ctx.insert("version", &"0.0.0"); ctx.insert("external_results", &Vec::<ResultEntry>::new()); let rendered = test_tera().render("index.html", &ctx).expect("render"); assert!(rendered.contains("no bookmarks found")); } #[test] fn test_interleave_empty() { let result = interleave(&[]); assert!(result.is_empty()); } #[test] fn test_interleave_single_engine() { let e = vec![ ResultEntry { title: "A".into(), url: "http://a".into(), description: None, engine: "e1".into() }, ResultEntry { title: "B".into(), url: "http://b".into(), description: None, engine: "e1".into() }, ]; let result = interleave(&[e]); assert_eq!(result.len(), 2); assert_eq!(result[0].title, "A"); assert_eq!(result[1].title, "B"); } #[test] fn test_interleave_two_engines_equal_length() { let e1 = vec![ ResultEntry { title: "A1".into(), url: "http://a1".into(), description: None, engine: "e1".into() }, ResultEntry { title: "A2".into(), url: "http://a2".into(), description: None, engine: "e1".into() }, ]; let e2 = vec![ ResultEntry { title: "B1".into(), url: "http://b1".into(), description: None, engine: "e2".into() }, ResultEntry { title: "B2".into(), url: "http://b2".into(), description: None, engine: "e2".into() }, ]; let result = interleave(&[e1, e2]); assert_eq!(result.len(), 4); assert_eq!(result[0].title, "A1"); assert_eq!(result[1].title, "B1"); assert_eq!(result[2].title, "A2"); assert_eq!(result[3].title, "B2"); } #[test] fn test_interleave_uneven_length() { let e1 = vec![ ResultEntry { title: "A1".into(), url: "http://a1".into(), description: None, engine: "e1".into() }, ResultEntry { title: "A2".into(), url: "http://a2".into(), description: None, engine: "e1".into() }, ResultEntry { title: "A3".into(), url: "http://a3".into(), description: None, engine: "e1".into() }, ]; let e2 = vec![ ResultEntry { title: "B1".into(), url: "http://b1".into(), description: None, engine: "e2".into() }, ]; let result = interleave(&[e1, e2]); assert_eq!(result.len(), 4); assert_eq!(result[0].title, "A1"); assert_eq!(result[1].title, "B1"); assert_eq!(result[2].title, "A2"); assert_eq!(result[3].title, "A3"); } }