use actix_web::{HttpResponse, web}; use db::cache::AppCache; use models::projects::project::{Column as PCol, Entity as PEntity}; use models::repos::repo::{Column as RCol, Entity as REntity}; use models::users::user::{Column as UCol, Entity as UEntity}; use sea_orm::*; use service::AppService; const CACHE_KEY_PREFIX: &str = "sidemap"; const CACHE_TTL_SECS: u64 = 8 * 3600; // 8 hours, no refresh /// Returns the base URL, forcing https:// prefix for public sitemap crawlers. fn public_base(config: &config::AppConfig) -> String { let fallback = "https://gitdata.ai".to_string(); let base = match config.main_domain() { Ok(b) => b.trim_end_matches('/').to_string(), Err(_) => fallback, }; if base.starts_with("https://") { base } else if base.starts_with("http://") { base.replacen("http://", "https://", 1) } else { format!("https://{base}") } } // ── Handlers ────────────────────────────────────────────────────────────────── /// Main sitemap index referencing all sub-sitemaps. pub async fn sitemap(service: web::Data) -> HttpResponse { let base = public_base(&service.config); let xml = format!( r#" {base}/sidemap/static {base}/sidemap/users {base}/sidemap/projects {base}/sidemap/repos "# ); HttpResponse::Ok() .content_type("application/xml; charset=utf-8") .body(xml) } /// Static routes (no DB, no cache). pub async fn sitemap_static(service: web::Data) -> HttpResponse { let base = public_base(&service.config); HttpResponse::Ok() .content_type("application/xml; charset=utf-8") .body(build_static_xml(&base)) } /// User profiles sitemap. pub async fn sitemap_users(service: web::Data) -> HttpResponse { let base = public_base(&service.config); let xml = cached_or_build(&service.cache, "users", || async { let db = service.db.reader(); let users: Vec<(String, String)> = UEntity::find() .filter(UCol::Username.ne("")) .order_by_asc(UCol::Username) .all(db) .await .unwrap_or_default() .into_iter() .map(|u| (u.username, u.updated_at.to_rfc3339())) .collect(); Ok(build_users_xml(&base, &users)) }) .await .unwrap_or_else(|_| build_users_xml(&base, &[])); HttpResponse::Ok() .content_type("application/xml; charset=utf-8") .body(xml) } /// Public projects sitemap. pub async fn sitemap_projects(service: web::Data) -> HttpResponse { let base = public_base(&service.config); let xml = cached_or_build(&service.cache, "projects", || async { let db = service.db.reader(); let projects: Vec<(String, String, String)> = PEntity::find() .filter(PCol::IsPublic.eq(true)) .order_by_asc(PCol::Name) .all(db) .await .unwrap_or_default() .into_iter() .map(|p| (p.name, p.id.to_string(), p.updated_at.to_rfc3339())) .collect(); Ok(build_projects_xml(&base, &projects)) }) .await .unwrap_or_else(|_| build_projects_xml(&base, &[])); HttpResponse::Ok() .content_type("application/xml; charset=utf-8") .body(xml) } /// Public repos sitemap. pub async fn sitemap_repos(service: web::Data) -> HttpResponse { let base = public_base(&service.config); let xml = cached_or_build(&service.cache, "repos", || async { let db = service.db.reader(); let project_map: std::collections::HashMap = PEntity::find() .filter(PCol::IsPublic.eq(true)) .all(db) .await .unwrap_or_default() .into_iter() .map(|p| (p.id.to_string(), p.name)) .collect(); let repos: Vec<(String, String)> = REntity::find() .filter(RCol::IsPrivate.eq(false)) .order_by_asc(RCol::RepoName) .all(db) .await .unwrap_or_default() .into_iter() .filter_map(|r| { let ns = project_map.get(&r.project.to_string())?; Some((format!("{ns}/{}", r.repo_name), r.updated_at.to_rfc3339())) }) .collect(); Ok(build_repos_xml(&base, &repos)) }) .await .unwrap_or_else(|_| build_repos_xml(&base, &[])); HttpResponse::Ok() .content_type("application/xml; charset=utf-8") .body(xml) } // ── Cache helpers ──────────────────────────────────────────────────────────────── async fn cached_or_build(cache: &AppCache, key: &str, build: F) -> Result where F: FnOnce() -> Fut, Fut: std::future::Future>, { let cache_key = format!("{CACHE_KEY_PREFIX}:{key}"); if let Ok(xml) = get_cached(cache, &cache_key).await { return Ok(xml); } let xml = build().await?; let _ = set_cached(cache, &cache_key, &xml).await; Ok(xml) } async fn get_cached(cache: &AppCache, key: &str) -> Result { let mut conn = cache.redis_pool().get().await.map_err(|e| { tracing::debug!("sidemap redis get pool error: {}", e); })?; redis::cmd("GET") .arg(key) .query_async::(&mut conn) .await .map_err(|e| { tracing::debug!("sidemap redis get error: {}", e); }) } async fn set_cached(cache: &AppCache, key: &str, value: &str) -> Result<(), ()> { let mut conn = cache.redis_pool().get().await.map_err(|e| { tracing::debug!("sidemap redis set pool error: {}", e); })?; redis::cmd("SETEX") .arg(key) .arg(CACHE_TTL_SECS) .arg(value) .query_async::<()>(&mut conn) .await .map_err(|e| { tracing::debug!("sidemap redis set error: {}", e); }) } // ── XML builders ──────────────────────────────────────────────────────────────── fn build_static_xml(base: &str) -> String { let mut xml = xml_header(); for loc in [ "/", "/auth/login", "/auth/register", "/auth/password/reset", "/auth/reset-password", "/auth/verify-email", "/about", "/pricing", "/pricing/enterprise", "/pricing/faq", "/skills", "/skills/publish", "/skills/docs", "/solutions", "/solutions/rooms", "/solutions/memory", "/solutions/governance", "/network", "/network/rooms", "/network/api", "/docs", ] { xml.push_str(&url_entry(&format!("{base}{loc}"), 0.9, "daily", None)); } xml.push_str(""); xml } fn build_users_xml(base: &str, users: &[(String, String)]) -> String { let mut xml = xml_header(); for (username, updated) in users { xml.push_str(&url_entry( &format!("{base}/user/{username}"), 0.6, "weekly", Some(updated), )); } xml.push_str(""); xml } fn build_projects_xml(base: &str, projects: &[(String, String, String)]) -> String { let mut xml = xml_header(); for (name, _, updated) in projects { xml.push_str(&url_entry( &format!("{base}/project/{name}"), 0.7, "weekly", Some(updated), )); for sub in [ "/activity", "/repositories", "/issues", "/members", "/articles", "/resources", ] { xml.push_str(&url_entry( &format!("{base}/project/{name}{sub}"), 0.6, "weekly", Some(updated), )); } } xml.push_str(""); xml } fn build_repos_xml(base: &str, repos: &[(String, String)]) -> String { let mut xml = xml_header(); for (path, updated) in repos { xml.push_str(&url_entry( &format!("{base}/repository/{path}"), 0.7, "daily", Some(updated), )); for sub in [ "/files", "/commits", "/branches", "/tags", "/contributors", "/pull-requests", ] { xml.push_str(&url_entry( &format!("{base}/repository/{path}{sub}"), 0.6, "daily", Some(updated), )); } } xml.push_str(""); xml } fn xml_header() -> String { String::from( r#" "#, ) } fn url_entry(loc: &str, priority: f32, changefreq: &str, updated: Option<&str>) -> String { let updated_xml = updated .map(|d| format!("\n {d}")) .unwrap_or_default(); format!( r#" {loc}{updated_xml} {changefreq} {priority} "#, ) }