From 127893cab752bf838062f6fc70fbfc90c3986e87 Mon Sep 17 00:00:00 2001 From: drei Date: Fri, 1 May 2026 12:51:42 -0500 Subject: [PATCH 1/2] Split api/links/routes.rs: extract landing renderer and QR helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `api/links/routes.rs` was 2249 lines — about 5x the median routes file. A new contributor (or AI agent) opening it had to load ~370 lines of HTML rendering and ~265 lines of QR styling before reaching the actual route handlers, even when their task only touched link CRUD. Three sibling modules now share the file's responsibilities: - `api/links/landing.rs` (~520 lines) — `LandingPageContext`, `render_smart_landing_page`, and the rendering-only helpers (`social_preview_from_metadata`, `js_escape`, `build_agent_panel`, `action_to_schema_type`). - `api/links/qr.rs` (~365 lines) — `QrOutputFormat`, `render_link_qr` (the orchestrator), `QrRenderOptions`, `LogoImage`, all the `parse_*` validators, `fetch_logo`, and `render_qr`. - `api/links/routes.rs` (~1400 lines) — handlers for link CRUD, resolve, attribution, timeseries, plus the helpers genuinely shared across multiple flows (`html_escape`, `urlencoding`, `canonical_link_url`, `compute_link_status`, `lookup_tenant_domain`, `build_rift_meta`, `check_link_resolvable`, `do_resolve`, `Platform`, `detect_platform`). Helpers used by both routes.rs and the new modules are marked `pub(crate)`: `html_escape`, `urlencoding`, `canonical_link_url`, `Platform`. Same approach as the attribution slice's reuse of `check_link_resolvable` etc. Pure code motion. No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- server/src/api/links/landing.rs | 518 +++++++++++++++++++ server/src/api/links/mod.rs | 2 + server/src/api/links/qr.rs | 364 +++++++++++++ server/src/api/links/routes.rs | 870 +------------------------------- 4 files changed, 892 insertions(+), 862 deletions(-) create mode 100644 server/src/api/links/landing.rs create mode 100644 server/src/api/links/qr.rs diff --git a/server/src/api/links/landing.rs b/server/src/api/links/landing.rs new file mode 100644 index 0000000..ae7ff81 --- /dev/null +++ b/server/src/api/links/landing.rs @@ -0,0 +1,518 @@ +//! Smart landing page renderer used by `do_resolve` for browser-targeted +//! GETs against `/r/{link_id}` and `/{link_id}` (custom domain). Returns +//! HTML; the JSON resolve flow lives in `routes.rs`. + +use serde_json::json; + +use super::routes::{html_escape, urlencoding, Platform}; +use crate::services::links::models::{AgentContext, Link, SocialPreview}; + +fn action_to_schema_type(action: &str) -> &'static str { + match action { + "purchase" => "BuyAction", + "subscribe" => "SubscribeAction", + "signup" => "RegisterAction", + "download" => "DownloadAction", + "read" => "ReadAction", + "book" => "ReserveAction", + _ => "ViewAction", + } +} + +// ── Smart Landing Page ── + +pub(crate) struct LandingPageContext<'a> { + pub platform: Platform, + pub link: &'a Link, + pub link_id: &'a str, + pub app_name: Option<&'a str>, + pub icon_url: Option<&'a str>, + pub theme_color: Option<&'a str>, + pub social_preview: Option<&'a SocialPreview>, + pub agent_context: Option<&'a AgentContext>, + pub link_status: &'a str, + pub tenant_domain: Option<&'a str>, + pub tenant_verified: bool, + pub alternate_domain: Option<&'a str>, +} + +/// Legacy fallback: before `social_preview` existed, customers used `metadata.{title,description,image}` +/// for OG tags. Read those keys when the link has no `social_preview` so existing links don't silently +/// lose their previews on deploy. +fn social_preview_from_metadata( + metadata: Option<&mongodb::bson::Document>, +) -> Option { + let meta = metadata?; + let title = meta.get_str("title").ok().map(str::to_string); + let description = meta.get_str("description").ok().map(str::to_string); + let image_url = meta.get_str("image").ok().map(str::to_string); + if title.is_none() && description.is_none() && image_url.is_none() { + return None; + } + Some(SocialPreview { + title, + description, + image_url, + }) +} + +pub(crate) fn render_smart_landing_page(ctx: &LandingPageContext) -> String { + let app_name_display = ctx.app_name.unwrap_or("App"); + let theme = ctx.theme_color.unwrap_or("#0d9488"); + let platform = ctx.platform; + let link = ctx.link; + let platform_js = js_escape(platform.as_str()); + + let metadata_fallback = if ctx.social_preview.is_none() { + social_preview_from_metadata(link.metadata.as_ref()) + } else { + None + }; + let effective_preview = ctx.social_preview.or(metadata_fallback.as_ref()); + + let store_url = match platform { + Platform::Ios => link.ios_store_url.as_deref().unwrap_or(""), + Platform::Android => link.android_store_url.as_deref().unwrap_or(""), + Platform::Other => "", + }; + + // For Android, append referrer with link_id to store URL. + let store_url_with_referrer = if platform == Platform::Android && !store_url.is_empty() { + let sep = if store_url.contains('?') { "&" } else { "?" }; + format!( + "{}{}referrer={}", + store_url, + sep, + urlencoding(&format!("rift_link={}", ctx.link_id)) + ) + } else { + store_url.to_string() + }; + let store_url_js = js_escape(&store_url_with_referrer); + + let web_url = link.web_url.as_deref().unwrap_or(""); + let web_url_js = js_escape(web_url); + + // Alternate domain URL for the "Open in App" button (cross-domain Universal Link trigger). + let alternate_url = ctx + .alternate_domain + .map(|d| format!("https://{}/{}", d, ctx.link_id)) + .unwrap_or_default(); + let alternate_url_js = js_escape(&alternate_url); + + let preview_title = effective_preview.and_then(|p| p.title.as_deref()); + let preview_description = effective_preview.and_then(|p| p.description.as_deref()); + let preview_image = effective_preview.and_then(|p| p.image_url.as_deref()); + let og_title = preview_title.unwrap_or(app_name_display); + let og_description = preview_description.unwrap_or("Open in app"); + + let json_ld = if let Some(ac) = ctx.agent_context { + if ac.action.is_some() || ac.cta.is_some() || ac.description.is_some() { + let action_type = ac + .action + .as_deref() + .map(action_to_schema_type) + .unwrap_or("ViewAction"); + + let entry_points: Vec<_> = [ + ( + ctx.link.ios_deep_link.as_deref(), + "http://schema.org/IOSPlatform", + ), + ( + ctx.link.android_deep_link.as_deref(), + "http://schema.org/AndroidPlatform", + ), + ( + ctx.link.web_url.as_deref(), + "http://schema.org/DesktopWebPlatform", + ), + ] + .into_iter() + .filter_map(|(opt, platform)| { + opt.map(|url| { + json!({ + "@type": "EntryPoint", + "urlTemplate": url, + "actionPlatform": platform, + }) + }) + }) + .collect(); + + let mut action = json!({ + "@context": "https://schema.org", + "@type": action_type, + }); + if let Some(cta) = &ac.cta { + action["name"] = json!(cta); + } + if let Some(desc) = &ac.description { + action["description"] = json!(desc); + } + if !entry_points.is_empty() { + action["target"] = json!(entry_points); + } + + // Add public preview info if available. + if preview_title.is_some() || preview_description.is_some() { + let mut product = json!({"@type": "Product"}); + if let Some(t) = preview_title { + product["name"] = json!(t); + } + if let Some(d) = preview_description { + product["description"] = json!(d); + } + action["object"] = product; + } + + // Add provenance metadata. + action["provider"] = json!({ + "@type": "Organization", + "name": ctx.tenant_domain.unwrap_or("unknown"), + "additionalProperty": [ + { "@type": "PropertyValue", "name": "status", "value": ctx.link_status }, + { "@type": "PropertyValue", "name": "verified", "value": ctx.tenant_verified }, + ] + }); + + let json_str = serde_json::to_string(&action).unwrap_or_default(); + // Escape in JSON-LD to prevent XSS. + let json_str = json_str.replace("{}"#, + json_str + ) + } else { + String::new() + } + } else { + String::new() + }; + + let og_image_tag = preview_image + .map(|img| { + format!( + r#" + "#, + img = html_escape(img) + ) + }) + .unwrap_or_default(); + + let icon_html = ctx + .icon_url + .map(|url| { + format!( + r#"{}"#, + html_escape(url), + html_escape(app_name_display), + ) + }) + .unwrap_or_default(); + + let title_html = preview_title + .map(|t| { + format!( + r#"

{}

"#, + html_escape(t) + ) + }) + .unwrap_or_default(); + + let desc_html = preview_description + .map(|d| { + format!( + r#"

{}

"#, + html_escape(d) + ) + }) + .unwrap_or_default(); + + let agent_description = ctx.agent_context.and_then(|ac| ac.description.as_deref()); + + let meta_desc_tag = agent_description + .or(preview_description) + .map(|d| { + format!( + r#" "#, + html_escape(d) + ) + }) + .unwrap_or_default(); + + let agent_panel = build_agent_panel(ctx); + + format!( + r##" + + + + + {og_title} — Rift + + + + + +{meta_desc_tag} +{og_image_tag} +{json_ld} + + + +
+
+
+ {icon_html} +
{app_name_escaped}
+ {title_html} + {desc_html} + Open in {app_name_escaped} +

+
+
+
+ {agent_panel} +
+
+ + +"##, + og_title = html_escape(og_title), + og_title_escaped = html_escape(og_title), + og_desc_escaped = html_escape(og_description), + meta_desc_tag = meta_desc_tag, + og_image_tag = og_image_tag, + json_ld = json_ld, + theme = html_escape(theme), + icon_html = icon_html, + app_name_escaped = html_escape(app_name_display), + title_html = title_html, + desc_html = desc_html, + agent_panel = agent_panel, + platform_js = platform_js, + store_url_js = store_url_js, + web_url_js = web_url_js, + alternate_url_js = alternate_url_js, + ) +} + +fn js_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\'' => out.push_str("\\'"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\0' => out.push_str("\\0"), + '<' => out.push_str("\\x3c"), + '>' => out.push_str("\\x3e"), + '&' => out.push_str("\\x26"), + '/' => out.push_str("\\/"), + '\u{2028}' => out.push_str("\\u2028"), + '\u{2029}' => out.push_str("\\u2029"), + _ => out.push(c), + } + } + out +} + +fn build_agent_panel(ctx: &LandingPageContext) -> String { + let ac = ctx.agent_context; + let link = ctx.link; + let theme = ctx.theme_color.unwrap_or("#0d9488"); + + let mut html = String::new(); + + // Badge + html.push_str(&format!( + r#"
Machine-Readable Link
"#, + theme = html_escape(theme) + )); + html.push_str( + r#"

This link is structured for both humans and AI agents.

"#, + ); + + // Verified by Rift + html.push_str(r#"
Verified by Rift
"#); + if let Some(domain) = ctx.tenant_domain { + let check = if ctx.tenant_verified { + r#""# + } else { + "" + }; + html.push_str(&format!( + r#"
Domain{}{}
"#, + html_escape(domain), check + )); + } + let status_class = match ctx.link_status { + "expired" => " expired", + "flagged" => " flagged", + _ => "", + }; + html.push_str(&format!( + r#"
Status{}
"#, + status_class, + html_escape(&ctx.link_status[..1].to_uppercase()) + &ctx.link_status[1..] + )); + html.push_str("
"); + + // Provided by creator + if ac.is_some_and(|a| a.action.is_some() || a.cta.is_some() || a.description.is_some()) { + let ac = ac.unwrap(); + html.push_str(r#"
Provided by link creator
"#); + if let Some(action) = &ac.action { + html.push_str(&format!( + r#"
Action{}
"#, + html_escape(action) + )); + } + if let Some(cta) = &ac.cta { + html.push_str(&format!( + r#"
CTA{}
"#, + html_escape(cta) + )); + } + if let Some(desc) = &ac.description { + html.push_str(&format!( + r#"
{}
"#, + html_escape(desc) + )); + if let Some(domain) = ctx.tenant_domain { + html.push_str(&format!( + r#"

Provided by the owner of {}. Not independently verified.

"#, + html_escape(domain) + )); + } + } + html.push_str("
"); + } + + // Destinations + let dests: Vec<(&str, &str)> = [ + ("iOS", link.ios_deep_link.as_deref()), + ("Android", link.android_deep_link.as_deref()), + ("Web", link.web_url.as_deref()), + ("App Store", link.ios_store_url.as_deref()), + ("Play Store", link.android_store_url.as_deref()), + ] + .into_iter() + .filter_map(|(label, opt)| opt.map(|v| (label, v))) + .collect(); + if !dests.is_empty() { + html.push_str(r#"
Destinations
"#); + for (label, url) in &dests { + let display_url = url + .trim_start_matches("https://") + .trim_start_matches("http://"); + html.push_str(&format!( + r#"
{}{}
"#, + label, + html_escape(url), + html_escape(display_url) + )); + } + html.push_str("
"); + } + + // Footer + html.push_str(r#""); + + html +} diff --git a/server/src/api/links/mod.rs b/server/src/api/links/mod.rs index d4a3279..f109ff9 100644 --- a/server/src/api/links/mod.rs +++ b/server/src/api/links/mod.rs @@ -1,4 +1,6 @@ +pub mod landing; pub mod models; +pub mod qr; pub mod routes; use axum::middleware; diff --git a/server/src/api/links/qr.rs b/server/src/api/links/qr.rs new file mode 100644 index 0000000..dc2cd0d --- /dev/null +++ b/server/src/api/links/qr.rs @@ -0,0 +1,364 @@ +//! Styled QR rendering for `/v1/links/{link_id}/qr.{format}`. +//! +//! Two layers: +//! - `render_link_qr` — top-level orchestrator called from the route +//! handler; resolves the link, builds options, dispatches to `render_qr`. +//! - Lower helpers (parse_*, fetch_logo, render_qr) wrap the +//! `qr_code_styling` crate with our defaults, validation, and +//! per-link canonical URL. + +use axum::http::{header, StatusCode}; +use axum::response::{IntoResponse, Json, Response}; +use qr_code_styling::config::{ + BackgroundOptions, Color, CornersDotOptions, CornersSquareOptions, DotsOptions, ImageOptions, + QROptions, +}; +use qr_code_styling::types::{ + CornerDotType, CornerSquareType, DotType, ErrorCorrectionLevel, OutputFormat, ShapeType, +}; +use qr_code_styling::QRCodeStyling; +use serde_json::json; +use std::io::Cursor; +use std::sync::Arc; +use std::time::Duration; + +use super::models::QrCodeQuery; +use super::routes::canonical_link_url; +use crate::api::auth::models::TenantId; +use crate::app::AppState; +use image::ImageFormat; + +#[derive(Debug, Clone, Copy)] +pub(crate) enum QrOutputFormat { + Png, + Svg, +} + +pub(crate) async fn render_link_qr( + state: Arc, + tenant: TenantId, + link_id: String, + query: QrCodeQuery, + format: QrOutputFormat, +) -> Response { + let Some(repo) = &state.links_repo else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ "error": "Database not configured", "code": "no_database" })), + ) + .into_response(); + }; + + let Some(link) = repo + .find_link_by_tenant_and_id(&tenant.0, &link_id) + .await + .ok() + .flatten() + else { + return ( + StatusCode::NOT_FOUND, + Json(json!({ "error": "Link not found", "code": "not_found" })), + ) + .into_response(); + }; + + let options = match QrRenderOptions::try_from_query(&query).await { + Ok(options) => options, + Err(message) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": message, "code": "invalid_qr_options" })), + ) + .into_response(); + } + }; + + let url = canonical_link_url(&state, &link).await; + match render_qr(&url, &options, format) { + Ok(bytes) => { + let content_type = match format { + QrOutputFormat::Png => "image/png", + QrOutputFormat::Svg => "image/svg+xml; charset=utf-8", + }; + ( + StatusCode::OK, + [ + (header::CONTENT_TYPE, content_type), + (header::CACHE_CONTROL, "no-store"), + ], + bytes, + ) + .into_response() + } + Err(message) => ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": message, "code": "qr_render_error" })), + ) + .into_response(), + } +} + +// ── QR Rendering Helpers ── + +const QR_DEFAULT_SIZE: u32 = 600; +const QR_MIN_SIZE: u32 = 128; +const QR_MAX_SIZE: u32 = 2048; +const QR_DEFAULT_MARGIN: u32 = 2; +const QR_MAX_MARGIN: u32 = 16; +const QR_MAX_LOGO_BYTES: usize = 512 * 1024; +const QR_LOGO_TIMEOUT_SECS: u64 = 5; + +struct QrRenderOptions { + size: u32, + margin: u32, + level: ErrorCorrectionLevel, + fg: Color, + bg: Color, + logo: Option, + dot_type: DotType, + corner_square_type: CornerSquareType, + corner_dot_type: CornerDotType, + shape: ShapeType, + dot_color: Option, + corner_square_color: Option, + corner_dot_color: Option, +} + +struct LogoImage { + png_bytes: Vec, +} + +impl QrRenderOptions { + async fn try_from_query(query: &QrCodeQuery) -> Result { + let size = query.size.unwrap_or(QR_DEFAULT_SIZE); + if !(QR_MIN_SIZE..=QR_MAX_SIZE).contains(&size) { + return Err(format!( + "size must be between {QR_MIN_SIZE} and {QR_MAX_SIZE}" + )); + } + + let margin = query.margin.unwrap_or_else(|| { + if query.include_margin == Some(false) { + 0 + } else { + QR_DEFAULT_MARGIN + } + }); + if margin > QR_MAX_MARGIN { + return Err(format!("margin must be between 0 and {QR_MAX_MARGIN}")); + } + + let will_render_logo = !query.hide_logo && query.logo.is_some(); + // A centered logo covers the middle of the QR, so force max error correction when the + // caller didn't pick one — otherwise the default (L) becomes unreadable with a logo. + let level = match query.level.as_deref() { + Some(v) => parse_ec_level(v)?, + None if will_render_logo => ErrorCorrectionLevel::H, + None => ErrorCorrectionLevel::L, + }; + let fg = parse_hex_color(query.fg_color.as_deref().unwrap_or("#000000"), "fgColor")?; + let bg = parse_hex_color(query.bg_color.as_deref().unwrap_or("#FFFFFF"), "bgColor")?; + let logo = if will_render_logo { + Some(fetch_logo(query.logo.as_deref().unwrap()).await?) + } else { + None + }; + + let dot_type = match query.dot_type.as_deref() { + Some(v) => parse_dot_type(v)?, + None => DotType::Rounded, + }; + let corner_square_type = match query.corner_square_type.as_deref() { + Some(v) => parse_corner_square_type(v)?, + None => CornerSquareType::ExtraRounded, + }; + let corner_dot_type = match query.corner_dot_type.as_deref() { + Some(v) => parse_corner_dot_type(v)?, + None => CornerDotType::Dot, + }; + let shape = match query.shape.as_deref() { + Some(v) => parse_shape(v)?, + None => ShapeType::Square, + }; + let dot_color = match query.dot_color.as_deref() { + Some(v) => Some(parse_hex_color(v, "dotColor")?), + None => None, + }; + let corner_square_color = match query.corner_square_color.as_deref() { + Some(v) => Some(parse_hex_color(v, "cornerSquareColor")?), + None => None, + }; + let corner_dot_color = match query.corner_dot_color.as_deref() { + Some(v) => Some(parse_hex_color(v, "cornerDotColor")?), + None => None, + }; + + Ok(Self { + size, + margin, + level, + fg, + bg, + logo, + dot_type, + corner_square_type, + corner_dot_type, + shape, + dot_color, + corner_square_color, + corner_dot_color, + }) + } +} + +fn parse_ec_level(value: &str) -> Result { + match value { + "L" | "l" => Ok(ErrorCorrectionLevel::L), + "M" | "m" => Ok(ErrorCorrectionLevel::M), + "Q" | "q" => Ok(ErrorCorrectionLevel::Q), + "H" | "h" => Ok(ErrorCorrectionLevel::H), + _ => Err("level must be one of L, M, Q, H".to_string()), + } +} + +fn parse_dot_type(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "square" => Ok(DotType::Square), + "dots" => Ok(DotType::Dots), + "rounded" => Ok(DotType::Rounded), + "classy" => Ok(DotType::Classy), + "classy-rounded" | "classyrounded" => Ok(DotType::ClassyRounded), + "extra-rounded" | "extrarounded" => Ok(DotType::ExtraRounded), + _ => Err( + "dotType must be one of square, dots, rounded, classy, classy-rounded, extra-rounded" + .to_string(), + ), + } +} + +fn parse_corner_square_type(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "square" => Ok(CornerSquareType::Square), + "dot" => Ok(CornerSquareType::Dot), + "extra-rounded" | "extrarounded" => Ok(CornerSquareType::ExtraRounded), + _ => Err("cornerSquareType must be one of square, dot, extra-rounded".to_string()), + } +} + +fn parse_corner_dot_type(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "dot" => Ok(CornerDotType::Dot), + "square" => Ok(CornerDotType::Square), + _ => Err("cornerDotType must be one of dot, square".to_string()), + } +} + +fn parse_shape(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "square" => Ok(ShapeType::Square), + "circle" => Ok(ShapeType::Circle), + _ => Err("shape must be one of square, circle".to_string()), + } +} + +fn parse_hex_color(value: &str, name: &str) -> Result { + let value = value.trim(); + let Some(hex) = value.strip_prefix('#') else { + return Err(format!("{name} must be #RGB or #RRGGBB")); + }; + if hex.len() != 3 && hex.len() != 6 { + return Err(format!("{name} must be #RGB or #RRGGBB")); + } + Color::from_hex(value).map_err(|_| format!("{name} must be #RGB or #RRGGBB")) +} + +async fn fetch_logo(url: &str) -> Result { + crate::core::validation::validate_web_url(url).map_err(|e| format!("logo: {e}"))?; + // SSRF guard: validate_web_url only checks the user-supplied string. Following redirects + // would let a public origin rebind to an internal IP mid-request, so disable them outright. + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(QR_LOGO_TIMEOUT_SECS)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|e| format!("logo client error: {e}"))?; + let response = client + .get(url) + .send() + .await + .map_err(|e| format!("logo fetch failed: {e}"))?; + if !response.status().is_success() { + return Err(format!("logo fetch returned {}", response.status())); + } + if let Some(content_type) = response.headers().get(header::CONTENT_TYPE) { + let content_type = content_type.to_str().unwrap_or_default().to_lowercase(); + let allowed = content_type.starts_with("image/png") + || content_type.starts_with("image/jpeg") + || content_type.starts_with("image/webp"); + if !allowed { + return Err("logo must be PNG, JPEG, or WebP".to_string()); + } + } + let bytes = response + .bytes() + .await + .map_err(|e| format!("logo read failed: {e}"))?; + if bytes.len() > QR_MAX_LOGO_BYTES { + return Err(format!("logo must be under {QR_MAX_LOGO_BYTES} bytes")); + } + + let image = image::load_from_memory(&bytes) + .map_err(|_| "logo must be a valid PNG, JPEG, or WebP image".to_string())?; + let mut png_bytes = Vec::new(); + image + .write_to(&mut Cursor::new(&mut png_bytes), ImageFormat::Png) + .map_err(|e| format!("logo encode failed: {e}"))?; + + Ok(LogoImage { png_bytes }) +} + +fn render_qr( + url: &str, + options: &QrRenderOptions, + format: QrOutputFormat, +) -> Result, String> { + let margin_px = options.margin.saturating_mul(10); + let dot_color = options.dot_color.unwrap_or(options.fg); + let corner_square_color = options.corner_square_color.unwrap_or(options.fg); + let corner_dot_color = options.corner_dot_color.unwrap_or(options.fg); + let mut builder = QRCodeStyling::builder() + .data(url) + .size(options.size) + .margin(margin_px) + .shape(options.shape) + .qr_options(QROptions::new().with_error_correction_level(options.level)) + .dots_options(DotsOptions::new(options.dot_type).with_color(dot_color)) + .corners_square_options( + CornersSquareOptions::new(options.corner_square_type).with_color(corner_square_color), + ) + .corners_dot_options( + CornersDotOptions::new(options.corner_dot_type).with_color(corner_dot_color), + ) + .background_options(BackgroundOptions::new(options.bg)); + + if let Some(logo) = &options.logo { + builder = builder.image(logo.png_bytes.clone()).image_options( + ImageOptions::new() + .with_image_size(0.22) + .with_margin(6) + .with_hide_background_dots(true) + .with_save_as_blob(true), + ); + } + + let qr = builder + .build() + .map_err(|e| format!("failed to build QR code: {e}"))?; + match format { + QrOutputFormat::Png => qr + .render(OutputFormat::Png) + .map_err(|e| format!("failed to render PNG: {e}")), + QrOutputFormat::Svg => qr + .render(OutputFormat::Svg) + .map_err(|e| format!("failed to render SVG: {e}")), + } +} diff --git a/server/src/api/links/routes.rs b/server/src/api/links/routes.rs index b497271..5b5cffc 100644 --- a/server/src/api/links/routes.rs +++ b/server/src/api/links/routes.rs @@ -1,24 +1,15 @@ use axum::extract::{Path, Query, State}; -use axum::http::{header, HeaderMap, StatusCode}; +use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Json, Redirect, Response}; use chrono::Utc; -use image::ImageFormat; use mongodb::bson::oid::ObjectId; use mongodb::bson::DateTime; -use qr_code_styling::config::{ - BackgroundOptions, Color, CornersDotOptions, CornersSquareOptions, DotsOptions, ImageOptions, - QROptions, -}; -use qr_code_styling::types::{ - CornerDotType, CornerSquareType, DotType, ErrorCorrectionLevel, OutputFormat, ShapeType, -}; -use qr_code_styling::QRCodeStyling; use serde_json::json; -use std::io::Cursor; use std::sync::Arc; -use std::time::Duration; +use super::landing::{render_smart_landing_page, LandingPageContext}; use super::models::{QrCodeQuery, ResolveQuery}; +use super::qr::{render_link_qr, QrOutputFormat}; use crate::api::auth::models::{CallerScope, SdkDomain, TenantId}; use crate::app::AppState; use crate::core::webhook_dispatcher::{AttributionEventPayload, ClickEventPayload}; @@ -363,76 +354,6 @@ pub async fn get_link_qr( render_link_qr(state, tenant, link_id, query, format).await } -#[derive(Debug, Clone, Copy)] -enum QrOutputFormat { - Png, - Svg, -} - -async fn render_link_qr( - state: Arc, - tenant: TenantId, - link_id: String, - query: QrCodeQuery, - format: QrOutputFormat, -) -> Response { - let Some(repo) = &state.links_repo else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "Database not configured", "code": "no_database" })), - ) - .into_response(); - }; - - let Some(link) = repo - .find_link_by_tenant_and_id(&tenant.0, &link_id) - .await - .ok() - .flatten() - else { - return ( - StatusCode::NOT_FOUND, - Json(json!({ "error": "Link not found", "code": "not_found" })), - ) - .into_response(); - }; - - let options = match QrRenderOptions::try_from_query(&query).await { - Ok(options) => options, - Err(message) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ "error": message, "code": "invalid_qr_options" })), - ) - .into_response(); - } - }; - - let url = canonical_link_url(&state, &link).await; - match render_qr(&url, &options, format) { - Ok(bytes) => { - let content_type = match format { - QrOutputFormat::Png => "image/png", - QrOutputFormat::Svg => "image/svg+xml; charset=utf-8", - }; - ( - StatusCode::OK, - [ - (header::CONTENT_TYPE, content_type), - (header::CACHE_CONTROL, "no-store"), - ], - bytes, - ) - .into_response() - } - Err(message) => ( - StatusCode::BAD_REQUEST, - Json(json!({ "error": message, "code": "qr_render_error" })), - ) - .into_response(), - } -} - // ── GET /r/{link_id} — Resolve/redirect (public) ── #[utoipa::path( @@ -1292,374 +1213,6 @@ pub async fn link_attribution( } } -// ── Smart Landing Page ── - -struct LandingPageContext<'a> { - platform: Platform, - link: &'a Link, - link_id: &'a str, - app_name: Option<&'a str>, - icon_url: Option<&'a str>, - theme_color: Option<&'a str>, - social_preview: Option<&'a SocialPreview>, - agent_context: Option<&'a AgentContext>, - link_status: &'a str, - tenant_domain: Option<&'a str>, - tenant_verified: bool, - alternate_domain: Option<&'a str>, -} - -/// Legacy fallback: before `social_preview` existed, customers used `metadata.{title,description,image}` -/// for OG tags. Read those keys when the link has no `social_preview` so existing links don't silently -/// lose their previews on deploy. -fn social_preview_from_metadata( - metadata: Option<&mongodb::bson::Document>, -) -> Option { - let meta = metadata?; - let title = meta.get_str("title").ok().map(str::to_string); - let description = meta.get_str("description").ok().map(str::to_string); - let image_url = meta.get_str("image").ok().map(str::to_string); - if title.is_none() && description.is_none() && image_url.is_none() { - return None; - } - Some(SocialPreview { - title, - description, - image_url, - }) -} - -fn render_smart_landing_page(ctx: &LandingPageContext) -> String { - let app_name_display = ctx.app_name.unwrap_or("App"); - let theme = ctx.theme_color.unwrap_or("#0d9488"); - let platform = ctx.platform; - let link = ctx.link; - let platform_js = js_escape(platform.as_str()); - - let metadata_fallback = if ctx.social_preview.is_none() { - social_preview_from_metadata(link.metadata.as_ref()) - } else { - None - }; - let effective_preview = ctx.social_preview.or(metadata_fallback.as_ref()); - - let store_url = match platform { - Platform::Ios => link.ios_store_url.as_deref().unwrap_or(""), - Platform::Android => link.android_store_url.as_deref().unwrap_or(""), - Platform::Other => "", - }; - - // For Android, append referrer with link_id to store URL. - let store_url_with_referrer = if platform == Platform::Android && !store_url.is_empty() { - let sep = if store_url.contains('?') { "&" } else { "?" }; - format!( - "{}{}referrer={}", - store_url, - sep, - urlencoding(&format!("rift_link={}", ctx.link_id)) - ) - } else { - store_url.to_string() - }; - let store_url_js = js_escape(&store_url_with_referrer); - - let web_url = link.web_url.as_deref().unwrap_or(""); - let web_url_js = js_escape(web_url); - - // Alternate domain URL for the "Open in App" button (cross-domain Universal Link trigger). - let alternate_url = ctx - .alternate_domain - .map(|d| format!("https://{}/{}", d, ctx.link_id)) - .unwrap_or_default(); - let alternate_url_js = js_escape(&alternate_url); - - let preview_title = effective_preview.and_then(|p| p.title.as_deref()); - let preview_description = effective_preview.and_then(|p| p.description.as_deref()); - let preview_image = effective_preview.and_then(|p| p.image_url.as_deref()); - let og_title = preview_title.unwrap_or(app_name_display); - let og_description = preview_description.unwrap_or("Open in app"); - - let json_ld = if let Some(ac) = ctx.agent_context { - if ac.action.is_some() || ac.cta.is_some() || ac.description.is_some() { - let action_type = ac - .action - .as_deref() - .map(action_to_schema_type) - .unwrap_or("ViewAction"); - - let entry_points: Vec<_> = [ - ( - ctx.link.ios_deep_link.as_deref(), - "http://schema.org/IOSPlatform", - ), - ( - ctx.link.android_deep_link.as_deref(), - "http://schema.org/AndroidPlatform", - ), - ( - ctx.link.web_url.as_deref(), - "http://schema.org/DesktopWebPlatform", - ), - ] - .into_iter() - .filter_map(|(opt, platform)| { - opt.map(|url| { - json!({ - "@type": "EntryPoint", - "urlTemplate": url, - "actionPlatform": platform, - }) - }) - }) - .collect(); - - let mut action = json!({ - "@context": "https://schema.org", - "@type": action_type, - }); - if let Some(cta) = &ac.cta { - action["name"] = json!(cta); - } - if let Some(desc) = &ac.description { - action["description"] = json!(desc); - } - if !entry_points.is_empty() { - action["target"] = json!(entry_points); - } - - // Add public preview info if available. - if preview_title.is_some() || preview_description.is_some() { - let mut product = json!({"@type": "Product"}); - if let Some(t) = preview_title { - product["name"] = json!(t); - } - if let Some(d) = preview_description { - product["description"] = json!(d); - } - action["object"] = product; - } - - // Add provenance metadata. - action["provider"] = json!({ - "@type": "Organization", - "name": ctx.tenant_domain.unwrap_or("unknown"), - "additionalProperty": [ - { "@type": "PropertyValue", "name": "status", "value": ctx.link_status }, - { "@type": "PropertyValue", "name": "verified", "value": ctx.tenant_verified }, - ] - }); - - let json_str = serde_json::to_string(&action).unwrap_or_default(); - // Escape in JSON-LD to prevent XSS. - let json_str = json_str.replace("{}"#, - json_str - ) - } else { - String::new() - } - } else { - String::new() - }; - - let og_image_tag = preview_image - .map(|img| { - format!( - r#" - "#, - img = html_escape(img) - ) - }) - .unwrap_or_default(); - - let icon_html = ctx - .icon_url - .map(|url| { - format!( - r#"{}"#, - html_escape(url), - html_escape(app_name_display), - ) - }) - .unwrap_or_default(); - - let title_html = preview_title - .map(|t| { - format!( - r#"

{}

"#, - html_escape(t) - ) - }) - .unwrap_or_default(); - - let desc_html = preview_description - .map(|d| { - format!( - r#"

{}

"#, - html_escape(d) - ) - }) - .unwrap_or_default(); - - let agent_description = ctx.agent_context.and_then(|ac| ac.description.as_deref()); - - let meta_desc_tag = agent_description - .or(preview_description) - .map(|d| { - format!( - r#" "#, - html_escape(d) - ) - }) - .unwrap_or_default(); - - let agent_panel = build_agent_panel(ctx); - - format!( - r##" - - - - - {og_title} — Rift - - - - - -{meta_desc_tag} -{og_image_tag} -{json_ld} - - - -
-
-
- {icon_html} -
{app_name_escaped}
- {title_html} - {desc_html} - Open in {app_name_escaped} -

-
-
-
- {agent_panel} -
-
- - -"##, - og_title = html_escape(og_title), - og_title_escaped = html_escape(og_title), - og_desc_escaped = html_escape(og_description), - meta_desc_tag = meta_desc_tag, - og_image_tag = og_image_tag, - json_ld = json_ld, - theme = html_escape(theme), - icon_html = icon_html, - app_name_escaped = html_escape(app_name_display), - title_html = title_html, - desc_html = desc_html, - agent_panel = agent_panel, - platform_js = platform_js, - store_url_js = store_url_js, - web_url_js = web_url_js, - alternate_url_js = alternate_url_js, - ) -} - // ── GET /llms.txt — Machine-readable link context for LLMs ── #[tracing::instrument] @@ -1674,271 +1227,6 @@ pub async fn llms_txt() -> impl IntoResponse { ) } -// ── QR Rendering Helpers ── - -const QR_DEFAULT_SIZE: u32 = 600; -const QR_MIN_SIZE: u32 = 128; -const QR_MAX_SIZE: u32 = 2048; -const QR_DEFAULT_MARGIN: u32 = 2; -const QR_MAX_MARGIN: u32 = 16; -const QR_MAX_LOGO_BYTES: usize = 512 * 1024; -const QR_LOGO_TIMEOUT_SECS: u64 = 5; - -struct QrRenderOptions { - size: u32, - margin: u32, - level: ErrorCorrectionLevel, - fg: Color, - bg: Color, - logo: Option, - dot_type: DotType, - corner_square_type: CornerSquareType, - corner_dot_type: CornerDotType, - shape: ShapeType, - dot_color: Option, - corner_square_color: Option, - corner_dot_color: Option, -} - -struct LogoImage { - png_bytes: Vec, -} - -impl QrRenderOptions { - async fn try_from_query(query: &QrCodeQuery) -> Result { - let size = query.size.unwrap_or(QR_DEFAULT_SIZE); - if !(QR_MIN_SIZE..=QR_MAX_SIZE).contains(&size) { - return Err(format!( - "size must be between {QR_MIN_SIZE} and {QR_MAX_SIZE}" - )); - } - - let margin = query.margin.unwrap_or_else(|| { - if query.include_margin == Some(false) { - 0 - } else { - QR_DEFAULT_MARGIN - } - }); - if margin > QR_MAX_MARGIN { - return Err(format!("margin must be between 0 and {QR_MAX_MARGIN}")); - } - - let will_render_logo = !query.hide_logo && query.logo.is_some(); - // A centered logo covers the middle of the QR, so force max error correction when the - // caller didn't pick one — otherwise the default (L) becomes unreadable with a logo. - let level = match query.level.as_deref() { - Some(v) => parse_ec_level(v)?, - None if will_render_logo => ErrorCorrectionLevel::H, - None => ErrorCorrectionLevel::L, - }; - let fg = parse_hex_color(query.fg_color.as_deref().unwrap_or("#000000"), "fgColor")?; - let bg = parse_hex_color(query.bg_color.as_deref().unwrap_or("#FFFFFF"), "bgColor")?; - let logo = if will_render_logo { - Some(fetch_logo(query.logo.as_deref().unwrap()).await?) - } else { - None - }; - - let dot_type = match query.dot_type.as_deref() { - Some(v) => parse_dot_type(v)?, - None => DotType::Rounded, - }; - let corner_square_type = match query.corner_square_type.as_deref() { - Some(v) => parse_corner_square_type(v)?, - None => CornerSquareType::ExtraRounded, - }; - let corner_dot_type = match query.corner_dot_type.as_deref() { - Some(v) => parse_corner_dot_type(v)?, - None => CornerDotType::Dot, - }; - let shape = match query.shape.as_deref() { - Some(v) => parse_shape(v)?, - None => ShapeType::Square, - }; - let dot_color = match query.dot_color.as_deref() { - Some(v) => Some(parse_hex_color(v, "dotColor")?), - None => None, - }; - let corner_square_color = match query.corner_square_color.as_deref() { - Some(v) => Some(parse_hex_color(v, "cornerSquareColor")?), - None => None, - }; - let corner_dot_color = match query.corner_dot_color.as_deref() { - Some(v) => Some(parse_hex_color(v, "cornerDotColor")?), - None => None, - }; - - Ok(Self { - size, - margin, - level, - fg, - bg, - logo, - dot_type, - corner_square_type, - corner_dot_type, - shape, - dot_color, - corner_square_color, - corner_dot_color, - }) - } -} - -fn parse_ec_level(value: &str) -> Result { - match value { - "L" | "l" => Ok(ErrorCorrectionLevel::L), - "M" | "m" => Ok(ErrorCorrectionLevel::M), - "Q" | "q" => Ok(ErrorCorrectionLevel::Q), - "H" | "h" => Ok(ErrorCorrectionLevel::H), - _ => Err("level must be one of L, M, Q, H".to_string()), - } -} - -fn parse_dot_type(value: &str) -> Result { - match value.to_ascii_lowercase().as_str() { - "square" => Ok(DotType::Square), - "dots" => Ok(DotType::Dots), - "rounded" => Ok(DotType::Rounded), - "classy" => Ok(DotType::Classy), - "classy-rounded" | "classyrounded" => Ok(DotType::ClassyRounded), - "extra-rounded" | "extrarounded" => Ok(DotType::ExtraRounded), - _ => Err( - "dotType must be one of square, dots, rounded, classy, classy-rounded, extra-rounded" - .to_string(), - ), - } -} - -fn parse_corner_square_type(value: &str) -> Result { - match value.to_ascii_lowercase().as_str() { - "square" => Ok(CornerSquareType::Square), - "dot" => Ok(CornerSquareType::Dot), - "extra-rounded" | "extrarounded" => Ok(CornerSquareType::ExtraRounded), - _ => Err("cornerSquareType must be one of square, dot, extra-rounded".to_string()), - } -} - -fn parse_corner_dot_type(value: &str) -> Result { - match value.to_ascii_lowercase().as_str() { - "dot" => Ok(CornerDotType::Dot), - "square" => Ok(CornerDotType::Square), - _ => Err("cornerDotType must be one of dot, square".to_string()), - } -} - -fn parse_shape(value: &str) -> Result { - match value.to_ascii_lowercase().as_str() { - "square" => Ok(ShapeType::Square), - "circle" => Ok(ShapeType::Circle), - _ => Err("shape must be one of square, circle".to_string()), - } -} - -fn parse_hex_color(value: &str, name: &str) -> Result { - let value = value.trim(); - let Some(hex) = value.strip_prefix('#') else { - return Err(format!("{name} must be #RGB or #RRGGBB")); - }; - if hex.len() != 3 && hex.len() != 6 { - return Err(format!("{name} must be #RGB or #RRGGBB")); - } - Color::from_hex(value).map_err(|_| format!("{name} must be #RGB or #RRGGBB")) -} - -async fn fetch_logo(url: &str) -> Result { - crate::core::validation::validate_web_url(url).map_err(|e| format!("logo: {e}"))?; - // SSRF guard: validate_web_url only checks the user-supplied string. Following redirects - // would let a public origin rebind to an internal IP mid-request, so disable them outright. - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(QR_LOGO_TIMEOUT_SECS)) - .redirect(reqwest::redirect::Policy::none()) - .build() - .map_err(|e| format!("logo client error: {e}"))?; - let response = client - .get(url) - .send() - .await - .map_err(|e| format!("logo fetch failed: {e}"))?; - if !response.status().is_success() { - return Err(format!("logo fetch returned {}", response.status())); - } - if let Some(content_type) = response.headers().get(header::CONTENT_TYPE) { - let content_type = content_type.to_str().unwrap_or_default().to_lowercase(); - let allowed = content_type.starts_with("image/png") - || content_type.starts_with("image/jpeg") - || content_type.starts_with("image/webp"); - if !allowed { - return Err("logo must be PNG, JPEG, or WebP".to_string()); - } - } - let bytes = response - .bytes() - .await - .map_err(|e| format!("logo read failed: {e}"))?; - if bytes.len() > QR_MAX_LOGO_BYTES { - return Err(format!("logo must be under {QR_MAX_LOGO_BYTES} bytes")); - } - - let image = image::load_from_memory(&bytes) - .map_err(|_| "logo must be a valid PNG, JPEG, or WebP image".to_string())?; - let mut png_bytes = Vec::new(); - image - .write_to(&mut Cursor::new(&mut png_bytes), ImageFormat::Png) - .map_err(|e| format!("logo encode failed: {e}"))?; - - Ok(LogoImage { png_bytes }) -} - -fn render_qr( - url: &str, - options: &QrRenderOptions, - format: QrOutputFormat, -) -> Result, String> { - let margin_px = options.margin.saturating_mul(10); - let dot_color = options.dot_color.unwrap_or(options.fg); - let corner_square_color = options.corner_square_color.unwrap_or(options.fg); - let corner_dot_color = options.corner_dot_color.unwrap_or(options.fg); - let mut builder = QRCodeStyling::builder() - .data(url) - .size(options.size) - .margin(margin_px) - .shape(options.shape) - .qr_options(QROptions::new().with_error_correction_level(options.level)) - .dots_options(DotsOptions::new(options.dot_type).with_color(dot_color)) - .corners_square_options( - CornersSquareOptions::new(options.corner_square_type).with_color(corner_square_color), - ) - .corners_dot_options( - CornersDotOptions::new(options.corner_dot_type).with_color(corner_dot_color), - ) - .background_options(BackgroundOptions::new(options.bg)); - - if let Some(logo) = &options.logo { - builder = builder.image(logo.png_bytes.clone()).image_options( - ImageOptions::new() - .with_image_size(0.22) - .with_margin(6) - .with_hide_background_dots(true) - .with_save_as_blob(true), - ); - } - - let qr = builder - .build() - .map_err(|e| format!("failed to build QR code: {e}"))?; - match format { - QrOutputFormat::Png => qr - .render(OutputFormat::Png) - .map_err(|e| format!("failed to render PNG: {e}")), - QrOutputFormat::Svg => qr - .render(OutputFormat::Svg) - .map_err(|e| format!("failed to render SVG: {e}")), - } -} - // ── Helpers ── fn link_error_to_response(err: LinkError) -> Response { @@ -1988,14 +1276,14 @@ fn link_error_to_response(err: LinkError) -> Response { } #[derive(Debug, Clone, Copy, PartialEq)] -enum Platform { +pub(crate) enum Platform { Ios, Android, Other, } impl Platform { - fn as_str(&self) -> &'static str { + pub(crate) fn as_str(&self) -> &'static str { match self { Platform::Ios => "ios", Platform::Android => "android", @@ -2019,29 +1307,7 @@ fn is_valid_link_id(id: &str) -> bool { !id.is_empty() && id.len() <= 64 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') } -fn js_escape(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for c in s.chars() { - match c { - '\\' => out.push_str("\\\\"), - '"' => out.push_str("\\\""), - '\'' => out.push_str("\\'"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\0' => out.push_str("\\0"), - '<' => out.push_str("\\x3c"), - '>' => out.push_str("\\x3e"), - '&' => out.push_str("\\x26"), - '/' => out.push_str("\\/"), - '\u{2028}' => out.push_str("\\u2028"), - '\u{2029}' => out.push_str("\\u2029"), - _ => out.push(c), - } - } - out -} - -fn html_escape(s: &str) -> String { +pub(crate) fn html_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") @@ -2049,114 +1315,6 @@ fn html_escape(s: &str) -> String { .replace('\'', "'") } -fn build_agent_panel(ctx: &LandingPageContext) -> String { - let ac = ctx.agent_context; - let link = ctx.link; - let theme = ctx.theme_color.unwrap_or("#0d9488"); - - let mut html = String::new(); - - // Badge - html.push_str(&format!( - r#"
Machine-Readable Link
"#, - theme = html_escape(theme) - )); - html.push_str( - r#"

This link is structured for both humans and AI agents.

"#, - ); - - // Verified by Rift - html.push_str(r#"
Verified by Rift
"#); - if let Some(domain) = ctx.tenant_domain { - let check = if ctx.tenant_verified { - r#""# - } else { - "" - }; - html.push_str(&format!( - r#"
Domain{}{}
"#, - html_escape(domain), check - )); - } - let status_class = match ctx.link_status { - "expired" => " expired", - "flagged" => " flagged", - _ => "", - }; - html.push_str(&format!( - r#"
Status{}
"#, - status_class, - html_escape(&ctx.link_status[..1].to_uppercase()) + &ctx.link_status[1..] - )); - html.push_str("
"); - - // Provided by creator - if ac.is_some_and(|a| a.action.is_some() || a.cta.is_some() || a.description.is_some()) { - let ac = ac.unwrap(); - html.push_str(r#"
Provided by link creator
"#); - if let Some(action) = &ac.action { - html.push_str(&format!( - r#"
Action{}
"#, - html_escape(action) - )); - } - if let Some(cta) = &ac.cta { - html.push_str(&format!( - r#"
CTA{}
"#, - html_escape(cta) - )); - } - if let Some(desc) = &ac.description { - html.push_str(&format!( - r#"
{}
"#, - html_escape(desc) - )); - if let Some(domain) = ctx.tenant_domain { - html.push_str(&format!( - r#"

Provided by the owner of {}. Not independently verified.

"#, - html_escape(domain) - )); - } - } - html.push_str("
"); - } - - // Destinations - let dests: Vec<(&str, &str)> = [ - ("iOS", link.ios_deep_link.as_deref()), - ("Android", link.android_deep_link.as_deref()), - ("Web", link.web_url.as_deref()), - ("App Store", link.ios_store_url.as_deref()), - ("Play Store", link.android_store_url.as_deref()), - ] - .into_iter() - .filter_map(|(label, opt)| opt.map(|v| (label, v))) - .collect(); - if !dests.is_empty() { - html.push_str(r#"
Destinations
"#); - for (label, url) in &dests { - let display_url = url - .trim_start_matches("https://") - .trim_start_matches("http://"); - html.push_str(&format!( - r#"
{}{}
"#, - label, - html_escape(url), - html_escape(display_url) - )); - } - html.push_str("
"); - } - - // Footer - html.push_str(r#""); - - html -} - fn compute_link_status(link: &Link) -> &'static str { if let Some(expires_at) = link.expires_at { if DateTime::now().timestamp_millis() > expires_at.timestamp_millis() { @@ -2192,7 +1350,7 @@ async fn lookup_tenant_domain( } } -async fn canonical_link_url(state: &AppState, link: &Link) -> String { +pub(crate) async fn canonical_link_url(state: &AppState, link: &Link) -> String { let domain = crate::services::links::service::resolve_verified_primary_domain( state.domains_repo.as_deref(), &link.tenant_id, @@ -2221,19 +1379,7 @@ fn build_rift_meta( }) } -fn action_to_schema_type(action: &str) -> &'static str { - match action { - "purchase" => "BuyAction", - "subscribe" => "SubscribeAction", - "signup" => "RegisterAction", - "download" => "DownloadAction", - "read" => "ReadAction", - "book" => "ReserveAction", - _ => "ViewAction", - } -} - -fn urlencoding(s: &str) -> String { +pub(crate) fn urlencoding(s: &str) -> String { let mut out = String::with_capacity(s.len()); for b in s.bytes() { match b { From c05d48662185114d5adfdee15fd3b81c977774be Mon Sep 17 00:00:00 2001 From: drei Date: Fri, 1 May 2026 12:56:48 -0500 Subject: [PATCH 2/2] Apply stepdown rule to landing.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I left `action_to_schema_type` and `social_preview_from_metadata` at the top of `landing.rs` when I created the file — both private helpers above the public `render_smart_landing_page`. That violates the stepdown rule we already codified. Reordered: `LandingPageContext` + `render_smart_landing_page` now sit at the top, all four private helpers (`action_to_schema_type`, `social_preview_from_metadata`, `js_escape`, `build_agent_panel`) collected in a `// ── Helpers ──` section at the bottom. A reader opening the file now sees the file's reason to exist first (the pub(crate) entry point) and descends into helpers as they read. Caught by review, not by the architecture test — the test's enforced file list doesn't yet include `landing.rs`. Worth a follow-up to flip that to a denylist so new helper files are caught automatically. Co-Authored-By: Claude Opus 4.7 (1M context) --- server/src/api/links/landing.rs | 68 +++++++++++++++++---------------- 1 file changed, 35 insertions(+), 33 deletions(-) diff --git a/server/src/api/links/landing.rs b/server/src/api/links/landing.rs index ae7ff81..bf8b1bb 100644 --- a/server/src/api/links/landing.rs +++ b/server/src/api/links/landing.rs @@ -7,19 +7,7 @@ use serde_json::json; use super::routes::{html_escape, urlencoding, Platform}; use crate::services::links::models::{AgentContext, Link, SocialPreview}; -fn action_to_schema_type(action: &str) -> &'static str { - match action { - "purchase" => "BuyAction", - "subscribe" => "SubscribeAction", - "signup" => "RegisterAction", - "download" => "DownloadAction", - "read" => "ReadAction", - "book" => "ReserveAction", - _ => "ViewAction", - } -} - -// ── Smart Landing Page ── +// ── Public surface ── pub(crate) struct LandingPageContext<'a> { pub platform: Platform, @@ -36,26 +24,6 @@ pub(crate) struct LandingPageContext<'a> { pub alternate_domain: Option<&'a str>, } -/// Legacy fallback: before `social_preview` existed, customers used `metadata.{title,description,image}` -/// for OG tags. Read those keys when the link has no `social_preview` so existing links don't silently -/// lose their previews on deploy. -fn social_preview_from_metadata( - metadata: Option<&mongodb::bson::Document>, -) -> Option { - let meta = metadata?; - let title = meta.get_str("title").ok().map(str::to_string); - let description = meta.get_str("description").ok().map(str::to_string); - let image_url = meta.get_str("image").ok().map(str::to_string); - if title.is_none() && description.is_none() && image_url.is_none() { - return None; - } - Some(SocialPreview { - title, - description, - image_url, - }) -} - pub(crate) fn render_smart_landing_page(ctx: &LandingPageContext) -> String { let app_name_display = ctx.app_name.unwrap_or("App"); let theme = ctx.theme_color.unwrap_or("#0d9488"); @@ -387,6 +355,40 @@ pub(crate) fn render_smart_landing_page(ctx: &LandingPageContext) -> String { ) } +// ── Helpers ── + +fn action_to_schema_type(action: &str) -> &'static str { + match action { + "purchase" => "BuyAction", + "subscribe" => "SubscribeAction", + "signup" => "RegisterAction", + "download" => "DownloadAction", + "read" => "ReadAction", + "book" => "ReserveAction", + _ => "ViewAction", + } +} + +/// Legacy fallback: before `social_preview` existed, customers used `metadata.{title,description,image}` +/// for OG tags. Read those keys when the link has no `social_preview` so existing links don't silently +/// lose their previews on deploy. +fn social_preview_from_metadata( + metadata: Option<&mongodb::bson::Document>, +) -> Option { + let meta = metadata?; + let title = meta.get_str("title").ok().map(str::to_string); + let description = meta.get_str("description").ok().map(str::to_string); + let image_url = meta.get_str("image").ok().map(str::to_string); + if title.is_none() && description.is_none() && image_url.is_none() { + return None; + } + Some(SocialPreview { + title, + description, + image_url, + }) +} + fn js_escape(s: &str) -> String { let mut out = String::with_capacity(s.len()); for c in s.chars() {