diff --git a/services/reports/src/config.rs b/services/reports/src/config.rs index c88d04d60..b139c8e31 100644 --- a/services/reports/src/config.rs +++ b/services/reports/src/config.rs @@ -1,78 +1,88 @@ use anyhow::Result; use clap::Parser; use comm_services_lib::blob::client::Url; use once_cell::sync::Lazy; use tracing::{info, warn}; use crate::email::config::{EmailArgs, EmailConfig}; // environment variable names const ENV_LOCALSTACK_ENDPOINT: &str = "LOCALSTACK_ENDPOINT"; const ENV_BLOB_SERVICE_URL: &str = "BLOB_SERVICE_URL"; +const ENV_PUBLIC_URL: &str = "PUBLIC_URL"; + +/// Base URL on which Reports service is accessible. +/// Used for sending e-mail links. +pub static SERVICE_PUBLIC_URL: Lazy = Lazy::new(|| { + std::env::var(ENV_PUBLIC_URL) + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "http://localhost:50056".to_string()) +}); #[derive(Parser)] #[command(version, about, long_about = None)] pub struct AppConfig { /// HTTP server listening port #[arg(long, default_value_t = 50056)] pub http_port: u16, #[arg(env = ENV_BLOB_SERVICE_URL)] #[arg(long, default_value = "http://localhost:50053")] pub blob_service_url: Url, /// AWS Localstack service URL #[arg(env = ENV_LOCALSTACK_ENDPOINT)] #[arg(long)] localstack_endpoint: Option, /// This config shouldn't be used directly. It's used for parsing purposes /// only. Use [`AppConfig::email_config()`] instead. #[command(flatten)] email_args: EmailArgs, } impl AppConfig { pub fn is_dev(&self) -> bool { self.localstack_endpoint.is_some() } pub fn email_config(&self) -> Option { // we return None in case of error because this should've already been // checked by parse_cmdline_args() self.email_args.parse().ok().flatten() } } /// Stores configuration parsed from command-line arguments /// and environment variables pub static CONFIG: Lazy = Lazy::new(AppConfig::parse); /// Processes the command-line arguments and environment variables. /// Should be called at the beginning of the `main()` function. pub(super) fn parse_cmdline_args() -> Result<&'static AppConfig> { // force evaluation of the lazy initialized config let cfg = Lazy::force(&CONFIG); // initialize e-mail config to check for errors match cfg.email_args.parse()? { Some(_) => { info!("E-mail config found. E-mail notifications are enabled."); } None => { warn!("E-mail config is disabled or missing! E-mails will not be sent."); } } Ok(cfg) } /// Provides region/credentials configuration for AWS SDKs pub async fn load_aws_config() -> aws_config::SdkConfig { let mut config_builder = aws_config::from_env(); if let Some(endpoint) = &CONFIG.localstack_endpoint { info!("Using Localstack. AWS Endpoint URL: {}", endpoint); config_builder = config_builder.endpoint_url(endpoint); } config_builder.load().await } diff --git a/services/reports/src/email/template/mod.rs b/services/reports/src/email/template/mod.rs index 0a164bed2..ad9825957 100644 --- a/services/reports/src/email/template/mod.rs +++ b/services/reports/src/email/template/mod.rs @@ -1,121 +1,172 @@ use maud::{html, Markup, Render}; +use tracing::error; -use crate::report_types::*; +use crate::{config::SERVICE_PUBLIC_URL, report_types::*}; mod html_layout; +const MAX_JSON_LINES: usize = 100; + pub fn render_email_for_report( report_input: &ReportInput, report_id: &ReportID, user_id: Option<&str>, ) -> String { html_layout::render_page(message_body_for_report( report_input, report_id, user_id, )) } // Examples: // [Android] Error report for User(ID = foo) // Media mission failed for User(ID = foo) // Thread inconsistency report for User(ID = foo) pub fn subject_for_report( report_input: &ReportInput, user_id: Option<&str>, ) -> String { let kind = title_for_report_type(&report_input.report_type); let user = format!("User(ID = {})", user_id.unwrap_or("[unknown]")); let object = if report_input.report_type.is_media_mission() { media_mission_status(report_input) } else { "report" }; let platform_prefix = if report_input.report_type.is_error() { format!("[{}] ", report_input.platform_details.platform) } else { "".into() }; format!("{platform_prefix}{kind} {object} for {user}") } fn message_body_for_report( report: &ReportInput, report_id: &ReportID, user_id: Option<&str>, ) -> Markup { let user = user_id.unwrap_or("N/A"); let platform = &report.platform_details; let time = report .time .unwrap_or_default() .format("%d/%m/%y %H:%M:%S") .to_string(); html! { h2 { (title_for_report_type(&report.report_type)) " report" } p { (intro_text(report, user_id)) } ul { li { "User ID: " b { (user) } } li { "Platform: " (platform) } li { "Time: " b { (time) } } li { "Report ID: " b { (report_id) } } } + (further_actions(report_id, report.report_type.is_error())) + (display_contents(report, report_id)) } } fn title_for_report_type(report_type: &ReportType) -> &str { match report_type { ReportType::ErrorReport => "Error", ReportType::ThreadInconsistency => "Thread inconsistency", ReportType::EntryInconsistency => "Entry inconsistency", ReportType::UserInconsistency => "User inconsistency", ReportType::MediaMission => "Media mission", } } fn intro_text(report: &ReportInput, user_id: Option<&str>) -> String { let user = format!("User (ID = {})", user_id.unwrap_or("[unknown]")); match &report.report_type { ReportType::ErrorReport => format!("{user} encountered an error :("), ReportType::ThreadInconsistency | ReportType::EntryInconsistency | ReportType::UserInconsistency => { format!("System detected inconsistency for {user}") } ReportType::MediaMission => { let status = media_mission_status(report); format!("Media mission {status} for {user}") } } } /// returns "success" or "failed" based on media mission status /// falls back to "completed" if couldn't determine fn media_mission_status(report: &ReportInput) -> &str { report .report_content .get("mediaMission") .and_then(|obj| obj["result"]["success"].as_bool()) .map(|success| if success { "success" } else { "failed" }) .unwrap_or("completed") } +fn further_actions( + report_id: &ReportID, + display_download_link: bool, +) -> Markup { + html! { + h3 { "Further actions" } + ul { + li { a href=(report_link(report_id)) { "Open raw report JSON" } } + @if display_download_link { + li { a href={ (report_link(report_id)) "/redux-devtools.json" } { "Redux Devtools import" } } + } + } + } +} + +fn display_contents(report: &ReportInput, report_id: &ReportID) -> Markup { + let pretty = match serde_json::to_string_pretty(&report.report_content) { + Ok(string) => string, + Err(err) => { + error!("Failed to render report JSON: {err}"); + return html! { pre { "ERROR: Failed to render JSON" } }; + } + }; + + let content: String = pretty + .split('\n') + .take(MAX_JSON_LINES) + .collect::>() + .join("\n"); + + html! { + h3 { "Report contents" } + em { + "The content is truncated to " (MAX_JSON_LINES) " lines. To view more, " + a href=(report_link(report_id)) { "open full report" } + "." + } + pre { (content) } + } +} + +fn report_link(id: &ReportID) -> String { + let base_url = SERVICE_PUBLIC_URL.as_str(); + format!("{base_url}/reports/{}", id.as_str()) +} + impl Render for PlatformDetails { fn render(&self) -> Markup { let code_version = self .code_version .map(|it| it.to_string()) .unwrap_or("N/A".into()); html! { b { (self.platform) } ", code version: " b { (code_version) } } } } impl Render for ReportID { fn render(&self) -> Markup { html! { (self.as_str()) } } }