You've already forked AstralRinth
forked from didirus/AstralRinth
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:
149
packages/api-client/src/features/node-auth.ts
Normal file
149
packages/api-client/src/features/node-auth.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user