From 0a53211389d988251ec71f3e4ad3efb1f3bb4890 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Thu, 30 Jul 2026 05:40:52 +0100 Subject: [PATCH 01/82] fix: analytics event endpoint (#6938) * fix: analytics event endpoint * split admin-only routes into internal * fix pr comment --- .../src/pages/admin/analytics/events.vue | 8 +- .../src/routes/internal/analytics_event.rs | 192 +++++++++++++++++ apps/labrinth/src/routes/internal/mod.rs | 17 +- .../labrinth/src/routes/v3/analytics_event.rs | 193 +----------------- apps/labrinth/src/routes/v3/mod.rs | 2 + packages/api-client/src/modules/index.ts | 2 + .../modules/labrinth/analytics/internal.ts | 51 +++++ .../src/modules/labrinth/analytics/v3.ts | 43 ---- .../api-client/src/modules/labrinth/index.ts | 1 + .../api-client/src/modules/labrinth/types.ts | 18 +- 10 files changed, 276 insertions(+), 251 deletions(-) create mode 100644 apps/labrinth/src/routes/internal/analytics_event.rs create mode 100644 packages/api-client/src/modules/labrinth/analytics/internal.ts diff --git a/apps/frontend/src/pages/admin/analytics/events.vue b/apps/frontend/src/pages/admin/analytics/events.vue index bffebfaada..a471615ce9 100644 --- a/apps/frontend/src/pages/admin/analytics/events.vue +++ b/apps/frontend/src/pages/admin/analytics/events.vue @@ -485,9 +485,9 @@ async function saveEvent() { const payload = buildEventPayload() if (modalMode.value === 'edit' && editingEventId.value !== null) { - await client.labrinth.analytics_v3.editEvent(editingEventId.value, payload) + await client.labrinth.analytics_internal.editEvent(editingEventId.value, payload) } else { - await client.labrinth.analytics_v3.createEvent(payload) + await client.labrinth.analytics_internal.createEvent(payload) } await queryClient.invalidateQueries({ queryKey: analyticsEventsQueryKey }) @@ -528,7 +528,7 @@ async function deleteEvent(eventId: Labrinth.Analytics.v3.AnalyticsEventId) { setDeletingEvent(eventId, true) try { - await client.labrinth.analytics_v3.deleteEvent(eventId) + await client.labrinth.analytics_internal.deleteEvent(eventId) await queryClient.invalidateQueries({ queryKey: analyticsEventsQueryKey }) addNotification({ title: 'Analytics event deleted', @@ -581,7 +581,7 @@ function commitAnnouncementUrl() { committedAnnouncementUrl.value = form.value.announcementUrl } -function buildEventPayload(): Labrinth.Analytics.v3.AnalyticsEventUpsert { +function buildEventPayload(): Labrinth.Analytics.Internal.AnalyticsEventUpsert { const selectedRange = getEventFormDateRange() if (!selectedRange) { throw new Error('Select a valid start and end date') diff --git a/apps/labrinth/src/routes/internal/analytics_event.rs b/apps/labrinth/src/routes/internal/analytics_event.rs new file mode 100644 index 0000000000..a743703da3 --- /dev/null +++ b/apps/labrinth/src/routes/internal/analytics_event.rs @@ -0,0 +1,192 @@ +use actix_web::{HttpRequest, delete, patch, post, web}; +use chrono::{DateTime, Utc}; +use eyre::eyre; +use serde::{Deserialize, Serialize}; +use xredis::RedisPool; + +use crate::{ + auth::get_user_from_headers, + database::{ + PgPool, + models::{ + DBAnalyticsEvent, DBAnalyticsEventId, generate_analytics_event_id, + }, + }, + models::{ + ids::AnalyticsEventId, + pats::Scopes, + v3::analytics_event::{AnalyticsEvent, AnalyticsEventMeta}, + }, + queue::session::AuthQueue, + routes::ApiError, + util::error::Context, +}; + +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(analytics_event_create) + .service(analytics_event_edit) + .service(analytics_event_delete); +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct AnalyticsEventUpsert { + #[serde(flatten)] + pub meta: AnalyticsEventMeta, + pub starts: DateTime, + pub ends: DateTime, +} + +/// Create an analytics event. +#[utoipa::path( + context_path = "/analytics-event", + tag = "analytics events", responses((status = OK, body = AnalyticsEvent)) +)] +#[post("")] +pub async fn analytics_event_create( + req: HttpRequest, + event: web::Json, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result, ApiError> { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::empty(), + ) + .await? + .1; + + if !user.role.is_admin() { + return Err(ApiError::Auth(eyre!( + "you do not have permission to manage analytics events" + ))); + } + + let mut transaction = pool + .begin() + .await + .wrap_internal_err("failed to begin transaction")?; + let id = generate_analytics_event_id(&mut transaction) + .await + .wrap_internal_err("failed to generate analytics event ID")?; + + let event = DBAnalyticsEvent { + id, + meta: event.meta.clone(), + starts: event.starts, + ends: event.ends, + }; + event + .insert(&mut transaction) + .await + .wrap_internal_err("failed to insert analytics event")?; + + transaction + .commit() + .await + .wrap_internal_err("failed to commit transaction")?; + DBAnalyticsEvent::clear_cache(&redis) + .await + .wrap_internal_err("failed to clear analytics event cache")?; + + Ok(web::Json(event.into())) +} + +/// Update an analytics event. +#[utoipa::path( + context_path = "/analytics-event", + tag = "analytics events", responses((status = OK, body = AnalyticsEvent)) +)] +#[patch("/{id}")] +pub async fn analytics_event_edit( + req: HttpRequest, + id: web::Path<(AnalyticsEventId,)>, + event: web::Json, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result, ApiError> { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::empty(), + ) + .await? + .1; + + if !user.role.is_admin() { + return Err(ApiError::Auth(eyre!( + "you do not have permission to manage analytics events" + ))); + } + + let event = DBAnalyticsEvent { + id: DBAnalyticsEventId::from(id.into_inner().0), + meta: event.meta.clone(), + starts: event.starts, + ends: event.ends, + }; + + let updated = event + .update(&**pool) + .await + .wrap_internal_err("failed to update analytics event")?; + if !updated { + return Err(ApiError::NotFound); + } + DBAnalyticsEvent::clear_cache(&redis) + .await + .wrap_internal_err("failed to clear analytics event cache")?; + + Ok(web::Json(event.into())) +} + +/// Delete an analytics event. +#[utoipa::path( + context_path = "/analytics-event", + tag = "analytics events", responses((status = NO_CONTENT)) +)] +#[delete("/{id}")] +pub async fn analytics_event_delete( + req: HttpRequest, + id: web::Path<(AnalyticsEventId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result<(), ApiError> { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::empty(), + ) + .await? + .1; + + if !user.role.is_admin() { + return Err(ApiError::Auth(eyre!( + "you do not have permission to manage analytics events" + ))); + } + + let deleted = DBAnalyticsEvent::remove( + DBAnalyticsEventId::from(id.into_inner().0), + &**pool, + ) + .await + .wrap_internal_err("failed to delete analytics event")?; + if !deleted { + return Err(ApiError::NotFound); + } + DBAnalyticsEvent::clear_cache(&redis) + .await + .wrap_internal_err("failed to clear analytics event cache")?; + + Ok(()) +} diff --git a/apps/labrinth/src/routes/internal/mod.rs b/apps/labrinth/src/routes/internal/mod.rs index 272ac2de8b..1ad10523b1 100644 --- a/apps/labrinth/src/routes/internal/mod.rs +++ b/apps/labrinth/src/routes/internal/mod.rs @@ -1,5 +1,6 @@ pub mod admin; pub mod affiliate; +pub mod analytics_event; pub mod attribution; pub mod billing; pub mod blocked_users; @@ -35,6 +36,10 @@ pub fn config(cfg: &mut web::ServiceConfig) { .configure(flows::config) .configure(pats::config) .configure(oauth_clients::config) + .service( + web::scope("/analytics-event") + .configure(analytics_event::config), + ) .service(web::scope("/moderation").configure(moderation::config)) .service(web::scope("/affiliate").configure(affiliate::config)) .service(web::scope("/campaign").configure(campaign::config)) @@ -50,11 +55,6 @@ pub fn config(cfg: &mut web::ServiceConfig) { .configure(medal::config) .configure(mural::config) .configure(statuses::config), - ) - .service( - web::scope("/v3/analytics-event") - .wrap(default_cors()) - .configure(super::v3::analytics_event::config), ); } @@ -180,10 +180,9 @@ pub fn config(cfg: &mut web::ServiceConfig) { medal::redeem, mural::get_bank_details, statuses::ws_init, - super::v3::analytics_event::analytics_events_get, - super::v3::analytics_event::analytics_event_create, - super::v3::analytics_event::analytics_event_edit, - super::v3::analytics_event::analytics_event_delete, + analytics_event::analytics_event_create, + analytics_event::analytics_event_edit, + analytics_event::analytics_event_delete, ), modifiers(&InternalPathModifier, &SecurityAddon) )] diff --git a/apps/labrinth/src/routes/v3/analytics_event.rs b/apps/labrinth/src/routes/v3/analytics_event.rs index 9bbe2ef0a0..53142b411e 100644 --- a/apps/labrinth/src/routes/v3/analytics_event.rs +++ b/apps/labrinth/src/routes/v3/analytics_event.rs @@ -1,48 +1,22 @@ -use actix_web::{HttpRequest, delete, get, patch, post, web}; -use chrono::{DateTime, Utc}; -use eyre::eyre; -use serde::{Deserialize, Serialize}; +use actix_web::{get, web}; use xredis::RedisPool; use crate::{ - auth::get_user_from_headers, - database::{ - PgPool, - models::{ - DBAnalyticsEvent, DBAnalyticsEventId, generate_analytics_event_id, - }, - }, - models::{ - ids::AnalyticsEventId, - pats::Scopes, - v3::analytics_event::{AnalyticsEvent, AnalyticsEventMeta}, - }, - queue::session::AuthQueue, + database::{PgPool, models::DBAnalyticsEvent}, + models::v3::analytics_event::AnalyticsEvent, routes::ApiError, util::error::Context, }; pub fn config(cfg: &mut actix_web::web::ServiceConfig) { - cfg.service(analytics_events_get) - .service(analytics_event_create) - .service(analytics_event_edit) - .service(analytics_event_delete); + cfg.service(analytics_events_get); } -#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct AnalyticsEventUpsert { - #[serde(flatten)] - pub meta: AnalyticsEventMeta, - pub starts: DateTime, - pub ends: DateTime, -} - -/// List analytics events. +/// List analytics events. #[utoipa::path( - context_path = "/v3/analytics-event", tag = "v3 analytics", responses((status = OK, body = Vec)) )] -#[get("")] +#[get("/analytics-event")] pub async fn analytics_events_get( pool: web::Data, redis: web::Data, @@ -56,158 +30,3 @@ pub async fn analytics_events_get( Ok(web::Json(events)) } - -/// Create an analytics event. -#[utoipa::path( - context_path = "/v3/analytics-event", - tag = "v3 analytics", responses((status = OK, body = AnalyticsEvent)) -)] -#[post("")] -pub async fn analytics_event_create( - req: HttpRequest, - event: web::Json, - pool: web::Data, - redis: web::Data, - session_queue: web::Data, -) -> Result, ApiError> { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Scopes::empty(), - ) - .await? - .1; - - if !user.role.is_admin() { - return Err(ApiError::Auth(eyre!( - "you do not have permission to manage analytics events" - ))); - } - - let mut transaction = pool - .begin() - .await - .wrap_internal_err("failed to begin transaction")?; - let id = generate_analytics_event_id(&mut transaction) - .await - .wrap_internal_err("failed to generate analytics event ID")?; - - let event = DBAnalyticsEvent { - id, - meta: event.meta.clone(), - starts: event.starts, - ends: event.ends, - }; - event - .insert(&mut transaction) - .await - .wrap_internal_err("failed to insert analytics event")?; - - transaction - .commit() - .await - .wrap_internal_err("failed to commit transaction")?; - DBAnalyticsEvent::clear_cache(&redis) - .await - .wrap_internal_err("failed to clear analytics event cache")?; - - Ok(web::Json(event.into())) -} - -/// Update an analytics event. -#[utoipa::path( - context_path = "/v3/analytics-event", - tag = "v3 analytics", responses((status = OK, body = AnalyticsEvent)) -)] -#[patch("/{id}")] -pub async fn analytics_event_edit( - req: HttpRequest, - id: web::Path<(AnalyticsEventId,)>, - event: web::Json, - pool: web::Data, - redis: web::Data, - session_queue: web::Data, -) -> Result, ApiError> { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Scopes::empty(), - ) - .await? - .1; - - if !user.role.is_admin() { - return Err(ApiError::Auth(eyre!( - "you do not have permission to manage analytics events" - ))); - } - - let event = DBAnalyticsEvent { - id: DBAnalyticsEventId::from(id.into_inner().0), - meta: event.meta.clone(), - starts: event.starts, - ends: event.ends, - }; - - let updated = event - .update(&**pool) - .await - .wrap_internal_err("failed to update analytics event")?; - if !updated { - return Err(ApiError::NotFound); - } - DBAnalyticsEvent::clear_cache(&redis) - .await - .wrap_internal_err("failed to clear analytics event cache")?; - - Ok(web::Json(event.into())) -} - -/// Delete an analytics event. -#[utoipa::path( - context_path = "/v3/analytics-event", - tag = "v3 analytics", responses((status = NO_CONTENT)) -)] -#[delete("/{id}")] -pub async fn analytics_event_delete( - req: HttpRequest, - id: web::Path<(AnalyticsEventId,)>, - pool: web::Data, - redis: web::Data, - session_queue: web::Data, -) -> Result<(), ApiError> { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Scopes::empty(), - ) - .await? - .1; - - if !user.role.is_admin() { - return Err(ApiError::Auth(eyre!( - "you do not have permission to manage analytics events" - ))); - } - - let deleted = DBAnalyticsEvent::remove( - DBAnalyticsEventId::from(id.into_inner().0), - &**pool, - ) - .await - .wrap_internal_err("failed to delete analytics event")?; - if !deleted { - return Err(ApiError::NotFound); - } - DBAnalyticsEvent::clear_cache(&redis) - .await - .wrap_internal_err("failed to clear analytics event cache")?; - - Ok(()) -} diff --git a/apps/labrinth/src/routes/v3/mod.rs b/apps/labrinth/src/routes/v3/mod.rs index 919bffee4e..735daff22f 100644 --- a/apps/labrinth/src/routes/v3/mod.rs +++ b/apps/labrinth/src/routes/v3/mod.rs @@ -49,6 +49,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("/v3") .wrap(default_cors()) + .configure(analytics_event::config) .configure(limits::config) .configure(collections::config) .configure(images::config) @@ -80,6 +81,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { description = include_str!("../../api_v3_description.md"), ), paths( + analytics_event::analytics_events_get, analytics_get::fetch_analytics, analytics_get::facets::fetch_facets, analytics_get::old::playtimes_get, diff --git a/packages/api-client/src/modules/index.ts b/packages/api-client/src/modules/index.ts index 9f8167bbc8..28b3c77e9a 100644 --- a/packages/api-client/src/modules/index.ts +++ b/packages/api-client/src/modules/index.ts @@ -19,6 +19,7 @@ import { KyrosLogsV1Module } from './kyros/logs/v1' import { KyrosUploadSessionsV1Module } from './kyros/upload-sessions/v1' import { LabrinthVersionsV2Module, LabrinthVersionsV3Module } from './labrinth' import { LabrinthAffiliateInternalModule } from './labrinth/affiliate/internal' +import { LabrinthAnalyticsInternalModule } from './labrinth/analytics/internal' import { LabrinthAnalyticsV3Module } from './labrinth/analytics/v3' import { LabrinthAttributionInternalModule } from './labrinth/attribution/internal' import { LabrinthAuthInternalModule } from './labrinth/auth/internal' @@ -97,6 +98,7 @@ export const MODULE_REGISTRY = { kyros_logs_v1: KyrosLogsV1Module, kyros_upload_sessions_v1: KyrosUploadSessionsV1Module, labrinth_affiliate_internal: LabrinthAffiliateInternalModule, + labrinth_analytics_internal: LabrinthAnalyticsInternalModule, labrinth_analytics_v3: LabrinthAnalyticsV3Module, labrinth_auth_internal: LabrinthAuthInternalModule, labrinth_auth_v2: LabrinthAuthV2Module, diff --git a/packages/api-client/src/modules/labrinth/analytics/internal.ts b/packages/api-client/src/modules/labrinth/analytics/internal.ts new file mode 100644 index 0000000000..b794327ef8 --- /dev/null +++ b/packages/api-client/src/modules/labrinth/analytics/internal.ts @@ -0,0 +1,51 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { Labrinth } from '../types' + +export class LabrinthAnalyticsInternalModule extends AbstractModule { + public getModuleID(): string { + return 'labrinth_analytics_internal' + } + + /** + * Create an analytics event. + * POST /_internal/analytics-event + */ + public async createEvent( + data: Labrinth.Analytics.Internal.AnalyticsEventUpsert, + ): Promise { + return this.client.request('/analytics-event', { + api: 'labrinth', + version: 'internal', + method: 'POST', + body: data, + }) + } + + /** + * Edit an analytics event. + * PATCH /_internal/analytics-event/{id} + */ + public async editEvent( + id: Labrinth.Analytics.v3.AnalyticsEventId, + data: Labrinth.Analytics.Internal.AnalyticsEventUpsert, + ): Promise { + return this.client.request(`/analytics-event/${id}`, { + api: 'labrinth', + version: 'internal', + method: 'PATCH', + body: data, + }) + } + + /** + * Delete an analytics event. + * DELETE /_internal/analytics-event/{id} + */ + public async deleteEvent(id: Labrinth.Analytics.v3.AnalyticsEventId): Promise { + return this.client.request(`/analytics-event/${id}`, { + api: 'labrinth', + version: 'internal', + method: 'DELETE', + }) + } +} diff --git a/packages/api-client/src/modules/labrinth/analytics/v3.ts b/packages/api-client/src/modules/labrinth/analytics/v3.ts index ba93ca4ac8..6b7757e9cb 100644 --- a/packages/api-client/src/modules/labrinth/analytics/v3.ts +++ b/packages/api-client/src/modules/labrinth/analytics/v3.ts @@ -70,47 +70,4 @@ export class LabrinthAnalyticsV3Module extends AbstractModule { method: 'GET', }) } - - /** - * Create an analytics event. - * POST /v3/analytics-event - */ - public async createEvent( - data: Labrinth.Analytics.v3.AnalyticsEventUpsert, - ): Promise { - return this.client.request('/analytics-event', { - api: 'labrinth', - version: 3, - method: 'POST', - body: data, - }) - } - - /** - * Edit an analytics event. - * PATCH /v3/analytics-event/{id} - */ - public async editEvent( - id: Labrinth.Analytics.v3.AnalyticsEventId, - data: Labrinth.Analytics.v3.AnalyticsEventUpsert, - ): Promise { - return this.client.request(`/analytics-event/${id}`, { - api: 'labrinth', - version: 3, - method: 'PATCH', - body: data, - }) - } - - /** - * Delete an analytics event. - * DELETE /v3/analytics-event/{id} - */ - public async deleteEvent(id: Labrinth.Analytics.v3.AnalyticsEventId): Promise { - return this.client.request(`/analytics-event/${id}`, { - api: 'labrinth', - version: 3, - method: 'DELETE', - }) - } } diff --git a/packages/api-client/src/modules/labrinth/index.ts b/packages/api-client/src/modules/labrinth/index.ts index 38d23edd70..51cca73d32 100644 --- a/packages/api-client/src/modules/labrinth/index.ts +++ b/packages/api-client/src/modules/labrinth/index.ts @@ -1,3 +1,4 @@ +export * from './analytics/internal' export * from './analytics/v3' export * from './attribution/internal' export * from './auth/internal' diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts index a5de5b8d8d..0e571a6291 100644 --- a/packages/api-client/src/modules/labrinth/types.ts +++ b/packages/api-client/src/modules/labrinth/types.ts @@ -463,6 +463,16 @@ export namespace Labrinth { } export namespace Analytics { + export namespace Internal { + export type AnalyticsEventUpsert = { + announcement_url: string | null + for_metric_kind: v3.AnalyticsEventMetricKind[] | null + title: string + ends: string + starts: string + } + } + export namespace v3 { export type AnalyticsEventId = number export type AnalyticsEventMetricKind = 'views' | 'revenue' | 'downloads' | 'playtime' @@ -476,14 +486,6 @@ export namespace Labrinth { starts: string } - export type AnalyticsEventUpsert = { - announcement_url: string | null - for_metric_kind: AnalyticsEventMetricKind[] | null - title: string - ends: string - starts: string - } - export type FetchRequest = { time_range: TimeRange return_metrics: ReturnMetrics From 572b8027ffc9ceceb054791e96822400b9a0b693 Mon Sep 17 00:00:00 2001 From: "Michael H." Date: Thu, 30 Jul 2026 19:23:10 +0200 Subject: [PATCH 02/82] fix: clickhouse migrations should not run normally --- apps/labrinth/src/background_task.rs | 3 ++ apps/labrinth/src/clickhouse/mod.rs | 51 ++++++++++++++++------------ apps/labrinth/src/main.rs | 2 +- apps/labrinth/src/test/mod.rs | 1 + 4 files changed, 34 insertions(+), 23 deletions(-) diff --git a/apps/labrinth/src/background_task.rs b/apps/labrinth/src/background_task.rs index 9a5150b3c5..3936356840 100644 --- a/apps/labrinth/src/background_task.rs +++ b/apps/labrinth/src/background_task.rs @@ -168,6 +168,9 @@ pub async fn update_bank_balances(pool: PgPool) -> eyre::Result<()> { pub async fn run_migrations() -> eyre::Result<()> { database::check_for_migrations().await?; + crate::clickhouse::run_migrations() + .await + .wrap_err("failed to run ClickHouse migrations")?; Ok(()) } diff --git a/apps/labrinth/src/clickhouse/mod.rs b/apps/labrinth/src/clickhouse/mod.rs index d20353d6ef..e511bc0481 100644 --- a/apps/labrinth/src/clickhouse/mod.rs +++ b/apps/labrinth/src/clickhouse/mod.rs @@ -19,29 +19,36 @@ pub async fn init_client() -> clickhouse::error::Result { pub async fn init_client_with_database( database: &str, ) -> clickhouse::error::Result { + Ok(connect()?.with_database(database)) +} + +fn connect() -> clickhouse::error::Result { + let https_connector = HttpsConnectorBuilder::new() + .with_native_roots()? + .https_or_http() + .enable_all_versions() + .build(); + let hyper_client = + hyper_util::client::legacy::Client::builder(TokioExecutor::new()) + .build(https_connector); + + Ok(clickhouse::Client::with_http_client(hyper_client) + .with_url(&ENV.CLICKHOUSE_URL) + .with_user(&ENV.CLICKHOUSE_USER) + .with_password(&ENV.CLICKHOUSE_PASSWORD) + .with_validation(false)) +} + +pub async fn run_migrations() -> clickhouse::error::Result<()> { + run_migrations_on_database(&ENV.CLICKHOUSE_DATABASE).await +} + +pub async fn run_migrations_on_database( + database: &str, +) -> clickhouse::error::Result<()> { const MINECRAFT_JAVA_SERVER_PINGS: &str = server_ping::CLICKHOUSE_TABLE; - let client = { - let https_connector = HttpsConnectorBuilder::new() - .with_native_roots()? - .https_or_http() - .enable_all_versions() - .build(); - let hyper_client = - hyper_util::client::legacy::Client::builder(TokioExecutor::new()) - .build(https_connector); - - clickhouse::Client::with_http_client(hyper_client) - .with_url(&ENV.CLICKHOUSE_URL) - .with_user(&ENV.CLICKHOUSE_USER) - .with_password(&ENV.CLICKHOUSE_PASSWORD) - .with_validation(false) - }; - - client - .query(&format!("CREATE DATABASE IF NOT EXISTS {database}")) - .execute() - .await?; + let client = connect()?; let clickhouse_replicated = ENV.CLICKHOUSE_REPLICATED; let cluster_line = if clickhouse_replicated { @@ -270,5 +277,5 @@ pub async fn init_client_with_database( .execute() .await?; - Ok(client.with_database(database)) + Ok(()) } diff --git a/apps/labrinth/src/main.rs b/apps/labrinth/src/main.rs index d788349117..b456b77a67 100644 --- a/apps/labrinth/src/main.rs +++ b/apps/labrinth/src/main.rs @@ -98,7 +98,7 @@ async fn app() -> std::io::Result<()> { info!("Starting labrinth on {}", &ENV.BIND_ADDR); if !args.no_migrations { - database::check_for_migrations() + labrinth::background_task::run_migrations() .await .expect("An error occurred while running migrations."); } diff --git a/apps/labrinth/src/test/mod.rs b/apps/labrinth/src/test/mod.rs index e81289e48d..7784392012 100644 --- a/apps/labrinth/src/test/mod.rs +++ b/apps/labrinth/src/test/mod.rs @@ -37,6 +37,7 @@ pub async fn setup(db: &database::TemporaryDatabase) -> LabrinthConfig { let search_backend = db.search_backend.clone(); let file_host: Arc = Arc::new(file_hosting::MockHost::new()); let file_host = web::Data::::from(file_host); + clickhouse::run_migrations().await.unwrap(); let mut clickhouse = clickhouse::init_client().await.unwrap(); let stripe_client = stripe::Client::new(ENV.STRIPE_API_KEY.clone()); From c5ce5bc9b314b937d9e30ab65dba5be81ff3b4ec Mon Sep 17 00:00:00 2001 From: "Michael H." Date: Thu, 30 Jul 2026 20:08:49 +0200 Subject: [PATCH 03/82] fix: create clickhouse db in tests only --- apps/labrinth/src/clickhouse/mod.rs | 8 ++++++++ apps/labrinth/src/test/mod.rs | 3 +++ 2 files changed, 11 insertions(+) diff --git a/apps/labrinth/src/clickhouse/mod.rs b/apps/labrinth/src/clickhouse/mod.rs index e511bc0481..1e3f92e73b 100644 --- a/apps/labrinth/src/clickhouse/mod.rs +++ b/apps/labrinth/src/clickhouse/mod.rs @@ -39,6 +39,14 @@ fn connect() -> clickhouse::error::Result { .with_validation(false)) } +#[cfg(feature = "test")] +pub async fn create_database(database: &str) -> clickhouse::error::Result<()> { + connect()? + .query(&format!("CREATE DATABASE IF NOT EXISTS {database}")) + .execute() + .await +} + pub async fn run_migrations() -> clickhouse::error::Result<()> { run_migrations_on_database(&ENV.CLICKHOUSE_DATABASE).await } diff --git a/apps/labrinth/src/test/mod.rs b/apps/labrinth/src/test/mod.rs index 7784392012..97fb991b87 100644 --- a/apps/labrinth/src/test/mod.rs +++ b/apps/labrinth/src/test/mod.rs @@ -37,6 +37,9 @@ pub async fn setup(db: &database::TemporaryDatabase) -> LabrinthConfig { let search_backend = db.search_backend.clone(); let file_host: Arc = Arc::new(file_hosting::MockHost::new()); let file_host = web::Data::::from(file_host); + clickhouse::create_database(&ENV.CLICKHOUSE_DATABASE) + .await + .unwrap(); clickhouse::run_migrations().await.unwrap(); let mut clickhouse = clickhouse::init_client().await.unwrap(); From 7abe6f8c7318a41aa859f9f8b577d5d0d4d0e492 Mon Sep 17 00:00:00 2001 From: "Michael H." Date: Thu, 30 Jul 2026 20:27:08 +0200 Subject: [PATCH 04/82] feat: sccache on windows --- .github/workflows/theseus-build.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/theseus-build.yml b/.github/workflows/theseus-build.yml index abc1305f3d..6b83d5bfde 100644 --- a/.github/workflows/theseus-build.yml +++ b/.github/workflows/theseus-build.yml @@ -92,7 +92,6 @@ jobs: run: corepack enable - name: Set up caches - if: contains(matrix.artifact-target-name, 'linux') || contains(matrix.artifact-target-name, 'darwin') uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1 with: cache: | @@ -100,7 +99,6 @@ jobs: pnpm - name: Configure sccache - if: contains(matrix.artifact-target-name, 'linux') || contains(matrix.artifact-target-name, 'darwin') run: nsc cache sccache setup --cache_name default >> "$GITHUB_ENV" - name: Generate tauri-dev.conf.json From 295db3fb7ac113ba6b5a8622d2d325670193241e Mon Sep 17 00:00:00 2001 From: Prospector <6166773+Prospector@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:10:08 -0700 Subject: [PATCH 05/82] =?UTF-8?q?only=20refresh=20tokens=20and=20clear=20a?= =?UTF-8?q?uth=20cookies=20on=20auth=20fails,=20not=20any=20ran=E2=80=A6?= =?UTF-8?q?=20(#6947)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit only refresh tokens and clear auth cookies on auth fails, not any random error --- apps/frontend/src/composables/auth.ts | 64 ++++++++++++++++++++------- 1 file changed, 48 insertions(+), 16 deletions(-) diff --git a/apps/frontend/src/composables/auth.ts b/apps/frontend/src/composables/auth.ts index 11f5f5c173..bffb9b72fc 100644 --- a/apps/frontend/src/composables/auth.ts +++ b/apps/frontend/src/composables/auth.ts @@ -30,6 +30,29 @@ const normalizeAuthToken = (value: unknown) => { return '' } +const getErrorStatus = (error: unknown): number | undefined => { + if (!error || typeof error !== 'object') { + return undefined + } + + const typedError = error as { statusCode?: unknown; status?: unknown } + const status = typedError.statusCode ?? typedError.status + + return typeof status === 'number' ? status : undefined +} + +// only when labrinth actually gives us an auth error +const isAuthFailure = (error: unknown): boolean => { + const status = getErrorStatus(error) + return status === 401 || status === 403 +} + +const clearAuthCookie = (auth: AuthState, authCookie: { value: string | null }) => { + authCookie.value = null + auth.token = '' + auth.user = null +} + const getQueryString = (value: QueryValue) => { if (Array.isArray(value)) { return value[0] ?? null @@ -90,6 +113,7 @@ export const initAuth = async (oldToken: string | null | undefined = null) => { } const tokenStr = normalizeAuthToken(authCookie.value) + let shouldRefresh = false if (authCookie.value != null && tokenStr === '') { authCookie.value = null @@ -111,12 +135,13 @@ export const initAuth = async (oldToken: string | null | undefined = null) => { }, true, )) as Labrinth.Users.v2.User - } catch { - /* empty */ + } catch (error) { + // only refresh when the token was rejected. not on timeouts or other errors (think this was the cause of random logouts) + shouldRefresh = isAuthFailure(error) } } - if (!auth.user && auth.token) { + if (!auth.user && auth.token && shouldRefresh) { try { const session = (await useBaseFetch( 'session/refresh', @@ -132,22 +157,29 @@ export const initAuth = async (oldToken: string | null | undefined = null) => { auth.token = normalizeAuthToken(session.session) if (auth.token) { authCookie.value = auth.token - auth.user = (await useBaseFetch( - 'user', - { - apiVersion: 3, - headers: { - Authorization: auth.token, + try { + auth.user = (await useBaseFetch( + 'user', + { + apiVersion: 3, + headers: { + Authorization: auth.token, + }, }, - }, - true, - )) as Labrinth.Users.v2.User + true, + )) as Labrinth.Users.v2.User + } catch (error) { + if (isAuthFailure(error)) { + clearAuthCookie(auth, authCookie) + } + } } else { - authCookie.value = null - auth.token = '' + clearAuthCookie(auth, authCookie) + } + } catch (error) { + if (isAuthFailure(error)) { + clearAuthCookie(auth, authCookie) } - } catch { - authCookie.value = null } } From 65a3ac4b34c14e44c9f351c40f92466714cdd1fe Mon Sep 17 00:00:00 2001 From: Prospector <6166773+Prospector@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:10:28 -0700 Subject: [PATCH 06/82] changelog --- packages/blog/changelog.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/blog/changelog.ts b/packages/blog/changelog.ts index bba5af2d91..979ffff830 100644 --- a/packages/blog/changelog.ts +++ b/packages/blog/changelog.ts @@ -10,6 +10,12 @@ export type VersionEntry = { } const VERSIONS: VersionEntry[] = [ + { + date: `2026-07-31T06:10:23+00:00`, + product: 'web', + body: `## Fixed +- Fixed randomly getting signed out of Modrinth account due to random non-auth related errors.`, + }, { date: `2026-07-29T21:32:06+00:00`, product: 'app', From 18b0b2848d688dfed2fee94a71ec8aabe5e4a824 Mon Sep 17 00:00:00 2001 From: "Michael H." Date: Fri, 31 Jul 2026 13:10:58 +0200 Subject: [PATCH 07/82] feat: labrinth argo --- .github/workflows/cmd-deploy.yml | 19 +++++ ...labrinth-docker.yml => labrinth-build.yml} | 69 ++++++++----------- .github/workflows/slash-cmds.yml | 18 +++++ apps/labrinth/Dockerfile | 2 +- 4 files changed, 67 insertions(+), 41 deletions(-) create mode 100644 .github/workflows/cmd-deploy.yml rename .github/workflows/{labrinth-docker.yml => labrinth-build.yml} (66%) create mode 100644 .github/workflows/slash-cmds.yml diff --git a/.github/workflows/cmd-deploy.yml b/.github/workflows/cmd-deploy.yml new file mode 100644 index 0000000000..010359a2e2 --- /dev/null +++ b/.github/workflows/cmd-deploy.yml @@ -0,0 +1,19 @@ +name: Deploy Command +run-name: Deploy PR #${{ github.event.client_payload.github.payload.issue.number }} + +on: + repository_dispatch: + types: [deploy-command] + +jobs: + deploy: + uses: SparkUniverse/workflows/.github/workflows/deploy-command.yaml@main + secrets: + ARGOCD_DEPLOY_KEY: ${{ secrets.ARGOCD_DEPLOY_KEY }} + CMD_DISPATCH_GH_TOKEN: ${{ secrets.SLASH_CMD_GH_TOKEN }} + with: + application-set: labrinth + build-workflow: labrinth-build.yml + branch: ${{ github.event.client_payload.pull_request.head.ref }} + issue-number: ${{ github.event.client_payload.github.payload.issue.number }} + head-sha: ${{ github.event.client_payload.pull_request.head.sha }} diff --git a/.github/workflows/labrinth-docker.yml b/.github/workflows/labrinth-build.yml similarity index 66% rename from .github/workflows/labrinth-docker.yml rename to .github/workflows/labrinth-build.yml index ce8d8effae..5aba8298a3 100644 --- a/.github/workflows/labrinth-docker.yml +++ b/.github/workflows/labrinth-build.yml @@ -1,18 +1,18 @@ -name: docker-build +name: Labrinth Build on: push: branches: - 'main' paths: - - .github/workflows/labrinth-docker.yml + - .github/workflows/labrinth-build.yml - 'apps/labrinth/**' - Cargo.toml - Cargo.lock pull_request: types: [opened, synchronize] paths: - - .github/workflows/labrinth-docker.yml + - .github/workflows/labrinth-build.yml - 'apps/labrinth/**' - Cargo.toml - Cargo.lock @@ -69,7 +69,8 @@ jobs: echo "skip=false" >> $GITHUB_OUTPUT fi - docker: + build: + name: Build Labrinth runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'namespace-profile-modrinth-labrinth' || 'ubuntu-latest' }} needs: [skip-if-clean] if: ${{ needs.skip-if-clean.outputs.skip != 'true' }} @@ -120,42 +121,30 @@ jobs: cp -r apps/labrinth/migrations apps/labrinth/docker-stage/migrations cp -r apps/labrinth/assets apps/labrinth/docker-stage/assets - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - - name: Generate Docker image metadata - id: docker-meta - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 - env: - # GitHub Packages requires annotations metadata in at least the index descriptor to show them - # up properly in its UI it seems, but it's not clear about it, because the docs refer to the - # image manifest only. See: - # https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#adding-a-description-to-multi-arch-images - DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index + - name: Upload Docker context + if: needs.skip-if-clean.outputs.internal == 'true' + uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1.0.3 with: - images: ghcr.io/modrinth/labrinth - labels: | - org.opencontainers.image.title=labrinth - org.opencontainers.image.description=Modrinth API - org.opencontainers.image.licenses=AGPL-3.0-only - annotations: | - org.opencontainers.image.title=labrinth - org.opencontainers.image.description=Modrinth API - org.opencontainers.image.licenses=AGPL-3.0-only + name: labrinth-docker-context + retention-days: 1 + path: apps/labrinth/docker-stage - - name: Login to GitHub Packages - uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + docker-build: + needs: [skip-if-clean, build] + if: ${{ needs.skip-if-clean.outputs.internal == 'true' }} + uses: SparkUniverse/workflows/.github/workflows/docker-build.yaml@main + with: + image-name: labrinth + dockerfile-path: apps/labrinth/Dockerfile + artifacts-name: labrinth-docker-context - - name: Build and push - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 - with: - context: ./apps/labrinth/docker-stage - file: ./apps/labrinth/Dockerfile - push: true - tags: ${{ steps.docker-meta.outputs.tags }} - labels: ${{ steps.docker-meta.outputs.labels }} - annotations: ${{ steps.docker-meta.outputs.annotations }} + deploy: + needs: docker-build + if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/prod' }} + uses: SparkUniverse/workflows/.github/workflows/argo-update.yaml@main + secrets: + ARGOCD_DEPLOY_KEY: ${{ secrets.ARGOCD_DEPLOY_KEY }} + with: + application-set: labrinth + branch: ${{ github.ref == 'refs/heads/prod' && 'main' || 'develop' }} + environment-name: ${{ github.ref == 'refs/heads/prod' && 'production' || 'staging' }} diff --git a/.github/workflows/slash-cmds.yml b/.github/workflows/slash-cmds.yml new file mode 100644 index 0000000000..65a6d8df2e --- /dev/null +++ b/.github/workflows/slash-cmds.yml @@ -0,0 +1,18 @@ +name: Slash Command Dispatch + +on: + issue_comment: + types: [created] + +jobs: + dispatch-command: + if: ${{ github.event.sender.type == 'User' && contains(fromJSON('["OWNER", "MEMBER"]'), github.event.comment.author_association) }} + runs-on: namespace-profile-tiny-arm64 + steps: + - name: Slash Command Dispatch + uses: peter-evans/slash-command-dispatch@9bdcd7914ec1b75590b790b844aa3b8eee7c683a # v5.0.2 + with: + token: ${{ secrets.SLASH_CMD_GH_TOKEN }} + issue-type: pull-request + commands: | + deploy diff --git a/apps/labrinth/Dockerfile b/apps/labrinth/Dockerfile index 328c4137d4..6d2923265c 100644 --- a/apps/labrinth/Dockerfile +++ b/apps/labrinth/Dockerfile @@ -11,7 +11,7 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates dumb-init curl \ && rm -rf /var/lib/apt/lists/* -COPY labrinth /labrinth/labrinth +COPY --chmod=0755 labrinth /labrinth/labrinth COPY migrations /labrinth/migrations COPY assets /labrinth/assets From 919b235621efce7278192c09f503f158ab19db43 Mon Sep 17 00:00:00 2001 From: "Michael H." Date: Fri, 31 Jul 2026 13:17:26 +0200 Subject: [PATCH 08/82] chore: address action issues --- .github/merge-queue-ci-skipper/action.yml | 32 +++++++++++++++-------- .github/workflows/cmd-deploy.yml | 4 +++ .github/workflows/slash-cmds.yml | 2 ++ 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/.github/merge-queue-ci-skipper/action.yml b/.github/merge-queue-ci-skipper/action.yml index 3a2474a6dd..18783bb80e 100644 --- a/.github/merge-queue-ci-skipper/action.yml +++ b/.github/merge-queue-ci-skipper/action.yml @@ -34,13 +34,13 @@ runs: using: 'composite' steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 - name: Extract PR Number and Commit ID id: extract-pr-info - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | const githubRef = process.env.GITHUB_REF; @@ -90,20 +90,26 @@ runs: - name: Print PR Branch if: env.CAN_SKIP_CHECKS != 'false' shell: bash + env: + PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }} run: | - echo "PR Branch: ${{ steps.get-pr-branch.outputs.prBranch }}" + echo "PR Branch: $PR_BRANCH" - name: Check if PR branch contains the Merge Queue target commit ID if: env.CAN_SKIP_CHECKS != 'false' shell: bash + env: + PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }} + COMMIT_ID: ${{ steps.extract-pr-info.outputs.commitId }} + TARGET_BRANCH: ${{ steps.extract-pr-info.outputs.targetBranchName }} run: | # Get the branch name from previous steps - branch_name="origin/${{ steps.get-pr-branch.outputs.prBranch }}" - commit_id="${{ steps.extract-pr-info.outputs.commitId }}" + branch_name="origin/$PR_BRANCH" + commit_id="$COMMIT_ID" # Check if the branch history contains the commit - if git branch -r --contains "$commit_id" | grep -q "$branch_name"; then - echo "Branch '$branch_name' contains commit '$commit_id'. It is up to date with ${{ steps.extract-pr-info.outputs.targetBranchName }}." + if git branch -r --contains "$commit_id" | grep -qF "$branch_name"; then + echo "Branch '$branch_name' contains commit '$commit_id'. It is up to date with $TARGET_BRANCH." else echo "Branch '$branch_name' does not contain commit '$commit_id'. It is outdated. Setting CAN_SKIP_CHECKS to false." echo "CAN_SKIP_CHECKS=false" >> "$GITHUB_ENV" @@ -112,8 +118,10 @@ runs: - name: Compare PR Branch with Current Branch if: env.CAN_SKIP_CHECKS != 'false' shell: bash + env: + PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }} run: | - if git diff --quiet "origin/${{ steps.get-pr-branch.outputs.prBranch }}"; then + if git diff --quiet "origin/$PR_BRANCH"; then echo "No differences found. PR branch is identical with this merge queue branch." else echo "Differences detected. PR branch has been updated after PR was added to merge queue. Setting CAN_SKIP_CHECKS to false." @@ -122,9 +130,11 @@ runs: - name: Compute/publish skip result id: passed-checks - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 env: SECRET: ${{ inputs.secret }} + PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }} + TARGET_BRANCH: ${{ steps.extract-pr-info.outputs.targetBranchName }} with: github-token: ${{ inputs.secret != '' && inputs.secret || github.token }} script: | @@ -144,7 +154,7 @@ runs: const { data: branchProtection } = await github.rest.repos.getBranchProtection({ owner: context.repo.owner, repo: context.repo.repo, - branch: "${{ steps.extract-pr-info.outputs.targetBranchName }}", + branch: process.env.TARGET_BRANCH, }); const requiredCheckNames = branchProtection.required_status_checks.contexts; console.log(`requiredCheckNames = ${requiredCheckNames}`); @@ -152,7 +162,7 @@ runs: const { data: checks } = await github.rest.checks.listForRef({ owner: context.repo.owner, repo: context.repo.repo, - ref: "refs/heads/${{ steps.get-pr-branch.outputs.prBranch }}", + ref: `refs/heads/${process.env.PR_BRANCH}`, }); console.log(`checks.check_runs = ${checks.check_runs.map(check => `${check.status},${check.conclusion},${check.name};`)}`); diff --git a/.github/workflows/cmd-deploy.yml b/.github/workflows/cmd-deploy.yml index 010359a2e2..bde79ac0f1 100644 --- a/.github/workflows/cmd-deploy.yml +++ b/.github/workflows/cmd-deploy.yml @@ -5,6 +5,10 @@ on: repository_dispatch: types: [deploy-command] +permissions: + contents: read + actions: read + jobs: deploy: uses: SparkUniverse/workflows/.github/workflows/deploy-command.yaml@main diff --git a/.github/workflows/slash-cmds.yml b/.github/workflows/slash-cmds.yml index 65a6d8df2e..e35babeddf 100644 --- a/.github/workflows/slash-cmds.yml +++ b/.github/workflows/slash-cmds.yml @@ -4,6 +4,8 @@ on: issue_comment: types: [created] +permissions: {} + jobs: dispatch-command: if: ${{ github.event.sender.type == 'User' && contains(fromJSON('["OWNER", "MEMBER"]'), github.event.comment.author_association) }} From a53c9c3771bb44ce65e24515c5df890f97ac3963 Mon Sep 17 00:00:00 2001 From: "Michael H." Date: Fri, 31 Jul 2026 13:21:35 +0200 Subject: [PATCH 09/82] fix: only deploy if internal --- .github/workflows/cmd-deploy.yml | 1 + .github/workflows/labrinth-build.yml | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cmd-deploy.yml b/.github/workflows/cmd-deploy.yml index bde79ac0f1..182f2bd5aa 100644 --- a/.github/workflows/cmd-deploy.yml +++ b/.github/workflows/cmd-deploy.yml @@ -11,6 +11,7 @@ permissions: jobs: deploy: + if: ${{ github.event.client_payload.pull_request.head.repo.full_name == github.repository }} uses: SparkUniverse/workflows/.github/workflows/deploy-command.yaml@main secrets: ARGOCD_DEPLOY_KEY: ${{ secrets.ARGOCD_DEPLOY_KEY }} diff --git a/.github/workflows/labrinth-build.yml b/.github/workflows/labrinth-build.yml index 5aba8298a3..966ec16f80 100644 --- a/.github/workflows/labrinth-build.yml +++ b/.github/workflows/labrinth-build.yml @@ -139,8 +139,8 @@ jobs: artifacts-name: labrinth-docker-context deploy: - needs: docker-build - if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/prod' }} + needs: [skip-if-clean, docker-build] + if: ${{ needs.skip-if-clean.outputs.internal == 'true' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/prod') }} uses: SparkUniverse/workflows/.github/workflows/argo-update.yaml@main secrets: ARGOCD_DEPLOY_KEY: ${{ secrets.ARGOCD_DEPLOY_KEY }} From 8b753a52ad5ca2a820bc4189e207728e405d7870 Mon Sep 17 00:00:00 2001 From: "Michael H." Date: Fri, 31 Jul 2026 14:45:16 +0200 Subject: [PATCH 10/82] fix: build labrinth on prod again --- .github/workflows/labrinth-build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/labrinth-build.yml b/.github/workflows/labrinth-build.yml index 966ec16f80..5fdc1ce080 100644 --- a/.github/workflows/labrinth-build.yml +++ b/.github/workflows/labrinth-build.yml @@ -4,6 +4,7 @@ on: push: branches: - 'main' + - 'prod' paths: - .github/workflows/labrinth-build.yml - 'apps/labrinth/**' From 1e49d7da7a141e5d9e5790e0264003824b56705c Mon Sep 17 00:00:00 2001 From: ThatGravyBoat Date: Sun, 2 Aug 2026 19:53:03 -0230 Subject: [PATCH 11/82] fix: missing auth providers in account details (#6965) * fix: missing auth providers in account details This was missing due to a bad merge in #6889 not merging in #6897 correctly * chore: run intl:extract --- .../layouts/shared/user-profile/layout.vue | 112 +++++++++++++++++- packages/ui/src/locales/en-US/index.json | 15 +++ 2 files changed, 125 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/layouts/shared/user-profile/layout.vue b/packages/ui/src/layouts/shared/user-profile/layout.vue index be4f1644b9..9c3b008a6b 100644 --- a/packages/ui/src/layouts/shared/user-profile/layout.vue +++ b/packages/ui/src/layouts/shared/user-profile/layout.vue @@ -111,7 +111,33 @@ {{ formatMessage(messages.authProvidersLabel) }} - {{ user.auth_providers?.join(', ') || '—' }} +
    +
  • + {{ authProviderNames[provider] ?? provider }} + + ({{ user.discord_id }}) + + + + ({{ user.steam_id }}) + +
  • +
@@ -460,7 +486,13 @@ import ProjectCardList from '#ui/components/project/ProjectCardList.vue' import UserBadges from '#ui/components/user/UserBadges.vue' import UserPageHeader from '#ui/components/user/UserPageHeader.vue' import { defineMessages, useVIntl } from '#ui/composables' -import { injectAuth, injectNotificationManager, injectPageContext, injectTags } from '#ui/providers' +import { + injectAuth, + injectModrinthClient, + injectNotificationManager, + injectPageContext, + injectTags, +} from '#ui/providers' import { commonMessages, getProjectTypeTitleMessage } from '#ui/utils' import { blockedUsersQueryKey, injectUserProfile } from './providers' @@ -520,6 +552,7 @@ const auth = injectAuth() const tags = injectTags(null) const pageContext = injectPageContext() const notificationManager = injectNotificationManager() +const client = injectModrinthClient() const queryClient = useQueryClient() const route = useRoute() const router = useRouter() @@ -567,6 +600,26 @@ const messages = defineMessages({ id: 'profile.details.label.auth-providers', defaultMessage: 'Auth providers', }, + viewGithubProfileLabel: { + id: 'profile.details.label.view-github-profile', + defaultMessage: 'View profile', + }, + loadingGithubProfileLabel: { + id: 'profile.details.label.loading-github-profile', + defaultMessage: 'Loading...', + }, + githubProfileErrorTitle: { + id: 'profile.details.error.github-profile-title', + defaultMessage: 'Unable to open GitHub profile', + }, + githubProfileErrorMessage: { + id: 'profile.details.error.github-profile-message', + defaultMessage: 'The GitHub profile could not be retrieved. Please try again.', + }, + githubPopupBlockedMessage: { + id: 'profile.details.error.github-popup-blocked', + defaultMessage: 'Allow pop-ups for Modrinth, then try again.', + }, paymentMethodsLabel: { id: 'profile.details.label.payment-methods', defaultMessage: 'Payment methods', @@ -853,6 +906,17 @@ const showCollectionsEmptyState = computed( const normalizedSiteUrl = computed(() => props.siteUrl.replace(/\/$/, '')) const editProfileLink = computed(() => props.editProfileLink ?? linkTarget('/settings/profile')) +const authProviderNames = { + github: 'GitHub', + discord: 'Discord', + microsoft: 'Microsoft', + gitlab: 'GitLab', + google: 'Google', + steam: 'Steam', + paypal: 'PayPal', +} +const isLoadingGithubProfile = ref(false) + function externalUrl(path: string): string { return `${normalizedSiteUrl.value}${path.startsWith('/') ? path : `/${path}`}` } @@ -896,6 +960,50 @@ async function copyPermalink(): Promise { } } +async function openGithubProfile() { + const githubId = user.value?.github_id + if (!githubId || isLoadingGithubProfile.value) return + + const profileWindow = window.open('about:blank', '_blank') + if (!profileWindow) { + notificationManager.addNotification({ + type: 'error', + title: formatMessage(messages.githubProfileErrorTitle), + text: formatMessage(messages.githubPopupBlockedMessage), + }) + return + } + + profileWindow.opener = null + isLoadingGithubProfile.value = true + + try { + const githubUser = await client.request<{ login?: string }>(`/${githubId}`, { + api: 'https://api.github.com', + version: 'user', + method: 'GET', + headers: { 'Content-Type': '' }, + skipAuth: true, + }) + + if (!githubUser?.login) { + throw new Error('GitHub user response did not include a login') + } + + profileWindow.location.replace(`https://github.com/${encodeURIComponent(githubUser.login)}`) + } catch (error) { + profileWindow.close() + console.error('Failed to retrieve GitHub profile:', error) + notificationManager.addNotification({ + type: 'error', + title: formatMessage(messages.githubProfileErrorTitle), + text: formatMessage(messages.githubProfileErrorMessage), + }) + } finally { + isLoadingGithubProfile.value = false + } +} + function reportProfile(): void { if (!user.value) return const reportPath = `/report?item=user&itemID=${encodeURIComponent(user.value.id)}` diff --git a/packages/ui/src/locales/en-US/index.json b/packages/ui/src/locales/en-US/index.json index e1c3941001..f842c47c3d 100644 --- a/packages/ui/src/locales/en-US/index.json +++ b/packages/ui/src/locales/en-US/index.json @@ -2921,6 +2921,15 @@ "profile.collection.projects-count": { "defaultMessage": "{count, plural, one {# project} other {# projects}}" }, + "profile.details.error.github-popup-blocked": { + "defaultMessage": "Allow pop-ups for Modrinth, then try again." + }, + "profile.details.error.github-profile-message": { + "defaultMessage": "The GitHub profile could not be retrieved. Please try again." + }, + "profile.details.error.github-profile-title": { + "defaultMessage": "Unable to open GitHub profile" + }, "profile.details.label.auth-providers": { "defaultMessage": "Auth providers" }, @@ -2933,9 +2942,15 @@ "profile.details.label.has-totp": { "defaultMessage": "Has TOTP" }, + "profile.details.label.loading-github-profile": { + "defaultMessage": "Loading..." + }, "profile.details.label.payment-methods": { "defaultMessage": "Payment methods" }, + "profile.details.label.view-github-profile": { + "defaultMessage": "View profile" + }, "profile.details.title": { "defaultMessage": "User details" }, From ae13d37edc2487fd0ebfd0f9c9aa096a63a99386 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?coco=20=F0=9F=90=BE?= <44563370+cocoelacanth@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:49:19 -0700 Subject: [PATCH 12/82] docs: corrections to GetLatestVersionFromHash and GetLatestVersionsFromHashes documentation (#6966) correct version_files documentation --- apps/docs/public/openapi.yaml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/docs/public/openapi.yaml b/apps/docs/public/openapi.yaml index be4c40ede2..0e098712ea 100644 --- a/apps/docs/public/openapi.yaml +++ b/apps/docs/public/openapi.yaml @@ -563,12 +563,18 @@ components: type: array items: type: string - example: [fabric] + example: [fabric] game_versions: type: array items: type: string example: ['1.18', 1.18.1] + version_types: + type: array + items: + type: string + enum: [release, alpha, beta] + example: [release] required: - loaders - game_versions @@ -612,6 +618,12 @@ components: items: type: string example: ['1.18', 1.18.1] + version_types: + type: array + items: + type: string + enum: [release, alpha, beta] + example: [release] required: - loaders - game_versions @@ -2865,7 +2877,7 @@ paths: $ref: '#/components/schemas/HashList' /version_files/update: post: - summary: Latest versions of multiple project from hashes, loader(s), and game version(s) + summary: Latest versions of multiple projects from hashes, loader(s), and game version(s) description: This is the same as [`/version_file/{hash}/update`](#operation/getLatestVersionFromHash) except it accepts multiple hashes. operationId: getLatestVersionsFromHashes tags: From 779edf03337114da11a2521776bbf364f4f51848 Mon Sep 17 00:00:00 2001 From: Prospector <6166773+Prospector@users.noreply.github.com> Date: Mon, 3 Aug 2026 00:00:54 -0700 Subject: [PATCH 13/82] Fix server ping race condition before protocol version is available (#6935) * fix race condition with server ping lookup before protocol resolved * fix refresh button on worlds page --- apps/app-frontend/src/helpers/worlds.ts | 16 ++++-- .../app-frontend/src/locales/en-US/index.json | 3 ++ .../src/pages/instance/Worlds.vue | 54 ++++++++++++++++--- 3 files changed, 62 insertions(+), 11 deletions(-) diff --git a/apps/app-frontend/src/helpers/worlds.ts b/apps/app-frontend/src/helpers/worlds.ts index 3b5285f51b..5eb3385497 100644 --- a/apps/app-frontend/src/helpers/worlds.ts +++ b/apps/app-frontend/src/helpers/worlds.ts @@ -435,11 +435,12 @@ export async function refreshServerData( } } -export function refreshServers( +export async function refreshServers( worlds: World[], serverData: Record, protocolVersion: ProtocolVersion | null, -) { + ping = true, +): Promise { const servers = worlds.filter(isServerWorld) servers.forEach((server) => { if (!serverData[server.address]) { @@ -451,9 +452,14 @@ export function refreshServers( } }) - // noinspection ES6MissingAwait - handled by refreshServerData - Object.keys(serverData).forEach((address) => - refreshServerData(serverData[address], protocolVersion, address), + if (!ping) { + return + } + + await Promise.all( + Object.keys(serverData).map((address) => + refreshServerData(serverData[address], protocolVersion, address), + ), ) } diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index 91ff2a64ff..475e1027ca 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -590,6 +590,9 @@ "app.instance.worlds.no-worlds-heading": { "message": "No servers or worlds added" }, + "app.instance.worlds.refreshing": { + "message": "Refreshing..." + }, "app.instance.worlds.remove-server-modal.remove-button": { "message": "Remove server" }, diff --git a/apps/app-frontend/src/pages/instance/Worlds.vue b/apps/app-frontend/src/pages/instance/Worlds.vue index 1ddcb5e943..4a5bd6192d 100644 --- a/apps/app-frontend/src/pages/instance/Worlds.vue +++ b/apps/app-frontend/src/pages/instance/Worlds.vue @@ -75,7 +75,11 @@
@@ -242,6 +246,10 @@ const messages = defineMessages({ id: 'app.instance.worlds.filter-offline', defaultMessage: 'Offline', }, + refreshingButton: { + id: 'app.instance.worlds.refreshing', + defaultMessage: 'Refreshing...', + }, }) const { formatMessage } = useVIntl() @@ -328,7 +336,7 @@ const isLinux = platform() === 'linux' const linuxRefreshCount = ref(0) const protocolVersion = ref(null) - +const protocolVersionReady = ref(false) const gameVersions = ref([]) const supportsServerQuickPlay = computed(() => hasServerQuickPlaySupport(gameVersions.value, instance.value.game_version), @@ -342,8 +350,16 @@ watch( (data) => { if (data) { worlds.value = [...data] - refreshServers(worlds.value, serverData.value, protocolVersion.value) hadNoWorlds.value = worlds.value.length === 0 + // Manual refresh handles its own server pings to avoid double-pinging + if (!refreshingAll.value) { + void refreshServers( + worlds.value, + serverData.value, + protocolVersion.value, + protocolVersionReady.value, + ) + } } }, { immediate: true }, @@ -443,9 +459,14 @@ async function initWorldsTab() { unlistenInstance = _unlistenInstance protocolVersion.value = resolvedProtocolVersion gameVersions.value = resolvedGameVersions + protocolVersionReady.value = true + + if (worlds.value.length > 0) { + refreshServers(worlds.value, serverData.value, protocolVersion.value) + } } -await initWorldsTab() +void initWorldsTab() async function refreshServer(address: string) { if (!serverData.value[address]) { @@ -453,6 +474,7 @@ async function refreshServer(address: string) { refreshing: true, } } + if (!protocolVersionReady.value) return await refreshServerData(serverData.value[address], protocolVersion.value, address) } @@ -463,8 +485,28 @@ async function refreshAllWorlds() { } refreshingAll.value = true - await queryClient.invalidateQueries({ queryKey: ['worlds', instance.value.id] }) - refreshingAll.value = false + try { + // Show loading on server rows immediately while the list refreshes + for (const world of worlds.value) { + if (world.type === 'server') { + if (!serverData.value[world.address]) { + serverData.value[world.address] = { refreshing: true } + } else { + serverData.value[world.address].refreshing = true + } + } + } + + await queryClient.invalidateQueries({ queryKey: ['worlds', instance.value.id] }) + await refreshServers( + worlds.value, + serverData.value, + protocolVersion.value, + protocolVersionReady.value, + ) + } finally { + refreshingAll.value = false + } } async function addServer(server: ServerWorld) { From 2e43f6a42bd10cbc31cc3ccc704e2b11674b4ef3 Mon Sep 17 00:00:00 2001 From: Prospector <6166773+Prospector@users.noreply.github.com> Date: Mon, 3 Aug 2026 00:03:45 -0700 Subject: [PATCH 14/82] collapse offline friends by default, save collapse value (#6937) --- .../src/components/ui/friends/FriendsList.vue | 38 +++++++++++++++++-- .../components/ui/friends/FriendsSection.vue | 7 ++++ apps/app-frontend/src/store/theme.ts | 4 ++ packages/app-lib/src/state/settings.rs | 4 ++ 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/apps/app-frontend/src/components/ui/friends/FriendsList.vue b/apps/app-frontend/src/components/ui/friends/FriendsList.vue index 71c8c1c8fe..1b954325ac 100644 --- a/apps/app-frontend/src/components/ui/friends/FriendsList.vue +++ b/apps/app-frontend/src/components/ui/friends/FriendsList.vue @@ -17,17 +17,40 @@ import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import { useFriends } from '@/composables/use-friends' import type { FriendWithUserData } from '@/helpers/friends.ts' import type { ModrinthCredentials } from '@/helpers/mr_auth' +import { get as getSettings, set as setSettings } from '@/helpers/settings.ts' +import { useTheming } from '@/store/state' const { formatMessage } = useVIntl() const { handleError } = injectNotificationManager() const formatRelativeTime = useRelativeTime() +const themeStore = useTheming() const props = defineProps<{ credentials: ModrinthCredentials | null signIn: () => void }>() +type FriendsSectionCollapsedFlag = + | 'friends_active_collapsed' + | 'friends_online_collapsed' + | 'friends_offline_collapsed' + | 'friends_pending_collapsed' + +function isFriendsSectionCollapsed(flag: FriendsSectionCollapsedFlag) { + return themeStore.getFeatureFlag(flag) +} + +function setFriendsSectionCollapsed(flag: FriendsSectionCollapsedFlag, collapsed: boolean) { + themeStore.featureFlags[flag] = collapsed + getSettings() + .then((settings) => { + settings.feature_flags[flag] = collapsed + return setSettings(settings) + }) + .catch(handleError) +} + const userCredentials = computed(() => props.credentials) const { friends: userFriends, @@ -331,33 +354,42 @@ const messages = defineMessages({

{{ formatMessage(messages.noFriendsMatch, { query: search }) }} diff --git a/apps/app-frontend/src/components/ui/friends/FriendsSection.vue b/apps/app-frontend/src/components/ui/friends/FriendsSection.vue index f9efeb79e6..ec453e6c38 100644 --- a/apps/app-frontend/src/components/ui/friends/FriendsSection.vue +++ b/apps/app-frontend/src/components/ui/friends/FriendsSection.vue @@ -31,6 +31,11 @@ const props = withDefaults( }, ) +const emit = defineEmits<{ + onOpen: [] + onClose: [] +}>() + function createContextMenuOptions(friend: FriendWithUserData) { if (friend.accepted) { return [ @@ -112,6 +117,8 @@ const messages = defineMessages({ ? '' : ' cursor-pointer hover:brightness-[--hover-brightness] active:scale-[0.98] transition-all') " + @on-open="emit('onOpen')" + @on-close="emit('onClose')" >

- - - +
@@ -95,8 +97,9 @@ import { import { Accordion, Avatar, - ButtonStyled, + Button, defineMessages, + IconButton, injectNotificationManager, useVIntl, } from '@modrinth/ui' diff --git a/apps/app-frontend/src/components/ui/AddContentButton.vue b/apps/app-frontend/src/components/ui/AddContentButton.vue index cdea116e87..65f6397a9e 100644 --- a/apps/app-frontend/src/components/ui/AddContentButton.vue +++ b/apps/app-frontend/src/components/ui/AddContentButton.vue @@ -1,6 +1,6 @@ diff --git a/apps/frontend/src/components/ui/NotificationItem.vue b/apps/frontend/src/components/ui/NotificationItem.vue index 1bbac8bf38..4f3cfa001b 100644 --- a/apps/frontend/src/components/ui/NotificationItem.vue +++ b/apps/frontend/src/components/ui/NotificationItem.vue @@ -46,18 +46,18 @@ class="flex flex-wrap items-center gap-3" :class="{ 'gap-2': compact }" > - - - - - - + +
+ + + +
+
+
+ - - - -
-
-
- - - - +
- - - - Open link - - - - - - - - + + + Open link + + +
@@ -390,10 +399,12 @@ import { } from '@modrinth/assets' import { Avatar, - ButtonStyled, + Button, + ButtonLink, Categories, CopyCode, DoubleIcon, + IconButton, injectModrinthClient, injectNotificationManager, ProjectStatusBadge, diff --git a/apps/frontend/src/components/ui/OrganizationPageHeader.vue b/apps/frontend/src/components/ui/OrganizationPageHeader.vue index c5f74542df..6063423a50 100644 --- a/apps/frontend/src/components/ui/OrganizationPageHeader.vue +++ b/apps/frontend/src/components/ui/OrganizationPageHeader.vue @@ -38,21 +38,19 @@ @@ -68,18 +66,17 @@ import { SettingsIcon, UsersIcon, } from '@modrinth/assets' +import { ButtonLink, TeleportOverflowMenu } from '@modrinth/ui' import { Avatar, - ButtonStyled, commonMessages, defineMessages, + type OverflowMenuOption, PageHeader, PageHeaderActions, PageHeaderBadgeItem, PageHeaderMetadata, PageHeaderMetadataNumberItem, - TeleportOverflowMenu, - type TeleportOverflowMenuItem, useFormatNumber, useVIntl, } from '@modrinth/ui' @@ -135,7 +132,7 @@ const emit = defineEmits<{ const { formatMessage } = useVIntl() const formatNumber = useFormatNumber() -const moreActions = computed(() => [ +const moreActions = computed(() => [ { id: 'manage-projects', label: formatMessage(messages.manageProjects), @@ -143,10 +140,7 @@ const moreActions = computed(() => [ action: () => emit('manageProjects'), shown: props.canManage, }, - { - divider: true, - shown: props.canManage, - }, + { type: 'divider', shown: props.canManage }, { id: 'copy-id', label: formatMessage(commonMessages.copyIdButton), diff --git a/apps/frontend/src/components/ui/OrganizationProjectTransferModal.vue b/apps/frontend/src/components/ui/OrganizationProjectTransferModal.vue index 6f66486fd0..46524dbff5 100644 --- a/apps/frontend/src/components/ui/OrganizationProjectTransferModal.vue +++ b/apps/frontend/src/components/ui/OrganizationProjectTransferModal.vue @@ -65,38 +65,37 @@
- - - +
- - - - diff --git a/packages/ui/src/components/base/ButtonStyled.vue b/packages/ui/src/components/base/ButtonStyled.vue deleted file mode 100644 index f546054c16..0000000000 --- a/packages/ui/src/components/base/ButtonStyled.vue +++ /dev/null @@ -1,397 +0,0 @@ - - - - - diff --git a/packages/ui/src/components/base/Card.vue b/packages/ui/src/components/base/Card.vue index f52f4533b0..c481f17eb0 100644 --- a/packages/ui/src/components/base/Card.vue +++ b/packages/ui/src/components/base/Card.vue @@ -2,7 +2,7 @@ import { DropdownIcon } from '@modrinth/assets' import { reactive } from 'vue' -import ButtonStyled from './ButtonStyled.vue' +import { IconButton } from '#ui/components/base/buttons' const props = defineProps({ collapsible: { @@ -33,11 +33,9 @@ function toggleCollapsed() {
- - - + + +
diff --git a/packages/ui/src/components/base/Chips.vue b/packages/ui/src/components/base/Chips.vue index e1359c469e..e7daddc930 100644 --- a/packages/ui/src/components/base/Chips.vue +++ b/packages/ui/src/components/base/Chips.vue @@ -15,7 +15,11 @@ }" @click="toggleItem(item)" > - +