From 5ed322d281e1660bd08c2bd6158223277dc093d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-Xavier=20Talbot?= <108630700+fetchfern@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:39:38 -0400 Subject: [PATCH 01/16] feat(frontend): don't assume secure protocol for kyros websocket/fs (#6420) * feat(frontend): don't assume https as websocket/fs protocol * fix(frontend): actually do the same for websocket * fix(frontend): don't strip ws path --- packages/api-client/src/features/node-auth.ts | 3 ++- packages/api-client/src/modules/kyros/files/v0.ts | 3 ++- .../api-client/src/platform/websocket-generic.ts | 3 ++- packages/api-client/src/utils/node-url.ts | 12 ++++++++++++ 4 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 packages/api-client/src/utils/node-url.ts diff --git a/packages/api-client/src/features/node-auth.ts b/packages/api-client/src/features/node-auth.ts index 62424d5d0..c526b68f9 100644 --- a/packages/api-client/src/features/node-auth.ts +++ b/packages/api-client/src/features/node-auth.ts @@ -1,6 +1,7 @@ import { AbstractFeature, type FeatureConfig } from '../core/abstract-feature' import { ModrinthApiError } from '../core/errors' import type { RequestContext } from '../types/request' +import { getNodeBaseUrl } from '../utils/node-url' /** * Node authentication credentials @@ -107,7 +108,7 @@ export class NodeAuthFeature extends AbstractFeature { } private applyAuth(context: RequestContext, auth: NodeAuth): void { - const baseUrl = `https://${auth.url.replace(/\/modrinth\/v\d+\/fs\/?$/, '')}` + const baseUrl = getNodeBaseUrl(auth.url) context.url = this.buildUrl(context.path, baseUrl, context.options.version) context.options.headers = { diff --git a/packages/api-client/src/modules/kyros/files/v0.ts b/packages/api-client/src/modules/kyros/files/v0.ts index 693b89a81..b027294e3 100644 --- a/packages/api-client/src/modules/kyros/files/v0.ts +++ b/packages/api-client/src/modules/kyros/files/v0.ts @@ -1,5 +1,6 @@ import { AbstractModule } from '../../../core/abstract-module' import type { UploadHandle, UploadProgress } from '../../../types/upload' +import { getNodeBaseUrl } from '../../../utils/node-url' import type { Archon } from '../../archon/types' import type { Kyros } from '../types' @@ -11,7 +12,7 @@ export class KyrosFilesV0Module extends AbstractModule { } private getNodeBaseUrl(auth: NodeFsAuth): string { - return `https://${auth.url.replace(/\/modrinth\/v\d+\/fs\/?$/, '')}` + return getNodeBaseUrl(auth.url) } /** diff --git a/packages/api-client/src/platform/websocket-generic.ts b/packages/api-client/src/platform/websocket-generic.ts index 53ce9a6e1..19aa098f4 100644 --- a/packages/api-client/src/platform/websocket-generic.ts +++ b/packages/api-client/src/platform/websocket-generic.ts @@ -2,6 +2,7 @@ import mitt from 'mitt' import { AbstractWebSocketClient, type WebSocketConnection } from '../core/abstract-websocket' import type { Archon } from '../modules/archon/types' +import { getNodeWebSocketUrl } from '../utils/node-url' type WSEventMap = { [K in Archon.Websocket.v0.WSEvent as `${string}:${K['event']}`]: K @@ -19,7 +20,7 @@ export class GenericWebSocketClient extends AbstractWebSocketClient { return new Promise((resolve, reject) => { try { - const ws = new WebSocket(`wss://${auth.url}`) + const ws = new WebSocket(getNodeWebSocketUrl(auth.url)) const connection: WebSocketConnection = { serverId, diff --git a/packages/api-client/src/utils/node-url.ts b/packages/api-client/src/utils/node-url.ts new file mode 100644 index 000000000..834250e4a --- /dev/null +++ b/packages/api-client/src/utils/node-url.ts @@ -0,0 +1,12 @@ +const NODE_FS_PATH_REGEX = /\/modrinth\/v\d+\/fs\/?$/ +const HTTP_SCHEME_REGEX = /^https?:\/\//i +const WS_SCHEME_REGEX = /^wss?:\/\//i + +export function getNodeBaseUrl(url: string): string { + const baseUrl = url.replace(NODE_FS_PATH_REGEX, '') + return HTTP_SCHEME_REGEX.test(baseUrl) ? baseUrl : `https://${baseUrl}` +} + +export function getNodeWebSocketUrl(url: string): string { + return WS_SCHEME_REGEX.test(url) ? url : `wss://${url}` +} From 2bb1ef775c02d1168c3d6bb91fc3049a0bb5d0b3 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Wed, 17 Jun 2026 15:58:07 +0100 Subject: [PATCH 02/16] Serve stale Tiltify data if API is not available (#6427) --- apps/labrinth/src/routes/internal/campaign.rs | 146 +++++++++++------- 1 file changed, 89 insertions(+), 57 deletions(-) diff --git a/apps/labrinth/src/routes/internal/campaign.rs b/apps/labrinth/src/routes/internal/campaign.rs index 34a6e9fe7..88f22b548 100644 --- a/apps/labrinth/src/routes/internal/campaign.rs +++ b/apps/labrinth/src/routes/internal/campaign.rs @@ -60,15 +60,24 @@ struct TiltifyMeta { subscription_source_type: String, } -#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct CampaignInfo { total_donations_usd: Decimal, target_usd: Decimal, num_donators: usize, + cached_at: DateTime, } const CAMPAIGN_INFO_CACHE_NAMESPACE: &str = "campaign_info"; -const CAMPAIGN_INFO_CACHE_TTL_SECONDS: i64 = 15 * 60; +const CAMPAIGN_INFO_CACHE_STALE_SECONDS: i64 = 15 * 60; +const CAMPAIGN_INFO_CACHE_TTL_SECONDS: i64 = 24 * 60 * 60; + +impl CampaignInfo { + fn is_stale(&self) -> bool { + Utc::now().signed_duration_since(self.cached_at) + >= Duration::seconds(CAMPAIGN_INFO_CACHE_STALE_SECONDS) + } +} #[derive(Debug, Deserialize)] struct TiltifyCampaignResponse { @@ -305,66 +314,89 @@ pub async fn pride_26( .await .wrap_internal_err("connecting to redis")?; - if let Some(cached) = redis_connection - .get(CAMPAIGN_INFO_CACHE_NAMESPACE, campaign_id) - .await - .wrap_internal_err("getting cached campaign info")? - { - let campaign_info = serde_json::from_str::(&cached) - .wrap_internal_err("parsing cached campaign info")?; - return Ok(web::Json(campaign_info)); - } - - let access_token = tiltify - .access_token() - .await - .wrap_internal_err("fetching Tiltify access token")?; - let url = format!( - "https://v5api.tiltify.com/api/public/team_campaigns/{campaign_id}", - ); - let response = http - .get(url) - .bearer_auth(&access_token) - .send() - .await - .wrap_internal_err("fetching campaign from Tiltify")? - .error_for_status() - .wrap_internal_err("fetching campaign from Tiltify")? - .json::() - .await - .wrap_internal_err("parsing Tiltify response")?; - - let raised_currency = &response.data.total_amount_raised.currency; - if raised_currency != "USD" { - return Err(ApiError::Internal(eyre!( - "total amount raised is in {raised_currency}, must be USD" - ))); - } - - let goal_currency = &response.data.goal.currency; - if goal_currency != "USD" { - return Err(ApiError::Internal(eyre!( - "goal amount is in {goal_currency}, must be USD" - ))); - } - - let campaign_info = CampaignInfo { - total_donations_usd: response.data.total_amount_raised.value, - target_usd: response.data.goal.value, - num_donators: num_donators(&http, &access_token, campaign_id).await?, - }; - - redis_connection - .set_serialized_to_json( + let cached = redis_connection + .get_deserialized_from_json::( CAMPAIGN_INFO_CACHE_NAMESPACE, campaign_id, - &campaign_info, - Some(CAMPAIGN_INFO_CACHE_TTL_SECONDS), ) .await - .wrap_internal_err("caching campaign info")?; + .wrap_internal_err("getting cached campaign info")?; - Ok(web::Json(campaign_info)) + if let Some(cached) = &cached + && !cached.is_stale() + { + return Ok(web::Json(cached.clone())); + } + + let result = async { + let access_token = tiltify + .access_token() + .await + .wrap_internal_err("fetching Tiltify access token")?; + let url = format!( + "https://v5api.tiltify.com/api/public/team_campaigns/{campaign_id}", + ); + let response = http + .get(url) + .bearer_auth(&access_token) + .send() + .await + .wrap_internal_err("fetching campaign from Tiltify")? + .error_for_status() + .wrap_internal_err("fetching campaign from Tiltify")? + .json::() + .await + .wrap_internal_err("parsing Tiltify response")?; + + let raised_currency = &response.data.total_amount_raised.currency; + if raised_currency != "USD" { + return Err(ApiError::Internal(eyre!( + "total amount raised is in {raised_currency}, must be USD" + ))); + } + + let goal_currency = &response.data.goal.currency; + if goal_currency != "USD" { + return Err(ApiError::Internal(eyre!( + "goal amount is in {goal_currency}, must be USD" + ))); + } + + let campaign_info = CampaignInfo { + total_donations_usd: response.data.total_amount_raised.value, + target_usd: response.data.goal.value, + num_donators: num_donators(&http, &access_token, campaign_id) + .await?, + cached_at: Utc::now(), + }; + + redis_connection + .set_serialized_to_json( + CAMPAIGN_INFO_CACHE_NAMESPACE, + campaign_id, + &campaign_info, + Some(CAMPAIGN_INFO_CACHE_TTL_SECONDS), + ) + .await + .wrap_internal_err("caching campaign info")?; + + Ok(campaign_info) + } + .await; + + match result { + Ok(campaign_info) => Ok(web::Json(campaign_info)), + Err(error) => { + if let Some(cached) = cached { + warn!( + "Failed to refresh campaign info from Tiltify: {error:?}" + ); + Ok(web::Json(cached)) + } else { + Err(error) + } + } + } } async fn num_donators( From 336050f4dffae0deb68ef49fce162a74bb066896 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Wed, 17 Jun 2026 20:24:44 +0100 Subject: [PATCH 03/16] Clear owned projects cache when deleting an org with projects (#6429) --- ...24ee0fb3c8b499b8f794c151e0c655b8f9365.json | 28 +++++++++++++++++++ ...3feb0a3eb432ed2b9d3be0e7a0bc6b2421cdd.json | 22 --------------- apps/labrinth/src/routes/v3/organizations.rs | 26 +++++++++++++++-- 3 files changed, 51 insertions(+), 25 deletions(-) create mode 100644 apps/labrinth/.sqlx/query-98e3a6418947b037d45bf3b9b1b24ee0fb3c8b499b8f794c151e0c655b8f9365.json delete mode 100644 apps/labrinth/.sqlx/query-d3c5adda017df70a88983baa82e3feb0a3eb432ed2b9d3be0e7a0bc6b2421cdd.json diff --git a/apps/labrinth/.sqlx/query-98e3a6418947b037d45bf3b9b1b24ee0fb3c8b499b8f794c151e0c655b8f9365.json b/apps/labrinth/.sqlx/query-98e3a6418947b037d45bf3b9b1b24ee0fb3c8b499b8f794c151e0c655b8f9365.json new file mode 100644 index 000000000..8a373f7c3 --- /dev/null +++ b/apps/labrinth/.sqlx/query-98e3a6418947b037d45bf3b9b1b24ee0fb3c8b499b8f794c151e0c655b8f9365.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT t.id team_id, m.id project_id FROM organizations o\n INNER JOIN mods m ON m.organization_id = o.id\n INNER JOIN teams t ON t.id = m.team_id\n WHERE o.id = $1 AND $1 IS NOT NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "team_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "project_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "98e3a6418947b037d45bf3b9b1b24ee0fb3c8b499b8f794c151e0c655b8f9365" +} diff --git a/apps/labrinth/.sqlx/query-d3c5adda017df70a88983baa82e3feb0a3eb432ed2b9d3be0e7a0bc6b2421cdd.json b/apps/labrinth/.sqlx/query-d3c5adda017df70a88983baa82e3feb0a3eb432ed2b9d3be0e7a0bc6b2421cdd.json deleted file mode 100644 index dd6dbd78a..000000000 --- a/apps/labrinth/.sqlx/query-d3c5adda017df70a88983baa82e3feb0a3eb432ed2b9d3be0e7a0bc6b2421cdd.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT t.id FROM organizations o\n INNER JOIN mods m ON m.organization_id = o.id\n INNER JOIN teams t ON t.id = m.team_id\n WHERE o.id = $1 AND $1 IS NOT NULL\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - false - ] - }, - "hash": "d3c5adda017df70a88983baa82e3feb0a3eb432ed2b9d3be0e7a0bc6b2421cdd" -} diff --git a/apps/labrinth/src/routes/v3/organizations.rs b/apps/labrinth/src/routes/v3/organizations.rs index 838f47c9a..aba34ca27 100644 --- a/apps/labrinth/src/routes/v3/organizations.rs +++ b/apps/labrinth/src/routes/v3/organizations.rs @@ -732,9 +732,9 @@ pub async fn organization_delete( // Handle projects- every project that is in this organization needs to have its owner changed the organization owner // Now, no project should have an owner if it is in an organization, and also // the owner of an organization should not be a team member in any project - let organization_project_teams = sqlx::query!( + let organization_projects = sqlx::query!( " - SELECT t.id FROM organizations o + SELECT t.id team_id, m.id project_id FROM organizations o INNER JOIN mods m ON m.organization_id = o.id INNER JOIN teams t ON t.id = m.team_id WHERE o.id = $1 AND $1 IS NOT NULL @@ -742,9 +742,22 @@ pub async fn organization_delete( organization.id as database::models::ids::DBOrganizationId ) .fetch(&mut transaction) - .map_ok(|c| database::models::DBTeamId(c.id)) + .map_ok(|c| { + ( + database::models::DBTeamId(c.team_id), + database::models::DBProjectId(c.project_id), + ) + }) .try_collect::>() .await?; + let organization_project_teams = organization_projects + .iter() + .map(|(team_id, _)| *team_id) + .collect::>(); + let organization_project_ids = organization_projects + .iter() + .map(|(_, project_id)| *project_id) + .collect::>(); for organization_project_team in &organization_project_teams { let new_id = crate::database::models::ids::generate_team_member_id( @@ -786,6 +799,13 @@ pub async fn organization_delete( database::models::DBTeamMember::clear_cache(*team_id, &redis).await?; } + for project_id in organization_project_ids { + database::models::DBProject::clear_cache( + project_id, None, None, &redis, + ) + .await?; + } + if !organization_project_teams.is_empty() { database::models::DBUser::clear_project_cache(&[owner_id], &redis) .await?; From 486b467af2906801d0171ee767722cbae7b26aaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-Xavier=20Talbot?= <108630700+fetchfern@users.noreply.github.com> Date: Wed, 17 Jun 2026 18:54:09 -0400 Subject: [PATCH 04/16] cont: fix(frontend): dont assume wss for panel pingtest as well (#6421) * fix(frontend): dont assume wss for panel pingtest as well * fix(frontend): wss assumptions * chore: fix export * chore: prettier --- packages/api-client/src/index.ts | 1 + packages/api-client/src/utils/node-url.ts | 8 +++++++- .../ui/src/layouts/wrapped/hosting/manage/root.vue | 14 ++++++++++---- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index e675904fc..091c1a05c 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -43,6 +43,7 @@ export { XHRUploadClient } from './platform/xhr-upload-client' export { clearNodeAuthState, nodeAuthState, setNodeAuthState } from './state/node-auth' export * from './types' export { withJWTRetry } from './utils/jwt-retry' +export { getNodeWebSocketUrl } from './utils/node-url' export { type ParsedSseEvent, type ParsedSseItem, diff --git a/packages/api-client/src/utils/node-url.ts b/packages/api-client/src/utils/node-url.ts index 834250e4a..2bea1274b 100644 --- a/packages/api-client/src/utils/node-url.ts +++ b/packages/api-client/src/utils/node-url.ts @@ -1,6 +1,8 @@ const NODE_FS_PATH_REGEX = /\/modrinth\/v\d+\/fs\/?$/ const HTTP_SCHEME_REGEX = /^https?:\/\//i const WS_SCHEME_REGEX = /^wss?:\/\//i +const HTTP_SECURE_SCHEME_REGEX = /^https:\/\//i +const HTTP_INSECURE_SCHEME_REGEX = /^http:\/\//i export function getNodeBaseUrl(url: string): string { const baseUrl = url.replace(NODE_FS_PATH_REGEX, '') @@ -8,5 +10,9 @@ export function getNodeBaseUrl(url: string): string { } export function getNodeWebSocketUrl(url: string): string { - return WS_SCHEME_REGEX.test(url) ? url : `wss://${url}` + if (WS_SCHEME_REGEX.test(url)) return url + if (HTTP_SECURE_SCHEME_REGEX.test(url)) return url.replace(HTTP_SECURE_SCHEME_REGEX, 'wss://') + if (HTTP_INSECURE_SCHEME_REGEX.test(url)) return url.replace(HTTP_INSECURE_SCHEME_REGEX, 'ws://') + + return `wss://${url}` } diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/root.vue b/packages/ui/src/layouts/wrapped/hosting/manage/root.vue index 281d2f90e..92f908ea6 100644 --- a/packages/ui/src/layouts/wrapped/hosting/manage/root.vue +++ b/packages/ui/src/layouts/wrapped/hosting/manage/root.vue @@ -344,7 +344,7 @@ diff --git a/apps/frontend/src/utils/slugs.ts b/apps/frontend/src/utils/slugs.ts new file mode 100644 index 000000000..3f123d1fe --- /dev/null +++ b/apps/frontend/src/utils/slugs.ts @@ -0,0 +1,10 @@ +const PROJECT_SLUG_UNSAFE_CHARS = /[^a-zA-Z0-9._-]/g + +export function generateUrlSlug(value: string) { + return value + .trim() + .toLowerCase() + .replaceAll(' ', '-') + .replaceAll(PROJECT_SLUG_UNSAFE_CHARS, '') + .replaceAll(/--+/gm, '-') +} diff --git a/apps/labrinth/src/routes/v2/users.rs b/apps/labrinth/src/routes/v2/users.rs index 79f5bc9e6..3d965598b 100644 --- a/apps/labrinth/src/routes/v2/users.rs +++ b/apps/labrinth/src/routes/v2/users.rs @@ -184,14 +184,14 @@ pub async fn projects_list( #[derive(Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct EditUser { - #[validate(length(min = 1, max = 39), regex(path = *crate::util::validate::RE_USERNAME))] + #[validate(length(min = 1, max = 39), regex(path = *crate::util::validate::RE_URL_SAFE))] pub username: Option, #[serde( default, skip_serializing_if = "Option::is_none", with = "::serde_with::rust::double_option" )] - #[validate(length(min = 1, max = 64), regex(path = *crate::util::validate::RE_USERNAME))] + #[validate(length(min = 1, max = 64), regex(path = *crate::util::validate::RE_URL_SAFE))] pub name: Option>, #[serde( default, diff --git a/apps/labrinth/src/routes/v2/version_creation.rs b/apps/labrinth/src/routes/v2/version_creation.rs index 3a21183ba..b3e0337f7 100644 --- a/apps/labrinth/src/routes/v2/version_creation.rs +++ b/apps/labrinth/src/routes/v2/version_creation.rs @@ -36,7 +36,7 @@ pub struct InitialVersionData { pub file_parts: Vec, #[validate( length(min = 1, max = 32), - regex(path = *crate::util::validate::RE_URL_SAFE) + regex(path = *crate::util::validate::RE_URL_SAFE_RELAXED) )] pub version_number: String, #[validate( diff --git a/apps/labrinth/src/routes/v2/versions.rs b/apps/labrinth/src/routes/v2/versions.rs index c7f2ffc3a..438c33175 100644 --- a/apps/labrinth/src/routes/v2/versions.rs +++ b/apps/labrinth/src/routes/v2/versions.rs @@ -314,7 +314,7 @@ pub struct EditVersion { pub name: Option, #[validate( length(min = 1, max = 32), - regex(path = *crate::util::validate::RE_URL_SAFE) + regex(path = *crate::util::validate::RE_URL_SAFE_RELAXED) )] pub version_number: Option, #[validate(length(max = 65536))] diff --git a/apps/labrinth/src/routes/v3/project_creation/new.rs b/apps/labrinth/src/routes/v3/project_creation/new.rs index cbfd33971..d996a25a4 100644 --- a/apps/labrinth/src/routes/v3/project_creation/new.rs +++ b/apps/labrinth/src/routes/v3/project_creation/new.rs @@ -101,6 +101,7 @@ impl ResponseError for CreateError { #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct ProjectCreate { + #[validate(nested)] pub base: exp::base::Project, #[serde(flatten)] #[validate(nested)] @@ -338,3 +339,59 @@ pub async fn create( Ok(web::Json(project_id)) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::projects::ProjectStatus; + + fn project_create_with_slug(slug: &str) -> ProjectCreate { + ProjectCreate { + base: exp::base::Project { + name: "test project".into(), + slug: slug.into(), + summary: "test summary".into(), + description: String::new(), + requested_status: ProjectStatus::Approved, + organization_id: None, + }, + components: exp::ProjectEdit { + minecraft_mod: None, + minecraft_server: None, + minecraft_java_server: None, + minecraft_bedrock_server: None, + }, + } + } + + fn assert_project_slug_validation(slug: &str, expected_valid: bool) { + let result = project_create_with_slug(slug).validate(); + + assert_eq!( + result.is_ok(), + expected_valid, + "unexpected validation result for slug `{slug}`" + ); + } + + #[test] + fn project_create_accepts_url_safe_base_slugs() { + for slug in ["valid-slug", "valid_slug", "valid.slug", "valid123"] { + assert_project_slug_validation(slug, true); + } + } + + #[test] + fn project_create_rejects_unsafe_base_slugs() { + for slug in [ + "invalid/slug", + "../invalid", + r#"invalid"slug"#, + "invalid$slug", + "invalid slug", + "invalid#slug", + ] { + assert_project_slug_validation(slug, false); + } + } +} diff --git a/apps/labrinth/src/routes/v3/users.rs b/apps/labrinth/src/routes/v3/users.rs index b33457e7d..1d517126b 100644 --- a/apps/labrinth/src/routes/v3/users.rs +++ b/apps/labrinth/src/routes/v3/users.rs @@ -642,7 +642,7 @@ pub async fn orgs_list( #[derive(Serialize, Deserialize, Validate)] pub struct EditUser { - #[validate(length(min = 1, max = 39), regex(path = *crate::util::validate::RE_USERNAME))] + #[validate(length(min = 1, max = 39), regex(path = *crate::util::validate::RE_URL_SAFE))] pub username: Option, #[serde( default, diff --git a/apps/labrinth/src/routes/v3/version_creation.rs b/apps/labrinth/src/routes/v3/version_creation.rs index 58d007ff6..26e335639 100644 --- a/apps/labrinth/src/routes/v3/version_creation.rs +++ b/apps/labrinth/src/routes/v3/version_creation.rs @@ -56,7 +56,7 @@ pub struct InitialVersionData { pub file_parts: Vec, #[validate( length(min = 1, max = 32), - regex(path = *crate::util::validate::RE_URL_SAFE) + regex(path = *crate::util::validate::RE_URL_SAFE_RELAXED) )] pub version_number: String, #[validate( diff --git a/apps/labrinth/src/routes/v3/versions.rs b/apps/labrinth/src/routes/v3/versions.rs index 4039be60f..5011d346a 100644 --- a/apps/labrinth/src/routes/v3/versions.rs +++ b/apps/labrinth/src/routes/v3/versions.rs @@ -222,7 +222,7 @@ pub struct EditVersion { pub name: Option, #[validate( length(min = 1, max = 32), - regex(path = *crate::util::validate::RE_URL_SAFE) + regex(path = *crate::util::validate::RE_URL_SAFE_RELAXED) )] pub version_number: Option, #[validate(length(max = 65536))] diff --git a/apps/labrinth/src/util/validate.rs b/apps/labrinth/src/util/validate.rs index 06af10317..0d4c796bd 100644 --- a/apps/labrinth/src/util/validate.rs +++ b/apps/labrinth/src/util/validate.rs @@ -7,9 +7,12 @@ use validator::{ValidationErrors, ValidationErrorsKind}; use crate::models::pats::Scopes; pub static RE_URL_SAFE: LazyLock = - LazyLock::new(|| Regex::new(r#"^[a-zA-Z0-9!@$()`.+,_"-]*$"#).unwrap()); -pub static RE_USERNAME: LazyLock = - LazyLock::new(|| Regex::new(r#"^[a-zA-Z0-9_-]*$"#).unwrap()); + LazyLock::new(|| Regex::new(r#"^[a-zA-Z0-9._-]+$"#).unwrap()); + +// only used for versions +// TODO: percent-encode version names in URLs instead of treating them as slugs +pub static RE_URL_SAFE_RELAXED: LazyLock = + LazyLock::new(|| Regex::new(r#"^[a-zA-Z0-9!@$()`.+,_"-]+$"#).unwrap()); //TODO: In order to ensure readability, only the first error is printed, this may need to be expanded on in the future! pub fn validation_errors_to_string( @@ -159,4 +162,70 @@ mod tests { let result = validate_name(" "); assert!(result.is_err()); } + + fn assert_url_safe_regex( + regex: &LazyLock, + value: &str, + expected_valid: bool, + ) { + assert_eq!( + regex.is_match(value), + expected_valid, + "unexpected URL-safe validation result for `{value}`" + ); + } + + fn assert_url_safe_slug(slug: &str, expected_valid: bool) { + assert_url_safe_regex(&RE_URL_SAFE, slug, expected_valid); + } + + fn assert_url_safe_version(version: &str, expected_valid: bool) { + assert_url_safe_regex(&RE_URL_SAFE_RELAXED, version, expected_valid); + } + + #[test] + fn url_safe_regex_accepts_allowed_slug_punctuation() { + for slug in ["valid-slug", "valid_slug", "valid.slug", "valid123"] { + assert_url_safe_slug(slug, true); + } + } + + #[test] + fn url_safe_regex_rejects_unsafe_slug_punctuation() { + for slug in [ + "invalid/slug", + "../invalid", + r#"invalid"slug"#, + "invalid$slug", + "invalid slug", + "invalid#slug", + ] { + assert_url_safe_slug(slug, false); + } + } + + #[test] + fn url_safe_relaxed_regex_accepts_legacy_version_punctuation() { + for version in [ + "1.0.0", + "1.0.0+build", + "version$beta", + r#"version"quoted"#, + "version!@$()`.+,_-", + ] { + assert_url_safe_version(version, true); + } + } + + #[test] + fn url_safe_relaxed_regex_rejects_non_version_safe_punctuation() { + for version in [ + "invalid/version", + "../invalid", + "invalid space", + "invalid#version", + ] { + assert_url_safe_version(version, false); + } + } } From d2b85c9f8ee10870d4837ac2379b5e00580874b1 Mon Sep 17 00:00:00 2001 From: "Calum H." Date: Fri, 19 Jun 2026 15:16:51 +0100 Subject: [PATCH 07/16] fix: pre-migration for skins drag and drop change (#6402) --- ...a61cff39b759f832b89e6491d29dc51c18714.json | 20 ++++++++++++++++ packages/app-lib/src/state/db.rs | 23 +++++++++++++++---- 2 files changed, 39 insertions(+), 4 deletions(-) create mode 100644 packages/app-lib/.sqlx/query-01d62695f1845e5258d7e39fd80a61cff39b759f832b89e6491d29dc51c18714.json diff --git a/packages/app-lib/.sqlx/query-01d62695f1845e5258d7e39fd80a61cff39b759f832b89e6491d29dc51c18714.json b/packages/app-lib/.sqlx/query-01d62695f1845e5258d7e39fd80a61cff39b759f832b89e6491d29dc51c18714.json new file mode 100644 index 000000000..86910c4a5 --- /dev/null +++ b/packages/app-lib/.sqlx/query-01d62695f1845e5258d7e39fd80a61cff39b759f832b89e6491d29dc51c18714.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT COUNT(*) AS \"count!: i64\" FROM sqlite_master WHERE type = 'table' AND name IN ('custom_minecraft_skins', 'minecraft_users')", + "describe": { + "columns": [ + { + "name": "count!: i64", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false + ] + }, + "hash": "01d62695f1845e5258d7e39fd80a61cff39b759f832b89e6491d29dc51c18714" +} diff --git a/packages/app-lib/src/state/db.rs b/packages/app-lib/src/state/db.rs index 7b5479357..60459c3d9 100644 --- a/packages/app-lib/src/state/db.rs +++ b/packages/app-lib/src/state/db.rs @@ -31,6 +31,12 @@ pub(crate) async fn connect( .connect_with(conn_options) .await?; + if let Err(err) = stale_data_cleanup(&pool).await { + tracing::warn!( + "Failed to clean up stale data from state database before migrations: {err}" + ); + } + sqlx::migrate!().run(&pool).await?; if let Err(err) = stale_data_cleanup(&pool).await { @@ -48,11 +54,20 @@ pub(crate) async fn connect( async fn stale_data_cleanup(pool: &Pool) -> crate::Result<()> { let mut tx = pool.begin().await?; - sqlx::query!( - "DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid NOT IN (SELECT uuid FROM minecraft_users)" + let has_skin_tables = sqlx::query!( + "SELECT COUNT(*) AS \"count!: i64\" FROM sqlite_master WHERE type = 'table' AND name IN ('custom_minecraft_skins', 'minecraft_users')", ) - .execute(&mut *tx) - .await?; + .fetch_one(&mut *tx) + .await? + .count == 2; + + if has_skin_tables { + sqlx::query!( + "DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid NOT IN (SELECT uuid FROM minecraft_users)" + ) + .execute(&mut *tx) + .await?; + } tx.commit().await?; From e0ea14226efc6f5baf4290c7b4764a6f530d489e Mon Sep 17 00:00:00 2001 From: Truman Gao <106889354+tdgao@users.noreply.github.com> Date: Fri, 19 Jun 2026 08:31:47 -0600 Subject: [PATCH 08/16] feat: add dawn launcher user agent (#6441) * feat: add dawn launcher user agent * rename to Dawn --- .../src/routes/v3/analytics_get/metrics/project_downloads.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/labrinth/src/routes/v3/analytics_get/metrics/project_downloads.rs b/apps/labrinth/src/routes/v3/analytics_get/metrics/project_downloads.rs index 859521e6b..2b47062df 100644 --- a/apps/labrinth/src/routes/v3/analytics_get/metrics/project_downloads.rs +++ b/apps/labrinth/src/routes/v3/analytics_get/metrics/project_downloads.rs @@ -581,6 +581,7 @@ static DOWNLOAD_SOURCE_PATTERNS: LazyLock> = (r"nothub/mrpack-install", P::Named("mrpack-install")), (r"^(packwiz-installer|packwiz/)", P::Named("Packwiz")), (r"^mrpack4server", P::Named("mrpack4server")), + (r"^DawnLauncher/", P::Named("Dawn")), ( r"^(Mozilla/|Chrome/|Chromium/|Firefox/|Safari/|AppleWebKit/|Edg/|OPR/)", P::Website, From 50b2b9567c267f564cb8af62a54a2137408935ab Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:50:02 +0100 Subject: [PATCH 09/16] Fix token separators in game version search (#6435) * Fix token separators in game version search * fix --- apps/labrinth/AGENTS.md | 3 ++ .../src/search/backend/typesense/mod.rs | 33 ++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/apps/labrinth/AGENTS.md b/apps/labrinth/AGENTS.md index 65b5575c6..8cd2edd6c 100644 --- a/apps/labrinth/AGENTS.md +++ b/apps/labrinth/AGENTS.md @@ -26,3 +26,6 @@ - If some steps require you to create a project/mod or version for testing, ask the user to go into the web frontend and manually create a project/version - When using `sqlx::query` etc. always use the macro form like `sqlx::query!` or `sqlx::query_scalar!` - never the plain function form. Avoid using `query_as!`. - Do not run `cargo test`, even for a single specific test, unless explicitly prompted to by the user, since it takes a long time to run. +- You can force a search reindex by: + - Running `cd apps/labrinth && cargo run -p labrinth -- --run-background-task index-search` (prefer this if backend is running locally) + - Hitting the force reindex admin endpoint diff --git a/apps/labrinth/src/search/backend/typesense/mod.rs b/apps/labrinth/src/search/backend/typesense/mod.rs index 750458714..20122c7f9 100644 --- a/apps/labrinth/src/search/backend/typesense/mod.rs +++ b/apps/labrinth/src/search/backend/typesense/mod.rs @@ -359,6 +359,7 @@ pub struct TypesenseFieldSpec { pub facet: bool, pub sort: bool, pub optional: bool, + pub token_separators: Option<&'static [&'static str]>, } impl SearchField { @@ -370,6 +371,7 @@ impl SearchField { facet: true, sort: false, optional: true, + token_separators: None, }, SearchField::Name => TypesenseFieldSpec { path: "name", @@ -377,6 +379,7 @@ impl SearchField { facet: true, sort: false, optional: false, + token_separators: None, }, SearchField::Author => TypesenseFieldSpec { path: "author", @@ -384,6 +387,7 @@ impl SearchField { facet: true, sort: false, optional: false, + token_separators: None, }, SearchField::License => TypesenseFieldSpec { path: "license", @@ -391,6 +395,7 @@ impl SearchField { facet: true, sort: false, optional: true, + token_separators: None, }, SearchField::ProjectTypes => TypesenseFieldSpec { path: "project_types", @@ -398,6 +403,7 @@ impl SearchField { facet: true, sort: false, optional: true, + token_separators: None, }, SearchField::ProjectId => TypesenseFieldSpec { path: "project_id", @@ -405,6 +411,7 @@ impl SearchField { facet: true, sort: false, optional: false, + token_separators: None, }, SearchField::OpenSource => TypesenseFieldSpec { path: "open_source", @@ -412,6 +419,7 @@ impl SearchField { facet: true, sort: false, optional: true, + token_separators: None, }, SearchField::Environment => TypesenseFieldSpec { path: "environment", @@ -419,6 +427,7 @@ impl SearchField { facet: true, sort: false, optional: true, + token_separators: None, }, SearchField::GameVersions => TypesenseFieldSpec { path: "game_versions", @@ -426,6 +435,7 @@ impl SearchField { facet: true, sort: false, optional: true, + token_separators: Some(&["-", "."]), }, SearchField::ClientSide => TypesenseFieldSpec { path: "client_side", @@ -433,6 +443,7 @@ impl SearchField { facet: true, sort: false, optional: true, + token_separators: None, }, SearchField::ServerSide => TypesenseFieldSpec { path: "server_side", @@ -440,6 +451,7 @@ impl SearchField { facet: true, sort: false, optional: true, + token_separators: None, }, SearchField::MinecraftServerRegion => TypesenseFieldSpec { path: "minecraft_server.region", @@ -447,6 +459,7 @@ impl SearchField { facet: true, sort: false, optional: true, + token_separators: None, }, SearchField::MinecraftServerLanguages => TypesenseFieldSpec { path: "minecraft_server.languages", @@ -454,6 +467,7 @@ impl SearchField { facet: true, sort: false, optional: true, + token_separators: None, }, SearchField::MinecraftJavaServerContentKind => TypesenseFieldSpec { path: "minecraft_java_server.content.kind", @@ -461,6 +475,7 @@ impl SearchField { facet: true, sort: false, optional: true, + token_separators: None, }, SearchField::MinecraftJavaServerContentSupportedGameVersions => { TypesenseFieldSpec { @@ -469,6 +484,7 @@ impl SearchField { facet: true, sort: false, optional: true, + token_separators: Some(&["-", "."]), } } SearchField::MinecraftJavaServerPingData => TypesenseFieldSpec { @@ -477,6 +493,7 @@ impl SearchField { facet: true, sort: false, optional: true, + token_separators: None, }, SearchField::DependencyProjectIds => TypesenseFieldSpec { path: "dependency_project_ids", @@ -484,6 +501,7 @@ impl SearchField { facet: true, sort: false, optional: true, + token_separators: None, }, SearchField::CompatibleDependencyProjectIds => TypesenseFieldSpec { path: "compatible_dependency_project_ids", @@ -491,6 +509,7 @@ impl SearchField { facet: true, sort: false, optional: true, + token_separators: None, }, } } @@ -515,6 +534,19 @@ static TYPESENSE_SEARCH_FIELDS: LazyLock> = LazyLock::new(|| { if spec.optional { obj.insert("optional".to_string(), Value::Bool(true)); } + if let Some(token_separators) = spec.token_separators { + obj.insert( + "token_separators".to_string(), + Value::Array( + token_separators + .iter() + .map(|separator| { + Value::String((*separator).to_string()) + }) + .collect(), + ), + ); + } Value::Object(obj) }) .collect() @@ -547,7 +579,6 @@ impl Typesense { json!({ "name": name, "enable_nested_fields": true, - "token_separators": ["-"], "fields": fields, "default_sorting_field": "log_downloads" }) From 8e6004fdd5d8393b2d465590b88c9d223be99967 Mon Sep 17 00:00:00 2001 From: "Calum H." Date: Fri, 19 Jun 2026 19:04:25 +0100 Subject: [PATCH 10/16] fix: billing fix for medal servers (#6437) * fix: billing fix for medal servers * fix: lint --- apps/frontend/src/pages/hosting/index.vue | 70 +----------- packages/api-client/src/index.ts | 1 + packages/api-client/src/utils/pingtest.ts | 97 ++++++++++++++++ .../billing/ModrinthServersPurchaseModal.vue | 107 ++++++++++++++++-- .../billing/ServersUpgradeModalWrapper.vue | 79 ------------- .../layouts/wrapped/hosting/manage/index.vue | 104 ----------------- 6 files changed, 195 insertions(+), 263 deletions(-) create mode 100644 packages/api-client/src/utils/pingtest.ts diff --git a/apps/frontend/src/pages/hosting/index.vue b/apps/frontend/src/pages/hosting/index.vue index 783383f88..f10016177 100644 --- a/apps/frontend/src/pages/hosting/index.vue +++ b/apps/frontend/src/pages/hosting/index.vue @@ -21,7 +21,6 @@ :server-name="`${auth?.user?.username}'s server`" :out-of-stock-url="outOfStockUrl" :fetch-capacity-statuses="fetchCapacityStatuses" - :pings="regionPings" :regions="regions" :refresh-payment-methods="fetchPaymentData" :fetch-stock="fetchStock" @@ -1233,81 +1232,16 @@ const planQuery = async () => { } const regions = ref([]) -const regionPings = ref([]) - -function pingRegions() { +function fetchRegions() { client.archon.servers_v1.getRegions().then((res) => { regions.value = res - regions.value.forEach((region) => { - runPingTest(region) - }) }) } -const PING_COUNT = 20 -const PING_INTERVAL = 200 -const MAX_PING_TIME = 1000 - -const initialIndex = { - 'eu-lim': 31, -} - -function runPingTest(region, index = initialIndex[region.shortcode] ?? 1) { - if (index > (initialIndex[region.shortcode] ?? 1) + 10) { - regionPings.value.push({ - region: region.shortcode, - ping: -1, - }) - return - } - - const wsUrl = `wss://${region.shortcode}${index}.${region.zone}/pingtest` - try { - const socket = new WebSocket(wsUrl) - const pings = [] - - socket.onopen = () => { - for (let i = 0; i < PING_COUNT; i++) { - setTimeout(() => { - socket.send(performance.now()) - }, i * PING_INTERVAL) - } - setTimeout( - () => { - socket.close() - - const median = Math.round([...pings].sort((a, b) => a - b)[Math.floor(pings.length / 2)]) - if (median) { - regionPings.value.push({ - region: region.shortcode, - ping: median, - }) - } - }, - PING_COUNT * PING_INTERVAL + MAX_PING_TIME, - ) - } - - socket.onmessage = (event) => { - pings.push(performance.now() - event.data) - } - - socket.onerror = (event) => { - console.error( - `Failed to connect pingtest WebSocket with ${wsUrl}, trying index ${index + 1}:`, - event, - ) - runPingTest(region, index + 1) - } - } catch (error) { - console.error(`Failed to connect pingtest WebSocket with ${wsUrl}:`, error) - } -} - onMounted(() => { startTyping() planQuery() - pingRegions() + fetchRegions() }) watch(customer, (newCustomer) => { diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index 091c1a05c..226adcbab 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -44,6 +44,7 @@ export { clearNodeAuthState, nodeAuthState, setNodeAuthState } from './state/nod export * from './types' export { withJWTRetry } from './utils/jwt-retry' export { getNodeWebSocketUrl } from './utils/node-url' +export { pingWebSocketUrl, type WebSocketPingOptions } from './utils/pingtest' export { type ParsedSseEvent, type ParsedSseItem, diff --git a/packages/api-client/src/utils/pingtest.ts b/packages/api-client/src/utils/pingtest.ts new file mode 100644 index 000000000..f79ed1b6b --- /dev/null +++ b/packages/api-client/src/utils/pingtest.ts @@ -0,0 +1,97 @@ +export interface WebSocketPingOptions { + count?: number + intervalMs?: number + settleDelayMs?: number + timeoutMs?: number + signal?: AbortSignal +} + +export async function pingWebSocketUrl( + url: string, + options: WebSocketPingOptions = {}, +): Promise { + const count = options.count ?? 5 + const intervalMs = options.intervalMs ?? 200 + const settleDelayMs = options.settleDelayMs ?? 1000 + const timeoutMs = options.timeoutMs ?? count * intervalMs + settleDelayMs + 1000 + + if (options.signal?.aborted) return -1 + + return await new Promise((resolve) => { + const samples: number[] = [] + const timers = new Set>() + let socket: WebSocket | undefined + let settled = false + + const setTrackedTimeout = (callback: () => void, ms: number) => { + const timer = setTimeout(() => { + timers.delete(timer) + callback() + }, ms) + timers.add(timer) + return timer + } + + const cleanup = () => { + for (const timer of timers) clearTimeout(timer) + timers.clear() + options.signal?.removeEventListener('abort', abort) + + if ( + socket && + (socket.readyState === WebSocket.CONNECTING || socket.readyState === WebSocket.OPEN) + ) { + socket.close() + } + } + + const finish = (ping: number) => { + if (settled) return + settled = true + cleanup() + resolve(ping) + } + + const abort = () => finish(-1) + options.signal?.addEventListener('abort', abort, { once: true }) + + try { + socket = new WebSocket(url) + } catch { + finish(-1) + return + } + + setTrackedTimeout(() => finish(-1), timeoutMs) + + socket.onopen = () => { + for (let i = 0; i < count; i++) { + setTrackedTimeout(() => { + if (socket?.readyState === WebSocket.OPEN) { + socket.send(String(performance.now())) + } + }, i * intervalMs) + } + + setTrackedTimeout( + () => { + const ping = + samples.length > 0 + ? Math.round([...samples].sort((a, b) => a - b)[Math.floor(samples.length / 2)]) + : -1 + finish(ping) + }, + count * intervalMs + settleDelayMs, + ) + } + + socket.onmessage = (event) => { + samples.push(performance.now() - Number(event.data)) + } + + socket.onerror = () => finish(-1) + socket.onclose = () => { + if (!settled && samples.length === 0) finish(-1) + } + }) +} diff --git a/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue b/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue index bbc816afe..bdae9d75e 100644 --- a/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue +++ b/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue @@ -1,5 +1,5 @@