diff --git a/server/src/api/links/landing.rs b/server/src/api/links/landing.rs new file mode 100644 index 0000000..bf8b1bb --- /dev/null +++ b/server/src/api/links/landing.rs @@ -0,0 +1,520 @@ +//! 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}; + +// ── Public surface ── + +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>, +} + +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, + ) +} + +// ── 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() { + 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 {