diff --git a/apps/app-frontend/src/pages/hosting/manage/Files.vue b/apps/app-frontend/src/pages/hosting/manage/Files.vue index 9bd07ee2e9..cb9994d39d 100644 --- a/apps/app-frontend/src/pages/hosting/manage/Files.vue +++ b/apps/app-frontend/src/pages/hosting/manage/Files.vue @@ -7,15 +7,17 @@ import { import { useQueryClient } from '@tanstack/vue-query' const client = injectModrinthClient() -const { serverId } = injectModrinthServerContext() +const { worldId } = injectModrinthServerContext() const queryClient = useQueryClient() try { - await queryClient.ensureQueryData({ - queryKey: ['files', serverId, '/'], - queryFn: () => client.kyros.files_v0.listDirectory('/', 1, 2000), - staleTime: 30_000, - }) + if (worldId.value) { + await queryClient.ensureQueryData({ + queryKey: ['files', 'v1', worldId.value, '/'], + queryFn: () => client.kyros.files_v1.listDescendants(worldId.value!, '/', 1, 200), + staleTime: 30_000, + }) + } } catch { // Let mounted layouts' useQuery surface errors; do not fail route setup. } diff --git a/packages/api-client/CLAUDE.md b/packages/api-client/CLAUDE.md index 6b7d150824..001b2fec8f 100644 --- a/packages/api-client/CLAUDE.md +++ b/packages/api-client/CLAUDE.md @@ -40,7 +40,8 @@ client.archon.servers_v1 client.archon.backups_queue_v1 client.archon.backups_v1 client.archon.content_v0 -client.kyros.files_v0 +client.kyros.files_v1 +client.kyros.upload_sessions_v1 client.iso3166.data ... etc. ``` @@ -140,7 +141,8 @@ Uploads go through the feature chain (auth, retry, etc.). Features detect upload ### Usage Example (server file upload) ```ts -const uploader = client.kyros.files_v0.uploadFile(path, file, { +await client.kyros.files_v1.ensureFile(worldId, path) +const uploader = client.kyros.files_v1.uploadFile(worldId, path, file, { onProgress: ({ progress }) => { uploadProgress.value = Math.round(progress * 100) }, diff --git a/packages/api-client/README.md b/packages/api-client/README.md index 8ae1bedbd2..0ba195c23e 100644 --- a/packages/api-client/README.md +++ b/packages/api-client/README.md @@ -147,7 +147,8 @@ Built-in features include authentication, node auth, retries, circuit breaking, Upload endpoints return an `UploadHandle` with progress and cancellation support: ```ts -const upload = client.kyros.files_v0.uploadFile(path, file) +await client.kyros.files_v1.ensureFile(worldId, path) +const upload = client.kyros.files_v1.uploadFile(worldId, path, file) upload.onProgress(({ progress }) => { console.log(Math.round(progress * 100)) diff --git a/packages/api-client/src/modules/index.ts b/packages/api-client/src/modules/index.ts index 7c1781603f..dcebecce9a 100644 --- a/packages/api-client/src/modules/index.ts +++ b/packages/api-client/src/modules/index.ts @@ -14,7 +14,6 @@ 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' import { KyrosFilesV1Module } from './kyros/files/v1' import { KyrosLogsV1Module } from './kyros/logs/v1' import { KyrosUploadSessionsV1Module } from './kyros/upload-sessions/v1' @@ -88,7 +87,6 @@ export const MODULE_REGISTRY = { mclogs_logs_v1: MclogsLogsV1Module, launchermeta_manifest_v0: LauncherMetaManifestV0Module, kyros_content_v1: KyrosContentV1Module, - kyros_files_v0: KyrosFilesV0Module, kyros_files_v1: KyrosFilesV1Module, kyros_logs_v1: KyrosLogsV1Module, kyros_upload_sessions_v1: KyrosUploadSessionsV1Module, diff --git a/packages/api-client/src/modules/kyros/files/v0.ts b/packages/api-client/src/modules/kyros/files/v0.ts deleted file mode 100644 index 3fa383917a..0000000000 --- a/packages/api-client/src/modules/kyros/files/v0.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { AbstractModule } from '../../../core/abstract-module' -import type { UploadHandle, UploadProgress } from '../../../types/upload' -import { getNodeBaseUrl } from '../../../utils/node-url' -import type { Archon } from '../../archon/types' -import type { Kyros } from '../types' - -type NodeFsAuth = Pick - -export class KyrosFilesV0Module extends AbstractModule { - public getModuleID(): string { - return 'kyros_files_v0' - } - - private getNodeBaseUrl(auth: NodeFsAuth): string { - return getNodeBaseUrl(auth.url) - } - - /** - * List directory contents with pagination - * - * @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( - path: string, - page: number = 1, - pageSize: number = 100, - ): Promise { - return this.client.request('/fs/list', { - api: '', - version: 'modrinth/v0', - method: 'GET', - params: { path, page, page_size: pageSize }, - useNodeAuth: true, - }) - } - - /** - * Create a file or directory - * - * @param path - Path for new item (e.g., "/new-folder") - * @param type - Type of item to create - */ - public async createFileOrFolder(path: string, type: 'file' | 'directory'): Promise { - return this.client.request('/fs/create', { - api: '', - version: 'modrinth/v0', - method: 'POST', - params: { path, type }, - headers: { 'Content-Type': 'application/octet-stream' }, - useNodeAuth: true, - }) - } - - /** - * Download a file from a server's filesystem - * - * @param path - File path (e.g., "/server-icon-original.png") - * @returns Promise resolving to file Blob - */ - public async downloadFile(path: string): Promise { - return this.client.request('/fs/download', { - api: '', - version: 'modrinth/v0', - method: 'GET', - params: { path }, - useNodeAuth: true, - }) - } - - /** - * Download a file using explicit filesystem auth credentials. - * - * @param auth - Filesystem auth (url + token) from Archon - * @param path - File path (e.g., "/server-icon.png") - * @returns Promise resolving to file Blob - */ - public async downloadFileWithAuth(auth: NodeFsAuth, path: string): Promise { - return this.client.request('/fs/download', { - api: this.getNodeBaseUrl(auth), - version: 'modrinth/v0', - method: 'GET', - params: { path }, - headers: { Authorization: `Bearer ${auth.token}` }, - skipAuth: true, - }) - } - - /** - * Upload a file to a server's filesystem with progress tracking - * - * @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 - * @deprecated Use `kyros.upload_sessions_v1` for bulk uploads so cancellation can remove staged files before finalize. - */ - public uploadFile( - path: string, - file: File | Blob, - options?: { - onProgress?: (progress: UploadProgress) => void - retry?: boolean | number - }, - ): UploadHandle { - return this.client.upload('/fs/create', { - api: '', - version: 'modrinth/v0', - file, - params: { path, type: 'file' }, - onProgress: options?.onProgress, - retry: options?.retry, - useNodeAuth: true, - }) - } - - /** - * Upload a file using explicit filesystem auth credentials. - * - * @param auth - Filesystem auth (url + token) from Archon - * @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 uploadFileWithAuth( - auth: NodeFsAuth, - path: string, - file: File | Blob, - options?: { - onProgress?: (progress: UploadProgress) => void - retry?: boolean | number - }, - ): UploadHandle { - return this.client.upload('/fs/create', { - api: this.getNodeBaseUrl(auth), - version: 'modrinth/v0', - file, - params: { path, type: 'file' }, - headers: { Authorization: `Bearer ${auth.token}` }, - onProgress: options?.onProgress, - retry: options?.retry, - skipAuth: true, - }) - } - - /** - * Update file contents - * - * @param path - File path to update - * @param content - New file content (string or Blob) - */ - public async updateFile(path: string, content: string | Blob): Promise { - const blob = typeof content === 'string' ? new Blob([content]) : content - - return this.client.request('/fs/update', { - api: '', - version: 'modrinth/v0', - method: 'PUT', - params: { path }, - body: blob, - headers: { 'Content-Type': 'application/octet-stream' }, - useNodeAuth: true, - }) - } - - /** - * Delete a file or folder using explicit filesystem auth credentials. - * - * @param auth - Filesystem auth (url + token) from Archon - * @param path - Path to delete - * @param recursive - If true, delete directory contents recursively - */ - public async deleteFileOrFolderWithAuth( - auth: NodeFsAuth, - path: string, - recursive: boolean, - ): Promise { - return this.client.request('/fs/delete', { - api: this.getNodeBaseUrl(auth), - version: 'modrinth/v0', - method: 'DELETE', - params: { path, recursive }, - headers: { Authorization: `Bearer ${auth.token}` }, - skipAuth: true, - }) - } - - /** - * Extract an archive file (zip, tar, etc.) - * - * Uses v1 API endpoint. - * - * @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( - path: string, - override: boolean = true, - dry: boolean = false, - ): Promise { - return this.client.request('/fs/unarchive', { - api: '', - version: 'v1', - method: 'POST', - params: { src: path, trg: '/', override, dry }, - useNodeAuth: true, - }) - } - - /** - * Modify a filesystem operation (dismiss or cancel) - * - * Uses v1 API endpoint. - * - * @param opId - Operation ID (UUID) - * @param action - Action to perform - */ - public async modifyOperation(opId: string, action: 'dismiss' | 'cancel'): Promise { - return this.client.request(`/fs/ops/${action}`, { - api: '', - version: 'v1', - method: 'POST', - params: { id: opId }, - useNodeAuth: true, - }) - } -} diff --git a/packages/api-client/src/modules/kyros/files/v1.ts b/packages/api-client/src/modules/kyros/files/v1.ts index c4039416c3..c8dba7179e 100644 --- a/packages/api-client/src/modules/kyros/files/v1.ts +++ b/packages/api-client/src/modules/kyros/files/v1.ts @@ -1,4 +1,5 @@ import { AbstractModule } from '../../../core/abstract-module' +import type { UploadHandle, UploadProgress } from '../../../types/upload' import type { Kyros } from '../types' export class KyrosFilesV1Module extends AbstractModule { @@ -6,6 +7,25 @@ export class KyrosFilesV1Module extends AbstractModule { return 'kyros_files_v1' } + private isConflict(error: unknown): boolean { + const err = error as { statusCode?: number; response?: { status?: number } } + return (err.statusCode ?? err.response?.status) === 409 + } + + public async createDownloadSession( + worldId: string, + path: string, + zipped: boolean, + ): Promise { + return this.client.request(`/worlds/${worldId}/files/contents`, { + api: '', + version: 'v1', + method: 'POST', + body: { path, zipped }, + useNodeAuth: true, + }) + } + public async listDescendants( worldId: string, path: string, @@ -47,6 +67,70 @@ export class KyrosFilesV1Module extends AbstractModule { }) } + public async editFile(worldId: string, path: string, content: string | Blob): Promise { + const body = typeof content === 'string' ? new Blob([content]) : content + + return this.client.request(`/worlds/${worldId}/files/edit`, { + api: '', + version: 'v1', + method: 'POST', + params: { path }, + body, + headers: { 'Content-Type': 'application/octet-stream' }, + useNodeAuth: true, + }) + } + + public uploadFile( + worldId: string, + path: string, + file: File | Blob, + options?: { + onProgress?: (progress: UploadProgress) => void + retry?: boolean | number + }, + ): UploadHandle { + return this.client.upload(`/worlds/${worldId}/files/edit`, { + api: '', + version: 'v1', + file, + params: { path }, + onProgress: options?.onProgress, + retry: options?.retry, + useNodeAuth: true, + }) + } + + public async touchFile(worldId: string, path: string): Promise { + return this.client.request(`/worlds/${worldId}/files/touch`, { + api: '', + version: 'v1', + method: 'POST', + body: { path }, + useNodeAuth: true, + }) + } + + public async mkdirFile(worldId: string, path: string): Promise { + return this.client.request(`/worlds/${worldId}/files/mkdir`, { + api: '', + version: 'v1', + method: 'POST', + body: { path }, + useNodeAuth: true, + }) + } + + public async ensureFile(worldId: string, path: string): Promise { + try { + await this.touchFile(worldId, path) + } catch (error) { + if (!this.isConflict(error)) { + throw error + } + } + } + public async deleteFile(worldId: string, path: string): Promise { return this.client.request(`/worlds/${worldId}/files/delete`, { api: '', @@ -90,4 +174,49 @@ export class KyrosFilesV1Module extends AbstractModule { }, ) } + + public unzipFile( + worldId: string, + request: Kyros.Files.v1.UnzipFileRequest, + ): Promise> { + return this.client.stream(`/worlds/${worldId}/files/unzip`, { + api: '', + version: 'v1', + method: 'POST', + body: request, + headers: { Accept: 'application/json-seq' }, + useNodeAuth: true, + }) + } + + public uploadZip( + worldId: string, + path: string, + file: File | Blob, + options?: { + onProgress?: (progress: UploadProgress) => void + retry?: boolean | number + }, + ): UploadHandle { + return this.client.upload(`/worlds/${worldId}/files/upload-zip`, { + api: '', + version: 'v1', + file, + params: { path }, + headers: { 'Content-Type': 'application/zip' }, + onProgress: options?.onProgress, + retry: options?.retry, + useNodeAuth: true, + }) + } + + public async modifyOperation(opId: string, action: 'dismiss' | 'cancel'): Promise { + return this.client.request(`/fs/ops/${action}`, { + api: '', + version: 'v1', + method: 'POST', + params: { id: opId }, + useNodeAuth: true, + }) + } } diff --git a/packages/api-client/src/modules/kyros/types.ts b/packages/api-client/src/modules/kyros/types.ts index 4f61f38aa0..000b32e526 100644 --- a/packages/api-client/src/modules/kyros/types.ts +++ b/packages/api-client/src/modules/kyros/types.ts @@ -2,27 +2,25 @@ export namespace Kyros { export namespace UploadSessions { export namespace v1 { export type Scope = 'content' | 'files' - export type UploadSessionStatus = - | 'active' - | 'uploading' - | 'finalizing' - | 'cancelled' - | 'finalized' - | 'expired' + + export type UploadSessionFile = { + file: File | Blob + filename: string + } export interface UploadSessionResponse { upload_id: string - status: UploadSessionStatus + status: string created_at: number updated_at: number - last_upload_at: number | null + last_upload_at?: number | null expires_at: number entry_count: number uploaded_byte_count: number } export interface GetUploadSessionResponse { - session: UploadSessionResponse | null + session?: UploadSessionResponse | null } } } @@ -31,6 +29,16 @@ export namespace Kyros { export namespace v1 { export type DescendantType = 'regular' | 'directory' | 'symlink' | 'other' + export type UnzipSource = + | { + type: 'zip_url' + url: string + } + | { + type: 'zip_path' + path: string + } + export interface CreateDownloadSessionRequest { path: string zipped: boolean @@ -81,29 +89,14 @@ export namespace Kyros { path: string name: string } - } - export namespace v0 { - export interface DirectoryItem { - name: string - type: 'file' | 'directory' | 'symlink' + export interface PathMutationRequest { 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[] + export interface UnzipFileRequest { + source: UnzipSource + target: string } } } diff --git a/packages/api-client/src/modules/kyros/upload-sessions/v1.ts b/packages/api-client/src/modules/kyros/upload-sessions/v1.ts index d88d6dacc0..234df008e7 100644 --- a/packages/api-client/src/modules/kyros/upload-sessions/v1.ts +++ b/packages/api-client/src/modules/kyros/upload-sessions/v1.ts @@ -2,11 +2,6 @@ import { AbstractModule } from '../../../core/abstract-module' import type { UploadHandle, UploadProgress } from '../../../types/upload' import type { Kyros } from '../types' -export type UploadSessionFile = { - file: File | Blob - filename: string -} - export class KyrosUploadSessionsV1Module extends AbstractModule { public getModuleID(): string { return 'kyros_upload_sessions_v1' @@ -46,7 +41,7 @@ export class KyrosUploadSessionsV1Module extends AbstractModule { scope: Kyros.UploadSessions.v1.Scope, worldId: string, uploadId: string, - files: UploadSessionFile[], + files: Kyros.UploadSessions.v1.UploadSessionFile[], options?: { onProgress?: (progress: UploadProgress) => void retry?: boolean | number diff --git a/packages/ui/src/components/servers/ServerListing.vue b/packages/ui/src/components/servers/ServerListing.vue index fbfe10d460..a26e48a011 100644 --- a/packages/ui/src/components/servers/ServerListing.vue +++ b/packages/ui/src/components/servers/ServerListing.vue @@ -407,6 +407,7 @@ export type PendingChange = { type ServerListingProps = { server_id: string + worldId?: string | null name: string status: Archon.Servers.v0.Status suspension_reason?: Archon.Servers.v0.SuspensionReason | null @@ -546,16 +547,28 @@ async function dataURLToBlob(dataURL: string): Promise { return res.blob() } +async function getActiveWorldId(serverId: string): Promise { + const server = await archon.servers_v1.get(serverId) + const activeWorld = server.worlds.find((world) => world.is_active) + return activeWorld?.id ?? server.worlds[0]?.id ?? null +} + +async function uploadWorldFile(worldId: string, path: string, file: File | Blob) { + await kyros.files_v1.ensureFile(worldId, path) + await kyros.files_v1.uploadFile(worldId, path, file).promise +} + const { data: image } = useQuery({ - queryKey: ['server-icon', props.server_id] as const, + queryKey: computed(() => ['server-icon', props.server_id, props.worldId ?? null] as const), queryFn: async (): Promise => { if (!props.server_id || props.status !== 'available') return null try { - const fsAuth = await archon.servers_v0.getFilesystemAuth(props.server_id) + const worldId = props.worldId ?? (await getActiveWorldId(props.server_id)) + if (!worldId) return null try { - const blob = await kyros.files_v0.downloadFileWithAuth(fsAuth, '/server-icon.png') + const blob = await kyros.files_v1.downloadRawFileContents(worldId, '/server-icon.png') return await processImageBlob(blob, 64) } catch (error) { const statusCode = (error as { statusCode?: number })?.statusCode @@ -564,8 +577,8 @@ const { data: image } = useQuery({ } try { - const originalBlob = await kyros.files_v0.downloadFileWithAuth( - fsAuth, + const originalBlob = await kyros.files_v1.downloadRawFileContents( + worldId, '/server-icon-original.png', ) return await processImageBlob(originalBlob, 64) @@ -585,13 +598,12 @@ const { data: image } = useQuery({ const scaledBlob = await dataURLToBlob(scaledDataUrl) const scaledFile = new File([scaledBlob], 'server-icon.png', { type: 'image/png' }) - await kyros.files_v0.uploadFileWithAuth(fsAuth, '/server-icon.png', scaledFile).promise + await uploadWorldFile(worldId, '/server-icon.png', scaledFile) const originalFile = new File([blob], 'server-icon-original.png', { type: 'image/png', }) - await kyros.files_v0.uploadFileWithAuth(fsAuth, '/server-icon-original.png', originalFile) - .promise + await uploadWorldFile(worldId, '/server-icon-original.png', originalFile) return scaledDataUrl } diff --git a/packages/ui/src/components/servers/edit-server-icon/EditServerIcon.vue b/packages/ui/src/components/servers/edit-server-icon/EditServerIcon.vue index 936e415cca..0f051f9371 100644 --- a/packages/ui/src/components/servers/edit-server-icon/EditServerIcon.vue +++ b/packages/ui/src/components/servers/edit-server-icon/EditServerIcon.vue @@ -73,7 +73,7 @@ const props = withDefaults( const { addNotification } = injectNotificationManager() const { formatMessage } = useVIntl() const client = injectModrinthClient() -const { serverId, server } = injectModrinthServerContext() +const { serverId, server, worldId } = injectModrinthServerContext() const queryClient = useQueryClient() const isUploadingIcon = ref(false) const isSyncingIcon = ref(false) @@ -95,6 +95,7 @@ const { computed(() => server.value?.upstream ?? null), { includeProjectFallback: false, + worldId, }, ) @@ -107,6 +108,19 @@ function isNotFound(error: unknown): boolean { return getStatusCode(error) === 404 } +function getWorldId() { + if (!worldId.value) { + throw new Error('World ID is not available.') + } + return worldId.value +} + +async function uploadWorldFile(path: string, file: File | Blob) { + const id = getWorldId() + await client.kyros.files_v1.ensureFile(id, path) + await client.kyros.files_v1.uploadFile(id, path, file).promise +} + const uploadFile = async (e: Event) => { if (isIconActionDisabled.value) return @@ -144,39 +158,11 @@ const uploadFile = async (e: Event) => { img.src = URL.createObjectURL(file) }) - const fsAuth = await client.archon.servers_v0.getFilesystemAuth(serverId) - - try { - await client.kyros.files_v0.uploadFileWithAuth(fsAuth, '/server-icon.png', scaledFile).promise - } catch (scaledUploadError) { - // Node FS may reject create when file already exists. Delete and retry once. - try { - await client.kyros.files_v0.deleteFileOrFolderWithAuth(fsAuth, '/server-icon.png', false) - } catch (deleteError) { - if (!isNotFound(deleteError)) { - throw scaledUploadError - } - } - - await client.kyros.files_v0.uploadFileWithAuth(fsAuth, '/server-icon.png', scaledFile).promise - } + await uploadWorldFile('/server-icon.png', scaledFile) // Keep original file in sync when possible, but don't block icon updates on failures here. try { - await client.kyros.files_v0.deleteFileOrFolderWithAuth( - fsAuth, - '/server-icon-original.png', - false, - ) - } catch (deleteOriginalError) { - if (!isNotFound(deleteOriginalError)) { - // best effort - } - } - - try { - await client.kyros.files_v0.uploadFileWithAuth(fsAuth, '/server-icon-original.png', file) - .promise + await uploadWorldFile('/server-icon-original.png', file) } catch (originalUploadError) { if (!isNotFound(originalUploadError)) { // best effort @@ -222,10 +208,10 @@ const resetIcon = async () => { isSyncingIcon.value = true try { - const fsAuth = await client.archon.servers_v0.getFilesystemAuth(serverId) + const id = getWorldId() const deleteResults = await Promise.allSettled([ - client.kyros.files_v0.deleteFileOrFolderWithAuth(fsAuth, '/server-icon.png', false), - client.kyros.files_v0.deleteFileOrFolderWithAuth(fsAuth, '/server-icon-original.png', false), + client.kyros.files_v1.deleteFile(id, '/server-icon.png'), + client.kyros.files_v1.deleteFile(id, '/server-icon-original.png'), ]) for (const result of deleteResults) { diff --git a/packages/ui/src/composables/servers/server-manage-core-runtime.ts b/packages/ui/src/composables/servers/server-manage-core-runtime.ts index 663db01f81..fcef94b5a9 100644 --- a/packages/ui/src/composables/servers/server-manage-core-runtime.ts +++ b/packages/ui/src/composables/servers/server-manage-core-runtime.ts @@ -370,7 +370,7 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp dismissedOpIds.value = new Set([...dismissedOpIds.value, opId]) } try { - await client.kyros.files_v0.modifyOperation(opId, action) + await client.kyros.files_v1.modifyOperation(opId, action) } catch (error) { if (action === 'dismiss') return console.error(`Failed to ${action} operation:`, error) diff --git a/packages/ui/src/composables/servers/use-server-image.ts b/packages/ui/src/composables/servers/use-server-image.ts index 9722f1c306..e0afedf109 100644 --- a/packages/ui/src/composables/servers/use-server-image.ts +++ b/packages/ui/src/composables/servers/use-server-image.ts @@ -6,11 +6,13 @@ import { injectModrinthClient } from '#ui/providers' type UpstreamRef = ComputedRef type ServerIdSource = string | { readonly value: string } +type WorldIdSource = string | null | undefined | { readonly value: string | null | undefined } type UseServerImageOptions = { enabled?: ComputedRef | boolean size?: number includeProjectFallback?: boolean + worldId?: WorldIdSource } export async function processImageBlob(blob: Blob, size: number): Promise { @@ -49,6 +51,7 @@ export function useServerImage( const iconSize = options.size ?? 512 const includeProjectFallback = options.includeProjectFallback ?? false const resolvedServerId = computed(() => resolveServerId(serverId)) + const resolvedWorldId = computed(() => resolveWorldId(options.worldId)) const queryKey = computed( () => @@ -57,6 +60,7 @@ export function useServerImage( 'detail', resolvedServerId.value, 'icon', + resolvedWorldId.value ?? 'active', upstream.value?.project_id ?? null, ] as const, ) @@ -74,18 +78,22 @@ export function useServerImage( if (!id) return null try { - const fsAuth = await client.archon.servers_v0.getFilesystemAuth(id) + const targetWorldId = resolvedWorldId.value ?? (await getActiveWorldId(id)) + if (!targetWorldId) return null try { - const blob = await client.kyros.files_v0.downloadFileWithAuth(fsAuth, '/server-icon.png') + const blob = await client.kyros.files_v1.downloadRawFileContents( + targetWorldId, + '/server-icon.png', + ) return await processImageBlob(blob, iconSize) } catch (error) { if (!isNotFound(error)) throw error } try { - const blob = await client.kyros.files_v0.downloadFileWithAuth( - fsAuth, + const blob = await client.kyros.files_v1.downloadRawFileContents( + targetWorldId, '/server-icon-original.png', ) return await processImageBlob(blob, iconSize) @@ -94,7 +102,6 @@ export function useServerImage( } } catch (error) { console.debug('Server image fetch failed:', error) - return null } if (!includeProjectFallback || !upstream.value?.project_id) return null @@ -133,6 +140,12 @@ export function useServerImage( localImage.value = undefined } + async function getActiveWorldId(id: string): Promise { + const server = await client.archon.servers_v1.get(id) + const activeWorld = server.worlds.find((world) => world.is_active) + return activeWorld?.id ?? server.worlds[0]?.id ?? null + } + return { image, queryKey, @@ -146,3 +159,9 @@ export function useServerImage( function resolveServerId(serverId: ServerIdSource): string { return typeof serverId === 'string' ? serverId : serverId.value } + +function resolveWorldId(worldId: WorldIdSource): string | null { + if (worldId == null) return null + if (typeof worldId === 'string') return worldId + return worldId.value ?? null +} diff --git a/packages/ui/src/layouts/shared/files-tab/components/modals/FileUploadZipUrlModal.vue b/packages/ui/src/layouts/shared/files-tab/components/modals/FileUploadZipUrlModal.vue index 423635d6e8..1b78f95c7f 100644 --- a/packages/ui/src/layouts/shared/files-tab/components/modals/FileUploadZipUrlModal.vue +++ b/packages/ui/src/layouts/shared/files-tab/components/modals/FileUploadZipUrlModal.vue @@ -121,6 +121,7 @@ const { formatMessage } = useVIntl() const props = withDefaults( defineProps<{ + worldId: string disabled?: boolean disabledTooltip?: string }>(), @@ -187,14 +188,6 @@ const messages = defineMessages({ id: 'files.zip-url-modal.error-url-invalid', defaultMessage: 'URL must be valid.', }, - cfNotFoundTitle: { - id: 'files.zip-url-modal.cf-not-found-title', - defaultMessage: 'CurseForge modpack not found', - }, - cfNotFoundText: { - id: 'files.zip-url-modal.cf-not-found-text', - defaultMessage: 'Could not find CurseForge modpack at that URL.', - }, installFailedTitle: { id: 'files.zip-url-modal.install-failed-title', defaultMessage: 'Installation failed', @@ -265,19 +258,16 @@ const handleSubmit = async () => { submitted.value = true try { - const dry = await client.kyros.files_v0.extractFile(trimmedUrl.value, true, true) - - if (!cf.value || dry.modpack_name) { - await client.kyros.files_v0.extractFile(trimmedUrl.value, true, false) - hide() - } else { - submitted.value = false - addNotification({ - title: formatMessage(messages.cfNotFoundTitle), - text: formatMessage(messages.cfNotFoundText), - type: 'error', - }) + const stream = await client.kyros.files_v1.unzipFile(props.worldId, { + source: { type: 'zip_url', url: trimmedUrl.value }, + target: '/', + }) + const reader = stream.getReader() + while (true) { + const { done } = await reader.read() + if (done) break } + hide() } catch (err) { submitted.value = false console.error('Error installing:', err) diff --git a/packages/ui/src/layouts/shared/files-tab/components/upload/FileUploadDropdown.vue b/packages/ui/src/layouts/shared/files-tab/components/upload/FileUploadDropdown.vue index a25cc3ebe1..1569f46832 100644 --- a/packages/ui/src/layouts/shared/files-tab/components/upload/FileUploadDropdown.vue +++ b/packages/ui/src/layouts/shared/files-tab/components/upload/FileUploadDropdown.vue @@ -181,12 +181,13 @@ interface UploadItem { | 'cancelled' | 'incorrect-type' size: string - uploader?: ReturnType + uploader?: ReturnType error?: Error } interface Props { currentPath: string + worldId: string fileType?: string marginBottom?: number acceptedTypes?: Array @@ -281,11 +282,12 @@ const uploadFile = async (file: File) => { uploadItem.status = 'uploading' const filePath = `${props.currentPath}/${file.name}`.replace('//', '/') - const uploader = client.kyros.files_v0.uploadFile(filePath, file, { + await client.kyros.files_v1.ensureFile(props.worldId, filePath) + const uploader = client.kyros.files_v1.uploadFile(props.worldId, filePath, file, { onProgress: ({ progress }) => { const index = uploadQueue.value.findIndex((item) => item.file.name === file.name) if (index !== -1) { - uploadQueue.value[index].progress = Math.round(progress) + uploadQueue.value[index].progress = Math.round(progress * 100) } }, }) diff --git a/packages/ui/src/layouts/shared/files-tab/layout.vue b/packages/ui/src/layouts/shared/files-tab/layout.vue index 55d94c34d2..79acdb8d07 100644 --- a/packages/ui/src/layouts/shared/files-tab/layout.vue +++ b/packages/ui/src/layouts/shared/files-tab/layout.vue @@ -4,8 +4,9 @@ @@ -48,7 +49,7 @@ :is-editor-find-open="fileEditorRef?.isFindOpen" :search-query="searchQuery" :show-refresh-button="showRefreshButton" - :show-install-from-url="ctx.showInstallFromUrl" + :show-install-from-url="showInstallFromUrl" :base-id="baseId" :disabled="isBusy" :disabled-tooltip="busyTooltip" @@ -378,6 +379,8 @@ const selectedItem = ref(null) const unsavedChangesModal = ref>() const hasUnsavedChanges = computed(() => fileEditorRef.value?.hasUnsavedChanges ?? false) +const installFromUrlWorldId = computed(() => ctx.worldId?.value ?? '') +const showInstallFromUrl = computed(() => !!ctx.showInstallFromUrl && !!installFromUrlWorldId.value) async function confirmDiscardChanges(): Promise { if (!hasUnsavedChanges.value) return true diff --git a/packages/ui/src/layouts/shared/files-tab/providers/file-manager.ts b/packages/ui/src/layouts/shared/files-tab/providers/file-manager.ts index 4eb5ba6e35..febf63f361 100644 --- a/packages/ui/src/layouts/shared/files-tab/providers/file-manager.ts +++ b/packages/ui/src/layouts/shared/files-tab/providers/file-manager.ts @@ -35,6 +35,7 @@ export interface FileManagerContext { uploadFiles: (files: File[]) => void cancelUpload?: () => void uploadState?: Ref | ComputedRef + worldId?: Ref | ComputedRef refresh: () => void diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/index.vue b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/index.vue index 84b19c2865..24814d35ae 100644 --- a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/index.vue +++ b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/index.vue @@ -818,6 +818,7 @@ const { disconnect: disconnectPanelSync } = useServerPanelSync({ const { image: serverImage } = useServerImage( props.serverId, computed(() => serverData.value?.upstream ?? null), + { worldId }, ) const { data: serverProject } = useServerProject(computed(() => serverData.value?.upstream ?? null)) @@ -1228,7 +1229,7 @@ const handleFilesystemOps = (data: Archon.Websocket.v0.WSFilesystemOpsEvent) => const cancelled = allOps.filter((x) => x.state === 'cancelled') Promise.all( cancelled.map((x) => - client.kyros.files_v0.modifyOperation(x.id, 'dismiss').catch((error) => { + client.kyros.files_v1.modifyOperation(x.id, 'dismiss').catch((error) => { console.error('Failed to dismiss cancelled operation:', error) }), ), @@ -1260,20 +1261,25 @@ const handleInstallationResult = async (data: Archon.Websocket.v0.WSInstallation installError.value = new Error(errorMessage.value) try { - let files = await client.kyros.files_v0.listDirectory('/', 1, 100) - if (files && files.total > 1) { - for (let i = 2; i <= files.total; i++) { - const nextFiles = await client.kyros.files_v0.listDirectory('/', i, 100) - if (nextFiles?.items?.length === 0) break - if (nextFiles) files = nextFiles + if (!worldId.value) break + let files = await client.kyros.files_v1.listDescendants(worldId.value, '/', 1, 100) + for (let i = 2; i <= files.page_total; i++) { + const nextFiles = await client.kyros.files_v1.listDescendants(worldId.value, '/', i, 100) + if (nextFiles.items.length === 0) break + files = { + ...nextFiles, + items: [...files.items, ...nextFiles.items], } } - const fileName = files?.items?.find((file) => + const file = files.items.find((file) => file.name.startsWith('modrinth-installation'), - )?.name - errorLogFile.value = fileName ?? '' - if (fileName) { - const content = await client.kyros.files_v0.downloadFile(fileName) + ) + errorLogFile.value = file?.full_path ?? '' + if (file) { + const content = await client.kyros.files_v1.downloadRawFileContents( + worldId.value, + file.full_path, + ) errorLog.value = await content.text() } } catch (err) { diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/instances/[instance-id]/files.vue b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/instances/[instance-id]/files.vue index ecb4a5c8a9..f5a567404c 100644 --- a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/instances/[instance-id]/files.vue +++ b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/instances/[instance-id]/files.vue @@ -326,9 +326,11 @@ const moveMutation = useMutation({ }) const createMutation = useMutation({ - mutationFn: (_vars: { path: string; type: 'file' | 'directory' }) => { - // await client.kyros.files_v0.createFileOrFolder(path, type) - throw new Error('Creating files or folders is not supported by the v1 world-scoped files API.') + mutationFn: ({ path, type }: { path: string; type: 'file' | 'directory' }) => { + const id = getWorldId() + return type === 'directory' + ? client.kyros.files_v1.mkdirFile(id, path) + : client.kyros.files_v1.touchFile(id, path) }, onError: (err: Error) => { addNotification({ @@ -337,6 +339,9 @@ const createMutation = useMutation({ type: 'error', }) }, + onSettled: () => { + refreshList() + }, }) // File I/O @@ -344,7 +349,7 @@ async function readFile(path: string): Promise { const normalizedPath = path.startsWith('/') ? path : `/${path}` const id = getWorldId() const cachedContent = queryClient.getQueryData(['file-content', id, normalizedPath]) - if (cachedContent) return cachedContent + if (cachedContent != null) return cachedContent const blob = await client.kyros.files_v1.downloadRawFileContents(id, normalizedPath) return await blob.text() } @@ -356,10 +361,11 @@ async function readFileAsBlob(path: string): Promise { async function writeFile(path: string, content: string): Promise { if (fileWriteDisabled.value) return - void path - void content - // await client.kyros.files_v0.updateFile(path, content) - throw new Error('Updating file contents is not supported by the v1 world-scoped files API.') + const normalizedPath = path.startsWith('/') ? path : `/${path}` + const id = getWorldId() + await client.kyros.files_v1.editFile(id, normalizedPath, content) + queryClient.setQueryData(['file-content', id, normalizedPath], content) + refreshList() } async function downloadFile(path: string, fileName: string): Promise { @@ -462,14 +468,14 @@ provideFileManager({ uploadFiles, cancelUpload, uploadState, + worldId, refresh: refreshList, isBusy: fileWriteDisabled, busyTooltip: fileWriteDisabledTooltip, busyWarning, - // extractFile: async (path, override, dry) => client.kyros.files_v0.extractFile(path, override, dry), prefetchDirectory, prefetchFile, - showInstallFromUrl: false, + showInstallFromUrl: true, canRestart: canUsePowerActions.value, restartServer, canShareToMclogs: true, diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/overview.vue b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/overview.vue index a644f2c4e9..ce6af7700c 100644 --- a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/overview.vue +++ b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/overview.vue @@ -58,6 +58,7 @@ const client = injectModrinthClient() const { server: _serverData, serverId, + worldId, isConnected, isWsAuthIncorrect, stats, @@ -83,9 +84,13 @@ const isDismissed = () => Date.now() < dismissedUntil.value const inspectError = async () => { if (isDismissed()) return + if (!worldId.value) return try { - const blob = await client.kyros.files_v0.downloadFile('/logs/latest.log') + const blob = await client.kyros.files_v1.downloadRawFileContents( + worldId.value, + '/logs/latest.log', + ) const log = await blob.text() if (!log) return diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/index.vue b/packages/ui/src/layouts/wrapped/hosting/manage/index.vue index d22ac2dac4..79d95503b8 100644 --- a/packages/ui/src/layouts/wrapped/hosting/manage/index.vue +++ b/packages/ui/src/layouts/wrapped/hosting/manage/index.vue @@ -180,6 +180,7 @@ v-for="server in ownedFilteredData.filter((s) => !s.is_medal)" :key="`owned-${server.server_id}`" v-bind="server" + :world-id="getServerWorldId(server.server_id)" :cancellation-date="serverBillingMap.get(server.server_id)?.cancellationDate" :is-provisioning="serverBillingMap.get(server.server_id)?.isProvisioning" :on-resubscribe="serverBillingMap.get(server.server_id)?.onResubscribe" @@ -211,6 +212,7 @@ v-for="server in sharedFilteredData.filter((s) => !s.is_medal)" :key="`shared-${server.server_id}`" v-bind="server" + :world-id="getServerWorldId(server.server_id)" />
@@ -762,6 +764,12 @@ const { data: serverFullList } = useQuery({ enabled: loggedIn, }) +function getServerWorldId(serverId: string): string | null { + const server = serverFullList.value?.find((server) => server.id === serverId) + const activeWorld = server?.worlds.find((world) => world.is_active) + return activeWorld?.id ?? server?.worlds[0]?.id ?? null +} + type ServerBillingInfo = { cancellationDate?: string | null isProvisioning?: boolean diff --git a/standards/frontend/CROSS_PLATFORM_PAGES.md b/standards/frontend/CROSS_PLATFORM_PAGES.md index 7378e3733a..807458e351 100644 --- a/standards/frontend/CROSS_PLATFORM_PAGES.md +++ b/standards/frontend/CROSS_PLATFORM_PAGES.md @@ -177,15 +177,17 @@ import { injectModrinthClient, injectModrinthServerContext, ServersManageFilesPa import { useQueryClient } from '@tanstack/vue-query' const client = injectModrinthClient() -const { serverId } = injectModrinthServerContext() +const { worldId } = injectModrinthServerContext() const queryClient = useQueryClient() try { - await queryClient.ensureQueryData({ - queryKey: ['files', serverId, '/'], - queryFn: () => client.kyros.files_v0.listDirectory('/', 1, 2000), - staleTime: 30_000, - }) + if (worldId.value) { + await queryClient.ensureQueryData({ + queryKey: ['files', 'v1', worldId.value, '/'], + queryFn: () => client.kyros.files_v1.listDescendants(worldId.value!, '/', 1, 200), + staleTime: 30_000, + }) + } } catch { // Let the mounted layout’s useQuery surface errors; do not fail route setup. }