diff --git a/.vscode/settings.json b/.vscode/settings.json index 7522198819..af3d483835 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,7 +9,7 @@ "files.insertFinalNewline": true, "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit", - "source.organizeImports": "always" + "source.organizeImports": "never" }, "editor.defaultFormatter": "esbenp.prettier-vscode", "[vue]": { diff --git a/apps/app-frontend/src/pages/hosting/manage/Access.vue b/apps/app-frontend/src/pages/hosting/manage/Access.vue new file mode 100644 index 0000000000..59a3b82e36 --- /dev/null +++ b/apps/app-frontend/src/pages/hosting/manage/Access.vue @@ -0,0 +1,33 @@ + + + diff --git a/apps/app-frontend/src/pages/hosting/manage/index.js b/apps/app-frontend/src/pages/hosting/manage/index.js index 50052e3f9e..0c10b07133 100644 --- a/apps/app-frontend/src/pages/hosting/manage/index.js +++ b/apps/app-frontend/src/pages/hosting/manage/index.js @@ -1,7 +1,8 @@ +import Access from './Access.vue' import Backups from './Backups.vue' import Content from './Content.vue' import Files from './Files.vue' import Index from './Index.vue' import Overview from './Overview.vue' -export { Backups, Content, Files, Index, Overview } +export { Access, Backups, Content, Files, Index, Overview } diff --git a/apps/app-frontend/src/routes.js b/apps/app-frontend/src/routes.js index c12306b5ad..2057d67e17 100644 --- a/apps/app-frontend/src/routes.js +++ b/apps/app-frontend/src/routes.js @@ -73,6 +73,14 @@ export default new createRouter({ breadcrumb: [{ name: '?Server' }], }, }, + { + path: 'access', + name: 'ServerManageAccess', + component: Hosting.Access, + meta: { + breadcrumb: [{ name: '?Server' }], + }, + }, ], }, { diff --git a/apps/frontend/CLAUDE.md b/apps/frontend/CLAUDE.md index 9d0d05c4df..03515cbb31 100644 --- a/apps/frontend/CLAUDE.md +++ b/apps/frontend/CLAUDE.md @@ -40,4 +40,3 @@ These composables are deprecated and should not be used in new code: - **`useAsyncData`** - we use tanstack, not nuxt's built in async data utility. - **`useBaseFetch`** (`src/composables/fetch.js`) — legacy Labrinth fetch wrapper. Use `client.labrinth.*` modules instead. -- **`useServersFetch`** (`src/composables/servers/servers-fetch.ts`) — legacy Archon fetch wrapper with manual retry/circuit-breaker. Use `client.archon.*` modules instead — refer to the `packages/api-client/CLAUDE.md` for more information. diff --git a/apps/frontend/src/components/ui/admin/AssignNoticeModal.vue b/apps/frontend/src/components/ui/admin/AssignNoticeModal.vue index 4a303112a0..a52bb5c01e 100644 --- a/apps/frontend/src/components/ui/admin/AssignNoticeModal.vue +++ b/apps/frontend/src/components/ui/admin/AssignNoticeModal.vue @@ -1,20 +1,22 @@ + + diff --git a/apps/frontend/src/templates/emails/index.ts b/apps/frontend/src/templates/emails/index.ts index 719611b64b..015c1fdeb9 100644 --- a/apps/frontend/src/templates/emails/index.ts +++ b/apps/frontend/src/templates/emails/index.ts @@ -32,6 +32,10 @@ export default { 'project-invited': () => import('./project/ProjectInvited.vue'), 'project-transferred': () => import('./project/ProjectTransferred.vue'), + // Server + 'server-invited': () => import('./server/ServerInvited.vue'), + 'server-invited-no-account': () => import('./server/ServerInvitedNoAccount.vue'), + // Organizations 'organization-invited': () => import('./organization/OrganizationInvited.vue'), } as Record Promise<{ default: Component }>> diff --git a/apps/frontend/src/templates/emails/server/ServerInvited.vue b/apps/frontend/src/templates/emails/server/ServerInvited.vue new file mode 100644 index 0000000000..b3876aa3e3 --- /dev/null +++ b/apps/frontend/src/templates/emails/server/ServerInvited.vue @@ -0,0 +1,82 @@ + + + diff --git a/apps/frontend/src/templates/emails/server/ServerInvitedNoAccount.vue b/apps/frontend/src/templates/emails/server/ServerInvitedNoAccount.vue new file mode 100644 index 0000000000..07b6f7cbee --- /dev/null +++ b/apps/frontend/src/templates/emails/server/ServerInvitedNoAccount.vue @@ -0,0 +1,80 @@ + + + diff --git a/apps/labrinth/.sqlx/query-147f31c59219c5485531914897618427375b203777a68fd6ece1a50feaf41df9.json b/apps/labrinth/.sqlx/query-147f31c59219c5485531914897618427375b203777a68fd6ece1a50feaf41df9.json new file mode 100644 index 0000000000..b7e45e18a1 --- /dev/null +++ b/apps/labrinth/.sqlx/query-147f31c59219c5485531914897618427375b203777a68fd6ece1a50feaf41df9.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id\n FROM notifications\n WHERE body @> $1::jsonb\n AND user_id = ANY($2::bigint[])\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Jsonb", + "Int8Array" + ] + }, + "nullable": [ + false + ] + }, + "hash": "147f31c59219c5485531914897618427375b203777a68fd6ece1a50feaf41df9" +} diff --git a/apps/labrinth/migrations/20260525120000_server-invite-notification.sql b/apps/labrinth/migrations/20260525120000_server-invite-notification.sql new file mode 100644 index 0000000000..db1fb6758c --- /dev/null +++ b/apps/labrinth/migrations/20260525120000_server-invite-notification.sql @@ -0,0 +1,28 @@ +INSERT INTO notifications_types + (name, delivery_priority, expose_in_user_preferences, expose_in_site_notifications) +VALUES ('server_invite', 1, FALSE, TRUE); + +INSERT INTO users_notifications_preferences (user_id, channel, notification_type, enabled) +VALUES (NULL, 'email', 'server_invite', FALSE); + +INSERT INTO notifications_templates + (channel, notification_type, subject_line, body_fetch_url, plaintext_fallback) +VALUES + ( + 'email', + 'server_invite', + 'You''ve been invited to a server', + 'https://modrinth.com/_internal/templates/email/server-invited', + CONCAT( + 'Hi {user.name},', + CHR(10), + CHR(10), + 'Modrinth user {inviter.name} has invited you to help manage {server.name} on Modrinth Hosting with the {server.role} role.', + CHR(10), + CHR(10), + 'To accept or reject this invitation, open your Modrinth notifications: https://modrinth.com/dashboard/notifications', + CHR(10), + CHR(10), + 'If you were not expecting this invitation, contact the server owner or reach out to Modrinth Support at https://support.modrinth.com' + ) + ); diff --git a/apps/labrinth/src/database/models/notification_item.rs b/apps/labrinth/src/database/models/notification_item.rs index c25858ef29..5ad8c897b4 100644 --- a/apps/labrinth/src/database/models/notification_item.rs +++ b/apps/labrinth/src/database/models/notification_item.rs @@ -129,12 +129,11 @@ impl NotificationBuilder { Ok(()) } - pub async fn insert_many( + async fn insert_many_records( &self, - users: Vec, + users: &[DBUserId], transaction: &mut PgTransaction<'_>, - redis: &RedisPool, - ) -> Result<(), DatabaseError> { + ) -> Result, DatabaseError> { let notification_ids = generate_many_notification_ids(users.len(), &mut *transaction) .await?; @@ -163,6 +162,20 @@ impl NotificationBuilder { .execute(&mut *transaction) .await?; + Ok(notification_ids) + } + + pub async fn insert_many( + &self, + users: Vec, + transaction: &mut PgTransaction<'_>, + redis: &RedisPool, + ) -> Result<(), DatabaseError> { + let notification_ids = + self.insert_many_records(&users, transaction).await?; + + let users_raw_ids = users.iter().map(|x| x.0).collect::>(); + let notification_types = notification_ids .iter() .map(|_| self.body.notification_type().as_str()) @@ -181,6 +194,19 @@ impl NotificationBuilder { Ok(()) } + /// Like [`insert_many`], but skips queuing deliveries so the caller can + /// manually send the notifications. + pub async fn insert_many_without_delivery( + &self, + users: Vec, + transaction: &mut PgTransaction<'_>, + redis: &RedisPool, + ) -> Result<(), DatabaseError> { + self.insert_many_records(&users, transaction).await?; + DBNotification::clear_user_notifications_cache(&users, redis).await?; + Ok(()) + } + pub async fn insert_many_deliveries( transaction: &mut PgTransaction<'_>, redis: &RedisPool, @@ -571,6 +597,38 @@ impl DBNotification { Ok(Some(())) } + pub async fn remove_many_matching_body( + body_filter: &serde_json::Value, + users: &[DBUserId], + transaction: &mut PgTransaction<'_>, + redis: &RedisPool, + ) -> Result { + let user_ids = users.iter().map(|x| x.0).collect::>(); + + let ids = sqlx::query!( + " + SELECT id + FROM notifications + WHERE body @> $1::jsonb + AND user_id = ANY($2::bigint[]) + ", + body_filter, + &user_ids + ) + .fetch(&mut *transaction) + .map_ok(|x| DBNotificationId(x.id)) + .try_collect::>() + .await?; + + if ids.is_empty() { + return Ok(0); + } + + Self::remove_many(&ids, transaction, redis).await?; + + Ok(ids.len()) + } + pub async fn clear_user_notifications_cache( user_ids: impl IntoIterator, redis: &RedisPool, diff --git a/apps/labrinth/src/models/v2/notifications.rs b/apps/labrinth/src/models/v2/notifications.rs index 66252e5dbb..0f88942394 100644 --- a/apps/labrinth/src/models/v2/notifications.rs +++ b/apps/labrinth/src/models/v2/notifications.rs @@ -11,6 +11,7 @@ use crate::models::{ use ariadne::ids::UserId; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use uuid::Uuid; #[derive(Serialize, Deserialize)] pub struct LegacyNotification { @@ -66,6 +67,12 @@ pub enum LegacyNotificationBody { team_id: TeamId, role: String, }, + ServerInvite { + server_id: Uuid, + server_name: String, + invited_by: UserId, + role: String, + }, StatusChange { project_id: ProjectId, old_status: ProjectStatus, @@ -166,6 +173,9 @@ impl LegacyNotification { NotificationBody::OrganizationInvite { .. } => { Some("organization_invite".to_string()) } + NotificationBody::ServerInvite { .. } => { + Some("server_invite".to_string()) + } NotificationBody::StatusChange { .. } => { Some("status_change".to_string()) } @@ -269,6 +279,17 @@ impl LegacyNotification { team_id, role, }, + NotificationBody::ServerInvite { + server_id, + server_name, + invited_by, + role, + } => LegacyNotificationBody::ServerInvite { + server_id, + server_name, + invited_by, + role, + }, NotificationBody::StatusChange { project_id, old_status, diff --git a/apps/labrinth/src/models/v3/notifications.rs b/apps/labrinth/src/models/v3/notifications.rs index dc9ea448a6..70823bf30f 100644 --- a/apps/labrinth/src/models/v3/notifications.rs +++ b/apps/labrinth/src/models/v3/notifications.rs @@ -12,6 +12,7 @@ use crate::routes::ApiError; use ariadne::ids::UserId; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use uuid::Uuid; #[derive(Serialize, Deserialize)] pub struct Notification { @@ -34,6 +35,7 @@ pub enum NotificationType { ProjectUpdate, TeamInvite, OrganizationInvite, + ServerInvite, StatusChange, ModeratorMessage, LegacyMarkdown, @@ -67,6 +69,7 @@ impl NotificationType { NotificationType::ProjectUpdate => "project_update", NotificationType::TeamInvite => "team_invite", NotificationType::OrganizationInvite => "organization_invite", + NotificationType::ServerInvite => "server_invite", NotificationType::StatusChange => "status_change", NotificationType::ModeratorMessage => "moderator_message", NotificationType::LegacyMarkdown => "legacy_markdown", @@ -104,6 +107,7 @@ impl NotificationType { "project_update" => NotificationType::ProjectUpdate, "team_invite" => NotificationType::TeamInvite, "organization_invite" => NotificationType::OrganizationInvite, + "server_invite" => NotificationType::ServerInvite, "status_change" => NotificationType::StatusChange, "moderator_message" => NotificationType::ModeratorMessage, "legacy_markdown" => NotificationType::LegacyMarkdown, @@ -156,6 +160,12 @@ pub enum NotificationBody { team_id: TeamId, role: String, }, + ServerInvite { + server_id: Uuid, + server_name: String, + invited_by: UserId, + role: String, + }, StatusChange { project_id: ProjectId, old_status: ProjectStatus, @@ -267,6 +277,9 @@ impl NotificationBody { NotificationBody::OrganizationInvite { .. } => { NotificationType::OrganizationInvite } + NotificationBody::ServerInvite { .. } => { + NotificationType::ServerInvite + } NotificationBody::StatusChange { .. } => { NotificationType::StatusChange } @@ -418,6 +431,34 @@ impl From for Notification { }, ], ), + NotificationBody::ServerInvite { + server_id, + server_name, + role, + .. + } => ( + "You have been invited to join a server!".to_string(), + format!( + "An invite has been sent for you to be {role} of {server_name}" + ), + "#".to_string(), + vec![ + NotificationAction { + name: "Accept".to_string(), + action_route: ( + "POST".to_string(), + format!("hosting/servers/{server_id}/invite/accept"), + ), + }, + NotificationAction { + name: "Deny".to_string(), + action_route: ( + "POST".to_string(), + format!("hosting/servers/{server_id}/invite/deny"), + ), + }, + ], + ), NotificationBody::StatusChange { old_status, new_status, diff --git a/apps/labrinth/src/queue/email/templates.rs b/apps/labrinth/src/queue/email/templates.rs index e1d3b97d0c..5638947da5 100644 --- a/apps/labrinth/src/queue/email/templates.rs +++ b/apps/labrinth/src/queue/email/templates.rs @@ -61,6 +61,10 @@ const ORGINVITE_INVITER_NAME: &str = "organizationinvite.inviter.name"; const ORGINVITE_ORG_NAME: &str = "organizationinvite.organization.name"; const ORGINVITE_ROLE_NAME: &str = "organizationinvite.role.name"; +const SERVERINVITE_INVITER_NAME: &str = "inviter.name"; +const SERVERINVITE_SERVER_NAME: &str = "server.name"; +const SERVERINVITE_ROLE_NAME: &str = "server.role"; + const STATUSCHANGE_PROJECT_NAME: &str = "statuschange.project.name"; const STATUSCHANGE_OLD_STATUS: &str = "statuschange.old.status"; const STATUSCHANGE_NEW_STATUS: &str = "statuschange.new.status"; @@ -735,6 +739,27 @@ async fn collect_template_variables( title: title.to_string(), }), + NotificationBody::ServerInvite { + server_name, + invited_by, + role, + .. + } => { + let inviter = DBUser::get_id( + DBUserId(invited_by.0 as i64), + &mut *exec, + redis, + ) + .await? + .ok_or_else(|| DatabaseError::Database(sqlx::Error::RowNotFound))?; + + map.insert(SERVERINVITE_INVITER_NAME, inviter.username); + map.insert(SERVERINVITE_SERVER_NAME, server_name.clone()); + map.insert(SERVERINVITE_ROLE_NAME, role.clone()); + + Ok(EmailTemplate::Static(map)) + } + NotificationBody::ProjectUpdate { .. } | NotificationBody::ModeratorMessage { .. } | NotificationBody::LegacyMarkdown { .. } diff --git a/apps/labrinth/src/routes/internal/external_notifications.rs b/apps/labrinth/src/routes/internal/external_notifications.rs index def435071a..802d606532 100644 --- a/apps/labrinth/src/routes/internal/external_notifications.rs +++ b/apps/labrinth/src/routes/internal/external_notifications.rs @@ -1,23 +1,34 @@ use crate::auth::get_user_from_headers; use crate::database::PgPool; use crate::database::models::ids::DBUserId; +use crate::database::models::notification_item::DBNotification; use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::user_item::DBUser; use crate::database::redis::RedisPool; use crate::models::users::Role; -use crate::models::v3::notifications::NotificationBody; +use crate::models::v3::notifications::{ + NotificationBody, NotificationDeliveryStatus, +}; use crate::models::v3::pats::Scopes; +use crate::queue::email::EmailQueue; use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::util::guards::external_notification_key_guard; -use actix_web::HttpRequest; +use actix_web::http::StatusCode; use actix_web::web; -use actix_web::{HttpResponse, post}; +use actix_web::{ + CustomizeResponder, HttpRequest, HttpResponse, Responder, delete, post, +}; use ariadne::ids::UserId; +use eyre::eyre; +use lettre::message::Mailbox; use serde::Deserialize; pub fn config(cfg: &mut web::ServiceConfig) { - cfg.service(create).service(send_custom_email); + cfg.service(create) + .service(create_email_sync) + .service(remove) + .service(send_custom_email); } #[derive(Deserialize)] @@ -56,6 +67,127 @@ pub async fn create( Ok(HttpResponse::Accepted().finish()) } +/// Inserts notifications for all users and tries to send emails immediately. +/// +/// Responds with the user IDs that could not be emailed: +/// - `200` if every recipient was emailed (empty list) +/// - `207` if some recipients could not be emailed (list of failed IDs) +#[post( + "external_notifications/email-sync", + guard = "external_notification_key_guard" +)] +pub async fn create_email_sync( + pool: web::Data, + redis: web::Data, + email_queue: web::Data, + create_notification: web::Json, +) -> Result>>, ApiError> { + let CreateNotification { body, user_ids } = + create_notification.into_inner(); + let user_ids = user_ids + .into_iter() + .map(|x| DBUserId(x.0 as i64)) + .collect::>(); + + let mut txn = pool.begin().await?; + + if !DBUser::exists_many(&user_ids, &mut txn).await? { + return Err(ApiError::InvalidInput( + "One of the specified users do not exist.".to_owned(), + )); + } + + NotificationBuilder { body: body.clone() } + .insert_many_without_delivery(user_ids.clone(), &mut txn, &redis) + .await?; + + txn.commit().await?; + + let mut email_txn = pool.begin().await?; + + let mut failed = Vec::new(); + for user_id in &user_ids { + let Some(user) = + DBUser::get_id(*user_id, &mut email_txn, &redis).await? + else { + failed.push(UserId(user_id.0 as u64)); + continue; + }; + + let delivered = match user + .email + .and_then(|email| email.parse::().ok()) + { + Some(mailbox) => { + email_queue + .send_one(&mut email_txn, body.clone(), *user_id, mailbox) + .await? + == NotificationDeliveryStatus::Delivered + } + None => false, + }; + + if !delivered { + failed.push(UserId(user_id.0 as u64)); + } + } + + let status = if failed.is_empty() { + StatusCode::OK + } else { + StatusCode::MULTI_STATUS + }; + + Ok(web::Json(failed).customize().with_status(status)) +} + +#[derive(Deserialize)] +struct NotificationFilter { + pub user_ids: Vec, + #[serde(flatten)] + pub body: serde_json::Map, +} + +#[delete("external_notifications", guard = "external_notification_key_guard")] +pub async fn remove( + pool: web::Data, + redis: web::Data, + notification_filter: web::Json, +) -> Result { + let NotificationFilter { user_ids, body } = + notification_filter.into_inner(); + + if user_ids.is_empty() { + return Err(ApiError::Request(eyre!( + "at least one user must be provided to remove notifications from" + ))); + } + + if body.is_empty() { + return Err(ApiError::Request(eyre!( + "at least one `body` field must be provided to match notifications" + ))); + } + + let filters = serde_json::Value::Object(body); + + let user_ids = user_ids + .into_iter() + .map(|x| DBUserId(x.0 as i64)) + .collect::>(); + + let mut txn = pool.begin().await?; + + DBNotification::remove_many_matching_body( + &filters, &user_ids, &mut txn, &redis, + ) + .await?; + + txn.commit().await?; + + Ok(HttpResponse::NoContent().finish()) +} + #[derive(Deserialize)] struct SendEmail { pub users: Vec, diff --git a/apps/labrinth/src/routes/mod.rs b/apps/labrinth/src/routes/mod.rs index 178a8a706b..2a653aeb56 100644 --- a/apps/labrinth/src/routes/mod.rs +++ b/apps/labrinth/src/routes/mod.rs @@ -34,6 +34,11 @@ pub fn root_config(cfg: &mut web::ServiceConfig) { .wrap(default_cors()) .configure(updates::config), ); + cfg.service( + web::scope("/hosting") + .wrap(default_cors()) + .service(web::Redirect::to(ENV.ARCHON_URL.to_string()).permanent()), + ); cfg.service( web::scope("/analytics") .wrap( diff --git a/packages/api-client/src/core/abstract-client.ts b/packages/api-client/src/core/abstract-client.ts index 97ce38dd1b..70c2f1584f 100644 --- a/packages/api-client/src/core/abstract-client.ts +++ b/packages/api-client/src/core/abstract-client.ts @@ -1,10 +1,11 @@ import type { InferredClientModules } from '../modules' import { buildModuleStructure } from '../modules' -import type { ClientConfig } from '../types/client' +import type { BaseUrlConfig, ClientConfig } from '../types/client' import type { RequestContext, RequestOptions } from '../types/request' import type { UploadMetadata, UploadProgress, UploadRequestOptions } from '../types/upload' import type { AbstractFeature } from './abstract-feature' import type { AbstractModule } from './abstract-module' +import type { AbstractSyncClient } from './abstract-sync' import { AbstractUploadClient } from './abstract-upload-client' import type { AbstractWebSocketClient } from './abstract-websocket' import { ModrinthApiError, ModrinthServerError } from './errors' @@ -32,7 +33,10 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient { private _moduleNamespaces: Map> = new Map() public readonly labrinth!: InferredClientModules['labrinth'] - public readonly archon!: ArchonClientModules & { sockets: AbstractWebSocketClient } + public readonly archon!: ArchonClientModules & { + sockets: AbstractWebSocketClient + sync: AbstractSyncClient + } public readonly kyros!: InferredClientModules['kyros'] public readonly iso3166!: InferredClientModules['iso3166'] public readonly mclogs!: InferredClientModules['mclogs'] @@ -116,9 +120,9 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient { async request(path: string, options: RequestOptions): Promise { let baseUrl: string if (options.api === 'labrinth') { - baseUrl = this.config.labrinthBaseUrl! + baseUrl = this.resolveBaseUrl(this.config.labrinthBaseUrl!) } else if (options.api === 'archon') { - baseUrl = this.config.archonBaseUrl! + baseUrl = this.resolveBaseUrl(this.config.archonBaseUrl!) } else { baseUrl = options.api } @@ -160,13 +164,55 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient { } } + async stream(path: string, options: RequestOptions): Promise> { + let baseUrl: string + if (options.api === 'labrinth') { + baseUrl = this.resolveBaseUrl(this.config.labrinthBaseUrl!) + } else if (options.api === 'archon') { + baseUrl = this.resolveBaseUrl(this.config.archonBaseUrl!) + } else { + baseUrl = options.api + } + + const url = this.buildUrl(path, baseUrl, options.version) + const defaultHeaders = await this.buildDefaultHeaders() + const mergedOptions: RequestOptions = { + method: 'GET', + retry: false, + circuitBreaker: false, + ...options, + headers: { + ...defaultHeaders, + Accept: 'text/event-stream', + ...options.headers, + }, + } + this.attachArchonSentryCaptureHeader(mergedOptions) + + const context = this.buildContext(url, path, mergedOptions) + + try { + return await this.executeFeatureChain>(context, () => + this.executeStreamRequest(context.url, context.options), + ) + } catch (error) { + const apiError = this.normalizeError(error, context) + await this.config.hooks?.onError?.(apiError, context) + + throw apiError + } + } + /** * Execute the feature chain and the actual request * * Features are executed in order, with each feature calling next() to continue. * The last "feature" in the chain is the actual request execution. */ - protected async executeFeatureChain(context: RequestContext): Promise { + protected async executeFeatureChain( + context: RequestContext, + executeTerminal: () => Promise = () => this.executeRequest(context.url, context.options), + ): Promise { // Filter to only features that should apply const applicableFeatures = this.features.filter((feature) => feature.shouldApply(context)) @@ -184,7 +230,7 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient { } else { // We've reached the end of the chain, execute the actual request await this.config.hooks?.onRequest?.(context) - return this.executeRequest(context.url, context.options) + return executeTerminal() } } @@ -243,6 +289,10 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient { return `${base}${versionPath}${cleanPath}` } + protected resolveBaseUrl(baseUrl: BaseUrlConfig): string { + return typeof baseUrl === 'function' ? baseUrl() : baseUrl + } + /** * Build the request context */ @@ -354,6 +404,11 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient { */ protected abstract executeRequest(url: string, options: RequestOptions): Promise + protected abstract executeStreamRequest( + url: string, + options: RequestOptions, + ): Promise> + /** * Execute the actual XHR upload * diff --git a/packages/api-client/src/core/abstract-sync.ts b/packages/api-client/src/core/abstract-sync.ts new file mode 100644 index 0000000000..a8e31c536e --- /dev/null +++ b/packages/api-client/src/core/abstract-sync.ts @@ -0,0 +1,167 @@ +import type mitt from 'mitt' + +import type { Archon } from '../modules/archon/types' +import type { RequestOptions } from '../types/request' + +export type SyncEventType = Archon.Sync.v1.SyncEvent['type'] + +export type SyncEventOfType = Extract< + Archon.Sync.v1.SyncEvent, + { type: E } +> + +export type SyncEventHandler = ( + event: E, +) => void + +export type SyncStatusState = + | 'idle' + | 'connecting' + | 'connected' + | 'reconnecting' + | 'disconnected' + | 'error' + +export type SyncStatus = { + state: SyncStatusState + connected: boolean + reconnecting: boolean + reconnectAttempts: number + retryDelay: number + lastEventId?: string + error?: unknown +} + +export type SyncStatusHandler = (status: SyncStatus) => void + +export type SyncConnectOptions = { + intent?: Archon.Sync.v1.SyncIntent + force?: boolean +} + +export type SyncConnection = { + serverId: string + intent: Archon.Sync.v1.SyncIntent + controller?: AbortController + reconnectAttempts: number + reconnectTimer?: ReturnType + reconnectResolve?: () => void + retryDelay: number + lastEventId?: string + stopped: boolean + status: SyncStatusState + error?: unknown +} + +export type SyncEmitterEvents = Record + +export abstract class AbstractSyncClient { + protected connections = new Map() + protected abstract emitter: ReturnType> + + constructor( + protected client: { + stream: (path: string, options: RequestOptions) => Promise> + }, + ) {} + + abstract safeConnectServer(serverId: string, options?: SyncConnectOptions): Promise + + abstract disconnect(serverId: string): void + + abstract disconnectAll(): void + + on( + serverId: string, + eventType: E, + handler: SyncEventHandler>, + ): () => void { + const eventKey = this.getEventKey(serverId, eventType) + const wrapped = handler as (event: unknown) => void + + this.emitter.on(eventKey, wrapped) + + return () => { + this.emitter.off(eventKey, wrapped) + } + } + + onAny(serverId: string, handler: SyncEventHandler): () => void { + const eventKey = this.getAnyEventKey(serverId) + const wrapped = handler as (event: unknown) => void + + this.emitter.on(eventKey, wrapped) + + return () => { + this.emitter.off(eventKey, wrapped) + } + } + + onStatus(serverId: string, handler: SyncStatusHandler): () => void { + const eventKey = this.getStatusEventKey(serverId) + const wrapped = handler as (event: unknown) => void + + this.emitter.on(eventKey, wrapped) + + return () => { + this.emitter.off(eventKey, wrapped) + } + } + + getStatus(serverId: string): SyncStatus | null { + const connection = this.connections.get(serverId) + if (!connection) return null + + return this.connectionToStatus(connection) + } + + protected emitSyncEvent(serverId: string, event: Archon.Sync.v1.SyncEvent): void { + this.emitter.emit(this.getEventKey(serverId, event.type), event) + this.emitter.emit(this.getAnyEventKey(serverId), event) + } + + protected updateStatus( + connection: SyncConnection, + status: SyncStatusState, + error?: unknown, + ): void { + connection.status = status + connection.error = error + this.emitter.emit( + this.getStatusEventKey(connection.serverId), + this.connectionToStatus(connection), + ) + } + + protected clearListeners(serverId: string): void { + this.emitter.all.forEach((_handlers, type) => { + if (type.toString().startsWith(`${serverId}:`)) { + this.emitter.all.delete(type) + } + }) + } + + protected connectionToStatus(connection: SyncConnection): SyncStatus { + return { + state: connection.status, + connected: connection.status === 'connected', + reconnecting: connection.status === 'reconnecting', + reconnectAttempts: connection.reconnectAttempts, + retryDelay: connection.retryDelay, + lastEventId: connection.lastEventId, + error: connection.error, + } + } + + private getEventKey(serverId: string, eventType: string): string { + return `${serverId}:${eventType}` + } + + private getAnyEventKey(serverId: string): string { + return `${serverId}:*` + } + + private getStatusEventKey(serverId: string): string { + return `${serverId}:__status` + } +} diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index 2e1e1a3ec0..e675904fc1 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -1,5 +1,16 @@ export { AbstractModrinthClient } from './core/abstract-client' export { AbstractFeature, type FeatureConfig } from './core/abstract-feature' +export { + AbstractSyncClient, + type SyncConnection, + type SyncConnectOptions, + type SyncEventHandler, + type SyncEventOfType, + type SyncEventType, + type SyncStatus, + type SyncStatusHandler, + type SyncStatusState, +} from './core/abstract-sync' export { AbstractUploadClient } from './core/abstract-upload-client' export { AbstractWebSocketClient, @@ -25,10 +36,18 @@ export * from './modules/types' export { GenericModrinthClient } from './platform/generic' export type { NuxtClientConfig } from './platform/nuxt' export { NuxtCircuitBreakerStorage, NuxtModrinthClient } from './platform/nuxt' +export { GenericSyncClient } from './platform/sync-generic' export type { TauriClientConfig } from './platform/tauri' export { TauriModrinthClient } from './platform/tauri' 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 { + type ParsedSseEvent, + type ParsedSseItem, + type ParsedSseRetry, + parseSyncEventData, + SseParser, +} from './utils/sse' export type { Override, RawDecimal } from './utils/types' diff --git a/packages/api-client/src/modules/archon/actions/v1.ts b/packages/api-client/src/modules/archon/actions/v1.ts new file mode 100644 index 0000000000..88227bf0e8 --- /dev/null +++ b/packages/api-client/src/modules/archon/actions/v1.ts @@ -0,0 +1,35 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { Archon } from '../types' + +export class ArchonActionsV1Module extends AbstractModule { + public getModuleID(): string { + return 'archon_actions_v1' + } + + /** + * Get server action log entries. + * GET /v1/servers/:server_id/action-log + */ + public async list( + serverId: string, + options: Archon.Actions.v1.ListActionLogOptions = {}, + ): Promise { + const params: Record = {} + if (options.filter) params.filter = JSON.stringify(options.filter) + if (options.limit !== undefined) params.limit = options.limit + if (options.offset !== undefined) params.offset = options.offset + if (options.order !== undefined) params.order = options.order + if (options.min_datetime !== undefined) params.min_datetime = options.min_datetime + if (options.max_datetime !== undefined) params.max_datetime = options.max_datetime + + return this.client.request( + `/servers/${serverId}/action-log`, + { + api: 'archon', + version: 1, + method: 'GET', + params: Object.keys(params).length > 0 ? params : undefined, + }, + ) + } +} diff --git a/packages/api-client/src/modules/archon/index.ts b/packages/api-client/src/modules/archon/index.ts index ed9719ad3c..194ea0333e 100644 --- a/packages/api-client/src/modules/archon/index.ts +++ b/packages/api-client/src/modules/archon/index.ts @@ -1,3 +1,4 @@ +export * from './actions/v1' export * from './backups/v1' export * from './backups-queue/v1' export * from './content/v1' diff --git a/packages/api-client/src/modules/archon/nodes/internal.ts b/packages/api-client/src/modules/archon/nodes/internal.ts new file mode 100644 index 0000000000..254f392c8c --- /dev/null +++ b/packages/api-client/src/modules/archon/nodes/internal.ts @@ -0,0 +1,20 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { Archon } from '../types' + +export class ArchonNodesInternalModule extends AbstractModule { + public getModuleID(): string { + return 'archon_nodes_internal' + } + + /** + * Get node hostnames and region summary for admin tooling. + * GET /_internal/nodes/overview + */ + public async overview(): Promise { + return this.client.request('/nodes/overview', { + api: 'archon', + version: 'internal', + method: 'GET', + }) + } +} diff --git a/packages/api-client/src/modules/archon/notices/v0.ts b/packages/api-client/src/modules/archon/notices/v0.ts new file mode 100644 index 0000000000..a6e76b0277 --- /dev/null +++ b/packages/api-client/src/modules/archon/notices/v0.ts @@ -0,0 +1,98 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { Archon } from '../types' + +export class ArchonNoticesV0Module extends AbstractModule { + public getModuleID(): string { + return 'archon_notices_v0' + } + + /** + * Get all server notices. + * GET /modrinth/v0/notices + */ + public async list(): Promise { + return this.client.request('/notices', { + api: 'archon', + version: 'modrinth/v0', + method: 'GET', + }) + } + + /** + * Create a server notice. + * POST /modrinth/v0/notices + */ + public async create( + request: Archon.Notices.v0.Announce, + ): Promise { + return this.client.request('/notices', { + api: 'archon', + version: 'modrinth/v0', + method: 'POST', + body: request, + }) + } + + /** + * Update a server notice. + * PATCH /modrinth/v0/notices/:id + */ + public async update(id: number, request: Archon.Notices.v0.AnnouncePatch): Promise { + await this.client.request(`/notices/${id}`, { + api: 'archon', + version: 'modrinth/v0', + method: 'PATCH', + body: request, + }) + } + + /** + * Delete a server notice. + * DELETE /modrinth/v0/notices/:id + */ + public async delete(id: number): Promise { + await this.client.request(`/notices/${id}`, { + api: 'archon', + version: 'modrinth/v0', + method: 'DELETE', + }) + } + + /** + * Assign a notice to a server or node. + * PUT /modrinth/v0/notices/:id/assign?server=:serverId + * PUT /modrinth/v0/notices/:id/assign?node=:nodeId + */ + public async assign(id: number, target: Archon.Notices.v0.AssignmentTarget): Promise { + await this.client.request(`/notices/${id}/assign`, { + api: 'archon', + version: 'modrinth/v0', + method: 'PUT', + params: this.assignmentTargetToParams(target), + }) + } + + /** + * Unassign a notice from a server or node. + * PUT /modrinth/v0/notices/:id/unassign?server=:serverId + * PUT /modrinth/v0/notices/:id/unassign?node=:nodeId + */ + public async unassign(id: number, target: Archon.Notices.v0.AssignmentTarget): Promise { + await this.client.request(`/notices/${id}/unassign`, { + api: 'archon', + version: 'modrinth/v0', + method: 'PUT', + params: this.assignmentTargetToParams(target), + }) + } + + private assignmentTargetToParams( + target: Archon.Notices.v0.AssignmentTarget, + ): Record { + if ('server' in target) { + return { server: target.server } + } + + return { node: target.node } + } +} diff --git a/packages/api-client/src/modules/archon/server-users/v1.ts b/packages/api-client/src/modules/archon/server-users/v1.ts new file mode 100644 index 0000000000..658c9fca17 --- /dev/null +++ b/packages/api-client/src/modules/archon/server-users/v1.ts @@ -0,0 +1,65 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { Archon } from '../types' + +export class ArchonServerUsersV1Module extends AbstractModule { + public getModuleID(): string { + return 'archon_server_users_v1' + } + + /** + * Get list of users with access to a server + * GET /v1/servers/:server_id/users + */ + public async list(serverId: string): Promise { + return this.client.request(`/servers/${serverId}/users`, { + api: 'archon', + version: 1, + method: 'GET', + }) + } + + /** + * Add a user to a server + * POST /v1/servers/:server_id/users + */ + public async add( + serverId: string, + user: Archon.ServerUsers.v1.AddServerUserRequest, + ): Promise { + await this.client.request(`/servers/${serverId}/users`, { + api: 'archon', + version: 1, + method: 'POST', + body: user, + }) + } + + /** + * Remove a user from a server + * DELETE /v1/servers/:server_id/users/:user_id + */ + public async delete(serverId: string, userId: string): Promise { + await this.client.request(`/servers/${serverId}/users/${userId}`, { + api: 'archon', + version: 1, + method: 'DELETE', + }) + } + + /** + * Update a user's server role + * PATCH /v1/servers/:server_id/users/:user_id + */ + public async update( + serverId: string, + userId: string, + role: Archon.ServerUsers.v1.AssignableServerUserRole, + ): Promise { + await this.client.request(`/servers/${serverId}/users/${userId}`, { + api: 'archon', + version: 1, + method: 'PATCH', + body: JSON.stringify(role), + }) + } +} diff --git a/packages/api-client/src/modules/archon/transfers/internal.ts b/packages/api-client/src/modules/archon/transfers/internal.ts new file mode 100644 index 0000000000..ccbedaffd8 --- /dev/null +++ b/packages/api-client/src/modules/archon/transfers/internal.ts @@ -0,0 +1,84 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { Archon } from '../types' + +export class ArchonTransfersInternalModule extends AbstractModule { + public getModuleID(): string { + return 'archon_transfers_internal' + } + + /** + * Schedule transfers for specific servers. + * POST /_internal/transfers/schedule/servers + */ + public async scheduleServers( + request: Archon.Transfers.Internal.ScheduleServerTransfersRequest, + ): Promise { + return this.client.request( + '/transfers/schedule/servers', + { + api: 'archon', + version: 'internal', + method: 'POST', + body: request, + }, + ) + } + + /** + * Schedule transfers for all servers on specific nodes. + * POST /_internal/transfers/schedule/nodes + */ + public async scheduleNodes( + request: Archon.Transfers.Internal.ScheduleNodeTransfersRequest, + ): Promise { + return this.client.request( + '/transfers/schedule/nodes', + { + api: 'archon', + version: 'internal', + method: 'POST', + body: request, + }, + ) + } + + /** + * Get transfer batch history. + * GET /_internal/transfers/history + */ + public async history( + options?: Archon.Transfers.Internal.TransferHistoryQuery, + ): Promise { + const params: Record = {} + if (options?.page !== undefined) params.page = options.page + if (options?.page_size !== undefined) params.page_size = options.page_size + + return this.client.request( + '/transfers/history', + { + api: 'archon', + version: 'internal', + method: 'GET', + params, + }, + ) + } + + /** + * Cancel pending transfer batches. + * POST /_internal/transfers/cancel + */ + public async cancel( + request: Archon.Transfers.Internal.CancelTransfersRequest, + ): Promise { + return this.client.request( + '/transfers/cancel', + { + api: 'archon', + version: 'internal', + method: 'POST', + body: request, + }, + ) + } +} diff --git a/packages/api-client/src/modules/archon/types.ts b/packages/api-client/src/modules/archon/types.ts index b348cbe7c8..1f541495af 100644 --- a/packages/api-client/src/modules/archon/types.ts +++ b/packages/api-client/src/modules/archon/types.ts @@ -1,6 +1,287 @@ import type { Labrinth } from '../labrinth/types' export namespace Archon { + export namespace Nodes { + export namespace Internal { + export type Node = { + id: string + hostname: string + region: string + created_at: string | null + locked: boolean + } + + export type Server = { + id: string + available: boolean + } + + export type NodeFull = Node & { + servers: Server[] + } + + export type Overview = { + node_hostnames: string[] + regions: Region[] + total_servers_active: number + } + + export type Region = { + display_name: string + country_code: string + key: string + server_count: number + node_count: number + } + + export type RegionWithStatistics = { + region: Region + active_servers: string[] + } + } + } + + export namespace Notices { + export namespace v0 { + export type Notice = { + id: number + dismissable: boolean + title: string | null + message: string + level: string + announced: string + } + + export type ListedNotice = { + id: number + dismissable: boolean + message: string + title: string | null + level: string + announce_at: string + expires: string | null + assigned: Assignment[] + dismissed_by: Dismisser[] + } + + export type Dismisser = { + server: string + dismissed_on: string + } + + export type Assignment = { + kind: string + id: string + name: string + } + + export type AssignmentTarget = { server: string } | { node: string } + + export type Announce = { + message: string + title?: string | null + level: string + dismissable: boolean + announce_at: string + expires?: string | null + } + + export type AnnouncePatch = { + message?: string + title?: string | null + level?: string + dismissable?: boolean + announce_at?: string + expires?: string | null + } + + export type PostNoticeResponseBody = { + id: number + } + } + } + + export namespace Actions { + export namespace v1 { + export type SortOrder = 'asc' | 'desc' + + export type ActionName = + | 'server_created' + | 'changed_server_name' + | 'changed_server_subdomain' + | 'server_reallocated' + | 'server_plan_changed' + | 'user_invited' + | 'user_invite_revoked' + | 'user_permission_modified' + | 'user_removed' + | 'addon_added' + | 'addon_uploaded' + | 'addon_disabled' + | 'addon_enabled' + | 'addon_deleted' + | 'addon_updated' + | 'modpack_changed' + | 'modpack_unlinked' + | 'server_repaired' + | 'server_reset' + | 'server_started' + | 'server_stopped' + | 'server_restarted' + | 'server_killed' + | 'port_allocation_added' + | 'port_allocation_removed' + | 'loader_version_edited' + | 'game_version_edited' + | 'server_properties_modified' + | 'file_uploaded' + | 'file_deleted' + | 'file_renamed' + | 'file_edited' + | 'sftp_login' + | 'console_command_executed' + | 'console_cleared' + | 'backup_created' + | 'backup_renamed' + | 'backup_restored' + | 'backup_deleted' + | 'startup_command_modified' + | 'java_runtime_modified' + | 'java_version_modified' + + export type Action = { + action: ActionName | string + metadata?: unknown + } + + export type UserPermissionsActionMetadata = { + user_id: string + permissions?: ServerUsers.v1.UserScope | null + } + + export type ActionUser = + | { + type: 'user' + user_id: string + } + | { + type: 'support' + user_id?: string | null + } + + export type ActionEntry = { + actor: ActionUser + action: Action + server_id: string + world_id?: string | null + timestamp: string + } + + export type UserResp = { + username: string + avatar_url?: string | null + } + + export type AddonResp = { + title: string + slug?: string | null + icon_url?: string | null + version?: string | null + } + + export type VersionResp = { + name: string + version_number?: string | null + } + + export type ActionLogResponse = { + next_offset?: number | null + data: ActionEntry[] + users: Record + addons: Record + versions: Record + } + + export type ActionLogFilter = { + users?: string[] + worlds?: Array + actions?: ActionName[] + } + + export type ListActionLogOptions = { + filter?: ActionLogFilter + limit?: number + offset?: number + order?: SortOrder + min_datetime?: string + max_datetime?: string + } + } + } + + export namespace Transfers { + export namespace Internal { + export type ProvisionOptions = { + region?: string | null + node_tags: string[] + } + + export type ScheduleServerTransfersRequest = { + server_ids: string[] + scheduled_at?: string | null + target_region?: string | null + node_tags?: string[] + reason?: string | null + } + + export type ScheduleNodeTransfersRequest = { + node_hostnames: string[] + scheduled_at?: string | null + target_region?: string | null + node_tags?: string[] + reason?: string | null + cordon_nodes?: boolean + tag_nodes?: string | null + } + + export type ScheduleTransfersResponse = { + batch_id: number + scheduled_count: number + } + + export type CancelTransfersRequest = { + batch_ids: number[] + } + + export type CancelTransfersResponse = { + cancelled_count: number + } + + export type TransferLogBatchEntry = { + id: number + created_by: string + created_at: string + reason?: string | null + scheduled_at: string + cancelled: boolean + log_count: number + provision_options: ProvisionOptions + } + + export type TransferHistoryQuery = { + page?: number + page_size?: number + } + + export type TransferHistoryResponse = { + batches: TransferLogBatchEntry[] + total: number + page: number + page_size: number + } + } + } + export namespace Content { export namespace v1 { export type AddonKind = 'mod' | 'plugin' | 'datapack' | 'shader' | 'resourcepack' @@ -222,11 +503,58 @@ export namespace Archon { } } + export namespace ServerUsers { + export namespace v1 { + export type ServerUserRole = 'Owner' | 'Editor' | 'Viewer' | 'Unknown' + + export type AssignableServerUserRole = Exclude + + export const UserScope = { + NONE: '', + SERVER_ADMIN: 'SERVER_ADMIN', + BASE_READ: 'BASE_READ', + POWER_ACTIONS: 'POWER_ACTIONS', + FILES_WRITE: 'FILES_WRITE', + SETUP: 'SETUP', + BACKUPS: 'BACKUPS', + ADVANCED: 'ADVANCED', + RESET_SERVER: 'RESET_SERVER', + MANAGE_USERS: 'MANAGE_USERS', + SUPPORT_AGENT: 'SUPPORT_AGENT', + INFRA_MANAGER: 'INFRA_MANAGER', + INFRA_MANAGER_READ: 'INFRA_MANAGER_READ', + INFRA_SERVERS_XFER: 'INFRA_SERVERS_XFER', + } as const + + export type UserScope = string | number + + export type UserResp = { + id: string + username: string + avatar_url?: string | null + } + + export type ServerUser = { + user: UserResp + added_on?: string | null + permissions: UserScope + } + + export type AddServerUserRequest = { + server_id?: string | null + user_id: string + added_on?: string | null + role: ServerUserRole + } + } + } + export namespace Servers { export namespace v0 { export type ServerGetResponse = { servers: Server[] pagination: Pagination + users: Record } export type Pagination = { @@ -236,6 +564,12 @@ export namespace Archon { total_items: number } + export type ServerOwner = { + id: string + username: string + avatar_url?: string | null + } + export type Status = 'installing' | 'broken' | 'available' | 'suspended' export type SuspensionReason = @@ -281,12 +615,15 @@ export namespace Archon { node: NodeInfo | null flows: Flows is_medal: boolean + current_user_permissions: UserScope medal_expires?: string } + export type UserScope = number + export type Net = { - ip: string + ip: string | null port: number domain: string } @@ -422,9 +759,9 @@ export namespace Archon { modloader: string modloader_version: string game_version: string - java_version: number - invocation: string - original_invocation: string + java_version: number | null + invocation: string | null + original_invocation: string | null } export type Region = { @@ -555,6 +892,106 @@ export namespace Archon { } } + export namespace Sync { + export namespace v1 { + export type SyncCategory = 'backup' | 'users' | 'server' | 'protocol' | 'world' + export type SyncIntent = 'all' | SyncCategory | SyncCategory[] + export type BackupOperationStatus = 'completed' | 'cancelled' | 'failed' | 'timed-out' + export type ServerNetworkPort = { port: number; name: string } + + export type ProtocolResetEvent = { type: 'protocol.reset' } + export type ProtocolInvalidEvent = { type: 'protocol.invalid' } + export type ProtocolErrorEvent = { type: 'protocol.error'; error: string } + + export type BackupNewEvent = { type: 'backup.new'; id: string } + export type BackupPatchEvent = { + type: 'backup.patch' + world_id: string + backup_id: string + name: string + } + export type BackupDeleteEvent = { + type: 'backup.delete' + world_id: string + backup_id: string + } + export type BackupOperationStartEvent = { + type: + | 'backup.operation.create.init' + | 'backup.operation.create.start' + | 'backup.operation.restore.init' + | 'backup.operation.restore.start' + world_id: string + backup_id: string + operation_id: number + } + export type BackupOperationDoneEvent = { + type: 'backup.operation.create.done' | 'backup.operation.restore.done' + world_id: string + backup_id: string + operation_id: number + status: BackupOperationStatus + } + + export type ServerPatchEvent = { + type: 'server.patch' + name: string + subdomain: string + } + export type ServerNetworkPatchEvent = { + type: 'server.network.patch' + ports: ServerNetworkPort[] + } + export type ServerTransferEvent = { + type: 'server.transfer.start' | 'server.transfer.done' + target_node: string + } + + export type UsersPatchEvent = { type: 'users.patch' } + + export type WorldPatchEvent = { + type: 'world.patch' + world_id: string + name: string + } + export type WorldStartupPatchEvent = { + type: 'world.startup.patch' + world_id: string + java_version: number | null + invocation: string | null + original_invocation: string | null + } + export type WorldContentAddonPatchEvent = { + type: 'world.content.addon.patch' + world_id: string + specs: Archon.Content.v1.Addon[] + } + export type WorldContentBaseUpdateEvent = { + type: 'world.content.base.update' + world_id: string + spec: Archon.Content.v1.Addons + } + + export type SyncEvent = + | ProtocolResetEvent + | ProtocolInvalidEvent + | ProtocolErrorEvent + | BackupNewEvent + | BackupPatchEvent + | BackupDeleteEvent + | BackupOperationStartEvent + | BackupOperationDoneEvent + | ServerPatchEvent + | ServerNetworkPatchEvent + | ServerTransferEvent + | UsersPatchEvent + | WorldPatchEvent + | WorldStartupPatchEvent + | WorldContentAddonPatchEvent + | WorldContentBaseUpdateEvent + } + } + export namespace Websocket { export namespace v0 { export type WSAuth = { diff --git a/packages/api-client/src/modules/index.ts b/packages/api-client/src/modules/index.ts index f1bb4f6661..4eedf8d1b8 100644 --- a/packages/api-client/src/modules/index.ts +++ b/packages/api-client/src/modules/index.ts @@ -1,12 +1,17 @@ import type { AbstractModrinthClient } from '../core/abstract-client' import type { AbstractModule } from '../core/abstract-module' +import { ArchonActionsV1Module } from './archon/actions/v1' import { ArchonBackupsV1Module } from './archon/backups/v1' import { ArchonBackupsQueueV1Module } from './archon/backups-queue/v1' import { ArchonContentV1Module } from './archon/content/v1' +import { ArchonNodesInternalModule } from './archon/nodes/internal' +import { ArchonNoticesV0Module } from './archon/notices/v0' import { ArchonOptionsV1Module } from './archon/options/v1' import { ArchonPropertiesV1Module } from './archon/properties/v1' +import { ArchonServerUsersV1Module } from './archon/server-users/v1' import { ArchonServersV0Module } from './archon/servers/v0' import { ArchonServersV1Module } from './archon/servers/v1' +import { ArchonTransfersInternalModule } from './archon/transfers/internal' import { ISO3166Module } from './iso3166' import { KyrosContentV1Module } from './kyros/content/v1' import { KyrosFilesV0Module } from './kyros/files/v0' @@ -19,6 +24,7 @@ import { LabrinthAuthV2Module } from './labrinth/auth/v2' import { LabrinthBillingInternalModule } from './labrinth/billing/internal' import { LabrinthCollectionsModule } from './labrinth/collections' import { LabrinthExternalProjectsInternalModule } from './labrinth/external-projects/internal' +import { LabrinthFriendsV3Module } from './labrinth/friends/v3' import { LabrinthGlobalsInternalModule } from './labrinth/globals/internal' import { LabrinthLimitsV3Module } from './labrinth/limits/v3' import { LabrinthModerationInternalModule } from './labrinth/moderation/internal' @@ -58,13 +64,18 @@ type ModuleConstructor = new (client: AbstractModrinthClient) => AbstractModule * TODO: Better way? Probably not */ export const MODULE_REGISTRY = { + archon_actions_v1: ArchonActionsV1Module, archon_backups_queue_v1: ArchonBackupsQueueV1Module, archon_backups_v1: ArchonBackupsV1Module, archon_content_v1: ArchonContentV1Module, + archon_nodes_internal: ArchonNodesInternalModule, + archon_notices_v0: ArchonNoticesV0Module, archon_options_v1: ArchonOptionsV1Module, archon_properties_v1: ArchonPropertiesV1Module, + archon_server_users_v1: ArchonServerUsersV1Module, archon_servers_v0: ArchonServersV0Module, archon_servers_v1: ArchonServersV1Module, + archon_transfers_internal: ArchonTransfersInternalModule, iso3166_data: ISO3166Module, mclogs_insights_v1: MclogsInsightsV1Module, mclogs_logs_v1: MclogsLogsV1Module, @@ -79,6 +90,7 @@ export const MODULE_REGISTRY = { labrinth_billing_internal: LabrinthBillingInternalModule, labrinth_collections: LabrinthCollectionsModule, labrinth_external_projects_internal: LabrinthExternalProjectsInternalModule, + labrinth_friends_v3: LabrinthFriendsV3Module, labrinth_globals_internal: LabrinthGlobalsInternalModule, labrinth_moderation_internal: LabrinthModerationInternalModule, labrinth_notifications_v2: LabrinthNotificationsV2Module, diff --git a/packages/api-client/src/modules/labrinth/friends/v3.ts b/packages/api-client/src/modules/labrinth/friends/v3.ts new file mode 100644 index 0000000000..d946880dc5 --- /dev/null +++ b/packages/api-client/src/modules/labrinth/friends/v3.ts @@ -0,0 +1,47 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { Labrinth } from '../types' + +export class LabrinthFriendsV3Module extends AbstractModule { + public getModuleID(): string { + return 'labrinth_friends_v3' + } + + /** + * Get friends and pending friend requests for the authenticated user + * + * @returns Promise resolving to friend relationships + */ + public async list(): Promise { + return this.client.request('/friends', { + api: 'labrinth', + version: 3, + method: 'GET', + }) + } + + /** + * Send or accept a friend request + * + * @param idOrUsername - The target user's ID or username + */ + public async add(idOrUsername: string): Promise { + return this.client.request(`/friend/${encodeURIComponent(idOrUsername)}`, { + api: 'labrinth', + version: 3, + method: 'POST', + }) + } + + /** + * Remove a friend or pending friend request + * + * @param idOrUsername - The target user's ID or username + */ + public async remove(idOrUsername: string): Promise { + return this.client.request(`/friend/${encodeURIComponent(idOrUsername)}`, { + 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 883bac31d7..725e57dc95 100644 --- a/packages/api-client/src/modules/labrinth/index.ts +++ b/packages/api-client/src/modules/labrinth/index.ts @@ -3,6 +3,7 @@ export * from './auth/v2' export * from './billing/internal' export * from './collections' export * from './external-projects/internal' +export * from './friends/v3' export * from './globals/internal' export * from './limits/v3' export * from './moderation/internal' diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts index 098e46d19f..c9c92bb3bf 100644 --- a/packages/api-client/src/modules/labrinth/types.ts +++ b/packages/api-client/src/modules/labrinth/types.ts @@ -1063,6 +1063,17 @@ export namespace Labrinth { } } + export namespace Friends { + export namespace v3 { + export type UserFriend = { + id: string + friend_id: string + accepted: boolean + created: string + } + } + } + export namespace ServerPing { export namespace Internal { export type MinecraftJavaPingRequest = { diff --git a/packages/api-client/src/platform/generic.ts b/packages/api-client/src/platform/generic.ts index 4a939b6f7a..7ec63b6824 100644 --- a/packages/api-client/src/platform/generic.ts +++ b/packages/api-client/src/platform/generic.ts @@ -1,8 +1,10 @@ import { $fetch, FetchError } from 'ofetch' -import type { ModrinthApiError } from '../core/errors' +import { ModrinthApiError } from '../core/errors' import type { ClientConfig } from '../types/client' import type { RequestOptions } from '../types/request' +import { appendRequestParams, parseResponseErrorData, toFetchBody } from '../utils/fetch' +import { GenericSyncClient } from './sync-generic' import { GenericWebSocketClient } from './websocket-generic' import { XHRUploadClient } from './xhr-upload-client' @@ -34,6 +36,12 @@ export class GenericModrinthClient extends XHRUploadClient { enumerable: true, configurable: false, }) + Object.defineProperty(this.archon, 'sync', { + value: new GenericSyncClient(this), + writable: false, + enumerable: true, + configurable: false, + }) } protected async executeRequest(url: string, options: RequestOptions): Promise { @@ -54,6 +62,38 @@ export class GenericModrinthClient extends XHRUploadClient { } } + protected async executeStreamRequest( + url: string, + options: RequestOptions, + ): Promise> { + try { + const response = await fetch(appendRequestParams(url, options.params), { + method: options.method ?? 'GET', + headers: options.headers, + body: toFetchBody(options.body), + signal: options.signal, + }) + + if (!response.ok) { + throw this.createNormalizedError( + new Error(`HTTP ${response.status}: ${response.statusText}`), + response.status, + await parseResponseErrorData(response), + ) + } + + if (!response.body) { + throw new ModrinthApiError('Streaming response has no readable body', { + statusCode: response.status, + }) + } + + return response.body + } catch (error) { + throw this.normalizeError(error) + } + } + protected normalizeError(error: unknown): ModrinthApiError { if (error instanceof FetchError) { return this.createNormalizedError(error, error.response?.status, error.data) diff --git a/packages/api-client/src/platform/nuxt.ts b/packages/api-client/src/platform/nuxt.ts index ce0c456345..a5308535ba 100644 --- a/packages/api-client/src/platform/nuxt.ts +++ b/packages/api-client/src/platform/nuxt.ts @@ -5,6 +5,8 @@ import type { CircuitBreakerState, CircuitBreakerStorage } from '../features/cir import type { ClientConfig } from '../types/client' import type { RequestOptions } from '../types/request' import type { UploadHandle, UploadRequestOptions } from '../types/upload' +import { appendRequestParams, parseResponseErrorData, toFetchBody } from '../utils/fetch' +import { GenericSyncClient } from './sync-generic' import { GenericWebSocketClient } from './websocket-generic' import { XHRUploadClient } from './xhr-upload-client' @@ -97,6 +99,12 @@ export class NuxtModrinthClient extends XHRUploadClient { enumerable: true, configurable: false, }) + Object.defineProperty(this.archon, 'sync', { + value: new GenericSyncClient(this), + writable: false, + enumerable: true, + configurable: false, + }) } /** @@ -167,6 +175,40 @@ export class NuxtModrinthClient extends XHRUploadClient { } } + protected async executeStreamRequest( + url: string, + options: RequestOptions, + ): Promise> { + try { + const response = await fetch(appendRequestParams(url, options.params), { + method: options.method ?? 'GET', + headers: options.headers, + body: toFetchBody(options.body), + signal: options.signal, + // @ts-expect-error - import.meta is provided by Nuxt + cache: import.meta.server ? undefined : 'no-store', + }) + + if (!response.ok) { + throw this.createNormalizedError( + new Error(`HTTP ${response.status}: ${response.statusText}`), + response.status, + await parseResponseErrorData(response), + ) + } + + if (!response.body) { + throw new ModrinthApiError('Streaming response has no readable body', { + statusCode: response.status, + }) + } + + return response.body + } catch (error) { + throw this.normalizeError(error) + } + } + protected normalizeError(error: unknown): ModrinthApiError { if (error instanceof FetchError) { return this.createNormalizedError(error, error.response?.status, error.data) diff --git a/packages/api-client/src/platform/sync-generic.ts b/packages/api-client/src/platform/sync-generic.ts new file mode 100644 index 0000000000..e43fbaf2da --- /dev/null +++ b/packages/api-client/src/platform/sync-generic.ts @@ -0,0 +1,229 @@ +import mitt from 'mitt' + +import { + AbstractSyncClient, + type SyncConnection, + type SyncConnectOptions, + type SyncEmitterEvents, +} from '../core/abstract-sync' +import type { Archon } from '../modules/archon/types' +import { type ParsedSseItem, parseSyncEventData, SseParser } from '../utils/sse' + +type StreamReadResult = 'closed' | 'protocol-reconnect' + +const DEFAULT_RETRY_DELAY = 1000 +const MAX_RECONNECT_DELAY = 30000 +const JITTER_MS = 1000 + +export class GenericSyncClient extends AbstractSyncClient { + protected emitter = mitt() + + async safeConnectServer(serverId: string, options: SyncConnectOptions = {}): Promise { + const existing = this.connections.get(serverId) + if (existing && !options.force && !existing.stopped && existing.status !== 'disconnected') { + return + } + + if (existing) { + this.closeConnection(serverId) + } + + const connection: SyncConnection = { + serverId, + intent: options.intent ?? 'all', + reconnectAttempts: 0, + retryDelay: DEFAULT_RETRY_DELAY, + stopped: false, + status: 'idle', + } + + this.connections.set(serverId, connection) + void this.runConnection(connection) + } + + disconnect(serverId: string): void { + this.closeConnection(serverId) + this.clearListeners(serverId) + } + + disconnectAll(): void { + for (const serverId of this.connections.keys()) { + this.disconnect(serverId) + } + } + + private async runConnection(connection: SyncConnection): Promise { + while (!connection.stopped) { + const hadConnected = connection.status === 'connected' + this.updateStatus(connection, hadConnected ? 'reconnecting' : 'connecting') + + const controller = new AbortController() + connection.controller = controller + + try { + const stream = await this.client.stream('/sync', { + api: 'archon', + version: 1, + method: 'GET', + params: { + scope: `server:${connection.serverId}`, + intent: this.intentToParam(connection.intent), + }, + headers: connection.lastEventId + ? { + 'Last-Event-Id': connection.lastEventId, + } + : undefined, + signal: controller.signal, + retry: false, + circuitBreaker: false, + }) + + if (connection.stopped) return + + connection.reconnectAttempts = 0 + this.updateStatus(connection, 'connected') + + const result = await this.consumeStream(connection, stream) + connection.controller = undefined + if (connection.stopped) return + + if (result === 'protocol-reconnect') { + connection.reconnectAttempts = 0 + continue + } + + await this.waitForReconnect(connection) + } catch (error) { + connection.controller = undefined + if (connection.stopped || this.isAbortError(error)) return + + connection.reconnectAttempts++ + this.updateStatus(connection, 'error', error) + console.warn(`[Sync] Connection failed for server ${connection.serverId}:`, error) + await this.waitForReconnect(connection) + } + } + } + + private async consumeStream( + connection: SyncConnection, + stream: ReadableStream, + ): Promise { + const reader = stream.getReader() + const decoder = new TextDecoder() + const parser = new SseParser() + + try { + while (!connection.stopped) { + const { done, value } = await reader.read() + if (done) break + + const chunk = decoder.decode(value, { stream: true }) + const result = this.processParsedItems(connection, parser.feed(chunk)) + if (result === 'protocol-reconnect') { + await reader.cancel() + connection.controller?.abort() + return result + } + } + + const finalChunk = decoder.decode() + const finalItems = finalChunk ? parser.feed(finalChunk) : [] + const result = this.processParsedItems(connection, [...finalItems, ...parser.end()]) + if (result === 'protocol-reconnect') { + await reader.cancel() + connection.controller?.abort() + return result + } + } finally { + reader.releaseLock() + } + + return 'closed' + } + + private processParsedItems(connection: SyncConnection, items: ParsedSseItem[]): StreamReadResult { + for (const item of items) { + if (item.kind === 'retry') { + connection.retryDelay = Math.min(item.retry, MAX_RECONNECT_DELAY) + continue + } + + this.updateLastEventId(connection, item.id) + + const event = parseSyncEventData(item.data) + if (!event) { + console.warn('[Sync] Dropping malformed SSE payload:', { + serverId: connection.serverId, + event: item.event, + data: item.data, + }) + continue + } + + this.emitSyncEvent(connection.serverId, event) + + if (event.type === 'protocol.reset' || event.type === 'protocol.invalid') { + connection.lastEventId = undefined + return 'protocol-reconnect' + } + } + + return 'closed' + } + + private async waitForReconnect(connection: SyncConnection): Promise { + if (connection.stopped) return + + this.updateStatus(connection, 'reconnecting') + const delay = this.getReconnectDelay(connection) + + await new Promise((resolve) => { + connection.reconnectResolve = resolve + connection.reconnectTimer = setTimeout(() => { + connection.reconnectTimer = undefined + connection.reconnectResolve = undefined + resolve() + }, delay) + }) + } + + private closeConnection(serverId: string): void { + const connection = this.connections.get(serverId) + if (!connection) return + + connection.stopped = true + connection.controller?.abort() + + if (connection.reconnectTimer) { + clearTimeout(connection.reconnectTimer) + connection.reconnectTimer = undefined + } + connection.reconnectResolve?.() + connection.reconnectResolve = undefined + + this.updateStatus(connection, 'disconnected') + this.connections.delete(serverId) + } + + private getReconnectDelay(connection: SyncConnection): number { + const exponentialDelay = + connection.retryDelay * Math.pow(2, Math.max(connection.reconnectAttempts - 1, 0)) + return Math.min(exponentialDelay, MAX_RECONNECT_DELAY) + Math.random() * JITTER_MS + } + + private updateLastEventId(connection: SyncConnection, id: string | undefined): void { + if (id === undefined) return + connection.lastEventId = id || undefined + } + + private intentToParam(intent: Archon.Sync.v1.SyncIntent): string { + return Array.isArray(intent) ? intent.join(',') : intent + } + + private isAbortError(error: unknown): boolean { + if (!(error instanceof Error)) return false + return error.name === 'AbortError' || error.message.toLowerCase().includes('abort') + } +} diff --git a/packages/api-client/src/platform/tauri.ts b/packages/api-client/src/platform/tauri.ts index 725bcc7795..ac82b53edb 100644 --- a/packages/api-client/src/platform/tauri.ts +++ b/packages/api-client/src/platform/tauri.ts @@ -1,6 +1,8 @@ import type { ModrinthApiError } from '../core/errors' import type { ClientConfig } from '../types/client' import type { RequestOptions } from '../types/request' +import { appendRequestParams, parseResponseErrorData, toFetchBody } from '../utils/fetch' +import { GenericSyncClient } from './sync-generic' import { GenericWebSocketClient } from './websocket-generic' import { XHRUploadClient } from './xhr-upload-client' @@ -49,6 +51,12 @@ export class TauriModrinthClient extends XHRUploadClient { enumerable: true, configurable: false, }) + Object.defineProperty(this.archon, 'sync', { + value: new GenericSyncClient(this), + writable: false, + enumerable: true, + configurable: false, + }) } protected async executeRequest(url: string, options: RequestOptions): Promise { @@ -57,36 +65,8 @@ export class TauriModrinthClient extends XHRUploadClient { // This allows the package to be used in non-Tauri environments const { fetch: tauriFetch } = await import('@tauri-apps/plugin-http') - let body: BodyInit | null | undefined = undefined - if (options.body) { - const raw = options.body - if ( - typeof raw === 'object' && - !(raw instanceof FormData) && - !(raw instanceof URLSearchParams) && - !(raw instanceof Blob) && - !(raw instanceof ArrayBuffer) && - !ArrayBuffer.isView(raw as ArrayBufferView) - ) { - body = JSON.stringify(raw) - } else { - body = raw as BodyInit - } - } - - let fullUrl = url - if (options.params) { - const filteredParams: Record = {} - for (const [key, value] of Object.entries(options.params)) { - if (value !== undefined && value !== null) { - filteredParams[key] = String(value) - } - } - const queryString = new URLSearchParams(filteredParams).toString() - if (queryString) { - fullUrl = `${url}?${queryString}` - } - } + const body = toFetchBody(options.body) + const fullUrl = appendRequestParams(url, options.params) const response = await tauriFetch(fullUrl, { method: options.method ?? 'GET', @@ -147,6 +127,41 @@ export class TauriModrinthClient extends XHRUploadClient { } } + protected async executeStreamRequest( + url: string, + options: RequestOptions, + ): Promise> { + try { + const { fetch: tauriFetch } = await import('@tauri-apps/plugin-http') + const response = await tauriFetch(appendRequestParams(url, options.params), { + method: options.method ?? 'GET', + headers: options.headers, + body: toFetchBody(options.body), + signal: options.signal, + }) + + if (!response.ok) { + throw this.createNormalizedError( + new Error(`HTTP ${response.status}: ${response.statusText}`), + response.status, + await parseResponseErrorData(response), + ) + } + + if (!response.body) { + throw this.createNormalizedError( + new Error('Streaming response has no readable body'), + response.status, + undefined, + ) + } + + return response.body + } catch (error) { + throw this.normalizeError(error) + } + } + protected normalizeError(error: unknown): ModrinthApiError { if (error instanceof Error) { const httpError = error as HttpError diff --git a/packages/api-client/src/platform/xhr-upload-client.ts b/packages/api-client/src/platform/xhr-upload-client.ts index d16e765899..40190f96d4 100644 --- a/packages/api-client/src/platform/xhr-upload-client.ts +++ b/packages/api-client/src/platform/xhr-upload-client.ts @@ -18,9 +18,9 @@ export abstract class XHRUploadClient extends AbstractModrinthClient { upload(path: string, options: UploadRequestOptions): UploadHandle { let baseUrl: string if (options.api === 'labrinth') { - baseUrl = this.config.labrinthBaseUrl! + baseUrl = this.resolveBaseUrl(this.config.labrinthBaseUrl!) } else if (options.api === 'archon') { - baseUrl = this.config.archonBaseUrl! + baseUrl = this.resolveBaseUrl(this.config.archonBaseUrl!) } else { baseUrl = options.api } diff --git a/packages/api-client/src/types/client.ts b/packages/api-client/src/types/client.ts index 45828d5c20..57d33105ef 100644 --- a/packages/api-client/src/types/client.ts +++ b/packages/api-client/src/types/client.ts @@ -3,6 +3,7 @@ import type { RequestContext } from './request' export type MaybePromise = T | Promise export type UserAgentProvider = string | (() => MaybePromise) +export type BaseUrlConfig = string | (() => string) /** * Request lifecycle hooks @@ -39,13 +40,15 @@ export interface ClientConfig { * Base URL for Labrinth API (main Modrinth API) * @default 'https://api.modrinth.com' */ - labrinthBaseUrl?: string + labrinthBaseUrl?: BaseUrlConfig /** * Base URL for Archon API (Modrinth Hosting API) + * Can be a callback so apps can drive this from runtime feature flags. + * * @default 'https://archon.modrinth.com' */ - archonBaseUrl?: string + archonBaseUrl?: BaseUrlConfig /** * Default request timeout in milliseconds diff --git a/packages/api-client/src/types/index.ts b/packages/api-client/src/types/index.ts index 30daf6520c..2fb40b14e4 100644 --- a/packages/api-client/src/types/index.ts +++ b/packages/api-client/src/types/index.ts @@ -7,7 +7,7 @@ export type { } from '../features/circuit-breaker' export type { BackoffStrategy, RetryConfig } from '../features/retry' export type { Archon } from '../modules/archon/types' -export type { ClientConfig, RequestHooks } from './client' +export type { BaseUrlConfig, ClientConfig, RequestHooks } from './client' export type { ApiErrorData, ModrinthErrorResponse } from './errors' export { isModrinthErrorResponse } from './errors' export type { HttpMethod, RequestContext, RequestOptions, ResponseData } from './request' diff --git a/packages/api-client/src/utils/fetch.ts b/packages/api-client/src/utils/fetch.ts new file mode 100644 index 0000000000..9ab9c7ab0a --- /dev/null +++ b/packages/api-client/src/utils/fetch.ts @@ -0,0 +1,55 @@ +import type { RequestOptions } from '../types/request' + +export function appendRequestParams(url: string, params?: RequestOptions['params']): string { + if (!params) return url + + const filteredParams: Record = {} + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null) { + filteredParams[key] = String(value) + } + } + + const queryString = new URLSearchParams(filteredParams).toString() + if (!queryString) return url + + return `${url}${url.includes('?') ? '&' : '?'}${queryString}` +} + +export function toFetchBody(body: unknown): BodyInit | null | undefined { + if (!body) return undefined + + if ( + typeof body === 'object' && + !(body instanceof FormData) && + !(body instanceof URLSearchParams) && + !(body instanceof Blob) && + !(body instanceof ArrayBuffer) && + !ArrayBuffer.isView(body as ArrayBufferView) + ) { + return JSON.stringify(body) + } + + return body as BodyInit +} + +export async function parseResponseErrorData(response: Response): Promise { + const contentType = response.headers.get('content-type')?.toLowerCase() ?? '' + + try { + if (contentType.includes('application/json') || contentType.includes('+json')) { + return await response.json() + } + + const text = await response.text() + if (!text) return undefined + + try { + return JSON.parse(text) + } catch { + return text + } + } catch { + return undefined + } +} diff --git a/packages/api-client/src/utils/sse.ts b/packages/api-client/src/utils/sse.ts new file mode 100644 index 0000000000..8497196875 --- /dev/null +++ b/packages/api-client/src/utils/sse.ts @@ -0,0 +1,139 @@ +import type { Archon } from '../modules/archon/types' + +export type ParsedSseEvent = { + kind: 'event' + id?: string + event?: string + data: string +} + +export type ParsedSseRetry = { + kind: 'retry' + retry: number +} + +export type ParsedSseItem = ParsedSseEvent | ParsedSseRetry + +export class SseParser { + private buffer = '' + private eventName = '' + private data = '' + private id: string | undefined + + feed(chunk: string): ParsedSseItem[] { + this.buffer += chunk + const items: ParsedSseItem[] = [] + + while (true) { + const lineEnd = this.findLineEnd() + if (!lineEnd) break + + const { line, length } = lineEnd + this.buffer = this.buffer.slice(length) + this.processLine(line, items) + } + + return items + } + + end(): ParsedSseItem[] { + const items: ParsedSseItem[] = [] + + if (this.buffer.length > 0) { + this.processLine(this.buffer.endsWith('\r') ? this.buffer.slice(0, -1) : this.buffer, items) + this.buffer = '' + } + + this.dispatch(items) + return items + } + + private findLineEnd(): { line: string; length: number } | null { + const lf = this.buffer.indexOf('\n') + const cr = this.buffer.indexOf('\r') + + if (lf === -1 && cr === -1) return null + + if (cr !== -1 && (lf === -1 || cr < lf)) { + if (cr === this.buffer.length - 1) return null + const length = this.buffer[cr + 1] === '\n' ? cr + 2 : cr + 1 + return { + line: this.buffer.slice(0, cr), + length, + } + } + + return { + line: this.buffer.slice(0, lf), + length: lf + 1, + } + } + + private processLine(line: string, items: ParsedSseItem[]): void { + if (line === '') { + this.dispatch(items) + return + } + + if (line.startsWith(':')) return + + const colon = line.indexOf(':') + const field = colon === -1 ? line : line.slice(0, colon) + let value = colon === -1 ? '' : line.slice(colon + 1) + if (value.startsWith(' ')) value = value.slice(1) + + switch (field) { + case 'event': + this.eventName = value + break + case 'data': + this.data += `${value}\n` + break + case 'id': + this.id = value + break + case 'retry': { + const retry = Number(value) + if (Number.isInteger(retry) && retry >= 0) { + items.push({ kind: 'retry', retry }) + } + break + } + } + } + + private dispatch(items: ParsedSseItem[]): void { + if (!this.data) { + this.eventName = '' + this.id = undefined + return + } + + items.push({ + kind: 'event', + id: this.id, + event: this.eventName || undefined, + data: this.data.endsWith('\n') ? this.data.slice(0, -1) : this.data, + }) + + this.eventName = '' + this.data = '' + this.id = undefined + } +} + +export function parseSyncEventData(data: string): Archon.Sync.v1.SyncEvent | null { + let parsed: unknown + + try { + parsed = JSON.parse(data) + } catch { + return null + } + + if (!parsed || typeof parsed !== 'object') return null + const event = parsed as { type?: unknown } + if (typeof event.type !== 'string') return null + + return parsed as Archon.Sync.v1.SyncEvent +} diff --git a/packages/assets/external/illustrations/intercom_bubble_icon.png b/packages/assets/external/illustrations/intercom_bubble_icon.png new file mode 100644 index 0000000000..6585b9b09e Binary files /dev/null and b/packages/assets/external/illustrations/intercom_bubble_icon.png differ diff --git a/packages/assets/index.ts b/packages/assets/index.ts index 4fb4028886..7a8ed170cd 100644 --- a/packages/assets/index.ts +++ b/packages/assets/index.ts @@ -41,6 +41,7 @@ import _DiscordIcon from './external/discord.svg?component' import _FacebookIcon from './external/facebook.svg?component' import _FlathubIcon from './external/flathub.svg?component' import _GithubIcon from './external/github.svg?component' +import _IntercomBubbleIcon from './external/illustrations/intercom_bubble_icon.png?url' import _MinecraftServerIcon from './external/illustrations/minecraft_server_icon.png?url' import _InstagramIcon from './external/instagram.svg?component' import _KoFiIcon from './external/kofi.svg?component' @@ -132,6 +133,7 @@ export const VenmoIcon = _VenmoIcon export const PolygonIcon = _PolygonIcon export const USDCColorIcon = _USDCColorIcon export const VisaIcon = _VisaIcon +export const IntercomBubbleIcon = _IntercomBubbleIcon export const MinecraftServerIcon = _MinecraftServerIcon export * from './generated-icons' diff --git a/packages/assets/styles/classes.scss b/packages/assets/styles/classes.scss index 1822c5fda5..3045700d1d 100644 --- a/packages/assets/styles/classes.scss +++ b/packages/assets/styles/classes.scss @@ -651,8 +651,8 @@ a:not(.no-click-animation), // TOOLTIPS -.v-popper--theme-dropdown, -.v-popper--theme-dropdown.v-popper--theme-ribbit-popout { +.v-popper__popper.v-popper--theme-dropdown, +.v-popper__popper.v-popper--theme-dropdown.v-popper--theme-ribbit-popout { .v-popper__inner { border: 1px solid var(--color-divider) !important; padding: var(--gap-sm) !important; @@ -710,9 +710,13 @@ a:not(.no-click-animation), //transform: scale(.9); } -.v-popper--theme-tooltip { +.v-popper__popper.v-popper--theme-tooltip { pointer-events: none; + &.v-popper--interactive { + pointer-events: auto; + } + .v-popper__inner { background: var(--color-tooltip-bg) !important; color: var(--color-tooltip-text) !important; @@ -730,7 +734,7 @@ a:not(.no-click-animation), } } -.v-popper--theme-dismissable-prompt { +.v-popper__popper.v-popper--theme-dismissable-prompt { z-index: 10; .v-popper__inner { diff --git a/packages/ui/.storybook/preview.scss b/packages/ui/.storybook/preview.scss new file mode 100644 index 0000000000..b9e6de3ad7 --- /dev/null +++ b/packages/ui/.storybook/preview.scss @@ -0,0 +1,17 @@ +html { + min-height: 100%; + overflow: auto; +} + +body { + position: static !important; + width: auto !important; + min-height: 100vh; + height: auto !important; + overflow: auto !important; +} + +#storybook-root { + min-height: 100vh; + height: auto; +} diff --git a/packages/ui/.storybook/preview.ts b/packages/ui/.storybook/preview.ts index 9b340008eb..e68f912e93 100644 --- a/packages/ui/.storybook/preview.ts +++ b/packages/ui/.storybook/preview.ts @@ -6,6 +6,7 @@ import '../../assets/styles/defaults.scss' // --- // app-frontend css imports import '../../../apps/app-frontend/src/assets/stylesheets/global.scss' +import './preview.scss' import type { Labrinth } from '@modrinth/api-client' import { GenericModrinthClient } from '@modrinth/api-client' diff --git a/packages/ui/src/components/base/BaseTerminal.vue b/packages/ui/src/components/base/BaseTerminal.vue index d266fd07ee..bb6c1ddf9a 100644 --- a/packages/ui/src/components/base/BaseTerminal.vue +++ b/packages/ui/src/components/base/BaseTerminal.vue @@ -26,8 +26,9 @@ > { + if (props.disableInput) return const cmd = commandInput.value.trim() if (!cmd) return emit('command', cmd) diff --git a/packages/ui/src/components/base/Combobox.vue b/packages/ui/src/components/base/Combobox.vue index eb50ece3bb..d297c47fe5 100644 --- a/packages/ui/src/components/base/Combobox.vue +++ b/packages/ui/src/components/base/Combobox.vue @@ -62,18 +62,18 @@ @click="handleTriggerClick($event)" @keydown="handleTriggerKeydown" > -
+
- + {{ triggerText }}
-
+
-
+
{{ noOptionsMessage }}
@@ -232,6 +233,9 @@ const props = withDefaults( forceDirection?: 'up' | 'down' noOptionsMessage?: string disableSearchFilter?: boolean + dropdownClass?: string + dropdownMinWidth?: string + minSearchLengthToOpen?: number /** Keep the selected option's label in the input after selection, and show all options on focus */ syncWithSelection?: boolean /** Select the searchable input text when the field receives focus */ @@ -249,6 +253,7 @@ const props = withDefaults( showIconInSelected: false, maxHeight: DEFAULT_MAX_HEIGHT, noOptionsMessage: 'No results found', + minSearchLengthToOpen: 0, syncWithSelection: true, selectSearchTextOnFocus: false, showSearchIcon: false, @@ -290,6 +295,7 @@ const dropdownStyle = ref({ top: '0px', left: '0px', width: '0px', + minWidth: '0px', }) const openDirection = ref<'down' | 'up'>('down') @@ -323,6 +329,10 @@ const triggerText = computed(() => { return props.placeholder }) +const hasMinimumSearchLength = computed( + () => !props.searchable || searchQuery.value.trim().length >= props.minSearchLengthToOpen, +) + const optionsWithKeys = computed(() => { return props.options.map((opt, index) => ({ ...opt, @@ -441,13 +451,15 @@ async function updateDropdownPosition() { top: `${top}px`, left: `${left}px`, width: `${triggerRect.width}px`, + minWidth: props.dropdownMinWidth ?? `${triggerRect.width}px`, } openDirection.value = direction } async function openDropdown() { - if (props.disabled || isOpen.value || !hasDropdownContent.value) return + if (props.disabled || isOpen.value || !hasMinimumSearchLength.value || !hasDropdownContent.value) + return isOpen.value = true emit('open') @@ -642,6 +654,10 @@ function handleSearchKeydown(event: KeyboardEvent) { function handleSearchInput() { userHasTyped.value = true emit('searchInput', searchQuery.value) + if (!hasMinimumSearchLength.value) { + closeDropdown() + return + } if (!isOpen.value) { openDropdown() } @@ -742,10 +758,16 @@ watch(hasDropdownContent, (value) => { } }) +watch(hasMinimumSearchLength, (canOpen) => { + if (!canOpen) { + closeDropdown() + } +}) + watch( [() => props.modelValue, () => props.options], ([val]) => { - if (props.searchable && props.syncWithSelection && !isOpen.value) { + if (props.searchable && props.syncWithSelection && !isOpen.value && !userHasTyped.value) { const opt = props.options.find((o) => isDropdownOption(o) && o.value === val) searchQuery.value = opt && isDropdownOption(opt) ? opt.label : '' } diff --git a/packages/ui/src/components/base/DropdownFilterBar.vue b/packages/ui/src/components/base/DropdownFilterBar.vue index 2cc71a73c1..e3ce3cdfe9 100644 --- a/packages/ui/src/components/base/DropdownFilterBar.vue +++ b/packages/ui/src/components/base/DropdownFilterBar.vue @@ -55,6 +55,7 @@