import type { DirectoryResponse, FilesystemOp, FileUploadQuery, FSQueuedOp, JWTAuth, } from '@modrinth/utils' import { ModrinthServerError } from '@modrinth/utils' import { useServersFetch } from '../servers-fetch.ts' import { ServerModule } from './base.ts' export class FSModule extends ServerModule { auth!: JWTAuth ops: FilesystemOp[] = [] queuedOps: FSQueuedOp[] = [] opsQueuedForModification: string[] = [] async fetch(): Promise { this.auth = await useServersFetch(`servers/${this.serverId}/fs`, {}, 'fs') this.ops = [] this.queuedOps = [] this.opsQueuedForModification = [] } private async retryWithAuth( requestFn: () => Promise, ignoreFailure: boolean = false, ): Promise { try { return await requestFn() } catch (error) { if (error instanceof ModrinthServerError && error.statusCode === 401) { console.debug('Auth failed, refreshing JWT and retrying') await this.fetch() // Refresh auth return await requestFn() } const available = await this.server.testNodeReachability() if (!available && !ignoreFailure) { this.server.moduleErrors.general = { error: new ModrinthServerError( 'Unable to reach node. FS operation failed and subsequent ping test failed.', 500, error as Error, 'fs', ), timestamp: Date.now(), } } throw error } } listDirContents( path: string, page: number, pageSize: number, ignoreFailure: boolean = false, ): Promise { return this.retryWithAuth(async () => { const encodedPath = encodeURIComponent(path) return await useServersFetch(`/list?path=${encodedPath}&page=${page}&page_size=${pageSize}`, { override: this.auth, retry: false, }) }, ignoreFailure) } createFileOrFolder(path: string, type: 'file' | 'directory'): Promise { return this.retryWithAuth(async () => { const encodedPath = encodeURIComponent(path) await useServersFetch(`/create?path=${encodedPath}&type=${type}`, { method: 'POST', contentType: 'application/octet-stream', override: this.auth, }) }) } uploadFile(path: string, file: File): FileUploadQuery { const encodedPath = encodeURIComponent(path) const progressSubject = new EventTarget() const abortController = new AbortController() const uploadPromise = new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) { const progress = (e.loaded / e.total) * 100 progressSubject.dispatchEvent( new CustomEvent('progress', { detail: { loaded: e.loaded, total: e.total, progress }, }), ) } }) xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(xhr.response) } else { reject(new Error(`Upload failed with status ${xhr.status}`)) } } xhr.onerror = () => reject(new Error('Upload failed')) xhr.onabort = () => reject(new Error('Upload cancelled')) xhr.open('POST', `https://${this.auth.url}/create?path=${encodedPath}&type=file`) xhr.setRequestHeader('Authorization', `Bearer ${this.auth.token}`) xhr.setRequestHeader('Content-Type', 'application/octet-stream') xhr.send(file) abortController.signal.addEventListener('abort', () => xhr.abort()) }) return { promise: uploadPromise, onProgress: ( callback: (progress: { loaded: number; total: number; progress: number }) => void, ) => { progressSubject.addEventListener('progress', ((e: CustomEvent) => { callback(e.detail) }) as EventListener) }, cancel: () => abortController.abort(), } as FileUploadQuery } renameFileOrFolder(path: string, name: string): Promise { const pathName = path.split('/').slice(0, -1).join('/') + '/' + name return this.retryWithAuth(async () => { await useServersFetch(`/move`, { method: 'POST', override: this.auth, body: { source: path, destination: pathName }, }) }) } updateFile(path: string, content: string): Promise { const octetStream = new Blob([content], { type: 'application/octet-stream' }) return this.retryWithAuth(async () => { await useServersFetch(`/update?path=${path}`, { method: 'PUT', contentType: 'application/octet-stream', body: octetStream, override: this.auth, }) }) } moveFileOrFolder(path: string, newPath: string): Promise { return this.retryWithAuth(async () => { await this.server.createMissingFolders(newPath.substring(0, newPath.lastIndexOf('/'))) await useServersFetch(`/move`, { method: 'POST', override: this.auth, body: { source: path, destination: newPath }, }) }) } deleteFileOrFolder(path: string, recursive: boolean): Promise { const encodedPath = encodeURIComponent(path) return this.retryWithAuth(async () => { await useServersFetch(`/delete?path=${encodedPath}&recursive=${recursive}`, { method: 'DELETE', override: this.auth, }) }) } downloadFile(path: string, raw: boolean = false, ignoreFailure: boolean = false): Promise { return this.retryWithAuth(async () => { const encodedPath = encodeURIComponent(path) const fileData = await useServersFetch(`/download?path=${encodedPath}`, { override: this.auth, }) if (fileData instanceof Blob) { return raw ? fileData : await fileData.text() } return fileData }, ignoreFailure) } extractFile( path: string, override = true, dry = false, silentQueue = false, ): Promise<{ modpack_name: string | null; conflicting_files: string[] }> { return this.retryWithAuth(async () => { const encodedPath = encodeURIComponent(path) if (!silentQueue) { this.queuedOps.push({ op: 'unarchive', src: path }) setTimeout(() => this.removeQueuedOp('unarchive', path), 4000) } try { return await useServersFetch( `/unarchive?src=${encodedPath}&trg=/&override=${override}&dry=${dry}`, { method: 'POST', override: this.auth, version: 1, }, undefined, 'Error extracting file', ) } catch (err) { this.removeQueuedOp('unarchive', path) throw err } }) } modifyOp(id: string, action: 'dismiss' | 'cancel'): Promise { return this.retryWithAuth(async () => { await useServersFetch( `/ops/${action}?id=${id}`, { method: 'POST', override: this.auth, version: 1, }, undefined, `Error ${action === 'dismiss' ? 'dismissing' : 'cancelling'} filesystem operation`, ) this.opsQueuedForModification = this.opsQueuedForModification.filter((x: string) => x !== id) this.ops = this.ops.filter((x: FilesystemOp) => x.id !== id) }) } removeQueuedOp(op: FSQueuedOp['op'], src: string): void { this.queuedOps = this.queuedOps.filter((x: FSQueuedOp) => x.op !== op || x.src !== src) } clearQueuedOps(): void { this.queuedOps = [] } }