diff --git a/.dockerignore b/.dockerignore new file mode 120000 index 000000000..3e4e48b0b --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +.gitignore \ No newline at end of file diff --git a/apps/app-frontend/src/components/ui/JavaSelector.vue b/apps/app-frontend/src/components/ui/JavaSelector.vue index 9beea538a..ebbeed89c 100644 --- a/apps/app-frontend/src/components/ui/JavaSelector.vue +++ b/apps/app-frontend/src/components/ui/JavaSelector.vue @@ -108,7 +108,6 @@ async function testJava() { testingJava.value = true testingJavaSuccess.value = await test_jre( props.modelValue ? props.modelValue.path : '', - 1, props.version, ) testingJava.value = false diff --git a/apps/app-frontend/src/helpers/jre.js b/apps/app-frontend/src/helpers/jre.js index 0814e9b0a..207c02583 100644 --- a/apps/app-frontend/src/helpers/jre.js +++ b/apps/app-frontend/src/helpers/jre.js @@ -36,8 +36,8 @@ export async function get_jre(path) { // Tests JRE version by running 'java -version' on it. // Returns true if the version is valid, and matches given (after extraction) -export async function test_jre(path, majorVersion, minorVersion) { - return await invoke('plugin:jre|jre_test_jre', { path, majorVersion, minorVersion }) +export async function test_jre(path, majorVersion) { + return await invoke('plugin:jre|jre_test_jre', { path, majorVersion }) } // Automatically installs specified java version diff --git a/apps/daedalus_client/Dockerfile b/apps/daedalus_client/Dockerfile index d33fc113a..9ea70f9ca 100644 --- a/apps/daedalus_client/Dockerfile +++ b/apps/daedalus_client/Dockerfile @@ -1,5 +1,4 @@ FROM rust:1.88.0 AS build -ENV PKG_CONFIG_ALLOW_CROSS=1 WORKDIR /usr/src/daedalus COPY . . @@ -10,11 +9,8 @@ FROM debian:bookworm-slim RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates openssl \ - && apt-get clean \ && rm -rf /var/lib/apt/lists/* -RUN update-ca-certificates - COPY --from=build /usr/src/daedalus/target/release/daedalus_client /daedalus/daedalus_client WORKDIR /daedalus_client diff --git a/apps/frontend/src/composables/servers/modrinth-servers.ts b/apps/frontend/src/composables/servers/modrinth-servers.ts index e91bb8530..8d07648d5 100644 --- a/apps/frontend/src/composables/servers/modrinth-servers.ts +++ b/apps/frontend/src/composables/servers/modrinth-servers.ts @@ -102,7 +102,7 @@ export class ModrinthServer { try { const fileData = await useServersFetch(`/download?path=/server-icon-original.png`, { override: auth, - retry: false, + retry: 1, // Reduce retries for optional resources }); if (fileData instanceof Blob && import.meta.client) { @@ -124,8 +124,14 @@ export class ModrinthServer { return dataURL; } } catch (error) { - if (error instanceof ModrinthServerError && error.statusCode === 404) { - if (iconUrl) { + if (error instanceof ModrinthServerError) { + if (error.statusCode && error.statusCode >= 500) { + console.debug("Service unavailable, skipping icon processing"); + sharedImage.value = undefined; + return undefined; + } + + if (error.statusCode === 404 && iconUrl) { try { const response = await fetch(iconUrl); if (!response.ok) throw new Error("Failed to fetch icon"); @@ -187,6 +193,45 @@ export class ModrinthServer { return undefined; } + async testNodeReachability(): Promise { + if (!this.general?.datacenter) { + console.warn("No datacenter info available for ping test"); + return false; + } + + const datacenter = this.general.datacenter; + const wsUrl = `wss://${datacenter}.nodes.modrinth.com/pingtest`; + + try { + return await new Promise((resolve) => { + const socket = new WebSocket(wsUrl); + const timeout = setTimeout(() => { + socket.close(); + resolve(false); + }, 5000); + + socket.onopen = () => { + clearTimeout(timeout); + socket.send(performance.now().toString()); + }; + + socket.onmessage = () => { + clearTimeout(timeout); + socket.close(); + resolve(true); + }; + + socket.onerror = () => { + clearTimeout(timeout); + resolve(false); + }; + }); + } catch (error) { + console.error(`Failed to ping node ${wsUrl}:`, error); + return false; + } + } + async refresh( modules: ModuleName[] = [], options?: { @@ -200,6 +245,8 @@ export class ModrinthServer { : (["general", "content", "backups", "network", "startup", "ws", "fs"] as ModuleName[]); for (const module of modulesToRefresh) { + this.errors[module] = undefined; + try { switch (module) { case "general": { @@ -250,7 +297,7 @@ export class ModrinthServer { continue; } - if (error.statusCode === 503) { + if (error.statusCode && error.statusCode >= 500) { console.debug(`Temporary ${module} unavailable:`, error.message); continue; } diff --git a/apps/frontend/src/composables/servers/modules/fs.ts b/apps/frontend/src/composables/servers/modules/fs.ts index 1072789e0..39fe75db6 100644 --- a/apps/frontend/src/composables/servers/modules/fs.ts +++ b/apps/frontend/src/composables/servers/modules/fs.ts @@ -22,26 +22,49 @@ export class FSModule extends ServerModule { this.opsQueuedForModification = []; } - private async retryWithAuth(requestFn: () => Promise): Promise { + 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): Promise { + 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 { @@ -150,7 +173,7 @@ export class FSModule extends ServerModule { }); } - downloadFile(path: string, raw?: boolean): Promise { + 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}`, { @@ -161,7 +184,7 @@ export class FSModule extends ServerModule { return raw ? fileData : await fileData.text(); } return fileData; - }); + }, ignoreFailure); } extractFile( diff --git a/apps/frontend/src/composables/servers/modules/general.ts b/apps/frontend/src/composables/servers/modules/general.ts index b2f100658..e46e62b4e 100644 --- a/apps/frontend/src/composables/servers/modules/general.ts +++ b/apps/frontend/src/composables/servers/modules/general.ts @@ -46,13 +46,18 @@ export class GeneralModule extends ServerModule implements ServerGeneral { data.image = (await this.server.processImage(data.project?.icon_url)) ?? undefined; } - const motd = await this.getMotd(); - if (motd === "A Minecraft Server") { - await this.setMotd( - `§b${data.project?.title || data.loader + " " + data.mc_version} §f♦ §aModrinth Servers`, - ); + try { + const motd = await this.getMotd(); + if (motd === "A Minecraft Server") { + await this.setMotd( + `§b${data.project?.title || data.loader + " " + data.mc_version} §f♦ §aModrinth Servers`, + ); + } + data.motd = motd; + } catch { + console.error("[Modrinth Servers] [General] Failed to fetch MOTD."); + data.motd = undefined; } - data.motd = motd; // Copy data to this module Object.assign(this, data); @@ -178,7 +183,7 @@ export class GeneralModule extends ServerModule implements ServerGeneral { async getMotd(): Promise { try { - const props = await this.server.fs.downloadFile("/server.properties"); + const props = await this.server.fs.downloadFile("/server.properties", false, true); if (props) { const lines = props.split("\n"); for (const line of lines) { diff --git a/apps/frontend/src/composables/servers/servers-fetch.ts b/apps/frontend/src/composables/servers/servers-fetch.ts index 137baea5a..5b5d925b1 100644 --- a/apps/frontend/src/composables/servers/servers-fetch.ts +++ b/apps/frontend/src/composables/servers/servers-fetch.ts @@ -42,6 +42,23 @@ export async function useServersFetch( retry = method === "GET" ? 3 : 0, } = options; + const circuitBreakerKey = `${module || "default"}_${path}`; + const failureCount = useState(`fetch_failures_${circuitBreakerKey}`, () => 0); + const lastFailureTime = useState(`last_failure_${circuitBreakerKey}`, () => 0); + + const now = Date.now(); + if (failureCount.value >= 3 && now - lastFailureTime.value < 30000) { + const error = new ModrinthServersFetchError( + "[Modrinth Servers] Circuit breaker open - too many recent failures", + 503, + ); + throw new ModrinthServerError("Service temporarily unavailable", 503, error, module); + } + + if (now - lastFailureTime.value > 30000) { + failureCount.value = 0; + } + const base = (import.meta.server ? config.pyroBaseUrl : config.public.pyroBaseUrl)?.replace( /\/$/, "", @@ -69,6 +86,7 @@ export async function useServersFetch( const headers: Record = { "User-Agent": "Modrinth/1.0 (https://modrinth.com)", + "X-Archon-Request": "true", Vary: "Accept, Origin", }; @@ -98,6 +116,7 @@ export async function useServersFetch( timeout: 10000, }); + failureCount.value = 0; return response; } catch (error) { lastError = error as Error; @@ -107,6 +126,11 @@ export async function useServersFetch( const statusCode = error.response?.status; const statusText = error.response?.statusText || "Unknown error"; + if (statusCode && statusCode >= 500) { + failureCount.value++; + lastFailureTime.value = now; + } + let v1Error: V1ErrorInfo | undefined; if (error.data?.error && error.data?.description) { v1Error = { @@ -134,9 +158,11 @@ export async function useServersFetch( ? errorMessages[statusCode] : `HTTP Error: ${statusCode || "unknown"} ${statusText}`; - const isRetryable = statusCode ? [408, 429, 500, 502, 504].includes(statusCode) : true; + const isRetryable = statusCode ? [408, 429].includes(statusCode) : false; + const is5xxRetryable = + statusCode && statusCode >= 500 && statusCode < 600 && method === "GET" && attempts === 1; - if (!isRetryable || attempts >= maxAttempts) { + if (!(isRetryable || is5xxRetryable) || attempts >= maxAttempts) { console.error("Fetch error:", error); const fetchError = new ModrinthServersFetchError( @@ -147,7 +173,8 @@ export async function useServersFetch( throw new ModrinthServerError(error.message, statusCode, fetchError, module, v1Error); } - const delay = Math.min(1000 * Math.pow(2, attempts - 1) + Math.random() * 1000, 10000); + const baseDelay = statusCode && statusCode >= 500 ? 5000 : 1000; + const delay = Math.min(baseDelay * Math.pow(2, attempts - 1) + Math.random() * 1000, 15000); console.warn(`Retrying request in ${delay}ms (attempt ${attempts}/${maxAttempts - 1})`); await new Promise((resolve) => setTimeout(resolve, delay)); continue; diff --git a/apps/frontend/src/pages/news/article/[slug].vue b/apps/frontend/src/pages/news/article/[slug].vue index fda42fae1..c2e35cdf6 100644 --- a/apps/frontend/src/pages/news/article/[slug].vue +++ b/apps/frontend/src/pages/news/article/[slug].vue @@ -1,9 +1,10 @@