feat: modrinth hosting - files tab refactor (#4912)

* feat: api-client module for content v0

* feat: delete unused components + modules + setting

* feat: xhr uploading

* feat: fs module -> api-client

* feat: migrate files.vue to use tanstack

* fix: mem leak + other issues

* fix: build

* feat: switch to monaco

* fix: go back to using ace, but improve preloading + theme

* fix: styling + dead attrs

* feat: match figma

* fix: padding

* feat: files-new for ui page structure

* feat: finalize files.vue

* fix: lint

* fix: qa

* fix: dep

* fix: lint

* fix: lockfile merge

* feat: icons on navtab

* fix: surface alternating on table

* fix: hover surface color

---------

Signed-off-by: Calum H. <contact@cal.engineer>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-01-06 00:35:51 +00:00
committed by GitHub
parent 61d4a34f0f
commit 099011a177
89 changed files with 5863 additions and 2091 deletions

View File

@@ -0,0 +1,149 @@
import { AbstractFeature, type FeatureConfig } from '../core/abstract-feature'
import { ModrinthApiError } from '../core/errors'
import type { RequestContext } from '../types/request'
/**
* Node authentication credentials
*/
export interface NodeAuth {
/** Node instance URL (e.g., "node-xyz.modrinth.com/modrinth/v0/fs") */
url: string
/** JWT token */
token: string
}
export interface NodeAuthConfig extends FeatureConfig {
/**
* Get current node auth. Returns null if not authenticated.
*/
getAuth: () => NodeAuth | null
/**
* Refresh the node authentication token.
*/
refreshAuth: () => Promise<void>
}
/**
* Handles authentication for Kyros node fs requests:
* - Automatically injects Authorization header
* - Builds the correct URL from node instance
* - Handles 401 errors by refreshing and retrying (max 3 times)
*
* Only applies to requests with `useNodeAuth: true` in options.
*
* @example
* ```typescript
* const nodeAuth = new NodeAuthFeature({
* getAuth: () => nodeAuthState.getAuth?.() ?? null,
* refreshAuth: async () => {
* if (nodeAuthState.refreshAuth) {
* await nodeAuthState.refreshAuth()
* }
* },
* })
* client.addFeature(nodeAuth)
* ```
*/
export class NodeAuthFeature extends AbstractFeature {
declare protected config: NodeAuthConfig
private refreshPromise: Promise<void> | null = null
shouldApply(context: RequestContext): boolean {
return context.options.useNodeAuth === true && this.config.enabled !== false
}
private async refreshAuthWithLock(): Promise<void> {
if (this.refreshPromise) {
return this.refreshPromise
}
this.refreshPromise = this.config.refreshAuth().finally(() => {
this.refreshPromise = null
})
return this.refreshPromise
}
async execute<T>(next: () => Promise<T>, context: RequestContext): Promise<T> {
const maxRetries = 3
let retryCount = 0
let auth = this.config.getAuth()
if (!auth || this.isTokenExpired(auth.token)) {
await this.refreshAuthWithLock()
auth = this.config.getAuth()
}
if (!auth) {
throw new Error('Failed to obtain node authentication')
}
this.applyAuth(context, auth)
while (true) {
try {
return await next()
} catch (error) {
if (error instanceof ModrinthApiError && error.statusCode === 401) {
retryCount++
if (retryCount >= maxRetries) {
throw new Error(
`Node authentication failed after ${maxRetries} retries. Please re-authenticate.`,
)
}
await this.refreshAuthWithLock()
auth = this.config.getAuth()
if (!auth) {
throw new Error('Failed to refresh node authentication')
}
this.applyAuth(context, auth)
continue
}
throw error
}
}
}
private applyAuth(context: RequestContext, auth: NodeAuth): void {
const baseUrl = `https://${auth.url.replace('v0/fs', '')}`
context.url = this.buildUrl(context.path, baseUrl, context.options.version)
context.options.headers = {
...context.options.headers,
Authorization: `Bearer ${auth.token}`,
}
context.options.skipAuth = true
}
private buildUrl(path: string, baseUrl: string, version: number | 'internal' | string): string {
const base = baseUrl.replace(/\/$/, '')
let versionPath = ''
if (version === 'internal') {
versionPath = '/_internal'
} else if (typeof version === 'number') {
versionPath = `/v${version}`
} else if (typeof version === 'string') {
versionPath = `/${version}`
}
const cleanPath = path.startsWith('/') ? path : `/${path}`
return `${base}${versionPath}${cleanPath}`
}
/**
* Check if a JWT token is expired or about to expire
* Refreshes proactively if expiring within next 10 seconds
*/
private isTokenExpired(token: string): boolean {
try {
const payload = JSON.parse(atob(token.split('.')[1]))
if (!payload.exp) return false
// refresh if expiring within 10 seconds
const expiresAt = payload.exp * 1000
return Date.now() >= expiresAt - 10000
} catch {
// cant decode, assume valid and let server decide
return false
}
}
}