From 1b789715d330997dd35f3a457ac56189d74fc6e1 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Mon, 15 Dec 2025 11:19:09 +0000 Subject: [PATCH 01/12] feat: api-client module for content v0 --- .../src/modules/archon/content/v0.ts | 56 +++++++++++++++++++ .../api-client/src/modules/archon/index.ts | 1 + .../api-client/src/modules/archon/types.ts | 36 ++++++++++++ packages/api-client/src/modules/index.ts | 2 + 4 files changed, 95 insertions(+) create mode 100644 packages/api-client/src/modules/archon/content/v0.ts diff --git a/packages/api-client/src/modules/archon/content/v0.ts b/packages/api-client/src/modules/archon/content/v0.ts new file mode 100644 index 0000000000..384fd38572 --- /dev/null +++ b/packages/api-client/src/modules/archon/content/v0.ts @@ -0,0 +1,56 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { Archon } from '../types' + +export class ArchonContentV0Module extends AbstractModule { + public getModuleID(): string { + return 'archon_content_v0' + } + + /** GET /modrinth/v0/servers/:server_id/mods */ + public async list(serverId: string): Promise { + return this.client.request(`/servers/${serverId}/mods`, { + api: 'archon', + version: 'modrinth/v0', + method: 'GET', + }) + } + + /** POST /modrinth/v0/servers/:server_id/mods */ + public async install( + serverId: string, + request: Archon.Content.v0.InstallModRequest, + ): Promise { + await this.client.request(`/servers/${serverId}/mods`, { + api: 'archon', + version: 'modrinth/v0', + method: 'POST', + body: request, + }) + } + + /** POST /modrinth/v0/servers/:server_id/deleteMod */ + public async delete( + serverId: string, + request: Archon.Content.v0.DeleteModRequest, + ): Promise { + await this.client.request(`/servers/${serverId}/deleteMod`, { + api: 'archon', + version: 'modrinth/v0', + method: 'POST', + body: request, + }) + } + + /** POST /modrinth/v0/servers/:server_id/mods/update */ + public async update( + serverId: string, + request: Archon.Content.v0.UpdateModRequest, + ): Promise { + await this.client.request(`/servers/${serverId}/mods/update`, { + api: 'archon', + version: 'modrinth/v0', + method: 'POST', + body: request, + }) + } +} diff --git a/packages/api-client/src/modules/archon/index.ts b/packages/api-client/src/modules/archon/index.ts index 7f4699aee9..304edc0749 100644 --- a/packages/api-client/src/modules/archon/index.ts +++ b/packages/api-client/src/modules/archon/index.ts @@ -1,5 +1,6 @@ export * from './backups/v0' export * from './backups/v1' +export * from './content/v0' export * from './servers/v0' export * from './servers/v1' export * from './types' diff --git a/packages/api-client/src/modules/archon/types.ts b/packages/api-client/src/modules/archon/types.ts index 0b96e2b0fb..5df2ce0dbd 100644 --- a/packages/api-client/src/modules/archon/types.ts +++ b/packages/api-client/src/modules/archon/types.ts @@ -1,4 +1,40 @@ export namespace Archon { + export namespace Content { + export namespace v0 { + export type ContentKind = 'mod' | 'plugin' + + export type Mod = { + filename: string + project_id: string | undefined + version_id: string | undefined + name: string | undefined + version_number: string | undefined + icon_url: string | undefined + owner: string | undefined + disabled: boolean + installing: boolean + } + + export type InstallModRequest = { + rinth_ids: { + project_id: string + version_id: string + } + install_as: ContentKind + } + + export type DeleteModRequest = { + path: string + } + + export type UpdateModRequest = { + replace: string + project_id: string + version_id: string + } + } + } + export namespace Servers { export namespace v0 { export type ServerGetResponse = { diff --git a/packages/api-client/src/modules/index.ts b/packages/api-client/src/modules/index.ts index e78a8b7541..015467319f 100644 --- a/packages/api-client/src/modules/index.ts +++ b/packages/api-client/src/modules/index.ts @@ -2,6 +2,7 @@ import type { AbstractModrinthClient } from '../core/abstract-client' import type { AbstractModule } from '../core/abstract-module' import { ArchonBackupsV0Module } from './archon/backups/v0' import { ArchonBackupsV1Module } from './archon/backups/v1' +import { ArchonContentV0Module } from './archon/content/v0' import { ArchonServersV0Module } from './archon/servers/v0' import { ArchonServersV1Module } from './archon/servers/v1' import { ISO3166Module } from './iso3166' @@ -26,6 +27,7 @@ type ModuleConstructor = new (client: AbstractModrinthClient) => AbstractModule export const MODULE_REGISTRY = { archon_backups_v0: ArchonBackupsV0Module, archon_backups_v1: ArchonBackupsV1Module, + archon_content_v0: ArchonContentV0Module, archon_servers_v0: ArchonServersV0Module, archon_servers_v1: ArchonServersV1Module, iso3166_data: ISO3166Module, From 35b105e9a8045b1b80983412fcacef7789398de3 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Mon, 15 Dec 2025 11:31:04 +0000 Subject: [PATCH 02/12] feat: delete unused components + modules + setting --- .../ui/servers/BackupSettingsModal.vue | 172 ------------------ .../composables/servers/modrinth-servers.ts | 14 +- .../manage/[id]/options/preferences.vue | 6 - .../ui/src/pages/hosting/manage/backups.vue | 5 - packages/utils/servers/types/api.ts | 2 +- 5 files changed, 2 insertions(+), 197 deletions(-) delete mode 100644 apps/frontend/src/components/ui/servers/BackupSettingsModal.vue diff --git a/apps/frontend/src/components/ui/servers/BackupSettingsModal.vue b/apps/frontend/src/components/ui/servers/BackupSettingsModal.vue deleted file mode 100644 index bbc6911eae..0000000000 --- a/apps/frontend/src/components/ui/servers/BackupSettingsModal.vue +++ /dev/null @@ -1,172 +0,0 @@ - - - - - diff --git a/apps/frontend/src/composables/servers/modrinth-servers.ts b/apps/frontend/src/composables/servers/modrinth-servers.ts index d6f2f7b743..15f40e605d 100644 --- a/apps/frontend/src/composables/servers/modrinth-servers.ts +++ b/apps/frontend/src/composables/servers/modrinth-servers.ts @@ -3,13 +3,11 @@ import type { JWTAuth, ModuleError, ModuleName } from '@modrinth/utils' import { ModrinthServerError } from '@modrinth/utils' import { - BackupsModule, ContentModule, FSModule, GeneralModule, NetworkModule, StartupModule, - WSModule, } from './modules/index.ts' import { useServersFetch } from './servers-fetch.ts' @@ -36,10 +34,8 @@ export class ModrinthServer { readonly general: GeneralModule readonly content: ContentModule - readonly backups: BackupsModule readonly network: NetworkModule readonly startup: StartupModule - readonly ws: WSModule readonly fs: FSModule constructor(serverId: string) { @@ -47,10 +43,8 @@ export class ModrinthServer { this.general = new GeneralModule(this) this.content = new ContentModule(this) - this.backups = new BackupsModule(this) this.network = new NetworkModule(this) this.startup = new StartupModule(this) - this.ws = new WSModule(this) this.fs = new FSModule(this) } @@ -242,7 +236,7 @@ export class ModrinthServer { const modulesToRefresh = modules.length > 0 ? modules - : (['general', 'content', 'backups', 'network', 'startup', 'ws', 'fs'] as ModuleName[]) + : (['general', 'content', 'network', 'startup', 'fs'] as ModuleName[]) for (const module of modulesToRefresh) { this.errors[module] = undefined @@ -274,18 +268,12 @@ export class ModrinthServer { case 'content': await this.content.fetch() break - case 'backups': - await this.backups.fetch() - break case 'network': await this.network.fetch() break case 'startup': await this.startup.fetch() break - case 'ws': - await this.ws.fetch() - break case 'fs': await this.fs.fetch() break diff --git a/apps/frontend/src/pages/hosting/manage/[id]/options/preferences.vue b/apps/frontend/src/pages/hosting/manage/[id]/options/preferences.vue index c4b289e02e..bb81742597 100644 --- a/apps/frontend/src/pages/hosting/manage/[id]/options/preferences.vue +++ b/apps/frontend/src/pages/hosting/manage/[id]/options/preferences.vue @@ -78,11 +78,6 @@ const preferences = { description: 'When enabled, you will be prompted before stopping and restarting your server.', implemented: true, }, - backupWhileRunning: { - displayName: 'Create backups while running', - description: 'When enabled, backups will be created even if the server is running.', - implemented: true, - }, } as const type PreferenceKeys = keyof typeof preferences @@ -96,7 +91,6 @@ const defaultPreferences: UserPreferences = { hideSubdomainLabel: false, autoRestart: false, powerDontAskAgain: false, - backupWhileRunning: false, } const userPreferences = useStorage( diff --git a/packages/ui/src/pages/hosting/manage/backups.vue b/packages/ui/src/pages/hosting/manage/backups.vue index 86cd8cb21a..96e8fcd3be 100644 --- a/packages/ui/src/pages/hosting/manage/backups.vue +++ b/packages/ui/src/pages/hosting/manage/backups.vue @@ -350,7 +350,6 @@ const createBackupModal = ref>() const renameBackupModal = ref>() const restoreBackupModal = ref>() const deleteBackupModal = ref>() -// const backupSettingsModal = ref>() const backupRestoreDisabled = computed(() => { if (props.isServerRunning) { @@ -400,10 +399,6 @@ const showCreateModel = () => { createBackupModal.value?.show() } -// const showbackupSettingsModal = () => { -// backupSettingsModal.value?.show() -// } - function triggerDownloadAnimation() { overTheTopDownloadAnimation.value = true setTimeout(() => (overTheTopDownloadAnimation.value = false), 500) diff --git a/packages/utils/servers/types/api.ts b/packages/utils/servers/types/api.ts index 634f79d9b0..23363f3710 100644 --- a/packages/utils/servers/types/api.ts +++ b/packages/utils/servers/types/api.ts @@ -16,4 +16,4 @@ export interface ModuleError { timestamp: number } -export type ModuleName = 'general' | 'content' | 'backups' | 'network' | 'startup' | 'ws' | 'fs' +export type ModuleName = 'general' | 'content' | 'network' | 'startup' | 'fs' From ae071165eea23e32a7f21ac5becd853607ac2e68 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Mon, 15 Dec 2025 12:33:59 +0000 Subject: [PATCH 03/12] feat: xhr uploading --- .../api-client/src/core/abstract-client.ts | 81 +++++++++- .../src/core/abstract-upload-client.ts | 25 +++ packages/api-client/src/index.ts | 2 + .../api-client/src/modules/kyros/files/v0.ts | 28 ++-- packages/api-client/src/platform/generic.ts | 4 +- packages/api-client/src/platform/nuxt.ts | 23 ++- packages/api-client/src/platform/tauri.ts | 8 +- .../src/platform/xhr-upload-client.ts | 153 ++++++++++++++++++ packages/api-client/src/types/index.ts | 1 + packages/api-client/src/types/request.ts | 7 + packages/api-client/src/types/upload.ts | 51 ++++++ 11 files changed, 365 insertions(+), 18 deletions(-) create mode 100644 packages/api-client/src/core/abstract-upload-client.ts create mode 100644 packages/api-client/src/platform/xhr-upload-client.ts create mode 100644 packages/api-client/src/types/upload.ts diff --git a/packages/api-client/src/core/abstract-client.ts b/packages/api-client/src/core/abstract-client.ts index 123d3d14db..3aed6c62bc 100644 --- a/packages/api-client/src/core/abstract-client.ts +++ b/packages/api-client/src/core/abstract-client.ts @@ -2,15 +2,17 @@ import type { InferredClientModules } from '../modules' import { buildModuleStructure } from '../modules' import type { 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 { AbstractUploadClient } from './abstract-upload-client' import type { AbstractWebSocketClient } from './abstract-websocket' import { ModrinthApiError, ModrinthServerError } from './errors' /** * Abstract base client for Modrinth APIs */ -export abstract class AbstractModrinthClient { +export abstract class AbstractModrinthClient extends AbstractUploadClient { protected config: ClientConfig protected features: AbstractFeature[] @@ -30,6 +32,7 @@ export abstract class AbstractModrinthClient { public readonly iso3166!: InferredClientModules['iso3166'] constructor(config: ClientConfig) { + super() this.config = { timeout: 10000, labrinthBaseUrl: 'https://api.modrinth.com', @@ -171,6 +174,35 @@ export abstract class AbstractModrinthClient { return next() } + /** + * Execute the feature chain for an upload + * + * Similar to executeFeatureChain but calls executeXHRUpload at the end. + * This allows features (auth, retry, etc.) to wrap the upload execution. + */ + protected async executeUploadFeatureChain( + context: RequestContext, + progressCallbacks: Array<(p: UploadProgress) => void>, + abortController: AbortController, + ): Promise { + const applicableFeatures = this.features.filter((feature) => feature.shouldApply(context)) + + let index = applicableFeatures.length + + const next = async (): Promise => { + index-- + + if (index >= 0) { + return applicableFeatures[index].execute(next, context) + } else { + await this.config.hooks?.onRequest?.(context) + return this.executeXHRUpload(context, progressCallbacks, abortController) + } + } + + return next() + } + /** * Build the full URL for a request */ @@ -207,6 +239,36 @@ export abstract class AbstractModrinthClient { } } + /** + * Build context for an upload request + * + * Sets metadata.isUpload = true so features can detect uploads. + */ + protected buildUploadContext( + url: string, + path: string, + options: UploadRequestOptions, + ): RequestContext { + const metadata: UploadMetadata = { + isUpload: true, + file: options.file, + onProgress: options.onProgress, + } + + return { + url, + path, + options: { + ...options, + method: 'POST', + body: options.file, + }, + attempt: 1, + startTime: Date.now(), + metadata, + } + } + /** * Build default headers for all requests * @@ -238,6 +300,23 @@ export abstract class AbstractModrinthClient { */ protected abstract executeRequest(url: string, options: RequestOptions): Promise + /** + * Execute the actual XHR upload + * + * This must be implemented by platform clients that support uploads. + * Called at the end of the upload feature chain. + * + * @param context - Request context with upload metadata + * @param progressCallbacks - Callbacks to invoke on progress events + * @param abortController - Controller for cancellation + * @returns Promise resolving to the response data + */ + protected abstract executeXHRUpload( + context: RequestContext, + progressCallbacks: Array<(p: UploadProgress) => void>, + abortController: AbortController, + ): Promise + /** * Normalize an error into a ModrinthApiError * diff --git a/packages/api-client/src/core/abstract-upload-client.ts b/packages/api-client/src/core/abstract-upload-client.ts new file mode 100644 index 0000000000..67de45e13a --- /dev/null +++ b/packages/api-client/src/core/abstract-upload-client.ts @@ -0,0 +1,25 @@ +import type { UploadHandle, UploadRequestOptions } from '../types/upload' + +/** + * Abstract base class defining upload capability + * + * All clients that support file uploads must extend this class. + * Platform-specific implementations should provide the actual upload mechanism + * (e.g., XHR for browser environments). + * + * Upload goes through the feature chain (auth, retry, circuit-breaker, etc.) + * just like regular requests. + */ +export abstract class AbstractUploadClient { + /** + * Upload a file with progress tracking + * + * Features (auth, retry, etc.) are applied to uploads. + * Retry is disabled by default to prevent re-uploading large files. + * + * @param path - API path (e.g., '/fs/create') + * @param options - Upload options including file, api, version + * @returns UploadHandle with promise, onProgress chain, and cancel method + */ + abstract upload(path: string, options: UploadRequestOptions): UploadHandle +} diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index 396a0ceb14..210dc65d2c 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -1,5 +1,6 @@ export { AbstractModrinthClient } from './core/abstract-client' export { AbstractFeature, type FeatureConfig } from './core/abstract-feature' +export { AbstractUploadClient } from './core/abstract-upload-client' export { AbstractWebSocketClient, type WebSocketConnection, @@ -25,4 +26,5 @@ export type { NuxtClientConfig } from './platform/nuxt' export { NuxtCircuitBreakerStorage, NuxtModrinthClient } from './platform/nuxt' export type { TauriClientConfig } from './platform/tauri' export { TauriModrinthClient } from './platform/tauri' +export { XHRUploadClient } from './platform/xhr-upload-client' export * from './types' diff --git a/packages/api-client/src/modules/kyros/files/v0.ts b/packages/api-client/src/modules/kyros/files/v0.ts index 6d1f86b634..5e158e15b5 100644 --- a/packages/api-client/src/modules/kyros/files/v0.ts +++ b/packages/api-client/src/modules/kyros/files/v0.ts @@ -1,4 +1,5 @@ import { AbstractModule } from '../../../core/abstract-module' +import type { UploadHandle, UploadProgress } from '../../../types/upload' export class KyrosFilesV0Module extends AbstractModule { public getModuleID(): string { @@ -24,29 +25,38 @@ export class KyrosFilesV0Module extends AbstractModule { } /** - * Upload a file to a server's filesystem + * Upload a file to a server's filesystem with progress tracking * - * @param nodeInstance - Node instance URL + * @param nodeInstance - Node instance URL (e.g., "node-xyz.modrinth.com/modrinth/v0/fs") * @param nodeToken - JWT token from getFilesystemAuth * @param path - Destination path (e.g., "/server-icon.png") * @param file - File to upload + * @param options - Optional progress callback and feature overrides + * @returns UploadHandle with promise, onProgress, and cancel */ - public async uploadFile( + public uploadFile( nodeInstance: string, nodeToken: string, path: string, file: File, - ): Promise { - return this.client.request(`/fs/create`, { - api: `https://${nodeInstance.replace('v0/fs', '')}`, - method: 'POST', + options?: { + onProgress?: (progress: UploadProgress) => void + retry?: boolean | number + }, + ): UploadHandle { + const baseUrl = `https://${nodeInstance.replace('v0/fs', '')}` + + return this.client.upload('/fs/create', { + api: baseUrl, version: 'v0', + file, params: { path, type: 'file' }, headers: { Authorization: `Bearer ${nodeToken}`, - 'Content-Type': 'application/octet-stream', }, - body: file, + onProgress: options?.onProgress, + retry: options?.retry, + skipAuth: true, // Use nodeToken, not main auth }) } } diff --git a/packages/api-client/src/platform/generic.ts b/packages/api-client/src/platform/generic.ts index 7301237ec7..94b01729b6 100644 --- a/packages/api-client/src/platform/generic.ts +++ b/packages/api-client/src/platform/generic.ts @@ -1,10 +1,10 @@ import { $fetch, FetchError } from 'ofetch' -import { AbstractModrinthClient } from '../core/abstract-client' import type { ModrinthApiError } from '../core/errors' import type { ClientConfig } from '../types/client' import type { RequestOptions } from '../types/request' import { GenericWebSocketClient } from './websocket-generic' +import { XHRUploadClient } from './xhr-upload-client' /** * Generic platform client using ofetch @@ -24,7 +24,7 @@ import { GenericWebSocketClient } from './websocket-generic' * const project = await client.request('/project/sodium', { api: 'labrinth', version: 2 }) * ``` */ -export class GenericModrinthClient extends AbstractModrinthClient { +export class GenericModrinthClient extends XHRUploadClient { constructor(config: ClientConfig) { super(config) diff --git a/packages/api-client/src/platform/nuxt.ts b/packages/api-client/src/platform/nuxt.ts index 5f57977a5f..449c3cadbb 100644 --- a/packages/api-client/src/platform/nuxt.ts +++ b/packages/api-client/src/platform/nuxt.ts @@ -1,11 +1,12 @@ import { FetchError } from 'ofetch' -import { AbstractModrinthClient } from '../core/abstract-client' -import type { ModrinthApiError } from '../core/errors' +import { ModrinthApiError } from '../core/errors' import type { CircuitBreakerState, CircuitBreakerStorage } from '../features/circuit-breaker' import type { ClientConfig } from '../types/client' import type { RequestOptions } from '../types/request' +import type { UploadHandle, UploadRequestOptions } from '../types/upload' import { GenericWebSocketClient } from './websocket-generic' +import { XHRUploadClient } from './xhr-upload-client' /** * Circuit breaker storage using Nuxt's useState @@ -53,6 +54,8 @@ export interface NuxtClientConfig extends ClientConfig { * * This client is optimized for Nuxt applications and handles SSR/CSR automatically. * + * Note: upload() is only available in browser context (CSR). It will throw during SSR. + * * @example * ```typescript * // In a Nuxt composable @@ -70,7 +73,7 @@ export interface NuxtClientConfig extends ClientConfig { * const project = await client.request('/project/sodium', { api: 'labrinth', version: 2 }) * ``` */ -export class NuxtModrinthClient extends AbstractModrinthClient { +export class NuxtModrinthClient extends XHRUploadClient { protected declare config: NuxtClientConfig constructor(config: NuxtClientConfig) { @@ -84,6 +87,20 @@ export class NuxtModrinthClient extends AbstractModrinthClient { }) } + /** + * Upload a file with progress tracking + * + * Note: This method is only available in browser context (CSR). + * Calling during SSR will throw an error. + */ + upload(path: string, options: UploadRequestOptions): UploadHandle { + // @ts-expect-error - import.meta is provided by Nuxt + if (import.meta.server) { + throw new ModrinthApiError('upload() is not supported during SSR') + } + return super.upload(path, options) + } + protected async executeRequest(url: string, options: RequestOptions): Promise { try { // @ts-expect-error - $fetch is provided by Nuxt runtime diff --git a/packages/api-client/src/platform/tauri.ts b/packages/api-client/src/platform/tauri.ts index 05bfb9fb99..80eec9977b 100644 --- a/packages/api-client/src/platform/tauri.ts +++ b/packages/api-client/src/platform/tauri.ts @@ -1,8 +1,8 @@ -import { AbstractModrinthClient } from '../core/abstract-client' import type { ModrinthApiError } from '../core/errors' import type { ClientConfig } from '../types/client' import type { RequestOptions } from '../types/request' import { GenericWebSocketClient } from './websocket-generic' +import { XHRUploadClient } from './xhr-upload-client' /** * Tauri-specific configuration @@ -20,7 +20,9 @@ interface HttpError extends Error { /** * Tauri platform client using Tauri v2 HTTP plugin - + * + * Extends XHRUploadClient to provide upload with progress tracking. + * * @example * ```typescript * import { getVersion } from '@tauri-apps/api/app' @@ -36,7 +38,7 @@ interface HttpError extends Error { * const project = await client.request('/project/sodium', { api: 'labrinth', version: 2 }) * ``` */ -export class TauriModrinthClient extends AbstractModrinthClient { +export class TauriModrinthClient extends XHRUploadClient { protected declare config: TauriClientConfig constructor(config: TauriClientConfig) { diff --git a/packages/api-client/src/platform/xhr-upload-client.ts b/packages/api-client/src/platform/xhr-upload-client.ts new file mode 100644 index 0000000000..10cf622486 --- /dev/null +++ b/packages/api-client/src/platform/xhr-upload-client.ts @@ -0,0 +1,153 @@ +import { AbstractModrinthClient } from '../core/abstract-client' +import { ModrinthApiError } from '../core/errors' +import type { RequestContext } from '../types/request' +import type { + UploadHandle, + UploadMetadata, + UploadProgress, + UploadRequestOptions, +} from '../types/upload' + +/** + * Abstract client with XHR-based upload implementation + * + * Provides upload() with progress tracking for browser environments. + * Uses XMLHttpRequest because fetch doesn't support upload progress in Firefox/Safari. + * + * Uploads go through the feature chain (auth, retry, circuit-breaker, etc.) + * just like regular requests. + * + * Platform-specific clients should extend this instead of AbstractModrinthClient + * to inherit the XHR upload implementation. + */ +export abstract class XHRUploadClient extends AbstractModrinthClient { + /** + * Upload a file with progress tracking + * + * Goes through the feature chain (auth, retry, etc.) + * Retry is disabled by default to prevent re-uploading large files. + */ + upload(path: string, options: UploadRequestOptions): UploadHandle { + // Build URL like request() does + let baseUrl: string + if (options.api === 'labrinth') { + baseUrl = this.config.labrinthBaseUrl! + } else if (options.api === 'archon') { + baseUrl = this.config.archonBaseUrl! + } else { + baseUrl = options.api + } + + const url = this.buildUrl(path, baseUrl, options.version) + + // Merge with defaults - retry defaults to false for uploads + const mergedOptions: UploadRequestOptions = { + retry: false, // Default: don't retry uploads + ...options, + headers: { + ...this.buildDefaultHeaders(), + 'Content-Type': 'application/octet-stream', + ...options.headers, + }, + } + + const context = this.buildUploadContext(url, path, mergedOptions) + + // Setup progress callbacks and abort controller + const progressCallbacks: Array<(p: UploadProgress) => void> = [] + if (mergedOptions.onProgress) { + progressCallbacks.push(mergedOptions.onProgress) + } + const abortController = new AbortController() + + // Link external signal if provided + if (mergedOptions.signal) { + mergedOptions.signal.addEventListener('abort', () => abortController.abort()) + } + + // Build the handle - promise goes through feature chain + const handle: UploadHandle = { + promise: this.executeUploadFeatureChain(context, progressCallbacks, abortController) + .then(async (result) => { + await this.config.hooks?.onResponse?.(result, context) + return result + }) + .catch(async (error) => { + const apiError = this.normalizeError(error, context) + await this.config.hooks?.onError?.(apiError, context) + throw apiError + }), + onProgress: (callback) => { + progressCallbacks.push(callback) + return handle + }, + cancel: () => abortController.abort(), + } + + return handle + } + + /** + * Execute the actual XHR upload (called at end of feature chain) + */ + protected executeXHRUpload( + context: RequestContext, + progressCallbacks: Array<(p: UploadProgress) => void>, + abortController: AbortController, + ): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + const metadata = context.metadata as UploadMetadata + + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable) { + const progress: UploadProgress = { + loaded: e.loaded, + total: e.total, + progress: e.loaded / e.total, + } + progressCallbacks.forEach((cb) => cb(progress)) + } + }) + + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + resolve(xhr.response ? JSON.parse(xhr.response) : (undefined as T)) + } catch { + resolve(undefined as T) + } + } else { + reject(this.createUploadError(xhr)) + } + }) + + xhr.addEventListener('error', () => reject(new ModrinthApiError('Upload failed'))) + xhr.addEventListener('abort', () => reject(new ModrinthApiError('Upload cancelled'))) + + xhr.open('POST', context.url) + + // Apply headers from context (features may have modified them) + for (const [key, value] of Object.entries(context.options.headers ?? {})) { + xhr.setRequestHeader(key, value) + } + + xhr.send(metadata.file) + abortController.signal.addEventListener('abort', () => xhr.abort()) + }) + } + + protected createUploadError(xhr: XMLHttpRequest): ModrinthApiError { + let responseData: unknown + try { + responseData = xhr.response ? JSON.parse(xhr.response) : undefined + } catch { + responseData = xhr.responseText + } + return this.createNormalizedError( + new Error(`Upload failed with status ${xhr.status}`), + xhr.status, + responseData, + ) + } +} diff --git a/packages/api-client/src/types/index.ts b/packages/api-client/src/types/index.ts index 35e1f83809..e1e92ff43c 100644 --- a/packages/api-client/src/types/index.ts +++ b/packages/api-client/src/types/index.ts @@ -11,3 +11,4 @@ export type { ClientConfig, RequestHooks } from './client' export type { ApiErrorData, ModrinthErrorResponse } from './errors' export { isModrinthErrorResponse } from './errors' export type { HttpMethod, RequestContext, RequestOptions, ResponseData } from './request' +export type { UploadHandle, UploadMetadata, UploadProgress, UploadRequestOptions } from './upload' diff --git a/packages/api-client/src/types/request.ts b/packages/api-client/src/types/request.ts index 1d513fcc77..22cf56a795 100644 --- a/packages/api-client/src/types/request.ts +++ b/packages/api-client/src/types/request.ts @@ -106,6 +106,13 @@ export type RequestContext = { /** * Additional metadata that features can attach + * + * For uploads, this contains: + * - isUpload: true + * - file: File | Blob being uploaded + * - onProgress: progress callback (if provided) + * + * Features can check `context.metadata?.isUpload` to detect uploads. */ metadata?: Record } diff --git a/packages/api-client/src/types/upload.ts b/packages/api-client/src/types/upload.ts new file mode 100644 index 0000000000..e8ec45bfe1 --- /dev/null +++ b/packages/api-client/src/types/upload.ts @@ -0,0 +1,51 @@ +import type { RequestOptions } from './request' + +/** + * Progress information for file uploads + */ +export interface UploadProgress { + /** Bytes uploaded so far */ + loaded: number + /** Total bytes to upload */ + total: number + /** Progress as a decimal (0-1) */ + progress: number +} + +/** + * Options for upload requests (matches request() style) + * + * Extends RequestOptions but excludes body and method since those + * are determined by the upload itself. + */ +export interface UploadRequestOptions extends Omit { + /** File or Blob to upload */ + file: File | Blob + /** Callback for progress updates */ + onProgress?: (progress: UploadProgress) => void +} + +/** + * Metadata attached to upload contexts + * + * Features can check `context.metadata?.isUpload` to detect uploads. + */ +export interface UploadMetadata extends Record { + isUpload: true + file: File | Blob + onProgress?: (progress: UploadProgress) => void +} + +/** + * Handle returned from upload operations + * + * Provides the upload promise, progress subscription, and cancellation. + */ +export interface UploadHandle { + /** Promise that resolves when upload completes */ + promise: Promise + /** Subscribe to progress updates (chainable) */ + onProgress: (callback: (progress: UploadProgress) => void) => UploadHandle + /** Cancel the upload */ + cancel: () => void +} From 923e071411e279f3da7809f4dab3c8bc3191af27 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Mon, 15 Dec 2025 13:22:17 +0000 Subject: [PATCH 04/12] feat: fs module -> api-client --- packages/api-client/src/index.ts | 1 + .../api-client/src/modules/kyros/files/v0.ts | 228 +++++++++++++++++- .../api-client/src/modules/kyros/types.ts | 22 +- packages/api-client/src/utils/jwt-retry.ts | 20 ++ 4 files changed, 262 insertions(+), 9 deletions(-) create mode 100644 packages/api-client/src/utils/jwt-retry.ts diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index 210dc65d2c..a2669ce67e 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -28,3 +28,4 @@ export type { TauriClientConfig } from './platform/tauri' export { TauriModrinthClient } from './platform/tauri' export { XHRUploadClient } from './platform/xhr-upload-client' export * from './types' +export { withJWTRetry } from './utils/jwt-retry' diff --git a/packages/api-client/src/modules/kyros/files/v0.ts b/packages/api-client/src/modules/kyros/files/v0.ts index 5e158e15b5..7194f64a95 100644 --- a/packages/api-client/src/modules/kyros/files/v0.ts +++ b/packages/api-client/src/modules/kyros/files/v0.ts @@ -1,11 +1,73 @@ import { AbstractModule } from '../../../core/abstract-module' import type { UploadHandle, UploadProgress } from '../../../types/upload' +import type { Kyros } from '../types' export class KyrosFilesV0Module extends AbstractModule { public getModuleID(): string { return 'kyros_files_v0' } + /** + * Get base URL from node instance + */ + private getBaseUrl(nodeInstance: string): string { + return `https://${nodeInstance.replace('v0/fs', '')}` + } + + /** + * List directory contents with pagination + * + * @param nodeInstance - Node instance URL (e.g., "node-xyz.modrinth.com/modrinth/v0/fs") + * @param nodeToken - JWT token from getFilesystemAuth + * @param path - Directory path (e.g., "/") + * @param page - Page number (1-indexed) + * @param pageSize - Items per page + * @returns Directory listing with items and pagination info + */ + public async listDirectory( + nodeInstance: string, + nodeToken: string, + path: string, + page: number = 1, + pageSize: number = 100, + ): Promise { + return this.client.request('/fs/list', { + api: this.getBaseUrl(nodeInstance), + version: 'v0', + method: 'GET', + params: { path, page, page_size: pageSize }, + headers: { Authorization: `Bearer ${nodeToken}` }, + skipAuth: true, + }) + } + + /** + * Create a file or directory + * + * @param nodeInstance - Node instance URL + * @param nodeToken - JWT token from getFilesystemAuth + * @param path - Path for new item (e.g., "/new-folder") + * @param type - Type of item to create + */ + public async createFileOrFolder( + nodeInstance: string, + nodeToken: string, + path: string, + type: 'file' | 'directory', + ): Promise { + return this.client.request('/fs/create', { + api: this.getBaseUrl(nodeInstance), + version: 'v0', + method: 'POST', + params: { path, type }, + headers: { + Authorization: `Bearer ${nodeToken}`, + 'Content-Type': 'application/octet-stream', + }, + skipAuth: true, + }) + } + /** * Download a file from a server's filesystem * @@ -15,12 +77,13 @@ export class KyrosFilesV0Module extends AbstractModule { * @returns Promise resolving to file Blob */ public async downloadFile(nodeInstance: string, nodeToken: string, path: string): Promise { - return this.client.request(`/fs/download`, { - api: `https://${nodeInstance.replace('v0/fs', '')}`, - method: 'GET', + return this.client.request('/fs/download', { + api: this.getBaseUrl(nodeInstance), version: 'v0', + method: 'GET', params: { path }, headers: { Authorization: `Bearer ${nodeToken}` }, + skipAuth: true, }) } @@ -38,16 +101,14 @@ export class KyrosFilesV0Module extends AbstractModule { nodeInstance: string, nodeToken: string, path: string, - file: File, + file: File | Blob, options?: { onProgress?: (progress: UploadProgress) => void retry?: boolean | number }, ): UploadHandle { - const baseUrl = `https://${nodeInstance.replace('v0/fs', '')}` - return this.client.upload('/fs/create', { - api: baseUrl, + api: this.getBaseUrl(nodeInstance), version: 'v0', file, params: { path, type: 'file' }, @@ -56,7 +117,158 @@ export class KyrosFilesV0Module extends AbstractModule { }, onProgress: options?.onProgress, retry: options?.retry, - skipAuth: true, // Use nodeToken, not main auth + skipAuth: true, + }) + } + + /** + * Update file contents + * + * @param nodeInstance - Node instance URL + * @param nodeToken - JWT token from getFilesystemAuth + * @param path - File path to update + * @param content - New file content (string or Blob) + */ + public async updateFile( + nodeInstance: string, + nodeToken: string, + path: string, + content: string | Blob, + ): Promise { + const blob = typeof content === 'string' ? new Blob([content]) : content + + return this.client.request('/fs/update', { + api: this.getBaseUrl(nodeInstance), + version: 'v0', + method: 'PUT', + params: { path }, + body: blob, + headers: { + Authorization: `Bearer ${nodeToken}`, + 'Content-Type': 'application/octet-stream', + }, + skipAuth: true, + }) + } + + /** + * Move a file or folder to a new location + * + * @param nodeInstance - Node instance URL + * @param nodeToken - JWT token from getFilesystemAuth + * @param sourcePath - Current path + * @param destPath - New path + */ + public async moveFileOrFolder( + nodeInstance: string, + nodeToken: string, + sourcePath: string, + destPath: string, + ): Promise { + return this.client.request('/fs/move', { + api: this.getBaseUrl(nodeInstance), + version: 'v0', + method: 'POST', + body: { source: sourcePath, destination: destPath }, + headers: { Authorization: `Bearer ${nodeToken}` }, + skipAuth: true, + }) + } + + /** + * Rename a file or folder (convenience wrapper around move) + * + * @param nodeInstance - Node instance URL + * @param nodeToken - JWT token from getFilesystemAuth + * @param path - Current file/folder path + * @param newName - New name (not full path) + */ + public async renameFileOrFolder( + nodeInstance: string, + nodeToken: string, + path: string, + newName: string, + ): Promise { + const newPath = path.split('/').slice(0, -1).join('/') + '/' + newName + return this.moveFileOrFolder(nodeInstance, nodeToken, path, newPath) + } + + /** + * Delete a file or folder + * + * @param nodeInstance - Node instance URL + * @param nodeToken - JWT token from getFilesystemAuth + * @param path - Path to delete + * @param recursive - If true, delete directory contents recursively + */ + public async deleteFileOrFolder( + nodeInstance: string, + nodeToken: string, + path: string, + recursive: boolean, + ): Promise { + return this.client.request('/fs/delete', { + api: this.getBaseUrl(nodeInstance), + version: 'v0', + method: 'DELETE', + params: { path, recursive }, + headers: { Authorization: `Bearer ${nodeToken}` }, + skipAuth: true, + }) + } + + /** + * Extract an archive file (zip, tar, etc.) + * + * Uses v1 API endpoint. + * + * @param nodeInstance - Node instance URL + * @param nodeToken - JWT token from getFilesystemAuth + * @param path - Path to archive file + * @param override - If true, overwrite existing files + * @param dry - If true, perform dry run (returns conflicts without extracting) + * @returns Extract result with modpack name and conflicting files + */ + public async extractFile( + nodeInstance: string, + nodeToken: string, + path: string, + override: boolean = true, + dry: boolean = false, + ): Promise { + return this.client.request('/fs/unarchive', { + api: this.getBaseUrl(nodeInstance), + version: 'v1', + method: 'POST', + params: { src: path, trg: '/', override, dry }, + headers: { Authorization: `Bearer ${nodeToken}` }, + skipAuth: true, + }) + } + + /** + * Modify a filesystem operation (dismiss or cancel) + * + * Uses v1 API endpoint. + * + * @param nodeInstance - Node instance URL + * @param nodeToken - JWT token from getFilesystemAuth + * @param opId - Operation ID (UUID) + * @param action - Action to perform + */ + public async modifyOperation( + nodeInstance: string, + nodeToken: string, + opId: string, + action: 'dismiss' | 'cancel', + ): Promise { + return this.client.request(`/fs/ops/${action}`, { + api: this.getBaseUrl(nodeInstance), + version: 'v1', + method: 'POST', + params: { id: opId }, + headers: { Authorization: `Bearer ${nodeToken}` }, + skipAuth: true, }) } } diff --git a/packages/api-client/src/modules/kyros/types.ts b/packages/api-client/src/modules/kyros/types.ts index 61d65fc2d2..102c72be7c 100644 --- a/packages/api-client/src/modules/kyros/types.ts +++ b/packages/api-client/src/modules/kyros/types.ts @@ -1,7 +1,27 @@ export namespace Kyros { export namespace Files { export namespace v0 { - // Empty for now + export interface DirectoryItem { + name: string + type: 'file' | 'directory' | 'symlink' + path: string + modified: number + created: number + size?: number + count?: number + target?: string + } + + export interface DirectoryResponse { + items: DirectoryItem[] + total: number + current: number + } + + export interface ExtractResult { + modpack_name: string | null + conflicting_files: string[] + } } } } diff --git a/packages/api-client/src/utils/jwt-retry.ts b/packages/api-client/src/utils/jwt-retry.ts new file mode 100644 index 0000000000..1ae0817bfc --- /dev/null +++ b/packages/api-client/src/utils/jwt-retry.ts @@ -0,0 +1,20 @@ +import { ModrinthApiError } from '../core/errors' + +/** + * Wrap a function with JWT retry logic. + * On 401, calls refreshToken() and retries once. + */ +export async function withJWTRetry( + fn: () => Promise, + refreshToken: () => Promise, +): Promise { + try { + return await fn() + } catch (error) { + if (error instanceof ModrinthApiError && error.statusCode === 401) { + await refreshToken() + return await fn() + } + throw error + } +} From 4b224f55430076ac2f90ff747e4c01a72fd3feda Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Mon, 15 Dec 2025 15:08:52 +0000 Subject: [PATCH 05/12] feat: migrate files.vue to use tanstack --- .../src/components/ui/servers/FileItem.vue | 35 +- .../components/ui/servers/FileVirtualList.vue | 7 +- .../ui/servers/FilesUploadDropdown.vue | 22 +- .../ui/servers/FilesUploadZipUrlModal.vue | 22 +- .../composables/servers/modrinth-servers.ts | 36 +- .../src/composables/servers/modules/fs.ts | 248 ------ .../composables/servers/modules/general.ts | 30 - .../src/composables/servers/modules/index.ts | 1 - apps/frontend/src/helpers/api.ts | 12 + .../src/pages/hosting/manage/[id].vue | 85 ++- .../hosting/manage/[id]/content/index.vue | 8 +- .../src/pages/hosting/manage/[id]/files.vue | 704 ++++++++++-------- .../src/pages/hosting/manage/[id]/index.vue | 9 +- .../hosting/manage/[id]/options/index.vue | 16 +- .../manage/[id]/options/properties.vue | 85 +-- packages/api-client/src/features/node-auth.ts | 153 ++++ packages/api-client/src/index.ts | 2 + .../api-client/src/modules/archon/types.ts | 9 +- .../api-client/src/modules/kyros/files/v0.ts | 134 +--- .../src/platform/xhr-upload-client.ts | 11 +- packages/api-client/src/state/node-auth.ts | 48 ++ packages/api-client/src/types/request.ts | 7 + packages/assets/generated-icons.ts | 2 + .../src => packages}/assets/icons/palette.svg | 0 packages/ui/src/providers/server-context.ts | 12 + packages/utils/servers/types/api.ts | 2 +- 26 files changed, 842 insertions(+), 858 deletions(-) delete mode 100644 apps/frontend/src/composables/servers/modules/fs.ts create mode 100644 packages/api-client/src/features/node-auth.ts create mode 100644 packages/api-client/src/state/node-auth.ts rename {apps/frontend/src => packages}/assets/icons/palette.svg (100%) diff --git a/apps/frontend/src/components/ui/servers/FileItem.vue b/apps/frontend/src/components/ui/servers/FileItem.vue index ac4c426591..9944b39777 100644 --- a/apps/frontend/src/components/ui/servers/FileItem.vue +++ b/apps/frontend/src/components/ui/servers/FileItem.vue @@ -12,6 +12,7 @@ @click="selectItem" @contextmenu="openContextMenu" @keydown="(e) => e.key === 'Enter' && selectItem()" + @mouseenter="handleMouseEnter" @dragstart="handleDragStart" @dragend="handleDragEnd" @dragenter.prevent="handleDragEnter" @@ -73,13 +74,14 @@ import { FolderOpenIcon, MoreHorizontalIcon, PackageOpenIcon, + PaletteIcon, RightArrowIcon, TrashIcon, } from '@modrinth/assets' import { ButtonStyled } from '@modrinth/ui' import { computed, ref, shallowRef } from 'vue' -import { renderToString } from 'vue/server-renderer' import { useRoute, useRouter } from 'vue-router' +import { renderToString } from 'vue/server-renderer' import { UiServersIconsCodeFileIcon, @@ -88,7 +90,6 @@ import { UiServersIconsImageFileIcon, UiServersIconsTextFileIcon, } from '#components' -import PaletteIcon from '~/assets/icons/palette.svg?component' import TeleportOverflowMenu from './TeleportOverflowMenu.vue' @@ -106,7 +107,7 @@ const props = defineProps() const emit = defineEmits<{ ( - e: 'rename' | 'move' | 'download' | 'delete' | 'edit' | 'extract', + e: 'rename' | 'move' | 'download' | 'delete' | 'edit' | 'extract' | 'hover', item: { name: string; type: string; path: string }, ): void (e: 'moveDirectTo', item: { name: string; type: string; path: string; destination: string }): void @@ -263,12 +264,18 @@ const formattedSize = computed(() => { return `${size} ${units[exponent]}` }) -const openContextMenu = (event: MouseEvent) => { +function openContextMenu(event: MouseEvent) { event.preventDefault() emit('contextmenu', event.clientX, event.clientY) } -const navigateToFolder = () => { +function handleMouseEnter() { + if (props.type === 'directory') { + emit('hover', { name: props.name, type: props.type, path: props.path }) + } +} + +function navigateToFolder() { const currentPath = route.value.query.path?.toString() || '' const newPath = currentPath.endsWith('/') ? `${currentPath}${props.name}` @@ -278,7 +285,7 @@ const navigateToFolder = () => { const isNavigating = ref(false) -const selectItem = () => { +function selectItem() { if (isNavigating.value) return isNavigating.value = true @@ -293,7 +300,7 @@ const selectItem = () => { }, 500) } -const getDragIcon = async () => { +async function getDragIcon() { let iconToUse if (props.type === 'directory') { @@ -322,7 +329,7 @@ const getDragIcon = async () => { return await renderToString(h(iconToUse)) } -const handleDragStart = async (event: DragEvent) => { +async function handleDragStart(event: DragEvent) { if (!event.dataTransfer) return isDragging.value = true @@ -363,29 +370,29 @@ const handleDragStart = async (event: DragEvent) => { event.dataTransfer.effectAllowed = 'move' } -const isChildPath = (parentPath: string, childPath: string) => { +function isChildPath(parentPath: string, childPath: string) { return childPath.startsWith(parentPath + '/') } -const handleDragEnd = () => { +function handleDragEnd() { isDragging.value = false } -const handleDragEnter = () => { +function handleDragEnter() { if (props.type !== 'directory') return isDragOver.value = true } -const handleDragOver = (event: DragEvent) => { +function handleDragOver(event: DragEvent) { if (props.type !== 'directory' || !event.dataTransfer) return event.dataTransfer.dropEffect = 'move' } -const handleDragLeave = () => { +function handleDragLeave() { isDragOver.value = false } -const handleDrop = (event: DragEvent) => { +function handleDrop(event: DragEvent) { isDragOver.value = false if (props.type !== 'directory' || !event.dataTransfer) return diff --git a/apps/frontend/src/components/ui/servers/FileVirtualList.vue b/apps/frontend/src/components/ui/servers/FileVirtualList.vue index 9381fb77be..e3cd5dc509 100644 --- a/apps/frontend/src/components/ui/servers/FileVirtualList.vue +++ b/apps/frontend/src/components/ui/servers/FileVirtualList.vue @@ -35,6 +35,7 @@ @move="$emit('move', item)" @move-direct-to="$emit('moveDirectTo', $event)" @edit="$emit('edit', item)" + @hover="$emit('hover', item)" @contextmenu="(x, y) => $emit('contextmenu', item, x, y)" /> @@ -53,7 +54,7 @@ const props = defineProps<{ const emit = defineEmits<{ ( - e: 'delete' | 'rename' | 'download' | 'move' | 'edit' | 'moveDirectTo' | 'extract', + e: 'delete' | 'rename' | 'download' | 'move' | 'edit' | 'moveDirectTo' | 'extract' | 'hover', item: any, ): void (e: 'contextmenu', item: any, x: number, y: number): void @@ -92,7 +93,7 @@ const visibleItems = computed(() => { return props.items.slice(visibleRange.value.start, visibleRange.value.end) }) -const handleScroll = () => { +function handleScroll() { windowScrollY.value = window.scrollY if (!listContainer.value) return @@ -105,7 +106,7 @@ const handleScroll = () => { } } -const handleResize = () => { +function handleResize() { windowHeight.value = window.innerHeight } diff --git a/apps/frontend/src/components/ui/servers/FilesUploadDropdown.vue b/apps/frontend/src/components/ui/servers/FilesUploadDropdown.vue index 7261b304db..344e3d06c5 100644 --- a/apps/frontend/src/components/ui/servers/FilesUploadDropdown.vue +++ b/apps/frontend/src/components/ui/servers/FilesUploadDropdown.vue @@ -102,14 +102,13 @@ diff --git a/apps/frontend/src/pages/hosting/manage/[id]/files.vue b/apps/frontend/src/pages/hosting/manage/[id]/files.vue index 5a7babd55d..40c1e20ecd 100644 --- a/apps/frontend/src/pages/hosting/manage/[id]/files.vue +++ b/apps/frontend/src/pages/hosting/manage/[id]/files.vue @@ -1,5 +1,5 @@