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:
parent
cad68eb941
commit
30a4343b83
@ -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}`)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user