Harden minecraft-server-play analytics (#5484)

* Harden minecraft-server-play analytics

* Verify based on mc token

* Fail for non-server projects

* Nitpicks and factor out HTTP client

* Allow passing old minecraft_uuid field for clients

* Remove server play analytics test since it relies on auth against Minecraft API which I don't want to mock :(

* Switch to using hasJoined for uuid validation

* Fix formatting

* Fix sessionserver status code

* Ensure profile name and queried username matches

* replace some wrap_request_errs with internal errs

* add HTTP client into web::Data

* short timeout on client-side session join query

* further fixes

* sqlx prepare

* fix clippy

---------

Co-authored-by: Creeperkatze <178587183+Creeperkatze@users.noreply.github.com>
Co-authored-by: aecsocket <aecsocket@tutanota.com>
This commit is contained in:
Arthur
2026-03-09 17:26:15 +01:00
committed by GitHub
parent 4a0c610fc5
commit 73abe272d1
18 changed files with 310 additions and 89 deletions

View File

@@ -1,5 +1,6 @@
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};
@@ -9,8 +10,11 @@ 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;
@@ -233,10 +237,18 @@ async fn playtime_ingest(
Ok(HttpResponse::NoContent().finish())
}
#[derive(Debug, Deserialize)]
struct MinecraftProfile {
id: Uuid,
name: String,
}
#[derive(Deserialize)]
pub struct MinecraftJavaServerPlayInput {
project_id: ProjectId,
minecraft_uuid: Uuid,
username: Option<String>,
server_id: Option<String>,
minecraft_uuid: Option<Uuid>,
}
pub const MINECRAFT_SERVER_PLAYS: &str = "minecraft_server_plays";
@@ -249,7 +261,8 @@ async fn minecraft_server_play_ingest(
play_input: web::Json<MinecraftJavaServerPlayInput>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
http: web::Data<HttpClient>,
) -> Result<(), ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
@@ -262,14 +275,86 @@ async fn minecraft_server_play_ingest(
.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::<MinecraftProfile>()
.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::<HashMap<String, String>>();
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: play_input.minecraft_uuid,
minecraft_uuid,
ip,
};
analytics_queue.add_minecraft_server_play(row);
Ok(HttpResponse::NoContent().finish())
Ok(())
}

View File

@@ -1,11 +1,10 @@
use std::{collections::HashMap, fmt::Write, sync::LazyLock, time::Instant};
use std::{collections::HashMap, fmt::Write, time::Instant};
use crate::database::PgPool;
use crate::env::ENV;
use crate::{database::PgPool, util::http::HttpClient};
use actix_web::{HttpRequest, HttpResponse, get, post, web};
use chrono::{DateTime, Utc};
use eyre::eyre;
use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT};
use serde::Deserialize;
use tokio::sync::Mutex;
use tracing::info;
@@ -46,21 +45,6 @@ pub fn config(cfg: &mut web::ServiceConfig) {
);
}
static DELPHI_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
reqwest::Client::builder()
.default_headers({
HeaderMap::from_iter([(
USER_AGENT,
HeaderValue::from_static(concat!(
"Labrinth/",
env!("COMPILATION_DATE")
)),
)])
})
.build()
.unwrap()
});
/// Type of [`DelphiReportIssueDetails::key`].
///
/// Delphi may provide `null` for the key, but we require a key for storing
@@ -367,6 +351,7 @@ async fn ingest_report_deserialized(
pub async fn run(
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
run_parameters: DelphiRunParameters,
http: &reqwest::Client,
) -> Result<HttpResponse, ApiError> {
let file_data = sqlx::query!(
r#"
@@ -389,8 +374,7 @@ pub async fn run(
run_parameters.file_id.0
);
DELPHI_CLIENT
.post(&ENV.DELPHI_URL)
http.post(&ENV.DELPHI_URL)
.json(&serde_json::json!({
"url": file_data.url,
"project_id": ProjectId(file_data.project_id.0 as u64),
@@ -489,6 +473,7 @@ async fn _run(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
run_parameters: web::Query<DelphiRunParameters>,
http: web::Data<HttpClient>,
) -> Result<HttpResponse, ApiError> {
check_is_moderator_from_headers(
&req,
@@ -499,7 +484,7 @@ async fn _run(
)
.await?;
run(&**pool, run_parameters.into_inner()).await
run(&**pool, run_parameters.into_inner(), &http).await
}
#[get("version")]
@@ -531,6 +516,7 @@ async fn issue_type_schema(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
http: web::Data<HttpClient>,
) -> Result<HttpResponse, ApiError> {
check_is_moderator_from_headers(
&req,
@@ -556,8 +542,7 @@ async fn issue_type_schema(
cache_entry => Ok(HttpResponse::Ok().json(
&cache_entry
.insert((
DELPHI_CLIENT
.get(format!("{}/schema", ENV.DELPHI_URL))
http.get(format!("{}/schema", ENV.DELPHI_URL))
.send()
.await
.and_then(|res| res.error_for_status())

View File

@@ -2,11 +2,14 @@ use eyre::{Result, WrapErr, eyre};
use futures::future::try_join_all;
use tracing::info;
use super::{DELPHI_CLIENT, DelphiRunParameters};
use super::DelphiRunParameters;
use crate::{database::PgPool, env::ENV, models::ids::FileId};
pub async fn rescan_projects_in_queue(pool: &PgPool) -> Result<()> {
let delphi_version = fetch_delphi_version().await?;
pub async fn rescan_projects_in_queue(
pool: &PgPool,
http: &reqwest::Client,
) -> Result<()> {
let delphi_version = fetch_delphi_version(http).await?;
let old_delphi_version = fetch_stored_delphi_version(pool).await?;
if old_delphi_version == Some(delphi_version) {
@@ -44,7 +47,7 @@ pub async fn rescan_projects_in_queue(pool: &PgPool) -> Result<()> {
.map(|file_id| FileId(file_id.cast_unsigned()));
try_join_all(file_ids.map(|file_id| async move {
super::run(pool, DelphiRunParameters { file_id })
super::run(pool, DelphiRunParameters { file_id }, http)
.await
.wrap_err_with(|| {
eyre!("failed to submit Delphi rescan for `{file_id:?}`")
@@ -60,8 +63,8 @@ pub async fn rescan_projects_in_queue(pool: &PgPool) -> Result<()> {
Ok(())
}
async fn fetch_delphi_version() -> Result<i32> {
let response = DELPHI_CLIENT
async fn fetch_delphi_version(http: &reqwest::Client) -> Result<i32> {
let response = http
.get(format!("{}/version", ENV.DELPHI_URL))
.send()
.await

View File

@@ -12,6 +12,7 @@ use crate::queue::session::AuthQueue;
use crate::routes::v3::project_creation::default_project_type;
use crate::routes::v3::project_creation::{CreateError, NewGalleryItem};
use crate::routes::{v2_reroute, v3};
use crate::util::http::HttpClient;
use actix_multipart::Multipart;
use actix_web::web::Data;
use actix_web::{HttpRequest, HttpResponse, post};
@@ -141,6 +142,7 @@ pub async fn project_create(
redis: Data<RedisPool>,
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
session_queue: Data<AuthQueue>,
http: Data<HttpClient>,
) -> Result<HttpResponse, CreateError> {
// Convert V2 multipart payload to V3 multipart payload
let payload = v2_reroute::alter_actix_multipart(
@@ -252,6 +254,7 @@ pub async fn project_create(
redis.clone(),
file_host,
session_queue,
http,
)
.await?;

View File

@@ -13,6 +13,7 @@ use crate::queue::session::AuthQueue;
use crate::routes::v3::project_creation::CreateError;
use crate::routes::v3::version_creation;
use crate::routes::{v2_reroute, v3};
use crate::util::http::HttpClient;
use actix_multipart::Multipart;
use actix_web::http::header::ContentDisposition;
use actix_web::web::Data;
@@ -83,6 +84,7 @@ pub async fn version_create(
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
session_queue: Data<AuthQueue>,
moderation_queue: Data<AutomatedModerationQueue>,
http: Data<HttpClient>,
) -> Result<HttpResponse, CreateError> {
let payload = v2_reroute::alter_actix_multipart(
payload,
@@ -237,6 +239,7 @@ pub async fn version_create(
file_host,
session_queue,
moderation_queue,
http,
)
.await?;
@@ -286,6 +289,7 @@ pub async fn upload_file_to_version(
redis: Data<RedisPool>,
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
session_queue: web::Data<AuthQueue>,
http: web::Data<HttpClient>,
) -> Result<HttpResponse, CreateError> {
// Returns NoContent, so no need to convert to V2
let response = v3::version_creation::upload_file_to_version(
@@ -296,6 +300,7 @@ pub async fn upload_file_to_version(
redis.clone(),
file_host,
session_queue,
http,
)
.await?;
Ok(response)

View File

@@ -24,6 +24,7 @@ use crate::models::v3::user_limits::UserLimits;
use crate::queue::session::AuthQueue;
use crate::search::indexing::IndexingError;
use crate::util::guards::admin_key_guard;
use crate::util::http::HttpClient;
use crate::util::img::upload_image_optimized;
use crate::util::routes::read_from_field;
use crate::util::validate::validation_errors_to_string;
@@ -300,6 +301,7 @@ pub async fn project_create(
redis: Data<RedisPool>,
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
session_queue: Data<AuthQueue>,
http: Data<HttpClient>,
) -> Result<HttpResponse, CreateError> {
project_create_internal(
req,
@@ -308,6 +310,7 @@ pub async fn project_create(
redis,
file_host,
session_queue,
http,
)
.await
}
@@ -319,6 +322,7 @@ pub async fn project_create_internal(
redis: Data<RedisPool>,
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
session_queue: Data<AuthQueue>,
http: Data<HttpClient>,
) -> Result<HttpResponse, CreateError> {
let mut transaction = client.begin().await?;
let mut uploaded_files = Vec::new();
@@ -335,6 +339,7 @@ pub async fn project_create_internal(
&client,
&redis,
&session_queue,
&http,
project_id,
)
.await;
@@ -366,6 +371,7 @@ pub async fn project_create_with_id(
redis: Data<RedisPool>,
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
session_queue: Data<AuthQueue>,
http: Data<HttpClient>,
path: web::Path<(ProjectId,)>,
) -> Result<HttpResponse, CreateError> {
let mut transaction = client.begin().await?;
@@ -382,6 +388,7 @@ pub async fn project_create_with_id(
&client,
&redis,
&session_queue,
&http,
project_id,
)
.await;
@@ -443,6 +450,7 @@ async fn project_create_inner(
pool: &PgPool,
redis: &RedisPool,
session_queue: &AuthQueue,
http: &reqwest::Client,
project_id: ProjectId,
) -> Result<HttpResponse, CreateError> {
// The currently logged in user
@@ -907,7 +915,9 @@ async fn project_create_inner(
let now = Utc::now();
let id = project_builder_actual.insert(&mut *transaction).await?;
let id = project_builder_actual
.insert(&mut *transaction, http)
.await?;
DBUser::clear_project_cache(&[current_user.id.into()], redis).await?;
for image_id in project_create_data.uploaded_images {

View File

@@ -29,7 +29,9 @@ use crate::{
},
queue::session::AuthQueue,
routes::ApiError,
util::{error::Context, validate::validation_errors_to_string},
util::{
error::Context, http::HttpClient, validate::validation_errors_to_string,
},
};
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
@@ -116,6 +118,7 @@ pub async fn create(
db: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
http: web::Data<HttpClient>,
web::Json(create): web::Json<ProjectCreate>,
) -> Result<web::Json<ProjectId>, CreateError> {
// check that the user can make a project
@@ -302,13 +305,13 @@ pub async fn create(
};
project_builder
.insert(&mut txn)
.insert(&mut txn, &http)
.await
.wrap_internal_err("failed to insert project")?;
if let Some(version_builder) = version_builder {
version_builder
.insert(&mut txn)
.insert(&mut txn, &http)
.await
.wrap_internal_err("failed to insert initial version")?;
}

View File

@@ -27,6 +27,7 @@ use crate::models::projects::{DependencyType, ProjectStatus, skip_nulls};
use crate::models::teams::ProjectPermissions;
use crate::queue::moderation::AutomatedModerationQueue;
use crate::queue::session::AuthQueue;
use crate::util::http::HttpClient;
use crate::util::routes::read_from_field;
use crate::util::validate::validation_errors_to_string;
use crate::validate::{ValidationResult, validate_file};
@@ -112,6 +113,7 @@ pub async fn version_create(
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
session_queue: Data<AuthQueue>,
moderation_queue: web::Data<AutomatedModerationQueue>,
http: web::Data<HttpClient>,
) -> Result<HttpResponse, CreateError> {
let mut transaction = client.begin().await?;
let mut uploaded_files = Vec::new();
@@ -126,6 +128,7 @@ pub async fn version_create(
&client,
&session_queue,
&moderation_queue,
&http,
)
.await;
@@ -159,6 +162,7 @@ async fn version_create_inner(
pool: &PgPool,
session_queue: &AuthQueue,
moderation_queue: &AutomatedModerationQueue,
http: &reqwest::Client,
) -> Result<HttpResponse, CreateError> {
let mut initial_version_data = None;
let mut version_builder = None;
@@ -480,7 +484,7 @@ async fn version_create_inner(
};
let project_id = builder.project_id;
builder.insert(transaction).await?;
builder.insert(transaction, http).await?;
for image_id in version_data.uploaded_images {
if let Some(db_image) =
@@ -542,6 +546,7 @@ pub async fn upload_file_to_version(
redis: Data<RedisPool>,
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
session_queue: web::Data<AuthQueue>,
http: web::Data<HttpClient>,
) -> Result<HttpResponse, CreateError> {
let mut transaction = client.begin().await?;
let mut uploaded_files = Vec::new();
@@ -558,6 +563,7 @@ pub async fn upload_file_to_version(
&mut uploaded_files,
version_id,
&session_queue,
&http,
)
.await;
@@ -591,6 +597,7 @@ async fn upload_file_to_version_inner(
uploaded_files: &mut Vec<UploadedFile>,
version_id: models::DBVersionId,
session_queue: &AuthQueue,
http: &reqwest::Client,
) -> Result<HttpResponse, CreateError> {
let mut initial_file_data: Option<InitialFileData> = None;
let mut file_builders: Vec<VersionFileBuilder> = Vec::new();
@@ -774,7 +781,7 @@ async fn upload_file_to_version_inner(
));
} else {
for file in file_builders {
file.insert(version_id, &mut *transaction).await?;
file.insert(version_id, &mut *transaction, http).await?;
}
}