use crate::auth::get_user_from_headers; use crate::database::PgPool; use crate::database::models::DBProject; use crate::database::redis::RedisPool; use crate::env::ENV; use crate::models::analytics::{MinecraftServerPlay, PageView, Playtime}; use crate::models::ids::ProjectId; use crate::models::pats::Scopes; use crate::queue::analytics::AnalyticsQueue; use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::util::date::get_current_tenths_of_ms; use crate::util::error::Context; use crate::util::http::HttpClient; use actix_web::{HttpRequest, HttpResponse}; use actix_web::{post, web}; use eyre::eyre; use serde::Deserialize; use std::collections::HashMap; use std::net::Ipv4Addr; use std::sync::Arc; use tracing::trace; use url::Url; use uuid::Uuid; pub const FILTERED_HEADERS: &[&str] = &[ "authorization", "cookie", "modrinth-admin", // we already retrieve/use these elsewhere- so they are unneeded "user-agent", "cf-connecting-ip", "cf-ipcountry", "x-forwarded-for", "x-real-ip", // We don't need the information vercel provides from its headers "x-vercel-ip-city", "x-vercel-ip-timezone", "x-vercel-ip-longitude", "x-vercel-proxy-signature", "x-vercel-ip-country-region", "x-vercel-forwarded-for", "x-vercel-proxied-for", "x-vercel-proxy-signature-ts", "x-vercel-ip-latitude", "x-vercel-ip-country", ]; pub fn config(cfg: &mut web::ServiceConfig) { cfg.service(page_view_ingest) .service(playtime_ingest) .service(minecraft_server_play_ingest); } #[derive(Deserialize)] pub struct UrlInput { url: String, } //this route should be behind the cloudflare WAF to prevent non-browsers from calling it #[post("view")] async fn page_view_ingest( req: HttpRequest, analytics_queue: web::Data>, session_queue: web::Data, url_input: web::Json, pool: web::Data, redis: web::Data, ) -> Result { let user = get_user_from_headers( &req, &**pool, &redis, &session_queue, Scopes::empty(), ) .await .ok(); let conn_info = req.connection_info().peer_addr().map(|x| x.to_string()); let url = Url::parse(&url_input.url).map_err(|_| { ApiError::InvalidInput("invalid page view URL specified!".to_string()) })?; let domain = url.host_str().ok_or_else(|| { ApiError::InvalidInput("invalid page view URL specified!".to_string()) })?; let url_origin = url.origin().ascii_serialization(); let is_valid_url_origin = ENV .ANALYTICS_ALLOWED_ORIGINS .iter() .any(|origin| origin == "*" || url_origin == *origin); if !is_valid_url_origin { return Err(ApiError::InvalidInput( "invalid page view URL specified!".to_string(), )); } let headers = req .headers() .into_iter() .map(|(key, val)| { ( key.to_string().to_lowercase(), val.to_str().unwrap_or_default().to_string(), ) }) .collect::>(); let ip = crate::util::ip::convert_to_ip_v6( if let Some(header) = headers.get("cf-connecting-ip") { header } else { conn_info.as_deref().unwrap_or_default() }, ) .unwrap_or_else(|_| Ipv4Addr::new(127, 0, 0, 1).to_ipv6_mapped()); let mut view = PageView { recorded: get_current_tenths_of_ms(), domain: domain.to_string(), site_path: url.path().to_string(), user_id: 0, project_id: 0, ip, country: headers.get("cf-ipcountry").cloned().unwrap_or_default(), user_agent: headers.get("user-agent").cloned().unwrap_or_default(), headers: headers .into_iter() .filter(|x| !FILTERED_HEADERS.contains(&&*x.0)) .collect(), monetized: true, }; if let Some(segments) = url.path_segments() { let segments_vec = segments.collect::>(); if segments_vec.len() >= 2 { const PROJECT_TYPES: &[&str] = &[ "mod", "modpack", "plugin", "resourcepack", "shader", "datapack", ]; if PROJECT_TYPES.contains(&segments_vec[0]) { let project = crate::database::models::DBProject::get( segments_vec[1], &**pool, &redis, ) .await?; if let Some(project) = project { view.project_id = project.inner.id.0 as u64; } } } } if let Some((_, user)) = user { view.user_id = user.id.0; } trace!("Ingested page view {view:?}"); analytics_queue.add_view(view); Ok(HttpResponse::NoContent().body("")) } #[derive(Deserialize, Debug)] pub struct PlaytimeInput { seconds: u16, loader: String, game_version: String, parent: Option, } #[post("playtime")] async fn playtime_ingest( req: HttpRequest, analytics_queue: web::Data>, session_queue: web::Data, playtime_input: web::Json< HashMap, >, pool: web::Data, redis: web::Data, ) -> Result { let (_, user) = get_user_from_headers( &req, &**pool, &redis, &session_queue, Scopes::PERFORM_ANALYTICS, ) .await?; let playtimes = playtime_input.0; if playtimes.len() > 2000 { return Err(ApiError::InvalidInput( "Too much playtime entered for version!".to_string(), )); } let versions = crate::database::models::DBVersion::get_many( &playtimes.iter().map(|x| (*x.0).into()).collect::>(), &**pool, &redis, ) .await?; let headers = req.headers(); for (id, playtime) in playtimes { if playtime.seconds > 300 { continue; } if let Some(version) = versions.iter().find(|x| id == x.inner.id.into()) { analytics_queue.add_playtime(Playtime { recorded: get_current_tenths_of_ms(), seconds: playtime.seconds as u64, user_id: user.id.0, project_id: version.inner.project_id.0 as u64, version_id: version.inner.id.0 as u64, loader: playtime.loader, game_version: playtime.game_version, parent: playtime.parent.map_or(0, |x| x.0), country: headers .get("cf-ipcountry") .and_then(|c| c.to_str().map(|s| s.to_string()).ok()) .unwrap_or_default(), }); } } Ok(HttpResponse::NoContent().finish()) } #[derive(Debug, Deserialize)] struct MinecraftProfile { id: Uuid, name: String, } #[derive(Deserialize)] pub struct MinecraftJavaServerPlayInput { project_id: ProjectId, username: Option, server_id: Option, minecraft_uuid: Option, } pub const MINECRAFT_SERVER_PLAYS: &str = "minecraft_server_plays"; #[post("minecraft-server-play")] async fn minecraft_server_play_ingest( req: HttpRequest, analytics_queue: web::Data>, session_queue: web::Data, play_input: web::Json, pool: web::Data, redis: web::Data, http: web::Data, ) -> Result<(), ApiError> { let user = get_user_from_headers( &req, &**pool, &redis, &session_queue, Scopes::empty(), ) .await .map(|(_, user)| user) .ok(); let project_id = play_input.project_id; let project = DBProject::get(&project_id.to_string(), &**pool, &redis) .await? .ok_or(ApiError::NotFound)?; if project.components.minecraft_server.is_none() { return Err(ApiError::Request(eyre!( "not a `minecraft_server` project" ))); } let minecraft_uuid = if let (Some(username), Some(server_id)) = (&play_input.username, &play_input.server_id) { let has_joined = http .get("https://sessionserver.mojang.com/session/minecraft/hasJoined") .query(&[ ("username", username.as_str()), ("serverId", server_id.as_str()), ]) .send() .await .wrap_internal_err("failed to contact Mojang session server")?; if has_joined.status() == reqwest::StatusCode::NO_CONTENT || !has_joined.status().is_success() { return Err(ApiError::Request(eyre!( "Minecraft session verification failed" ))); } let profile = has_joined .json::() .await .wrap_internal_err("invalid Mojang session response")?; if profile.name != *username { return Err(ApiError::Request(eyre!( "returned Mojang profile name does not match username" ))); } profile.id } else { play_input .minecraft_uuid .wrap_request_err("missing `minecraft_uuid`")? }; let conn_info = req.connection_info().peer_addr().map(|x| x.to_string()); let headers = req .headers() .into_iter() .map(|(key, val)| { ( key.to_string().to_lowercase(), val.to_str().unwrap_or_default().to_string(), ) }) .collect::>(); let ip = crate::util::ip::convert_to_ip_v6( if let Some(header) = headers.get("cf-connecting-ip") { header } else { conn_info.as_deref().unwrap_or_default() }, ) .unwrap_or_else(|_| Ipv4Addr::new(127, 0, 0, 1).to_ipv6_mapped()); let row = MinecraftServerPlay { recorded: get_current_tenths_of_ms(), user_id: user.map(|u| u.id.0).unwrap_or(0), project_id: project_id.0, minecraft_uuid, ip, }; analytics_queue.add_minecraft_server_play(row); Ok(()) }