fix(ui): kill console spam — pine/mempool icon 404s + filebrowser 401 loop

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 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-22 19:08:31 -04:00
parent cad68eb941
commit 30a4343b83
2 changed files with 49 additions and 40 deletions

View File

@ -89,19 +89,43 @@ class FileBrowserClient {
return h 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. */ /** Ensure we're authenticated before making a request. Auto-logins if needed. */
private async ensureAuth(): Promise<void> { private async ensureAuth(): Promise<void> {
if (this._authenticated && this.getAuthCookie()) return 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() 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<Response> {
await this.ensureAuth()
let res = await fetch(url, { ...init, headers: { ...(init?.headers as Record<string, string> | undefined), ...this.headers() } })
if (res.status === 401) {
this._authenticated = false
await this.ensureAuth()
res = await fetch(url, { ...init, headers: { ...(init?.headers as Record<string, string> | undefined), ...this.headers() } })
}
return res
} }
async listDirectory(path: string): Promise<FileBrowserItem[]> { async listDirectory(path: string): Promise<FileBrowserItem[]> {
await this.ensureAuth()
const safePath = sanitizePath(path) const safePath = sanitizePath(path)
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, { const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}`)
headers: this.headers(),
})
if (!res.ok) throw new Error(`File Browser is not available (HTTP ${res.status})`) 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 // 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. // 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. * For large files (video/audio), prefer streamUrl() instead.
*/ */
async fetchBlobUrl(path: string): Promise<string> { async fetchBlobUrl(path: string): Promise<string> {
await this.ensureAuth()
const safePath = sanitizePath(path) const safePath = sanitizePath(path)
const res = await fetch(`${this.baseUrl}/api/raw${safePath}`, { const res = await this.authedFetch(`${this.baseUrl}/api/raw${safePath}`)
headers: this.headers(),
})
if (!res.ok) throw new Error(`Failed to fetch file: ${res.status}`) if (!res.ok) throw new Error(`Failed to fetch file: ${res.status}`)
const blob = await res.blob() const blob = await res.blob()
return URL.createObjectURL(blob) return URL.createObjectURL(blob)
@ -171,17 +192,12 @@ class FileBrowserClient {
} }
async upload(dirPath: string, file: File): Promise<void> { async upload(dirPath: string, file: File): Promise<void> {
await this.ensureAuth()
const sanitized = sanitizePath(dirPath) const sanitized = sanitizePath(dirPath)
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/` const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
const encodedName = encodeURIComponent(file.name) const encodedName = encodeURIComponent(file.name)
const res = await fetch( const res = await this.authedFetch(
`${this.baseUrl}/api/resources${safePath}${encodedName}?override=true`, `${this.baseUrl}/api/resources${safePath}${encodedName}?override=true`,
{ { method: 'POST', body: file },
method: 'POST',
headers: this.headers(),
body: file,
},
) )
if (!res.ok) { if (!res.ok) {
const text = await res.text().catch(() => '') const text = await res.text().catch(() => '')
@ -190,35 +206,31 @@ class FileBrowserClient {
} }
async createFolder(parentPath: string, name: string): Promise<void> { async createFolder(parentPath: string, name: string): Promise<void> {
await this.ensureAuth()
const sanitized = sanitizePath(parentPath) const sanitized = sanitizePath(parentPath)
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/` const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
const sanitizedName = name.replace(/\.\./g, '').replace(/\//g, '') 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', method: 'POST',
headers: this.headers(),
}) })
if (!res.ok) throw new Error(`Create folder failed: ${res.status}`) if (!res.ok) throw new Error(`Create folder failed: ${res.status}`)
} }
async deleteItem(path: string): Promise<void> { async deleteItem(path: string): Promise<void> {
await this.ensureAuth()
const safePath = sanitizePath(path) 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', method: 'DELETE',
headers: this.headers(),
}) })
if (!res.ok) throw new Error(`Delete failed: ${res.status}`) if (!res.ok) throw new Error(`Delete failed: ${res.status}`)
} }
async getUsage(): Promise<{ totalSize: number; folderCount: number; fileCount: number }> { async getUsage(): Promise<{ totalSize: number; folderCount: number; fileCount: number }> {
if (!this._authenticated || !this.getAuthCookie()) { let res: Response
const ok = await this.login() try {
if (!ok) return { totalSize: 0, folderCount: 0, fileCount: 0 } 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 } if (!res.ok) return { totalSize: 0, folderCount: 0, fileCount: 0 }
const data: FileBrowserListResponse = await res.json() const data: FileBrowserListResponse = await res.json()
const items = data.items || [] const items = data.items || []
@ -242,17 +254,11 @@ class FileBrowserClient {
} }
async readFileAsText(path: string, maxBytes = 102400): Promise<{ content: string; truncated: boolean; size: number }> { 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)) { if (!this.isTextFile(path)) {
throw new Error(`Cannot read binary file: ${path}`) throw new Error(`Cannot read binary file: ${path}`)
} }
const safePath = sanitizePath(path) const safePath = sanitizePath(path)
const res = await fetch(`${this.baseUrl}/api/raw${safePath}`, { const res = await this.authedFetch(`${this.baseUrl}/api/raw${safePath}`)
headers: this.headers(),
})
if (!res.ok) throw new Error(`Failed to read file: ${res.status}`) if (!res.ok) throw new Error(`Failed to read file: ${res.status}`)
const blob = await res.blob() const blob = await res.blob()
const size = blob.size const size = blob.size
@ -266,12 +272,9 @@ class FileBrowserClient {
const safePath = sanitizePath(oldPath) const safePath = sanitizePath(oldPath)
const dir = safePath.substring(0, safePath.lastIndexOf('/') + 1) const dir = safePath.substring(0, safePath.lastIndexOf('/') + 1)
const sanitizedName = newName.replace(/\.\./g, '').replace(/\//g, '') 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', method: 'PATCH',
headers: { headers: { 'Content-Type': 'application/json' },
...this.headers(),
'Content-Type': 'application/json',
},
body: JSON.stringify({ destination: `${dir}${sanitizedName}` }), body: JSON.stringify({ destination: `${dir}${sanitizedName}` }),
}) })
if (!res.ok) throw new Error(`Rename failed: ${res.status}`) if (!res.ok) throw new Error(`Rename failed: ${res.status}`)

View File

@ -181,6 +181,12 @@ export function opensInTab(id: string): boolean {
// are explicit because icon extensions vary (.png / .webp / .svg). // are explicit because icon extensions vary (.png / .webp / .svg).
const APP_ICON_FALLBACKS: Record<string, string> = { const APP_ICON_FALLBACKS: Record<string, string> = {
gitea: '/assets/img/app-icons/gitea.svg', gitea: '/assets/img/app-icons/gitea.svg',
// Apps whose icon extension isn't .png: without an explicit entry the
// default `<id>.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-gateway': '/assets/img/app-icons/fedimint.png',
'fedimint-clientd': '/assets/img/app-icons/fedimint.png', 'fedimint-clientd': '/assets/img/app-icons/fedimint.png',
// immich stack // immich stack