/// Landing Page Handler /// /// Generates HTML landing page for the Nostr relay. use crate::config::Config; use crate::git::percent_encode; use crate::http::nip11::RelayInformationDocument; use std::collections::HashMap; /// Get the software version string (version + optional git commit) fn get_version() -> String { let version = env!("CARGO_PKG_VERSION"); match option_env!("GIT_COMMIT_SHORT") { Some(commit) if !commit.is_empty() => format!("v{}-{}", version, commit), _ => format!("v{}", version), } } /// Generate the footer JavaScript that sets the domain dynamically fn get_footer_script() -> &'static str { r#""# } /// Generate the theme toggle HTML button fn get_theme_toggle_html() -> &'static str { r##""## } /// Generate the theme toggle JavaScript fn get_theme_script() -> &'static str { r#""# } /// Generate the common base CSS used across all pages fn get_base_css() -> &'static str { r#"/* Dark mode (default) */ :root { --brand: #4434FF; --brand-light: #6b5fff; --bg: #0a0a0f; --surface: #12121a; --border: #1e1e2e; --text: #e4e4eb; --text-muted: #a8a8bd; --error: #ff4444; --success: #22c55e; --logo-bg: #4434FF; --logo-icon: white; } /* Light mode - system preference */ @media (prefers-color-scheme: light) { :root:not([data-theme="dark"]) { --brand: #4434FF; --brand-light: #3525cc; --bg: #f8f9fa; --surface: #ffffff; --border: #e1e4e8; --text: #1a1a2e; --text-muted: #586069; --error: #dc3545; --success: #28a745; --logo-bg: #4434FF; --logo-icon: white; } } /* Manual light mode override */ :root[data-theme="light"] { --brand: #4434FF; --brand-light: #3525cc; --bg: #f8f9fa; --surface: #ffffff; --border: #e1e4e8; --text: #1a1a2e; --text-muted: #586069; --error: #dc3545; --success: #28a745; --logo-bg: #4434FF; --logo-icon: white; } /* Manual dark mode override */ :root[data-theme="dark"] { --brand: #4434FF; --brand-light: #6b5fff; --bg: #0a0a0f; --surface: #12121a; --border: #1e1e2e; --text: #e4e4eb; --text-muted: #a8a8bd; --error: #ff4444; --success: #22c55e; --logo-bg: #4434FF; --logo-icon: white; } * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', sans-serif; line-height: 1.6; background: var(--bg); color: var(--text); min-height: 100vh; transition: background-color 0.3s ease, color 0.3s ease; } a { color: var(--brand-light); text-decoration: none; } a:hover { text-decoration: underline; } code { background: var(--border); padding: 4px 8px; border-radius: 4px; font-family: 'SF Mono', 'Consolas', monospace; font-size: 0.875rem; color: var(--brand-light); } /* Theme toggle button */ .theme-toggle { position: fixed; top: 16px; right: 16px; z-index: 1000; background: var(--surface); border: 1px solid var(--border); border-radius: 50%; width: 44px; height: 44px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.3s ease; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } .theme-toggle:hover { transform: scale(1.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); } .theme-toggle svg { width: 20px; height: 20px; fill: var(--text); transition: fill 0.3s ease; } .theme-toggle .sun-icon { display: none; } .theme-toggle .moon-icon { display: block; } :root[data-theme="light"] .theme-toggle .sun-icon, :root:not([data-theme="dark"]) .theme-toggle .sun-icon { display: block; } :root[data-theme="light"] .theme-toggle .moon-icon, :root:not([data-theme="dark"]) .theme-toggle .moon-icon { display: none; } @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .theme-toggle .sun-icon { display: none; } :root:not([data-theme="light"]) .theme-toggle .moon-icon { display: block; } } .footer { margin-top: 48px; padding-top: 24px; border-top: 1px solid var(--border); text-align: center; color: var(--text-muted); font-size: 0.875rem; } .footer-separator { margin: 0 0.5em; opacity: 0.5; } .software-box { display: flex; align-items: flex-start; gap: 16px; text-align: left; } .software-logo { width: 48px; height: 48px; flex-shrink: 0; } .software-logo rect { fill: var(--logo-bg); } .software-logo path { fill: var(--logo-icon); } .software-content { flex: 1; } .software-heading { font-size: 1.125rem; font-weight: 500; margin-bottom: 8px; color: var(--text-muted); } .software-heading a { color: var(--brand-light); } .software-heading a:hover { text-decoration: underline; } .software-desc { color: var(--text-muted); font-size: 0.9rem; line-height: 1.5; }"# } /// Metadata for a NIP (title and description for the landing page) struct NipMetadata { title: &'static str, description: &'static str, } /// Metadata for a GRASP (title and description for the landing page) struct GraspMetadata { title: &'static str, description: &'static str, } /// Get known NIP metadata for landing page display fn get_nip_metadata() -> HashMap { let mut map = HashMap::new(); map.insert( 1, NipMetadata { title: "Basic Protocol", description: "Core Nostr protocol flow and event structure", }, ); map.insert( 11, NipMetadata { title: "Relay Information", description: "Relay metadata and capabilities document", }, ); map.insert( 34, NipMetadata { title: "Git Events", description: "Repository announcements and state tracking", }, ); map.insert( 77, NipMetadata { title: "Negentropy Sync", description: "Efficient set reconciliation protocol", }, ); map } /// Get known GRASP metadata for landing page display fn get_grasp_metadata() -> HashMap<&'static str, GraspMetadata> { let mut map = HashMap::new(); map.insert( "GRASP-01", GraspMetadata { title: "Nostr Authorised HTTP Git Server", description: "with embedded Nostr Relay", }, ); map } /// Generate hero section tags HTML from NIP-11 document fn generate_hero_tags(nip11: &RelayInformationDocument) -> String { let mut html = String::new(); // Add GRASP tags for grasp in &nip11.supported_grasps { html.push_str(&format!(r#"{}"#, grasp)); html.push('\n'); } // Add NIP tags for nip in &nip11.supported_nips { html.push_str(&format!( r#"NIP-{:02}"#, nip )); html.push('\n'); } html } /// Generate detailed GRASP cards HTML from NIP-11 document fn generate_grasp_cards(nip11: &RelayInformationDocument) -> String { let metadata = get_grasp_metadata(); let mut html = String::new(); for grasp in &nip11.supported_grasps { if let Some(meta) = metadata.get(grasp.as_str()) { html.push_str(&format!( r#"
{}{}
{}
"#, grasp, meta.title, meta.description )); } else { // Fallback for unknown GRASPs - still show them html.push_str(&format!( r#"
{}{}
"#, grasp, grasp )); } html.push('\n'); } html } /// Generate detailed NIP cards HTML from NIP-11 document fn generate_nip_cards(nip11: &RelayInformationDocument) -> String { let metadata = get_nip_metadata(); let mut html = String::new(); for nip in &nip11.supported_nips { if let Some(meta) = metadata.get(nip) { html.push_str(&format!( r#"
NIP-{:02} {}
{}
"#, nip, meta.title, meta.description )); } else { // Fallback for unknown NIPs - still show them html.push_str(&format!( r#"
NIP-{:02}
"#, nip )); } html.push('\n'); } html } /// Escape HTML special characters fn escape_html(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } /// Generate table rows for NIP-11 relay information fn generate_relay_info_rows(nip11: &RelayInformationDocument) -> String { let mut html = String::new(); // Follow NIP-11 document order: // name html.push_str(&format!( r#"name{}"#, escape_html(&nip11.name) )); html.push('\n'); // description html.push_str(&format!( r#"description{}"#, escape_html(&nip11.description) )); html.push('\n'); // pubkey (if present) if let Some(ref pubkey) = nip11.pubkey { html.push_str(&format!( r#"pubkey{}"#, escape_html(pubkey) )); html.push('\n'); } // contact (if present) if let Some(ref contact) = nip11.contact { html.push_str(&format!( r#"contact{}"#, escape_html(contact) )); html.push('\n'); } // supported_nips let nips_str = nip11 .supported_nips .iter() .map(|n| n.to_string()) .collect::>() .join(", "); html.push_str(&format!( r#"supported_nips{}"#, nips_str )); html.push('\n'); // software html.push_str(&format!( r#"software{}"#, escape_html(&nip11.software), escape_html(&nip11.software) )); html.push('\n'); // version html.push_str(&format!( r#"version{}"#, escape_html(&nip11.version) )); html.push('\n'); // icon (if present) if let Some(ref icon) = nip11.icon { html.push_str(&format!( r#"icon{}"#, escape_html(icon), escape_html(icon) )); html.push('\n'); } // GRASP-01 Extensions: // supported_grasps let grasps_str = nip11.supported_grasps.join(", "); html.push_str(&format!( r#"supported_grasps{}"#, escape_html(&grasps_str) )); html.push('\n'); // repo_acceptance_criteria html.push_str(&format!( r#"repo_acceptance_criteria{}"#, escape_html(&nip11.repo_acceptance_criteria) )); html.push('\n'); // curation (if present) if let Some(ref curation) = nip11.curation { html.push_str(&format!( r#"curation{}"#, escape_html(curation) )); html.push('\n'); } html } /// Generate the relay icon HTML for the header fn generate_relay_icon_html(nip11: &RelayInformationDocument) -> String { match &nip11.icon { Some(icon_url) => format!( r#"Relay Icon"#, escape_html(icon_url) ), None => String::new(), } } /// Generate the HTML landing page pub fn get_html(config: &Config) -> String { // Get NIP-11 document for supported NIPs and GRASPs let nip11 = RelayInformationDocument::from_config(config); // Curation matches NIP-11 document let curation = nip11.curation.as_deref().unwrap_or("None").to_string(); // Generate dynamic HTML for NIPs and GRASPs let hero_tags = generate_hero_tags(&nip11); let grasp_cards = generate_grasp_cards(&nip11); let nip_cards = generate_nip_cards(&nip11); let relay_info_rows = generate_relay_info_rows(&nip11); let relay_icon = generate_relay_icon_html(&nip11); format!( include_str!("../../templates/landing.html"), base_css = get_base_css(), relay_name = config.relay_name(), relay_description = config.relay_description, version = get_version(), nip11_version = escape_html(&nip11.version), curation = curation, repo_acceptance_criteria = escape_html(&nip11.repo_acceptance_criteria), theme_toggle = get_theme_toggle_html(), theme_script = get_theme_script(), hero_tags = hero_tags, grasp_cards = grasp_cards, nip_cards = nip_cards, relay_info_rows = relay_info_rows, relay_icon = relay_icon, ) } /// Generate a generic 404 page for unknown paths /// /// Used for any path that doesn't match a known route pub fn get_generic_404_html(config: &Config, path: &str) -> String { format!( r##" Not Found - {relay_name} {theme_toggle}
404

Page Not Found

The page you're looking for doesn't exist.

Requested Path
{path}
← Back to {relay_name}
{footer_script} {theme_script} "##, base_css = get_base_css(), relay_name = config.relay_name(), path = path, version = get_version(), footer_script = get_footer_script(), theme_toggle = get_theme_toggle_html(), theme_script = get_theme_script(), ) } /// Generate a 404 page for a non-existent repository /// /// GRASP-01: "...and a 404 page for repositories it doesn't host" pub fn get_404_html(config: &Config, npub: &str, identifier: &str) -> String { format!( r##" Repository Not Found - {relay_name} {theme_toggle}
404

Repository Not Found

This repository doesn't exist on this GRASP server.

Owner {npub}
Repository {identifier}
The repository may not have been announced to this server, or the URL may be incorrect.
← Back to {relay_name}
{footer_script} {theme_script} "##, base_css = get_base_css(), relay_name = config.relay_name(), npub = npub, identifier = identifier, version = get_version(), footer_script = get_footer_script(), theme_toggle = get_theme_toggle_html(), theme_script = get_theme_script(), ) } /// Generate a webpage for an existing repository /// /// GRASP-01: "SHOULD serve a webpage at the same endpoint linking to git nostr client(s) /// to browse the repository" pub fn get_repo_html(config: &Config, npub: &str, identifier: &str) -> String { format!( r##" {identifier} - {relay_name} {theme_toggle}

{identifier}

by {npub}

Git repository hosted using the Grasp Protocol

Browse Repository on GitWorkshop.dev →
Clone
curl -Ls https://ngit.dev/install.sh | bash
git clone nostr://{npub}//{encoded_identifier}
{theme_script} "##, base_css = get_base_css(), relay_name = config.relay_name(), npub = npub, identifier = identifier, encoded_identifier = percent_encode(identifier), version = get_version(), theme_toggle = get_theme_toggle_html(), theme_script = get_theme_script(), ) }