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("", "<\\/");
+ format!(
+ r#" "#,
+ 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}
+
+
+
+
+
+
+"##,
+ 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#""#);
+ 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#""#);
+ 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#""#);
+ 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("", "<\\/");
- format!(
- r#" "#,
- 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}
-
-
-
-
-
-
-"##,
- 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#""#);
- 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#""#);
- 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#""#);
- 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 {