From 30a4343b83df2e206bdedaa20aecf53b144f8aaf Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 22 Jul 2026 19:08:31 -0400 Subject: [PATCH] =?UTF-8?q?fix(ui):=20kill=20console=20spam=20=E2=80=94=20?= =?UTF-8?q?pine/mempool=20icon=20404s=20+=20filebrowser=20401=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pine/mempool get explicit icon entries (their extensions aren't .png, so the default guess 404'd on every render). filebrowser-client: central authedFetch does ONE transparent re-login when the short-lived JWT expires (an expired cookie previously kept _authenticated=true and every Files-card poll 401'd forever), plus a 60s login cooldown so a missing filebrowser doesn't spam either. Co-Authored-By: Claude Fable 5 --- neode-ui/src/api/filebrowser-client.ts | 83 +++++++++++++------------- neode-ui/src/views/apps/appsConfig.ts | 6 ++ 2 files changed, 49 insertions(+), 40 deletions(-) diff --git a/neode-ui/src/api/filebrowser-client.ts b/neode-ui/src/api/filebrowser-client.ts index 7e743132..42faffb4 100644 --- a/neode-ui/src/api/filebrowser-client.ts +++ b/neode-ui/src/api/filebrowser-client.ts @@ -89,19 +89,43 @@ class FileBrowserClient { return h } + /** Don't hammer app.filebrowser-token after a failed login — one attempt + * per cooldown window, so a broken/missing filebrowser doesn't turn every + * poll of the Files card into a fresh login + 401 pair (console spam). */ + private _lastLoginFailure = 0 + private static readonly LOGIN_RETRY_COOLDOWN_MS = 60_000 + /** Ensure we're authenticated before making a request. Auto-logins if needed. */ private async ensureAuth(): Promise { if (this._authenticated && this.getAuthCookie()) return + if (Date.now() - this._lastLoginFailure < FileBrowserClient.LOGIN_RETRY_COOLDOWN_MS) { + throw new Error('FileBrowser authentication failed — please open Cloud to log in') + } const ok = await this.login() - if (!ok) throw new Error('FileBrowser authentication failed — please open Cloud to log in') + if (!ok) { + this._lastLoginFailure = Date.now() + throw new Error('FileBrowser authentication failed — please open Cloud to log in') + } + } + + /** fetch() with auth headers + ONE transparent re-login on 401. The JWT + * from app.filebrowser-token is short-lived; before this, an expired + * cookie kept `_authenticated` true and every Files-card poll 401'd + * forever (the console-spam bug, 2026-07-22). */ + private async authedFetch(url: string, init?: RequestInit): Promise { + await this.ensureAuth() + let res = await fetch(url, { ...init, headers: { ...(init?.headers as Record | undefined), ...this.headers() } }) + if (res.status === 401) { + this._authenticated = false + await this.ensureAuth() + res = await fetch(url, { ...init, headers: { ...(init?.headers as Record | undefined), ...this.headers() } }) + } + return res } async listDirectory(path: string): Promise { - await this.ensureAuth() const safePath = sanitizePath(path) - const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, { - headers: this.headers(), - }) + const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}`) if (!res.ok) throw new Error(`File Browser is not available (HTTP ${res.status})`) // When File Browser isn't installed, nginx falls through to the SPA and // returns index.html (200, text/html); when it's down it returns 502. @@ -133,11 +157,8 @@ class FileBrowserClient { * For large files (video/audio), prefer streamUrl() instead. */ async fetchBlobUrl(path: string): Promise { - await this.ensureAuth() const safePath = sanitizePath(path) - const res = await fetch(`${this.baseUrl}/api/raw${safePath}`, { - headers: this.headers(), - }) + const res = await this.authedFetch(`${this.baseUrl}/api/raw${safePath}`) if (!res.ok) throw new Error(`Failed to fetch file: ${res.status}`) const blob = await res.blob() return URL.createObjectURL(blob) @@ -171,17 +192,12 @@ class FileBrowserClient { } async upload(dirPath: string, file: File): Promise { - await this.ensureAuth() const sanitized = sanitizePath(dirPath) const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/` const encodedName = encodeURIComponent(file.name) - const res = await fetch( + const res = await this.authedFetch( `${this.baseUrl}/api/resources${safePath}${encodedName}?override=true`, - { - method: 'POST', - headers: this.headers(), - body: file, - }, + { method: 'POST', body: file }, ) if (!res.ok) { const text = await res.text().catch(() => '') @@ -190,35 +206,31 @@ class FileBrowserClient { } async createFolder(parentPath: string, name: string): Promise { - await this.ensureAuth() const sanitized = sanitizePath(parentPath) const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/` const sanitizedName = name.replace(/\.\./g, '').replace(/\//g, '') - const res = await fetch(`${this.baseUrl}/api/resources${safePath}${sanitizedName}/`, { + const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}${sanitizedName}/`, { method: 'POST', - headers: this.headers(), }) if (!res.ok) throw new Error(`Create folder failed: ${res.status}`) } async deleteItem(path: string): Promise { - await this.ensureAuth() const safePath = sanitizePath(path) - const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, { + const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}`, { method: 'DELETE', - headers: this.headers(), }) if (!res.ok) throw new Error(`Delete failed: ${res.status}`) } async getUsage(): Promise<{ totalSize: number; folderCount: number; fileCount: number }> { - if (!this._authenticated || !this.getAuthCookie()) { - const ok = await this.login() - if (!ok) return { totalSize: 0, folderCount: 0, fileCount: 0 } + let res: Response + try { + res = await this.authedFetch(`${this.baseUrl}/api/resources/`) + } catch { + // Not installed / login cooling down — the Files card shows zeros. + return { totalSize: 0, folderCount: 0, fileCount: 0 } } - const res = await fetch(`${this.baseUrl}/api/resources/`, { - headers: this.headers(), - }) if (!res.ok) return { totalSize: 0, folderCount: 0, fileCount: 0 } const data: FileBrowserListResponse = await res.json() const items = data.items || [] @@ -242,17 +254,11 @@ class FileBrowserClient { } async readFileAsText(path: string, maxBytes = 102400): Promise<{ content: string; truncated: boolean; size: number }> { - if (!this._authenticated || !this.getAuthCookie()) { - const ok = await this.login() - if (!ok) throw new Error('FileBrowser authentication failed') - } if (!this.isTextFile(path)) { throw new Error(`Cannot read binary file: ${path}`) } const safePath = sanitizePath(path) - const res = await fetch(`${this.baseUrl}/api/raw${safePath}`, { - headers: this.headers(), - }) + const res = await this.authedFetch(`${this.baseUrl}/api/raw${safePath}`) if (!res.ok) throw new Error(`Failed to read file: ${res.status}`) const blob = await res.blob() const size = blob.size @@ -266,12 +272,9 @@ class FileBrowserClient { const safePath = sanitizePath(oldPath) const dir = safePath.substring(0, safePath.lastIndexOf('/') + 1) const sanitizedName = newName.replace(/\.\./g, '').replace(/\//g, '') - const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, { + const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}`, { method: 'PATCH', - headers: { - ...this.headers(), - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ destination: `${dir}${sanitizedName}` }), }) if (!res.ok) throw new Error(`Rename failed: ${res.status}`) diff --git a/neode-ui/src/views/apps/appsConfig.ts b/neode-ui/src/views/apps/appsConfig.ts index ef56d125..1b484f35 100644 --- a/neode-ui/src/views/apps/appsConfig.ts +++ b/neode-ui/src/views/apps/appsConfig.ts @@ -181,6 +181,12 @@ export function opensInTab(id: string): boolean { // are explicit because icon extensions vary (.png / .webp / .svg). const APP_ICON_FALLBACKS: Record = { gitea: '/assets/img/app-icons/gitea.svg', + // Apps whose icon extension isn't .png: without an explicit entry the + // default `.png` guess 404s on every render (console spam, and for + // mempool the .png→.svg fallback chain 404s TWICE before giving up). + pine: '/assets/img/app-icons/pine.svg', + mempool: '/assets/img/app-icons/mempool.webp', + 'mempool-web': '/assets/img/app-icons/mempool.webp', 'fedimint-gateway': '/assets/img/app-icons/fedimint.png', 'fedimint-clientd': '/assets/img/app-icons/fedimint.png', // immich stack